Compare commits

...

3 Commits

Author SHA1 Message Date
bymyself
2e50d7d0cb fix: unobserve before re-observe to trigger fresh RO callback on settle
observe() on an already-observed element is a no-op per spec.
Unobserve first to ensure the flush produces a fresh measurement.
2026-03-24 18:40:16 -07:00
bymyself
85039f5d2f perf: throttle ResizeObserver during zoom/pan interactions
Skip expensive getBoundingClientRect() calls in the shared ResizeObserver
callback while the canvas is actively being zoomed or panned. During zoom,
border-box sizes don't actually change — the browser just recomputes layout
after the CSS scale transform, causing 10–40× more expensive UpdateLayoutTree
calls compared to panning.

- Export a module-level canvasTransformActive ref from useTransformSettling
- Early-return in the RO callback when transform is active, deferring elements
- Flush one accurate measurement for all deferred elements when settled
2026-03-24 16:48:29 -07:00
bymyself
af228aa40e test: add large graph zoom perf test for ResizeObserver baseline
Adds a @perf test that zooms in/out 30 steps on a 245-node workflow,
stressing the ResizeObserver callback that fires getBoundingClientRect()
for every node element on each CSS scale change.

This is PR 1 of 2. The fix (throttling RO during zoom) will follow
once this baseline is established on main.
2026-03-24 16:46:57 -07:00
4 changed files with 94 additions and 2 deletions

View File

@@ -154,6 +154,38 @@ test.describe('Performance', { tag: ['@perf'] }, () => {
)
})
test('large graph zoom interaction', 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')
// Position mouse at center so wheel events hit the canvas
const centerX = box.x + box.width / 2
const centerY = box.y + box.height / 2
await comfyPage.page.mouse.move(centerX, centerY)
await comfyPage.perf.startMeasuring()
// Zoom in 30 steps then out 30 steps — each step triggers
// ResizeObserver for all ~245 node elements due to CSS scale change.
for (let i = 0; i < 30; i++) {
await comfyPage.page.mouse.wheel(0, -100)
await comfyPage.nextFrame()
}
for (let i = 0; i < 30; i++) {
await comfyPage.page.mouse.wheel(0, 100)
await comfyPage.nextFrame()
}
const m = await comfyPage.perf.stopMeasuring('large-graph-zoom')
recordMeasurement(m)
console.log(
`Large graph zoom: ${m.layouts} layouts, ${m.layoutDurationMs.toFixed(1)}ms layout, ${m.frameDurationMs.toFixed(1)}ms/frame, TBT=${m.totalBlockingTimeMs.toFixed(0)}ms`
)
})
test('subgraph DOM widget clipping during node selection', async ({
comfyPage
}) => {

View File

@@ -2,7 +2,10 @@ import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import { useTransformSettling } from '@/renderer/core/layout/transform/useTransformSettling'
import {
canvasTransformActive,
useTransformSettling
} from '@/renderer/core/layout/transform/useTransformSettling'
describe('useTransformSettling', () => {
let element: HTMLDivElement
@@ -11,6 +14,7 @@ describe('useTransformSettling', () => {
vi.useFakeTimers()
element = document.createElement('div')
document.body.appendChild(element)
canvasTransformActive.value = false
})
afterEach(() => {
@@ -197,4 +201,18 @@ describe('useTransformSettling', () => {
expect.objectContaining({ passive: true, capture: true })
)
})
it('should set canvasTransformActive during interaction and clear on settle', async () => {
useTransformSettling(element, { settleDelay: 200 })
expect(canvasTransformActive.value).toBe(false)
element.dispatchEvent(new WheelEvent('wheel', { bubbles: true }))
await nextTick()
expect(canvasTransformActive.value).toBe(true)
vi.advanceTimersByTime(200)
expect(canvasTransformActive.value).toBe(false)
})
})

View File

@@ -1,6 +1,6 @@
import { useDebounceFn, useEventListener } from '@vueuse/core'
import { ref } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import type { MaybeRefOrGetter, Ref } from 'vue'
interface TransformSettlingOptions {
/**
@@ -15,6 +15,16 @@ interface TransformSettlingOptions {
passive?: boolean
}
/**
* Module-level flag shared across the application.
* `true` while the canvas is actively being zoomed or panned;
* `false` once the interaction settles.
*
* Used by the shared ResizeObserver to skip expensive `getBoundingClientRect()`
* calls during transform interactions.
*/
export const canvasTransformActive: Ref<boolean> = ref(false)
/**
* Tracks when canvas transforms (zoom or pan) are actively changing vs settled.
*
@@ -52,10 +62,12 @@ export function useTransformSettling(
const markTransformSettled = useDebounceFn(() => {
isTransforming.value = false
canvasTransformActive.value = false
}, settleDelay)
function markInteracting() {
isTransforming.value = true
canvasTransformActive.value = true
void markTransformSettled()
}

View File

@@ -17,6 +17,7 @@ import { useSharedCanvasPositionConversion } from '@/composables/element/useCanv
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { canvasTransformActive } from '@/renderer/core/layout/transform/useTransformSettling'
import type { Bounds, NodeId } from '@/renderer/core/layout/types'
import { LayoutSource } from '@/renderer/core/layout/types'
import {
@@ -73,6 +74,8 @@ const trackingConfigs: Map<string, ElementTrackingConfig> = new Map([
// Elements whose ResizeObserver fired while the tab was hidden
const deferredElements = new Set<HTMLElement>()
// Elements deferred because a canvas transform (zoom/pan) was active
const transformDeferredElements = new Set<HTMLElement>()
const elementsNeedingFreshMeasurement = new WeakSet<HTMLElement>()
const cachedNodeMeasurements = new WeakMap<HTMLElement, CachedNodeMeasurement>()
const visibility = useDocumentVisibility()
@@ -95,6 +98,21 @@ watch(visibility, (state) => {
deferredElements.clear()
})
// When a canvas transform settles, flush one accurate measurement for all
// elements that were deferred during the interaction.
watch(canvasTransformActive, (active) => {
if (active || transformDeferredElements.size === 0) return
for (const element of transformDeferredElements) {
if (element.isConnected) {
markElementForFreshMeasurement(element)
resizeObserver.unobserve(element)
resizeObserver.observe(element)
}
}
transformDeferredElements.clear()
})
// Single ResizeObserver instance for all Vue elements
const resizeObserver = new ResizeObserver((entries) => {
if (useCanvasStore().linearMode) return
@@ -111,6 +129,18 @@ const resizeObserver = new ResizeObserver((entries) => {
return
}
// Skip measurements during active zoom/pan — border-box hasn't truly
// changed, the browser just recomputes layout after the scale transform.
// Defer elements so they get one accurate measurement after settling.
if (canvasTransformActive.value) {
for (const entry of entries) {
if (entry.target instanceof HTMLElement) {
transformDeferredElements.add(entry.target)
}
}
return
}
// Canvas is ready when this code runs; no defensive guards needed.
const conv = useSharedCanvasPositionConversion()
// Group updates by type, then flush via each config's handler