mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-22 21:38:52 +00:00
Compare commits
3 Commits
fix/subgra
...
glary/raf-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63d62bc025 | ||
|
|
48a3269bea | ||
|
|
a7485927a2 |
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user