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
5 changed files with 248 additions and 277 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

@@ -3,11 +3,7 @@ import { createTestingPinia } from '@pinia/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, nextTick, watch } from 'vue'
import {
extractVueNodeData,
uninstrumentNode,
useGraphNodeManager
} from '@/composables/graph/useGraphNodeManager'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import { createPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
import { BaseWidget, LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
@@ -811,81 +807,3 @@ describe('reconcileNodeErrorFlags (via lastNodeErrors watcher)', () => {
expect(subgraphNode.has_errors).toBe(true)
})
})
describe('extractVueNodeData idempotency and cleanup', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('reuses reactive containers when called multiple times on the same node', () => {
const node = new LGraphNode('test')
node.addWidget('number', 'val', 1, () => undefined, {})
const data1 = extractVueNodeData(node)
const data2 = extractVueNodeData(node)
// The reactive inputs/outputs arrays should be the same references
expect(data1.inputs).toBe(data2.inputs)
expect(data1.outputs).toBe(data2.outputs)
})
it('does not chain property descriptors on repeated calls', () => {
const node = new LGraphNode('test')
node.addWidget('number', 'val', 1, () => undefined, {})
// Save the descriptor after first instrumentation
extractVueNodeData(node)
const desc1 = Object.getOwnPropertyDescriptor(node, 'widgets')
// Second call should not create a new getter wrapping the old one
extractVueNodeData(node)
const desc2 = Object.getOwnPropertyDescriptor(node, 'widgets')
expect(desc1!.get).toBe(desc2!.get)
})
it('restores original property descriptors on uninstrument', () => {
const node = new LGraphNode('test')
node.addWidget('number', 'v', 0, () => {}, {})
// Capture original descriptor
const originalDesc = Object.getOwnPropertyDescriptor(node, 'widgets')
extractVueNodeData(node)
// Property is now a getter/setter
const instrumentedDesc = Object.getOwnPropertyDescriptor(node, 'widgets')
expect(instrumentedDesc!.get).toBeDefined()
// Uninstrument should restore
uninstrumentNode(node)
const restoredDesc = Object.getOwnPropertyDescriptor(node, 'widgets')
if (originalDesc) {
expect(restoredDesc).toEqual(originalDesc)
}
})
it('cleanup stops effect scopes for all nodes', () => {
const graph = new LGraph()
const node1 = new LGraphNode('test1')
const node2 = new LGraphNode('test2')
graph.add(node1)
graph.add(node2)
const { vueNodeData, cleanup } = useGraphNodeManager(graph)
expect(vueNodeData.size).toBe(2)
cleanup()
// After cleanup, descriptors should be restored (no getter)
const desc1 = Object.getOwnPropertyDescriptor(node1, 'inputs')
const desc2 = Object.getOwnPropertyDescriptor(node2, 'inputs')
// Original nodes don't have own 'inputs' descriptors (they use prototype)
// So after cleanup, the own descriptor should be removed
expect(desc1?.get).toBeUndefined()
expect(desc2?.get).toBeUndefined()
})
})

View File

@@ -3,8 +3,7 @@
* Provides event-driven reactivity with performance optimizations
*/
import { reactiveComputed } from '@vueuse/core'
import { effectScope, reactive, shallowReactive } from 'vue'
import type { EffectScope } from 'vue'
import { reactive, shallowReactive } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
@@ -390,86 +389,25 @@ function buildSlotMetadata(
return metadata
}
/**
* Tracks reactive instrumentation applied to a LiteGraph node.
* Stored in a WeakMap so entries are GC'd when the node is collected.
*/
interface NodeInstrumentation {
reactiveWidgets: IBaseWidget[]
reactiveInputs: INodeInputSlot[]
reactiveOutputs: INodeOutputSlot[]
originalWidgetsDescriptor: PropertyDescriptor | undefined
originalInputsDescriptor: PropertyDescriptor | undefined
originalOutputsDescriptor: PropertyDescriptor | undefined
scope: EffectScope
}
// Extract safe data from LiteGraph node for Vue consumption
export function extractVueNodeData(node: LGraphNode): VueNodeData {
// Determine subgraph ID - null for root graph, string for subgraphs
const subgraphId =
node.graph && 'id' in node.graph && node.graph !== node.graph.rootGraph
? String(node.graph.id)
: null
// Extract safe widget data
const slotMetadata = new Map<string, WidgetSlotMetadata>()
const instrumentedNodes = new WeakMap<LGraphNode, NodeInstrumentation>()
/**
* Restores original property descriptors on a node and stops its
* reactive effect scope. Called during cleanup to prevent leaked
* Vue reactivity objects (Link, Dep, ComputedRefImpl).
*/
export function uninstrumentNode(node: LGraphNode): void {
const inst = instrumentedNodes.get(node)
if (!inst) return
inst.scope.stop()
// Restore original property descriptors
restoreDescriptor(node, 'widgets', inst.originalWidgetsDescriptor)
restoreDescriptor(node, 'inputs', inst.originalInputsDescriptor)
restoreDescriptor(node, 'outputs', inst.originalOutputsDescriptor)
instrumentedNodes.delete(node)
}
function restoreDescriptor(
node: LGraphNode,
prop: string,
descriptor: PropertyDescriptor | undefined
) {
if (descriptor) {
Object.defineProperty(node, prop, descriptor)
} else {
// Property was a plain value before — delete the accessor to expose
// the prototype's default (or leave as undefined).
delete (node as unknown as Record<string, unknown>)[prop]
}
}
/**
* Instruments a LiteGraph node's widgets/inputs/outputs with Vue reactive
* wrappers so that Vue components receive reactive data.
*
* **Idempotent**: if the node is already instrumented the existing reactive
* containers are reused and their contents are synced. This prevents the
* memory leak that occurred when repeated calls created new shallowReactive
* arrays, new reactiveComputed effects, and chained property descriptors
* without ever stopping the old effects.
*/
function instrumentNode(node: LGraphNode): NodeInstrumentation {
const existing = instrumentedNodes.get(node)
if (existing) return existing
// Capture original descriptors BEFORE we overwrite them
const originalWidgetsDescriptor = Object.getOwnPropertyDescriptor(
const existingWidgetsDescriptor = Object.getOwnPropertyDescriptor(
node,
'widgets'
)
const originalInputsDescriptor = Object.getOwnPropertyDescriptor(
node,
'inputs'
)
const originalOutputsDescriptor = Object.getOwnPropertyDescriptor(
node,
'outputs'
)
const reactiveWidgets = shallowReactive<IBaseWidget[]>(node.widgets ?? [])
if (originalWidgetsDescriptor?.get) {
const originalGetter = originalWidgetsDescriptor.get
if (existingWidgetsDescriptor?.get) {
// Node has a custom widgets getter (e.g. SubgraphNode's synthetic getter).
// Preserve it but sync results into a reactive array for Vue.
const originalGetter = existingWidgetsDescriptor.get
Object.defineProperty(node, 'widgets', {
get() {
const current: IBaseWidget[] = originalGetter.call(node) ?? []
@@ -481,7 +419,7 @@ function instrumentNode(node: LGraphNode): NodeInstrumentation {
}
return reactiveWidgets
},
set: originalWidgetsDescriptor.set ?? (() => {}),
set: existingWidgetsDescriptor.set ?? (() => {}),
configurable: true,
enumerable: true
})
@@ -497,7 +435,6 @@ function instrumentNode(node: LGraphNode): NodeInstrumentation {
enumerable: true
})
}
const reactiveInputs = shallowReactive<INodeInputSlot[]>(node.inputs ?? [])
Object.defineProperty(node, 'inputs', {
get() {
@@ -509,7 +446,6 @@ function instrumentNode(node: LGraphNode): NodeInstrumentation {
configurable: true,
enumerable: true
})
const reactiveOutputs = shallowReactive<INodeOutputSlot[]>(node.outputs ?? [])
Object.defineProperty(node, 'outputs', {
get() {
@@ -522,60 +458,15 @@ function instrumentNode(node: LGraphNode): NodeInstrumentation {
enumerable: true
})
const scope = effectScope()
const safeWidgets = reactiveComputed<SafeWidgetData[]>(() => {
const widgetsSnapshot = node.widgets ?? []
const inst: NodeInstrumentation = {
reactiveWidgets,
reactiveInputs,
reactiveOutputs,
originalWidgetsDescriptor,
originalInputsDescriptor,
originalOutputsDescriptor,
scope
}
instrumentedNodes.set(node, inst)
return inst
}
// Extract safe data from LiteGraph node for Vue consumption
export function extractVueNodeData(node: LGraphNode): VueNodeData {
// Determine subgraph ID - null for root graph, string for subgraphs
const subgraphId =
node.graph && 'id' in node.graph && node.graph !== node.graph.rootGraph
? String(node.graph.id)
: null
const inst = instrumentNode(node)
const { reactiveInputs, reactiveOutputs } = inst
// Sync reactive arrays with current node state (idempotent)
const currentWidgets = node.widgets ?? []
if (
currentWidgets.length !== inst.reactiveWidgets.length ||
currentWidgets.some((w, i) => w !== inst.reactiveWidgets[i])
) {
inst.reactiveWidgets.splice(
0,
inst.reactiveWidgets.length,
...currentWidgets
)
}
const slotMetadata = new Map<string, WidgetSlotMetadata>()
// Create the reactiveComputed inside the node's effect scope so it
// is stopped when the node is uninstrumented.
let safeWidgets!: SafeWidgetData[]
inst.scope.run(() => {
safeWidgets = reactiveComputed<SafeWidgetData[]>(() => {
const widgetsSnapshot = node.widgets ?? []
const freshMetadata = buildSlotMetadata(node.inputs, node.graph)
slotMetadata.clear()
for (const [key, value] of freshMetadata) {
slotMetadata.set(key, value)
}
return widgetsSnapshot.map(safeWidgetMapper(node, slotMetadata))
})
const freshMetadata = buildSlotMetadata(node.inputs, node.graph)
slotMetadata.clear()
for (const [key, value] of freshMetadata) {
slotMetadata.set(key, value)
}
return widgetsSnapshot.map(safeWidgetMapper(node, slotMetadata))
})
const nodeType =
@@ -595,7 +486,7 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
mode: node.mode || 0,
titleMode: node.title_mode,
selected: node.selected || false,
executing: false,
executing: false, // Will be updated separately based on execution state
subgraphId,
apiNode,
badges,
@@ -645,11 +536,9 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
const currentNodes = new Set(graph._nodes.map((n) => String(n.id)))
// Remove deleted nodes and uninstrument them
// Remove deleted nodes
for (const id of Array.from(vueNodeData.keys())) {
if (!currentNodes.has(id)) {
const node = nodeRefs.get(id)
if (node) uninstrumentNode(node)
nodeRefs.delete(id)
vueNodeData.delete(id)
}
@@ -743,9 +632,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
setSource(LayoutSource.Canvas)
void deleteNode(id)
// Stop reactive effects and restore original property descriptors
uninstrumentNode(node)
// Clean up all tracking references
nodeRefs.delete(id)
vueNodeData.delete(id)
@@ -770,12 +656,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
graph.onNodeRemoved = originalOnNodeRemoved || undefined
graph.onTrigger = originalOnTrigger || undefined
// Uninstrument all tracked nodes to stop their effect scopes
// and restore original property descriptors
for (const node of nodeRefs.values()) {
uninstrumentNode(node)
}
// Clear all state maps
nodeRefs.clear()
vueNodeData.clear()
@@ -948,52 +828,16 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
)
}
// Set up event listeners immediately.
// setupEventListeners() calls syncWithGraph() which populates all existing
// nodes. We intentionally do NOT replay onNodeAdded for existing nodes here
// — syncWithGraph already calls extractVueNodeData for each node, and
// handleNodeAdded would call it again, causing duplicate reactive
// instrumentation that leaked memory.
// Set up event listeners immediately
const cleanup = setupEventListeners()
// Initialize layout for existing nodes (the part handleNodeAdded does
// beyond extractVueNodeData)
// Process any existing nodes after event listeners are set up
if (graph._nodes && graph._nodes.length > 0) {
for (const node of graph._nodes) {
const id = String(node.id)
if (!nodeRefs.has(id)) continue
const existingLayout = layoutStore.getNodeLayoutRef(id).value
if (existingLayout) continue
if (window.app?.configuringGraph) {
node.onAfterGraphConfigured = useChainCallback(
node.onAfterGraphConfigured,
() => {
if (!nodeRefs.has(id)) return
vueNodeData.set(id, extractVueNodeData(node))
const nodePosition = { x: node.pos[0], y: node.pos[1] }
const nodeSize = { width: node.size[0], height: node.size[1] }
if (layoutStore.getNodeLayoutRef(id).value) return
setSource(LayoutSource.Canvas)
void createNode(id, {
position: nodePosition,
size: nodeSize,
zIndex: node.order || 0,
visible: true
})
}
)
} else {
setSource(LayoutSource.Canvas)
void createNode(id, {
position: { x: node.pos[0], y: node.pos[1] },
size: { width: node.size[0], height: node.size[1] },
zIndex: node.order || 0,
visible: true
})
graph._nodes.forEach((node: LGraphNode) => {
if (graph.onNodeAdded) {
graph.onNodeAdded(node)
}
}
})
}
return {

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 }
}