Skip to content
Draft
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
54 changes: 41 additions & 13 deletions packages/react/src/PageLayout/usePaneWidth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PaneWidth, number> = {small: 256, medium: 296, large: 320}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<typeof setTimeout> | null = null

let isResizing = false

// Apply containment during resize to reduce layout thrashing on large DOMs
Expand All @@ -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)
}
Expand Down
Loading