Compare commits

...

1 Commits

Author SHA1 Message Date
bymyself
4df5ec176e perf: progressive node rendering during subgraph transitions
Break the synchronous mount/unmount cycle that blocks the main thread
for ~1.4s when entering/exiting subgraphs.

Introduce useProgressiveNodeRendering composable that renders nodes in
RAF-driven batches with a per-frame time budget (~6ms), yielding to
the browser between batches. Small graphs (≤40 nodes) render
immediately to avoid unnecessary overhead.

- Initial batch of 24 nodes renders on first frame for quick first paint
- Subsequent batches of 40 nodes render per animation frame
- Cancellation token pattern prevents stale renders on rapid navigation
- Link rendering suppressed via pendingSlotSync until all nodes mount
2026-03-24 16:33:08 -07:00
3 changed files with 215 additions and 6 deletions

View File

@@ -71,9 +71,9 @@
@pointerup.capture="forwardPanEvent"
@pointermove.capture="forwardPanEvent"
>
<!-- Vue nodes rendered based on graph nodes -->
<!-- Vue nodes rendered progressively to avoid long-task UI freezes -->
<LGraphNode
v-for="nodeData in allNodes"
v-for="nodeData in visibleNodes"
:key="nodeData.id"
:node-data="nodeData"
:error="
@@ -145,6 +145,7 @@ import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { installErrorClearingHooks } from '@/composables/graph/useErrorClearingHooks'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useProgressiveNodeRendering } from '@/composables/graph/useProgressiveNodeRendering'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { useNodeBadge } from '@/composables/node/useNodeBadge'
import { useCanvasDrop } from '@/composables/useCanvasDrop'
@@ -256,11 +257,23 @@ watch(
}
)
const allNodes = computed((): VueNodeData[] =>
Array.from(vueNodeLifecycle.nodeManager.value?.vueNodeData?.values() ?? [])
)
const {
visibleNodes,
start: startProgressiveRender,
reset: resetProgressiveRender
} = useProgressiveNodeRendering(allNodes)
const handleVueNodeLifecycleReset = async () => {
if (shouldRenderVueNodes.value) {
resetProgressiveRender()
vueNodeLifecycle.disposeNodeManagerAndSyncs()
await nextTick()
vueNodeLifecycle.initializeNodeManager()
startProgressiveRender()
}
}
@@ -276,10 +289,6 @@ watch(
}
)
const allNodes = computed((): VueNodeData[] =>
Array.from(vueNodeLifecycle.nodeManager.value?.vueNodeData?.values() ?? [])
)
function onLinkOverlayReady(el: HTMLCanvasElement) {
if (!canvasStore.canvas) return
canvasStore.canvas.overlayCanvas = el

View File

@@ -0,0 +1,109 @@
import { nextTick, ref } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useProgressiveNodeRendering } from '@/composables/graph/useProgressiveNodeRendering'
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
layoutStore: {
setPendingSlotSync: vi.fn()
}
}))
function makeNodes(count: number): VueNodeData[] {
return Array.from({ length: count }, (_, i) => ({
id: String(i)
})) as VueNodeData[]
}
describe('useProgressiveNodeRendering', () => {
let rafCallbacks: Array<() => void>
let originalRAF: typeof globalThis.requestAnimationFrame
let originalCancel: typeof globalThis.cancelAnimationFrame
beforeEach(() => {
rafCallbacks = []
originalRAF = globalThis.requestAnimationFrame
originalCancel = globalThis.cancelAnimationFrame
globalThis.requestAnimationFrame = vi.fn((cb: FrameRequestCallback) => {
const id = rafCallbacks.length + 1
rafCallbacks.push(() => cb(performance.now()))
return id
})
globalThis.cancelAnimationFrame = vi.fn()
})
afterEach(() => {
globalThis.requestAnimationFrame = originalRAF
globalThis.cancelAnimationFrame = originalCancel
})
it('renders all nodes immediately for small graphs', () => {
const allNodes = ref(makeNodes(10))
const { visibleNodes, start } = useProgressiveNodeRendering(allNodes)
start()
expect(visibleNodes.value).toHaveLength(10)
expect(requestAnimationFrame).not.toHaveBeenCalled()
})
it('renders initial batch then progressively adds more', () => {
const allNodes = ref(makeNodes(100))
const { visibleNodes, start } = useProgressiveNodeRendering(allNodes)
start()
expect(visibleNodes.value.length).toBeLessThan(100)
expect(visibleNodes.value.length).toBeGreaterThan(0)
expect(requestAnimationFrame).toHaveBeenCalled()
})
it('renders all nodes after enough RAF frames', () => {
const allNodes = ref(makeNodes(100))
const { visibleNodes, start } = useProgressiveNodeRendering(allNodes)
start()
while (rafCallbacks.length > 0) {
const cb = rafCallbacks.shift()!
cb()
}
expect(visibleNodes.value).toHaveLength(100)
})
it('cancels in-flight rendering on reset', () => {
const allNodes = ref(makeNodes(100))
const { visibleNodes, start, reset } = useProgressiveNodeRendering(allNodes)
start()
expect(visibleNodes.value.length).toBeGreaterThan(0)
reset()
expect(visibleNodes.value).toHaveLength(0)
})
it('handles empty node list', () => {
const allNodes = ref(makeNodes(0))
const { visibleNodes, start } = useProgressiveNodeRendering(allNodes)
start()
expect(visibleNodes.value).toHaveLength(0)
})
it('tracks allNodes changes when not progressively rendering', async () => {
const allNodes = ref(makeNodes(5))
const { visibleNodes, start } = useProgressiveNodeRendering(allNodes)
start()
expect(visibleNodes.value).toHaveLength(5)
allNodes.value = makeNodes(8)
await nextTick()
expect(visibleNodes.value).toHaveLength(8)
})
})

View File

@@ -0,0 +1,91 @@
import { computed, onUnmounted, ref, watch } from 'vue'
import type { Ref } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
const PROGRESSIVE_THRESHOLD = 40
const INITIAL_BATCH_SIZE = 24
const BATCH_SIZE = 40
const FRAME_BUDGET_MS = 6
/**
* Progressively renders nodes in batches across animation frames
* to avoid blocking the main thread during subgraph transitions.
*
* For small graphs (≤ threshold), all nodes render immediately.
* For larger graphs, an initial batch renders first, then remaining
* nodes are added in RAF-driven batches with a per-frame time budget.
*/
export function useProgressiveNodeRendering(allNodes: Ref<VueNodeData[]>) {
const renderedCount = ref(0)
let renderToken = 0
let rafId: number | null = null
const visibleNodes = computed(() =>
allNodes.value.slice(0, renderedCount.value)
)
function cancel() {
renderToken++
if (rafId != null) {
cancelAnimationFrame(rafId)
rafId = null
}
}
function reset() {
cancel()
renderedCount.value = 0
}
function start() {
cancel()
const total = allNodes.value.length
if (total === 0) {
renderedCount.value = 0
return
}
if (total <= PROGRESSIVE_THRESHOLD) {
renderedCount.value = total
return
}
layoutStore.setPendingSlotSync(true)
const token = ++renderToken
renderedCount.value = Math.min(INITIAL_BATCH_SIZE, total)
const pump = () => {
if (token !== renderToken) return
const currentTotal = allNodes.value.length
const start = performance.now()
let next = renderedCount.value
while (next < currentTotal && performance.now() - start < FRAME_BUDGET_MS)
next += BATCH_SIZE
renderedCount.value = Math.min(next, currentTotal)
if (renderedCount.value < currentTotal) {
rafId = requestAnimationFrame(pump)
} else {
rafId = null
}
}
rafId = requestAnimationFrame(pump)
}
watch(allNodes, (nodes) => {
if (rafId == null) {
renderedCount.value = nodes.length
}
})
onUnmounted(cancel)
return { visibleNodes, start, reset, cancel }
}