From 190058f6864c89bb7655cd72a5057214a3d785e7 Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Wed, 26 Nov 2025 20:32:08 +0000 Subject: [PATCH 01/67] simple improvements --- packages/react/src/PageLayout/PageLayout.tsx | 80 +++++++++++++------- 1 file changed, 51 insertions(+), 29 deletions(-) diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index 29afbe4477a..4dfa74fea03 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -194,13 +194,17 @@ const VerticalDivider: React.FC { stableOnDrag.current = onDrag - }, [onDrag]) + }) React.useEffect(() => { stableOnDragEnd.current = onDragEnd - }, [onDragEnd]) + }) React.useEffect(() => { + if (!isDragging) { + return + } + function handleDrag(event: MouseEvent) { stableOnDrag.current?.(event.movementX, false) event.preventDefault() @@ -212,6 +216,20 @@ const VerticalDivider: React.FC { + window.removeEventListener('mousemove', handleDrag) + window.removeEventListener('mouseup', handleDragEnd) + } + }, [isDragging]) + + React.useEffect(() => { + if (!isKeyboardDrag) { + return + } + function handleKeyDrag(event: KeyboardEvent) { let delta = 0 // https://github.com/github/accessibility/issues/5101#issuecomment-1822870655 @@ -232,32 +250,28 @@ const VerticalDivider: React.FC { + window.removeEventListener('keydown', handleKeyDrag) + window.removeEventListener('keyup', handleKeyDragEnd) + } + }, [isKeyboardDrag, currentWidth, minWidth, maxWidth]) + + React.useEffect(() => { + const body = document.body as HTMLElement | undefined if (isDragging || isKeyboardDrag) { - window.addEventListener('mousemove', handleDrag) - window.addEventListener('keydown', handleKeyDrag) - window.addEventListener('mouseup', handleDragEnd) - window.addEventListener('keyup', handleKeyDragEnd) - const body = document.body as HTMLElement | undefined body?.setAttribute('data-page-layout-dragging', 'true') } else { - window.removeEventListener('mousemove', handleDrag) - window.removeEventListener('mouseup', handleDragEnd) - window.removeEventListener('keydown', handleKeyDrag) - window.removeEventListener('keyup', handleKeyDragEnd) - const body = document.body as HTMLElement | undefined body?.removeAttribute('data-page-layout-dragging') } return () => { - window.removeEventListener('mousemove', handleDrag) - window.removeEventListener('mouseup', handleDragEnd) - window.removeEventListener('keydown', handleKeyDrag) - window.removeEventListener('keyup', handleKeyDragEnd) - const body = document.body as HTMLElement | undefined body?.removeAttribute('data-page-layout-dragging') } - }, [isDragging, isKeyboardDrag, currentWidth, minWidth, maxWidth]) + }, [isDragging, isKeyboardDrag]) return (
{ - setPaneWidth(width) + type NextPaneWidth = number | ((previous: number) => number) - try { - localStorage.setItem(widthStorageKey, width.toString()) - } catch (_error) { - // Ignore errors - } + const updatePaneWidth = (nextWidth: NextPaneWidth, {persist = false}: {persist?: boolean} = {}) => { + setPaneWidth(previous => { + const resolvedWidth = typeof nextWidth === 'function' ? nextWidth(previous) : nextWidth + + if (persist) { + try { + localStorage.setItem(widthStorageKey, resolvedWidth.toString()) + } catch (_error) { + // Ignore errors + } + } + + return resolvedWidth + }) } useRefObjectAsForwardedRef(forwardRef, paneRef) @@ -725,17 +747,17 @@ const Pane = React.forwardRef previous + deltaWithDirection) }} // Ensure `paneWidth` state and actual pane width are in sync when the drag ends onDragEnd={() => { const paneRect = paneRef.current?.getBoundingClientRect() if (!paneRect) return - updatePaneWidth(paneRect.width) + updatePaneWidth(() => paneRect.width, {persist: true}) }} position={positionProp} // Reset pane width on double click - onDoubleClick={() => updatePaneWidth(getDefaultPaneWidth(width))} + onDoubleClick={() => updatePaneWidth(() => getDefaultPaneWidth(width), {persist: true})} className={classes.PaneVerticalDivider} style={ { From 44a815c0e880838d2449892831ce1c6848e36129 Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Wed, 26 Nov 2025 20:44:42 +0000 Subject: [PATCH 02/67] adds a queuing structure for raf throttling updates --- packages/react/src/PageLayout/PageLayout.tsx | 123 ++++++++++++++++--- 1 file changed, 108 insertions(+), 15 deletions(-) diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index 4dfa74fea03..38352239217 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -639,21 +639,36 @@ const Pane = React.forwardRef number) - const updatePaneWidth = (nextWidth: NextPaneWidth, {persist = false}: {persist?: boolean} = {}) => { - setPaneWidth(previous => { - const resolvedWidth = typeof nextWidth === 'function' ? nextWidth(previous) : nextWidth - - if (persist) { - try { - localStorage.setItem(widthStorageKey, resolvedWidth.toString()) - } catch (_error) { - // Ignore errors + const updatePaneWidth = React.useCallback( + (nextWidth: NextPaneWidth, {persist = false}: {persist?: boolean} = {}) => { + setPaneWidth(previous => { + const resolvedWidth = typeof nextWidth === 'function' ? nextWidth(previous) : nextWidth + + if (persist) { + try { + localStorage.setItem(widthStorageKey, resolvedWidth.toString()) + } catch (_error) { + // Ignore errors + } } - } - return resolvedWidth - }) - } + return resolvedWidth + }) + }, + [widthStorageKey], + ) + const applyPaneDelta = React.useCallback( + (delta: number) => { + updatePaneWidth(previous => previous + delta) + }, + [updatePaneWidth], + ) + + const { + enqueue: enqueuePaneDelta, + flush: flushPaneDelta, + cancel: cancelPaneDelta, + } = useRafAccumulator(applyPaneDelta) useRefObjectAsForwardedRef(forwardRef, paneRef) @@ -747,17 +762,23 @@ const Pane = React.forwardRef previous + deltaWithDirection) + enqueuePaneDelta(deltaWithDirection, {immediate: isKeyboard}) }} // Ensure `paneWidth` state and actual pane width are in sync when the drag ends onDragEnd={() => { + cancelPaneDelta() + flushPaneDelta() const paneRect = paneRef.current?.getBoundingClientRect() if (!paneRect) return updatePaneWidth(() => paneRect.width, {persist: true}) }} position={positionProp} // Reset pane width on double click - onDoubleClick={() => updatePaneWidth(() => getDefaultPaneWidth(width), {persist: true})} + onDoubleClick={() => { + cancelPaneDelta() + flushPaneDelta() + updatePaneWidth(() => getDefaultPaneWidth(width), {persist: true}) + }} className={classes.PaneVerticalDivider} style={ { @@ -879,3 +900,75 @@ Header.__SLOT__ = Symbol('PageLayout.Header') Content.__SLOT__ = Symbol('PageLayout.Content') ;(Pane as WithSlotMarker).__SLOT__ = Symbol('PageLayout.Pane') Footer.__SLOT__ = Symbol('PageLayout.Footer') + +type RafAccumulatorOptions = { + immediate?: boolean +} + +type RafAccumulatorControls = { + enqueue: (delta: number, options?: RafAccumulatorOptions) => void + flush: () => void + cancel: () => void +} + +/** + * Batches numeric updates so that only a single callback runs per animation frame. + * Falls back to synchronous updates when the DOM is not available (SSR/tests). + */ +function useRafAccumulator(applyDelta: (delta: number) => void): RafAccumulatorControls { + const pendingDeltaRef = React.useRef(0) + const rafIdRef = React.useRef(null) + + const flush = React.useCallback(() => { + if (pendingDeltaRef.current === 0) return + const delta = pendingDeltaRef.current + pendingDeltaRef.current = 0 + applyDelta(delta) + }, [applyDelta]) + + const cancel = React.useCallback(() => { + if (!canUseDOM) return + if (rafIdRef.current !== null) { + window.cancelAnimationFrame(rafIdRef.current) + rafIdRef.current = null + } + }, []) + + const schedule = React.useCallback(() => { + if (!canUseDOM) { + flush() + return + } + if (rafIdRef.current !== null) { + return + } + rafIdRef.current = window.requestAnimationFrame(() => { + rafIdRef.current = null + flush() + }) + }, [flush]) + + const enqueue = React.useCallback( + (delta: number, options?: RafAccumulatorOptions) => { + const immediate = options?.immediate ?? false + pendingDeltaRef.current += delta + + if (immediate) { + cancel() + flush() + } else { + schedule() + } + }, + [cancel, flush, schedule], + ) + + React.useEffect(() => { + return () => { + cancel() + pendingDeltaRef.current = 0 + } + }, [cancel]) + + return {enqueue, flush, cancel} +} From 3cf401aea7737928095425346c8be633092cd640 Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Wed, 26 Nov 2025 20:55:39 +0000 Subject: [PATCH 03/67] update body flag writing --- packages/react/src/PageLayout/PageLayout.tsx | 22 ++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index 38352239217..d5e83f22291 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -260,18 +260,28 @@ const VerticalDivider: React.FC { const body = document.body as HTMLElement | undefined - if (isDragging || isKeyboardDrag) { - body?.setAttribute('data-page-layout-dragging', 'true') + if (!body) return + + const previousDraggingValue = body.getAttribute('data-page-layout-dragging') + + if (isDraggingByAnyMeans) { + body.setAttribute('data-page-layout-dragging', 'true') } else { - body?.removeAttribute('data-page-layout-dragging') + body.removeAttribute('data-page-layout-dragging') } return () => { - body?.removeAttribute('data-page-layout-dragging') + if (previousDraggingValue === null) { + body.removeAttribute('data-page-layout-dragging') + } else { + body.setAttribute('data-page-layout-dragging', previousDraggingValue) + } } - }, [isDragging, isKeyboardDrag]) + }, [isDraggingByAnyMeans]) return (
Date: Thu, 27 Nov 2025 17:15:31 +0000 Subject: [PATCH 04/67] Initial plan From 63599b82d749fc30e1494b50b6112cb19836c0d1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 27 Nov 2025 17:29:59 +0000 Subject: [PATCH 05/67] Use setPointerCapture for drag and optimize width updates during mouse drag Co-authored-by: mattcosta7 <8616962+mattcosta7@users.noreply.github.com> --- .../src/PageLayout/PageLayout.module.css | 17 ++- packages/react/src/PageLayout/PageLayout.tsx | 112 +++++++++++++----- .../__snapshots__/PageLayout.test.tsx.snap | 8 +- 3 files changed, 97 insertions(+), 40 deletions(-) diff --git a/packages/react/src/PageLayout/PageLayout.module.css b/packages/react/src/PageLayout/PageLayout.module.css index 5c3041059ea..b1e5a15ae35 100644 --- a/packages/react/src/PageLayout/PageLayout.module.css +++ b/packages/react/src/PageLayout/PageLayout.module.css @@ -4,12 +4,6 @@ body[data-page-layout-dragging='true'] { cursor: col-resize; } -/* Disable text selection while dragging */ -/* stylelint-disable-next-line selector-no-qualifying-type */ -body[data-page-layout-dragging='true'] * { - user-select: none; -} - .PageLayoutRoot { /* Region Order */ --region-order-header: 0; @@ -581,6 +575,8 @@ body[data-page-layout-dragging='true'] * { } .Pane { + --pane-drag-delta: 0; + width: var(--pane-width-size); /* stylelint-disable-next-line primer/spacing */ padding: var(--spacing); @@ -596,6 +592,13 @@ body[data-page-layout-dragging='true'] * { width: clamp(var(--pane-min-width), var(--pane-width), var(--pane-max-width)); } } + + /* Apply transform during mouse drag for better performance (avoids reflow) */ + &:where([data-is-dragging]) { + @media screen and (min-width: 768px) { + width: clamp(var(--pane-min-width), calc(var(--pane-width) + var(--pane-drag-delta)), var(--pane-max-width)); + } + } } .PaneHorizontalDivider { @@ -702,6 +705,8 @@ body[data-page-layout-dragging='true'] * { cursor: col-resize; background-color: transparent; transition-delay: 0.1s; + touch-action: none; + user-select: none; } .DraggableHandle:hover { diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index d5e83f22291..2ca9b20cd79 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -166,6 +166,7 @@ const VerticalDivider: React.FC { const [isDragging, setIsDragging] = React.useState(false) const [isKeyboardDrag, setIsKeyboardDrag] = React.useState(false) + const dragHandleRef = React.useRef(null) const stableOnDrag = React.useRef(onDrag) const stableOnDragEnd = React.useRef(onDragEnd) @@ -200,30 +201,38 @@ const VerticalDivider: React.FC { - if (!isDragging) { - return - } + // Handle pointer drag with setPointerCapture for better performance + const handlePointerDown = React.useCallback( + (event: React.PointerEvent) => { + if (event.button !== 0) return - function handleDrag(event: MouseEvent) { + const target = event.currentTarget as HTMLElement + target.setPointerCapture(event.pointerId) + setIsDragging(true) + onDragStart?.() + }, + [onDragStart], + ) + + const handlePointerMove = React.useCallback( + (event: React.PointerEvent) => { + if (!isDragging) return stableOnDrag.current?.(event.movementX, false) - event.preventDefault() - } + }, + [isDragging], + ) + + const handlePointerUp = React.useCallback( + (event: React.PointerEvent) => { + if (!isDragging) return - function handleDragEnd(event: MouseEvent) { + const target = event.currentTarget as HTMLElement + target.releasePointerCapture(event.pointerId) setIsDragging(false) stableOnDragEnd.current?.() - event.preventDefault() - } - - window.addEventListener('mousemove', handleDrag) - window.addEventListener('mouseup', handleDragEnd) - - return () => { - window.removeEventListener('mousemove', handleDrag) - window.removeEventListener('mouseup', handleDragEnd) - } - }, [isDragging]) + }, + [isDragging], + ) React.useEffect(() => { if (!isKeyboardDrag) { @@ -291,8 +300,9 @@ const VerticalDivider: React.FC {draggable ? ( - // Drag handle + // Drag handle using pointer events with setPointerCapture for performance
{ - if (event.button === 0) { - setIsDragging(true) - onDragStart?.() - } - }} + onPointerDown={handlePointerDown} + onPointerMove={handlePointerMove} + onPointerUp={handlePointerUp} onKeyDown={(event: React.KeyboardEvent) => { if ( event.key === 'ArrowLeft' || @@ -667,6 +674,12 @@ const Pane = React.forwardRef { updatePaneWidth(previous => previous + delta) @@ -674,12 +687,24 @@ const Pane = React.forwardRef { + setDragDelta(prev => prev + delta) + }, []) + const { enqueue: enqueuePaneDelta, flush: flushPaneDelta, cancel: cancelPaneDelta, } = useRafAccumulator(applyPaneDelta) + // For visual feedback during mouse drag + const { + enqueue: enqueueDragDelta, + flush: flushDragDelta, + cancel: cancelDragDelta, + } = useRafAccumulator(applyDragDelta) + useRefObjectAsForwardedRef(forwardRef, paneRef) const hasOverflow = useOverflow(paneRef) @@ -735,6 +760,7 @@ const Pane = React.forwardRef @@ -764,6 +792,9 @@ const Pane = React.forwardRef { + setIsDragging(true) + }} onDrag={(delta, isKeyboard = false) => { // Get the number of pixels the divider was dragged let deltaWithDirection @@ -772,21 +803,42 @@ const Pane = React.forwardRef { + setIsDragging(false) cancelPaneDelta() flushPaneDelta() - const paneRect = paneRef.current?.getBoundingClientRect() - if (!paneRect) return - updatePaneWidth(() => paneRect.width, {persist: true}) + cancelDragDelta() + flushDragDelta() + + // Commit the drag delta to actual width + if (dragDelta !== 0) { + updatePaneWidth(prev => prev + dragDelta, {persist: true}) + setDragDelta(0) + } else { + // Fallback: read from DOM if no delta tracked + const paneRect = paneRef.current?.getBoundingClientRect() + if (paneRect) { + updatePaneWidth(() => paneRect.width, {persist: true}) + } + } }} position={positionProp} // Reset pane width on double click onDoubleClick={() => { + setIsDragging(false) cancelPaneDelta() flushPaneDelta() + cancelDragDelta() + setDragDelta(0) updatePaneWidth(() => getDefaultPaneWidth(width), {persist: true}) }} className={classes.PaneVerticalDivider} diff --git a/packages/react/src/PageLayout/__snapshots__/PageLayout.test.tsx.snap b/packages/react/src/PageLayout/__snapshots__/PageLayout.test.tsx.snap index 5ac82e6ca02..a4fccf6c848 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
From cee9442fdb464ffcf9cb20e2b3ca02603fb261e0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 27 Nov 2025 17:34:08 +0000 Subject: [PATCH 06/67] Fix race condition in drag delta tracking by using ref Co-authored-by: mattcosta7 <8616962+mattcosta7@users.noreply.github.com> --- packages/react/src/PageLayout/PageLayout.tsx | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index 2ca9b20cd79..8ef763ee999 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -678,6 +678,7 @@ const Pane = React.forwardRef { + dragDeltaRef.current += delta setDragDelta(prev => prev + delta) }, []) + const resetDragDelta = React.useCallback(() => { + dragDeltaRef.current = 0 + setDragDelta(0) + }, []) + const { enqueue: enqueuePaneDelta, flush: flushPaneDelta, @@ -819,10 +826,11 @@ const Pane = React.forwardRef prev + dragDelta, {persist: true}) - setDragDelta(0) + // Commit the drag delta to actual width using the ref for accurate value + const totalDelta = dragDeltaRef.current + if (totalDelta !== 0) { + updatePaneWidth(prev => prev + totalDelta, {persist: true}) + resetDragDelta() } else { // Fallback: read from DOM if no delta tracked const paneRect = paneRef.current?.getBoundingClientRect() @@ -838,7 +846,7 @@ const Pane = React.forwardRef getDefaultPaneWidth(width), {persist: true}) }} className={classes.PaneVerticalDivider} From f245cd8649e0b1f51cd4f9d2818c0016a5c1afcd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 28 Nov 2025 13:51:13 +0000 Subject: [PATCH 07/67] Use GPU-accelerated transforms for drag preview instead of width updates Co-authored-by: mattcosta7 <8616962+mattcosta7@users.noreply.github.com> --- .../react/src/PageLayout/PageLayout.module.css | 14 ++++++++++++-- packages/react/src/PageLayout/PageLayout.tsx | 9 +++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/react/src/PageLayout/PageLayout.module.css b/packages/react/src/PageLayout/PageLayout.module.css index b1e5a15ae35..e09322266e0 100644 --- a/packages/react/src/PageLayout/PageLayout.module.css +++ b/packages/react/src/PageLayout/PageLayout.module.css @@ -593,10 +593,15 @@ body[data-page-layout-dragging='true'] { } } - /* Apply transform during mouse drag for better performance (avoids reflow) */ + /* Use GPU-accelerated transform during mouse drag instead of width changes to avoid reflow. + We keep the width fixed and use translateX to show the visual drag effect. + Actual width is committed only when drag ends. */ &:where([data-is-dragging]) { @media screen and (min-width: 768px) { - width: clamp(var(--pane-min-width), calc(var(--pane-width) + var(--pane-drag-delta)), var(--pane-max-width)); + /* Promote to own compositing layer for GPU acceleration */ + will-change: transform, width; + /* translateZ(0) forces GPU compositing */ + transform: translateZ(0); } } } @@ -700,6 +705,8 @@ body[data-page-layout-dragging='true'] { } .DraggableHandle { + --pane-drag-delta: 0; + position: absolute; inset: 0 -2px; cursor: col-resize; @@ -715,6 +722,9 @@ body[data-page-layout-dragging='true'] { .DraggableHandle[data-dragging='true'] { background-color: var(--bgColor-accent-emphasis); + /* Use GPU-accelerated transform to show drag preview position */ + will-change: transform; + transform: translateX(var(--pane-drag-delta)); } .DraggableHandle[data-dragging='true']:hover { diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index 8ef763ee999..1e1ab3fc612 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -147,6 +147,7 @@ const HorizontalDivider: React.FC> = ({ type DraggableDividerProps = { draggable?: boolean + dragDelta?: number onDragStart?: () => void onDrag?: (delta: number, isKeyboard: boolean) => void onDragEnd?: () => void @@ -156,6 +157,7 @@ type DraggableDividerProps = { const VerticalDivider: React.FC> = ({ variant = 'none', draggable = false, + dragDelta = 0, onDragStart, onDrag, onDragEnd, @@ -312,6 +314,11 @@ const VerticalDivider: React.FC { setIsDragging(true) }} From 219306b7fe8e34431809b2e6b0d552cbc508bd76 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 28 Nov 2025 13:54:22 +0000 Subject: [PATCH 08/67] Remove width from will-change since only transforms are used during drag Co-authored-by: mattcosta7 <8616962+mattcosta7@users.noreply.github.com> --- packages/react/src/PageLayout/PageLayout.module.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/PageLayout/PageLayout.module.css b/packages/react/src/PageLayout/PageLayout.module.css index e09322266e0..17658cbb9f5 100644 --- a/packages/react/src/PageLayout/PageLayout.module.css +++ b/packages/react/src/PageLayout/PageLayout.module.css @@ -599,7 +599,7 @@ body[data-page-layout-dragging='true'] { &:where([data-is-dragging]) { @media screen and (min-width: 768px) { /* Promote to own compositing layer for GPU acceleration */ - will-change: transform, width; + will-change: transform; /* translateZ(0) forces GPU compositing */ transform: translateZ(0); } From 296ef29fa6891000ece4871b798a0a9e889c68aa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 28 Nov 2025 14:04:02 +0000 Subject: [PATCH 09/67] Use direct DOM style manipulation for drag delta to avoid React re-renders Co-authored-by: mattcosta7 <8616962+mattcosta7@users.noreply.github.com> --- packages/react/src/PageLayout/PageLayout.tsx | 56 ++++++++++++++------ 1 file changed, 39 insertions(+), 17 deletions(-) diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index 1e1ab3fc612..e89b57639f6 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -147,7 +147,7 @@ const HorizontalDivider: React.FC> = ({ type DraggableDividerProps = { draggable?: boolean - dragDelta?: number + dragHandleRef?: React.RefObject onDragStart?: () => void onDrag?: (delta: number, isKeyboard: boolean) => void onDragEnd?: () => void @@ -157,7 +157,7 @@ type DraggableDividerProps = { const VerticalDivider: React.FC> = ({ variant = 'none', draggable = false, - dragDelta = 0, + dragHandleRef: externalDragHandleRef, onDragStart, onDrag, onDragEnd, @@ -168,7 +168,9 @@ const VerticalDivider: React.FC { const [isDragging, setIsDragging] = React.useState(false) const [isKeyboardDrag, setIsKeyboardDrag] = React.useState(false) - const dragHandleRef = React.useRef(null) + const internalDragHandleRef = React.useRef(null) + // Use external ref if provided, otherwise use internal ref + const dragHandleRef = externalDragHandleRef || internalDragHandleRef const stableOnDrag = React.useRef(onDrag) const stableOnDragEnd = React.useRef(onDragEnd) @@ -303,6 +305,7 @@ const VerticalDivider: React.FC {draggable ? ( // Drag handle using pointer events with setPointerCapture for performance + // Style is updated directly via DOM for 120fps performance (--pane-drag-delta)
(null) const [isDragging, setIsDragging] = React.useState(false) const applyPaneDelta = React.useCallback( @@ -695,16 +700,33 @@ const Pane = React.forwardRef { - dragDeltaRef.current += delta - setDragDelta(prev => prev + delta) - }, []) + // For mouse dragging, accumulate delta and update DOM directly for visual feedback + // This avoids React re-renders for maximum performance (120fps target) + const applyDragDelta = React.useCallback( + (delta: number) => { + dragDeltaRef.current += delta + // Update DOM directly without React state to avoid re-renders + if (paneRef.current) { + paneRef.current.style.setProperty('--pane-drag-delta', `${dragDeltaRef.current}px`) + } + if (dragHandleRef.current) { + const adjustedDelta = position === 'end' ? -dragDeltaRef.current : dragDeltaRef.current + dragHandleRef.current.style.setProperty('--pane-drag-delta', `${adjustedDelta}px`) + } + }, + [paneRef, position], + ) const resetDragDelta = React.useCallback(() => { dragDeltaRef.current = 0 - setDragDelta(0) - }, []) + // Reset DOM styles + if (paneRef.current) { + paneRef.current.style.setProperty('--pane-drag-delta', '0px') + } + if (dragHandleRef.current) { + dragHandleRef.current.style.setProperty('--pane-drag-delta', '0px') + } + }, [paneRef]) const { enqueue: enqueuePaneDelta, @@ -783,8 +805,8 @@ const Pane = React.forwardRef @@ -806,8 +828,8 @@ const Pane = React.forwardRef { setIsDragging(true) }} From 79685a9764e77e355dca453b93e07ec7e84e52b5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 28 Nov 2025 14:11:18 +0000 Subject: [PATCH 10/67] Refactor: Extract CSS variable constant and helper function for delta adjustment Co-authored-by: mattcosta7 <8616962+mattcosta7@users.noreply.github.com> --- packages/react/src/PageLayout/PageLayout.tsx | 33 +++++++++++--------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index e89b57639f6..6f10545a05e 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -21,6 +21,9 @@ const REGION_ORDER = { footer: 4, } +// CSS variable name for drag delta (used for direct DOM manipulation during drag) +const DRAG_DELTA_CSS_VAR = '--pane-drag-delta' + // eslint-disable-next-line @typescript-eslint/no-unused-vars const SPACING_MAP = { none: 0, @@ -320,7 +323,7 @@ const VerticalDivider: React.FC>( ( { @@ -707,11 +717,11 @@ const Pane = React.forwardRef @@ -834,13 +844,8 @@ const Pane = React.forwardRef { - // Get the number of pixels the divider was dragged - let deltaWithDirection - if (isKeyboard) { - deltaWithDirection = delta - } else { - deltaWithDirection = position === 'end' ? -delta : delta - } + // Get the number of pixels the divider was dragged (adjusted for pane position) + const deltaWithDirection = isKeyboard ? delta : getAdjustedDelta(delta, position) if (isKeyboard) { // For keyboard, apply width changes immediately enqueuePaneDelta(deltaWithDirection, {immediate: true}) From e9d5f67ccc32dbdf8019986aa44510559d3bd0ec Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 28 Nov 2025 14:20:36 +0000 Subject: [PATCH 11/67] Use useImperativeHandle for ref synchronization instead of ref swapping Co-authored-by: mattcosta7 <8616962+mattcosta7@users.noreply.github.com> --- packages/react/src/PageLayout/PageLayout.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index 6f10545a05e..77b2468a016 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -150,7 +150,7 @@ const HorizontalDivider: React.FC> = ({ type DraggableDividerProps = { draggable?: boolean - dragHandleRef?: React.RefObject + dragHandleRef?: React.MutableRefObject onDragStart?: () => void onDrag?: (delta: number, isKeyboard: boolean) => void onDragEnd?: () => void @@ -171,9 +171,11 @@ const VerticalDivider: React.FC { const [isDragging, setIsDragging] = React.useState(false) const [isKeyboardDrag, setIsKeyboardDrag] = React.useState(false) - const internalDragHandleRef = React.useRef(null) - // Use external ref if provided, otherwise use internal ref - const dragHandleRef = externalDragHandleRef || internalDragHandleRef + const dragHandleRef = React.useRef(null) + + // Sync external ref with internal ref using useImperativeHandle + // This allows parent component to access the drag handle DOM element for direct style manipulation + React.useImperativeHandle(externalDragHandleRef as React.Ref, () => dragHandleRef.current, []) const stableOnDrag = React.useRef(onDrag) const stableOnDragEnd = React.useRef(onDragEnd) From af4e09f5a31e25bcaba30d270188b2639620c394 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 28 Nov 2025 18:13:04 +0000 Subject: [PATCH 12/67] Simplify drag performance improvements Co-authored-by: mattcosta7 <8616962+mattcosta7@users.noreply.github.com> --- .../src/PageLayout/PageLayout.module.css | 19 --- packages/react/src/PageLayout/PageLayout.tsx | 117 ++++-------------- .../__snapshots__/PageLayout.test.tsx.snap | 8 +- 3 files changed, 31 insertions(+), 113 deletions(-) diff --git a/packages/react/src/PageLayout/PageLayout.module.css b/packages/react/src/PageLayout/PageLayout.module.css index 17658cbb9f5..d1a3757dec4 100644 --- a/packages/react/src/PageLayout/PageLayout.module.css +++ b/packages/react/src/PageLayout/PageLayout.module.css @@ -575,8 +575,6 @@ body[data-page-layout-dragging='true'] { } .Pane { - --pane-drag-delta: 0; - width: var(--pane-width-size); /* stylelint-disable-next-line primer/spacing */ padding: var(--spacing); @@ -592,18 +590,6 @@ body[data-page-layout-dragging='true'] { width: clamp(var(--pane-min-width), var(--pane-width), var(--pane-max-width)); } } - - /* Use GPU-accelerated transform during mouse drag instead of width changes to avoid reflow. - We keep the width fixed and use translateX to show the visual drag effect. - Actual width is committed only when drag ends. */ - &:where([data-is-dragging]) { - @media screen and (min-width: 768px) { - /* Promote to own compositing layer for GPU acceleration */ - will-change: transform; - /* translateZ(0) forces GPU compositing */ - transform: translateZ(0); - } - } } .PaneHorizontalDivider { @@ -705,8 +691,6 @@ body[data-page-layout-dragging='true'] { } .DraggableHandle { - --pane-drag-delta: 0; - position: absolute; inset: 0 -2px; cursor: col-resize; @@ -722,9 +706,6 @@ body[data-page-layout-dragging='true'] { .DraggableHandle[data-dragging='true'] { background-color: var(--bgColor-accent-emphasis); - /* Use GPU-accelerated transform to show drag preview position */ - will-change: transform; - transform: translateX(var(--pane-drag-delta)); } .DraggableHandle[data-dragging='true']:hover { diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index 77b2468a016..d49c3474aeb 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -21,9 +21,6 @@ const REGION_ORDER = { footer: 4, } -// CSS variable name for drag delta (used for direct DOM manipulation during drag) -const DRAG_DELTA_CSS_VAR = '--pane-drag-delta' - // eslint-disable-next-line @typescript-eslint/no-unused-vars const SPACING_MAP = { none: 0, @@ -150,7 +147,6 @@ const HorizontalDivider: React.FC> = ({ type DraggableDividerProps = { draggable?: boolean - dragHandleRef?: React.MutableRefObject onDragStart?: () => void onDrag?: (delta: number, isKeyboard: boolean) => void onDragEnd?: () => void @@ -160,7 +156,6 @@ type DraggableDividerProps = { const VerticalDivider: React.FC> = ({ variant = 'none', draggable = false, - dragHandleRef: externalDragHandleRef, onDragStart, onDrag, onDragEnd, @@ -171,11 +166,6 @@ const VerticalDivider: React.FC { const [isDragging, setIsDragging] = React.useState(false) const [isKeyboardDrag, setIsKeyboardDrag] = React.useState(false) - const dragHandleRef = React.useRef(null) - - // Sync external ref with internal ref using useImperativeHandle - // This allows parent component to access the drag handle DOM element for direct style manipulation - React.useImperativeHandle(externalDragHandleRef as React.Ref, () => dragHandleRef.current, []) const stableOnDrag = React.useRef(onDrag) const stableOnDragEnd = React.useRef(onDragEnd) @@ -210,11 +200,10 @@ const VerticalDivider: React.FC { if (event.button !== 0) return - const target = event.currentTarget as HTMLElement target.setPointerCapture(event.pointerId) setIsDragging(true) @@ -234,7 +223,6 @@ const VerticalDivider: React.FC { if (!isDragging) return - const target = event.currentTarget as HTMLElement target.releasePointerCapture(event.pointerId) setIsDragging(false) @@ -309,10 +297,7 @@ const VerticalDivider: React.FC {draggable ? ( - // Drag handle using pointer events with setPointerCapture for performance - // Style is updated directly via DOM for 120fps performance (--pane-drag-delta)
>( ( { @@ -698,60 +670,42 @@ const Pane = React.forwardRef(null) - const [isDragging, setIsDragging] = React.useState(false) - const applyPaneDelta = React.useCallback( - (delta: number) => { - updatePaneWidth(previous => previous + delta) - }, - [updatePaneWidth], - ) - - // For mouse dragging, accumulate delta and update DOM directly for visual feedback - // This avoids React re-renders for maximum performance (120fps target) + // Apply delta directly to DOM for 120fps performance during mouse drag const applyDragDelta = React.useCallback( (delta: number) => { dragDeltaRef.current += delta - // Update DOM directly without React state to avoid re-renders if (paneRef.current) { - paneRef.current.style.setProperty(DRAG_DELTA_CSS_VAR, `${dragDeltaRef.current}px`) - } - if (dragHandleRef.current) { - const adjustedDelta = getAdjustedDelta(dragDeltaRef.current, position) - dragHandleRef.current.style.setProperty(DRAG_DELTA_CSS_VAR, `${adjustedDelta}px`) + const newWidth = paneWidth + dragDeltaRef.current + paneRef.current.style.setProperty('--pane-width', `${newWidth}px`) } }, - [paneRef, position], + [paneRef, paneWidth], ) const resetDragDelta = React.useCallback(() => { dragDeltaRef.current = 0 - // Reset DOM styles - if (paneRef.current) { - paneRef.current.style.setProperty(DRAG_DELTA_CSS_VAR, '0px') - } - if (dragHandleRef.current) { - dragHandleRef.current.style.setProperty(DRAG_DELTA_CSS_VAR, '0px') - } - }, [paneRef]) + }, []) - const { - enqueue: enqueuePaneDelta, - flush: flushPaneDelta, - cancel: cancelPaneDelta, - } = useRafAccumulator(applyPaneDelta) + const applyPaneDelta = React.useCallback( + (delta: number) => { + updatePaneWidth(previous => previous + delta) + }, + [updatePaneWidth], + ) - // For visual feedback during mouse drag const { enqueue: enqueueDragDelta, flush: flushDragDelta, cancel: cancelDragDelta, } = useRafAccumulator(applyDragDelta) + const { + enqueue: enqueuePaneDelta, + flush: flushPaneDelta, + cancel: cancelPaneDelta, + } = useRafAccumulator(applyPaneDelta) useRefObjectAsForwardedRef(forwardRef, paneRef) @@ -808,7 +762,6 @@ const Pane = React.forwardRef @@ -840,51 +791,37 @@ const Pane = React.forwardRef { - setIsDragging(true) - }} onDrag={(delta, isKeyboard = false) => { - // Get the number of pixels the divider was dragged (adjusted for pane position) - const deltaWithDirection = isKeyboard ? delta : getAdjustedDelta(delta, position) + // Get the number of pixels the divider was dragged + const deltaWithDirection = isKeyboard ? delta : position === 'end' ? -delta : delta if (isKeyboard) { - // For keyboard, apply width changes immediately + // Keyboard: update React state immediately enqueuePaneDelta(deltaWithDirection, {immediate: true}) } else { - // For mouse, use transform-based visual feedback to avoid reflow + // Mouse: update DOM directly for 120fps performance enqueueDragDelta(deltaWithDirection) } }} // Ensure `paneWidth` state and actual pane width are in sync when the drag ends onDragEnd={() => { - setIsDragging(false) - cancelPaneDelta() - flushPaneDelta() cancelDragDelta() flushDragDelta() - - // Commit the drag delta to actual width using the ref for accurate value + cancelPaneDelta() + flushPaneDelta() + // Commit accumulated drag delta to React state const totalDelta = dragDeltaRef.current if (totalDelta !== 0) { updatePaneWidth(prev => prev + totalDelta, {persist: true}) resetDragDelta() - } else { - // Fallback: read from DOM if no delta tracked - const paneRect = paneRef.current?.getBoundingClientRect() - if (paneRect) { - updatePaneWidth(() => paneRect.width, {persist: true}) - } } }} position={positionProp} // Reset pane width on double click onDoubleClick={() => { - setIsDragging(false) - cancelPaneDelta() - flushPaneDelta() cancelDragDelta() resetDragDelta() + cancelPaneDelta() + flushPaneDelta() updatePaneWidth(() => getDefaultPaneWidth(width), {persist: true}) }} className={classes.PaneVerticalDivider} diff --git a/packages/react/src/PageLayout/__snapshots__/PageLayout.test.tsx.snap b/packages/react/src/PageLayout/__snapshots__/PageLayout.test.tsx.snap index a4fccf6c848..5ac82e6ca02 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
From 340d3b5327709d6430237508fc37db1bf9f9fe6d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 28 Nov 2025 18:16:26 +0000 Subject: [PATCH 13/67] Fix flush/cancel order in drag handlers Co-authored-by: mattcosta7 <8616962+mattcosta7@users.noreply.github.com> --- packages/react/src/PageLayout/PageLayout.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index d49c3474aeb..07844e76ee9 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -792,7 +792,7 @@ const Pane = React.forwardRef { - // Get the number of pixels the divider was dragged + // Adjust delta direction based on pane position const deltaWithDirection = isKeyboard ? delta : position === 'end' ? -delta : delta if (isKeyboard) { // Keyboard: update React state immediately @@ -804,10 +804,11 @@ const Pane = React.forwardRef { - cancelDragDelta() + // Flush pending updates then cancel any scheduled RAFs flushDragDelta() - cancelPaneDelta() + cancelDragDelta() flushPaneDelta() + cancelPaneDelta() // Commit accumulated drag delta to React state const totalDelta = dragDeltaRef.current if (totalDelta !== 0) { @@ -818,10 +819,11 @@ const Pane = React.forwardRef { + flushDragDelta() cancelDragDelta() resetDragDelta() - cancelPaneDelta() flushPaneDelta() + cancelPaneDelta() updatePaneWidth(() => getDefaultPaneWidth(width), {persist: true}) }} className={classes.PaneVerticalDivider} From 8f75d751f60008c6dd53b9c0aa4df88df26bdbfe Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Fri, 28 Nov 2025 20:04:35 +0000 Subject: [PATCH 14/67] simplify stable --- .../src/PageLayout/PageLayout.module.css | 7 +- packages/react/src/PageLayout/PageLayout.tsx | 357 +++++------------- 2 files changed, 103 insertions(+), 261 deletions(-) diff --git a/packages/react/src/PageLayout/PageLayout.module.css b/packages/react/src/PageLayout/PageLayout.module.css index d1a3757dec4..00972725ffe 100644 --- a/packages/react/src/PageLayout/PageLayout.module.css +++ b/packages/react/src/PageLayout/PageLayout.module.css @@ -1,9 +1,3 @@ -/* Maintain resize cursor while dragging */ -/* stylelint-disable-next-line selector-no-qualifying-type */ -body[data-page-layout-dragging='true'] { - cursor: col-resize; -} - .PageLayoutRoot { /* Region Order */ --region-order-header: 0; @@ -706,6 +700,7 @@ body[data-page-layout-dragging='true'] { .DraggableHandle[data-dragging='true'] { background-color: var(--bgColor-accent-emphasis); + cursor: col-resize; } .DraggableHandle[data-dragging='true']:hover { diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index 07844e76ee9..77ec1a1978d 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -164,11 +164,16 @@ const VerticalDivider: React.FC { - const [isDragging, setIsDragging] = React.useState(false) - const [isKeyboardDrag, setIsKeyboardDrag] = React.useState(false) + const isDraggingRef = React.useRef<'mouse' | 'keyboard' | false>(false) + const stableOnDragStart = React.useRef(onDragStart) const stableOnDrag = React.useRef(onDrag) const stableOnDragEnd = React.useRef(onDragEnd) + React.useLayoutEffect(() => { + stableOnDrag.current = onDrag + stableOnDragEnd.current = onDragEnd + stableOnDragStart.current = onDragStart + }) const {paneRef} = React.useContext(PageLayoutContext) @@ -176,6 +181,7 @@ const VerticalDivider: React.FC { if (paneRef.current !== null) { const paneStyles = getComputedStyle(paneRef.current as Element) @@ -188,51 +194,60 @@ const VerticalDivider: React.FC maxPaneWidthDiff ? viewportWidth - maxPaneWidthDiff : viewportWidth setMinWidth(minPaneWidth) setMaxWidth(maxPaneWidth) - setCurrentWidth(paneWidth || 0) + setCurrentWidth(paneWidth) } - }, [paneRef, isKeyboardDrag, isDragging]) + }, [paneRef]) + + const handlePointerDown = React.useCallback((event: React.PointerEvent) => { + if (event.button !== 0) return + event.preventDefault() // OPTIMIZATION: Prevent browser defaults + const target = event.currentTarget as HTMLElement + target.setPointerCapture(event.pointerId) + isDraggingRef.current = 'mouse' + target.setAttribute('data-dragging', 'true') + stableOnDragStart.current?.() + }, []) - React.useEffect(() => { - stableOnDrag.current = onDrag - }) + const handlePointerMove = React.useCallback((event: React.PointerEvent) => { + if (!isDraggingRef.current) return + event.preventDefault() // OPTIMIZATION: Critical for smooth dragging + if (event.movementX !== 0) { + stableOnDrag.current?.(event.movementX, false) + } + }, []) - React.useEffect(() => { - stableOnDragEnd.current = onDragEnd - }) + const handlePointerUp = React.useCallback((event: React.PointerEvent) => { + if (isDraggingRef.current !== 'mouse') return + const target = event.currentTarget as HTMLElement + target.releasePointerCapture(event.pointerId) + isDraggingRef.current = false + isKeyboardDragRef.current = false + target.removeAttribute('data-dragging') + stableOnDragEnd.current?.() + }, []) - // Handle pointer events with setPointerCapture for better performance - const handlePointerDown = React.useCallback( - (event: React.PointerEvent) => { - if (event.button !== 0) return + const handleKeyDown = React.useCallback((event: React.KeyboardEvent) => { + if ( + event.key === 'ArrowLeft' || + event.key === 'ArrowRight' || + event.key === 'ArrowUp' || + event.key === 'ArrowDown' + ) { + isDraggingRef.current = 'keyboard' + + // Update attributes directly const target = event.currentTarget as HTMLElement - target.setPointerCapture(event.pointerId) - setIsDragging(true) - onDragStart?.() - }, - [onDragStart], - ) + target.setAttribute('data-dragging', 'true') - const handlePointerMove = React.useCallback( - (event: React.PointerEvent) => { - if (!isDragging) return - stableOnDrag.current?.(event.movementX, false) - }, - [isDragging], - ) + stableOnDragStart.current?.() + } + }, []) - const handlePointerUp = React.useCallback( - (event: React.PointerEvent) => { - if (!isDragging) return - const target = event.currentTarget as HTMLElement - target.releasePointerCapture(event.pointerId) - setIsDragging(false) - stableOnDragEnd.current?.() - }, - [isDragging], - ) + // CONTINUATION FROM PART 1 - Insert after handleKeyDown in VerticalDivider + // Keyboard drag handling React.useEffect(() => { - if (!isKeyboardDrag) { + if (!isKeyboardDragRef.current) { return } @@ -252,7 +267,15 @@ const VerticalDivider: React.FC { - const body = document.body as HTMLElement | undefined - if (!body) return - - const previousDraggingValue = body.getAttribute('data-page-layout-dragging') - - if (isDraggingByAnyMeans) { - body.setAttribute('data-page-layout-dragging', 'true') - } else { - body.removeAttribute('data-page-layout-dragging') - } - - return () => { - if (previousDraggingValue === null) { - body.removeAttribute('data-page-layout-dragging') - } else { - body.setAttribute('data-page-layout-dragging', previousDraggingValue) - } - } - }, [isDraggingByAnyMeans]) + }, [minWidth, maxWidth, currentWidth]) return (
{ - if ( - event.key === 'ArrowLeft' || - event.key === 'ArrowRight' || - event.key === 'ArrowUp' || - event.key === 'ArrowDown' - ) { - setIsKeyboardDrag(true) - onDragStart?.() - } - }} + onKeyDown={handleKeyDown} onDoubleClick={onDoubleClick} + style={{touchAction: 'none'}} // OPTIMIZATION: Prevent touch scrolling /> ) : null}
@@ -520,17 +510,6 @@ export type PageLayoutPaneProps = { position?: keyof typeof panePositions | ResponsiveValue /** * @deprecated Use the `position` prop with a responsive value instead. - * - * Before: - * ``` - * position="start" - * positionWhenNarrow="end" - * ``` - * - * After: - * ``` - * position={{regular: 'start', narrow: 'end'}} - * ``` */ positionWhenNarrow?: 'inherit' | keyof typeof panePositions 'aria-labelledby'?: string @@ -543,17 +522,6 @@ export type PageLayoutPaneProps = { divider?: 'none' | 'line' | ResponsiveValue<'none' | 'line', 'none' | 'line' | 'filled'> /** * @deprecated Use the `divider` prop with a responsive value instead. - * - * Before: - * ``` - * divider="line" - * dividerWhenNarrow="filled" - * ``` - * - * After: - * ``` - * divider={{regular: 'line', narrow: 'filled'}} - * ``` */ dividerWhenNarrow?: 'inherit' | 'none' | 'line' | 'filled' sticky?: boolean @@ -581,6 +549,8 @@ const defaultPaneWidth = {small: 256, medium: 296, large: 320} const overflowProps = {tabIndex: 0, role: 'region'} +// CONTINUATION FROM PART 2 - Insert after overflowProps in Pane section + const Pane = React.forwardRef>( ( { @@ -649,64 +619,10 @@ const Pane = React.forwardRef number) - - const updatePaneWidth = React.useCallback( - (nextWidth: NextPaneWidth, {persist = false}: {persist?: boolean} = {}) => { - setPaneWidth(previous => { - const resolvedWidth = typeof nextWidth === 'function' ? nextWidth(previous) : nextWidth - - if (persist) { - try { - localStorage.setItem(widthStorageKey, resolvedWidth.toString()) - } catch (_error) { - // Ignore errors - } - } - - return resolvedWidth - }) - }, - [widthStorageKey], - ) - - // Track drag delta for direct DOM updates (avoids React re-renders during drag) + // OPTIMIZATION: Track accumulated drag delta during pointer drag + // This allows us to batch DOM updates without React state changes const dragDeltaRef = React.useRef(0) - // Apply delta directly to DOM for 120fps performance during mouse drag - const applyDragDelta = React.useCallback( - (delta: number) => { - dragDeltaRef.current += delta - if (paneRef.current) { - const newWidth = paneWidth + dragDeltaRef.current - paneRef.current.style.setProperty('--pane-width', `${newWidth}px`) - } - }, - [paneRef, paneWidth], - ) - - const resetDragDelta = React.useCallback(() => { - dragDeltaRef.current = 0 - }, []) - - const applyPaneDelta = React.useCallback( - (delta: number) => { - updatePaneWidth(previous => previous + delta) - }, - [updatePaneWidth], - ) - - const { - enqueue: enqueueDragDelta, - flush: flushDragDelta, - cancel: cancelDragDelta, - } = useRafAccumulator(applyDragDelta) - const { - enqueue: enqueuePaneDelta, - flush: flushPaneDelta, - cancel: cancelPaneDelta, - } = useRafAccumulator(applyPaneDelta) - useRefObjectAsForwardedRef(forwardRef, paneRef) const hasOverflow = useOverflow(paneRef) @@ -794,37 +710,49 @@ const Pane = React.forwardRef { // Adjust delta direction based on pane position const deltaWithDirection = isKeyboard ? delta : position === 'end' ? -delta : delta + if (isKeyboard) { - // Keyboard: update React state immediately - enqueuePaneDelta(deltaWithDirection, {immediate: true}) + // OPTIMIZATION: Keyboard uses React state for immediate visual feedback + setPaneWidth(prev => prev + deltaWithDirection) } else { - // Mouse: update DOM directly for 120fps performance - enqueueDragDelta(deltaWithDirection) + // OPTIMIZATION: Pointer drag - direct DOM manipulation for 120fps + // Accumulate delta without triggering React re-renders + dragDeltaRef.current += deltaWithDirection + if (paneRef.current) { + const newWidth = paneWidth + dragDeltaRef.current + // Direct CSS variable update - bypasses React reconciliation + paneRef.current.style.setProperty('--pane-width', `${newWidth}px`) + } } }} - // Ensure `paneWidth` state and actual pane width are in sync when the drag ends onDragEnd={() => { - // Flush pending updates then cancel any scheduled RAFs - flushDragDelta() - cancelDragDelta() - flushPaneDelta() - cancelPaneDelta() - // Commit accumulated drag delta to React state + // OPTIMIZATION: Commit accumulated pointer drag delta to React state + // This is the only state update during the entire drag operation const totalDelta = dragDeltaRef.current if (totalDelta !== 0) { - updatePaneWidth(prev => prev + totalDelta, {persist: true}) - resetDragDelta() + setPaneWidth(prev => { + const newWidth = prev + totalDelta + try { + localStorage.setItem(widthStorageKey, newWidth.toString()) + } catch (_error) { + // Ignore errors + } + return newWidth + }) + dragDeltaRef.current = 0 } }} position={positionProp} // Reset pane width on double click onDoubleClick={() => { - flushDragDelta() - cancelDragDelta() - resetDragDelta() - flushPaneDelta() - cancelPaneDelta() - updatePaneWidth(() => getDefaultPaneWidth(width), {persist: true}) + dragDeltaRef.current = 0 + const defaultWidth = getDefaultPaneWidth(width) + setPaneWidth(defaultWidth) + try { + localStorage.setItem(widthStorageKey, defaultWidth.toString()) + } catch (_error) { + // Ignore errors + } }} className={classes.PaneVerticalDivider} style={ @@ -857,17 +785,6 @@ export type PageLayoutFooterProps = { divider?: 'none' | 'line' | ResponsiveValue<'none' | 'line', 'none' | 'line' | 'filled'> /** * @deprecated Use the `divider` prop with a responsive value instead. - * - * Before: - * ``` - * divider="line" - * dividerWhenNarrow="filled" - * ``` - * - * After: - * ``` - * divider={{regular: 'line', narrow: 'filled'}} - * ``` */ dividerWhenNarrow?: 'inherit' | 'none' | 'line' | 'filled' hidden?: boolean | ResponsiveValue @@ -932,6 +849,8 @@ const Footer: FCWithSlotMarker> = Footer.displayName = 'PageLayout.Footer' +// CONTINUATION FROM PART 3 - Final part with exports + // ---------------------------------------------------------------------------- // Export @@ -947,75 +866,3 @@ Header.__SLOT__ = Symbol('PageLayout.Header') Content.__SLOT__ = Symbol('PageLayout.Content') ;(Pane as WithSlotMarker).__SLOT__ = Symbol('PageLayout.Pane') Footer.__SLOT__ = Symbol('PageLayout.Footer') - -type RafAccumulatorOptions = { - immediate?: boolean -} - -type RafAccumulatorControls = { - enqueue: (delta: number, options?: RafAccumulatorOptions) => void - flush: () => void - cancel: () => void -} - -/** - * Batches numeric updates so that only a single callback runs per animation frame. - * Falls back to synchronous updates when the DOM is not available (SSR/tests). - */ -function useRafAccumulator(applyDelta: (delta: number) => void): RafAccumulatorControls { - const pendingDeltaRef = React.useRef(0) - const rafIdRef = React.useRef(null) - - const flush = React.useCallback(() => { - if (pendingDeltaRef.current === 0) return - const delta = pendingDeltaRef.current - pendingDeltaRef.current = 0 - applyDelta(delta) - }, [applyDelta]) - - const cancel = React.useCallback(() => { - if (!canUseDOM) return - if (rafIdRef.current !== null) { - window.cancelAnimationFrame(rafIdRef.current) - rafIdRef.current = null - } - }, []) - - const schedule = React.useCallback(() => { - if (!canUseDOM) { - flush() - return - } - if (rafIdRef.current !== null) { - return - } - rafIdRef.current = window.requestAnimationFrame(() => { - rafIdRef.current = null - flush() - }) - }, [flush]) - - const enqueue = React.useCallback( - (delta: number, options?: RafAccumulatorOptions) => { - const immediate = options?.immediate ?? false - pendingDeltaRef.current += delta - - if (immediate) { - cancel() - flush() - } else { - schedule() - } - }, - [cancel, flush, schedule], - ) - - React.useEffect(() => { - return () => { - cancel() - pendingDeltaRef.current = 0 - } - }, [cancel]) - - return {enqueue, flush, cancel} -} From b86736aa5ea52eed7b7c2db66f6b48bca7bc585b Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Fri, 28 Nov 2025 20:08:16 +0000 Subject: [PATCH 15/67] simplify stable --- packages/react/src/PageLayout/PageLayout.tsx | 82 ++++++++------------ 1 file changed, 34 insertions(+), 48 deletions(-) diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index 77ec1a1978d..a5390416af0 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -221,7 +221,6 @@ const VerticalDivider: React.FC { - if (!isKeyboardDragRef.current) { - return - } - - function handleKeyDrag(event: KeyboardEvent) { - let delta = 0 - // https://github.com/github/accessibility/issues/5101#issuecomment-1822870655 - if ((event.key === 'ArrowLeft' || event.key === 'ArrowDown') && currentWidth > minWidth) { - delta = -3 - } else if ((event.key === 'ArrowRight' || event.key === 'ArrowUp') && currentWidth < maxWidth) { - delta = 3 - } else { - return - } - setCurrentWidth(currentWidth + delta) - stableOnDrag.current?.(delta, true) - event.preventDefault() - } - - function handleKeyDragEnd(event: KeyboardEvent) { - isDraggingRef.current = false - isKeyboardDragRef.current = false + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if ( + event.key === 'ArrowLeft' || + event.key === 'ArrowRight' || + event.key === 'ArrowUp' || + event.key === 'ArrowDown' + ) { + event.preventDefault() + + let delta = 0 + // https://github.com/github/accessibility/issues/5101#issuecomment-1822870655 + if ((event.key === 'ArrowLeft' || event.key === 'ArrowDown') && currentWidth > minWidth) { + delta = -3 + } else if ((event.key === 'ArrowRight' || event.key === 'ArrowUp') && currentWidth < maxWidth) { + delta = 3 + } else { + return + } - // Clean up attributes directly - const draggableHandle = document.querySelector('[role="slider"]') as HTMLElement | null - if (draggableHandle) { - draggableHandle.removeAttribute('data-dragging') + setCurrentWidth(currentWidth + delta) + stableOnDrag.current?.(delta, true) } + }, + [currentWidth, minWidth, maxWidth], + ) - stableOnDragEnd.current?.() + const handleKeyUp = React.useCallback((event: React.KeyboardEvent) => { + if ( + event.key === 'ArrowLeft' || + event.key === 'ArrowRight' || + event.key === 'ArrowUp' || + event.key === 'ArrowDown' + ) { event.preventDefault() + stableOnDragEnd.current?.() } - - window.addEventListener('keydown', handleKeyDrag) - window.addEventListener('keyup', handleKeyDragEnd) - - return () => { - window.removeEventListener('keydown', handleKeyDrag) - window.removeEventListener('keyup', handleKeyDragEnd) - } - }, [minWidth, maxWidth, currentWidth]) + }, []) return (
@@ -549,8 +542,6 @@ const defaultPaneWidth = {small: 256, medium: 296, large: 320} const overflowProps = {tabIndex: 0, role: 'region'} -// CONTINUATION FROM PART 2 - Insert after overflowProps in Pane section - const Pane = React.forwardRef>( ( { @@ -849,11 +840,6 @@ const Footer: FCWithSlotMarker> = Footer.displayName = 'PageLayout.Footer' -// CONTINUATION FROM PART 3 - Final part with exports - -// ---------------------------------------------------------------------------- -// Export - export const PageLayout = Object.assign(Root, { __SLOT__: Symbol('PageLayout'), Header, From b069bfd3ad03df0d9d597a132efa11d4e35f30fc Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Fri, 28 Nov 2025 20:10:11 +0000 Subject: [PATCH 16/67] simplify stable --- packages/react/src/PageLayout/PageLayout.tsx | 44 +++++++------------- 1 file changed, 15 insertions(+), 29 deletions(-) diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index a5390416af0..7b01e52b97d 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -225,23 +225,6 @@ const VerticalDivider: React.FC { - if ( - event.key === 'ArrowLeft' || - event.key === 'ArrowRight' || - event.key === 'ArrowUp' || - event.key === 'ArrowDown' - ) { - isDraggingRef.current = 'keyboard' - - // Update attributes directly - const target = event.currentTarget as HTMLElement - target.setAttribute('data-dragging', 'true') - - stableOnDragStart.current?.() - } - }, []) - const handleKeyDown = React.useCallback( (event: React.KeyboardEvent) => { if ( @@ -252,21 +235,24 @@ const VerticalDivider: React.FC minWidth) { - delta = -3 - } else if ((event.key === 'ArrowRight' || event.key === 'ArrowUp') && currentWidth < maxWidth) { - delta = 3 - } else { - return - } + setCurrentWidth(prevWidth => { + let delta = 0 + // https://github.com/github/accessibility/issues/5101#issuecomment-1822870655 + if ((event.key === 'ArrowLeft' || event.key === 'ArrowDown') && prevWidth > minWidth) { + delta = -3 + } else if ((event.key === 'ArrowRight' || event.key === 'ArrowUp') && prevWidth < maxWidth) { + delta = 3 + } + + if (delta !== 0) { + stableOnDrag.current?.(delta, true) + } - setCurrentWidth(currentWidth + delta) - stableOnDrag.current?.(delta, true) + return prevWidth + delta + }) } }, - [currentWidth, minWidth, maxWidth], + [minWidth, maxWidth], ) const handleKeyUp = React.useCallback((event: React.KeyboardEvent) => { From c670f5ff443f5c8e2c98c65828eab8d34683d37b Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Fri, 28 Nov 2025 20:12:00 +0000 Subject: [PATCH 17/67] simplify stable --- packages/react/src/PageLayout/PageLayout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index 7b01e52b97d..e9619b48fc0 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -169,7 +169,7 @@ const VerticalDivider: React.FC { + React.useEffect(() => { stableOnDrag.current = onDrag stableOnDragEnd.current = onDragEnd stableOnDragStart.current = onDragStart From f71c9b0b8b631602a99a50eb2a5e6b10f5fa463d Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Fri, 28 Nov 2025 20:26:35 +0000 Subject: [PATCH 18/67] simplify stable --- packages/react/src/PageLayout/PageLayout.tsx | 35 +++++++++++--------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index e9619b48fc0..0e11e4c0a9a 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -152,7 +152,6 @@ type DraggableDividerProps = { onDragEnd?: () => void onDoubleClick?: () => void } - const VerticalDivider: React.FC> = ({ variant = 'none', draggable = false, @@ -164,7 +163,7 @@ const VerticalDivider: React.FC { - const isDraggingRef = React.useRef<'mouse' | 'keyboard' | false>(false) + const isDraggingRef = React.useRef(false) const stableOnDragStart = React.useRef(onDragStart) const stableOnDrag = React.useRef(onDrag) @@ -184,7 +183,7 @@ const VerticalDivider: React.FC { if (paneRef.current !== null) { - const paneStyles = getComputedStyle(paneRef.current as Element) + const paneStyles = getComputedStyle(paneRef.current) const maxPaneWidthDiffPixels = paneStyles.getPropertyValue('--pane-max-width-diff') const minWidthPixels = paneStyles.getPropertyValue('--pane-min-width') const paneWidth = paneRef.current.getBoundingClientRect().width @@ -198,17 +197,17 @@ const VerticalDivider: React.FC { + const handlePointerDown = React.useCallback((event: React.PointerEvent) => { if (event.button !== 0) return event.preventDefault() // OPTIMIZATION: Prevent browser defaults - const target = event.currentTarget as HTMLElement + const target = event.currentTarget target.setPointerCapture(event.pointerId) - isDraggingRef.current = 'mouse' + isDraggingRef.current = true target.setAttribute('data-dragging', 'true') stableOnDragStart.current?.() }, []) - const handlePointerMove = React.useCallback((event: React.PointerEvent) => { + const handlePointerMove = React.useCallback((event: React.PointerEvent) => { if (!isDraggingRef.current) return event.preventDefault() // OPTIMIZATION: Critical for smooth dragging if (event.movementX !== 0) { @@ -216,17 +215,22 @@ const VerticalDivider: React.FC { - if (isDraggingRef.current !== 'mouse') return - const target = event.currentTarget as HTMLElement - target.releasePointerCapture(event.pointerId) + const handlePointerUp = React.useCallback((event: React.PointerEvent) => { + if (!isDraggingRef.current) return + event.preventDefault() + // Cleanup will happen in onLostPointerCapture + }, []) + + const handleLostPointerCapture = React.useCallback((event: React.PointerEvent) => { + if (!isDraggingRef.current) return isDraggingRef.current = false + const target = event.currentTarget target.removeAttribute('data-dragging') stableOnDragEnd.current?.() }, []) const handleKeyDown = React.useCallback( - (event: React.KeyboardEvent) => { + (event: React.KeyboardEvent) => { if ( event.key === 'ArrowLeft' || event.key === 'ArrowRight' || @@ -234,7 +238,8 @@ const VerticalDivider: React.FC { let delta = 0 // https://github.com/github/accessibility/issues/5101#issuecomment-1822870655 @@ -255,7 +260,7 @@ const VerticalDivider: React.FC { + const handleKeyUp = React.useCallback((event: React.KeyboardEvent) => { if ( event.key === 'ArrowLeft' || event.key === 'ArrowRight' || @@ -287,10 +292,10 @@ const VerticalDivider: React.FC ) : null}
From 1b6344487006c3d545760f4124fcb091603880e5 Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Fri, 28 Nov 2025 20:45:05 +0000 Subject: [PATCH 19/67] frame --- .../PageLayout.features.stories.tsx | 22 +++++- .../src/PageLayout/PageLayout.module.css | 2 + packages/react/src/PageLayout/PageLayout.tsx | 74 +++++++++++++------ 3 files changed, 73 insertions(+), 25 deletions(-) diff --git a/packages/react/src/PageLayout/PageLayout.features.stories.tsx b/packages/react/src/PageLayout/PageLayout.features.stories.tsx index aef5c9346b0..2b493d2004c 100644 --- a/packages/react/src/PageLayout/PageLayout.features.stories.tsx +++ b/packages/react/src/PageLayout/PageLayout.features.stories.tsx @@ -291,7 +291,27 @@ export const ResizablePane: StoryFn = () => ( - + + {/** 10 columns, 1000 rows*/} + + + {Array.from({length: 10}).map((_, colIndex) => ( + + ))} + + + + {Array.from({length: 1000}).map((_, rowIndex) => ( + + {Array.from({length: 10}).map((_, colIndex) => ( + + ))} + + ))} + +
Header {colIndex + 1}
+ Row {rowIndex + 1} - Col {colIndex + 1} +
diff --git a/packages/react/src/PageLayout/PageLayout.module.css b/packages/react/src/PageLayout/PageLayout.module.css index 00972725ffe..f0a1e130acc 100644 --- a/packages/react/src/PageLayout/PageLayout.module.css +++ b/packages/react/src/PageLayout/PageLayout.module.css @@ -573,6 +573,8 @@ /* stylelint-disable-next-line primer/spacing */ padding: var(--spacing); + contain: layout style paint; + @media screen and (min-width: 768px) { overflow: auto; } diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index 0e11e4c0a9a..099995d63b6 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -197,15 +197,21 @@ const VerticalDivider: React.FC) => { - if (event.button !== 0) return - event.preventDefault() // OPTIMIZATION: Prevent browser defaults - const target = event.currentTarget - target.setPointerCapture(event.pointerId) - isDraggingRef.current = true - target.setAttribute('data-dragging', 'true') - stableOnDragStart.current?.() - }, []) + const handlePointerDown = React.useCallback( + (event: React.PointerEvent) => { + if (event.button !== 0) return + event.preventDefault() // OPTIMIZATION: Prevent browser defaults + const target = event.currentTarget + target.setPointerCapture(event.pointerId) + isDraggingRef.current = true + target.setAttribute('data-dragging', 'true') + if (paneRef.current) { + paneRef.current.style.willChange = 'width' + } + stableOnDragStart.current?.() + }, + [paneRef], + ) const handlePointerMove = React.useCallback((event: React.PointerEvent) => { if (!isDraggingRef.current) return @@ -221,13 +227,19 @@ const VerticalDivider: React.FC) => { - if (!isDraggingRef.current) return - isDraggingRef.current = false - const target = event.currentTarget - target.removeAttribute('data-dragging') - stableOnDragEnd.current?.() - }, []) + const handleLostPointerCapture = React.useCallback( + (event: React.PointerEvent) => { + if (!isDraggingRef.current) return + isDraggingRef.current = false + const target = event.currentTarget + target.removeAttribute('data-dragging') + if (paneRef.current) { + paneRef.current.style.willChange = 'auto' + } + stableOnDragEnd.current?.() + }, + [paneRef], + ) const handleKeyDown = React.useCallback( (event: React.KeyboardEvent) => { @@ -627,6 +639,8 @@ const Pane = React.forwardRef(null) + return (
{ - // Adjust delta direction based on pane position const deltaWithDirection = isKeyboard ? delta : position === 'end' ? -delta : delta if (isKeyboard) { // OPTIMIZATION: Keyboard uses React state for immediate visual feedback setPaneWidth(prev => prev + deltaWithDirection) } else { - // OPTIMIZATION: Pointer drag - direct DOM manipulation for 120fps - // Accumulate delta without triggering React re-renders + // OPTIMIZATION: Pointer drag - batch DOM updates in requestAnimationFrame + // This eliminates layout thrashing by grouping all reads, then all writes dragDeltaRef.current += deltaWithDirection - if (paneRef.current) { - const newWidth = paneWidth + dragDeltaRef.current - // Direct CSS variable update - bypasses React reconciliation - paneRef.current.style.setProperty('--pane-width', `${newWidth}px`) + + if (!animationFrameRef.current) { + animationFrameRef.current = requestAnimationFrame(() => { + if (paneRef.current) { + // BATCH READS: Get all DOM measurements first + // const {minWidth: minPaneWidth, maxWidth: maxPaneWidth} = getMinMaxWidth() + const newWidth = paneWidth + dragDeltaRef.current + // BATCH WRITES: Update DOM after all reads complete + paneRef.current.style.setProperty('--pane-width', `${newWidth}px`) + } + animationFrameRef.current = null + }) } } }} onDragEnd={() => { + // Clear any pending animation frame + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current) + animationFrameRef.current = null + } + // OPTIMIZATION: Commit accumulated pointer drag delta to React state - // This is the only state update during the entire drag operation const totalDelta = dragDeltaRef.current if (totalDelta !== 0) { setPaneWidth(prev => { From 8f9d1e5a3fc74d25c8488795e4318e20109211fa Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Fri, 28 Nov 2025 20:59:37 +0000 Subject: [PATCH 20/67] constrain --- packages/react/src/PageLayout/PageLayout.tsx | 80 ++++++++++++++------ 1 file changed, 55 insertions(+), 25 deletions(-) diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index 099995d63b6..dd3c77cd7ee 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -183,16 +183,10 @@ const VerticalDivider: React.FC { if (paneRef.current !== null) { - const paneStyles = getComputedStyle(paneRef.current) - const maxPaneWidthDiffPixels = paneStyles.getPropertyValue('--pane-max-width-diff') - const minWidthPixels = paneStyles.getPropertyValue('--pane-min-width') + const constraints = getConstraints(paneRef.current) 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) + setMinWidth(constraints.minWidth) + setMaxWidth(constraints.maxWidth) setCurrentWidth(paneWidth) } }, [paneRef]) @@ -641,6 +635,30 @@ const Pane = React.forwardRef(null) + React.useEffect(() => { + const pane = paneRef.current + if (!pane) return + + const updateIntrinsicSize = () => { + const height = pane.scrollHeight + pane.style.containIntrinsicSize = `auto ${height}px` + } + + // Initial measurement + pane.style.contentVisibility = 'auto' + updateIntrinsicSize() + + // Update when content size changes + const resizeObserver = new ResizeObserver(updateIntrinsicSize) + resizeObserver.observe(pane) + + return () => { + resizeObserver.disconnect() + pane.style.contentVisibility = '' + pane.style.containIntrinsicSize = '' + } + }, [paneRef]) + return (
prev + deltaWithDirection) } else { // OPTIMIZATION: Pointer drag - batch DOM updates in requestAnimationFrame - // This eliminates layout thrashing by grouping all reads, then all writes dragDeltaRef.current += deltaWithDirection if (!animationFrameRef.current) { animationFrameRef.current = requestAnimationFrame(() => { if (paneRef.current) { - // BATCH READS: Get all DOM measurements first - // const {minWidth: minPaneWidth, maxWidth: maxPaneWidth} = getMinMaxWidth() + // CHANGED: Use helper instead of inline calculation + const {minWidth: minPaneWidth, maxWidth: maxPaneWidth} = getConstraints(paneRef.current) + const newWidth = paneWidth + dragDeltaRef.current - // BATCH WRITES: Update DOM after all reads complete - paneRef.current.style.setProperty('--pane-width', `${newWidth}px`) + const clampedWidth = Math.max(minPaneWidth, Math.min(maxPaneWidth, newWidth)) + + paneRef.current.style.setProperty('--pane-width', `${clampedWidth}px`) } animationFrameRef.current = null }) @@ -737,16 +755,17 @@ const Pane = React.forwardRef { - const newWidth = prev + totalDelta - try { - localStorage.setItem(widthStorageKey, newWidth.toString()) - } catch (_error) { - // Ignore errors - } - return newWidth - }) + if (totalDelta !== 0 && paneRef.current) { + // FIX: Read the actual applied width from DOM to handle clamping + const actualWidth = parseInt(paneRef.current.style.getPropertyValue('--pane-width')) || paneWidth + + setPaneWidth(actualWidth) // Use actual width, not paneWidth + totalDelta + + try { + localStorage.setItem(widthStorageKey, actualWidth.toString()) + } catch (_error) { + // Ignore errors + } dragDeltaRef.current = 0 } }} @@ -869,3 +888,14 @@ Header.__SLOT__ = Symbol('PageLayout.Header') Content.__SLOT__ = Symbol('PageLayout.Content') ;(Pane as WithSlotMarker).__SLOT__ = Symbol('PageLayout.Pane') Footer.__SLOT__ = Symbol('PageLayout.Footer') + +// Add this helper function before VerticalDivider component (around line 157) +function getConstraints(element: HTMLElement) { + const paneStyles = getComputedStyle(element) + const maxPaneWidthDiff = Number(paneStyles.getPropertyValue('--pane-max-width-diff').split('px')[0]) || 511 + const minPaneWidth = Number(paneStyles.getPropertyValue('--pane-min-width').split('px')[0]) || 256 + const viewportWidth = window.innerWidth + const maxPaneWidth = viewportWidth > maxPaneWidthDiff ? viewportWidth - maxPaneWidthDiff : viewportWidth + + return {minWidth: minPaneWidth, maxWidth: maxPaneWidth} +} From 24ff5c51dab232d808ca9a1d807fa62905154cf6 Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Fri, 28 Nov 2025 21:23:20 +0000 Subject: [PATCH 21/67] mcss --- .../src/PageLayout/PageLayout.module.css | 49 ++++++++++++++++++- packages/react/src/PageLayout/PageLayout.tsx | 41 +++++++++++++--- 2 files changed, 81 insertions(+), 9 deletions(-) diff --git a/packages/react/src/PageLayout/PageLayout.module.css b/packages/react/src/PageLayout/PageLayout.module.css index f0a1e130acc..d15866861d0 100644 --- a/packages/react/src/PageLayout/PageLayout.module.css +++ b/packages/react/src/PageLayout/PageLayout.module.css @@ -345,6 +345,10 @@ flex-grow: 1; flex-shrink: 1; + /* OPTIMIZATION: Isolate content area from rest of page + Note: No 'paint' containment to allow overflow effects (tooltips, modals, etc.) */ + contain: layout style; + &:where([data-is-hidden='true']) { display: none; } @@ -371,6 +375,26 @@ } } +/* OPTIMIZATION: Aggressive containment during drag for ContentWrapper + Safe to add 'paint' containment since user can't interact during drag */ +.PageLayoutContent:has(.DraggableHandle[data-dragging='true']) .ContentWrapper { + /* Add paint containment during drag */ + contain: layout style paint; + + /* Disable interactions */ + pointer-events: none; + + /* Disable transitions - prevents expensive recalculations */ + transition: none !important; +} + +/* OPTIMIZATION: Disable animations in ContentWrapper during drag + Prevents expensive animation recalculations on every frame */ +.PageLayoutContent:has(.DraggableHandle[data-dragging='true']) .ContentWrapper * { + animation-play-state: paused !important; + transition: none !important; +} + .Content { width: 100%; @@ -573,7 +597,14 @@ /* stylelint-disable-next-line primer/spacing */ padding: var(--spacing); + /* OPTIMIZATION: Full containment for pane - isolates from rest of page */ contain: layout style paint; + + /* OPTIMIZATION: For extremely tall content - skip rendering off-screen content */ + content-visibility: auto; + + /* OPTIMIZATION: Provide size estimate for content-visibility */ + contain-intrinsic-size: auto 100vh; @media screen and (min-width: 768px) { overflow: auto; @@ -588,6 +619,22 @@ } } +/* OPTIMIZATION: Additional optimizations for Pane during drag + Redundant with JavaScript but provides fallback */ +.PaneWrapper:has(.DraggableHandle[data-dragging='true']) .Pane { + /* Disable interactions during drag */ + pointer-events: none; + + /* Disable transitions during drag */ + transition: none !important; +} + +/* OPTIMIZATION: Disable animations in Pane during drag */ +.PaneWrapper:has(.DraggableHandle[data-dragging='true']) .Pane * { + animation-play-state: paused !important; + transition: none !important; +} + .PaneHorizontalDivider { &:where([data-position='start']) { /* stylelint-disable-next-line primer/spacing */ @@ -707,4 +754,4 @@ .DraggableHandle[data-dragging='true']:hover { background-color: var(--bgColor-accent-emphasis); -} +} \ No newline at end of file diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index dd3c77cd7ee..16acbc41086 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -201,6 +201,13 @@ const VerticalDivider: React.FC { + if (paneRef.current) { + const newHeight = paneRef.current.scrollHeight + paneRef.current.style.containIntrinsicSize = `auto ${newHeight}px` + } + }) } + stableOnDragEnd.current?.() }, [paneRef], @@ -639,17 +658,23 @@ const Pane = React.forwardRef { - const height = pane.scrollHeight - pane.style.containIntrinsicSize = `auto ${height}px` - } - // Initial measurement + const height = pane.scrollHeight pane.style.contentVisibility = 'auto' - updateIntrinsicSize() + pane.style.containIntrinsicSize = `auto ${height}px` + + // Update when content changes (but not during drag) + const updateSize = () => { + // Only update if not currently dragging + const isDragging = document.querySelector('.DraggableHandle[data-dragging="true"]') + if (!isDragging) { + const newHeight = pane.scrollHeight + pane.style.containIntrinsicSize = `auto ${newHeight}px` + } + } - // Update when content size changes - const resizeObserver = new ResizeObserver(updateIntrinsicSize) + // Use ResizeObserver to detect content changes + const resizeObserver = new ResizeObserver(updateSize) resizeObserver.observe(pane) return () => { From 24ba053d08c180d28b0099f307de123ecb7b91e9 Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Fri, 28 Nov 2025 21:32:48 +0000 Subject: [PATCH 22/67] smoother --- .../src/PageLayout/PageLayout.module.css | 22 +++++++++ packages/react/src/PageLayout/PageLayout.tsx | 45 +++++++++++++------ 2 files changed, 53 insertions(+), 14 deletions(-) diff --git a/packages/react/src/PageLayout/PageLayout.module.css b/packages/react/src/PageLayout/PageLayout.module.css index d15866861d0..a1e5c584106 100644 --- a/packages/react/src/PageLayout/PageLayout.module.css +++ b/packages/react/src/PageLayout/PageLayout.module.css @@ -386,6 +386,17 @@ /* Disable transitions - prevents expensive recalculations */ transition: none !important; + + content-visibility: auto; + contain-intrinsic-size: auto 100vh; +} + +/* Hide complex UI during drag */ +.PageLayoutContent:has(.DraggableHandle[data-dragging='true']) .ContentWrapper [data-expensive], +.PageLayoutContent:has(.DraggableHandle[data-dragging='true']) .ContentWrapper canvas, +.PageLayoutContent:has(.DraggableHandle[data-dragging='true']) .ContentWrapper video { + /* Hide expensive elements during drag */ + visibility: hidden !important; } /* OPTIMIZATION: Disable animations in ContentWrapper during drag @@ -606,6 +617,9 @@ /* OPTIMIZATION: Provide size estimate for content-visibility */ contain-intrinsic-size: auto 100vh; + image-rendering: auto; + text-rendering: optimizeLegibility; + @media screen and (min-width: 768px) { overflow: auto; } @@ -627,6 +641,14 @@ /* Disable transitions during drag */ transition: none !important; + + /* NEW: Reduce rendering quality during drag */ + image-rendering: pixelated; + text-rendering: optimizeSpeed; + + /* Force hardware acceleration */ + transform: translateZ(0); + backface-visibility: hidden; } /* OPTIMIZATION: Disable animations in Pane during drag */ diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index 16acbc41086..4f8e4c046de 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -194,31 +194,41 @@ const VerticalDivider: React.FC) => { if (event.button !== 0) return - event.preventDefault() // OPTIMIZATION: Prevent browser defaults + event.preventDefault() const target = event.currentTarget target.setPointerCapture(event.pointerId) isDraggingRef.current = true target.setAttribute('data-dragging', 'true') + if (paneRef.current) { paneRef.current.style.willChange = 'width' const currentHeight = paneRef.current.scrollHeight paneRef.current.style.containIntrinsicSize = `auto ${currentHeight}px` - // Disable interactions paneRef.current.style.pointerEvents = 'none' - - // Disable animations/transitions paneRef.current.style.transitionProperty = 'none' + // paneRef.current.style.contentVisibility = 'hidden' } + stableOnDragStart.current?.() }, [paneRef], ) + const pointerMoveThrottleRafIdRef = React.useRef(null) + const handlePointerMove = React.useCallback((event: React.PointerEvent) => { if (!isDraggingRef.current) return - event.preventDefault() // OPTIMIZATION: Critical for smooth dragging + event.preventDefault() + if (event.movementX !== 0) { - stableOnDrag.current?.(event.movementX, false) + // OPTIMIZATION: Throttle to every other frame for huge DOM + // This skips every other drag event, halving layout work + if (!pointerMoveThrottleRafIdRef.current) { + pointerMoveThrottleRafIdRef.current = requestAnimationFrame(() => { + stableOnDrag.current?.(event.movementX, false) + pointerMoveThrottleRafIdRef.current = null + }) + } } }, []) @@ -234,13 +244,14 @@ const VerticalDivider: React.FC { if (paneRef.current) { const newHeight = paneRef.current.scrollHeight @@ -752,22 +763,28 @@ const Pane = React.forwardRef prev + deltaWithDirection) } else { - // OPTIMIZATION: Pointer drag - batch DOM updates in requestAnimationFrame dragDeltaRef.current += deltaWithDirection if (!animationFrameRef.current) { - animationFrameRef.current = requestAnimationFrame(() => { + let skippedFrame = false + + const applyUpdate = () => { + if (!skippedFrame) { + skippedFrame = true + animationFrameRef.current = requestAnimationFrame(applyUpdate) + return + } + if (paneRef.current) { - // CHANGED: Use helper instead of inline calculation const {minWidth: minPaneWidth, maxWidth: maxPaneWidth} = getConstraints(paneRef.current) - const newWidth = paneWidth + dragDeltaRef.current const clampedWidth = Math.max(minPaneWidth, Math.min(maxPaneWidth, newWidth)) - paneRef.current.style.setProperty('--pane-width', `${clampedWidth}px`) } animationFrameRef.current = null - }) + } + + animationFrameRef.current = requestAnimationFrame(applyUpdate) } } }} From 7770e636e830756d0f440e048494f04c4f508586 Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Fri, 28 Nov 2025 21:54:23 +0000 Subject: [PATCH 23/67] updates --- .../src/PageLayout/PageLayout.module.css | 94 +++++++---- packages/react/src/PageLayout/PageLayout.tsx | 147 +++++++++++------- 2 files changed, 159 insertions(+), 82 deletions(-) diff --git a/packages/react/src/PageLayout/PageLayout.module.css b/packages/react/src/PageLayout/PageLayout.module.css index a1e5c584106..eb805ae850f 100644 --- a/packages/react/src/PageLayout/PageLayout.module.css +++ b/packages/react/src/PageLayout/PageLayout.module.css @@ -345,8 +345,10 @@ flex-grow: 1; flex-shrink: 1; - /* OPTIMIZATION: Isolate content area from rest of page - Note: No 'paint' containment to allow overflow effects (tooltips, modals, etc.) */ + /** + * OPTIMIZATION: Isolate content area from rest of page + * Note: No 'paint' containment to allow overflow effects (tooltips, modals, etc.) + */ contain: layout style; &:where([data-is-hidden='true']) { @@ -375,32 +377,30 @@ } } -/* OPTIMIZATION: Aggressive containment during drag for ContentWrapper - Safe to add 'paint' containment since user can't interact during drag */ +/** + * OPTIMIZATION: Aggressive containment during drag for ContentWrapper + * CSS handles most optimizations automatically via :has() selector + * JavaScript only handles scroll locking (can't be done in CSS) + */ .PageLayoutContent:has(.DraggableHandle[data-dragging='true']) .ContentWrapper { - /* Add paint containment during drag */ + /* Add paint containment during drag - safe since user can't interact */ contain: layout style paint; /* Disable interactions */ pointer-events: none; - /* Disable transitions - prevents expensive recalculations */ + /* Disable transitions to prevent expensive recalculations */ transition: none !important; - content-visibility: auto; - contain-intrinsic-size: auto 100vh; -} - -/* Hide complex UI during drag */ -.PageLayoutContent:has(.DraggableHandle[data-dragging='true']) .ContentWrapper [data-expensive], -.PageLayoutContent:has(.DraggableHandle[data-dragging='true']) .ContentWrapper canvas, -.PageLayoutContent:has(.DraggableHandle[data-dragging='true']) .ContentWrapper video { - /* Hide expensive elements during drag */ - visibility: hidden !important; + /* Force compositor layer for hardware acceleration */ + will-change: width; + transform: translateZ(0); } -/* OPTIMIZATION: Disable animations in ContentWrapper during drag - Prevents expensive animation recalculations on every frame */ +/** + * OPTIMIZATION: Disable animations in ContentWrapper during drag + * Prevents expensive animation recalculations on every frame + */ .PageLayoutContent:has(.DraggableHandle[data-dragging='true']) .ContentWrapper * { animation-play-state: paused !important; transition: none !important; @@ -608,18 +608,22 @@ /* stylelint-disable-next-line primer/spacing */ padding: var(--spacing); - /* OPTIMIZATION: Full containment for pane - isolates from rest of page */ + /** + * OPTIMIZATION: Full containment for pane - isolates from rest of page + */ contain: layout style paint; - /* OPTIMIZATION: For extremely tall content - skip rendering off-screen content */ + /** + * OPTIMIZATION: For extremely tall content - skip rendering off-screen content + */ content-visibility: auto; - /* OPTIMIZATION: Provide size estimate for content-visibility */ + /** + * OPTIMIZATION: Provide size estimate for content-visibility + * JavaScript updates this dynamically based on actual content height + */ contain-intrinsic-size: auto 100vh; - image-rendering: auto; - text-rendering: optimizeLegibility; - @media screen and (min-width: 768px) { overflow: auto; } @@ -633,8 +637,10 @@ } } -/* OPTIMIZATION: Additional optimizations for Pane during drag - Redundant with JavaScript but provides fallback */ +/** + * OPTIMIZATION: Performance enhancements for Pane during drag + * CSS handles all optimizations automatically - JavaScript only locks scroll + */ .PaneWrapper:has(.DraggableHandle[data-dragging='true']) .Pane { /* Disable interactions during drag */ pointer-events: none; @@ -642,16 +648,19 @@ /* Disable transitions during drag */ transition: none !important; - /* NEW: Reduce rendering quality during drag */ + /* Reduce rendering quality during drag */ image-rendering: pixelated; text-rendering: optimizeSpeed; /* Force hardware acceleration */ + will-change: width, transform; transform: translateZ(0); backface-visibility: hidden; } -/* OPTIMIZATION: Disable animations in Pane during drag */ +/** + * OPTIMIZATION: Disable animations in Pane during drag + */ .PaneWrapper:has(.DraggableHandle[data-dragging='true']) .Pane * { animation-play-state: paused !important; transition: none !important; @@ -755,12 +764,20 @@ padding: var(--spacing); } +/** + * DraggableHandle - Interactive resize handle + */ .DraggableHandle { 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 + */ touch-action: none; user-select: none; } @@ -776,4 +793,25 @@ .DraggableHandle[data-dragging='true']:hover { background-color: var(--bgColor-accent-emphasis); +} + +/** + * ACCESSIBILITY: Keyboard focus styles + * Show focus indicator when navigating via keyboard + */ +.DraggableHandle:focus { + outline: 2px solid var(--fgColor-accent); + outline-offset: 2px; +} + +/** + * Only show focus outline when navigating via keyboard, not mouse + */ +.DraggableHandle:focus:not(:focus-visible) { + outline: none; +} + +.DraggableHandle:focus-visible { + outline: 2px solid var(--fgColor-accent); + outline-offset: 2px; } \ No newline at end of file diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index 4f8e4c046de..aa8056dae95 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -152,6 +152,17 @@ type DraggableDividerProps = { onDragEnd?: () => void onDoubleClick?: () => void } + +function getConstraints(element: HTMLElement) { + const paneStyles = getComputedStyle(element) + const maxPaneWidthDiff = Number(paneStyles.getPropertyValue('--pane-max-width-diff').split('px')[0]) || 511 + const minPaneWidth = Number(paneStyles.getPropertyValue('--pane-min-width').split('px')[0]) || 256 + const viewportWidth = window.innerWidth + const maxPaneWidth = viewportWidth > maxPaneWidthDiff ? viewportWidth - maxPaneWidthDiff : viewportWidth + + return {minWidth: minPaneWidth, maxWidth: maxPaneWidth} +} + const VerticalDivider: React.FC> = ({ variant = 'none', draggable = false, @@ -176,6 +187,13 @@ const VerticalDivider: React.FC(null) + + React.useLayoutEffect(() => { + contentWrapperRef.current = document.querySelector('.ContentWrapper') + }, []) + const [minWidth, setMinWidth] = React.useState(0) const [maxWidth, setMaxWidth] = React.useState(0) const [currentWidth, setCurrentWidth] = React.useState(0) @@ -201,12 +219,22 @@ const VerticalDivider: React.FC { - stableOnDrag.current?.(event.movementX, false) - pointerMoveThrottleRafIdRef.current = null - }) + // Snap to 4px grid - reduces updates by 75% + const quantized = Math.round(event.movementX / 4) * 4 + if (quantized !== 0) { + // Throttle to every other frame for huge DOM + if (!pointerMoveThrottleRafIdRef.current) { + pointerMoveThrottleRafIdRef.current = requestAnimationFrame(() => { + stableOnDrag.current?.(quantized, false) + pointerMoveThrottleRafIdRef.current = null + }) + } } } }, []) @@ -245,19 +276,30 @@ const VerticalDivider: React.FC { if (paneRef.current) { const newHeight = paneRef.current.scrollHeight paneRef.current.style.containIntrinsicSize = `auto ${newHeight}px` } }) + + // Restore ContentWrapper scroll + const contentWrapper = contentWrapperRef.current + if (contentWrapper) { + contentWrapper.style.overflow = '' + } } stableOnDragEnd.current?.() @@ -274,8 +316,7 @@ const VerticalDivider: React.FC { let delta = 0 // https://github.com/github/accessibility/issues/5101#issuecomment-1822870655 @@ -637,9 +678,8 @@ const Pane = React.forwardRef(null) + const lastFrameTimeRef = React.useRef(0) + const TARGET_FPS_DURING_DRAG = 30 + const FRAME_BUDGET = 1000 / TARGET_FPS_DURING_DRAG + + // Extract duplicate width calculation logic + const applyWidthUpdate = React.useCallback(() => { + if (paneRef.current) { + const {minWidth: minPaneWidth, maxWidth: maxPaneWidth} = getConstraints(paneRef.current) + const newWidth = paneWidth + accumulatedDragDeltaRef.current + const clampedWidth = Math.max(minPaneWidth, Math.min(maxPaneWidth, newWidth)) + paneRef.current.style.setProperty('--pane-width', `${clampedWidth}px`) + } + animationFrameRef.current = null + }, [paneRef, paneWidth]) React.useEffect(() => { const pane = paneRef.current @@ -763,28 +817,24 @@ const Pane = React.forwardRef prev + deltaWithDirection) } else { - dragDeltaRef.current += deltaWithDirection + // Accumulate deltas + accumulatedDragDeltaRef.current += deltaWithDirection if (!animationFrameRef.current) { - let skippedFrame = false - - const applyUpdate = () => { - if (!skippedFrame) { - skippedFrame = true - animationFrameRef.current = requestAnimationFrame(applyUpdate) + animationFrameRef.current = requestAnimationFrame(timestamp => { + // Cap at 30fps for smoother experience with huge DOM + if (timestamp - lastFrameTimeRef.current < FRAME_BUDGET) { + // Skip this frame, reschedule + animationFrameRef.current = requestAnimationFrame(ts => { + lastFrameTimeRef.current = ts + applyWidthUpdate() + }) return } - if (paneRef.current) { - const {minWidth: minPaneWidth, maxWidth: maxPaneWidth} = getConstraints(paneRef.current) - const newWidth = paneWidth + dragDeltaRef.current - const clampedWidth = Math.max(minPaneWidth, Math.min(maxPaneWidth, newWidth)) - paneRef.current.style.setProperty('--pane-width', `${clampedWidth}px`) - } - animationFrameRef.current = null - } - - animationFrameRef.current = requestAnimationFrame(applyUpdate) + lastFrameTimeRef.current = timestamp + applyWidthUpdate() + }) } } }} @@ -795,10 +845,10 @@ const Pane = React.forwardRef { - dragDeltaRef.current = 0 + accumulatedDragDeltaRef.current = 0 const defaultWidth = getDefaultPaneWidth(width) setPaneWidth(defaultWidth) try { @@ -930,14 +980,3 @@ Header.__SLOT__ = Symbol('PageLayout.Header') Content.__SLOT__ = Symbol('PageLayout.Content') ;(Pane as WithSlotMarker).__SLOT__ = Symbol('PageLayout.Pane') Footer.__SLOT__ = Symbol('PageLayout.Footer') - -// Add this helper function before VerticalDivider component (around line 157) -function getConstraints(element: HTMLElement) { - const paneStyles = getComputedStyle(element) - const maxPaneWidthDiff = Number(paneStyles.getPropertyValue('--pane-max-width-diff').split('px')[0]) || 511 - const minPaneWidth = Number(paneStyles.getPropertyValue('--pane-min-width').split('px')[0]) || 256 - const viewportWidth = window.innerWidth - const maxPaneWidth = viewportWidth > maxPaneWidthDiff ? viewportWidth - maxPaneWidthDiff : viewportWidth - - return {minWidth: minPaneWidth, maxWidth: maxPaneWidth} -} From 80270a7307a2d1102c8fa3ef3022d387a19b7632 Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Fri, 28 Nov 2025 21:59:24 +0000 Subject: [PATCH 24/67] updates --- packages/react/src/PageLayout/PageLayout.tsx | 38 +++++++++++++------- 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index aa8056dae95..c210eb0a72e 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -209,6 +209,10 @@ const VerticalDivider: React.FC) => { if (event.button !== 0) return @@ -218,6 +222,10 @@ const VerticalDivider: React.FC { - stableOnDrag.current?.(quantized, false) - pointerMoveThrottleRafIdRef.current = null - }) - } + // Calculate total delta from start position + const totalDelta = event.clientX - dragStartXRef.current + + // Snap to 4px grid + const quantized = Math.round(totalDelta / 4) * 4 + + // Only update if quantized position changed + const lastQuantized = Math.round((lastAppliedXRef.current - dragStartXRef.current) / 4) * 4 + if (quantized !== lastQuantized) { + const delta = quantized - lastQuantized + lastAppliedXRef.current = dragStartXRef.current + quantized + + // Throttle to every other frame for huge DOM + if (!pointerMoveThrottleRafIdRef.current) { + pointerMoveThrottleRafIdRef.current = requestAnimationFrame(() => { + stableOnDrag.current?.(delta, false) + pointerMoveThrottleRafIdRef.current = null + }) } } }, []) @@ -708,7 +723,6 @@ const Pane = React.forwardRef { if (paneRef.current) { const {minWidth: minPaneWidth, maxWidth: maxPaneWidth} = getConstraints(paneRef.current) From f165dd92a7987dadf7d4ff99283d8c3f503cd85c Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Fri, 28 Nov 2025 22:04:16 +0000 Subject: [PATCH 25/67] progressive fps --- packages/react/src/PageLayout/PageLayout.tsx | 34 +++++++++++++++++--- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index c210eb0a72e..33621ea6d68 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -268,7 +268,7 @@ const VerticalDivider: React.FC { stableOnDrag.current?.(delta, false) @@ -720,9 +720,10 @@ const Pane = React.forwardRef(null) const lastFrameTimeRef = React.useRef(0) - const TARGET_FPS_DURING_DRAG = 30 - const FRAME_BUDGET = 1000 / TARGET_FPS_DURING_DRAG + const targetFpsRef = React.useRef(60) // Start optimistic at 60fps + const slowFrameCountRef = React.useRef(0) + // Extract duplicate width calculation logic const applyWidthUpdate = React.useCallback(() => { if (paneRef.current) { const {minWidth: minPaneWidth, maxWidth: maxPaneWidth} = getConstraints(paneRef.current) @@ -836,8 +837,27 @@ const Pane = React.forwardRef { - // Cap at 30fps for smoother experience with huge DOM - if (timestamp - lastFrameTimeRef.current < FRAME_BUDGET) { + const frameBudget = 1000 / targetFpsRef.current + const timeSinceLastFrame = timestamp - lastFrameTimeRef.current + + // Adaptive FPS: Detect slow frames and downgrade if needed + if (lastFrameTimeRef.current > 0) { + // Frame took too long (missed our target by 50%+) + if (timeSinceLastFrame > frameBudget * 1.5) { + slowFrameCountRef.current++ + + // After 3 consecutive slow frames, reduce target FPS + if (slowFrameCountRef.current >= 3 && targetFpsRef.current > 30) { + targetFpsRef.current = 30 + } + } else { + // Frame was fast enough, reset counter + slowFrameCountRef.current = 0 + } + } + + // Throttle: Skip frame if too soon + if (timeSinceLastFrame < frameBudget) { // Skip this frame, reschedule animationFrameRef.current = requestAnimationFrame(ts => { lastFrameTimeRef.current = ts @@ -859,6 +879,10 @@ const Pane = React.forwardRef Date: Sat, 29 Nov 2025 18:01:15 +0000 Subject: [PATCH 26/67] update snapshots --- .../src/PageLayout/__snapshots__/PageLayout.test.tsx.snap | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/react/src/PageLayout/__snapshots__/PageLayout.test.tsx.snap b/packages/react/src/PageLayout/__snapshots__/PageLayout.test.tsx.snap index 5ac82e6ca02..da5cb0ec6c1 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
From d4a70916e94905f245ddbd23c69d394a5bab3a1b Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Sat, 29 Nov 2025 18:09:56 +0000 Subject: [PATCH 27/67] simplify a bit --- packages/react/src/PageLayout/PageLayout.tsx | 213 ++++--------------- 1 file changed, 39 insertions(+), 174 deletions(-) diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index 33621ea6d68..1008ce5650d 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -187,13 +187,6 @@ const VerticalDivider: React.FC(null) - - React.useLayoutEffect(() => { - contentWrapperRef.current = document.querySelector('.ContentWrapper') - }, []) - const [minWidth, setMinWidth] = React.useState(0) const [maxWidth, setMaxWidth] = React.useState(0) const [currentWidth, setCurrentWidth] = React.useState(0) @@ -209,72 +202,26 @@ const VerticalDivider: React.FC) => { - if (event.button !== 0) return - event.preventDefault() - const target = event.currentTarget - target.setPointerCapture(event.pointerId) - isDraggingRef.current = true - target.setAttribute('data-dragging', 'true') - - // Track start position - dragStartXRef.current = event.clientX - lastAppliedXRef.current = event.clientX - - if (paneRef.current) { - // Essential JS optimizations that can't be done in CSS - const currentHeight = paneRef.current.scrollHeight - paneRef.current.style.containIntrinsicSize = `auto ${currentHeight}px` - - // Lock scroll position to prevent reflow triggers - const scrollTop = paneRef.current.scrollTop - paneRef.current.style.overflow = 'hidden' - paneRef.current.scrollTop = scrollTop - - // Optimize ContentWrapper - const contentWrapper = contentWrapperRef.current - if (contentWrapper) { - const contentScrollTop = contentWrapper.scrollTop || 0 - contentWrapper.style.overflow = 'hidden' - contentWrapper.scrollTop = contentScrollTop - } - } + const handlePointerDown = React.useCallback((event: React.PointerEvent) => { + if (event.button !== 0) return + event.preventDefault() + const target = event.currentTarget + target.setPointerCapture(event.pointerId) + isDraggingRef.current = true + target.setAttribute('data-dragging', 'true') - stableOnDragStart.current?.() - }, - [paneRef], - ) + // Set global cursor so it doesn't separate from divider during fast drags + document.body.style.cursor = 'col-resize' - const pointerMoveThrottleRafIdRef = React.useRef(null) + stableOnDragStart.current?.() + }, []) const handlePointerMove = React.useCallback((event: React.PointerEvent) => { if (!isDraggingRef.current) return event.preventDefault() - // Calculate total delta from start position - const totalDelta = event.clientX - dragStartXRef.current - - // Snap to 4px grid - const quantized = Math.round(totalDelta / 4) * 4 - - // Only update if quantized position changed - const lastQuantized = Math.round((lastAppliedXRef.current - dragStartXRef.current) / 4) * 4 - if (quantized !== lastQuantized) { - const delta = quantized - lastQuantized - lastAppliedXRef.current = dragStartXRef.current + quantized - - // Throttle based on adaptive FPS target - if (!pointerMoveThrottleRafIdRef.current) { - pointerMoveThrottleRafIdRef.current = requestAnimationFrame(() => { - stableOnDrag.current?.(delta, false) - pointerMoveThrottleRafIdRef.current = null - }) - } + if (event.movementX !== 0) { + stableOnDrag.current?.(event.movementX, false) } }, []) @@ -284,43 +231,17 @@ const VerticalDivider: React.FC) => { - if (!isDraggingRef.current) return - isDraggingRef.current = false - const target = event.currentTarget - target.removeAttribute('data-dragging') - - // Cancel any pending throttle frame - if (pointerMoveThrottleRafIdRef.current) { - cancelAnimationFrame(pointerMoveThrottleRafIdRef.current) - pointerMoveThrottleRafIdRef.current = null - } - - if (paneRef.current) { - // Restore scroll (CSS handles most other cleanup) - paneRef.current.style.overflow = '' - paneRef.current.style.containIntrinsicSize = '' + const handleLostPointerCapture = React.useCallback((event: React.PointerEvent) => { + if (!isDraggingRef.current) return + isDraggingRef.current = false + const target = event.currentTarget + target.removeAttribute('data-dragging') - // Re-measure content after drag - requestAnimationFrame(() => { - if (paneRef.current) { - const newHeight = paneRef.current.scrollHeight - paneRef.current.style.containIntrinsicSize = `auto ${newHeight}px` - } - }) + // Reset global cursor + document.body.style.cursor = '' - // Restore ContentWrapper scroll - const contentWrapper = contentWrapperRef.current - if (contentWrapper) { - contentWrapper.style.overflow = '' - } - } - - stableOnDragEnd.current?.() - }, - [paneRef], - ) + stableOnDragEnd.current?.() + }, []) const handleKeyDown = React.useCallback( (event: React.KeyboardEvent) => { @@ -693,8 +614,11 @@ const Pane = React.forwardRef { + currentWidthRef.current = paneWidth + }, [paneWidth]) useRefObjectAsForwardedRef(forwardRef, paneRef) @@ -718,22 +642,6 @@ const Pane = React.forwardRef(null) - const lastFrameTimeRef = React.useRef(0) - const targetFpsRef = React.useRef(60) // Start optimistic at 60fps - const slowFrameCountRef = React.useRef(0) - - // Extract duplicate width calculation logic - const applyWidthUpdate = React.useCallback(() => { - if (paneRef.current) { - const {minWidth: minPaneWidth, maxWidth: maxPaneWidth} = getConstraints(paneRef.current) - const newWidth = paneWidth + accumulatedDragDeltaRef.current - const clampedWidth = Math.max(minPaneWidth, Math.min(maxPaneWidth, newWidth)) - paneRef.current.style.setProperty('--pane-width', `${clampedWidth}px`) - } - animationFrameRef.current = null - }, [paneRef, paneWidth]) - React.useEffect(() => { const pane = paneRef.current if (!pane) return @@ -832,77 +740,34 @@ const Pane = React.forwardRef prev + deltaWithDirection) } else { - // Accumulate deltas - accumulatedDragDeltaRef.current += deltaWithDirection - - if (!animationFrameRef.current) { - animationFrameRef.current = requestAnimationFrame(timestamp => { - const frameBudget = 1000 / targetFpsRef.current - const timeSinceLastFrame = timestamp - lastFrameTimeRef.current - - // Adaptive FPS: Detect slow frames and downgrade if needed - if (lastFrameTimeRef.current > 0) { - // Frame took too long (missed our target by 50%+) - if (timeSinceLastFrame > frameBudget * 1.5) { - slowFrameCountRef.current++ - - // After 3 consecutive slow frames, reduce target FPS - if (slowFrameCountRef.current >= 3 && targetFpsRef.current > 30) { - targetFpsRef.current = 30 - } - } else { - // Frame was fast enough, reset counter - slowFrameCountRef.current = 0 - } - } - - // Throttle: Skip frame if too soon - if (timeSinceLastFrame < frameBudget) { - // Skip this frame, reschedule - animationFrameRef.current = requestAnimationFrame(ts => { - lastFrameTimeRef.current = ts - applyWidthUpdate() - }) - return - } - - lastFrameTimeRef.current = timestamp - applyWidthUpdate() - }) + // Apply delta directly via CSS variable for immediate visual feedback + if (paneRef.current) { + const {minWidth: minPaneWidth, maxWidth: maxPaneWidth} = getConstraints(paneRef.current) + // Use ref to get current width during drag, not stale state + const newWidth = currentWidthRef.current + deltaWithDirection + const clampedWidth = Math.max(minPaneWidth, Math.min(maxPaneWidth, newWidth)) + paneRef.current.style.setProperty('--pane-width', `${clampedWidth}px`) + // Update ref immediately so next delta is calculated correctly + currentWidthRef.current = clampedWidth } } }} onDragEnd={() => { - // Clear any pending animation frame - if (animationFrameRef.current) { - cancelAnimationFrame(animationFrameRef.current) - animationFrameRef.current = null - } - - // Reset adaptive FPS for next drag - targetFpsRef.current = 60 - slowFrameCountRef.current = 0 - - // Commit accumulated pointer drag delta to React state - const totalDelta = accumulatedDragDeltaRef.current - if (totalDelta !== 0 && paneRef.current) { - // Read the actual applied width from DOM to handle clamping + // Commit final width from DOM to React state + if (paneRef.current) { const actualWidth = parseInt(paneRef.current.style.getPropertyValue('--pane-width')) || paneWidth - - setPaneWidth(actualWidth) // Use actual width, not paneWidth + totalDelta + setPaneWidth(actualWidth) try { localStorage.setItem(widthStorageKey, actualWidth.toString()) } catch (_error) { // Ignore errors } - accumulatedDragDeltaRef.current = 0 } }} position={positionProp} // Reset pane width on double click onDoubleClick={() => { - accumulatedDragDeltaRef.current = 0 const defaultWidth = getDefaultPaneWidth(width) setPaneWidth(defaultWidth) try { From 9782183dd30cb41572db5d958b835cd4f2b43cdd Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Sun, 30 Nov 2025 00:05:53 +0000 Subject: [PATCH 28/67] deop some unnecessary complexity --- packages/react/src/PageLayout/PageLayout.tsx | 68 +++++++++---------- .../__snapshots__/PageLayout.test.tsx.snap | 8 +-- 2 files changed, 36 insertions(+), 40 deletions(-) diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index 1008ce5650d..c5b612d7367 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -153,12 +153,35 @@ type DraggableDividerProps = { onDoubleClick?: () => void } +// Cache the last computed style to avoid repeated getComputedStyle calls during drag +let constraintsCache: {element: HTMLElement | null; minWidth: number; maxWidth: number; viewportWidth: number} = { + element: null, + minWidth: 256, + maxWidth: 1024, + viewportWidth: 0, +} + function getConstraints(element: HTMLElement) { + const currentViewportWidth = window.innerWidth + + // Return cached values if element and viewport haven't changed + if (constraintsCache.element === element && constraintsCache.viewportWidth === currentViewportWidth) { + return {minWidth: constraintsCache.minWidth, maxWidth: constraintsCache.maxWidth} + } + const paneStyles = getComputedStyle(element) const maxPaneWidthDiff = Number(paneStyles.getPropertyValue('--pane-max-width-diff').split('px')[0]) || 511 const minPaneWidth = Number(paneStyles.getPropertyValue('--pane-min-width').split('px')[0]) || 256 - const viewportWidth = window.innerWidth - const maxPaneWidth = viewportWidth > maxPaneWidthDiff ? viewportWidth - maxPaneWidthDiff : viewportWidth + const maxPaneWidth = + currentViewportWidth > maxPaneWidthDiff ? currentViewportWidth - maxPaneWidthDiff : currentViewportWidth + + // Update cache + constraintsCache = { + element, + minWidth: minPaneWidth, + maxWidth: maxPaneWidth, + viewportWidth: currentViewportWidth, + } return {minWidth: minPaneWidth, maxWidth: maxPaneWidth} } @@ -642,36 +665,6 @@ const Pane = React.forwardRef { - const pane = paneRef.current - if (!pane) return - - // Initial measurement - const height = pane.scrollHeight - pane.style.contentVisibility = 'auto' - pane.style.containIntrinsicSize = `auto ${height}px` - - // Update when content changes (but not during drag) - const updateSize = () => { - // Only update if not currently dragging - const isDragging = document.querySelector('.DraggableHandle[data-dragging="true"]') - if (!isDragging) { - const newHeight = pane.scrollHeight - pane.style.containIntrinsicSize = `auto ${newHeight}px` - } - } - - // Use ResizeObserver to detect content changes - const resizeObserver = new ResizeObserver(updateSize) - resizeObserver.observe(pane) - - return () => { - resizeObserver.disconnect() - pane.style.contentVisibility = '' - pane.style.containIntrinsicSize = '' - } - }, [paneRef]) - return (
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
From fca99fec9f0e1c9e77bba163937a88dc2a08e11a Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Sun, 30 Nov 2025 09:43:48 +0000 Subject: [PATCH 29/67] deop some unnecessary complexity --- packages/react/src/PageLayout/PageLayout.tsx | 134 ++++++++----------- 1 file changed, 56 insertions(+), 78 deletions(-) diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index c5b612d7367..116a9f77bfd 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -153,36 +153,14 @@ type DraggableDividerProps = { onDoubleClick?: () => void } -// Cache the last computed style to avoid repeated getComputedStyle calls during drag -let constraintsCache: {element: HTMLElement | null; minWidth: number; maxWidth: number; viewportWidth: number} = { - element: null, - minWidth: 256, - maxWidth: 1024, - viewportWidth: 0, -} - function getConstraints(element: HTMLElement) { - const currentViewportWidth = window.innerWidth - - // Return cached values if element and viewport haven't changed - if (constraintsCache.element === element && constraintsCache.viewportWidth === currentViewportWidth) { - return {minWidth: constraintsCache.minWidth, maxWidth: constraintsCache.maxWidth} - } - const paneStyles = getComputedStyle(element) const maxPaneWidthDiff = Number(paneStyles.getPropertyValue('--pane-max-width-diff').split('px')[0]) || 511 const minPaneWidth = Number(paneStyles.getPropertyValue('--pane-min-width').split('px')[0]) || 256 + const currentViewportWidth = window.innerWidth const maxPaneWidth = currentViewportWidth > maxPaneWidthDiff ? currentViewportWidth - maxPaneWidthDiff : currentViewportWidth - // Update cache - constraintsCache = { - element, - minWidth: minPaneWidth, - maxWidth: maxPaneWidth, - viewportWidth: currentViewportWidth, - } - return {minWidth: minPaneWidth, maxWidth: maxPaneWidth} } @@ -209,44 +187,50 @@ const VerticalDivider: React.FC(null) - const [minWidth, setMinWidth] = React.useState(0) - const [maxWidth, setMaxWidth] = React.useState(0) - const [currentWidth, setCurrentWidth] = React.useState(0) - - // Initialize dimensions once - React.useEffect(() => { - if (paneRef.current !== null) { - const constraints = getConstraints(paneRef.current) - const paneWidth = paneRef.current.getBoundingClientRect().width - setMinWidth(constraints.minWidth) - setMaxWidth(constraints.maxWidth) - setCurrentWidth(paneWidth) - } - }, [paneRef]) - - const handlePointerDown = React.useCallback((event: React.PointerEvent) => { - if (event.button !== 0) return - event.preventDefault() - const target = event.currentTarget - target.setPointerCapture(event.pointerId) - isDraggingRef.current = true - target.setAttribute('data-dragging', 'true') + const handlePointerDown = React.useCallback( + (event: React.PointerEvent) => { + if (event.button !== 0) return + event.preventDefault() + const target = event.currentTarget + target.setPointerCapture(event.pointerId) + isDraggingRef.current = true + target.setAttribute('data-dragging', 'true') + + // Update ARIA attributes directly on DOM + if (paneRef.current && handleRef.current) { + const constraints = getConstraints(paneRef.current) + const currentWidth = Math.round(paneRef.current.getBoundingClientRect().width) + handleRef.current.setAttribute('aria-valuemin', String(constraints.minWidth)) + handleRef.current.setAttribute('aria-valuemax', String(constraints.maxWidth)) + handleRef.current.setAttribute('aria-valuenow', String(currentWidth)) + handleRef.current.setAttribute('aria-valuetext', `Pane width ${currentWidth} pixels`) + } - // Set global cursor so it doesn't separate from divider during fast drags - document.body.style.cursor = 'col-resize' + stableOnDragStart.current?.() + }, + [paneRef], + ) - stableOnDragStart.current?.() - }, []) + const handlePointerMove = React.useCallback( + (event: React.PointerEvent) => { + if (!isDraggingRef.current) return + event.preventDefault() - const handlePointerMove = React.useCallback((event: React.PointerEvent) => { - if (!isDraggingRef.current) return - event.preventDefault() + if (event.movementX !== 0) { + stableOnDrag.current?.(event.movementX, false) - if (event.movementX !== 0) { - stableOnDrag.current?.(event.movementX, false) - } - }, []) + // Update ARIA valuenow for live feedback to screen readers + if (paneRef.current && handleRef.current) { + const currentWidth = Math.round(paneRef.current.getBoundingClientRect().width) + handleRef.current.setAttribute('aria-valuenow', String(currentWidth)) + handleRef.current.setAttribute('aria-valuetext', `Pane width ${currentWidth} pixels`) + } + } + }, + [paneRef], + ) const handlePointerUp = React.useCallback((event: React.PointerEvent) => { if (!isDraggingRef.current) return @@ -260,9 +244,6 @@ const VerticalDivider: React.FC { - let delta = 0 - // https://github.com/github/accessibility/issues/5101#issuecomment-1822870655 - if ((event.key === 'ArrowLeft' || event.key === 'ArrowDown') && prevWidth > minWidth) { - delta = -3 - } else if ((event.key === 'ArrowRight' || event.key === 'ArrowUp') && prevWidth < maxWidth) { - delta = 3 - } - if (delta !== 0) { - stableOnDrag.current?.(delta, true) - } + if (!paneRef.current) return + const constraints = getConstraints(paneRef.current) + const currentWidth = Math.round(paneRef.current.getBoundingClientRect().width) + + let delta = 0 + // https://github.com/github/accessibility/issues/5101#issuecomment-1822870655 + if ((event.key === 'ArrowLeft' || event.key === 'ArrowDown') && currentWidth > constraints.minWidth) { + delta = -3 + } else if ((event.key === 'ArrowRight' || event.key === 'ArrowUp') && currentWidth < constraints.maxWidth) { + delta = 3 + } - return prevWidth + delta - }) + if (delta !== 0) { + stableOnDrag.current?.(delta, true) + } } }, - [minWidth, maxWidth], + [paneRef], ) const handleKeyUp = React.useCallback((event: React.KeyboardEvent) => { @@ -317,13 +298,10 @@ const VerticalDivider: React.FC {draggable ? (
Date: Sun, 30 Nov 2025 09:52:12 +0000 Subject: [PATCH 30/67] improve a11y --- packages/react/src/PageLayout/PageLayout.tsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index 116a9f77bfd..0d1aeeec307 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -189,6 +189,18 @@ const VerticalDivider: React.FC(null) + // Initialize ARIA attributes on mount + React.useEffect(() => { + if (paneRef.current && handleRef.current) { + const constraints = getConstraints(paneRef.current) + const currentWidth = Math.round(paneRef.current.getBoundingClientRect().width) + handleRef.current.setAttribute('aria-valuemin', String(constraints.minWidth)) + handleRef.current.setAttribute('aria-valuemax', String(constraints.maxWidth)) + handleRef.current.setAttribute('aria-valuenow', String(currentWidth)) + handleRef.current.setAttribute('aria-valuetext', `Pane width ${currentWidth} pixels`) + } + }, [paneRef]) + const handlePointerDown = React.useCallback( (event: React.PointerEvent) => { if (event.button !== 0) return @@ -271,6 +283,13 @@ const VerticalDivider: React.FC Date: Sun, 30 Nov 2025 16:52:48 +0000 Subject: [PATCH 31/67] improve perf --- packages/react/src/PageLayout/PageLayout.tsx | 102 ++++++++++--------- 1 file changed, 54 insertions(+), 48 deletions(-) diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index 0d1aeeec307..7d6b295a265 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -176,6 +176,7 @@ const VerticalDivider: React.FC { const isDraggingRef = React.useRef(false) + const constraintsRef = React.useRef({minWidth: 256, maxWidth: 1024}) const stableOnDragStart = React.useRef(onDragStart) const stableOnDrag = React.useRef(onDrag) @@ -189,17 +190,30 @@ const VerticalDivider: React.FC(null) - // Initialize ARIA attributes on mount + // Helper to update ARIA attributes (only valuenow/valuetext change dynamically) + const updateAriaValue = React.useCallback((width: number) => { + if (handleRef.current) { + handleRef.current.setAttribute('aria-valuenow', String(width)) + handleRef.current.setAttribute('aria-valuetext', `Pane width ${width} pixels`) + } + }, []) + + // Initialize static ARIA attributes on mount and cache constraints React.useEffect(() => { if (paneRef.current && handleRef.current) { - const constraints = getConstraints(paneRef.current) + // Cache constraints (expensive getComputedStyle call) + constraintsRef.current = getConstraints(paneRef.current) + const currentWidth = Math.round(paneRef.current.getBoundingClientRect().width) - handleRef.current.setAttribute('aria-valuemin', String(constraints.minWidth)) - handleRef.current.setAttribute('aria-valuemax', String(constraints.maxWidth)) - handleRef.current.setAttribute('aria-valuenow', String(currentWidth)) - handleRef.current.setAttribute('aria-valuetext', `Pane width ${currentWidth} pixels`) + + // Set static attributes once + handleRef.current.setAttribute('aria-valuemin', String(constraintsRef.current.minWidth)) + handleRef.current.setAttribute('aria-valuemax', String(constraintsRef.current.maxWidth)) + + // Set dynamic attributes + updateAriaValue(currentWidth) } - }, [paneRef]) + }, [paneRef, updateAriaValue]) const handlePointerDown = React.useCallback( (event: React.PointerEvent) => { @@ -210,14 +224,9 @@ const VerticalDivider: React.FC) => { - if (!isDraggingRef.current) return - event.preventDefault() - - if (event.movementX !== 0) { - stableOnDrag.current?.(event.movementX, false) + const handlePointerMove = React.useCallback((event: React.PointerEvent) => { + if (!isDraggingRef.current) return + event.preventDefault() - // Update ARIA valuenow for live feedback to screen readers - if (paneRef.current && handleRef.current) { - const currentWidth = Math.round(paneRef.current.getBoundingClientRect().width) - handleRef.current.setAttribute('aria-valuenow', String(currentWidth)) - handleRef.current.setAttribute('aria-valuetext', `Pane width ${currentWidth} pixels`) - } - } - }, - [paneRef], - ) + if (event.movementX !== 0) { + stableOnDrag.current?.(event.movementX, false) + } + }, []) const handlePointerUp = React.useCallback((event: React.PointerEvent) => { if (!isDraggingRef.current) return @@ -250,14 +249,23 @@ const VerticalDivider: React.FC) => { - if (!isDraggingRef.current) return - isDraggingRef.current = false - const target = event.currentTarget - target.removeAttribute('data-dragging') + const handleLostPointerCapture = React.useCallback( + (event: React.PointerEvent) => { + if (!isDraggingRef.current) return + isDraggingRef.current = false + const target = event.currentTarget + target.removeAttribute('data-dragging') - stableOnDragEnd.current?.() - }, []) + // Update ARIA with final width after drag completes + if (paneRef.current) { + const finalWidth = Math.round(paneRef.current.getBoundingClientRect().width) + updateAriaValue(finalWidth) + } + + stableOnDragEnd.current?.() + }, + [paneRef, updateAriaValue], + ) const handleKeyDown = React.useCallback( (event: React.KeyboardEvent) => { @@ -270,14 +278,14 @@ const VerticalDivider: React.FC constraints.minWidth) { + if ((event.key === 'ArrowLeft' || event.key === 'ArrowDown') && currentWidth > minWidth) { delta = -3 - } else if ((event.key === 'ArrowRight' || event.key === 'ArrowUp') && currentWidth < constraints.maxWidth) { + } else if ((event.key === 'ArrowRight' || event.key === 'ArrowUp') && currentWidth < maxWidth) { delta = 3 } @@ -285,15 +293,12 @@ const VerticalDivider: React.FC) => { @@ -321,6 +326,7 @@ const VerticalDivider: React.FC Date: Sun, 30 Nov 2025 17:25:44 +0000 Subject: [PATCH 32/67] more perf, less css reading --- packages/react/src/PageLayout/PageLayout.tsx | 125 +++++++++++++------ 1 file changed, 85 insertions(+), 40 deletions(-) diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index 7d6b295a265..2955666c289 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -13,6 +13,50 @@ import {getResponsiveAttributes} from '../internal/utils/getResponsiveAttributes import classes from './PageLayout.module.css' import type {FCWithSlotMarker, WithSlotMarker} from '../utils/types' +// Module-scoped ResizeObserver subscription for viewport width tracking +let viewportWidthListeners: Set<() => void> | undefined +let viewportWidthObserver: ResizeObserver | undefined + +function subscribeToViewportWidth(callback: () => void) { + if (!viewportWidthListeners) { + viewportWidthListeners = new Set() + viewportWidthObserver = new ResizeObserver(() => { + if (viewportWidthListeners) { + for (const listener of viewportWidthListeners) { + listener() + } + } + }) + viewportWidthObserver.observe(document.documentElement) + } + + viewportWidthListeners.add(callback) + + return () => { + viewportWidthListeners?.delete(callback) + if (viewportWidthListeners?.size === 0) { + viewportWidthObserver?.disconnect() + viewportWidthObserver = undefined + viewportWidthListeners = undefined + } + } +} + +function getViewportWidth() { + return window.innerWidth +} + +function getServerViewportWidth() { + return 0 +} + +/** + * Custom hook that subscribes to viewport width changes using a shared ResizeObserver + */ +function useViewportWidth() { + return React.useSyncExternalStore(subscribeToViewportWidth, getViewportWidth, getServerViewportWidth) +} + const REGION_ORDER = { header: 0, paneStart: 1, @@ -147,26 +191,19 @@ const HorizontalDivider: React.FC> = ({ type DraggableDividerProps = { draggable?: boolean + minWidth?: number + maxWidth?: number onDragStart?: () => void onDrag?: (delta: number, isKeyboard: boolean) => void onDragEnd?: () => void onDoubleClick?: () => void } -function getConstraints(element: HTMLElement) { - const paneStyles = getComputedStyle(element) - const maxPaneWidthDiff = Number(paneStyles.getPropertyValue('--pane-max-width-diff').split('px')[0]) || 511 - const minPaneWidth = Number(paneStyles.getPropertyValue('--pane-min-width').split('px')[0]) || 256 - const currentViewportWidth = window.innerWidth - const maxPaneWidth = - currentViewportWidth > maxPaneWidthDiff ? currentViewportWidth - maxPaneWidthDiff : currentViewportWidth - - return {minWidth: minPaneWidth, maxWidth: maxPaneWidth} -} - const VerticalDivider: React.FC> = ({ variant = 'none', draggable = false, + minWidth = 256, + maxWidth = 1024, onDragStart, onDrag, onDragEnd, @@ -176,7 +213,6 @@ const VerticalDivider: React.FC { const isDraggingRef = React.useRef(false) - const constraintsRef = React.useRef({minWidth: 256, maxWidth: 1024}) const stableOnDragStart = React.useRef(onDragStart) const stableOnDrag = React.useRef(onDrag) @@ -198,41 +234,30 @@ const VerticalDivider: React.FC { if (paneRef.current && handleRef.current) { - // Cache constraints (expensive getComputedStyle call) - constraintsRef.current = getConstraints(paneRef.current) - const currentWidth = Math.round(paneRef.current.getBoundingClientRect().width) // Set static attributes once - handleRef.current.setAttribute('aria-valuemin', String(constraintsRef.current.minWidth)) - handleRef.current.setAttribute('aria-valuemax', String(constraintsRef.current.maxWidth)) + handleRef.current.setAttribute('aria-valuemin', String(minWidth)) + handleRef.current.setAttribute('aria-valuemax', String(maxWidth)) // Set dynamic attributes updateAriaValue(currentWidth) } - }, [paneRef, updateAriaValue]) - - const handlePointerDown = React.useCallback( - (event: React.PointerEvent) => { - if (event.button !== 0) return - event.preventDefault() - const target = event.currentTarget - target.setPointerCapture(event.pointerId) - isDraggingRef.current = true - target.setAttribute('data-dragging', 'true') + }, [paneRef, minWidth, maxWidth, updateAriaValue]) - // Refresh constraints at drag start (viewport may have resized) - if (paneRef.current) { - constraintsRef.current = getConstraints(paneRef.current) - } + const handlePointerDown = React.useCallback((event: React.PointerEvent) => { + if (event.button !== 0) return + event.preventDefault() + const target = event.currentTarget + target.setPointerCapture(event.pointerId) + isDraggingRef.current = true + target.setAttribute('data-dragging', 'true') - stableOnDragStart.current?.() - }, - [paneRef], - ) + stableOnDragStart.current?.() + }, []) const handlePointerMove = React.useCallback((event: React.PointerEvent) => { if (!isDraggingRef.current) return @@ -279,7 +304,6 @@ const VerticalDivider: React.FC) => { @@ -646,6 +670,26 @@ const Pane = React.forwardRef { + const minPaneWidth = isCustomWidthOptions(width) ? Number(width.min.split('px')[0]) : minWidth + + let maxPaneWidth: number + if (isCustomWidthOptions(width)) { + maxPaneWidth = Number(width.max.split('px')[0]) + } else { + // Use CSS variable logic: calc(100vw - var(--pane-max-width-diff)) + // maxWidthDiff matches CSS: 959px for wide (≥1280px), 511px otherwise + const maxWidthDiff = viewportWidth >= 1280 ? 959 : 511 + maxPaneWidth = viewportWidth > 0 ? Math.max(minPaneWidth, viewportWidth - maxWidthDiff) : minPaneWidth + } + + return {minWidth: minPaneWidth, maxWidth: maxPaneWidth} + }, [width, minWidth, viewportWidth]) + useRefObjectAsForwardedRef(forwardRef, paneRef) const hasOverflow = useOverflow(paneRef) @@ -730,6 +774,8 @@ const Pane = React.forwardRef { const deltaWithDirection = isKeyboard ? delta : position === 'end' ? -delta : delta @@ -738,9 +784,8 @@ const Pane = React.forwardRef Date: Sun, 30 Nov 2025 18:45:29 -0500 Subject: [PATCH 33/67] Update packages/react/src/PageLayout/PageLayout.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/react/src/PageLayout/PageLayout.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index 2955666c289..ca67a8b658c 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -350,7 +350,6 @@ const VerticalDivider: React.FC Date: Mon, 1 Dec 2025 13:22:55 +0000 Subject: [PATCH 34/67] slight improvement --- .../src/PageLayout/PageLayout.module.css | 20 -------- packages/react/src/PageLayout/PageLayout.tsx | 46 +++++++++---------- 2 files changed, 22 insertions(+), 44 deletions(-) diff --git a/packages/react/src/PageLayout/PageLayout.module.css b/packages/react/src/PageLayout/PageLayout.module.css index eb805ae850f..40077cbd8c9 100644 --- a/packages/react/src/PageLayout/PageLayout.module.css +++ b/packages/react/src/PageLayout/PageLayout.module.css @@ -397,14 +397,6 @@ transform: translateZ(0); } -/** - * OPTIMIZATION: Disable animations in ContentWrapper during drag - * Prevents expensive animation recalculations on every frame - */ -.PageLayoutContent:has(.DraggableHandle[data-dragging='true']) .ContentWrapper * { - animation-play-state: paused !important; - transition: none !important; -} .Content { width: 100%; @@ -644,12 +636,8 @@ .PaneWrapper:has(.DraggableHandle[data-dragging='true']) .Pane { /* Disable interactions during drag */ pointer-events: none; - /* Disable transitions during drag */ transition: none !important; - - /* Reduce rendering quality during drag */ - image-rendering: pixelated; text-rendering: optimizeSpeed; /* Force hardware acceleration */ @@ -658,14 +646,6 @@ backface-visibility: hidden; } -/** - * OPTIMIZATION: Disable animations in Pane during drag - */ -.PaneWrapper:has(.DraggableHandle[data-dragging='true']) .Pane * { - animation-play-state: paused !important; - transition: none !important; -} - .PaneHorizontalDivider { &:where([data-position='start']) { /* stylelint-disable-next-line primer/spacing */ diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index ca67a8b658c..0f14b6fc3d9 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -193,17 +193,27 @@ type DraggableDividerProps = { draggable?: boolean minWidth?: number maxWidth?: number + currentWidth: number onDragStart?: () => void onDrag?: (delta: number, isKeyboard: boolean) => void onDragEnd?: () => void onDoubleClick?: () => void } +// Helper to update ARIA attributes (only valuenow/valuetext change dynamically) +const updateAriaValue = (handle: HTMLElement | null, width: number) => { + if (handle) { + handle.setAttribute('aria-valuenow', String(width)) + handle.setAttribute('aria-valuetext', `Pane width ${width} pixels`) + } +} + const VerticalDivider: React.FC> = ({ variant = 'none', draggable = false, minWidth = 256, maxWidth = 1024, + currentWidth, onDragStart, onDrag, onDragEnd, @@ -226,27 +236,11 @@ const VerticalDivider: React.FC(null) - // Helper to update ARIA attributes (only valuenow/valuetext change dynamically) - const updateAriaValue = React.useCallback((width: number) => { - if (handleRef.current) { - handleRef.current.setAttribute('aria-valuenow', String(width)) - handleRef.current.setAttribute('aria-valuetext', `Pane width ${width} pixels`) - } - }, []) - // Initialize static ARIA attributes on mount React.useEffect(() => { - if (paneRef.current && handleRef.current) { - const currentWidth = Math.round(paneRef.current.getBoundingClientRect().width) - - // Set static attributes once - handleRef.current.setAttribute('aria-valuemin', String(minWidth)) - handleRef.current.setAttribute('aria-valuemax', String(maxWidth)) - - // Set dynamic attributes - updateAriaValue(currentWidth) - } - }, [paneRef, minWidth, maxWidth, updateAriaValue]) + // Set dynamic attributes + updateAriaValue(handleRef.current, currentWidth) + }, [currentWidth]) const handlePointerDown = React.useCallback((event: React.PointerEvent) => { if (event.button !== 0) return @@ -284,12 +278,12 @@ const VerticalDivider: React.FC) => { @@ -351,6 +344,10 @@ const VerticalDivider: React.FC { const deltaWithDirection = isKeyboard ? delta : position === 'end' ? -delta : delta From 585a67b180dae0aeec525fdbae9695b5fd110cde Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Mon, 1 Dec 2025 13:27:17 +0000 Subject: [PATCH 35/67] fix up some diff --- packages/react/src/PageLayout/PageLayout.tsx | 38 ++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index 0f14b6fc3d9..73e0120a162 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -338,6 +338,7 @@ const VerticalDivider: React.FC {draggable ? ( + // Drag handle
/** * @deprecated Use the `position` prop with a responsive value instead. + * + * Before: + * ``` + * position="start" + * positionWhenNarrow="end" + * ``` + * + * After: + * ``` + * position={{regular: 'start', narrow: 'end'}} + * ``` */ positionWhenNarrow?: 'inherit' | keyof typeof panePositions 'aria-labelledby'?: string @@ -565,6 +577,17 @@ export type PageLayoutPaneProps = { divider?: 'none' | 'line' | ResponsiveValue<'none' | 'line', 'none' | 'line' | 'filled'> /** * @deprecated Use the `divider` prop with a responsive value instead. + * + * Before: + * ``` + * divider="line" + * dividerWhenNarrow="filled" + * ``` + * + * After: + * ``` + * divider={{regular: 'line', narrow: 'filled'}} + * ``` */ dividerWhenNarrow?: 'inherit' | 'none' | 'line' | 'filled' sticky?: boolean @@ -793,6 +816,7 @@ const Pane = React.forwardRef { // Commit final width from DOM to React state if (paneRef.current) { @@ -848,6 +872,17 @@ export type PageLayoutFooterProps = { divider?: 'none' | 'line' | ResponsiveValue<'none' | 'line', 'none' | 'line' | 'filled'> /** * @deprecated Use the `divider` prop with a responsive value instead. + * + * Before: + * ``` + * divider="line" + * dividerWhenNarrow="filled" + * ``` + * + * After: + * ``` + * divider={{regular: 'line', narrow: 'filled'}} + * ``` */ dividerWhenNarrow?: 'inherit' | 'none' | 'line' | 'filled' hidden?: boolean | ResponsiveValue @@ -912,6 +947,9 @@ const Footer: FCWithSlotMarker> = Footer.displayName = 'PageLayout.Footer' +// ---------------------------------------------------------------------------- +// Export + export const PageLayout = Object.assign(Root, { __SLOT__: Symbol('PageLayout'), Header, From 591e9ac9dfebb14861e329c3b9c182e7776f8d54 Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Mon, 1 Dec 2025 13:50:51 +0000 Subject: [PATCH 36/67] use-data-dragging instead of `isDragging` --- packages/react/src/PageLayout/PageLayout.tsx | 21 +++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index 73e0120a162..25e36d154bc 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -208,6 +208,11 @@ const updateAriaValue = (handle: HTMLElement | null, width: number) => { } } +const DATA_DRAGGING_ATTR = 'data-dragging' +const isDragging = (handle: HTMLElement | null) => { + return handle?.getAttribute(DATA_DRAGGING_ATTR) === 'true' +} + const VerticalDivider: React.FC> = ({ variant = 'none', draggable = false, @@ -222,8 +227,6 @@ const VerticalDivider: React.FC { - const isDraggingRef = React.useRef(false) - const stableOnDragStart = React.useRef(onDragStart) const stableOnDrag = React.useRef(onDrag) const stableOnDragEnd = React.useRef(onDragEnd) @@ -247,14 +250,13 @@ const VerticalDivider: React.FC) => { - if (!isDraggingRef.current) return + if (!isDragging(handleRef.current)) return event.preventDefault() if (event.movementX !== 0) { @@ -263,17 +265,16 @@ const VerticalDivider: React.FC) => { - if (!isDraggingRef.current) return + if (!isDragging(handleRef.current)) return event.preventDefault() // Cleanup will happen in onLostPointerCapture }, []) const handleLostPointerCapture = React.useCallback( (event: React.PointerEvent) => { - if (!isDraggingRef.current) return - isDraggingRef.current = false + if (!isDragging(handleRef.current)) return const target = event.currentTarget - target.removeAttribute('data-dragging') + target.removeAttribute(DATA_DRAGGING_ATTR) // Update ARIA with final width after drag completes if (paneRef.current) { @@ -307,6 +308,7 @@ const VerticalDivider: React.FC Date: Mon, 1 Dec 2025 13:51:02 +0000 Subject: [PATCH 37/67] undo feature story change --- .../PageLayout.features.stories.tsx | 22 +------------------ 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/packages/react/src/PageLayout/PageLayout.features.stories.tsx b/packages/react/src/PageLayout/PageLayout.features.stories.tsx index 2b493d2004c..aef5c9346b0 100644 --- a/packages/react/src/PageLayout/PageLayout.features.stories.tsx +++ b/packages/react/src/PageLayout/PageLayout.features.stories.tsx @@ -291,27 +291,7 @@ export const ResizablePane: StoryFn = () => ( - - {/** 10 columns, 1000 rows*/} - - - {Array.from({length: 10}).map((_, colIndex) => ( - - ))} - - - - {Array.from({length: 1000}).map((_, rowIndex) => ( - - {Array.from({length: 10}).map((_, colIndex) => ( - - ))} - - ))} - -
Header {colIndex + 1}
- Row {rowIndex + 1} - Col {colIndex + 1} -
+
From ccb20a8b2dc4e08cffb33fe447fbad5b67f33bbc Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Mon, 1 Dec 2025 14:07:00 +0000 Subject: [PATCH 38/67] perf story --- .../PageLayout.performance.stories.tsx | 581 ++++++++++++++++++ 1 file changed, 581 insertions(+) create mode 100644 packages/react/src/PageLayout/PageLayout.performance.stories.tsx diff --git a/packages/react/src/PageLayout/PageLayout.performance.stories.tsx b/packages/react/src/PageLayout/PageLayout.performance.stories.tsx new file mode 100644 index 00000000000..7e23d82ffab --- /dev/null +++ b/packages/react/src/PageLayout/PageLayout.performance.stories.tsx @@ -0,0 +1,581 @@ +import React from 'react' +import type {Meta, StoryObj} from '@storybook/react-vite' +import {PageLayout} from './PageLayout' + +const meta: Meta = { + title: 'Components/PageLayout/Performance Tests', + component: PageLayout, +} + +export default meta + +type Story = StoryObj + +// ============================================================================ +// Shared Performance Monitor Hook & Component +// ============================================================================ + +interface PerformanceMetrics { + fps: number + avgFrameTime: number + minFrameTime: number + maxFrameTime: number +} + +function usePerformanceMonitor(): PerformanceMetrics { + const [fps, setFps] = React.useState(0) + const [frameTimes, setFrameTimes] = React.useState([]) + const frameTimesRef = React.useRef([]) + const lastFrameTimeRef = React.useRef(0) + const animationFrameRef = React.useRef() + + React.useEffect(() => { + const measureFPS = (timestamp: number) => { + if (lastFrameTimeRef.current) { + const frameTime = timestamp - lastFrameTimeRef.current + frameTimesRef.current.push(frameTime) + + if (frameTimesRef.current.length > 60) { + frameTimesRef.current.shift() + } + + const avgFrameTime = frameTimesRef.current.reduce((a, b) => a + b, 0) / frameTimesRef.current.length + const currentFps = 1000 / avgFrameTime + + setFps(Math.round(currentFps)) + setFrameTimes([...frameTimesRef.current]) + } + + lastFrameTimeRef.current = timestamp + animationFrameRef.current = requestAnimationFrame(measureFPS) + } + + animationFrameRef.current = requestAnimationFrame(measureFPS) + + return () => { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current) + } + } + }, []) + + const avgFrameTime = frameTimes.length > 0 ? frameTimes.reduce((a, b) => a + b, 0) / frameTimes.length : 0 + const maxFrameTime = frameTimes.length > 0 ? Math.max(...frameTimes) : 0 + const minFrameTime = frameTimes.length > 0 ? Math.min(...frameTimes) : 0 + + return {fps, avgFrameTime, minFrameTime, maxFrameTime} +} + +interface PerformanceHeaderProps { + title: string + loadDescription: string + targetFps: string + minGoodFps?: number + minOkFps?: number +} + +function PerformanceHeader({ + title, + loadDescription, + targetFps, + minGoodFps = 55, + minOkFps = 40, +}: PerformanceHeaderProps) { + const {fps, avgFrameTime, minFrameTime, maxFrameTime} = usePerformanceMonitor() + + return ( +
+

{title}

+
+
+ FPS:{' '} + = minGoodFps ? 'green' : fps >= minOkFps ? 'orange' : 'red'}}> + {fps} + +
+
+ Avg: {avgFrameTime.toFixed(2)}ms +
+
+ Min: {minFrameTime.toFixed(2)}ms +
+
+ Max: {maxFrameTime.toFixed(2)}ms +
+
+

+ Load: {loadDescription} +
+ Target: {targetFps} +

+
+ ) +} + +// ============================================================================ +// Story 1: Baseline - Light Content (~100 elements) +// ============================================================================ + +export const BaselineLight: Story = { + name: '1. Light Content - Baseline (~100 elements)', + render: () => { + return ( + + + + + + +
+

Light Content Baseline

+

Minimal DOM elements to establish baseline.

+

Should be effortless 60 FPS.

+
+
+ + +
+

Resizable Pane

+

Drag to test - should be instant.

+
+
+
+ ) + }, +} + +// ============================================================================ +// Story 2: Medium Content - Virtualized Table (~3000 elements) +// ============================================================================ + +export const MediumContent: Story = { + name: '2. Medium Content - Large Table (~3000 elements)', + render: () => { + return ( + + + + + +
+

Performance Monitor

+
+ DOM Load: ~3,000 elements +
+ Table: 300 rows × 10 cols +
+ Target: 55-60 FPS +
+

+ This table has enough elements to show performance differences. Drag and watch FPS. +

+
+
+ +
+ {/* Large table with complex cells */} +

Data Table (300 rows × 10 columns)

+
+ + + + {['ID', 'Name', 'Email', 'Role', 'Status', 'Date', 'Count', 'Value', 'Tags', 'Actions'].map( + (header, i) => ( + + ), + )} + + + + {Array.from({length: 300}).map((_, rowIndex) => ( + + + + + + + + + + + + + ))} + +
+ {header} +
#{10000 + rowIndex} + {['Alice', 'Bob', 'Charlie', 'Diana', 'Eve'][rowIndex % 5]}{' '} + {['Smith', 'Jones', 'Davis'][rowIndex % 3]} + + user{rowIndex}@example.com + + {['Admin', 'Editor', 'Viewer', 'Manager'][rowIndex % 4]} + + + {rowIndex % 3 === 0 ? 'Active' : rowIndex % 2 === 0 ? 'Pending' : 'Inactive'} + + + 2024-{String((rowIndex % 12) + 1).padStart(2, '0')}- + {String((rowIndex % 28) + 1).padStart(2, '0')} + + {(rowIndex * 17) % 1000} + + ${((rowIndex * 123.45) % 10000).toFixed(2)} + + + tag{rowIndex % 10} + + + type{rowIndex % 5} + + + + +
+
+
+
+
+ ) + }, +} + +// ============================================================================ +// Story 3: Heavy Content - Multiple Sections (~5000 elements) +// ============================================================================ + +export const HeavyContent: Story = { + name: '3. Heavy Content - Multiple Sections (~5000 elements)', + render: () => { + return ( + + + + + + +
+

Stress Test

+
+ DOM Load: ~5,000 elements +
+ Mix: Cards, tables, lists +
+ Target: 50-60 FPS +
+

+ Sections: +

+
    +
  • 200 activity cards (~1000 elem)
  • +
  • 150-row table (~1200 elem)
  • +
  • 200 issue items (~1200 elem)
  • +
  • + Headers, buttons, etc
  • +
+

+ This should show measurable FPS impact. Target is 50-60 FPS. +

+
+
+ + +
+ {/* Section 1: Large card grid */} +
+

Activity Feed (200 cards)

+
+ {Array.from({length: 200}).map((_, i) => ( +
+
+ Activity #{i + 1} + {i % 60}m ago +
+
+ ))} +
+
+ + {/* Section 2: Large table */} +
+

Data Table (150 rows × 8 columns)

+ + + + {['ID', 'Name', 'Type', 'Status', 'Date', 'Value', 'Priority', 'Owner'].map((header, i) => ( + + ))} + + + + {Array.from({length: 150}).map((_, i) => ( + + + + + + + + + + + ))} + +
+ {header} +
#{5000 + i}Item {i + 1} + {['Type A', 'Type B', 'Type C', 'Type D'][i % 4]} + + + {i % 2 === 0 ? 'Done' : 'In Progress'} + + Dec {(i % 30) + 1}${(i * 50 + 100).toFixed(2)}{['Low', 'Medium', 'High'][i % 3]}user{i % 20}
+
+ + {/* Section 3: List with nested content */} +
+

Issue Tracker (200 items)

+ {Array.from({length: 200}).map((_, i) => ( +
+
+
+ Issue #{i + 1} + + {['bug', 'feature', 'enhancement'][i % 3]} + +
+ {i % 10}d ago +
+
+ Description for issue {i + 1}: This is some text that describes the issue in detail. +
+
+ 👤 {['alice', 'bob', 'charlie'][i % 3]} + 💬 {i % 15} comments + ⭐ {i % 20} reactions +
+
+ ))} +
+
+
+
+ ) + }, +} + +// Rest of stories... +export const ResponsiveConstraintsTest: Story = { + name: '4. Responsive Constraints Test', + render: () => { + const [viewportWidth, setViewportWidth] = React.useState(typeof window !== 'undefined' ? window.innerWidth : 1280) + + React.useEffect(() => { + const handleResize = () => setViewportWidth(window.innerWidth) + // eslint-disable-next-line github/prefer-observers + window.addEventListener('resize', handleResize) + return () => window.removeEventListener('resize', handleResize) + }, []) + + const maxWidthDiff = viewportWidth >= 1280 ? 959 : 511 + const calculatedMaxWidth = Math.max(256, viewportWidth - maxWidthDiff) + + return ( + + + + + + +
+

Resizable Pane

+

Max width: {calculatedMaxWidth}px

+
+
+ + +
+

Test responsive max width constraints

+

Resize window and watch max pane width update.

+

+ ⚠️ Current implementation uses hardcoded values. Should read from CSS. +

+
+
+
+ ) + }, +} + +export const KeyboardARIATest: Story = { + name: '5. Keyboard & ARIA Test', + render: () => { + return ( + + + + + + +
+

Resizable Pane

+

Use keyboard: ← → ↑ ↓

+
+
+ + +
+

Test Instructions

+
    +
  1. Tab to resize handle
  2. +
  3. Use arrow keys to resize
  4. +
  5. Test with screen reader
  6. +
+

+ Fix needed: Move ARIA from paneRef to handleRef +

+
+
+
+ ) + }, +} From 5de4de965b21ab15242a2acfc6dcea59d9cb5323 Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Mon, 1 Dec 2025 14:35:18 +0000 Subject: [PATCH 39/67] monitor --- .../PageLayout.performance.stories.tsx | 322 +++++++++++++++--- 1 file changed, 277 insertions(+), 45 deletions(-) diff --git a/packages/react/src/PageLayout/PageLayout.performance.stories.tsx b/packages/react/src/PageLayout/PageLayout.performance.stories.tsx index 7e23d82ffab..0b739f04988 100644 --- a/packages/react/src/PageLayout/PageLayout.performance.stories.tsx +++ b/packages/react/src/PageLayout/PageLayout.performance.stories.tsx @@ -15,55 +15,59 @@ type Story = StoryObj // Shared Performance Monitor Hook & Component // ============================================================================ -interface PerformanceMetrics { - fps: number - avgFrameTime: number - minFrameTime: number - maxFrameTime: number -} - -function usePerformanceMonitor(): PerformanceMetrics { - const [fps, setFps] = React.useState(0) - const [frameTimes, setFrameTimes] = React.useState([]) - const frameTimesRef = React.useRef([]) - const lastFrameTimeRef = React.useRef(0) - const animationFrameRef = React.useRef() - +function usePerformanceMonitor( + fpsRef: React.RefObject, + avgRef: React.RefObject, + minRef: React.RefObject, + maxRef: React.RefObject, + minGoodFps: number, + minOkFps: number, +) { React.useEffect(() => { + const frameTimes: number[] = [] + let lastFrameTime = 0 + let lastUpdateTime = 0 + let animationFrameId: number + const measureFPS = (timestamp: number) => { - if (lastFrameTimeRef.current) { - const frameTime = timestamp - lastFrameTimeRef.current - frameTimesRef.current.push(frameTime) + if (lastFrameTime) { + const frameTime = timestamp - lastFrameTime + frameTimes.push(frameTime) - if (frameTimesRef.current.length > 60) { - frameTimesRef.current.shift() + if (frameTimes.length > 120) { + frameTimes.shift() } - const avgFrameTime = frameTimesRef.current.reduce((a, b) => a + b, 0) / frameTimesRef.current.length - const currentFps = 1000 / avgFrameTime - - setFps(Math.round(currentFps)) - setFrameTimes([...frameTimesRef.current]) + // Update DOM directly every 500ms - zero React overhead + if (timestamp - lastUpdateTime >= 500) { + const avgFrameTime = frameTimes.reduce((a, b) => a + b, 0) / frameTimes.length + const currentFps = Math.round(1000 / avgFrameTime) + const maxFrameTime = Math.max(...frameTimes) + const minFrameTime = Math.min(...frameTimes) + + // Direct DOM updates - no React re-renders + if (fpsRef.current) { + fpsRef.current.textContent = isFinite(currentFps) ? String(currentFps) : '—' + fpsRef.current.style.color = currentFps >= minGoodFps ? 'green' : currentFps >= minOkFps ? 'orange' : 'red' + } + if (avgRef.current) avgRef.current.textContent = isFinite(avgFrameTime) ? `${avgFrameTime.toFixed(2)}ms` : '—' + if (minRef.current) minRef.current.textContent = isFinite(minFrameTime) ? `${minFrameTime.toFixed(2)}ms` : '—' + if (maxRef.current) maxRef.current.textContent = isFinite(maxFrameTime) ? `${maxFrameTime.toFixed(2)}ms` : '—' + + lastUpdateTime = timestamp + } } - lastFrameTimeRef.current = timestamp - animationFrameRef.current = requestAnimationFrame(measureFPS) + lastFrameTime = timestamp + animationFrameId = requestAnimationFrame(measureFPS) } - animationFrameRef.current = requestAnimationFrame(measureFPS) + animationFrameId = requestAnimationFrame(measureFPS) return () => { - if (animationFrameRef.current) { - cancelAnimationFrame(animationFrameRef.current) - } + cancelAnimationFrame(animationFrameId) } - }, []) - - const avgFrameTime = frameTimes.length > 0 ? frameTimes.reduce((a, b) => a + b, 0) / frameTimes.length : 0 - const maxFrameTime = frameTimes.length > 0 ? Math.max(...frameTimes) : 0 - const minFrameTime = frameTimes.length > 0 ? Math.min(...frameTimes) : 0 - - return {fps, avgFrameTime, minFrameTime, maxFrameTime} + }, [fpsRef, avgRef, minRef, maxRef, minGoodFps, minOkFps]) } interface PerformanceHeaderProps { @@ -81,7 +85,12 @@ function PerformanceHeader({ minGoodFps = 55, minOkFps = 40, }: PerformanceHeaderProps) { - const {fps, avgFrameTime, minFrameTime, maxFrameTime} = usePerformanceMonitor() + const fpsRef = React.useRef(null) + const avgRef = React.useRef(null) + const minRef = React.useRef(null) + const maxRef = React.useRef(null) + + usePerformanceMonitor(fpsRef, avgRef, minRef, maxRef, minGoodFps, minOkFps) return (
@@ -96,18 +105,18 @@ function PerformanceHeader({ >
FPS:{' '} - = minGoodFps ? 'green' : fps >= minOkFps ? 'orange' : 'red'}}> - {fps} + + 0
- Avg: {avgFrameTime.toFixed(2)}ms + Avg: 0ms
- Min: {minFrameTime.toFixed(2)}ms + Min: 0ms
- Max: {maxFrameTime.toFixed(2)}ms + Max: 0ms

@@ -119,6 +128,59 @@ function PerformanceHeader({ ) } +// ============================================================================ +// Story 0: Empty Baseline - No PageLayout +// ============================================================================ + +export const EmptyBaseline: Story = { + name: '0. Empty Baseline - No PageLayout (diagnostic)', + render: () => { + const fpsRef = React.useRef(null) + const avgRef = React.useRef(null) + const minRef = React.useRef(null) + const maxRef = React.useRef(null) + + usePerformanceMonitor(fpsRef, avgRef, minRef, maxRef, 55, 40) + + return ( +

+

Diagnostic: Empty Page FPS

+
+
+ FPS:{' '} + + 0 + +
+
+ Avg: 0ms +
+
+ Min: 0ms +
+
+ Max: 0ms +
+
+

+ This page has NO PageLayout component - just the FPS monitor. +
+ If this shows 30 FPS, the issue is external (browser throttling, power settings, etc). +
+ If this shows 60 FPS, the issue is in PageLayout. +

+
+ ) + }, +} + // ============================================================================ // Story 1: Baseline - Light Content (~100 elements) // ============================================================================ @@ -380,6 +442,17 @@ export const HeavyContent: Story = { Activity #{i + 1} {i % 60}m ago
+
+ User {['Alice', 'Bob', 'Charlie'][i % 3]} performed action on item {i} +
+
+ + {['create', 'update', 'delete'][i % 3]} + + + priority-{(i % 3) + 1} + +
))}
@@ -491,9 +564,168 @@ export const HeavyContent: Story = { }, } +// ============================================================================ +// Story 4: Extra Heavy Content - Extreme Load (10,000 elements) +// ============================================================================ + +// Progressive loading hook to avoid killing the browser +// Uses startTransition to keep the UI responsive during loading +function useProgressiveLoad(totalItems: number, batchSize = 100, delayMs = 16) { + const [loadedCount, setLoadedCount] = React.useState(batchSize) + const [, startTransition] = React.useTransition() + + React.useEffect(() => { + if (loadedCount >= totalItems) return + + const timeoutId = setTimeout(() => { + startTransition(() => { + setLoadedCount(prev => Math.min(prev + batchSize, totalItems)) + }) + }, delayMs) + + return () => clearTimeout(timeoutId) + }, [loadedCount, totalItems, batchSize, delayMs]) + + return loadedCount +} + +// Simple element with exactly 5 DOM nodes: +// div > (span + span + span + span) = 1 container + 4 children = 5 elements +function StressItem({index}: {index: number}) { + return ( +
+ #{index} + Item {index} + {index % 100}ms + + {index % 2 === 0 ? '✓' : '○'} + +
+ ) +} + +// Each StressItem = 5 DOM elements +// 2000 items × 5 = 10,000 elements +const TOTAL_ITEMS = 2000 +const ELEMENTS_PER_ITEM = 5 +const TOTAL_ELEMENTS = TOTAL_ITEMS * ELEMENTS_PER_ITEM + +export const ExtraHeavyContent: Story = { + name: '4. Extra Heavy Content - Extreme Load (10,000 elements)', + render: () => { + const loadedItems = useProgressiveLoad(TOTAL_ITEMS, 100, 16) + const loadedElements = loadedItems * ELEMENTS_PER_ITEM + const loadProgress = Math.round((loadedItems / TOTAL_ITEMS) * 100) + + return ( + + + + + + +
+

Extreme Stress Test

+
+ {loadProgress < 100 ? '⏳ Loading...' : '✓ Loaded:'} +
+ {loadedItems.toLocaleString()} / {TOTAL_ITEMS.toLocaleString()} items +
+ {loadedElements.toLocaleString()} / {TOTAL_ELEMENTS.toLocaleString()} elements +
+
+
+
+

+ Each item = 5 DOM nodes +
+ {TOTAL_ITEMS} items × 5 = {TOTAL_ELEMENTS.toLocaleString()} elements +

+
+ + + +
+

+ Stress Test Items ({loadedItems.toLocaleString()} / {TOTAL_ITEMS.toLocaleString()}) +

+
+ {/* Header row */} +
+ ID + Name + Time + Status +
+ {/* Items */} + {Array.from({length: loadedItems}).map((_, i) => ( + + ))} +
+
+
+ + ) + }, +} + // Rest of stories... export const ResponsiveConstraintsTest: Story = { - name: '4. Responsive Constraints Test', + name: '5. Responsive Constraints Test', render: () => { const [viewportWidth, setViewportWidth] = React.useState(typeof window !== 'undefined' ? window.innerWidth : 1280) @@ -541,7 +773,7 @@ export const ResponsiveConstraintsTest: Story = { } export const KeyboardARIATest: Story = { - name: '5. Keyboard & ARIA Test', + name: '6. Keyboard & ARIA Test', render: () => { return ( From d45b8c1bef0c1f5014ea4190f42622e8e4f097f3 Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Mon, 1 Dec 2025 14:42:40 +0000 Subject: [PATCH 40/67] update --- .../src/PageLayout/PageLayout.module.css | 38 ++++++++++---- .../PageLayout.performance.stories.tsx | 51 ++++++++++++------- 2 files changed, 62 insertions(+), 27 deletions(-) diff --git a/packages/react/src/PageLayout/PageLayout.module.css b/packages/react/src/PageLayout/PageLayout.module.css index 40077cbd8c9..9747da25fe0 100644 --- a/packages/react/src/PageLayout/PageLayout.module.css +++ b/packages/react/src/PageLayout/PageLayout.module.css @@ -385,10 +385,10 @@ .PageLayoutContent:has(.DraggableHandle[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 !important; @@ -397,7 +397,6 @@ transform: translateZ(0); } - .Content { width: 100%; @@ -407,6 +406,14 @@ margin-left: auto; flex-grow: 1; + /** + * OPTIMIZATION: Skip rendering off-screen content during scrolling/resizing + * This automatically helps consumers with large content by only rendering + * elements that are visible in the viewport + */ + content-visibility: auto; + contain-intrinsic-size: auto 500px; + &:where([data-width='medium']) { max-width: 768px; } @@ -424,6 +431,16 @@ } } +/** + * OPTIMIZATION: Freeze content layout during resize drag + * This prevents expensive recalculations of large content areas + * while keeping content visible (just frozen in place) + */ +.PageLayoutContent:has(.DraggableHandle[data-dragging='true']) .Content { + /* Full containment - isolate from layout recalculations */ + contain: strict; +} + .PaneWrapper { display: flex; width: 100%; @@ -604,12 +621,12 @@ * OPTIMIZATION: Full containment for pane - isolates from rest of page */ contain: layout style paint; - + /** * OPTIMIZATION: For extremely tall content - skip rendering off-screen content */ content-visibility: auto; - + /** * OPTIMIZATION: Provide size estimate for content-visibility * JavaScript updates this dynamically based on actual content height @@ -634,12 +651,15 @@ * CSS handles all optimizations automatically - JavaScript only locks scroll */ .PaneWrapper:has(.DraggableHandle[data-dragging='true']) .Pane { + /* Full containment - isolate from layout recalculations */ + contain: strict; + /* Disable interactions during drag */ pointer-events: none; + /* Disable transitions during drag */ transition: none !important; - text-rendering: optimizeSpeed; - + /* Force hardware acceleration */ will-change: width, transform; transform: translateZ(0); @@ -753,7 +773,7 @@ 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 @@ -794,4 +814,4 @@ .DraggableHandle:focus-visible { outline: 2px solid var(--fgColor-accent); outline-offset: 2px; -} \ No newline at end of file +} diff --git a/packages/react/src/PageLayout/PageLayout.performance.stories.tsx b/packages/react/src/PageLayout/PageLayout.performance.stories.tsx index 0b739f04988..a9ab30415a0 100644 --- a/packages/react/src/PageLayout/PageLayout.performance.stories.tsx +++ b/packages/react/src/PageLayout/PageLayout.performance.stories.tsx @@ -568,30 +568,36 @@ export const HeavyContent: Story = { // Story 4: Extra Heavy Content - Extreme Load (10,000 elements) // ============================================================================ -// Progressive loading hook to avoid killing the browser -// Uses startTransition to keep the UI responsive during loading -function useProgressiveLoad(totalItems: number, batchSize = 100, delayMs = 16) { +// Progressive loading hook that only loads when browser is idle +// Uses requestIdleCallback to avoid freezing the browser +function useProgressiveLoad(totalItems: number, batchSize = 50) { const [loadedCount, setLoadedCount] = React.useState(batchSize) - const [, startTransition] = React.useTransition() React.useEffect(() => { if (loadedCount >= totalItems) return - const timeoutId = setTimeout(() => { - startTransition(() => { - setLoadedCount(prev => Math.min(prev + batchSize, totalItems)) - }) - }, delayMs) + // Use requestIdleCallback if available, otherwise setTimeout with long delay + const scheduleNext = (callback: () => void) => { + return window.requestIdleCallback(callback, {timeout: 500}) + } + + const cancelNext = (id: number) => { + window.cancelIdleCallback(id) + } - return () => clearTimeout(timeoutId) - }, [loadedCount, totalItems, batchSize, delayMs]) + const id = scheduleNext(() => { + setLoadedCount(prev => Math.min(prev + batchSize, totalItems)) + }) + + return () => cancelNext(id) + }, [loadedCount, totalItems, batchSize]) return loadedCount } // Simple element with exactly 5 DOM nodes: // div > (span + span + span + span) = 1 container + 4 children = 5 elements -function StressItem({index}: {index: number}) { +const StressItem = React.memo(function StressItem({index}: {index: number}) { return (
) -} +}) + +// Memoized list to prevent re-renders during drag +const StressItemList = React.memo(function StressItemList({count}: {count: number}) { + return ( + <> + {Array.from({length: count}).map((_, i) => ( + + ))} + + ) +}) // Each StressItem = 5 DOM elements // 2000 items × 5 = 10,000 elements @@ -628,7 +645,7 @@ const TOTAL_ELEMENTS = TOTAL_ITEMS * ELEMENTS_PER_ITEM export const ExtraHeavyContent: Story = { name: '4. Extra Heavy Content - Extreme Load (10,000 elements)', render: () => { - const loadedItems = useProgressiveLoad(TOTAL_ITEMS, 100, 16) + const loadedItems = useProgressiveLoad(TOTAL_ITEMS, 50) // 50 items per idle callback const loadedElements = loadedItems * ELEMENTS_PER_ITEM const loadProgress = Math.round((loadedItems / TOTAL_ITEMS) * 100) @@ -711,10 +728,8 @@ export const ExtraHeavyContent: Story = { Time Status
- {/* Items */} - {Array.from({length: loadedItems}).map((_, i) => ( - - ))} + {/* Items - memoized to prevent re-render during drag */} +
From f99ff5f463586fb9b2236820470246df4ce2e23d Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Mon, 1 Dec 2025 14:45:38 +0000 Subject: [PATCH 41/67] transition --- packages/react/src/PageLayout/PageLayout.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index 25e36d154bc..b83badbd031 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -642,6 +642,7 @@ const Pane = React.forwardRef { + const [, startTransition] = React.useTransition() // Combine position and positionWhenNarrow for backwards compatibility const positionProp = !isResponsiveValue(responsivePosition) && positionWhenNarrow !== 'inherit' @@ -822,9 +823,15 @@ const Pane = React.forwardRef { // Commit final width from DOM to React state + // Use startTransition to mark this as low-priority, letting the browser + // finish painting the final position before React reconciles if (paneRef.current) { const actualWidth = parseInt(paneRef.current.style.getPropertyValue('--pane-width')) || paneWidth - setPaneWidth(actualWidth) + + // Low-priority update - won't block the main thread + startTransition(() => { + setPaneWidth(actualWidth) + }) try { localStorage.setItem(widthStorageKey, actualWidth.toString()) @@ -837,7 +844,10 @@ const Pane = React.forwardRef { const defaultWidth = getDefaultPaneWidth(width) - setPaneWidth(defaultWidth) + // Low-priority update - won't block the main thread + startTransition(() => { + setPaneWidth(defaultWidth) + }) try { localStorage.setItem(widthStorageKey, defaultWidth.toString()) } catch (_error) { From ce2d7cc164a83613a654d7ceeee585861ab6f8bc Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Mon, 1 Dec 2025 15:06:24 +0000 Subject: [PATCH 42/67] transition --- .../PageLayout.performance.stories.tsx | 179 +----------------- packages/react/src/PageLayout/PageLayout.tsx | 21 +- 2 files changed, 12 insertions(+), 188 deletions(-) diff --git a/packages/react/src/PageLayout/PageLayout.performance.stories.tsx b/packages/react/src/PageLayout/PageLayout.performance.stories.tsx index a9ab30415a0..871aa3dfe62 100644 --- a/packages/react/src/PageLayout/PageLayout.performance.stories.tsx +++ b/packages/react/src/PageLayout/PageLayout.performance.stories.tsx @@ -564,183 +564,8 @@ export const HeavyContent: Story = { }, } -// ============================================================================ -// Story 4: Extra Heavy Content - Extreme Load (10,000 elements) -// ============================================================================ - -// Progressive loading hook that only loads when browser is idle -// Uses requestIdleCallback to avoid freezing the browser -function useProgressiveLoad(totalItems: number, batchSize = 50) { - const [loadedCount, setLoadedCount] = React.useState(batchSize) - - React.useEffect(() => { - if (loadedCount >= totalItems) return - - // Use requestIdleCallback if available, otherwise setTimeout with long delay - const scheduleNext = (callback: () => void) => { - return window.requestIdleCallback(callback, {timeout: 500}) - } - - const cancelNext = (id: number) => { - window.cancelIdleCallback(id) - } - - const id = scheduleNext(() => { - setLoadedCount(prev => Math.min(prev + batchSize, totalItems)) - }) - - return () => cancelNext(id) - }, [loadedCount, totalItems, batchSize]) - - return loadedCount -} - -// Simple element with exactly 5 DOM nodes: -// div > (span + span + span + span) = 1 container + 4 children = 5 elements -const StressItem = React.memo(function StressItem({index}: {index: number}) { - return ( -
- #{index} - Item {index} - {index % 100}ms - - {index % 2 === 0 ? '✓' : '○'} - -
- ) -}) - -// Memoized list to prevent re-renders during drag -const StressItemList = React.memo(function StressItemList({count}: {count: number}) { - return ( - <> - {Array.from({length: count}).map((_, i) => ( - - ))} - - ) -}) - -// Each StressItem = 5 DOM elements -// 2000 items × 5 = 10,000 elements -const TOTAL_ITEMS = 2000 -const ELEMENTS_PER_ITEM = 5 -const TOTAL_ELEMENTS = TOTAL_ITEMS * ELEMENTS_PER_ITEM - -export const ExtraHeavyContent: Story = { - name: '4. Extra Heavy Content - Extreme Load (10,000 elements)', - render: () => { - const loadedItems = useProgressiveLoad(TOTAL_ITEMS, 50) // 50 items per idle callback - const loadedElements = loadedItems * ELEMENTS_PER_ITEM - const loadProgress = Math.round((loadedItems / TOTAL_ITEMS) * 100) - - return ( - - - - - - -
-

Extreme Stress Test

-
- {loadProgress < 100 ? '⏳ Loading...' : '✓ Loaded:'} -
- {loadedItems.toLocaleString()} / {TOTAL_ITEMS.toLocaleString()} items -
- {loadedElements.toLocaleString()} / {TOTAL_ELEMENTS.toLocaleString()} elements -
-
-
-
-

- Each item = 5 DOM nodes -
- {TOTAL_ITEMS} items × 5 = {TOTAL_ELEMENTS.toLocaleString()} elements -

-
- - - -
-

- Stress Test Items ({loadedItems.toLocaleString()} / {TOTAL_ITEMS.toLocaleString()}) -

-
- {/* Header row */} -
- ID - Name - Time - Status -
- {/* Items - memoized to prevent re-render during drag */} - -
-
-
- - ) - }, -} - -// Rest of stories... export const ResponsiveConstraintsTest: Story = { - name: '5. Responsive Constraints Test', + name: 'Responsive Constraints Test', render: () => { const [viewportWidth, setViewportWidth] = React.useState(typeof window !== 'undefined' ? window.innerWidth : 1280) @@ -788,7 +613,7 @@ export const ResponsiveConstraintsTest: Story = { } export const KeyboardARIATest: Story = { - name: '6. Keyboard & ARIA Test', + name: 'Keyboard & ARIA Test', render: () => { return ( diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index b83badbd031..78cfa732fc0 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -735,6 +735,14 @@ const Pane = React.forwardRef { + try { + localStorage.setItem(widthStorageKey, value) + } catch { + // Ignore write errors + } + } + return (
{ setPaneWidth(actualWidth) }) - - try { - localStorage.setItem(widthStorageKey, actualWidth.toString()) - } catch (_error) { - // Ignore errors - } + setWidthInLocalStorage(actualWidth.toString()) } }} position={positionProp} @@ -848,11 +851,7 @@ const Pane = React.forwardRef { setPaneWidth(defaultWidth) }) - try { - localStorage.setItem(widthStorageKey, defaultWidth.toString()) - } catch (_error) { - // Ignore errors - } + setWidthInLocalStorage(defaultWidth.toString()) }} className={classes.PaneVerticalDivider} style={ From 0cb267b0ecd612f77ae085e05d6a6437d44742be Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Mon, 1 Dec 2025 15:14:25 +0000 Subject: [PATCH 43/67] update pane width in effect --- packages/react/src/PageLayout/PageLayout.tsx | 45 ++++++++++---------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index 78cfa732fc0..69d0a036020 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -12,6 +12,7 @@ import {getResponsiveAttributes} from '../internal/utils/getResponsiveAttributes import classes from './PageLayout.module.css' import type {FCWithSlotMarker, WithSlotMarker} from '../utils/types' +import useIsomorphicLayoutEffect from '../utils/useIsomorphicLayoutEffect' // Module-scoped ResizeObserver subscription for viewport width tracking let viewportWidthListeners: Set<() => void> | undefined @@ -642,7 +643,6 @@ const Pane = React.forwardRef { - const [, startTransition] = React.useTransition() // Combine position and positionWhenNarrow for backwards compatibility const positionProp = !isResponsiveValue(responsivePosition) && positionWhenNarrow !== 'inherit' @@ -693,6 +693,13 @@ const Pane = React.forwardRef { + paneRef.current?.style.setProperty('--pane-width', `${currentWidthRef.current}px`) + }) + // Subscribe to viewport width changes for responsive max constraint calculation const viewportWidth = useViewportWidth() @@ -735,9 +742,9 @@ const Pane = React.forwardRef { + const setWidthInLocalStorage = (value: number) => { try { - localStorage.setItem(widthStorageKey, value) + localStorage.setItem(widthStorageKey, value.toString()) } catch { // Ignore write errors } @@ -783,7 +790,7 @@ const Pane = React.forwardRef @@ -828,30 +835,24 @@ const Pane = React.forwardRef { - // Commit final width from DOM to React state - // Use startTransition to mark this as low-priority, letting the browser - // finish painting the final position before React reconciles - if (paneRef.current) { - const actualWidth = parseInt(paneRef.current.style.getPropertyValue('--pane-width')) || paneWidth - - // Low-priority update - won't block the main thread - startTransition(() => { - setPaneWidth(actualWidth) - }) - setWidthInLocalStorage(actualWidth.toString()) - } + // For mouse drag: The CSS variable is already set and currentWidthRef is in sync. + // We intentionally skip setPaneWidth() to avoid triggering expensive React + // reconciliation with large DOM trees. The ref is the source of truth for + // subsequent drag operations. + setWidthInLocalStorage(currentWidthRef.current) }} position={positionProp} // Reset pane width on double click onDoubleClick={() => { const defaultWidth = getDefaultPaneWidth(width) - // Low-priority update - won't block the main thread - startTransition(() => { - setPaneWidth(defaultWidth) - }) - setWidthInLocalStorage(defaultWidth.toString()) + // Update CSS variable and ref directly - skip React state to avoid reconciliation + if (paneRef.current) { + paneRef.current.style.setProperty('--pane-width', `${defaultWidth}px`) + currentWidthRef.current = defaultWidth + } + setWidthInLocalStorage(defaultWidth) }} className={classes.PaneVerticalDivider} style={ From 1b685171f615e3a4021a6f3d50945c584aa25eeb Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Mon, 1 Dec 2025 15:35:19 +0000 Subject: [PATCH 44/67] update pane width in effect --- packages/react/src/PageLayout/PageLayout.tsx | 157 ++++++++++--------- 1 file changed, 81 insertions(+), 76 deletions(-) diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index 69d0a036020..089ca3ab228 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -58,6 +58,17 @@ function useViewportWidth() { return React.useSyncExternalStore(subscribeToViewportWidth, getViewportWidth, getServerViewportWidth) } +/** + * Gets the --pane-max-width-diff CSS variable value from a pane element. + * This value is set by CSS media queries and controls the max pane width constraint. + * Falls back to 511 (the CSS default) if the value cannot be read. + */ +function getPaneMaxWidthDiff(paneElement: HTMLElement | null): number { + if (!paneElement) return 511 + const value = parseInt(getComputedStyle(paneElement).getPropertyValue('--pane-max-width-diff'), 10) + return value > 0 ? value : 511 +} + const REGION_ORDER = { header: 0, paneStart: 1, @@ -192,20 +203,22 @@ const HorizontalDivider: React.FC> = ({ type DraggableDividerProps = { draggable?: boolean - minWidth?: number - maxWidth?: number - currentWidth: number + handleRef?: React.RefObject onDragStart?: () => void onDrag?: (delta: number, isKeyboard: boolean) => void onDragEnd?: () => void onDoubleClick?: () => void } -// Helper to update ARIA attributes (only valuenow/valuetext change dynamically) -const updateAriaValue = (handle: HTMLElement | null, width: number) => { - if (handle) { - handle.setAttribute('aria-valuenow', String(width)) - handle.setAttribute('aria-valuetext', `Pane width ${width} pixels`) +// Helper to update ARIA slider attributes via direct DOM manipulation +// This avoids re-renders when values change during drag or on viewport resize +const updateAriaValues = (handle: HTMLElement | null, values: {current?: number; min?: number; max?: number}) => { + if (!handle) return + if (values.min !== undefined) handle.setAttribute('aria-valuemin', String(values.min)) + if (values.max !== undefined) handle.setAttribute('aria-valuemax', String(values.max)) + if (values.current !== undefined) { + handle.setAttribute('aria-valuenow', String(values.current)) + handle.setAttribute('aria-valuetext', `Pane width ${values.current} pixels`) } } @@ -217,9 +230,7 @@ const isDragging = (handle: HTMLElement | null) => { const VerticalDivider: React.FC> = ({ variant = 'none', draggable = false, - minWidth = 256, - maxWidth = 1024, - currentWidth, + handleRef, onDragStart, onDrag, onDragEnd, @@ -238,13 +249,6 @@ const VerticalDivider: React.FC(null) - - // Initialize static ARIA attributes on mount - React.useEffect(() => { - // Set dynamic attributes - updateAriaValue(handleRef.current, currentWidth) - }, [currentWidth]) const handlePointerDown = React.useCallback((event: React.PointerEvent) => { if (event.button !== 0) return @@ -256,36 +260,36 @@ const VerticalDivider: React.FC) => { - if (!isDragging(handleRef.current)) return - event.preventDefault() + const handlePointerMove = React.useCallback( + (event: React.PointerEvent) => { + if (!handleRef?.current || !isDragging(handleRef.current)) return + event.preventDefault() - if (event.movementX !== 0) { - stableOnDrag.current?.(event.movementX, false) - } - }, []) + if (event.movementX !== 0) { + stableOnDrag.current?.(event.movementX, false) + } + }, + [handleRef], + ) - const handlePointerUp = React.useCallback((event: React.PointerEvent) => { - if (!isDragging(handleRef.current)) return - event.preventDefault() - // Cleanup will happen in onLostPointerCapture - }, []) + const handlePointerUp = React.useCallback( + (event: React.PointerEvent) => { + if (!handleRef?.current || !isDragging(handleRef.current)) return + event.preventDefault() + // Cleanup will happen in onLostPointerCapture + }, + [handleRef], + ) const handleLostPointerCapture = React.useCallback( (event: React.PointerEvent) => { - if (!isDragging(handleRef.current)) return + if (!handleRef?.current || !isDragging(handleRef.current)) return const target = event.currentTarget target.removeAttribute(DATA_DRAGGING_ATTR) - // Update ARIA with final width after drag completes - if (paneRef.current) { - const finalWidth = Math.round(paneRef.current.getBoundingClientRect().width) - updateAriaValue(handleRef.current, finalWidth) - } - stableOnDragEnd.current?.() }, - [paneRef], + [handleRef], ) const handleKeyDown = React.useCallback( @@ -300,25 +304,14 @@ const VerticalDivider: React.FC minWidth) { - delta = -3 - } else if ((event.key === 'ArrowRight' || event.key === 'ArrowUp') && currentWidth < maxWidth) { - delta = 3 - } - - if (delta !== 0) { - event.currentTarget.setAttribute(DATA_DRAGGING_ATTR, 'true') - stableOnDrag.current?.(delta, true) + const delta = event.key === 'ArrowLeft' || event.key === 'ArrowDown' ? -3 : 3 - // Update ARIA after keyboard resize - const newWidth = Math.round(paneRef.current.getBoundingClientRect().width) - updateAriaValue(handleRef.current, newWidth) - } + event.currentTarget.setAttribute(DATA_DRAGGING_ATTR, 'true') + stableOnDrag.current?.(delta, true) } }, - [paneRef, currentWidth, minWidth, maxWidth], + [paneRef], ) const handleKeyUp = React.useCallback((event: React.KeyboardEvent) => { @@ -342,17 +335,13 @@ const VerticalDivider: React.FC {draggable ? ( - // Drag handle + // Drag handle - ARIA attributes set via DOM manipulation for performance
{ - const minPaneWidth = isCustomWidthOptions(width) ? Number(width.min.split('px')[0]) : minWidth + // Calculate min width constraint from width configuration + const minPaneWidth = React.useMemo(() => { + return isCustomWidthOptions(width) ? parseInt(width.min, 10) : minWidth + }, [width, minWidth]) - let maxPaneWidth: number + // Get current max width by reading CSS variable (call only in event handlers/effects, not during render) + const getCurrentMaxWidth = React.useCallback(() => { if (isCustomWidthOptions(width)) { - maxPaneWidth = Number(width.max.split('px')[0]) - } else { - // Use CSS variable logic: calc(100vw - var(--pane-max-width-diff)) - // maxWidthDiff matches CSS: 959px for wide (≥1280px), 511px otherwise - const maxWidthDiff = viewportWidth >= 1280 ? 959 : 511 - maxPaneWidth = viewportWidth > 0 ? Math.max(minPaneWidth, viewportWidth - maxWidthDiff) : minPaneWidth + return parseInt(width.max, 10) } + const maxWidthDiff = getPaneMaxWidthDiff(paneRef.current) + const currentViewportWidth = window.innerWidth + return currentViewportWidth > 0 ? Math.max(minPaneWidth, currentViewportWidth - maxWidthDiff) : minPaneWidth + }, [width, minPaneWidth, paneRef]) - return {minWidth: minPaneWidth, maxWidth: maxPaneWidth} - }, [width, minWidth, viewportWidth]) + // Ref to the drag handle for updating ARIA attributes + const handleRef = React.useRef(null) + + // Update ARIA attributes on mount and when viewport changes (which affects max width) + React.useEffect(() => { + updateAriaValues(handleRef.current, { + min: minPaneWidth, + max: getCurrentMaxWidth(), + current: currentWidthRef.current, + }) + }, [minPaneWidth, getCurrentMaxWidth, viewportWidth]) useRefObjectAsForwardedRef(forwardRef, paneRef) @@ -812,25 +811,30 @@ const Pane = React.forwardRef { const deltaWithDirection = isKeyboard ? delta : position === 'end' ? -delta : delta + const maxWidth = getCurrentMaxWidth() if (isKeyboard) { - setPaneWidth(prev => prev + deltaWithDirection) + // Clamp keyboard delta to stay within bounds + const newWidth = Math.max(minPaneWidth, Math.min(maxWidth, currentWidthRef.current + deltaWithDirection)) + if (newWidth !== currentWidthRef.current) { + setPaneWidth(newWidth) + updateAriaValues(handleRef.current, {current: newWidth}) + } } else { // Apply delta directly via CSS variable for immediate visual feedback if (paneRef.current) { const newWidth = currentWidthRef.current + deltaWithDirection - const clampedWidth = Math.max(paneConstraints.minWidth, Math.min(paneConstraints.maxWidth, newWidth)) + const clampedWidth = Math.max(minPaneWidth, Math.min(maxWidth, newWidth)) // Only update if the clamped width actually changed // This prevents drift when dragging against min/max constraints if (clampedWidth !== currentWidthRef.current) { paneRef.current.style.setProperty('--pane-width', `${clampedWidth}px`) currentWidthRef.current = clampedWidth + updateAriaValues(handleRef.current, {current: clampedWidth}) } } } @@ -851,6 +855,7 @@ const Pane = React.forwardRef Date: Mon, 1 Dec 2025 15:38:02 +0000 Subject: [PATCH 45/67] pane width subscription --- packages/react/src/PageLayout/PageLayout.tsx | 25 +++++++++++--------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index 089ca3ab228..3dd37470d76 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -697,27 +697,30 @@ const Pane = React.forwardRef { + // Cache max width constraint - updated when viewport changes (which triggers CSS breakpoint changes) + // This avoids calling getComputedStyle() on every drag frame + const maxPaneWidthRef = React.useRef(minPaneWidth) + React.useEffect(() => { if (isCustomWidthOptions(width)) { - return parseInt(width.max, 10) + maxPaneWidthRef.current = parseInt(width.max, 10) + } else { + const maxWidthDiff = getPaneMaxWidthDiff(paneRef.current) + maxPaneWidthRef.current = + viewportWidth > 0 ? Math.max(minPaneWidth, viewportWidth - maxWidthDiff) : minPaneWidth } - const maxWidthDiff = getPaneMaxWidthDiff(paneRef.current) - const currentViewportWidth = window.innerWidth - return currentViewportWidth > 0 ? Math.max(minPaneWidth, currentViewportWidth - maxWidthDiff) : minPaneWidth - }, [width, minPaneWidth, paneRef]) + }, [width, minPaneWidth, viewportWidth, paneRef]) // Ref to the drag handle for updating ARIA attributes const handleRef = React.useRef(null) - // Update ARIA attributes on mount and when viewport changes (which affects max width) + // Update ARIA attributes on mount and when viewport/constraints change React.useEffect(() => { updateAriaValues(handleRef.current, { min: minPaneWidth, - max: getCurrentMaxWidth(), + max: maxPaneWidthRef.current, current: currentWidthRef.current, }) - }, [minPaneWidth, getCurrentMaxWidth, viewportWidth]) + }, [minPaneWidth, viewportWidth]) useRefObjectAsForwardedRef(forwardRef, paneRef) @@ -814,7 +817,7 @@ const Pane = React.forwardRef { const deltaWithDirection = isKeyboard ? delta : position === 'end' ? -delta : delta - const maxWidth = getCurrentMaxWidth() + const maxWidth = maxPaneWidthRef.current if (isKeyboard) { // Clamp keyboard delta to stay within bounds From 3aff251349bf1d21ec5d0ecb7304af26e162f47a Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Mon, 1 Dec 2025 15:46:09 +0000 Subject: [PATCH 46/67] improve keyboard interaction --- packages/react/src/PageLayout/PageLayout.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index 3dd37470d76..0e161a3cc92 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -823,6 +823,8 @@ const Pane = React.forwardRef Date: Mon, 1 Dec 2025 15:49:23 +0000 Subject: [PATCH 47/67] improve keyboard interaction --- .../PageLayout.performance.stories.tsx | 93 +++++++++++++++++-- 1 file changed, 87 insertions(+), 6 deletions(-) diff --git a/packages/react/src/PageLayout/PageLayout.performance.stories.tsx b/packages/react/src/PageLayout/PageLayout.performance.stories.tsx index 871aa3dfe62..d4b6581a5a4 100644 --- a/packages/react/src/PageLayout/PageLayout.performance.stories.tsx +++ b/packages/react/src/PageLayout/PageLayout.performance.stories.tsx @@ -602,9 +602,6 @@ export const ResponsiveConstraintsTest: Story = {

Test responsive max width constraints

Resize window and watch max pane width update.

-

- ⚠️ Current implementation uses hardcoded values. Should read from CSS. -

@@ -615,6 +612,62 @@ export const ResponsiveConstraintsTest: Story = { export const KeyboardARIATest: Story = { name: 'Keyboard & ARIA Test', render: () => { + const [ariaAttributes, setAriaAttributes] = React.useState({ + valuemin: '—', + valuemax: '—', + valuenow: '—', + valuetext: '—', + }) + + React.useEffect(() => { + if (typeof window === 'undefined') { + return undefined + } + + const ATTRIBUTE_NAMES = ['aria-valuemin', 'aria-valuemax', 'aria-valuenow', 'aria-valuetext'] as const + const attributeFilter = ATTRIBUTE_NAMES.map(attribute => attribute) + let handleElement: HTMLElement | null = null + const mutationObserver = new MutationObserver(() => { + if (!handleElement) return + setAriaAttributes({ + valuemin: handleElement.getAttribute('aria-valuemin') ?? '—', + valuemax: handleElement.getAttribute('aria-valuemax') ?? '—', + valuenow: handleElement.getAttribute('aria-valuenow') ?? '—', + valuetext: handleElement.getAttribute('aria-valuetext') ?? '—', + }) + }) + + const attachObserver = () => { + handleElement = document.querySelector("[role='slider'][aria-label='Draggable pane splitter']") + if (!handleElement) return false + + mutationObserver.observe(handleElement, { + attributes: true, + attributeFilter, + }) + + setAriaAttributes({ + valuemin: handleElement.getAttribute('aria-valuemin') ?? '—', + valuemax: handleElement.getAttribute('aria-valuemax') ?? '—', + valuenow: handleElement.getAttribute('aria-valuenow') ?? '—', + valuetext: handleElement.getAttribute('aria-valuetext') ?? '—', + }) + + return true + } + + const retryInterval = window.setInterval(() => { + if (attachObserver()) { + window.clearInterval(retryInterval) + } + }, 100) + + return () => { + window.clearInterval(retryInterval) + mutationObserver.disconnect() + } + }, []) + return ( @@ -642,9 +695,37 @@ export const KeyboardARIATest: Story = {
  • Use arrow keys to resize
  • Test with screen reader
  • -

    - Fix needed: Move ARIA from paneRef to handleRef -

    +
    +

    Live ARIA attributes

    +
    +
    aria-valuemin
    +
    {ariaAttributes.valuemin}
    +
    aria-valuemax
    +
    {ariaAttributes.valuemax}
    +
    aria-valuenow
    +
    {ariaAttributes.valuenow}
    +
    aria-valuetext
    +
    {ariaAttributes.valuetext}
    +
    +

    + Values update live when the slider handle changes size via keyboard or pointer interactions. +

    +
    From e1ff076978046b45e2990aad9fa44d70af99603d Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Mon, 1 Dec 2025 15:56:06 +0000 Subject: [PATCH 48/67] avoid name --- packages/react/src/PageLayout/PageLayout.performance.stories.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/react/src/PageLayout/PageLayout.performance.stories.tsx b/packages/react/src/PageLayout/PageLayout.performance.stories.tsx index d4b6581a5a4..be774e71eef 100644 --- a/packages/react/src/PageLayout/PageLayout.performance.stories.tsx +++ b/packages/react/src/PageLayout/PageLayout.performance.stories.tsx @@ -565,7 +565,6 @@ export const HeavyContent: Story = { } export const ResponsiveConstraintsTest: Story = { - name: 'Responsive Constraints Test', render: () => { const [viewportWidth, setViewportWidth] = React.useState(typeof window !== 'undefined' ? window.innerWidth : 1280) From 9dae9556afe7c749e22ee6f85f2dbb905f934a1e Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Mon, 1 Dec 2025 15:59:19 +0000 Subject: [PATCH 49/67] comment --- packages/react/src/PageLayout/PageLayout.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index 0e161a3cc92..54a47dc3ff0 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -341,6 +341,13 @@ const VerticalDivider: React.FC Date: Mon, 1 Dec 2025 16:03:36 +0000 Subject: [PATCH 50/67] update css --- packages/react/src/PageLayout/PageLayout.module.css | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/packages/react/src/PageLayout/PageLayout.module.css b/packages/react/src/PageLayout/PageLayout.module.css index 9747da25fe0..db5df39a5d1 100644 --- a/packages/react/src/PageLayout/PageLayout.module.css +++ b/packages/react/src/PageLayout/PageLayout.module.css @@ -390,7 +390,7 @@ pointer-events: none; /* Disable transitions to prevent expensive recalculations */ - transition: none !important; + transition: none; /* Force compositor layer for hardware acceleration */ will-change: width; @@ -621,18 +621,11 @@ * OPTIMIZATION: Full containment for pane - isolates from rest of page */ contain: layout style paint; - /** * OPTIMIZATION: For extremely tall content - skip rendering off-screen content */ content-visibility: auto; - /** - * OPTIMIZATION: Provide size estimate for content-visibility - * JavaScript updates this dynamically based on actual content height - */ - contain-intrinsic-size: auto 100vh; - @media screen and (min-width: 768px) { overflow: auto; } @@ -658,7 +651,7 @@ pointer-events: none; /* Disable transitions during drag */ - transition: none !important; + transition: none; /* Force hardware acceleration */ will-change: width, transform; From bc5555ce9d1324f7f277f23fe6d1733c5b109e58 Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Mon, 1 Dec 2025 16:07:56 +0000 Subject: [PATCH 51/67] changeset --- .changeset/olive-heads-enter.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/olive-heads-enter.md diff --git a/.changeset/olive-heads-enter.md b/.changeset/olive-heads-enter.md new file mode 100644 index 00000000000..e95379932a0 --- /dev/null +++ b/.changeset/olive-heads-enter.md @@ -0,0 +1,5 @@ +--- +'@primer/react': patch +--- + +Improve drag performance for PageLayout From 8fe1364101a18084f9f04ac3b2c008de42923978 Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Mon, 1 Dec 2025 16:12:50 +0000 Subject: [PATCH 52/67] fix containment --- .../src/PageLayout/PageLayout.module.css | 25 ++----------------- 1 file changed, 2 insertions(+), 23 deletions(-) diff --git a/packages/react/src/PageLayout/PageLayout.module.css b/packages/react/src/PageLayout/PageLayout.module.css index db5df39a5d1..85867202011 100644 --- a/packages/react/src/PageLayout/PageLayout.module.css +++ b/packages/react/src/PageLayout/PageLayout.module.css @@ -437,8 +437,8 @@ * while keeping content visible (just frozen in place) */ .PageLayoutContent:has(.DraggableHandle[data-dragging='true']) .Content { - /* Full containment - isolate from layout recalculations */ - contain: strict; + /* Full containment (without size) - isolate from layout recalculations */ + contain: layout style paint; } .PaneWrapper { @@ -787,24 +787,3 @@ .DraggableHandle[data-dragging='true']:hover { background-color: var(--bgColor-accent-emphasis); } - -/** - * ACCESSIBILITY: Keyboard focus styles - * Show focus indicator when navigating via keyboard - */ -.DraggableHandle:focus { - outline: 2px solid var(--fgColor-accent); - outline-offset: 2px; -} - -/** - * Only show focus outline when navigating via keyboard, not mouse - */ -.DraggableHandle:focus:not(:focus-visible) { - outline: none; -} - -.DraggableHandle:focus-visible { - outline: 2px solid var(--fgColor-accent); - outline-offset: 2px; -} From 3b04f70a8e14c48f1775d6002ae9cf9ee1ae8542 Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Mon, 1 Dec 2025 16:57:36 +0000 Subject: [PATCH 53/67] remove onDragStart --- e2e/components/PageLayout.performance.test.ts | 126 ++++++++++++++++++ packages/react/src/PageLayout/PageLayout.tsx | 6 - 2 files changed, 126 insertions(+), 6 deletions(-) create mode 100644 e2e/components/PageLayout.performance.test.ts diff --git a/e2e/components/PageLayout.performance.test.ts b/e2e/components/PageLayout.performance.test.ts new file mode 100644 index 00000000000..d68c64639a1 --- /dev/null +++ b/e2e/components/PageLayout.performance.test.ts @@ -0,0 +1,126 @@ +import {test, expect, type Page} from '@playwright/test' +import {visit} from '../test-helpers/storybook' + +const HEAVY_PERFORMANCE_STORY = 'components-pagelayout-performance-tests--heavy-content' + +const dragHandleSelector = "[role='slider'][aria-label='Draggable pane splitter']" + +async function readContentWrapperState(page: Page) { + return page.evaluate(() => { + const content = document.querySelector('[data-width]') as HTMLElement | null + const wrapper = content?.parentElement as HTMLElement | null + const getContain = (element: HTMLElement | null | undefined) => (element ? getComputedStyle(element).contain : '') + const getPointerEvents = (element: HTMLElement | null | undefined) => + element ? getComputedStyle(element).pointerEvents : '' + + const handle = document.querySelector("[role='slider'][aria-label='Draggable pane splitter']") as HTMLElement | null + + return { + wrapperContain: getContain(wrapper), + wrapperPointerEvents: getPointerEvents(wrapper), + contentContain: getContain(content), + handleDraggingAttr: handle?.getAttribute('data-dragging') ?? null, + } + }) +} + +async function readPaneCssWidth(page: Page) { + return page.evaluate(() => { + const pane = document.querySelector('[data-resizable]') as HTMLElement | null + if (!pane) return 0 + const value = getComputedStyle(pane).getPropertyValue('--pane-width').trim() + if (!value) return 0 + const parsed = Number.parseFloat(value) + return Number.isFinite(parsed) ? parsed : 0 + }) +} + +test.describe('PageLayout performance optimizations', () => { + test('applies containment optimizations during pointer drag', async ({page}) => { + await visit(page, {id: HEAVY_PERFORMANCE_STORY}) + + const initialState = await readContentWrapperState(page) + expect(initialState.wrapperContain.toLowerCase()).toContain('layout') + expect(initialState.wrapperContain.toLowerCase()).not.toContain('paint') + expect(initialState.wrapperContain.toLowerCase()).not.toContain('size') + expect(initialState.wrapperPointerEvents).toBe('auto') + expect(initialState.handleDraggingAttr).toBeNull() + + const handle = page.locator(dragHandleSelector) + await expect(handle).toBeVisible() + const box = await handle.boundingBox() + if (!box) { + throw new Error('Could not determine drag handle position') + } + + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2) + await page.mouse.down() + await expect + .poll(async () => (await readContentWrapperState(page)).wrapperPointerEvents, { + interval: 20, + timeout: 1000, + }) + .toBe('none') + + const draggingState = await readContentWrapperState(page) + expect(draggingState.wrapperContain.toLowerCase()).toContain('paint') + expect(draggingState.wrapperContain.toLowerCase()).not.toContain('size') + expect(draggingState.wrapperPointerEvents).toBe('none') + expect(draggingState.handleDraggingAttr).toBe('true') + expect(draggingState.contentContain.toLowerCase()).toContain('paint') + + await page.mouse.up() + await expect + .poll(async () => (await readContentWrapperState(page)).wrapperPointerEvents, { + interval: 20, + timeout: 1000, + }) + .toBe('auto') + + const releasedState = await readContentWrapperState(page) + expect(releasedState.wrapperContain.toLowerCase()).not.toContain('paint') + expect(releasedState.wrapperPointerEvents).toBe('auto') + expect(releasedState.handleDraggingAttr).toBeNull() + }) + + test('updates pane width via CSS variables and persists on drag end', async ({page}) => { + await visit(page, {id: HEAVY_PERFORMANCE_STORY}) + await page.evaluate(() => localStorage.removeItem('paneWidth')) + + await expect.poll(async () => readPaneCssWidth(page), {interval: 50, timeout: 2000}).toBeGreaterThan(0) + + const recordedInitialWidth = await readPaneCssWidth(page) + + const handle = page.locator(dragHandleSelector) + await expect(handle).toBeVisible() + const box = await handle.boundingBox() + if (!box) { + throw new Error('Could not determine drag handle position') + } + + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2) + await page.mouse.down() + await page.mouse.move(box.x + box.width / 2 + 120, box.y + box.height / 2, {steps: 4}) + await expect + .poll(async () => readPaneCssWidth(page), {interval: 20, timeout: 1000}) + .toBeGreaterThan(recordedInitialWidth) + + await page.mouse.up() + await expect + .poll(async () => readPaneCssWidth(page), {interval: 20, timeout: 1000}) + .toBeGreaterThan(recordedInitialWidth) + + const widthAfterDrag = await readPaneCssWidth(page) + + await expect + .poll(async () => page.evaluate(() => localStorage.getItem('paneWidth')), { + interval: 20, + timeout: 1000, + }) + .not.toBeNull() + + const storedValue = await page.evaluate(() => localStorage.getItem('paneWidth')) + const parsedStoredWidth = storedValue ? Number.parseFloat(storedValue) : NaN + expect(parsedStoredWidth).toBeCloseTo(widthAfterDrag, 0) + }) +}) diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index 54a47dc3ff0..06e46c8efa2 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -204,7 +204,6 @@ const HorizontalDivider: React.FC> = ({ type DraggableDividerProps = { draggable?: boolean handleRef?: React.RefObject - onDragStart?: () => void onDrag?: (delta: number, isKeyboard: boolean) => void onDragEnd?: () => void onDoubleClick?: () => void @@ -231,7 +230,6 @@ const VerticalDivider: React.FC { - const stableOnDragStart = React.useRef(onDragStart) const stableOnDrag = React.useRef(onDrag) const stableOnDragEnd = React.useRef(onDragEnd) React.useEffect(() => { stableOnDrag.current = onDrag stableOnDragEnd.current = onDragEnd - stableOnDragStart.current = onDragStart }) const {paneRef} = React.useContext(PageLayoutContext) @@ -256,8 +252,6 @@ const VerticalDivider: React.FC Date: Mon, 1 Dec 2025 17:13:41 +0000 Subject: [PATCH 54/67] backout test --- e2e/components/PageLayout.performance.test.ts | 126 ------------------ 1 file changed, 126 deletions(-) delete mode 100644 e2e/components/PageLayout.performance.test.ts diff --git a/e2e/components/PageLayout.performance.test.ts b/e2e/components/PageLayout.performance.test.ts deleted file mode 100644 index d68c64639a1..00000000000 --- a/e2e/components/PageLayout.performance.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -import {test, expect, type Page} from '@playwright/test' -import {visit} from '../test-helpers/storybook' - -const HEAVY_PERFORMANCE_STORY = 'components-pagelayout-performance-tests--heavy-content' - -const dragHandleSelector = "[role='slider'][aria-label='Draggable pane splitter']" - -async function readContentWrapperState(page: Page) { - return page.evaluate(() => { - const content = document.querySelector('[data-width]') as HTMLElement | null - const wrapper = content?.parentElement as HTMLElement | null - const getContain = (element: HTMLElement | null | undefined) => (element ? getComputedStyle(element).contain : '') - const getPointerEvents = (element: HTMLElement | null | undefined) => - element ? getComputedStyle(element).pointerEvents : '' - - const handle = document.querySelector("[role='slider'][aria-label='Draggable pane splitter']") as HTMLElement | null - - return { - wrapperContain: getContain(wrapper), - wrapperPointerEvents: getPointerEvents(wrapper), - contentContain: getContain(content), - handleDraggingAttr: handle?.getAttribute('data-dragging') ?? null, - } - }) -} - -async function readPaneCssWidth(page: Page) { - return page.evaluate(() => { - const pane = document.querySelector('[data-resizable]') as HTMLElement | null - if (!pane) return 0 - const value = getComputedStyle(pane).getPropertyValue('--pane-width').trim() - if (!value) return 0 - const parsed = Number.parseFloat(value) - return Number.isFinite(parsed) ? parsed : 0 - }) -} - -test.describe('PageLayout performance optimizations', () => { - test('applies containment optimizations during pointer drag', async ({page}) => { - await visit(page, {id: HEAVY_PERFORMANCE_STORY}) - - const initialState = await readContentWrapperState(page) - expect(initialState.wrapperContain.toLowerCase()).toContain('layout') - expect(initialState.wrapperContain.toLowerCase()).not.toContain('paint') - expect(initialState.wrapperContain.toLowerCase()).not.toContain('size') - expect(initialState.wrapperPointerEvents).toBe('auto') - expect(initialState.handleDraggingAttr).toBeNull() - - const handle = page.locator(dragHandleSelector) - await expect(handle).toBeVisible() - const box = await handle.boundingBox() - if (!box) { - throw new Error('Could not determine drag handle position') - } - - await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2) - await page.mouse.down() - await expect - .poll(async () => (await readContentWrapperState(page)).wrapperPointerEvents, { - interval: 20, - timeout: 1000, - }) - .toBe('none') - - const draggingState = await readContentWrapperState(page) - expect(draggingState.wrapperContain.toLowerCase()).toContain('paint') - expect(draggingState.wrapperContain.toLowerCase()).not.toContain('size') - expect(draggingState.wrapperPointerEvents).toBe('none') - expect(draggingState.handleDraggingAttr).toBe('true') - expect(draggingState.contentContain.toLowerCase()).toContain('paint') - - await page.mouse.up() - await expect - .poll(async () => (await readContentWrapperState(page)).wrapperPointerEvents, { - interval: 20, - timeout: 1000, - }) - .toBe('auto') - - const releasedState = await readContentWrapperState(page) - expect(releasedState.wrapperContain.toLowerCase()).not.toContain('paint') - expect(releasedState.wrapperPointerEvents).toBe('auto') - expect(releasedState.handleDraggingAttr).toBeNull() - }) - - test('updates pane width via CSS variables and persists on drag end', async ({page}) => { - await visit(page, {id: HEAVY_PERFORMANCE_STORY}) - await page.evaluate(() => localStorage.removeItem('paneWidth')) - - await expect.poll(async () => readPaneCssWidth(page), {interval: 50, timeout: 2000}).toBeGreaterThan(0) - - const recordedInitialWidth = await readPaneCssWidth(page) - - const handle = page.locator(dragHandleSelector) - await expect(handle).toBeVisible() - const box = await handle.boundingBox() - if (!box) { - throw new Error('Could not determine drag handle position') - } - - await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2) - await page.mouse.down() - await page.mouse.move(box.x + box.width / 2 + 120, box.y + box.height / 2, {steps: 4}) - await expect - .poll(async () => readPaneCssWidth(page), {interval: 20, timeout: 1000}) - .toBeGreaterThan(recordedInitialWidth) - - await page.mouse.up() - await expect - .poll(async () => readPaneCssWidth(page), {interval: 20, timeout: 1000}) - .toBeGreaterThan(recordedInitialWidth) - - const widthAfterDrag = await readPaneCssWidth(page) - - await expect - .poll(async () => page.evaluate(() => localStorage.getItem('paneWidth')), { - interval: 20, - timeout: 1000, - }) - .not.toBeNull() - - const storedValue = await page.evaluate(() => localStorage.getItem('paneWidth')) - const parsedStoredWidth = storedValue ? Number.parseFloat(storedValue) : NaN - expect(parsedStoredWidth).toBeCloseTo(widthAfterDrag, 0) - }) -}) From 0c8bf15a9e6e1b90c4c3e66c721867de401cf7dd Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Mon, 1 Dec 2025 12:16:32 -0500 Subject: [PATCH 55/67] Update packages/react/src/PageLayout/PageLayout.module.css Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/react/src/PageLayout/PageLayout.module.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/PageLayout/PageLayout.module.css b/packages/react/src/PageLayout/PageLayout.module.css index 85867202011..02dc721bae0 100644 --- a/packages/react/src/PageLayout/PageLayout.module.css +++ b/packages/react/src/PageLayout/PageLayout.module.css @@ -645,7 +645,7 @@ */ .PaneWrapper:has(.DraggableHandle[data-dragging='true']) .Pane { /* Full containment - isolate from layout recalculations */ - contain: strict; + contain: layout style paint; /* Disable interactions during drag */ pointer-events: none; From 22cfbf8ade4c530d75c3d745681f69e6689affb8 Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Mon, 1 Dec 2025 19:33:11 +0000 Subject: [PATCH 56/67] aat --- .../PageLayout.performance.stories.tsx | 149 ++++++++++++------ 1 file changed, 105 insertions(+), 44 deletions(-) diff --git a/packages/react/src/PageLayout/PageLayout.performance.stories.tsx b/packages/react/src/PageLayout/PageLayout.performance.stories.tsx index be774e71eef..5d54b7c4f4f 100644 --- a/packages/react/src/PageLayout/PageLayout.performance.stories.tsx +++ b/packages/react/src/PageLayout/PageLayout.performance.stories.tsx @@ -16,10 +16,10 @@ type Story = StoryObj // ============================================================================ function usePerformanceMonitor( - fpsRef: React.RefObject, - avgRef: React.RefObject, - minRef: React.RefObject, - maxRef: React.RefObject, + fpsRef: React.RefObject, + avgRef: React.RefObject, + minRef: React.RefObject, + maxRef: React.RefObject, minGoodFps: number, minOkFps: number, ) { @@ -48,7 +48,12 @@ function usePerformanceMonitor( // Direct DOM updates - no React re-renders if (fpsRef.current) { fpsRef.current.textContent = isFinite(currentFps) ? String(currentFps) : '—' - fpsRef.current.style.color = currentFps >= minGoodFps ? 'green' : currentFps >= minOkFps ? 'orange' : 'red' + fpsRef.current.style.color = + currentFps >= minGoodFps + ? 'var(--fgColor-success)' + : currentFps >= minOkFps + ? 'var(--fgColor-attention)' + : 'var(--fgColor-danger)' } if (avgRef.current) avgRef.current.textContent = isFinite(avgFrameTime) ? `${avgFrameTime.toFixed(2)}ms` : '—' if (minRef.current) minRef.current.textContent = isFinite(minFrameTime) ? `${minFrameTime.toFixed(2)}ms` : '—' @@ -93,7 +98,13 @@ function PerformanceHeader({ usePerformanceMonitor(fpsRef, avgRef, minRef, maxRef, minGoodFps, minOkFps) return ( -
    +

    {title}

    +

    Diagnostic: Empty Page FPS

    {/* Large table with complex cells */}

    Data Table (300 rows × 10 columns)

    -
    +
    - - + + {['ID', 'Name', 'Email', 'Role', 'Status', 'Date', 'Count', 'Value', 'Tags', 'Actions'].map( (header, i) => ( {Array.from({length: 300}).map((_, rowIndex) => ( - + - - @@ -321,11 +351,22 @@ export const MediumContent: Story = { @@ -337,9 +378,9 @@ export const MediumContent: Story = { padding: '4px 8px', marginRight: '4px', cursor: 'pointer', - border: '1px solid #e1e4e8', + border: '1px solid var(--borderColor-default)', borderRadius: '3px', - background: '#fff', + background: 'var(--bgColor-default)', }} > Edit @@ -350,9 +391,9 @@ export const MediumContent: Story = { fontSize: '11px', padding: '4px 8px', cursor: 'pointer', - border: '1px solid #e1e4e8', + border: '1px solid var(--borderColor-default)', borderRadius: '3px', - background: '#fff', + background: 'var(--bgColor-default)', }} > Delete @@ -395,7 +436,7 @@ export const HeavyContent: Story = {
    Activity #{i + 1} - {i % 60}m ago + {i % 60}m ago
    -
    +
    User {['Alice', 'Bob', 'Charlie'][i % 3]} performed action on item {i}
    - + {['create', 'update', 'delete'][i % 3]} - + priority-{(i % 3) + 1}
    @@ -462,15 +517,15 @@ export const HeavyContent: Story = {

    Data Table (150 rows × 8 columns)

    #{10000 + rowIndex} {['Alice', 'Bob', 'Charlie', 'Diana', 'Eve'][rowIndex % 5]}{' '} {['Smith', 'Jones', 'Davis'][rowIndex % 3]} + user{rowIndex}@example.com @@ -300,7 +325,12 @@ export const MediumContent: Story = { + 2024-{String((rowIndex % 12) + 1).padStart(2, '0')}- {String((rowIndex % 28) + 1).padStart(2, '0')} tag{rowIndex % 10} - + type{rowIndex % 5}
    - - + + {['ID', 'Name', 'Type', 'Status', 'Date', 'Value', 'Priority', 'Owner'].map((header, i) => ( {Array.from({length: 150}).map((_, i) => ( - + - + @@ -518,9 +575,9 @@ export const HeavyContent: Story = { style={{ padding: '12px', marginBottom: '8px', - background: i % 2 === 0 ? '#fff' : '#f6f8fa', + background: i % 2 === 0 ? 'var(--bgColor-default)' : 'var(--bgColor-canvas-subtle)', borderRadius: '6px', - border: '1px solid #e1e4e8', + border: '1px solid var(--borderColor-default)', }} >
    {['bug', 'feature', 'enhancement'][i % 3]}
    - {i % 10}d ago + {i % 10}d ago -
    +
    Description for issue {i + 1}: This is some text that describes the issue in detail.
    -
    +
    👤 {['alice', 'bob', 'charlie'][i % 3]} 💬 {i % 15} comments ⭐ {i % 20} reactions @@ -698,9 +759,9 @@ export const KeyboardARIATest: Story = { style={{ marginTop: '24px', padding: '16px', - border: '1px solid #d0d7de', + border: '1px solid var(--borderColor-default)', borderRadius: '6px', - background: '#f6f8fa', + background: 'var(--bgColor-canvas-subtle)', }} >

    Live ARIA attributes

    @@ -721,7 +782,7 @@ export const KeyboardARIATest: Story = {
    aria-valuetext
    {ariaAttributes.valuetext}
    -

    +

    Values update live when the slider handle changes size via keyboard or pointer interactions.

    From c8e7a06f11dfa69bb90446c1bb14699a587824cf Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Mon, 1 Dec 2025 20:02:12 +0000 Subject: [PATCH 57/67] aat --- .../PageLayout.performance.stories.tsx | 118 ++++-------------- 1 file changed, 22 insertions(+), 96 deletions(-) diff --git a/packages/react/src/PageLayout/PageLayout.performance.stories.tsx b/packages/react/src/PageLayout/PageLayout.performance.stories.tsx index 5d54b7c4f4f..ca73dc19ce1 100644 --- a/packages/react/src/PageLayout/PageLayout.performance.stories.tsx +++ b/packages/react/src/PageLayout/PageLayout.performance.stories.tsx @@ -1,6 +1,8 @@ import React from 'react' import type {Meta, StoryObj} from '@storybook/react-vite' import {PageLayout} from './PageLayout' +import {Button} from '../Button' +import Label from '../Label' const meta: Meta = { title: 'Components/PageLayout/Performance Tests', @@ -322,22 +324,12 @@ export const MediumContent: Story = { {['Admin', 'Editor', 'Viewer', 'Manager'][rowIndex % 4]}
    ))} @@ -487,26 +445,12 @@ export const HeavyContent: Story = { User {['Alice', 'Bob', 'Charlie'][i % 3]} performed action on item {i}
    - + - + + +
    ))} @@ -543,16 +487,9 @@ export const HeavyContent: Story = { {['Type A', 'Type B', 'Type C', 'Type D'][i % 4]} ))} @@ -394,7 +392,7 @@ export const HeavyContent: Story = {
    Date: Mon, 1 Dec 2025 21:33:42 +0000 Subject: [PATCH 59/67] aat fix --- .../PageLayout/PageLayout.performance.stories.tsx | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/react/src/PageLayout/PageLayout.performance.stories.tsx b/packages/react/src/PageLayout/PageLayout.performance.stories.tsx index 95974cba07b..af60141c30d 100644 --- a/packages/react/src/PageLayout/PageLayout.performance.stories.tsx +++ b/packages/react/src/PageLayout/PageLayout.performance.stories.tsx @@ -229,7 +229,7 @@ export const BaselineLight: Story = {
    -

    Resizable Pane

    +

    Resizable Pane

    Drag to test - should be instant.

    @@ -256,7 +256,7 @@ export const MediumContent: Story = {
    -

    Performance Monitor

    +

    Performance Monitor

    Data Table (300 rows × 10 columns)
    -

    Stress Test

    +

    Stress Test

    -
    +
    {/* Section 1: Large card grid */}

    Activity Feed (200 cards)

    @@ -577,7 +578,7 @@ export const ResponsiveConstraintsTest: Story = {
    -

    Resizable Pane

    +

    Resizable Pane

    Max width: {calculatedMaxWidth}px

    @@ -666,7 +667,7 @@ export const KeyboardARIATest: Story = {
    -

    Resizable Pane

    +

    Resizable Pane

    Use keyboard: ← → ↑ ↓

    @@ -688,7 +689,7 @@ export const KeyboardARIATest: Story = { background: 'var(--bgColor-canvas-subtle)', }} > -

    Live ARIA attributes

    +

    Live ARIA attributes

    Date: Mon, 1 Dec 2025 21:59:59 +0000 Subject: [PATCH 60/67] handle frames softer --- .../PageLayout.performance.stories.tsx | 162 +++++++++++------- 1 file changed, 101 insertions(+), 61 deletions(-) diff --git a/packages/react/src/PageLayout/PageLayout.performance.stories.tsx b/packages/react/src/PageLayout/PageLayout.performance.stories.tsx index af60141c30d..8ebfd84e257 100644 --- a/packages/react/src/PageLayout/PageLayout.performance.stories.tsx +++ b/packages/react/src/PageLayout/PageLayout.performance.stories.tsx @@ -20,61 +20,106 @@ type Story = StoryObj function usePerformanceMonitor( fpsRef: React.RefObject, avgRef: React.RefObject, - minRef: React.RefObject, - maxRef: React.RefObject, + longFrameRef: React.RefObject, minGoodFps: number, minOkFps: number, + longFrameThreshold = 50, ) { React.useEffect(() => { const frameTimes: number[] = [] - let lastFrameTime = 0 - let lastUpdateTime = 0 - let animationFrameId: number + let lastUpdateTime = typeof performance !== 'undefined' ? performance.now() : 0 + let animationFrameId: number | null = null + let observer: PerformanceObserver | null = null - const measureFPS = (timestamp: number) => { - if (lastFrameTime) { - const frameTime = timestamp - lastFrameTime - frameTimes.push(frameTime) + const updateDisplays = () => { + if (frameTimes.length === 0) { + return + } + + const now = typeof performance !== 'undefined' ? performance.now() : Date.now() + if (now - lastUpdateTime < 500) { + return + } + + lastUpdateTime = now + + const framesSum = frameTimes.reduce((a, b) => a + b, 0) + const avgFrameTime = framesSum / frameTimes.length + const currentFps = Math.round(1000 / avgFrameTime) + const longFrameCount = frameTimes.reduce((count, time) => (time >= longFrameThreshold ? count + 1 : count), 0) + + if (fpsRef.current) { + fpsRef.current.textContent = isFinite(currentFps) ? String(currentFps) : '—' + fpsRef.current.style.color = + currentFps >= minGoodFps + ? 'var(--fgColor-success)' + : currentFps >= minOkFps + ? 'var(--fgColor-attention)' + : 'var(--fgColor-danger)' + } + if (avgRef.current) { + avgRef.current.textContent = isFinite(avgFrameTime) ? `${avgFrameTime.toFixed(2)}ms` : '—' + } + if (longFrameRef.current) { + longFrameRef.current.textContent = String(longFrameCount) + longFrameRef.current.style.color = + longFrameCount === 0 + ? 'var(--fgColor-success)' + : longFrameCount <= 3 + ? 'var(--fgColor-attention)' + : 'var(--fgColor-danger)' + } + } + + const recordFrameTime = (frameTime: number) => { + frameTimes.push(frameTime) + if (frameTimes.length > 120) { + frameTimes.shift() + } + updateDisplays() + } - if (frameTimes.length > 120) { - frameTimes.shift() + const supportsFrameObserver = + typeof PerformanceObserver !== 'undefined' && + Array.isArray(PerformanceObserver.supportedEntryTypes) && + PerformanceObserver.supportedEntryTypes.includes('frame') + + if (supportsFrameObserver) { + observer = new PerformanceObserver(list => { + for (const entry of list.getEntries()) { + recordFrameTime(entry.duration) } + }) + try { + observer.observe({type: 'frame', buffered: false}) + } catch (_error) { + observer.disconnect() + observer = null + } + } - // Update DOM directly every 500ms - zero React overhead - if (timestamp - lastUpdateTime >= 500) { - const avgFrameTime = frameTimes.reduce((a, b) => a + b, 0) / frameTimes.length - const currentFps = Math.round(1000 / avgFrameTime) - const maxFrameTime = Math.max(...frameTimes) - const minFrameTime = Math.min(...frameTimes) - - // Direct DOM updates - no React re-renders - if (fpsRef.current) { - fpsRef.current.textContent = isFinite(currentFps) ? String(currentFps) : '—' - fpsRef.current.style.color = - currentFps >= minGoodFps - ? 'var(--fgColor-success)' - : currentFps >= minOkFps - ? 'var(--fgColor-attention)' - : 'var(--fgColor-danger)' - } - if (avgRef.current) avgRef.current.textContent = isFinite(avgFrameTime) ? `${avgFrameTime.toFixed(2)}ms` : '—' - if (minRef.current) minRef.current.textContent = isFinite(minFrameTime) ? `${minFrameTime.toFixed(2)}ms` : '—' - if (maxRef.current) maxRef.current.textContent = isFinite(maxFrameTime) ? `${maxFrameTime.toFixed(2)}ms` : '—' - - lastUpdateTime = timestamp + if (!observer) { + let lastFrameTime = 0 + const measureFPS = (timestamp: number) => { + if (lastFrameTime) { + recordFrameTime(timestamp - lastFrameTime) } + lastFrameTime = timestamp + animationFrameId = requestAnimationFrame(measureFPS) } - lastFrameTime = timestamp animationFrameId = requestAnimationFrame(measureFPS) } - animationFrameId = requestAnimationFrame(measureFPS) - return () => { - cancelAnimationFrame(animationFrameId) + if (animationFrameId !== null) { + cancelAnimationFrame(animationFrameId) + } + if (observer) { + observer.disconnect() + } } - }, [fpsRef, avgRef, minRef, maxRef, minGoodFps, minOkFps]) + }, [fpsRef, avgRef, longFrameRef, minGoodFps, minOkFps, longFrameThreshold]) } interface PerformanceHeaderProps { @@ -83,6 +128,7 @@ interface PerformanceHeaderProps { targetFps: string minGoodFps?: number minOkFps?: number + longFrameThreshold?: number } function PerformanceHeader({ @@ -91,13 +137,13 @@ function PerformanceHeader({ targetFps, minGoodFps = 55, minOkFps = 40, + longFrameThreshold = 50, }: PerformanceHeaderProps) { const fpsRef = React.useRef(null) const avgRef = React.useRef(null) - const minRef = React.useRef(null) - const maxRef = React.useRef(null) + const longRef = React.useRef(null) - usePerformanceMonitor(fpsRef, avgRef, minRef, maxRef, minGoodFps, minOkFps) + usePerformanceMonitor(fpsRef, avgRef, longRef, minGoodFps, minOkFps, longFrameThreshold) return (
    Avg: 0ms
    - Min: 0ms -
    -
    - Max: 0ms + Long (≥{longFrameThreshold}ms): 0

    @@ -150,10 +193,10 @@ export const EmptyBaseline: Story = { render: () => { const fpsRef = React.useRef(null) const avgRef = React.useRef(null) - const minRef = React.useRef(null) - const maxRef = React.useRef(null) + const longRef = React.useRef(null) + const longFrameThreshold = 50 - usePerformanceMonitor(fpsRef, avgRef, minRef, maxRef, 55, 40) + usePerformanceMonitor(fpsRef, avgRef, longRef, 55, 40, longFrameThreshold) return (

    Avg: 0ms
    - Min: 0ms -
    -
    - Max: 0ms + Long (≥{longFrameThreshold}ms): 0

    @@ -210,7 +250,7 @@ export const BaselineLight: Story = { name: '1. Light Content - Baseline (~100 elements)', render: () => { return ( - + { return ( - + { return ( - + -

    +
    {/* Section 1: Large card grid */}

    Activity Feed (200 cards)

    @@ -565,7 +605,7 @@ export const ResponsiveConstraintsTest: Story = { const calculatedMaxWidth = Math.max(256, viewportWidth - maxWidthDiff) return ( - + + Date: Mon, 1 Dec 2025 22:28:21 +0000 Subject: [PATCH 61/67] remove perf header --- .../PageLayout.performance.stories.tsx | 293 +----------------- .../__snapshots__/PageLayout.test.tsx.snap | 8 +- 2 files changed, 17 insertions(+), 284 deletions(-) diff --git a/packages/react/src/PageLayout/PageLayout.performance.stories.tsx b/packages/react/src/PageLayout/PageLayout.performance.stories.tsx index 8ebfd84e257..b1b887999ba 100644 --- a/packages/react/src/PageLayout/PageLayout.performance.stories.tsx +++ b/packages/react/src/PageLayout/PageLayout.performance.stories.tsx @@ -3,6 +3,7 @@ import type {Meta, StoryObj} from '@storybook/react-vite' import {PageLayout} from './PageLayout' import {Button} from '../Button' import Label from '../Label' +import Heading from '../Heading' const meta: Meta = { title: 'Components/PageLayout/Performance Tests', @@ -13,235 +14,6 @@ export default meta type Story = StoryObj -// ============================================================================ -// Shared Performance Monitor Hook & Component -// ============================================================================ - -function usePerformanceMonitor( - fpsRef: React.RefObject, - avgRef: React.RefObject, - longFrameRef: React.RefObject, - minGoodFps: number, - minOkFps: number, - longFrameThreshold = 50, -) { - React.useEffect(() => { - const frameTimes: number[] = [] - let lastUpdateTime = typeof performance !== 'undefined' ? performance.now() : 0 - let animationFrameId: number | null = null - let observer: PerformanceObserver | null = null - - const updateDisplays = () => { - if (frameTimes.length === 0) { - return - } - - const now = typeof performance !== 'undefined' ? performance.now() : Date.now() - if (now - lastUpdateTime < 500) { - return - } - - lastUpdateTime = now - - const framesSum = frameTimes.reduce((a, b) => a + b, 0) - const avgFrameTime = framesSum / frameTimes.length - const currentFps = Math.round(1000 / avgFrameTime) - const longFrameCount = frameTimes.reduce((count, time) => (time >= longFrameThreshold ? count + 1 : count), 0) - - if (fpsRef.current) { - fpsRef.current.textContent = isFinite(currentFps) ? String(currentFps) : '—' - fpsRef.current.style.color = - currentFps >= minGoodFps - ? 'var(--fgColor-success)' - : currentFps >= minOkFps - ? 'var(--fgColor-attention)' - : 'var(--fgColor-danger)' - } - if (avgRef.current) { - avgRef.current.textContent = isFinite(avgFrameTime) ? `${avgFrameTime.toFixed(2)}ms` : '—' - } - if (longFrameRef.current) { - longFrameRef.current.textContent = String(longFrameCount) - longFrameRef.current.style.color = - longFrameCount === 0 - ? 'var(--fgColor-success)' - : longFrameCount <= 3 - ? 'var(--fgColor-attention)' - : 'var(--fgColor-danger)' - } - } - - const recordFrameTime = (frameTime: number) => { - frameTimes.push(frameTime) - if (frameTimes.length > 120) { - frameTimes.shift() - } - updateDisplays() - } - - const supportsFrameObserver = - typeof PerformanceObserver !== 'undefined' && - Array.isArray(PerformanceObserver.supportedEntryTypes) && - PerformanceObserver.supportedEntryTypes.includes('frame') - - if (supportsFrameObserver) { - observer = new PerformanceObserver(list => { - for (const entry of list.getEntries()) { - recordFrameTime(entry.duration) - } - }) - try { - observer.observe({type: 'frame', buffered: false}) - } catch (_error) { - observer.disconnect() - observer = null - } - } - - if (!observer) { - let lastFrameTime = 0 - const measureFPS = (timestamp: number) => { - if (lastFrameTime) { - recordFrameTime(timestamp - lastFrameTime) - } - lastFrameTime = timestamp - animationFrameId = requestAnimationFrame(measureFPS) - } - - animationFrameId = requestAnimationFrame(measureFPS) - } - - return () => { - if (animationFrameId !== null) { - cancelAnimationFrame(animationFrameId) - } - if (observer) { - observer.disconnect() - } - } - }, [fpsRef, avgRef, longFrameRef, minGoodFps, minOkFps, longFrameThreshold]) -} - -interface PerformanceHeaderProps { - title: string - loadDescription: string - targetFps: string - minGoodFps?: number - minOkFps?: number - longFrameThreshold?: number -} - -function PerformanceHeader({ - title, - loadDescription, - targetFps, - minGoodFps = 55, - minOkFps = 40, - longFrameThreshold = 50, -}: PerformanceHeaderProps) { - const fpsRef = React.useRef(null) - const avgRef = React.useRef(null) - const longRef = React.useRef(null) - - usePerformanceMonitor(fpsRef, avgRef, longRef, minGoodFps, minOkFps, longFrameThreshold) - - return ( -
    -

    {title}

    -
    -
    - FPS:{' '} - - 0 - -
    -
    - Avg: 0ms -
    -
    - Long (≥{longFrameThreshold}ms): 0 -
    -
    -

    - Load: {loadDescription} -
    - Target: {targetFps} -

    -
    - ) -} - -// ============================================================================ -// Story 0: Empty Baseline - No PageLayout -// ============================================================================ - -export const EmptyBaseline: Story = { - name: '0. Empty Baseline - No PageLayout (diagnostic)', - render: () => { - const fpsRef = React.useRef(null) - const avgRef = React.useRef(null) - const longRef = React.useRef(null) - const longFrameThreshold = 50 - - usePerformanceMonitor(fpsRef, avgRef, longRef, 55, 40, longFrameThreshold) - - return ( -
    -

    Diagnostic: Empty Page FPS

    -
    -
    - FPS:{' '} - - 0 - -
    -
    - Avg: 0ms -
    -
    - Long (≥{longFrameThreshold}ms): 0 -
    -
    -

    - This page has NO PageLayout component - just the FPS monitor. -
    - If this shows 30 FPS, the issue is external (browser throttling, power settings, etc). -
    - If this shows 60 FPS, the issue is in PageLayout. -

    -
    - ) - }, -} - // ============================================================================ // Story 1: Baseline - Light Content (~100 elements) // ============================================================================ @@ -252,16 +24,11 @@ export const BaselineLight: Story = { return ( - + Light Content Baseline
    -

    Light Content Baseline

    Minimal DOM elements to establish baseline.

    Should be effortless 60 FPS.

    @@ -269,7 +36,6 @@ export const BaselineLight: Story = {
    -

    Resizable Pane

    Drag to test - should be instant.

    @@ -288,11 +54,7 @@ export const MediumContent: Story = { return ( - + Medium Content - Large Table
    @@ -310,11 +72,7 @@ export const MediumContent: Story = {
    Table: 300 rows × 10 cols
    - Target: 55-60 FPS
    -

    - This table has enough elements to show performance differences. Drag and watch FPS. -

    @@ -418,18 +176,11 @@ export const HeavyContent: Story = { return ( - + Heavy Content - Multiple Sections (~5000 elements)
    -

    Stress Test

    Mix: Cards, tables, lists
    - Target: 50-60 FPS

    Sections: @@ -454,16 +204,13 @@ export const HeavyContent: Story = {

  • 200 issue items (~1200 elem)
  • + Headers, buttons, etc
  • -

    - This should show measurable FPS impact. Target is 50-60 FPS. -

    {/* Section 1: Large card grid */} -
    +

    Activity Feed (200 cards)

    {Array.from({length: 200}).map((_, i) => ( @@ -494,10 +241,10 @@ export const HeavyContent: Story = {
    ))}
    -
    + {/* Section 2: Large table */} -
    +

    Data Table (150 rows × 8 columns)

    @@ -481,7 +536,7 @@ export const HeavyContent: Story = {
    #{5000 + i} Item {i + 1} @@ -491,7 +546,7 @@ export const HeavyContent: Story = { Dec {(i % 30) + 1} + Dec {(i % 30) + 1} + ${(i * 50 + 100).toFixed(2)} {['Low', 'Medium', 'High'][i % 3]} user{i % 20} - {rowIndex % 3 === 0 ? 'Active' : rowIndex % 2 === 0 ? 'Pending' : 'Inactive'} - + 2024-{String((rowIndex % 12) + 1).padStart(2, '0')}- @@ -350,54 +342,20 @@ export const MediumContent: Story = { ${((rowIndex * 123.45) % 10000).toFixed(2)} - + - + + + - - + +
    - + + Dec {(i % 30) + 1} @@ -590,20 +527,9 @@ export const HeavyContent: Story = { >
    Issue #{i + 1} - + +
    {i % 10}d ago From 94ef9c940031b9bf2aecbc0016a75dd33d3b003f Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Mon, 1 Dec 2025 20:19:19 +0000 Subject: [PATCH 58/67] aat --- .../src/PageLayout/PageLayout.performance.stories.tsx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/react/src/PageLayout/PageLayout.performance.stories.tsx b/packages/react/src/PageLayout/PageLayout.performance.stories.tsx index ca73dc19ce1..95974cba07b 100644 --- a/packages/react/src/PageLayout/PageLayout.performance.stories.tsx +++ b/packages/react/src/PageLayout/PageLayout.performance.stories.tsx @@ -260,7 +260,7 @@ export const MediumContent: Story = {
    - - +
    @@ -540,10 +287,10 @@ export const HeavyContent: Story = { ))}
    -
    + {/* Section 3: List with nested content */} -
    +

    Issue Tracker (200 items)

    {Array.from({length: 200}).map((_, i) => (
    ))} -
    +
    @@ -607,18 +354,11 @@ export const ResponsiveConstraintsTest: Story = { return ( - + Responsive Constraints Test
    -

    Resizable Pane

    Max width: {calculatedMaxWidth}px

    @@ -696,18 +436,11 @@ export const KeyboardARIATest: Story = { return ( - + Keyboard & ARIA Test
    -

    Resizable Pane

    Use keyboard: ← → ↑ ↓

    @@ -729,7 +462,7 @@ export const KeyboardARIATest: Story = { background: 'var(--bgColor-canvas-subtle)', }} > -

    Live ARIA attributes

    +

    Live ARIA attributes

    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
    From e93ccff0bf4b3bd84631649c6228d59e38b9c1ae Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Mon, 1 Dec 2025 22:43:02 +0000 Subject: [PATCH 62/67] update snapshots --- .../src/PageLayout/__snapshots__/PageLayout.test.tsx.snap | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/react/src/PageLayout/__snapshots__/PageLayout.test.tsx.snap b/packages/react/src/PageLayout/__snapshots__/PageLayout.test.tsx.snap index fdc56378f75..5ac82e6ca02 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
    From 66230cf1d2b9b23124c213bb84207d1aeb38a6fb Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Mon, 1 Dec 2025 22:51:13 +0000 Subject: [PATCH 63/67] shave a few bytes on the handle checks --- packages/react/src/PageLayout/PageLayout.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index 06e46c8efa2..8b693927c75 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -203,7 +203,7 @@ const HorizontalDivider: React.FC> = ({ type DraggableDividerProps = { draggable?: boolean - handleRef?: React.RefObject + handleRef: React.RefObject onDrag?: (delta: number, isKeyboard: boolean) => void onDragEnd?: () => void onDoubleClick?: () => void @@ -256,7 +256,7 @@ const VerticalDivider: React.FC) => { - if (!handleRef?.current || !isDragging(handleRef.current)) return + if (!isDragging(handleRef.current)) return event.preventDefault() if (event.movementX !== 0) { @@ -268,7 +268,7 @@ const VerticalDivider: React.FC) => { - if (!handleRef?.current || !isDragging(handleRef.current)) return + if (!isDragging(handleRef.current)) return event.preventDefault() // Cleanup will happen in onLostPointerCapture }, @@ -277,7 +277,7 @@ const VerticalDivider: React.FC) => { - if (!handleRef?.current || !isDragging(handleRef.current)) return + if (!isDragging(handleRef.current)) return const target = event.currentTarget target.removeAttribute(DATA_DRAGGING_ATTR) From bf13da97d02e7f4b9cdb1f1422a176f5e0a56f2f Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Mon, 1 Dec 2025 22:55:49 +0000 Subject: [PATCH 64/67] cleanup a few bytes --- packages/react/src/PageLayout/PageLayout.tsx | 41 ++++++++++++++++---- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index 8b693927c75..6ff2909bf30 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -204,9 +204,9 @@ const HorizontalDivider: React.FC> = ({ type DraggableDividerProps = { draggable?: boolean handleRef: React.RefObject - onDrag?: (delta: number, isKeyboard: boolean) => void - onDragEnd?: () => void - onDoubleClick?: () => void + onDrag: (delta: number, isKeyboard: boolean) => void + onDragEnd: () => void + onDoubleClick: () => void } // Helper to update ARIA slider attributes via direct DOM manipulation @@ -246,6 +246,11 @@ const VerticalDivider: React.FC) => { if (event.button !== 0) return event.preventDefault() @@ -254,18 +259,27 @@ const VerticalDivider: React.FC) => { if (!isDragging(handleRef.current)) return event.preventDefault() if (event.movementX !== 0) { - stableOnDrag.current?.(event.movementX, false) + stableOnDrag.current(event.movementX, false) } }, [handleRef], ) + /** + * Pointer up ends a drag operation + * Prevents default to avoid unwanted selection behavior + */ const handlePointerUp = React.useCallback( (event: React.PointerEvent) => { if (!isDragging(handleRef.current)) return @@ -275,17 +289,28 @@ const VerticalDivider: React.FC) => { if (!isDragging(handleRef.current)) return const target = event.currentTarget target.removeAttribute(DATA_DRAGGING_ATTR) - - stableOnDragEnd.current?.() + stableOnDragEnd.current() }, [handleRef], ) + /** + * Keyboard handling for accessibility + * Arrow keys adjust the pane size in 3px increments + * Prevents default scrolling behavior + * Sets and clears dragging state via data attribute + * Calls onDrag + */ const handleKeyDown = React.useCallback( (event: React.KeyboardEvent) => { if ( @@ -302,7 +327,7 @@ const VerticalDivider: React.FC Date: Mon, 1 Dec 2025 23:49:07 +0000 Subject: [PATCH 65/67] avoid heavy tests --- e2e/components/Axe.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/e2e/components/Axe.test.ts b/e2e/components/Axe.test.ts index 74b17d88f6b..714ac97c6f0 100644 --- a/e2e/components/Axe.test.ts +++ b/e2e/components/Axe.test.ts @@ -14,6 +14,8 @@ const SKIPPED_TESTS = [ 'components-flash-features--with-icon-action-dismiss', // TODO: Remove once color-contrast issues have been resolved 'components-flash-features--with-icon-and-action', // TODO: Remove once color-contrast issues have been resolved 'components-filteredactionlist--default', + 'components-pagelayout-performance-tests--medium-content', + 'components-pagelayout-performance-tests--heavy-content', ] type Component = { From 0029dfe90c1a3c758c85027b4e734e89d5fa0c50 Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Tue, 2 Dec 2025 01:50:50 +0000 Subject: [PATCH 66/67] avoid heavy tests --- packages/react/src/PageLayout/PageLayout.tsx | 74 ++++++++----------- .../__snapshots__/PageLayout.test.tsx.snap | 8 +- 2 files changed, 35 insertions(+), 47 deletions(-) diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index 6ff2909bf30..5fdc0edd19b 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -5,7 +5,6 @@ import {useRefObjectAsForwardedRef} from '../hooks/useRefObjectAsForwardedRef' import type {ResponsiveValue} from '../hooks/useResponsiveValue' import {isResponsiveValue} from '../hooks/useResponsiveValue' import {useSlots} from '../hooks/useSlots' -import {canUseDOM} from '../utils/environment' import {useOverflow} from '../hooks/useOverflow' import {warning} from '../utils/warning' import {getResponsiveAttributes} from '../internal/utils/getResponsiveAttributes' @@ -569,6 +568,15 @@ const isPaneWidth = (width: PaneWidth | CustomWidthOptions): width is PaneWidth return ['small', 'medium', 'large'].includes(width as PaneWidth) } +const getDefaultPaneWidth = (w: PaneWidth | CustomWidthOptions): number => { + if (isPaneWidth(w)) { + return defaultPaneWidth[w] + } else if (isCustomWidthOptions(w)) { + return parseInt(w.default, 10) + } + return 0 +} + export type PageLayoutPaneProps = { position?: keyof typeof panePositions | ResponsiveValue /** @@ -677,51 +685,32 @@ const Pane = React.forwardRef { - if (isPaneWidth(width)) { - return defaultPaneWidth[width] - } else if (isCustomWidthOptions(width)) { - return parseInt(width.default, 10) - } - return 0 + // Track current width during drag - initialized to default for hydration safety + const currentWidthRef = React.useRef(null) + // if we don't have a ref set, set one to the default width. This is only for the first render. + if (currentWidthRef.current === null) { + currentWidthRef.current = getDefaultPaneWidth(width) } - const [paneWidth, setPaneWidth] = React.useState(() => { - if (!canUseDOM) { - return getDefaultPaneWidth(width) - } - - let storedWidth - + useIsomorphicLayoutEffect(() => { + // before paint reads the stored width from localStorage and update if necessary try { - storedWidth = localStorage.getItem(widthStorageKey) - } catch (_error) { - storedWidth = null + const value = localStorage.getItem(widthStorageKey) + if (value !== null && !isNaN(Number(value))) { + const num = Number(value) + currentWidthRef.current = num + paneRef.current?.style.setProperty('--pane-width', `${num}px`) + } + } catch { + // localStorage unavailable (e.g., private browsing) } - - return storedWidth && !isNaN(Number(storedWidth)) ? Number(storedWidth) : getDefaultPaneWidth(width) - }) - - // Track current width during drag to avoid reading stale state - const currentWidthRef = React.useRef(paneWidth) - React.useEffect(() => { - currentWidthRef.current = paneWidth - }, [paneWidth]) - - // Set --pane-width via layout effect. This runs on every render to ensure - // the CSS variable reflects currentWidthRef (which may differ from state after drag). - // We intentionally have no deps array - we want this to run on every render. - useIsomorphicLayoutEffect(() => { - paneRef.current?.style.setProperty('--pane-width', `${currentWidthRef.current}px`) - }) + }, [widthStorageKey]) // Subscribe to viewport width changes for responsive max constraint calculation const viewportWidth = useViewportWidth() // Calculate min width constraint from width configuration - const minPaneWidth = React.useMemo(() => { - return isCustomWidthOptions(width) ? parseInt(width.min, 10) : minWidth - }, [width, minWidth]) + const minPaneWidth = isCustomWidthOptions(width) ? parseInt(width.min, 10) : minWidth // Cache max width constraint - updated when viewport changes (which triggers CSS breakpoint changes) // This avoids calling getComputedStyle() on every drag frame @@ -740,11 +729,11 @@ const Pane = React.forwardRef(null) // Update ARIA attributes on mount and when viewport/constraints change - React.useEffect(() => { + useIsomorphicLayoutEffect(() => { updateAriaValues(handleRef.current, { min: minPaneWidth, max: maxPaneWidthRef.current, - current: currentWidthRef.current, + current: currentWidthRef.current!, }) }, [minPaneWidth, viewportWidth]) @@ -847,17 +836,16 @@ const Pane = React.forwardRef 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
    From 6844efd352459006c280d674411440a4e8d51bd9 Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Tue, 2 Dec 2025 04:13:09 +0000 Subject: [PATCH 67/67] fix vrt? --- packages/react/src/PageLayout/PageLayout.tsx | 28 +++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx index 5fdc0edd19b..3aafaf85b22 100644 --- a/packages/react/src/PageLayout/PageLayout.tsx +++ b/packages/react/src/PageLayout/PageLayout.tsx @@ -685,26 +685,34 @@ const Pane = React.forwardRef(null) - // if we don't have a ref set, set one to the default width. This is only for the first render. - if (currentWidthRef.current === null) { - currentWidthRef.current = getDefaultPaneWidth(width) - } + // Initial pane width for the first render - only used to set the initial CSS variable. + // After mount, all updates go directly to the DOM via style.setProperty() to avoid re-renders. + const defaultWidth = getDefaultPaneWidth(width) + + // Track current width during drag - initialized lazily in layout effect + const currentWidthRef = React.useRef(defaultWidth) + + // Track whether we've initialized the width from localStorage + const initializedRef = React.useRef(false) useIsomorphicLayoutEffect(() => { - // before paint reads the stored width from localStorage and update if necessary + // Only initialize once on mount - subsequent updates come from drag operations + if (initializedRef.current || !resizable) return + initializedRef.current = true + // Before paint, check localStorage for a stored width try { const value = localStorage.getItem(widthStorageKey) if (value !== null && !isNaN(Number(value))) { const num = Number(value) currentWidthRef.current = num paneRef.current?.style.setProperty('--pane-width', `${num}px`) + return } } catch { - // localStorage unavailable (e.g., private browsing) + // localStorage unavailable - set default via DOM } - }, [widthStorageKey]) + paneRef.current?.style.setProperty('--pane-width', `${defaultWidth}px`) + }, [widthStorageKey, paneRef, resizable, defaultWidth]) // Subscribe to viewport width changes for responsive max constraint calculation const viewportWidth = useViewportWidth() @@ -807,7 +815,7 @@ const Pane = React.forwardRef