diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index 29afbe4477a..8c1a4a4af5a 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -9,6 +9,7 @@ import {canUseDOM} from '../utils/environment' import {useOverflow} from '../hooks/useOverflow' import {warning} from '../utils/warning' import {getResponsiveAttributes} from '../internal/utils/getResponsiveAttributes' +import {usePaneWidth} from './usePaneWidth' import classes from './PageLayout.module.css' import type {FCWithSlotMarker, WithSlotMarker} from '../utils/types' @@ -172,25 +173,18 @@ const VerticalDivider: React.FC { - if (paneRef.current !== null) { - const paneStyles = getComputedStyle(paneRef.current as Element) - const maxPaneWidthDiffPixels = paneStyles.getPropertyValue('--pane-max-width-diff') - const minWidthPixels = paneStyles.getPropertyValue('--pane-min-width') - const paneWidth = paneRef.current.getBoundingClientRect().width - const maxPaneWidthDiff = Number(maxPaneWidthDiffPixels.split('px')[0]) - const minPaneWidth = Number(minWidthPixels.split('px')[0]) - const viewportWidth = window.innerWidth - const maxPaneWidth = viewportWidth > maxPaneWidthDiff ? viewportWidth - maxPaneWidthDiff : viewportWidth - setMinWidth(minPaneWidth) - setMaxWidth(maxPaneWidth) - setCurrentWidth(paneWidth || 0) - } - }, [paneRef, isKeyboardDrag, isDragging]) + currentWidthRef.current = currentWidth + }, [currentWidth]) React.useEffect(() => { stableOnDrag.current = onDrag @@ -215,14 +209,14 @@ const VerticalDivider: React.FC minWidth) { + if ((event.key === 'ArrowLeft' || event.key === 'ArrowDown') && currentWidthRef.current > minWidth) { delta = -3 - } else if ((event.key === 'ArrowRight' || event.key === 'ArrowUp') && currentWidth < maxWidth) { + } else if ((event.key === 'ArrowRight' || event.key === 'ArrowUp') && currentWidthRef.current < maxWidth) { delta = 3 } else { return } - setCurrentWidth(currentWidth + delta) + currentWidthRef.current = currentWidthRef.current + delta stableOnDrag.current?.(delta, true) event.preventDefault() } diff --git a/packages/react/src/PageLayout/__snapshots__/PageLayout.test.tsx.snap b/packages/react/src/PageLayout/__snapshots__/PageLayout.test.tsx.snap index 5ac82e6ca02..56a6806088c 100644 --- a/packages/react/src/PageLayout/__snapshots__/PageLayout.test.tsx.snap +++ b/packages/react/src/PageLayout/__snapshots__/PageLayout.test.tsx.snap @@ -57,7 +57,7 @@ exports[`PageLayout > renders condensed layout 1`] = ` />
Pane
@@ -149,7 +149,7 @@ exports[`PageLayout > renders default layout 1`] = ` />
Pane
@@ -243,7 +243,7 @@ exports[`PageLayout > renders pane in different position when narrow 1`] = ` />
Pane
@@ -337,7 +337,7 @@ exports[`PageLayout > renders with dividers 1`] = ` />
Pane
diff --git a/packages/react/src/PageLayout/usePaneWidth.test.tsx b/packages/react/src/PageLayout/usePaneWidth.test.tsx new file mode 100644 index 00000000000..cfd799c3550 --- /dev/null +++ b/packages/react/src/PageLayout/usePaneWidth.test.tsx @@ -0,0 +1,129 @@ +import {render, waitFor} from '@testing-library/react' +import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest' +import {usePaneWidth} from './usePaneWidth' +import {useRef} from 'react' + +describe('usePaneWidth', () => { + let mockPane: HTMLDivElement + + beforeEach(() => { + // Create a mock pane element + mockPane = document.createElement('div') + mockPane.style.setProperty('--pane-max-width-diff', '300px') + mockPane.style.setProperty('--pane-min-width', '256px') + + // Mock getBoundingClientRect + vi.spyOn(mockPane, 'getBoundingClientRect').mockReturnValue({ + width: 320, + height: 600, + top: 0, + left: 0, + right: 320, + bottom: 600, + x: 0, + y: 0, + toJSON: () => ({}), + }) + + // Mock window.innerWidth + Object.defineProperty(window, 'innerWidth', { + writable: true, + configurable: true, + value: 1024, + }) + + document.body.appendChild(mockPane) + }) + + afterEach(() => { + document.body.removeChild(mockPane) + vi.restoreAllMocks() + }) + + it('should calculate initial pane width metrics', async () => { + const TestComponent = () => { + const paneRef = useRef(mockPane) + const {minWidth, maxWidth, currentWidth} = usePaneWidth({paneRef}) + + return ( +
+ {minWidth} + {maxWidth} + {currentWidth} +
+ ) + } + + const {getByTestId} = render() + + await waitFor(() => { + expect(getByTestId('min').textContent).toBe('256') + expect(getByTestId('max').textContent).toBe('724') // 1024 - 300 + expect(getByTestId('current').textContent).toBe('320') + }) + }) + + it('should use requestAnimationFrame for window resize', async () => { + const rafSpy = vi.spyOn(window, 'requestAnimationFrame') + + const TestComponent = () => { + const paneRef = useRef(mockPane) + usePaneWidth({paneRef}) + return
Test
+ } + + render() + + // Trigger resize event + window.dispatchEvent(new Event('resize')) + + // Verify requestAnimationFrame was called + await waitFor(() => { + expect(rafSpy).toHaveBeenCalled() + }) + + rafSpy.mockRestore() + }) + + it('should throttle multiple resize events to one requestAnimationFrame', async () => { + const rafSpy = vi.spyOn(window, 'requestAnimationFrame') + + const TestComponent = () => { + const paneRef = useRef(mockPane) + usePaneWidth({paneRef}) + return
Test
+ } + + render() + + // Dispatch multiple resize events in quick succession + window.dispatchEvent(new Event('resize')) + window.dispatchEvent(new Event('resize')) + window.dispatchEvent(new Event('resize')) + + // Should only schedule one requestAnimationFrame + await waitFor(() => { + expect(rafSpy).toHaveBeenCalledTimes(1) + }) + + rafSpy.mockRestore() + }) + + it('should clean up event listeners on unmount', () => { + const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener') + + const TestComponent = () => { + const paneRef = useRef(mockPane) + usePaneWidth({paneRef}) + return
Test
+ } + + const {unmount} = render() + + unmount() + + expect(removeEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function)) + + removeEventListenerSpy.mockRestore() + }) +}) diff --git a/packages/react/src/PageLayout/usePaneWidth.ts b/packages/react/src/PageLayout/usePaneWidth.ts new file mode 100644 index 00000000000..a6a5dcc8354 --- /dev/null +++ b/packages/react/src/PageLayout/usePaneWidth.ts @@ -0,0 +1,130 @@ +import React from 'react' + +export interface PaneWidthMetrics { + minWidth: number + maxWidth: number + currentWidth: number +} + +export interface UsePaneWidthOptions { + paneRef: React.RefObject + isDragging?: boolean + isKeyboardDrag?: boolean +} + +/** + * Hook to manage pane width calculations with optimized window resize handling. + * Uses requestAnimationFrame for smooth visual updates aligned with browser paint cycle. + */ +export function usePaneWidth({paneRef, isDragging = false, isKeyboardDrag = false}: UsePaneWidthOptions) { + const [minWidth, setMinWidth] = React.useState(0) + const [maxWidth, setMaxWidth] = React.useState(0) + const [currentWidth, setCurrentWidth] = React.useState(0) + + // Refs to track pending updates + const rafIdRef = React.useRef(null) + const idleIdRef = React.useRef(null) + + const updateMetrics = React.useCallback(() => { + if (paneRef.current === null) return + + const paneStyles = getComputedStyle(paneRef.current as Element) + const maxPaneWidthDiffPixels = paneStyles.getPropertyValue('--pane-max-width-diff') + const minWidthPixels = paneStyles.getPropertyValue('--pane-min-width') + const paneWidth = paneRef.current.getBoundingClientRect().width + const maxPaneWidthDiff = Number(maxPaneWidthDiffPixels.split('px')[0]) + const minPaneWidth = Number(minWidthPixels.split('px')[0]) + const viewportWidth = window.innerWidth + const maxPaneWidth = viewportWidth > maxPaneWidthDiff ? viewportWidth - maxPaneWidthDiff : viewportWidth + + // Update CSS variable immediately for visual clamping - this prevents overflow during resize + paneRef.current.style.setProperty('--pane-max-width-visual', `${maxPaneWidth}px`) + + return { + minPaneWidth, + maxPaneWidth, + paneWidth: paneWidth || 0, + } + }, [paneRef]) + + const updateStateAndAria = React.useCallback( + (metrics: {minPaneWidth: number; maxPaneWidth: number; paneWidth: number}) => { + // Defer state updates to avoid blocking during active resize + // Use requestIdleCallback with fallback to setTimeout + const hasIdleCallback = typeof window !== 'undefined' && 'requestIdleCallback' in window + const idleCallback = hasIdleCallback + ? window.requestIdleCallback! + : (cb: IdleRequestCallback) => setTimeout(cb, 1) as unknown as number + + if (idleIdRef.current !== null) { + if (hasIdleCallback) { + window.cancelIdleCallback!(idleIdRef.current) + } else { + clearTimeout(idleIdRef.current) + } + } + + idleIdRef.current = idleCallback(() => { + setMinWidth(metrics.minPaneWidth) + setMaxWidth(metrics.maxPaneWidth) + setCurrentWidth(metrics.paneWidth) + idleIdRef.current = null + }) + }, + [], + ) + + // Initial setup and dragging state changes + React.useEffect(() => { + const metrics = updateMetrics() + if (metrics) { + // For initial mount and dragging state changes, update immediately + setMinWidth(metrics.minPaneWidth) + setMaxWidth(metrics.maxPaneWidth) + setCurrentWidth(metrics.paneWidth) + } + }, [paneRef, isKeyboardDrag, isDragging, updateMetrics]) + + // Window resize handler with requestAnimationFrame + React.useEffect(() => { + const handleResize = () => { + // Only schedule one animation frame at a time + if (rafIdRef.current !== null) return + + rafIdRef.current = requestAnimationFrame(() => { + const metrics = updateMetrics() + if (metrics) { + // Defer non-critical state updates + updateStateAndAria(metrics) + } + rafIdRef.current = null + }) + } + + // eslint-disable-next-line github/prefer-observers + window.addEventListener('resize', handleResize) + + return () => { + window.removeEventListener('resize', handleResize) + + // Cleanup pending animation frames + if (rafIdRef.current !== null) { + cancelAnimationFrame(rafIdRef.current) + rafIdRef.current = null + } + + // Cleanup pending idle callbacks + const hasIdleCallback = typeof window !== 'undefined' && 'requestIdleCallback' in window + if (idleIdRef.current !== null) { + if (hasIdleCallback) { + window.cancelIdleCallback!(idleIdRef.current) + } else { + clearTimeout(idleIdRef.current) + } + idleIdRef.current = null + } + } + }, [updateMetrics, updateStateAndAria]) + + return {minWidth, maxWidth, currentWidth} +}