mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-02 03:30:04 +00:00
fix: batch updateClipPath via requestAnimationFrame (#9173)
## Summary Batch `getBoundingClientRect()` calls in `updateClipPath` via `requestAnimationFrame` to avoid forced synchronous layout. ## Changes - **What**: Wrap the layout-reading portion of `updateClipPath` in `requestAnimationFrame()` with cancellation. Multiple rapid calls within the same frame are coalesced into a single layout read. Eliminates ~1,053 forced synchronous layouts per profiling session. ## Review Focus - `getBoundingClientRect()` forces synchronous layout. When interleaved with style mutations (from PrimeVue `useStyle`, cursor writes, Vue VDOM patching), this creates layout thrashing — especially in Firefox where Stylo aggressively invalidates the entire style cache. - The RAF wrapper coalesces all calls within a frame into one, reading layout only once per frame. The `cancelAnimationFrame` ensures only the latest parameters are used. - `willChange: 'clip-path'` is included to hint the browser to optimize clip-path animations. ## Stack 4 of 4 in Firefox perf fix stack. Depends on #9170. <!-- Fixes #ISSUE_NUMBER --> ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9173-fix-batch-updateClipPath-via-requestAnimationFrame-3116d73d3650810392f7fba7ea5ceb6f) by [Unito](https://www.unito.io)
This commit is contained in:
224
src/composables/element/useDomClipping.test.ts
Normal file
224
src/composables/element/useDomClipping.test.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
import { useDomClipping } from './useDomClipping'
|
||||
|
||||
function createMockElement(rect: {
|
||||
left: number
|
||||
top: number
|
||||
width: number
|
||||
height: number
|
||||
}): HTMLElement {
|
||||
return {
|
||||
getBoundingClientRect: vi.fn(
|
||||
() =>
|
||||
({
|
||||
...rect,
|
||||
x: rect.left,
|
||||
y: rect.top,
|
||||
right: rect.left + rect.width,
|
||||
bottom: rect.top + rect.height,
|
||||
toJSON: () => ({})
|
||||
}) as DOMRect
|
||||
)
|
||||
} as unknown as HTMLElement
|
||||
}
|
||||
|
||||
function createMockCanvas(rect: {
|
||||
left: number
|
||||
top: number
|
||||
width: number
|
||||
height: number
|
||||
}): HTMLCanvasElement {
|
||||
return {
|
||||
getBoundingClientRect: vi.fn(
|
||||
() =>
|
||||
({
|
||||
...rect,
|
||||
x: rect.left,
|
||||
y: rect.top,
|
||||
right: rect.left + rect.width,
|
||||
bottom: rect.top + rect.height,
|
||||
toJSON: () => ({})
|
||||
}) as DOMRect
|
||||
)
|
||||
} as unknown as HTMLCanvasElement
|
||||
}
|
||||
|
||||
describe('useDomClipping', () => {
|
||||
let rafCallbacks: Map<number, FrameRequestCallback>
|
||||
let nextRafId: number
|
||||
|
||||
beforeEach(() => {
|
||||
rafCallbacks = new Map()
|
||||
nextRafId = 1
|
||||
|
||||
vi.stubGlobal(
|
||||
'requestAnimationFrame',
|
||||
vi.fn((cb: FrameRequestCallback) => {
|
||||
const id = nextRafId++
|
||||
rafCallbacks.set(id, cb)
|
||||
return id
|
||||
})
|
||||
)
|
||||
|
||||
vi.stubGlobal(
|
||||
'cancelAnimationFrame',
|
||||
vi.fn((id: number) => {
|
||||
rafCallbacks.delete(id)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function flushRaf() {
|
||||
const callbacks = [...rafCallbacks.values()]
|
||||
rafCallbacks.clear()
|
||||
for (const cb of callbacks) {
|
||||
cb(performance.now())
|
||||
}
|
||||
}
|
||||
|
||||
it('coalesces multiple rapid calls into a single getBoundingClientRect read', () => {
|
||||
const { updateClipPath } = useDomClipping()
|
||||
const element = createMockElement({
|
||||
left: 10,
|
||||
top: 10,
|
||||
width: 100,
|
||||
height: 50
|
||||
})
|
||||
const canvas = createMockCanvas({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 800,
|
||||
height: 600
|
||||
})
|
||||
|
||||
updateClipPath(element, canvas, true)
|
||||
updateClipPath(element, canvas, true)
|
||||
updateClipPath(element, canvas, true)
|
||||
|
||||
expect(element.getBoundingClientRect).not.toHaveBeenCalled()
|
||||
|
||||
flushRaf()
|
||||
|
||||
expect(element.getBoundingClientRect).toHaveBeenCalledTimes(1)
|
||||
expect(canvas.getBoundingClientRect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('updates style ref after RAF fires', () => {
|
||||
const { style, updateClipPath } = useDomClipping()
|
||||
const element = createMockElement({
|
||||
left: 10,
|
||||
top: 10,
|
||||
width: 100,
|
||||
height: 50
|
||||
})
|
||||
const canvas = createMockCanvas({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 800,
|
||||
height: 600
|
||||
})
|
||||
|
||||
updateClipPath(element, canvas, true)
|
||||
|
||||
expect(style.value).toEqual({})
|
||||
|
||||
flushRaf()
|
||||
|
||||
expect(style.value).toEqual({
|
||||
clipPath: 'none',
|
||||
willChange: 'clip-path'
|
||||
})
|
||||
})
|
||||
|
||||
it('cancels previous RAF when called again before it fires', () => {
|
||||
const { style, updateClipPath } = useDomClipping()
|
||||
const element1 = createMockElement({
|
||||
left: 10,
|
||||
top: 10,
|
||||
width: 100,
|
||||
height: 50
|
||||
})
|
||||
const element2 = createMockElement({
|
||||
left: 20,
|
||||
top: 20,
|
||||
width: 200,
|
||||
height: 100
|
||||
})
|
||||
const canvas = createMockCanvas({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 800,
|
||||
height: 600
|
||||
})
|
||||
|
||||
updateClipPath(element1, canvas, true)
|
||||
updateClipPath(element2, canvas, true)
|
||||
|
||||
expect(cancelAnimationFrame).toHaveBeenCalledTimes(1)
|
||||
|
||||
flushRaf()
|
||||
|
||||
expect(element1.getBoundingClientRect).not.toHaveBeenCalled()
|
||||
expect(element2.getBoundingClientRect).toHaveBeenCalledTimes(1)
|
||||
expect(style.value).toEqual({
|
||||
clipPath: 'none',
|
||||
willChange: 'clip-path'
|
||||
})
|
||||
})
|
||||
|
||||
it('generates clip-path polygon when element intersects unselected area', () => {
|
||||
const { style, updateClipPath } = useDomClipping()
|
||||
const element = createMockElement({
|
||||
left: 50,
|
||||
top: 50,
|
||||
width: 100,
|
||||
height: 100
|
||||
})
|
||||
const canvas = createMockCanvas({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 800,
|
||||
height: 600
|
||||
})
|
||||
const selectedArea = {
|
||||
x: 40,
|
||||
y: 40,
|
||||
width: 200,
|
||||
height: 200,
|
||||
scale: 1,
|
||||
offset: [0, 0] as [number, number]
|
||||
}
|
||||
|
||||
updateClipPath(element, canvas, false, selectedArea)
|
||||
flushRaf()
|
||||
|
||||
expect(style.value.clipPath).toContain('polygon')
|
||||
expect(style.value.willChange).toBe('clip-path')
|
||||
})
|
||||
|
||||
it('does not read layout before RAF fires', () => {
|
||||
const { updateClipPath } = useDomClipping()
|
||||
const element = createMockElement({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 50,
|
||||
height: 50
|
||||
})
|
||||
const canvas = createMockCanvas({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 800,
|
||||
height: 600
|
||||
})
|
||||
|
||||
updateClipPath(element, canvas, true)
|
||||
|
||||
expect(element.getBoundingClientRect).not.toHaveBeenCalled()
|
||||
expect(canvas.getBoundingClientRect).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -85,8 +85,12 @@ export const useDomClipping = (options: ClippingOptions = {}) => {
|
||||
return ''
|
||||
}
|
||||
|
||||
let pendingRaf = 0
|
||||
|
||||
/**
|
||||
* Updates the clip-path style based on element and selection information
|
||||
* Updates the clip-path style based on element and selection information.
|
||||
* Batched via requestAnimationFrame to avoid forcing synchronous layout
|
||||
* from getBoundingClientRect() on every reactive state change.
|
||||
*/
|
||||
const updateClipPath = (
|
||||
element: HTMLElement,
|
||||
@@ -101,20 +105,24 @@ export const useDomClipping = (options: ClippingOptions = {}) => {
|
||||
offset: [number, number]
|
||||
}
|
||||
) => {
|
||||
const elementRect = element.getBoundingClientRect()
|
||||
const canvasRect = canvasElement.getBoundingClientRect()
|
||||
if (pendingRaf) cancelAnimationFrame(pendingRaf)
|
||||
pendingRaf = requestAnimationFrame(() => {
|
||||
pendingRaf = 0
|
||||
const elementRect = element.getBoundingClientRect()
|
||||
const canvasRect = canvasElement.getBoundingClientRect()
|
||||
|
||||
const clipPath = calculateClipPath(
|
||||
elementRect,
|
||||
canvasRect,
|
||||
isSelected,
|
||||
selectedArea
|
||||
)
|
||||
const clipPath = calculateClipPath(
|
||||
elementRect,
|
||||
canvasRect,
|
||||
isSelected,
|
||||
selectedArea
|
||||
)
|
||||
|
||||
style.value = {
|
||||
clipPath: clipPath || 'none',
|
||||
willChange: 'clip-path'
|
||||
}
|
||||
style.value = {
|
||||
clipPath: clipPath || 'none',
|
||||
willChange: 'clip-path'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user