Skip to content

Commit c3045a0

Browse files
committed
perf: retain offscreen Vue nodes with KeepAlive
1 parent a8483a8 commit c3045a0

15 files changed

Lines changed: 801 additions & 19 deletions

File tree

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import {
2+
comfyExpect as expect,
3+
comfyPageFixture as test
4+
} from '@e2e/fixtures/ComfyPage'
5+
import type { Page } from '@playwright/test'
6+
7+
async function setCanvasOffsetX(page: Page, offsetX: number): Promise<void> {
8+
await page.evaluate((x) => {
9+
const canvas = window.app!.canvas
10+
canvas.ds.offset[0] = x
11+
canvas.setDirty(true, true)
12+
}, offsetX)
13+
}
14+
15+
test.describe(
16+
'Vue node viewport KeepAlive',
17+
{ tag: ['@canvas', '@node', '@vue-nodes', '@slow'] },
18+
() => {
19+
test.beforeEach(async ({ comfyPage }) => {
20+
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
21+
await comfyPage.workflow.loadWorkflow('large-graph-workflow')
22+
await comfyPage.canvasOps.setScale(1)
23+
await comfyPage.page.evaluate(() => {
24+
const canvas = window.app!.canvas
25+
canvas.ds.offset[0] = 0
26+
canvas.ds.offset[1] = 0
27+
canvas.setDirty(true, true)
28+
})
29+
await comfyPage.nextFrame()
30+
await comfyPage.vueNodes.waitForNodes()
31+
})
32+
33+
test.afterEach(async ({ comfyPage }) => {
34+
await comfyPage.canvasOps.resetView()
35+
})
36+
37+
test('detaches nodes and restores the same element without changing graph size', async ({
38+
comfyPage
39+
}) => {
40+
test.slow()
41+
const initialNode = comfyPage.vueNodes.nodes.first()
42+
await expect(initialNode).toBeVisible()
43+
const nodeId = await initialNode.getAttribute('data-node-id')
44+
const initialElement = await initialNode.elementHandle()
45+
if (!nodeId || !initialElement) {
46+
throw new Error('Expected an active Vue node with an id')
47+
}
48+
const initialSize = await comfyPage.page.evaluate((id) => {
49+
const node = window.app!.graph!.nodes.find(
50+
(candidate) => String(candidate.id) === id
51+
)
52+
if (!node) throw new Error(`Node ${id} is missing`)
53+
return Array.from(node.size)
54+
}, nodeId)
55+
56+
await setCanvasOffsetX(comfyPage.page, -100_000)
57+
58+
await expect
59+
.poll(() => initialElement.evaluate((element) => element.isConnected))
60+
.toBe(false)
61+
await expect
62+
.poll(() => comfyPage.vueNodes.getNodeCount())
63+
.toBeLessThan(245)
64+
65+
await setCanvasOffsetX(comfyPage.page, 0)
66+
67+
const returnedNode = comfyPage.vueNodes.getNodeLocator(nodeId)
68+
await expect(returnedNode).toBeVisible()
69+
const returnedElement = await returnedNode.elementHandle()
70+
if (!returnedElement) throw new Error('Expected the Vue node to return')
71+
72+
expect(
73+
await initialElement.evaluate(
74+
(element, candidate) => element === candidate,
75+
returnedElement
76+
)
77+
).toBe(true)
78+
expect(
79+
await comfyPage.page.evaluate((id) => {
80+
const node = window.app!.graph!.nodes.find(
81+
(candidate) => String(candidate.id) === id
82+
)
83+
if (!node) throw new Error(`Node ${id} is missing`)
84+
return Array.from(node.size)
85+
}, nodeId)
86+
).toEqual(initialSize)
87+
})
88+
89+
test('keeps the focused node connected while panning away', async ({
90+
comfyPage
91+
}) => {
92+
test.slow()
93+
const focusedNode = comfyPage.vueNodes.nodes.first()
94+
const focusedElement = await focusedNode.elementHandle()
95+
if (!focusedElement) throw new Error('Expected an active Vue node')
96+
await focusedNode.focus()
97+
98+
await setCanvasOffsetX(comfyPage.page, -100_000)
99+
100+
await expect
101+
.poll(() => focusedElement.evaluate((element) => element.isConnected))
102+
.toBe(true)
103+
expect(
104+
await focusedElement.evaluate((element) =>
105+
element.contains(document.activeElement)
106+
)
107+
).toBe(true)
108+
})
109+
110+
test('keeps a link-drag source connected while panning away', async ({
111+
comfyPage
112+
}) => {
113+
test.slow()
114+
const sourceNode = comfyPage.vueNodes.getNodeLocator('3')
115+
const outputSlot = sourceNode
116+
.locator('.lg-slot--output')
117+
.getByTestId('slot-connection-dot')
118+
await expect(outputSlot).toBeVisible()
119+
const sourceElement = await sourceNode.elementHandle()
120+
if (!sourceElement) throw new Error('Expected a link source node')
121+
122+
await outputSlot.hover()
123+
await comfyPage.page.mouse.down()
124+
try {
125+
await comfyPage.page.evaluate(() => {
126+
window.app!.canvas.deselectAll()
127+
if (document.activeElement instanceof HTMLElement) {
128+
document.activeElement.blur()
129+
}
130+
})
131+
await expect
132+
.poll(() =>
133+
comfyPage.page.evaluate(
134+
() => window.app!.canvas.linkConnector.isConnecting
135+
)
136+
)
137+
.toBe(true)
138+
139+
await setCanvasOffsetX(comfyPage.page, -100_000)
140+
141+
await expect
142+
.poll(() => sourceElement.evaluate((element) => element.isConnected))
143+
.toBe(true)
144+
} finally {
145+
await comfyPage.page.mouse.up()
146+
}
147+
148+
await expect
149+
.poll(() => sourceElement.evaluate((element) => element.isConnected))
150+
.toBe(false)
151+
})
152+
}
153+
)

src/components/graph/GraphCanvas.vue

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -65,25 +65,26 @@
6565
<!-- TransformPane for Vue node rendering -->
6666
<TransformPane
6767
v-if="shouldRenderVueNodes && comfyApp.canvas && comfyAppReady"
68+
ref="transformPaneRef"
6869
:canvas="comfyApp.canvas"
6970
@wheel.capture="canvasInteractions.forwardEventToCanvas"
7071
@pointerdown.capture="forwardPointerDownPanEvent"
7172
@pointerup.capture="forwardPointerUpPanEvent"
7273
@pointermove.capture="forwardPointerMovePanEvent"
7374
@keydown.space="forwardSpaceKeyEvent"
7475
>
75-
<!-- Vue nodes rendered based on graph nodes -->
76-
<LGraphNode
77-
v-for="nodeData in allNodes"
78-
:key="nodeData.id"
79-
:node-data="nodeData"
80-
:error="
81-
executionErrorStore.lastExecutionErrorNodeId === nodeData.id
82-
? 'Execution error'
83-
: null
84-
"
85-
:data-node-id="nodeData.id"
86-
/>
76+
<KeepAlive v-for="nodeData in rawNodes" :key="nodeData.id">
77+
<LGraphNode
78+
v-if="activeNodeIds.has(nodeData.id)"
79+
:node-data="nodeData"
80+
:error="
81+
executionErrorStore.lastExecutionErrorNodeId === nodeData.id
82+
? 'Execution error'
83+
: null
84+
"
85+
:data-node-id="nodeData.id"
86+
/>
87+
</KeepAlive>
8788
</TransformPane>
8889

8990
<LinkOverlayCanvas
@@ -116,7 +117,7 @@
116117
</template>
117118

118119
<script setup lang="ts">
119-
import { until, useEventListener } from '@vueuse/core'
120+
import { until, useElementSize, useEventListener } from '@vueuse/core'
120121
import {
121122
computed,
122123
nextTick,
@@ -184,7 +185,12 @@ import type { StartupOutcome } from '@/platform/workflow/persistence/base/draftT
184185
import { useFirstRunEntry } from '@/renderer/extensions/firstRunTour/gettingStarted/firstRunEntry'
185186
import MiniMap from '@/renderer/extensions/minimap/MiniMap.vue'
186187
import LGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
187-
import { requestSlotLayoutSyncForAllNodes } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
188+
import {
189+
requestSlotLayoutSyncForAllNodes,
190+
setExpectedRenderedNodeIds
191+
} from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
192+
import { useViewportKeepAlive } from '@/renderer/extensions/vueNodes/composables/useViewportKeepAlive'
193+
import { useViewportKeepAlivePins } from '@/renderer/extensions/vueNodes/composables/useViewportKeepAlivePins'
188194
import { UnauthorizedError } from '@/scripts/api'
189195
import { app as comfyApp } from '@/scripts/app'
190196
import { ChangeTracker } from '@/scripts/changeTracker'
@@ -213,6 +219,9 @@ const emit = defineEmits<{
213219
ready: []
214220
}>()
215221
const canvasRef = ref<HTMLCanvasElement | null>(null)
222+
const { width: canvasWidth, height: canvasHeight } = useElementSize(canvasRef)
223+
const transformPaneRef =
224+
useTemplateRef<InstanceType<typeof TransformPane>>('transformPaneRef')
216225
const canvasPanelBoundsRef = useTemplateRef('canvasPanelBoundsRef')
217226
const nodeSearchboxPopoverRef = shallowRef<InstanceType<
218227
typeof NodeSearchboxPopover
@@ -293,9 +302,29 @@ watch(
293302
}
294303
)
295304
296-
const allNodes = computed((): VueNodeData[] =>
305+
const rawNodes = computed((): VueNodeData[] =>
297306
Array.from(vueNodeLifecycle.nodeManager.value?.vueNodeData?.values() ?? [])
298307
)
308+
const { pinnedNodeIds } = useViewportKeepAlivePins({
309+
selectedNodeIds: computed(() => canvasStore.selectedNodeIds),
310+
getRoot: () => transformPaneRef.value?.element ?? null,
311+
getLinkConnector: () => canvasStore.canvas?.linkConnector
312+
})
313+
const { activeNodeIds } = useViewportKeepAlive({
314+
nodes: rawNodes,
315+
pinnedNodeIds,
316+
getNodeBounds: (nodeId) =>
317+
layoutStore.getNodeLayoutRef(nodeId).value?.bounds ?? null,
318+
getViewportSize: () => ({
319+
width: canvasWidth.value,
320+
height: canvasHeight.value
321+
}),
322+
onNodeGeometryChange: (callback) => layoutStore.onNodeGeometryChange(callback)
323+
})
324+
watch(activeNodeIds, () => setExpectedRenderedNodeIds(activeNodeIds.value), {
325+
immediate: true,
326+
flush: 'post'
327+
})
299328
watch(
300329
() => linearMode.value,
301330
(isLinearMode) => {
@@ -604,6 +633,7 @@ onMounted(async () => {
604633
})
605634
606635
onUnmounted(() => {
636+
setExpectedRenderedNodeIds(undefined)
607637
cleanupErrorHooks?.()
608638
cleanupErrorHooks = null
609639
vueNodeLifecycle.cleanup()

src/lib/litegraph/src/canvas/LinkConnector.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1052,6 +1052,7 @@ export class LinkConnector {
10521052
} satisfies ConnectingLink
10531053
})
10541054
this._setConnectingLinks(links)
1055+
this.events.dispatch('drag-started', undefined)
10551056
}
10561057

10571058
/**

src/lib/litegraph/src/infrastructure/LinkConnectorEventMap.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type { IWidget } from '@/lib/litegraph/src/types/widgets'
1414

1515
export interface LinkConnectorEventMap {
1616
reset: boolean
17+
'drag-started': undefined
1718

1819
'before-drop-links': {
1920
renderLinks: RenderLink[]

src/renderer/core/layout/store/layoutStore.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,55 @@ describe('layoutStore CRDT operations', () => {
4444
bounds: { x: 100, y: 100, width: 200, height: 100 }
4545
})
4646

47+
it('does not record unchanged bounds reported after activation', () => {
48+
const nodeId = toNodeId('unchanged-bounds')
49+
layoutStore.applyOperation({
50+
type: 'createNode',
51+
entity: 'node',
52+
nodeId,
53+
layout: createTestNode(nodeId),
54+
timestamp: Date.now(),
55+
source: LayoutSource.External,
56+
actor: 'test'
57+
})
58+
const version = layoutStore.layoutVersion
59+
60+
layoutStore.batchUpdateNodeBounds([
61+
{ nodeId, bounds: { x: 100, y: 100, width: 200, height: 100 } }
62+
])
63+
64+
expect(layoutStore.layoutVersion).toBe(version)
65+
})
66+
67+
it('normalizes DOM height before detecting unchanged bounds', () => {
68+
const nodeId = toNodeId('unchanged-dom-bounds')
69+
layoutStore.applyOperation({
70+
type: 'createNode',
71+
entity: 'node',
72+
nodeId,
73+
layout: createTestNode(nodeId),
74+
timestamp: Date.now(),
75+
source: LayoutSource.External,
76+
actor: 'test'
77+
})
78+
const version = layoutStore.layoutVersion
79+
80+
layoutStore.setSource(LayoutSource.DOM)
81+
layoutStore.batchUpdateNodeBounds([
82+
{
83+
nodeId,
84+
bounds: {
85+
x: 100,
86+
y: 100,
87+
width: 200,
88+
height: 100 + LiteGraph.NODE_TITLE_HEIGHT
89+
}
90+
}
91+
])
92+
93+
expect(layoutStore.layoutVersion).toBe(version)
94+
})
95+
4796
it('should create and retrieve nodes', () => {
4897
const nodeId = toNodeId('test-node-1')
4998
const layout = createTestNode(nodeId)

src/renderer/core/layout/store/layoutStore.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ class LayoutStoreImpl implements LayoutStore {
133133
>()
134134
private pendingGlobalChanges: LayoutChange[] = []
135135
private isGlobalDispatchQueued = false
136+
private nodeGeometryListeners = new Set<() => void>()
136137

137138
// CustomRef cache and trigger functions
138139
private nodeRefs = new Map<NodeId, Ref<NodeLayout | null>>()
@@ -244,6 +245,7 @@ class LayoutStoreImpl implements LayoutStore {
244245

245246
if (changedKeys.some((key) => NODE_GEOMETRY_KEYS.has(key))) {
246247
this._nodeGeometryVersion++
248+
this.nodeGeometryListeners.forEach((listener) => listener())
247249
return
248250
}
249251
}
@@ -970,6 +972,11 @@ class LayoutStoreImpl implements LayoutStore {
970972
return () => this.changeListeners.delete(callback)
971973
}
972974

975+
onNodeGeometryChange(callback: () => void): () => void {
976+
this.nodeGeometryListeners.add(callback)
977+
return () => this.nodeGeometryListeners.delete(callback)
978+
}
979+
973980
onNodeChange(
974981
nodeId: NodeId,
975982
callback: (change: LayoutChange) => void
@@ -1574,6 +1581,8 @@ class LayoutStoreImpl implements LayoutStore {
15741581
}
15751582
: bounds
15761583

1584+
if (isBoundsEqual(normalizedBounds, currentLayout.bounds)) continue
1585+
15771586
boundsRecord[nodeId] = {
15781587
bounds: normalizedBounds,
15791588
previousBounds: currentLayout.bounds

src/renderer/core/layout/transform/TransformPane.vue

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ const { isTransforming: isInteracting } = useTransformSettling(canvasElement, {
3232
3333
const transformPaneRef = useTemplateRef('transformPaneRef')
3434
35+
defineExpose({ element: transformPaneRef })
36+
3537
/**
3638
* Apply transform style and will-change class via direct DOM mutation
3739
* instead of reactive template bindings (:style / :class).

src/renderer/core/layout/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,7 @@ export interface LayoutStore {
335335

336336
// Change subscription
337337
onChange(callback: (change: LayoutChange) => void): () => void
338+
onNodeGeometryChange(callback: () => void): () => void
338339
onNodeChange(
339340
nodeId: NodeId,
340341
callback: (change: LayoutChange) => void

0 commit comments

Comments
 (0)