Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/pagelayout-pane-resize-perf.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': patch
---

Fix PageLayout pane resize lag during fast window resizes. During an active window-resize gesture the pane now updates CSS variables and ARIA attributes via a single `requestAnimationFrame` per frame (DOM only, no React state). A single `startTransition` commit is deferred until the gesture ends, eliminating the "slowly catching up" churn caused by stacking a React render request every ~16 ms.
71 changes: 57 additions & 14 deletions packages/react/src/PageLayout/usePaneWidth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -654,7 +654,7 @@ describe('usePaneWidth', () => {
}),
)

// Adds resize listener for throttled CSS updates and debounced state sync
// Adds resize listener for rAF-coalesced DOM updates and debounced state sync
expect(addEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function))
addEventListenerSpy.mockRestore()
})
Expand Down Expand Up @@ -700,7 +700,7 @@ describe('usePaneWidth', () => {
// Shrink viewport
vi.stubGlobal('innerWidth', 800)

// Wrap resize + throttle in act() since it triggers startTransition state update
// Wrap resize in act() since it eventually triggers startTransition state update (via debounce)
await act(async () => {
window.dispatchEvent(new Event('resize'))
await vi.runAllTimersAsync()
Expand All @@ -714,7 +714,7 @@ describe('usePaneWidth', () => {
vi.useRealTimers()
})

it('should throttle CSS variable update', async () => {
it('should update CSS variable via rAF on resize', async () => {
vi.useFakeTimers()
vi.stubGlobal('innerWidth', 1280)
const refs = createMockRefs()
Expand All @@ -735,11 +735,10 @@ describe('usePaneWidth', () => {
// Shrink viewport (crosses 1280 breakpoint, diff switches to 511)
vi.stubGlobal('innerWidth', 1000)

// Fire resize - with throttle, first update happens immediately (if THROTTLE_MS passed)
// Fire resize — the DOM update is coalesced via rAF
window.dispatchEvent(new Event('resize'))

// Since Date.now() starts at 0 and lastUpdateTime is 0, first update should happen immediately
// but it's in rAF, so we need to advance through rAF
// Advance through rAF and debounce
await act(async () => {
await vi.runAllTimersAsync()
})
Expand All @@ -750,7 +749,7 @@ describe('usePaneWidth', () => {
vi.useRealTimers()
})

it('should update ARIA attributes after throttle', async () => {
it('should update ARIA attributes via rAF on resize', async () => {
vi.useFakeTimers()
vi.stubGlobal('innerWidth', 1280)
const refs = createMockRefs()
Expand All @@ -771,7 +770,7 @@ describe('usePaneWidth', () => {
// Shrink viewport (crosses 1280 breakpoint, diff switches to 511)
vi.stubGlobal('innerWidth', 900)

// Fire resize - with throttle, update happens via rAF
// Fire resize — DOM update is coalesced via rAF
window.dispatchEvent(new Event('resize'))

// Wait for rAF to complete
Expand All @@ -785,7 +784,7 @@ describe('usePaneWidth', () => {
vi.useRealTimers()
})

it('should throttle full sync on rapid resize', async () => {
it('should coalesce rapid resize events into a single rAF update', async () => {
vi.useFakeTimers()
vi.stubGlobal('innerWidth', 1280)
const refs = createMockRefs()
Expand All @@ -809,7 +808,7 @@ describe('usePaneWidth', () => {
vi.stubGlobal('innerWidth', 1100)
window.dispatchEvent(new Event('resize'))

// With throttle, CSS should update immediately or via rAF
// With rAF coalescing, CSS should update once per animation frame
await act(async () => {
await vi.runAllTimersAsync()
})
Expand All @@ -820,13 +819,13 @@ describe('usePaneWidth', () => {
// Clear for next test
setPropertySpy.mockClear()

// Fire more resize events rapidly (within throttle window)
// Fire more resize events rapidly — all coalesced into a single pending rAF
for (let i = 0; i < 3; i++) {
vi.stubGlobal('innerWidth', 1000 - i * 50)
window.dispatchEvent(new Event('resize'))
}

// Should schedule via rAF
// Should coalesce all three events into one rAF + debounce
await act(async () => {
await vi.runAllTimersAsync()
})
Expand All @@ -838,7 +837,7 @@ describe('usePaneWidth', () => {
vi.useRealTimers()
})

it('should update React state via startTransition after throttle', async () => {
it('should update React state via startTransition once when resize gesture ends', async () => {
vi.useFakeTimers()
vi.stubGlobal('innerWidth', 1280)
const refs = createMockRefs()
Expand All @@ -860,7 +859,7 @@ describe('usePaneWidth', () => {
vi.stubGlobal('innerWidth', 800)
window.dispatchEvent(new Event('resize'))

// After throttle (via rAF), state updated via startTransition
// After the gesture ends (debounce fires), state is committed once via startTransition
await act(async () => {
await vi.runAllTimersAsync()
})
Expand All @@ -871,6 +870,50 @@ describe('usePaneWidth', () => {
vi.useRealTimers()
})

it('should not call startTransition during resize frames, only once at gesture end', async () => {
vi.useFakeTimers()
vi.stubGlobal('innerWidth', 1280)
const refs = createMockRefs()

renderHook(() =>
usePaneWidth({
width: 'medium',
minWidth: 256,
resizable: true,
widthStorageKey: 'test-no-transition-during-resize',
...refs,
}),
)

// Clear any startTransition calls from mount / initial render
startTransitionSpy.mockClear()

// Fire many resize events rapidly — simulates fast window resize
for (let i = 0; i < 5; i++) {
vi.stubGlobal('innerWidth', 1000 - i * 50)
window.dispatchEvent(new Event('resize'))
}

// Advance well past one rAF frame (~16ms) but before the 150ms debounce.
// This flushes DOM-only rAF callbacks without triggering the gesture-end commit.
await act(async () => {
await vi.advanceTimersByTimeAsync(149)
})

// No React commits should have happened during the resize frames
expect(startTransitionSpy).not.toHaveBeenCalled()

// Advance the remaining 1ms — fires the debounce, the single commit for the whole gesture
await act(async () => {
await vi.advanceTimersByTimeAsync(1)
})

// Exactly one startTransition call at gesture end
expect(startTransitionSpy).toHaveBeenCalledTimes(1)

vi.useRealTimers()
})

it('should skip startTransition when maxPaneWidth has not changed', async () => {
vi.useFakeTimers()
vi.stubGlobal('innerWidth', 900)
Expand Down
117 changes: 62 additions & 55 deletions packages/react/src/PageLayout/usePaneWidth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,10 @@ export function usePaneWidth({
// Track last maxPaneWidth to skip redundant startTransition calls on resize (see #7801)
const maxPaneWidthRef = React.useRef(maxPaneWidth)

// Pending values cached during window-resize gesture, flushed once at gesture end
const pendingMaxRef = React.useRef(maxPaneWidth)
const pendingClampedRef = React.useRef(false)

// Keep currentWidthRef in sync with state (ref is used during drag to avoid re-renders)
useIsomorphicLayoutEffect(() => {
currentWidthRef.current = currentWidth
Expand Down Expand Up @@ -330,14 +334,48 @@ export function usePaneWidth({
})

// Update CSS variable, refs, and ARIA on mount and window resize.
// Strategy: Only sync when resize stops (debounced) to avoid layout thrashing on large DOMs
// During resize: DOM-only updates per frame (no React state). Single React commit at gesture end.
useIsomorphicLayoutEffect(() => {
if (!resizable) return

let lastViewportWidth = window.innerWidth

// Full sync of refs, ARIA, and state (debounced, runs when resize stops)
const syncAll = () => {
// Initial calculation on mount — use viewport-based lookup to avoid
// getComputedStyle which forces a synchronous layout recalc on the
// freshly-committed DOM tree (measured at ~614ms on large pages).
maxWidthDiffRef.current = getMaxWidthDiffFromViewport()
const initialMax = getMaxPaneWidthRef.current()
maxPaneWidthRef.current = initialMax
pendingMaxRef.current = initialMax
setMaxPaneWidth(initialMax)
paneRef.current?.style.setProperty('--pane-max-width', `${initialMax}px`)
updateAriaValues(handleRef.current, {min: minPaneWidth, max: initialMax, current: currentWidthRef.current})

// For custom widths that aren't viewport-constrained, max is fixed - no need to listen to resize
if (customMaxWidth !== null && !constrainToViewport) return

const DEBOUNCE_MS = 150 // Delay before removing containment after resize stops
let rafId: number | null = null
let debounceId: ReturnType<typeof setTimeout> | null = null
let isResizing = false

const startResizeOptimizations = () => {
if (isResizing) return
isResizing = true
paneRef.current?.setAttribute('data-dragging', 'true')
contentWrapperRef.current?.setAttribute('data-dragging', 'true')
}

const endResizeOptimizations = () => {
if (!isResizing) return
isResizing = false
paneRef.current?.removeAttribute('data-dragging')
contentWrapperRef.current?.removeAttribute('data-dragging')
}

// DOM-only sync: updates CSS variables, refs, and ARIA attributes.
// No React state — safe to call on every animation frame during resize.
const syncDom = () => {
const currentViewportWidth = window.innerWidth

// Only update the cached diff value if we crossed the breakpoint
Expand All @@ -353,23 +391,32 @@ export function usePaneWidth({
}

const actualMax = getMaxPaneWidthRef.current()
pendingMaxRef.current = actualMax

// Update CSS variable for visual clamping (may already be set by throttled update)
// Update CSS variable for visual clamping
paneRef.current?.style.setProperty('--pane-max-width', `${actualMax}px`)

// Track if we clamped current width
const wasClamped = currentWidthRef.current > actualMax
if (wasClamped) {
// Clamp current width if it exceeds the new max
if (currentWidthRef.current > actualMax) {
currentWidthRef.current = actualMax
pendingClampedRef.current = true
paneRef.current?.style.setProperty('--pane-width', `${actualMax}px`)
}

// Update ARIA via DOM - cheap, no React re-render
updateAriaValues(handleRef.current, {max: actualMax, current: currentWidthRef.current})
}

// React state commit: flushes pending values into React state once per gesture.
// Called exactly once when the resize gesture ends (inside the debounce callback).
const commitToReact = () => {
const actualMax = pendingMaxRef.current
const wasClamped = pendingClampedRef.current
pendingClampedRef.current = false

// Only trigger React re-render if values actually changed.
// startTransition doesn't bail out on same-value updates like normal setState,
// so we guard explicitly to avoid unnecessary re-renders on every resize tick. (#7801)
// so we guard explicitly to avoid unnecessary re-renders. (#7801)
const maxChanged = actualMax !== maxPaneWidthRef.current
if (maxChanged || wasClamped) {
maxPaneWidthRef.current = actualMax
Expand All @@ -382,66 +429,26 @@ export function usePaneWidth({
}
}

// Initial calculation on mount — use viewport-based lookup to avoid
// getComputedStyle which forces a synchronous layout recalc on the
// freshly-committed DOM tree (measured at ~614ms on large pages).
maxWidthDiffRef.current = getMaxWidthDiffFromViewport()
const initialMax = getMaxPaneWidthRef.current()
maxPaneWidthRef.current = initialMax
setMaxPaneWidth(initialMax)
paneRef.current?.style.setProperty('--pane-max-width', `${initialMax}px`)
updateAriaValues(handleRef.current, {min: minPaneWidth, max: initialMax, current: currentWidthRef.current})

// For custom widths that aren't viewport-constrained, max is fixed - no need to listen to resize
if (customMaxWidth !== null && !constrainToViewport) return

// Throttle approach for window resize - provides immediate visual feedback for small DOMs
// while still limiting update frequency
const THROTTLE_MS = 16 // ~60fps
const DEBOUNCE_MS = 150 // Delay before removing containment after resize stops
let lastUpdateTime = 0
let pendingUpdate = false
let rafId: number | null = null
let debounceId: ReturnType<typeof setTimeout> | null = null
let isResizing = false

const startResizeOptimizations = () => {
if (isResizing) return
isResizing = true
paneRef.current?.setAttribute('data-dragging', 'true')
contentWrapperRef.current?.setAttribute('data-dragging', 'true')
}

const endResizeOptimizations = () => {
if (!isResizing) return
isResizing = false
paneRef.current?.removeAttribute('data-dragging')
contentWrapperRef.current?.removeAttribute('data-dragging')
}

const handleResize = () => {
// Apply containment on first resize event (stays applied until resize stops)
startResizeOptimizations()

const now = Date.now()
if (now - lastUpdateTime >= THROTTLE_MS) {
lastUpdateTime = now
syncAll()
} else if (!pendingUpdate) {
pendingUpdate = true
// Coalesce all resize events within a single animation frame into one DOM update.
// rAF already guarantees at most one call per frame — no separate Date.now() throttle needed.
if (rafId === null) {
rafId = requestAnimationFrame(() => {
pendingUpdate = false
rafId = null
lastUpdateTime = Date.now()
syncAll()
syncDom()
})
}

// Debounce the cleanup — remove containment after resize stops
// Debounce the gesture end: guarantee a final DOM sync and a single React commit
if (debounceId !== null) clearTimeout(debounceId)
debounceId = setTimeout(() => {
debounceId = null
endResizeOptimizations()
syncDom() // ensure the final frame matches the final viewport
commitToReact() // single React update for the whole gesture
}, DEBOUNCE_MS)
}

Expand Down