Compare commits

...

7 Commits

Author SHA1 Message Date
Christian Byrne
3517e14124 Revert performance.spec.ts to pre-merge state 2026-03-27 17:06:02 -07:00
Christian Byrne
84fad7f507 fix: resolve merge conflict with main in performance.spec.ts 2026-03-27 17:04:35 -07:00
bymyself
ca0e41a786 fix: add ResizeObserver on canvas element for sidebar/splitter resizes 2026-03-25 11:41:58 -07:00
bymyself
08b357ae18 perf: skip shallowRef update when mounted node set is unchanged
Avoid publishing a new Set to mountedNodeIds when no nodes entered
the viewport, preventing unnecessary downstream computed invalidation
during pan/zoom ticks.

https://github.com/Comfy-Org/ComfyUI_frontend/pull/10481#discussion_r2985002114
2026-03-24 18:35:59 -07:00
bymyself
aaa5faf87c perf: add viewport culling to reduce Vue node mount/unmount GC churn
Nodes outside the viewport are no longer rendered as Vue components.
This reduces the number of mounted component instances and avoids the
costly mount/unmount cycle that was generating excessive GC pressure
(~1,273 GC events/sec) during panning.

Key design decisions:
- Generous viewport margin (0.75x) prevents visible pop-in
- Immediate mount when nodes enter viewport
- Debounced unmount (250ms) when nodes leave viewport, preventing
  churn when nodes oscillate at the viewport edge
- Throttled visibility recomputation (96ms) during active pan/zoom
  to avoid making culling itself a per-frame hotspot
- Nodes without layout data are always mounted (safe fallback)
- Camera state is sampled imperatively via throttle, not bound as
  a reactive dependency on allNodes, preserving the existing
  TransformPane optimization that avoids full vdom diffs
2026-03-24 18:35:59 -07:00
bymyself
c5dd55e219 fix: correct domNodes log label — absolute count, not delta
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10479#discussion_r2984981001
2026-03-24 18:35:01 -07:00
bymyself
ae51e7b320 test: add perf test for viewport pan sweep GC churn
Adds a @perf test that pans aggressively back and forth across a
large graph (245 nodes), forcing many nodes to cross the viewport
boundary. Measures style recalcs, forced layouts, task duration,
heap delta, and DOM node churn.

This is PR 1 of 2. The optimization (viewport culling) will follow
in a separate PR once this baseline is established on main.
2026-03-24 16:48:47 -07:00
4 changed files with 454 additions and 1 deletions

View File

@@ -154,6 +154,45 @@ test.describe('Performance', { tag: ['@perf'] }, () => {
)
})
test('large graph viewport pan sweep', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('large-graph-workflow')
const canvas = comfyPage.canvas
const box = await canvas.boundingBox()
if (!box) throw new Error('Canvas bounding box not available')
// Pan aggressively across the full graph so many nodes cross the
// viewport boundary, triggering mount/unmount cycles and GC churn.
const centerX = box.x + box.width / 2
const centerY = box.y + box.height / 2
await comfyPage.page.mouse.move(centerX, centerY)
await comfyPage.page.mouse.down({ button: 'middle' })
await comfyPage.perf.startMeasuring()
// Sweep right (nodes exit left edge, new nodes enter right edge)
for (let i = 0; i < 120; i++) {
await comfyPage.page.mouse.move(centerX + i * 8, centerY + i * 3)
await comfyPage.nextFrame()
}
// Sweep back left
for (let i = 120; i > 0; i--) {
await comfyPage.page.mouse.move(centerX + i * 8, centerY + i * 3)
await comfyPage.nextFrame()
}
await comfyPage.page.mouse.up({ button: 'middle' })
const m = await comfyPage.perf.stopMeasuring('viewport-pan-sweep')
recordMeasurement(m)
console.log(
`Viewport pan sweep: ${m.styleRecalcs} recalcs, ${m.layouts} layouts, ` +
`${m.taskDurationMs.toFixed(1)}ms task, ` +
`heap Δ${(m.heapDeltaBytes / 1024).toFixed(0)}KB, ` +
`${m.domNodes} DOM nodes`
)
})
test('subgraph DOM widget clipping during node selection', async ({
comfyPage
}) => {

View File

@@ -147,6 +147,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 { useViewportCulling } from '@/composables/graph/useViewportCulling'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { useNodeBadge } from '@/composables/node/useNodeBadge'
import { useCanvasDrop } from '@/composables/useCanvasDrop'
@@ -167,6 +168,7 @@ import { useWorkflowPersistenceV2 as useWorkflowPersistence } from '@/platform/w
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useTransformSettling } from '@/renderer/core/layout/transform/useTransformSettling'
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
import MiniMap from '@/renderer/extensions/minimap/MiniMap.vue'
import LGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
@@ -282,7 +284,7 @@ watch(
}
)
const allNodes = computed((): VueNodeData[] =>
const rawNodes = computed((): VueNodeData[] =>
Array.from(vueNodeLifecycle.nodeManager.value?.vueNodeData?.values() ?? [])
)
watch(
@@ -302,6 +304,28 @@ watch(
}
)
const canvasElement = computed(() => canvasStore.canvas?.canvas)
const { isTransforming } = useTransformSettling(canvasElement, {
settleDelay: 256
})
const nodeLayouts = layoutStore.getAllNodes()
const { mountedNodeIds } = useViewportCulling({
rawNodes,
nodeLayouts,
getViewportSize: () => {
const rect = canvasStore.canvas?.canvas?.getBoundingClientRect()
return { width: rect?.width ?? 0, height: rect?.height ?? 0 }
},
isTransforming,
canvasElement
})
const allNodes = computed(() =>
rawNodes.value.filter((node) => mountedNodeIds.value.has(node.id))
)
function onLinkOverlayReady(el: HTMLCanvasElement) {
if (!canvasStore.canvas) return
canvasStore.canvas.overlayCanvas = el

View File

@@ -0,0 +1,240 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, nextTick, ref } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import type { NodeId, NodeLayout } from '@/renderer/core/layout/types'
const mockIsNodeInViewport = vi.fn()
vi.mock('@/renderer/core/layout/transform/useTransformState', () => ({
useTransformState: () => ({
isNodeInViewport: mockIsNodeInViewport,
camera: { x: 0, y: 0, z: 1 }
})
}))
// Must import after mock setup
const { useViewportCulling } =
await import('@/composables/graph/useViewportCulling')
function makeNode(id: string): VueNodeData {
return {
id: id as NodeId,
title: `Node ${id}`,
type: 'test',
mode: 0,
executing: false,
selected: false
}
}
let layoutCounter = 0
function makeLayout(x: number, y: number, w = 200, h = 100): NodeLayout {
return {
id: `layout-${layoutCounter++}`,
position: { x, y },
size: { width: w, height: h },
bounds: { x, y, width: w, height: h },
zIndex: 0,
visible: true
}
}
describe('useViewportCulling', () => {
const isTransforming = ref(false)
const viewport = { width: 1000, height: 600 }
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
isTransforming.value = false
mockIsNodeInViewport.mockReturnValue(true)
})
afterEach(() => {
vi.useRealTimers()
})
it('mounts all nodes when all are visible', () => {
const nodes = [makeNode('1'), makeNode('2'), makeNode('3')]
const layouts = new Map<NodeId, NodeLayout>([
['1', makeLayout(100, 100)],
['2', makeLayout(300, 100)],
['3', makeLayout(500, 100)]
])
const { mountedNodeIds } = useViewportCulling({
rawNodes: computed(() => nodes),
nodeLayouts: computed(() => layouts),
getViewportSize: () => viewport,
isTransforming
})
expect(mountedNodeIds.value.size).toBe(3)
expect(mountedNodeIds.value.has('1')).toBe(true)
expect(mountedNodeIds.value.has('2')).toBe(true)
expect(mountedNodeIds.value.has('3')).toBe(true)
})
it('culls nodes outside the viewport after debounce', async () => {
const nodes = [makeNode('1'), makeNode('2')]
const layouts = new Map<NodeId, NodeLayout>([
['1', makeLayout(100, 100)],
['2', makeLayout(5000, 5000)]
])
mockIsNodeInViewport.mockImplementation(
(pos: [number, number]) => pos[0] < 2000 && pos[1] < 2000
)
const { mountedNodeIds } = useViewportCulling({
rawNodes: computed(() => nodes),
nodeLayouts: computed(() => layouts),
getViewportSize: () => viewport,
isTransforming
})
// Node 2 is outside viewport, but hasn't been pruned yet on
// initial mount since it was never in the set to begin with
// and computeVisibleNodeIds runs immediately
expect(mountedNodeIds.value.has('1')).toBe(true)
expect(mountedNodeIds.value.has('2')).toBe(false)
})
it('mounts nodes without layout data', () => {
const nodes = [makeNode('1'), makeNode('2')]
const layouts = new Map<NodeId, NodeLayout>([
['1', makeLayout(100, 100)]
// Node '2' has no layout
])
mockIsNodeInViewport.mockReturnValue(true)
const { mountedNodeIds } = useViewportCulling({
rawNodes: computed(() => nodes),
nodeLayouts: computed(() => layouts),
getViewportSize: () => viewport,
isTransforming
})
expect(mountedNodeIds.value.has('1')).toBe(true)
expect(mountedNodeIds.value.has('2')).toBe(true)
})
it('mounts all nodes when viewport size is zero', () => {
const nodes = [makeNode('1'), makeNode('2')]
const layouts = new Map<NodeId, NodeLayout>([
['1', makeLayout(100, 100)],
['2', makeLayout(5000, 5000)]
])
mockIsNodeInViewport.mockReturnValue(false)
const { mountedNodeIds } = useViewportCulling({
rawNodes: computed(() => nodes),
nodeLayouts: computed(() => layouts),
getViewportSize: () => ({ width: 0, height: 0 }),
isTransforming
})
expect(mountedNodeIds.value.size).toBe(2)
})
it('delays unmounting nodes that leave the viewport', async () => {
const nodes = [makeNode('1'), makeNode('2')]
const layouts = new Map<NodeId, NodeLayout>([
['1', makeLayout(100, 100)],
['2', makeLayout(300, 100)]
])
mockIsNodeInViewport.mockReturnValue(true)
const rawNodes = ref(nodes)
const nodeLayouts = ref(layouts)
const { mountedNodeIds } = useViewportCulling({
rawNodes: computed(() => rawNodes.value),
nodeLayouts: computed(() => nodeLayouts.value),
getViewportSize: () => viewport,
isTransforming
})
expect(mountedNodeIds.value.size).toBe(2)
// Node 2 leaves viewport
mockIsNodeInViewport.mockImplementation(
(pos: [number, number]) => pos[0] < 200
)
// Trigger a refresh by updating layouts
nodeLayouts.value = new Map(layouts)
await nextTick()
// Node 2 should still be mounted (debounce hasn't fired)
expect(mountedNodeIds.value.has('2')).toBe(true)
// After debounce delay, node 2 should be unmounted
await vi.advanceTimersByTimeAsync(300)
expect(mountedNodeIds.value.has('1')).toBe(true)
expect(mountedNodeIds.value.has('2')).toBe(false)
})
it('immediately mounts nodes entering the viewport', async () => {
const nodes = [makeNode('1'), makeNode('2')]
const layouts = new Map<NodeId, NodeLayout>([
['1', makeLayout(100, 100)],
['2', makeLayout(5000, 5000)]
])
mockIsNodeInViewport.mockImplementation(
(pos: [number, number]) => pos[0] < 2000
)
const rawNodes = ref(nodes)
const nodeLayouts = ref(layouts)
const { mountedNodeIds } = useViewportCulling({
rawNodes: computed(() => rawNodes.value),
nodeLayouts: computed(() => nodeLayouts.value),
getViewportSize: () => viewport,
isTransforming
})
expect(mountedNodeIds.value.has('2')).toBe(false)
// Node 2 enters viewport
mockIsNodeInViewport.mockReturnValue(true)
nodeLayouts.value = new Map(layouts)
await nextTick()
// Should be immediately mounted without waiting for debounce
expect(mountedNodeIds.value.has('2')).toBe(true)
})
it('handles new nodes being added to the graph', async () => {
const nodes = ref([makeNode('1')])
const layouts = ref(
new Map<NodeId, NodeLayout>([['1', makeLayout(100, 100)]])
)
mockIsNodeInViewport.mockReturnValue(true)
const { mountedNodeIds } = useViewportCulling({
rawNodes: computed(() => nodes.value),
nodeLayouts: computed(() => layouts.value),
getViewportSize: () => viewport,
isTransforming
})
expect(mountedNodeIds.value.size).toBe(1)
// Add a new node
nodes.value = [...nodes.value, makeNode('2')]
layouts.value = new Map([...layouts.value, ['2', makeLayout(200, 200)]])
await nextTick()
expect(mountedNodeIds.value.has('2')).toBe(true)
})
})

View File

@@ -0,0 +1,150 @@
/**
* Viewport Culling for Vue Node Components
*
* Controls which nodes are mounted as Vue components based on viewport
* visibility. Nodes entering the viewport are mounted immediately; nodes
* leaving are unmounted after a debounce delay to avoid mount/unmount
* churn when nodes oscillate at the viewport edge during panning.
*
* Visibility checks are throttled during active pan/zoom interactions
* to avoid turning culling into a per-frame reactive hotspot.
*/
import {
useDebounceFn,
useEventListener,
useResizeObserver,
useThrottleFn
} from '@vueuse/core'
import { shallowRef, watch } from 'vue'
import type { ComputedRef, Ref } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import type { NodeId, NodeLayout } from '@/renderer/core/layout/types'
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
/** Viewport margin as a fraction of viewport dimensions (0.75 = 75% extra) */
const VIEWPORT_MARGIN = 0.75
/** Delay before unmounting nodes that left the viewport */
const HIDE_DELAY_MS = 250
/** Throttle interval for visibility recomputation during pan/zoom */
const CULL_THROTTLE_MS = 96
interface UseViewportCullingOptions {
rawNodes: ComputedRef<VueNodeData[]>
nodeLayouts: ComputedRef<ReadonlyMap<NodeId, NodeLayout>>
getViewportSize: () => { width: number; height: number }
isTransforming: Ref<boolean>
canvasElement?: Ref<HTMLElement | undefined | null>
}
export function useViewportCulling({
rawNodes,
nodeLayouts,
getViewportSize,
isTransforming,
canvasElement
}: UseViewportCullingOptions) {
const { isNodeInViewport } = useTransformState()
const mountedNodeIds = shallowRef(new Set<string>())
function computeVisibleNodeIds(): Set<string> {
const viewport = getViewportSize()
const layouts = nodeLayouts.value
const visible = new Set<string>()
if (!viewport.width || !viewport.height) {
for (const node of rawNodes.value) visible.add(node.id)
return visible
}
for (const node of rawNodes.value) {
const layout = layouts.get(node.id)
if (!layout) {
visible.add(node.id)
continue
}
if (
isNodeInViewport(
[layout.position.x, layout.position.y],
[layout.size.width, layout.size.height],
viewport,
VIEWPORT_MARGIN
)
) {
visible.add(node.id)
}
}
return visible
}
const pruneMountedNodes = useDebounceFn(() => {
mountedNodeIds.value = computeVisibleNodeIds()
}, HIDE_DELAY_MS)
function refreshMountedNodes() {
const visibleNow = computeVisibleNodeIds()
const current = mountedNodeIds.value
let hasNewNodes = false
let needsPrune = false
let next = current
for (const id of visibleNow) {
if (!current.has(id)) {
if (next === current) next = new Set(current)
next.add(id)
hasNewNodes = true
}
}
for (const id of current) {
if (!visibleNow.has(id)) {
needsPrune = true
break
}
}
if (hasNewNodes) {
mountedNodeIds.value = next
}
if (needsPrune) {
void pruneMountedNodes()
}
}
const refreshThrottled = useThrottleFn(refreshMountedNodes, CULL_THROTTLE_MS)
watch([rawNodes, nodeLayouts], refreshMountedNodes, { immediate: true })
const { camera } = useTransformState()
watch(
() => [camera.x, camera.y, camera.z] as const,
() => {
if (isTransforming.value) {
void refreshThrottled()
}
}
)
watch(isTransforming, (moving) => {
if (!moving) {
refreshMountedNodes()
}
})
useEventListener(window, 'resize', refreshMountedNodes)
if (canvasElement) {
useResizeObserver(canvasElement, refreshMountedNodes)
}
return {
mountedNodeIds
}
}