mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 06:20:11 +00:00
Compare commits
1 Commits
perf/inves
...
perf/subgr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4df5ec176e |
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
109
src/composables/graph/useProgressiveNodeRendering.test.ts
Normal file
109
src/composables/graph/useProgressiveNodeRendering.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
91
src/composables/graph/useProgressiveNodeRendering.ts
Normal file
91
src/composables/graph/useProgressiveNodeRendering.ts
Normal 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user