diff --git a/packages/react/src/PageLayout/usePaneWidth.ts b/packages/react/src/PageLayout/usePaneWidth.ts index 0b1dd7431d2..117300d5c31 100644 --- a/packages/react/src/PageLayout/usePaneWidth.ts +++ b/packages/react/src/PageLayout/usePaneWidth.ts @@ -67,6 +67,18 @@ export const SSR_DEFAULT_MAX_WIDTH = 600 */ export const ARROW_KEY_STEP = 3 +/** + * Throttle interval for CSS-only updates during resize (16ms = ~60fps). + * Provides immediate visual feedback while limiting update frequency. + */ +const RESIZE_THROTTLE_MS = 16 + +/** + * Debounce delay for full state sync during resize (150ms). + * Defers expensive operations (React state, ARIA, localStorage) until resize stops. + */ +const RESIZE_DEBOUNCE_MS = 150 + /** Default widths for preset size options */ export const defaultPaneWidth: Record = {small: 256, medium: 296, large: 320} @@ -211,12 +223,18 @@ 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 + // Strategy: Throttle CSS updates for immediate visual feedback, debounce full sync for when resize stops useIsomorphicLayoutEffect(() => { if (!resizable) return let lastViewportWidth = window.innerWidth + // CSS-only update for immediate visual feedback (throttled) + const updateCSSOnly = () => { + const actualMax = getMaxPaneWidthRef.current() + paneRef.current?.style.setProperty('--pane-max-width', `${actualMax}px`) + } + // Full sync of refs, ARIA, and state (debounced, runs when resize stops) const syncAll = () => { const currentViewportWidth = window.innerWidth @@ -267,12 +285,13 @@ export function usePaneWidth({ // For custom widths, max is fixed - no need to listen to resize if (customMaxWidth !== null) return - // Throttle approach for window resize - provides immediate visual feedback for small DOMs - // while still limiting update frequency - const THROTTLE_MS = 16 // ~60fps + // Throttle for CSS updates - provides immediate visual feedback let lastUpdateTime = 0 - let pendingUpdate = false let rafId: number | null = null + + // Debounce for full state sync - defers expensive operations until resize stops + let debounceId: ReturnType | null = null + let isResizing = false // Apply containment during resize to reduce layout thrashing on large DOMs @@ -295,26 +314,35 @@ export function usePaneWidth({ startResizeOptimizations() const now = Date.now() - if (now - lastUpdateTime >= THROTTLE_MS) { + + // Throttled CSS-only update for immediate visual feedback + if (now - lastUpdateTime >= RESIZE_THROTTLE_MS) { lastUpdateTime = now - syncAll() - endResizeOptimizations() - } else if (!pendingUpdate) { - pendingUpdate = true + updateCSSOnly() + } else if (rafId === null) { rafId = requestAnimationFrame(() => { - pendingUpdate = false rafId = null lastUpdateTime = Date.now() - syncAll() - endResizeOptimizations() + updateCSSOnly() }) } + + // Debounced full sync (state, ARIA, cleanup) when resize stops + if (debounceId !== null) { + clearTimeout(debounceId) + } + debounceId = setTimeout(() => { + debounceId = null + syncAll() + endResizeOptimizations() + }, RESIZE_DEBOUNCE_MS) } // eslint-disable-next-line github/prefer-observers -- Uses window resize events instead of ResizeObserver to avoid INP issues. ResizeObserver on document.documentElement fires on any content change (typing, etc), while window resize only fires on actual viewport changes. window.addEventListener('resize', handleResize) return () => { if (rafId !== null) cancelAnimationFrame(rafId) + if (debounceId !== null) clearTimeout(debounceId) endResizeOptimizations() window.removeEventListener('resize', handleResize) }