diff --git a/.changeset/pagelayout-pane-resize-perf.md b/.changeset/pagelayout-pane-resize-perf.md new file mode 100644 index 00000000000..aed8f87d24a --- /dev/null +++ b/.changeset/pagelayout-pane-resize-perf.md @@ -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. diff --git a/packages/react/src/PageLayout/usePaneWidth.test.ts b/packages/react/src/PageLayout/usePaneWidth.test.ts index dca2ac2adb8..a0bffbf4616 100644 --- a/packages/react/src/PageLayout/usePaneWidth.test.ts +++ b/packages/react/src/PageLayout/usePaneWidth.test.ts @@ -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() }) @@ -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() @@ -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() @@ -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() }) @@ -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() @@ -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 @@ -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() @@ -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() }) @@ -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() }) @@ -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() @@ -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() }) @@ -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) diff --git a/packages/react/src/PageLayout/usePaneWidth.ts b/packages/react/src/PageLayout/usePaneWidth.ts index f0c12b57e11..00ec74d82da 100644 --- a/packages/react/src/PageLayout/usePaneWidth.ts +++ b/packages/react/src/PageLayout/usePaneWidth.ts @@ -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 @@ -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 | 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 @@ -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 @@ -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 | 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) }