Compare commits

...

3 Commits

Author SHA1 Message Date
Glary-Bot
63d62bc025 fix: re-defer pending measurements eagerly on tab-hidden
Address CodeRabbit review: between an RO callback and its scheduled RAF
flush, the tab can be hidden. The browser then suspends RAF until the
tab is shown again, at which point flushPendingMeasurements() runs with
visibility === 'visible' and writes stale bounds.

Move the hidden-state re-deferral into the visibility watch so pending
measurements are immediately shunted to deferredElements / unobserved
the moment the tab is hidden, and cancel the scheduled RAF. The flush
path keeps a defensive hidden check for any RO fire that races a flip
back to visible.
2026-05-15 21:19:26 +00:00
Glary-Bot
48a3269bea fix: route widgets-grid signals directly to slot sync, drop extra RAF hop
Address review: widgets-grid ResizeObserver signals were being staged
in this file's RAF batch and then forwarded to scheduleSlotLayoutSync(),
which RAF-batches a second time. Route them directly inside the RO
callback, restoring single-RAF latency for slot drift correction and
preserving the prior linearMode/hidden-tab gating behavior that the
slot tracker already handles.
2026-05-15 21:04:29 +00:00
Glary-Bot
a7485927a2 fix: RAF-batch Vue node ResizeObserver writes to layoutStore
Coalesces multiple ResizeObserver callbacks fired during the same frame
into a single layoutStore write, mirroring the pattern already used by
useSlotElementTracking. Defers measurement/write until next RAF so the
canvas ResizeObserver has had a chance to update lgCanvas.ds before any
DOM->canvas conversion fallback runs.

Fixes the case where opening the bottom panel during an animated splitter
resize causes Vue workflow nodes to flicker / appear displaced for ~1-2s
before the view settles, because the splitter's per-frame size changes
each triggered a synchronous layoutStore write with stale viewport state.
2026-05-15 20:55:05 +00:00
2 changed files with 235 additions and 40 deletions

View File

@@ -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: <T>(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: <T>(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()
}
})
})

View File

@@ -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)
}
watch(visibility, (state) => {
if (state !== 'visible' || deferredElements.size === 0) return
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<HTMLElement, PendingMeasurement>()
const rafBatch = createRafBatch(() => {
flushPendingMeasurements()
})
function deferElementsForHiddenTab(elements: Iterable<HTMLElement>) {
for (const element of elements) {
deferredElements.add(element)
markElementForFreshMeasurement(element)
resizeObserver.unobserve(element)
}
}
watch(visibility, (state) => {
// 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
// Skip measurements when tab is hidden — bounding rects are unreliable
if (useCanvasStore().linearMode) {
pendingMeasurements.clear()
return
}
// 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<NodeId>()
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()
}
})
/**