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:
Christian Byrne
2026-02-26 18:53:14 -08:00
committed by GitHub
parent 0698ec23c0
commit 1054ba8949
2 changed files with 245 additions and 13 deletions

View 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()
})
})

View File

@@ -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 {