diff --git a/.changeset/optimized-page-layout-resize.md b/.changeset/optimized-page-layout-resize.md new file mode 100644 index 00000000000..6584fae7ebc --- /dev/null +++ b/.changeset/optimized-page-layout-resize.md @@ -0,0 +1,17 @@ +--- +"@primer/react": patch +--- + +PageLayout: Optimize drag/resize performance with inline styles and new optimizations + +**Refactored:** +- Use direct attribute selectors (`.Pane[data-dragging='true']`) instead of descendant selectors for CSS containment (O(1) vs O(n) selector matching) +- Extract optimization utilities to `paneUtils.ts` +- Apply drag handle visual feedback via inline styles and CSS variables + +**Added:** +- `content-visibility: auto` during drag/resize to skip off-screen content rendering +- rAF throttle for drag updates (one update per frame, latest position wins) +- Containment during window resize (parity with drag) + +These changes improve style recalculation performance on large DOMs (100k+ nodes) by eliminating descendant selector traversal. diff --git a/packages/react/src/PageLayout/PageLayout.module.css b/packages/react/src/PageLayout/PageLayout.module.css index 079d34f6cff..7c600dc3968 100644 --- a/packages/react/src/PageLayout/PageLayout.module.css +++ b/packages/react/src/PageLayout/PageLayout.module.css @@ -381,26 +381,6 @@ } } -/** - * OPTIMIZATION: Aggressive containment during drag for ContentWrapper - * data-dragging is set on PageLayoutContent by JavaScript - * This avoids expensive :has() selectors - */ -.PageLayoutContent[data-dragging='true'] .ContentWrapper { - /* Add paint containment during drag - safe since user can't interact */ - contain: layout style paint; - - /* Disable interactions */ - pointer-events: none; - - /* Disable transitions to prevent expensive recalculations */ - transition: none; - - /* Force compositor layer for hardware acceleration */ - will-change: width; - transform: translateZ(0); -} - .Content { width: 100%; @@ -427,16 +407,6 @@ } } -/** - * OPTIMIZATION: Freeze content layout during resize drag - * This prevents expensive recalculations of large content areas - * while keeping content visible (just frozen in place) - */ -.PageLayoutContent[data-dragging='true'] .Content { - /* Full containment (without size) - isolate from layout recalculations */ - contain: layout style paint; -} - .PaneWrapper { display: flex; width: 100%; @@ -630,27 +600,6 @@ } } -/** - * OPTIMIZATION: Performance enhancements for Pane during drag - * data-dragging is set on PageLayoutContent by JavaScript - * This avoids expensive :has() selectors - */ -.PageLayoutContent[data-dragging='true'] .Pane { - /* Full containment - isolate from layout recalculations */ - contain: layout style paint; - - /* Disable interactions during drag */ - pointer-events: none; - - /* Disable transitions during drag */ - transition: none; - - /* Force hardware acceleration */ - will-change: width, transform; - transform: translateZ(0); - backface-visibility: hidden; -} - .PaneHorizontalDivider { &:where([data-position='start']) { /* stylelint-disable-next-line primer/spacing */ @@ -756,26 +705,36 @@ position: absolute; inset: 0 -2px; cursor: col-resize; - background-color: transparent; - transition-delay: 0.1s; - /** - * OPTIMIZATION: Prevent touch scrolling and text selection during drag - * This is done in CSS because it needs to be set before any pointer events - */ + /* Prevent touch scrolling and text selection during drag */ touch-action: none; user-select: none; } -.DraggableHandle:hover { - background-color: var(--bgColor-neutral-muted); +.DraggableHandle::before { + content: ''; + position: absolute; + inset: 0; + /* stylelint-disable-next-line primer/colors */ + background-color: var(--draggable-handle--bg-color, var(--bgColor-neutral-muted)); + opacity: var(--draggable-handle--drag-opacity, 0); + transition: var(--draggable-handle--transition, opacity 150ms ease); /* compositor-friendly, disabled during drag */ + border-radius: inherit; /* optional if you need rounded corners */ } -.DraggableHandle[data-dragging='true'] { - background-color: var(--bgColor-accent-emphasis); - cursor: col-resize; +/* Hover effect */ +.DraggableHandle:hover::before { + opacity: 1; } -.DraggableHandle[data-dragging='true']:hover { - background-color: var(--bgColor-accent-emphasis); +/** + * OPTIMIZATION: CSS containment during drag/resize + * Direct attribute selectors are O(1) - only the attributed element is invalidated + * (Unlike descendant selectors which require O(n) traversal) + */ +.Pane[data-dragging='true'], +.ContentWrapper[data-dragging='true'] { + contain: layout style paint; + pointer-events: none; + content-visibility: auto; } diff --git a/packages/react/src/PageLayout/PageLayout.test.tsx b/packages/react/src/PageLayout/PageLayout.test.tsx index 6ca0100ad79..52bc50c9874 100644 --- a/packages/react/src/PageLayout/PageLayout.test.tsx +++ b/packages/react/src/PageLayout/PageLayout.test.tsx @@ -165,8 +165,8 @@ describe('PageLayout', async () => { ) const placeholder = await screen.findByText('Pane') - const pane = placeholder.parentNode - const initialWidth = (pane as HTMLElement).style.getPropertyValue('--pane-width') + const pane = placeholder.parentNode as HTMLElement | null + const initialWidth = pane?.style.getPropertyValue('--pane-width') const divider = await screen.findByRole('slider') // Moving divider should resize pane. @@ -176,11 +176,11 @@ describe('PageLayout', async () => { fireEvent.keyDown(divider, {key: 'ArrowRight'}) fireEvent.keyDown(divider, {key: 'ArrowRight'}) - const finalWidth = (pane as HTMLElement).style.getPropertyValue('--pane-width') + const finalWidth = pane?.style.getPropertyValue('--pane-width') expect(finalWidth).not.toEqual(initialWidth) }) - it('should set data-dragging attribute during pointer drag', async () => { + it('should set optimization styles during pointer drag', async () => { const {container} = render( @@ -192,22 +192,21 @@ describe('PageLayout', async () => { , ) - const content = container.querySelector('[class*="PageLayoutContent"]') + const contentWrapper = container.querySelector('[class*="ContentWrapper"]') const divider = await screen.findByRole('slider') // Before drag - no data-dragging attribute - expect(content).not.toHaveAttribute('data-dragging') + expect(contentWrapper).not.toHaveAttribute('data-dragging') - // Start drag + // Start drag - optimization attribute is set fireEvent.pointerDown(divider, {clientX: 300, clientY: 200, pointerId: 1}) - expect(content).toHaveAttribute('data-dragging', 'true') - - // End drag - pointer capture lost ends the drag and removes attribute + expect(contentWrapper).toHaveAttribute('data-dragging', 'true') + // End drag - pointer capture lost ends the drag and removes optimization attribute fireEvent.lostPointerCapture(divider, {pointerId: 1}) - expect(content).not.toHaveAttribute('data-dragging') + expect(contentWrapper).not.toHaveAttribute('data-dragging') }) - it('should set data-dragging attribute during keyboard resize', async () => { + it('should set optimization styles during keyboard resize', async () => { const {container} = render( @@ -219,20 +218,46 @@ describe('PageLayout', async () => { , ) - const content = container.querySelector('[class*="PageLayoutContent"]') + const contentWrapper = container.querySelector('[class*="ContentWrapper"]') const divider = await screen.findByRole('slider') // Before interaction - no data-dragging attribute - expect(content).not.toHaveAttribute('data-dragging') + expect(contentWrapper).not.toHaveAttribute('data-dragging') // Start keyboard resize (focus first) fireEvent.focus(divider) fireEvent.keyDown(divider, {key: 'ArrowRight'}) - expect(content).toHaveAttribute('data-dragging', 'true') + expect(contentWrapper).toHaveAttribute('data-dragging', 'true') - // End keyboard resize - removes attribute + // End keyboard resize - removes optimization attribute fireEvent.keyUp(divider, {key: 'ArrowRight'}) - expect(content).not.toHaveAttribute('data-dragging') + expect(contentWrapper).not.toHaveAttribute('data-dragging') + }) + + it('should not add will-change during drag', async () => { + const {container} = render( + + + + + + + + , + ) + + const pane = container.querySelector('[class*="Pane"][data-resizable]') + const divider = await screen.findByRole('slider') + + // Before drag - no will-change + expect(pane!.style.willChange).toBe('') + + // Start drag - will-change should still not be set (removed optimization) + fireEvent.pointerDown(divider, {clientX: 300, clientY: 200, pointerId: 1}) + expect(pane!.style.willChange).toBe('') + // End drag - will-change remains unset + fireEvent.lostPointerCapture(divider, {pointerId: 1}) + expect(pane!.style.willChange).toBe('') }) }) diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index 901db1c5120..753e353ae95 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -1,4 +1,4 @@ -import React, {useRef} from 'react' +import React, {memo, useRef} from 'react' import {clsx} from 'clsx' import {useId} from '../hooks/useId' import {useRefObjectAsForwardedRef} from '../hooks/useRefObjectAsForwardedRef' @@ -20,6 +20,7 @@ import { type CustomWidthOptions, type PaneWidth, } from './usePaneWidth' +import {setDraggingStyles, removeDraggingStyles} from './paneUtils' const REGION_ORDER = { header: 0, @@ -29,6 +30,10 @@ const REGION_ORDER = { footer: 4, } +const isArrowKey = (key: string) => + key === 'ArrowLeft' || key === 'ArrowRight' || key === 'ArrowUp' || key === 'ArrowDown' +const isShrinkKey = (key: string) => key === 'ArrowLeft' || key === 'ArrowDown' + // eslint-disable-next-line @typescript-eslint/no-unused-vars const SPACING_MAP = { none: 0, @@ -41,13 +46,13 @@ const PageLayoutContext = React.createContext<{ rowGap: keyof typeof SPACING_MAP columnGap: keyof typeof SPACING_MAP paneRef: React.RefObject - contentRef: React.RefObject + contentWrapperRef: React.RefObject }>({ padding: 'normal', rowGap: 'normal', columnGap: 'normal', paneRef: {current: null}, - contentRef: {current: null}, + contentWrapperRef: {current: null}, }) // ---------------------------------------------------------------------------- @@ -87,7 +92,7 @@ const Root: React.FC> = ({ _slotsConfig: slotsConfig, }) => { const paneRef = useRef(null) - const contentRef = useRef(null) + const contentWrapperRef = useRef(null) const [slots, rest] = useSlots(children, slotsConfig ?? {header: Header, footer: Footer}) @@ -97,12 +102,31 @@ const Root: React.FC> = ({ rowGap, columnGap, paneRef, - contentRef, + contentWrapperRef, } - }, [padding, rowGap, columnGap, paneRef, contentRef]) + }, [padding, rowGap, columnGap, paneRef, contentWrapperRef]) return ( + +
+ {slots.header} +
{rest}
+ {slots.footer} +
+
+
+ ) +} + +const RootWrapper = memo( + ({ + style, + padding, + children, + className, + }: React.PropsWithChildren>) => { + return (
> = ({ } className={clsx(classes.PageLayoutRoot, className)} > -
- {slots.header} -
- {rest} -
- {slots.footer} -
+ {children}
- - ) -} + ) + }, +) Root.displayName = 'PageLayout' @@ -136,51 +154,48 @@ type DividerProps = { position?: keyof typeof panePositions | ResponsiveValue } -const HorizontalDivider: React.FC> = ({ - variant = 'none', - className, - position, - style, -}) => { - const {padding} = React.useContext(PageLayoutContext) +const HorizontalDivider = memo>( + ({variant = 'none', className, position, style}) => { + const {padding} = React.useContext(PageLayoutContext) - return ( -
- ) -} + return ( +
+ ) + }, +) + +HorizontalDivider.displayName = 'HorizontalDivider' type VerticalDividerProps = DividerProps & { draggable?: boolean } -const VerticalDivider: React.FC> = ({ - variant = 'none', - position, - className, - style, - children, -}) => { - return ( -
- {children} -
- ) -} +const VerticalDivider = memo>( + ({variant = 'none', position, className, style, children}) => { + return ( +
+ {children} +
+ ) + }, +) + +VerticalDivider.displayName = 'VerticalDivider' type DragHandleProps = { /** Ref for imperative ARIA updates during drag */ @@ -201,17 +216,12 @@ type DragHandleProps = { 'aria-valuenow'?: number } -const DATA_DRAGGING_ATTR = 'data-dragging' -const isDragging = (handle: HTMLElement | null) => { - return handle?.getAttribute(DATA_DRAGGING_ATTR) === 'true' -} - /** * DragHandle - handles all pointer and keyboard interactions for resizing * ARIA values are set in JSX for SSR accessibility, * then updated via DOM manipulation during drag for performance */ -const DragHandle: React.FC = ({ +const DragHandle = memo(function DragHandle({ handleRef, onDragStart, onDrag, @@ -220,7 +230,7 @@ const DragHandle: React.FC = ({ 'aria-valuemin': ariaValueMin, 'aria-valuemax': ariaValueMax, 'aria-valuenow': ariaValueNow, -}) => { +}) { const stableOnDragStart = React.useRef(onDragStart) const stableOnDrag = React.useRef(onDrag) const stableOnDragEnd = React.useRef(onDragEnd) @@ -230,79 +240,106 @@ const DragHandle: React.FC = ({ stableOnDragEnd.current = onDragEnd }) - const {paneRef, contentRef} = React.useContext(PageLayoutContext) - - // Helper to set/remove dragging attribute on content wrapper - // This avoids expensive :has() selectors - CSS uses simple descendant selectors instead - const setDragging = React.useCallback( - (dragging: boolean) => { - if (dragging) { - handleRef.current?.setAttribute(DATA_DRAGGING_ATTR, 'true') - contentRef.current?.setAttribute(DATA_DRAGGING_ATTR, 'true') - } else { - handleRef.current?.removeAttribute(DATA_DRAGGING_ATTR) - contentRef.current?.removeAttribute(DATA_DRAGGING_ATTR) - } - }, - [handleRef, contentRef], - ) + const {paneRef, contentWrapperRef} = React.useContext(PageLayoutContext) + + // Dragging state as a ref - cheaper than reading from DOM style + const isDraggingRef = React.useRef(false) + + // Set inline styles for drag optimizations - zero overhead at rest + const startDragging = React.useCallback(() => { + if (isDraggingRef.current) return + setDraggingStyles({ + handle: handleRef.current, + pane: paneRef.current, + contentWrapper: contentWrapperRef.current, + }) + isDraggingRef.current = true + }, [handleRef, contentWrapperRef, paneRef]) + + const endDragging = React.useCallback(() => { + if (!isDraggingRef.current) return + removeDraggingStyles({ + handle: handleRef.current, + pane: paneRef.current, + contentWrapper: contentWrapperRef.current, + }) + isDraggingRef.current = false + }, [handleRef, contentWrapperRef, paneRef]) /** * Pointer down starts a drag operation * Capture the pointer to continue receiving events outside the handle area - * Set a data attribute to indicate dragging state */ const handlePointerDown = React.useCallback( (event: React.PointerEvent) => { if (event.button !== 0) return event.preventDefault() const target = event.currentTarget - target.setPointerCapture(event.pointerId) + // Try to capture pointer - may fail in test environments or if pointer is already released + try { + target.setPointerCapture(event.pointerId) + } catch { + // Ignore - pointer capture is a nice-to-have for dragging outside the element + } stableOnDragStart.current(event.clientX) - setDragging(true) + startDragging() }, - [setDragging], + [startDragging], ) + // Simple rAF throttle - one update per frame, latest position wins + const rafIdRef = React.useRef(null) + const pendingClientXRef = React.useRef(null) + /** * Pointer move during drag * Calls onDrag with absolute cursor X position - * Using absolute position avoids drift from accumulated deltas - * Prevents default to avoid unwanted selection behavior + * Uses rAF to coalesce updates to one per frame */ - const handlePointerMove = React.useCallback( - (event: React.PointerEvent) => { - if (!isDragging(handleRef.current)) return - event.preventDefault() - - stableOnDrag.current(event.clientX, false) - }, - [handleRef], - ) + const handlePointerMove = React.useCallback((event: React.PointerEvent) => { + if (!isDraggingRef.current) return + event.preventDefault() + + // Store latest position - only the final position before rAF fires matters + pendingClientXRef.current = event.clientX + + // Schedule update if not already scheduled + if (rafIdRef.current === null) { + rafIdRef.current = requestAnimationFrame(() => { + rafIdRef.current = null + if (pendingClientXRef.current !== null) { + stableOnDrag.current(pendingClientXRef.current, false) + pendingClientXRef.current = null + } + }) + } + }, []) /** - * Pointer up ends a drag operation - * Prevents default to avoid unwanted selection behavior + * Pointer up - cleanup is handled by onLostPointerCapture event + * which fires when pointer capture is released (including on pointerup) */ - const handlePointerUp = React.useCallback( - (event: React.PointerEvent) => { - if (!isDragging(handleRef.current)) return - event.preventDefault() - // Cleanup will happen in onLostPointerCapture - }, - [handleRef], - ) + const handlePointerUp = React.useCallback((event: React.PointerEvent) => { + if (!isDraggingRef.current) return + event.preventDefault() + }, []) /** * Lost pointer capture ends a drag operation - * Cleans up dragging state + * Cleans up dragging state and cancels any pending rAF * Calls onDragEnd callback */ const handleLostPointerCapture = React.useCallback(() => { - if (!isDragging(handleRef.current)) return - setDragging(false) + if (!isDraggingRef.current) return + // Cancel any pending rAF to prevent stale updates + if (rafIdRef.current !== null) { + cancelAnimationFrame(rafIdRef.current) + rafIdRef.current = null + pendingClientXRef.current = null + } + endDragging() stableOnDragEnd.current() - }, [handleRef, setDragging]) + }, [endDragging]) /** * Keyboard handling for accessibility @@ -313,42 +350,41 @@ const DragHandle: React.FC = ({ */ const handleKeyDown = React.useCallback( (event: React.KeyboardEvent) => { - if ( - event.key === 'ArrowLeft' || - event.key === 'ArrowRight' || - event.key === 'ArrowUp' || - event.key === 'ArrowDown' - ) { - event.preventDefault() - - if (!paneRef.current) return + if (!isArrowKey(event.key)) return + event.preventDefault() - // https://github.com/github/accessibility/issues/5101#issuecomment-1822870655 - const delta = event.key === 'ArrowLeft' || event.key === 'ArrowDown' ? -ARROW_KEY_STEP : ARROW_KEY_STEP + // https://github.com/github/accessibility/issues/5101#issuecomment-1822870655 + const delta = isShrinkKey(event.key) ? -ARROW_KEY_STEP : ARROW_KEY_STEP - setDragging(true) - stableOnDrag.current(delta, true) + // Only set dragging on first keydown (not repeats) + if (!isDraggingRef.current) { + startDragging() } + stableOnDrag.current(delta, true) }, - [paneRef, setDragging], + [startDragging], ) const handleKeyUp = React.useCallback( (event: React.KeyboardEvent) => { - if ( - event.key === 'ArrowLeft' || - event.key === 'ArrowRight' || - event.key === 'ArrowUp' || - event.key === 'ArrowDown' - ) { - event.preventDefault() - setDragging(false) - stableOnDragEnd.current() - } + if (!isArrowKey(event.key)) return + event.preventDefault() + endDragging() + stableOnDragEnd.current() }, - [setDragging], + [endDragging], ) + // Cleanup rAF on unmount to prevent stale callbacks + React.useEffect(() => { + return () => { + if (rafIdRef.current !== null) { + cancelAnimationFrame(rafIdRef.current) + rafIdRef.current = null + } + } + }, []) + return (
= ({ onDoubleClick={onDoubleClick} /> ) -} +}) // ---------------------------------------------------------------------------- // PageLayout.Header @@ -461,7 +497,6 @@ const Header: FCWithSlotMarker> = ) } - Header.displayName = 'PageLayout.Header' // ---------------------------------------------------------------------------- @@ -511,9 +546,11 @@ const Content: FCWithSlotMarker> style, }) => { const Component = as + const {contentWrapperRef} = React.useContext(PageLayoutContext) return ( > ) } - Content.displayName = 'PageLayout.Content' // ---------------------------------------------------------------------------- @@ -637,7 +673,7 @@ const Pane = React.forwardRef(null) @@ -657,6 +693,7 @@ const Pane = React.forwardRef> = ) } - Footer.displayName = 'PageLayout.Footer' // ---------------------------------------------------------------------------- diff --git a/packages/react/src/PageLayout/paneUtils.ts b/packages/react/src/PageLayout/paneUtils.ts new file mode 100644 index 00000000000..a33f8bbc010 --- /dev/null +++ b/packages/react/src/PageLayout/paneUtils.ts @@ -0,0 +1,31 @@ +type DraggingStylesParams = { + handle: HTMLElement | null + pane: HTMLElement | null + contentWrapper: HTMLElement | null +} + +const DATA_DRAGGING_ATTR = 'data-dragging' + +/** Apply visual feedback and performance optimizations during drag */ +export function setDraggingStyles({handle, pane, contentWrapper}: DraggingStylesParams) { + // Handle visual feedback (must be inline for instant response) + // Use CSS variable to control ::before pseudo-element background color. + // This avoids cascade conflicts between inline styles and pseudo-element backgrounds. + handle?.style.setProperty('--draggable-handle--bg-color', 'var(--bgColor-accent-emphasis)') + handle?.style.setProperty('--draggable-handle--drag-opacity', '1') + handle?.style.setProperty('--draggable-handle--transition', 'none') + + // Set attribute for CSS containment (O(1) direct selector, not descendant) + pane?.setAttribute(DATA_DRAGGING_ATTR, 'true') + contentWrapper?.setAttribute(DATA_DRAGGING_ATTR, 'true') +} + +/** Remove drag styles and restore normal state */ +export function removeDraggingStyles({handle, pane, contentWrapper}: DraggingStylesParams) { + handle?.style.removeProperty('--draggable-handle--bg-color') + handle?.style.removeProperty('--draggable-handle--drag-opacity') + handle?.style.removeProperty('--draggable-handle--transition') + + pane?.removeAttribute(DATA_DRAGGING_ATTR) + contentWrapper?.removeAttribute(DATA_DRAGGING_ATTR) +} diff --git a/packages/react/src/PageLayout/usePaneWidth.test.ts b/packages/react/src/PageLayout/usePaneWidth.test.ts index a529d0b7367..570fb6c47ed 100644 --- a/packages/react/src/PageLayout/usePaneWidth.test.ts +++ b/packages/react/src/PageLayout/usePaneWidth.test.ts @@ -17,6 +17,7 @@ import { const createMockRefs = () => ({ paneRef: {current: document.createElement('div')} as React.RefObject, handleRef: {current: document.createElement('div')} as React.RefObject, + contentWrapperRef: {current: document.createElement('div')} as React.RefObject, }) describe('usePaneWidth', () => { @@ -399,10 +400,10 @@ describe('usePaneWidth', () => { // Shrink viewport vi.stubGlobal('innerWidth', 800) - // Wrap resize + debounce in act() since it triggers startTransition state update + // Wrap resize + throttle in act() since it triggers startTransition state update await act(async () => { window.dispatchEvent(new Event('resize')) - await vi.advanceTimersByTimeAsync(150) + await vi.runAllTimersAsync() }) // getMaxPaneWidth now returns 800 - 511 = 289 @@ -413,7 +414,7 @@ describe('usePaneWidth', () => { vi.useRealTimers() }) - it('should update CSS variable immediately via throttle', async () => { + it('should throttle CSS variable update', async () => { vi.useFakeTimers() vi.stubGlobal('innerWidth', 1280) const refs = createMockRefs() @@ -434,16 +435,22 @@ describe('usePaneWidth', () => { // Shrink viewport vi.stubGlobal('innerWidth', 1000) - // Fire resize - CSS should update immediately (throttled at 16ms) + // Fire resize - with throttle, first update happens immediately (if THROTTLE_MS passed) window.dispatchEvent(new Event('resize')) - // CSS variable should be updated immediately: 1000 - 511 = 489 + // 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 + await act(async () => { + await vi.runAllTimersAsync() + }) + + // CSS variable should now be updated: 1000 - 511 = 489 expect(refs.paneRef.current?.style.getPropertyValue('--pane-max-width')).toBe('489px') vi.useRealTimers() }) - it('should update ARIA attributes after debounce', async () => { + it('should update ARIA attributes after throttle', async () => { vi.useFakeTimers() vi.stubGlobal('innerWidth', 1280) const refs = createMockRefs() @@ -453,7 +460,7 @@ describe('usePaneWidth', () => { width: 'medium', minWidth: 256, resizable: true, - widthStorageKey: 'test-aria-debounce', + widthStorageKey: 'test-aria-throttle', ...refs, }), ) @@ -464,16 +471,12 @@ describe('usePaneWidth', () => { // Shrink viewport vi.stubGlobal('innerWidth', 900) - // Fire resize but don't wait for debounce + // Fire resize - with throttle, update happens via rAF window.dispatchEvent(new Event('resize')) - await vi.advanceTimersByTimeAsync(50) - // ARIA should NOT be updated yet - expect(refs.handleRef.current?.getAttribute('aria-valuemax')).toBe('769') - - // Wait for debounce + // Wait for rAF to complete await act(async () => { - await vi.advanceTimersByTimeAsync(100) + await vi.runAllTimersAsync() }) // ARIA should now be updated: 900 - 511 = 389 @@ -482,7 +485,7 @@ describe('usePaneWidth', () => { vi.useRealTimers() }) - it('should throttle CSS updates and debounce full sync on rapid resize', async () => { + it('should throttle full sync on rapid resize', async () => { vi.useFakeTimers() vi.stubGlobal('innerWidth', 1280) const refs = createMockRefs() @@ -494,7 +497,7 @@ describe('usePaneWidth', () => { width: 'medium', minWidth: 256, resizable: true, - widthStorageKey: 'test-throttle-debounce', + widthStorageKey: 'test-throttle', ...refs, }), ) @@ -502,13 +505,19 @@ describe('usePaneWidth', () => { // Clear mount calls setPropertySpy.mockClear() - // Fire resize - first one updates CSS immediately + // Fire resize events rapidly vi.stubGlobal('innerWidth', 1100) window.dispatchEvent(new Event('resize')) - // CSS should update immediately (first call, throttle allows) - expect(setPropertySpy).toHaveBeenCalledWith('--pane-max-width', '589px') // 1100 - 511 + // With throttle, CSS should update immediately or via rAF + await act(async () => { + await vi.runAllTimersAsync() + }) + + // First update should have happened: 1100 - 511 = 589 + expect(setPropertySpy).toHaveBeenCalledWith('--pane-max-width', '589px') + // Clear for next test setPropertySpy.mockClear() // Fire more resize events rapidly (within throttle window) @@ -517,28 +526,19 @@ describe('usePaneWidth', () => { window.dispatchEvent(new Event('resize')) } - // Throttle limits calls - may have scheduled RAF but not executed yet - // Advance past throttle window to let RAF execute - await vi.advanceTimersByTimeAsync(20) - - // Should have at least one more CSS update from RAF - expect(setPropertySpy).toHaveBeenCalled() - - // But ARIA should not be updated yet (debounced) - expect(refs.handleRef.current?.getAttribute('aria-valuemax')).toBe('769') // Still initial - - // Wait for debounce to complete + // Should schedule via rAF await act(async () => { - await vi.advanceTimersByTimeAsync(150) + await vi.runAllTimersAsync() }) - // Now ARIA and refs are synced - expect(refs.handleRef.current?.getAttribute('aria-valuemax')).toBe('389') // 900 - 511 + // Now CSS and ARIA should be synced with final viewport value (900) + expect(setPropertySpy).toHaveBeenCalledWith('--pane-max-width', '389px') // 900 - 511 + expect(refs.handleRef.current?.getAttribute('aria-valuemax')).toBe('389') vi.useRealTimers() }) - it('should update React state via startTransition after debounce', async () => { + it('should update React state via startTransition after throttle', async () => { vi.useFakeTimers() vi.stubGlobal('innerWidth', 1280) const refs = createMockRefs() @@ -560,13 +560,9 @@ describe('usePaneWidth', () => { vi.stubGlobal('innerWidth', 800) window.dispatchEvent(new Event('resize')) - // Before debounce completes, state unchanged - await vi.advanceTimersByTimeAsync(50) - expect(result.current.maxPaneWidth).toBe(769) - - // After debounce, state updated via startTransition + // After throttle (via rAF), state updated via startTransition await act(async () => { - await vi.advanceTimersByTimeAsync(100) + await vi.runAllTimersAsync() }) // State now reflects new max: 800 - 511 = 289 @@ -615,6 +611,86 @@ describe('usePaneWidth', () => { expect(addEventListenerSpy).not.toHaveBeenCalledWith('resize', expect.any(Function)) addEventListenerSpy.mockRestore() }) + + it('should apply and remove containment attributes during resize', async () => { + vi.useFakeTimers() + vi.stubGlobal('innerWidth', 1280) + const refs = createMockRefs() + + renderHook(() => + usePaneWidth({ + width: 'medium', + minWidth: 256, + resizable: true, + widthStorageKey: 'test-containment', + ...refs, + }), + ) + + // Initially no data-dragging attribute + expect(refs.paneRef.current?.hasAttribute('data-dragging')).toBe(false) + expect(refs.contentWrapperRef.current?.hasAttribute('data-dragging')).toBe(false) + + // Fire resize + vi.stubGlobal('innerWidth', 1000) + window.dispatchEvent(new Event('resize')) + + // Attribute should be applied immediately on first resize + expect(refs.paneRef.current?.hasAttribute('data-dragging')).toBe(true) + expect(refs.contentWrapperRef.current?.hasAttribute('data-dragging')).toBe(true) + + // Fire another resize event immediately (simulating continuous resize) + vi.stubGlobal('innerWidth', 900) + window.dispatchEvent(new Event('resize')) + + // Attribute should still be present (containment stays on during continuous resize) + expect(refs.paneRef.current?.hasAttribute('data-dragging')).toBe(true) + expect(refs.contentWrapperRef.current?.hasAttribute('data-dragging')).toBe(true) + + // Wait for the debounce timeout (150ms) to complete after resize stops + await act(async () => { + await vi.advanceTimersByTimeAsync(150) + }) + + // Attribute should be removed after debounce completes + expect(refs.paneRef.current?.hasAttribute('data-dragging')).toBe(false) + expect(refs.contentWrapperRef.current?.hasAttribute('data-dragging')).toBe(false) + + vi.useRealTimers() + }) + + it('should cleanup containment attributes on unmount during resize', async () => { + vi.useFakeTimers() + vi.stubGlobal('innerWidth', 1280) + const refs = createMockRefs() + + const {unmount} = renderHook(() => + usePaneWidth({ + width: 'medium', + minWidth: 256, + resizable: true, + widthStorageKey: 'test-cleanup-containment', + ...refs, + }), + ) + + // Fire resize + vi.stubGlobal('innerWidth', 1000) + window.dispatchEvent(new Event('resize')) + + // Attribute should be applied + expect(refs.paneRef.current?.hasAttribute('data-dragging')).toBe(true) + expect(refs.contentWrapperRef.current?.hasAttribute('data-dragging')).toBe(true) + + // Unmount immediately (before debounce timer fires) + unmount() + + // Attribute should be cleaned up on unmount regardless of timing + expect(refs.paneRef.current?.hasAttribute('data-dragging')).toBe(false) + expect(refs.contentWrapperRef.current?.hasAttribute('data-dragging')).toBe(false) + + vi.useRealTimers() + }) }) describe('on-demand max calculation', () => { diff --git a/packages/react/src/PageLayout/usePaneWidth.ts b/packages/react/src/PageLayout/usePaneWidth.ts index 2449901b0a0..4244e94d1e9 100644 --- a/packages/react/src/PageLayout/usePaneWidth.ts +++ b/packages/react/src/PageLayout/usePaneWidth.ts @@ -23,6 +23,7 @@ export type UsePaneWidthOptions = { widthStorageKey: string paneRef: React.RefObject handleRef: React.RefObject + contentWrapperRef: React.RefObject } export type UsePaneWidthResult = { @@ -77,7 +78,7 @@ export const isCustomWidthOptions = (width: PaneWidth | CustomWidthOptions): wid } export const isPaneWidth = (width: PaneWidth | CustomWidthOptions): width is PaneWidth => { - return ['small', 'medium', 'large'].includes(width as PaneWidth) + return width === 'small' || width === 'medium' || width === 'large' } export const getDefaultPaneWidth = (w: PaneWidth | CustomWidthOptions): number => { @@ -130,6 +131,7 @@ export function usePaneWidth({ widthStorageKey, paneRef, handleRef, + contentWrapperRef, }: UsePaneWidthOptions): UsePaneWidthResult { // Derive constraints from width configuration const isCustomWidth = isCustomWidthOptions(width) @@ -187,7 +189,10 @@ export function usePaneWidth({ const saveWidth = React.useCallback( (value: number) => { currentWidthRef.current = value - setCurrentWidth(value) + // Visual update already done via inline styles - React state sync is non-urgent + startTransition(() => { + setCurrentWidth(value) + }) try { localStorage.setItem(widthStorageKey, value.toString()) } catch { @@ -205,20 +210,12 @@ export function usePaneWidth({ }) // Update CSS variable, refs, and ARIA on mount and window resize. - // Strategy: - // 1. Throttled (16ms): Update --pane-max-width CSS variable for immediate visual clamp - // 2. Debounced (150ms): Sync refs, ARIA, and React state when resize stops + // Strategy: Only sync when resize stops (debounced) to avoid layout thrashing on large DOMs useIsomorphicLayoutEffect(() => { if (!resizable) return let lastViewportWidth = window.innerWidth - // Quick 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 @@ -269,36 +266,53 @@ export function usePaneWidth({ // For custom widths, max is fixed - no need to listen to resize if (customMaxWidth !== null) return - // Throttle CSS updates (16ms ≈ 60fps), debounce full sync (150ms) - const THROTTLE_MS = 16 - const DEBOUNCE_MS = 150 + // 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 lastThrottleTime = 0 + 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 = () => { - const now = Date.now() + // Apply containment on first resize event (stays applied until resize stops) + startResizeOptimizations() - // Throttled CSS update for immediate visual feedback - if (now - lastThrottleTime >= THROTTLE_MS) { - lastThrottleTime = now - updateCSSOnly() - } else if (rafId === null) { - // Schedule next frame if we're within throttle window + const now = Date.now() + if (now - lastUpdateTime >= THROTTLE_MS) { + lastUpdateTime = now + syncAll() + } else if (!pendingUpdate) { + pendingUpdate = true rafId = requestAnimationFrame(() => { + pendingUpdate = false rafId = null - lastThrottleTime = Date.now() - updateCSSOnly() + lastUpdateTime = Date.now() + syncAll() }) } - // Debounced full sync (refs, ARIA, state) when resize stops - if (debounceId !== null) { - clearTimeout(debounceId) - } + // Debounce the cleanup — remove containment after resize stops + if (debounceId !== null) clearTimeout(debounceId) debounceId = setTimeout(() => { debounceId = null - syncAll() + endResizeOptimizations() }, DEBOUNCE_MS) } @@ -307,9 +321,10 @@ export function usePaneWidth({ return () => { if (rafId !== null) cancelAnimationFrame(rafId) if (debounceId !== null) clearTimeout(debounceId) + endResizeOptimizations() window.removeEventListener('resize', handleResize) } - }, [resizable, customMaxWidth, minPaneWidth, paneRef, handleRef]) + }, [resizable, customMaxWidth, minPaneWidth, paneRef, handleRef, contentWrapperRef]) return { currentWidth,