From 1054ba894955fbefcbb35b89bbe2ae3a12b14499 Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Thu, 26 Feb 2026 18:53:14 -0800 Subject: [PATCH] fix: batch updateClipPath via requestAnimationFrame (#9173) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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. ┆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) --- .../element/useDomClipping.test.ts | 224 ++++++++++++++++++ src/composables/element/useDomClipping.ts | 34 ++- 2 files changed, 245 insertions(+), 13 deletions(-) create mode 100644 src/composables/element/useDomClipping.test.ts diff --git a/src/composables/element/useDomClipping.test.ts b/src/composables/element/useDomClipping.test.ts new file mode 100644 index 0000000000..0900d0d24b --- /dev/null +++ b/src/composables/element/useDomClipping.test.ts @@ -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 + 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() + }) +}) diff --git a/src/composables/element/useDomClipping.ts b/src/composables/element/useDomClipping.ts index 7dcc31fb60..60bd1ce3eb 100644 --- a/src/composables/element/useDomClipping.ts +++ b/src/composables/element/useDomClipping.ts @@ -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 {