diff --git a/src/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking.test.ts b/src/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking.test.ts index e0a7e6d50e1..ad24b97d808 100644 --- a/src/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking.test.ts +++ b/src/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { ref } from 'vue' +import { nextTick, ref } from 'vue' import type { Ref } from 'vue' import { LiteGraph } from '@/lib/litegraph/src/litegraph' @@ -47,11 +47,19 @@ const testState = vi.hoisted(() => ({ scheduleSlotLayoutSync: vi.fn() })) -vi.mock('@vueuse/core', () => ({ - useDocumentVisibility: () => ref<'visible' | 'hidden'>('visible'), - createSharedComposable: (fn: T) => fn +const visibilityState = vi.hoisted(() => ({ + ref: null as Ref<'visible' | 'hidden'> | null })) +vi.mock('@vueuse/core', async () => { + const { ref: vueRef } = await import('vue') + visibilityState.ref = vueRef<'visible' | 'hidden'>('visible') + return { + useDocumentVisibility: () => visibilityState.ref, + createSharedComposable: (fn: T) => fn + } +}) + vi.mock('@/renderer/core/canvas/canvasStore', () => ({ useCanvasStore: () => ({ linearMode: testState.linearMode @@ -78,6 +86,31 @@ vi.mock('./useSlotElementTracking', () => ({ syncNodeSlotLayoutsFromDOM: testState.syncNodeSlotLayoutsFromDOM })) +const rafBatchState = vi.hoisted(() => ({ + pending: null as (() => void) | null, + flush: () => {} +})) + +vi.mock('@/utils/rafBatch', () => ({ + createRafBatch: (run: () => void) => { + rafBatchState.flush = () => { + if (!rafBatchState.pending) return + rafBatchState.pending = null + run() + } + return { + schedule: () => { + rafBatchState.pending = run + }, + cancel: () => { + rafBatchState.pending = null + }, + flush: rafBatchState.flush, + isScheduled: () => rafBatchState.pending != null + } + } +})) + import './useVueNodeResizeTracking' function createResizeEntry(options?: { @@ -165,6 +198,8 @@ describe('useVueNodeResizeTracking', () => { resizeObserverState.observe.mockReset() resizeObserverState.unobserve.mockReset() resizeObserverState.disconnect.mockReset() + rafBatchState.pending = null + if (visibilityState.ref) visibilityState.ref.value = 'visible' }) it('skips repeated no-op resize entries after first measurement', () => { @@ -184,6 +219,7 @@ describe('useVueNodeResizeTracking', () => { seedNodeLayout({ nodeId, left, top, width, height }) resizeObserverState.callback?.([entry], createObserverMock()) + rafBatchState.flush() // When layout store already has correct position, getBoundingClientRect // is not needed — position is read from the store instead. @@ -197,6 +233,7 @@ describe('useVueNodeResizeTracking', () => { testState.syncNodeSlotLayoutsFromDOM.mockReset() resizeObserverState.callback?.([entry], createObserverMock()) + rafBatchState.flush() expect(rectSpy).not.toHaveBeenCalled() expect(testState.setSource).not.toHaveBeenCalled() @@ -225,6 +262,7 @@ describe('useVueNodeResizeTracking', () => { }) resizeObserverState.callback?.([entry], createObserverMock()) + rafBatchState.flush() // Position from DOM should NOT override layout store position expect(rectSpy).not.toHaveBeenCalled() @@ -252,6 +290,7 @@ describe('useVueNodeResizeTracking', () => { }) resizeObserverState.callback?.([entry], createObserverMock()) + rafBatchState.flush() expect(testState.setSource).toHaveBeenCalledWith(LayoutSource.DOM) expect(testState.batchUpdateNodeBounds).toHaveBeenCalledWith([ @@ -286,6 +325,7 @@ describe('useVueNodeResizeTracking', () => { seedNodeLayout({ nodeId, left: 100, top: 200, width: 240, height: 180 }) resizeObserverState.callback?.([entry], createObserverMock()) + rafBatchState.flush() expect(testState.setSource).toHaveBeenCalledWith(LayoutSource.DOM) expect(testState.batchUpdateNodeBounds).toHaveBeenCalledWith([ @@ -316,6 +356,7 @@ describe('useVueNodeResizeTracking', () => { top: 200 }) resizeObserverState.callback?.([entry], createObserverMock()) + rafBatchState.flush() expect(testState.setSource).toHaveBeenCalledWith(LayoutSource.DOM) expect(testState.batchUpdateNodeBounds).toHaveBeenCalled() @@ -335,10 +376,108 @@ describe('useVueNodeResizeTracking', () => { } satisfies ResizeEntryLike resizeObserverState.callback?.([entry], createObserverMock()) + rafBatchState.flush() expect(testState.scheduleSlotLayoutSync).toHaveBeenCalledWith(parentNodeId) expect(testState.batchUpdateNodeBounds).not.toHaveBeenCalled() expect(testState.setSource).not.toHaveBeenCalled() expect(testState.syncNodeSlotLayoutsFromDOM).not.toHaveBeenCalled() }) + + it('defers layoutStore writes until the next animation frame', () => { + const nodeId = 'test-node' + const { entry } = createResizeEntry({ + nodeId, + width: 300, + height: 200, + left: 100, + top: 200 + }) + seedNodeLayout({ nodeId, left: 100, top: 200, width: 220, height: 140 }) + + resizeObserverState.callback?.([entry], createObserverMock()) + + expect(testState.batchUpdateNodeBounds).not.toHaveBeenCalled() + expect(testState.setSource).not.toHaveBeenCalled() + + rafBatchState.flush() + + expect(testState.batchUpdateNodeBounds).toHaveBeenCalledTimes(1) + expect(testState.setSource).toHaveBeenCalledWith(LayoutSource.DOM) + }) + + it('coalesces successive resizes for the same node into one write per frame', () => { + const nodeId = 'test-node' + const titleHeight = LiteGraph.NODE_TITLE_HEIGHT + seedNodeLayout({ nodeId, left: 100, top: 200, width: 220, height: 140 }) + + const intermediate = createResizeEntry({ + nodeId, + width: 240, + height: 160, + left: 100, + top: 200 + }) + const final = createResizeEntry({ + nodeId, + width: 260, + height: 180, + left: 100, + top: 200 + }) + final.entry.target = intermediate.entry.target + + resizeObserverState.callback?.([intermediate.entry], createObserverMock()) + resizeObserverState.callback?.([final.entry], createObserverMock()) + + expect(testState.batchUpdateNodeBounds).not.toHaveBeenCalled() + + rafBatchState.flush() + + expect(testState.batchUpdateNodeBounds).toHaveBeenCalledTimes(1) + expect(testState.batchUpdateNodeBounds).toHaveBeenCalledWith([ + { + nodeId, + bounds: { + x: 100, + y: 200 + titleHeight, + width: 260, + height: 180 + } + } + ]) + expect(testState.syncNodeSlotLayoutsFromDOM).toHaveBeenCalledTimes(1) + }) + + it('re-defers pending measurements when the tab becomes hidden before flush', async () => { + const nodeId = 'test-node' + const { entry } = createResizeEntry({ + nodeId, + width: 300, + height: 200, + left: 100, + top: 200 + }) + document.body.appendChild(entry.target) + seedNodeLayout({ nodeId, left: 100, top: 200, width: 220, height: 140 }) + + try { + resizeObserverState.callback?.([entry], createObserverMock()) + expect(testState.batchUpdateNodeBounds).not.toHaveBeenCalled() + + visibilityState.ref!.value = 'hidden' + await nextTick() + + rafBatchState.flush() + expect(testState.batchUpdateNodeBounds).not.toHaveBeenCalled() + expect(resizeObserverState.unobserve).toHaveBeenCalledWith(entry.target) + + visibilityState.ref!.value = 'visible' + await nextTick() + + expect(resizeObserverState.observe).toHaveBeenCalledWith(entry.target) + } finally { + entry.target.remove() + } + }) }) diff --git a/src/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking.ts b/src/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking.ts index 1534e76c6e0..5b9c154d93a 100644 --- a/src/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking.ts +++ b/src/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking.ts @@ -23,6 +23,7 @@ import { isSizeEqual } from '@/renderer/core/layout/utils/geometry' import { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil' +import { createRafBatch } from '@/utils/rafBatch' import { scheduleSlotLayoutSync, @@ -87,10 +88,44 @@ function markElementForFreshMeasurement(element: HTMLElement) { cachedNodeMeasurements.delete(element) } +interface PendingMeasurement { + width: number + height: number +} + +// RAF-batched pending measurements keyed by element. Coalesces multiple RO +// callbacks fired during the same frame (e.g. while a splitter is animated +// open) into a single layoutStore write, and defers measurement until after +// the canvas RO has had a chance to update lgCanvas.ds. This prevents +// transient off-screen position writes from stale DOM→canvas conversion. +const pendingMeasurements = new Map() +const rafBatch = createRafBatch(() => { + flushPendingMeasurements() +}) + +function deferElementsForHiddenTab(elements: Iterable) { + for (const element of elements) { + deferredElements.add(element) + markElementForFreshMeasurement(element) + resizeObserver.unobserve(element) + } +} + watch(visibility, (state) => { - if (state !== 'visible' || deferredElements.size === 0) return + // Tab is hidden mid-flight: a scheduled RAF would be suspended by the + // browser and only resume on re-show, at which point flushPendingMeasurements + // would see visibility === 'visible' and write stale bounds. Re-defer + // anything pending immediately so it gets a fresh measurement on revisit. + if (state === 'hidden') { + if (pendingMeasurements.size === 0) return + deferElementsForHiddenTab(pendingMeasurements.keys()) + pendingMeasurements.clear() + rafBatch.cancel() + return + } + + if (deferredElements.size === 0) return - // Re-observe deferred elements to trigger fresh measurements for (const element of deferredElements) { if (element.isConnected) { markElementForFreshMeasurement(element) @@ -100,19 +135,19 @@ watch(visibility, (state) => { deferredElements.clear() }) -// Single ResizeObserver instance for all Vue elements -const resizeObserver = new ResizeObserver((entries) => { - if (useCanvasStore().linearMode) return +function flushPendingMeasurements() { + if (pendingMeasurements.size === 0) return + + if (useCanvasStore().linearMode) { + pendingMeasurements.clear() + return + } - // Skip measurements when tab is hidden — bounding rects are unreliable + // RO callbacks that fired while the tab was visible can still land in the + // flush after a hidden→visible flip if scheduling raced. Re-defer. if (visibility.value === 'hidden') { - for (const entry of entries) { - if (entry.target instanceof HTMLElement) { - deferredElements.add(entry.target) - markElementForFreshMeasurement(entry.target) - resizeObserver.unobserve(entry.target) - } - } + deferElementsForHiddenTab(pendingMeasurements.keys()) + pendingMeasurements.clear() return } @@ -123,18 +158,7 @@ const resizeObserver = new ResizeObserver((entries) => { // Track nodes whose slots should be resynced after node size changes const nodesNeedingSlotResync = new Set() - for (const entry of entries) { - if (!(entry.target instanceof HTMLElement)) continue - const element = entry.target - - // Signal-only widgets-grid resize - route the parent node through the - // slot-layout pipeline and skip bounds processing entirely. - const widgetsGridParentNodeId = element.dataset.widgetsGridNodeId - if (widgetsGridParentNodeId) { - scheduleSlotLayoutSync(widgetsGridParentNodeId as NodeId) - continue - } - + for (const [element, measurement] of pendingMeasurements) { // Find which type this element belongs to let elementType: string | undefined let elementId: string | undefined @@ -152,16 +176,7 @@ const resizeObserver = new ResizeObserver((entries) => { const nodeId: NodeId | undefined = elementType === 'node' ? elementId : undefined - // Use borderBoxSize when available; fall back to contentRect for older engines/tests - // Border box is the border included FULL wxh DOM value. - const borderBox = Array.isArray(entry.borderBoxSize) - ? entry.borderBoxSize[0] - : { - inlineSize: entry.contentRect.width, - blockSize: entry.contentRect.height - } - const width = Math.max(0, borderBox.inlineSize) - const height = Math.max(0, borderBox.blockSize) + const { width, height } = measurement const nodeLayout = nodeId ? layoutStore.getNodeLayoutRef(nodeId).value @@ -244,6 +259,8 @@ const resizeObserver = new ResizeObserver((entries) => { } } + pendingMeasurements.clear() + if (updatesByType.size === 0 && nodesNeedingSlotResync.size === 0) return if (updatesByType.size > 0) { @@ -262,6 +279,45 @@ const resizeObserver = new ResizeObserver((entries) => { syncNodeSlotLayoutsFromDOM(nodeId) } } +} + +// Single ResizeObserver instance for all Vue elements +const resizeObserver = new ResizeObserver((entries) => { + if (useCanvasStore().linearMode) { + pendingMeasurements.clear() + return + } + + for (const entry of entries) { + if (!(entry.target instanceof HTMLElement)) continue + const element = entry.target + + // Signal-only widgets-grid resize - route the parent node through the + // slot-layout pipeline (already RAF-batched) and skip bounds processing. + const widgetsGridParentNodeId = element.dataset.widgetsGridNodeId + if (widgetsGridParentNodeId) { + scheduleSlotLayoutSync(widgetsGridParentNodeId as NodeId) + continue + } + + // Use borderBoxSize when available; fall back to contentRect for older + // engines/tests. Border box is the full w×h DOM value including border. + const borderBox = Array.isArray(entry.borderBoxSize) + ? entry.borderBoxSize[0] + : { + inlineSize: entry.contentRect.width, + blockSize: entry.contentRect.height + } + + pendingMeasurements.set(element, { + width: Math.max(0, borderBox.inlineSize), + height: Math.max(0, borderBox.blockSize) + }) + } + + if (pendingMeasurements.size > 0) { + rafBatch.schedule() + } }) /**