Skip to content

Commit 007e71b

Browse files
committed
fix: address viewport virtualization review feedback
1 parent 45c78f1 commit 007e71b

7 files changed

Lines changed: 161 additions & 14 deletions

File tree

browser_tests/tests/vueNodes/viewportVirtualization.spec.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,6 @@ test.describe(
109109
if (!nodeId) throw new Error('Expected a node in the group workflow')
110110
await expect.poll(() => comfyPage.vueNodes.getNodeIds()).toEqual([nodeId])
111111

112-
await comfyPage.page.clock.install()
113-
114112
await comfyPage.page.evaluate(() => {
115113
const canvas = window.app!.canvas
116114
const node = window.app!.graph.nodes[0]
@@ -120,9 +118,13 @@ test.describe(
120118
node.updateArea()
121119
canvas.setDirty(true, true)
122120
})
123-
await comfyPage.page.clock.runFor(200)
124-
expect(await comfyPage.vueNodes.getNodeIds()).toEqual([nodeId])
125-
await comfyPage.page.clock.resume()
121+
const settleDeadline = Date.now() + 200
122+
await expect
123+
.poll(async () => {
124+
if (Date.now() < settleDeadline) return []
125+
return await comfyPage.vueNodes.getNodeIds()
126+
})
127+
.toEqual([nodeId])
126128

127129
await comfyPage.page.evaluate(() => {
128130
window.app!.canvas.isDragging = false

src/locales/en/settings.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,18 @@
430430
"name": "Modern Node Design (Nodes 2.0)",
431431
"tooltip": "Modern: DOM-based rendering with enhanced interactivity, native browser features, and updated visual design. Classic: Traditional canvas rendering."
432432
},
433+
"Comfy_VueNodes_FullDetailZoom": {
434+
"name": "Full-detail zoom",
435+
"tooltip": "Nodes use full detail at and above this zoom percentage. Low detail is used only below it."
436+
},
437+
"Comfy_VueNodes_LowZoomLOD": {
438+
"name": "Low-zoom level of detail",
439+
"tooltip": "Hide expensive node details below the full-detail zoom threshold while preserving node shells, titles, sockets, links, and layout."
440+
},
441+
"Comfy_VueNodes_ViewportVirtualization": {
442+
"name": "Viewport virtualization",
443+
"tooltip": "Mount all nodes once to establish layout, then render only nodes in the settled viewport. Workflow execution and serialization are unaffected."
444+
},
433445
"Comfy_WidgetControlMode": {
434446
"name": "Widget control mode",
435447
"tooltip": "Controls when widget values are updated (randomize/increment/decrement), either before the prompt is queued or after.",

src/platform/settings/constants/coreSettings.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1221,7 +1221,7 @@ export const CORE_SETTINGS: SettingParams[] = [
12211221
tooltip:
12221222
'Hide expensive node details below the full-detail zoom threshold while preserving node shells, titles, sockets, links, and layout.',
12231223
defaultValue: true,
1224-
sortOrder: 80,
1224+
sortOrder: 75,
12251225
experimental: true,
12261226
versionAdded: '1.49.0'
12271227
},

src/renderer/extensions/vueNodes/composables/useViewportVirtualization.test.ts

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { createTestingPinia } from '@pinia/testing'
22
import type * as VueUse from '@vueuse/core'
33
import { setActivePinia } from 'pinia'
4-
import { effectScope, nextTick, ref } from 'vue'
4+
import { computed, effectScope, nextTick, ref } from 'vue'
55
import type { ShallowRef } from 'vue'
66
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
77

@@ -277,6 +277,84 @@ describe('viewport virtualization behavior', () => {
277277
scope.stop()
278278
})
279279

280+
it('retries a settled refresh when the node manager becomes available', () => {
281+
vi.useFakeTimers()
282+
setActivePinia(createTestingPinia({ stubActions: false }))
283+
const { runAnimationFrame } = stubAnimationFrames()
284+
const nodeData = createNodeData(1)
285+
const node = createNode(1, 0, 0)
286+
const canvas = {
287+
ds: {
288+
scale: 1,
289+
offset: [0, 0],
290+
visible_area: [0, 0, 200, 200],
291+
computeVisibleArea: vi.fn()
292+
},
293+
graph: {},
294+
linkConnector: { renderLinks: [] },
295+
viewport: [0, 0, 200, 200]
296+
} as unknown as LGraphCanvas
297+
const nodeManager = ref<GraphNodeManager | null>(null)
298+
const scope = effectScope()
299+
const virtualization = scope.run(() =>
300+
useViewportVirtualization({
301+
allNodes: [nodeData],
302+
canvas,
303+
enabled: true,
304+
nodeManager
305+
})
306+
)
307+
if (!virtualization)
308+
throw new Error('Failed to create virtualization scope')
309+
310+
virtualization.onNodeMounted(nodeData.id)
311+
runAnimationFrame()
312+
runAnimationFrame()
313+
expect(virtualization.renderedNodes.value).toEqual([])
314+
315+
nodeManager.value = {
316+
getNode: vi.fn(() => node)
317+
} as unknown as GraphNodeManager
318+
vi.advanceTimersByTime(150)
319+
320+
expect(virtualization.renderedNodes.value).toEqual([nodeData])
321+
scope.stop()
322+
})
323+
324+
it('does not reconcile node ids when only node data changes', async () => {
325+
setActivePinia(createTestingPinia({ stubActions: false }))
326+
const { animationFrames, runAnimationFrame } = stubAnimationFrames()
327+
const title = ref('Initial')
328+
const allNodes = computed(() => [
329+
{
330+
...createNodeData(1),
331+
title: title.value
332+
}
333+
])
334+
const scope = effectScope()
335+
const virtualization = scope.run(() =>
336+
useViewportVirtualization({
337+
allNodes,
338+
canvas: null,
339+
enabled: true,
340+
nodeManager: null
341+
})
342+
)
343+
if (!virtualization)
344+
throw new Error('Failed to create virtualization scope')
345+
346+
virtualization.onNodeMounted(toNodeId(1))
347+
runAnimationFrame()
348+
runAnimationFrame()
349+
expect(animationFrames.size).toBe(0)
350+
351+
title.value = 'Updated'
352+
await nextTick()
353+
354+
expect(animationFrames.size).toBe(0)
355+
scope.stop()
356+
})
357+
280358
it('recomputes the viewport after a canvas transform settles', () => {
281359
vi.useFakeTimers()
282360
setActivePinia(createTestingPinia({ stubActions: false }))

src/renderer/extensions/vueNodes/composables/useViewportVirtualization.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,10 @@ export function useViewportVirtualization({
167167
}
168168
const activeCanvas = toValue(canvas)
169169
const manager = toValue(nodeManager)
170-
if (!activeCanvas || !manager) return
170+
if (!activeCanvas || !manager) {
171+
scheduleSettledRefresh()
172+
return
173+
}
171174
const mountSetFrozen =
172175
layoutStore.isDraggingVueNodes.value ||
173176
layoutStore.isResizingVueNodes.value ||
@@ -281,8 +284,9 @@ export function useViewportVirtualization({
281284
)
282285

283286
watch(
284-
() => toValue(allNodes).map((node) => node.id),
285-
(nodeIds) => {
287+
() => JSON.stringify(toValue(allNodes).map((node) => node.id)),
288+
() => {
289+
const nodeIds = toValue(allNodes).map((node) => node.id)
286290
const currentNodeIds = new Set(nodeIds)
287291
const nextHydratedNodeIds = new Set(
288292
Array.from(hydratedNodeIds.value).filter((nodeId) =>
@@ -295,9 +299,12 @@ export function useViewportVirtualization({
295299
for (const nodeId of pendingHydrationNodeIds) {
296300
if (!currentNodeIds.has(nodeId)) pendingHydrationNodeIds.delete(nodeId)
297301
}
298-
viewportNodeIds.value = new Set(
302+
const nextViewportNodeIds = new Set(
299303
Array.from(viewportNodeIds.value).filter((id) => currentNodeIds.has(id))
300304
)
305+
if (!areNodeIdSetsEqual(viewportNodeIds.value, nextViewportNodeIds)) {
306+
viewportNodeIds.value = nextViewportNodeIds
307+
}
301308
if (
302309
nodeIds.every(
303310
(nodeId) =>

src/renderer/extensions/vueNodes/composables/useVueNodeLOD.test.ts

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,29 @@
1-
import { describe, expect, it } from 'vitest'
1+
import type * as VueUse from '@vueuse/core'
2+
import { effectScope } from 'vue'
3+
import { describe, expect, it, vi } from 'vitest'
24

3-
import { shouldUseVueNodeLowDetail } from './useVueNodeLOD'
5+
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
6+
7+
import { shouldUseVueNodeLowDetail, useVueNodeLOD } from './useVueNodeLOD'
8+
9+
const rafWatcher = vi.hoisted(() => ({
10+
callback: undefined as Parameters<typeof VueUse.useRafFn>[0] | undefined
11+
}))
12+
13+
vi.mock('@vueuse/core', async (importOriginal) => {
14+
const vueUse = await importOriginal<typeof VueUse>()
15+
return {
16+
...vueUse,
17+
useRafFn: vi.fn((callback: Parameters<typeof VueUse.useRafFn>[0]) => {
18+
rafWatcher.callback = callback
19+
return {
20+
isActive: true,
21+
pause: vi.fn(),
22+
resume: vi.fn()
23+
}
24+
})
25+
}
26+
})
427

528
describe('shouldUseVueNodeLowDetail', () => {
629
it('uses low detail strictly below the percentage threshold', () => {
@@ -25,4 +48,29 @@ describe('shouldUseVueNodeLowDetail', () => {
2548
expect(shouldUseVueNodeLowDetail(0.99, true, 200, true)).toBe(true)
2649
expect(shouldUseVueNodeLowDetail(1, true, 200, true)).toBe(false)
2750
})
51+
52+
it('does not reevaluate an unchanged NaN scale on every frame', () => {
53+
const canvas = {
54+
ds: { scale: Number.NaN }
55+
} as unknown as LGraphCanvas
56+
const fullDetailZoom = vi.fn(() => 95)
57+
const scope = effectScope()
58+
scope.run(() =>
59+
useVueNodeLOD({
60+
canvas,
61+
enabled: true,
62+
fullDetailZoom,
63+
vueNodesEnabled: true
64+
})
65+
)
66+
const callback = rafWatcher.callback
67+
if (!callback) throw new Error('RAF watcher callback was not captured')
68+
69+
callback({ delta: 0, timestamp: 0 })
70+
const evaluationCount = fullDetailZoom.mock.calls.length
71+
callback({ delta: 0, timestamp: 16 })
72+
73+
expect(fullDetailZoom).toHaveBeenCalledTimes(evaluationCount)
74+
scope.stop()
75+
})
2876
})

src/renderer/extensions/vueNodes/composables/useVueNodeLOD.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ export function useVueNodeLOD({
6565

6666
useRafFn(() => {
6767
const scale = toValue(canvas)?.ds.scale
68-
if (scale == null || scale === lastScale) return
68+
if (scale == null || Object.is(scale, lastScale)) return
6969
lastScale = scale
7070
update(scale)
7171
})

0 commit comments

Comments
 (0)