From 599bdc2c0b8a97f5d800f5c5606e2b0582d7a912 Mon Sep 17 00:00:00 2001 From: Hector Garcia Date: Thu, 12 Feb 2026 13:50:28 +0000 Subject: [PATCH 1/7] perf(PageLayout): eliminate forced reflow from getComputedStyle on mount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace getPaneMaxWidthDiff (which calls getComputedStyle, forcing a synchronous layout recalc) with getMaxWidthDiffFromViewport, a pure JS function that derives the same value from window.innerWidth. The CSS variable --pane-max-width-diff only has two values controlled by a single @media (min-width: 1280px) breakpoint, so a simple threshold check is semantically equivalent — no DOM query needed. This eliminates ~614ms of blocking time on mount for pages with large DOM trees (e.g. SplitPageLayout). --- .../src/PageLayout/PageLayout.module.css | 2 + .../react/src/PageLayout/usePaneWidth.test.ts | 52 +++++++++++++------ packages/react/src/PageLayout/usePaneWidth.ts | 22 ++++++-- 3 files changed, 55 insertions(+), 21 deletions(-) diff --git a/packages/react/src/PageLayout/PageLayout.module.css b/packages/react/src/PageLayout/PageLayout.module.css index 0e9d2effe27..526fa6d8ee5 100644 --- a/packages/react/src/PageLayout/PageLayout.module.css +++ b/packages/react/src/PageLayout/PageLayout.module.css @@ -6,6 +6,8 @@ paneMaxWidthDiffDefault: 511; /* Default value for --sidebar-max-width-diff (constant across all viewports) */ sidebarMaxWidthDiffDefault: 256; + /* Value for --pane-max-width-diff at/above the breakpoint */ + paneMaxWidthDiffWide: 959; } .PageLayoutRoot { diff --git a/packages/react/src/PageLayout/usePaneWidth.test.ts b/packages/react/src/PageLayout/usePaneWidth.test.ts index b31fcc38162..1e9fe02f984 100644 --- a/packages/react/src/PageLayout/usePaneWidth.test.ts +++ b/packages/react/src/PageLayout/usePaneWidth.test.ts @@ -6,6 +6,7 @@ import { isPaneWidth, getDefaultPaneWidth, getPaneMaxWidthDiff, + getMaxWidthDiffFromViewport, updateAriaValues, defaultPaneWidth, DEFAULT_MAX_WIDTH_DIFF, @@ -554,7 +555,7 @@ describe('usePaneWidth', () => { it('should calculate max based on viewport for preset widths', () => { const refs = createMockRefs() - vi.stubGlobal('innerWidth', 1280) + vi.stubGlobal('innerWidth', 1024) const {result} = renderHook(() => usePaneWidth({ @@ -566,8 +567,8 @@ describe('usePaneWidth', () => { }), ) - // viewport (1280) - DEFAULT_MAX_WIDTH_DIFF (511) = 769 - expect(result.current.getMaxPaneWidth()).toBe(769) + // viewport (1024) - DEFAULT_MAX_WIDTH_DIFF (511) = 513 + expect(result.current.getMaxPaneWidth()).toBe(513) }) it('should return minPaneWidth when viewport is too small', () => { @@ -711,10 +712,10 @@ describe('usePaneWidth', () => { }), ) - // Initial --pane-max-width should be set on mount - expect(refs.paneRef.current?.style.getPropertyValue('--pane-max-width')).toBe('769px') + // Initial --pane-max-width should be set on mount (1280 - 959 wide diff = 321) + expect(refs.paneRef.current?.style.getPropertyValue('--pane-max-width')).toBe('321px') - // Shrink viewport + // Shrink viewport (crosses 1280 breakpoint, diff switches to 511) vi.stubGlobal('innerWidth', 1000) // Fire resize - with throttle, first update happens immediately (if THROTTLE_MS passed) @@ -747,10 +748,10 @@ describe('usePaneWidth', () => { }), ) - // Initial ARIA max should be set on mount - expect(refs.handleRef.current?.getAttribute('aria-valuemax')).toBe('769') + // Initial ARIA max should be set on mount (1280 - 959 wide diff = 321) + expect(refs.handleRef.current?.getAttribute('aria-valuemax')).toBe('321') - // Shrink viewport + // Shrink viewport (crosses 1280 breakpoint, diff switches to 511) vi.stubGlobal('innerWidth', 900) // Fire resize - with throttle, update happens via rAF @@ -835,10 +836,10 @@ describe('usePaneWidth', () => { }), ) - // Initial maxPaneWidth state - expect(result.current.maxPaneWidth).toBe(769) + // Initial maxPaneWidth state (1280 - 959 wide diff = 321) + expect(result.current.maxPaneWidth).toBe(321) - // Shrink viewport + // Shrink viewport (crosses 1280 breakpoint, diff switches to 511) vi.stubGlobal('innerWidth', 800) window.dispatchEvent(new Event('resize')) @@ -990,14 +991,14 @@ describe('usePaneWidth', () => { }), ) - // Initial max at 1280px: 1280 - 511 = 769 - expect(result.current.getMaxPaneWidth()).toBe(769) + // Initial max at 1280px: 1280 - 959 (wide diff) = 321 + expect(result.current.getMaxPaneWidth()).toBe(321) - // Viewport changes (no resize event needed) + // Viewport changes (no resize event fired, so maxWidthDiffRef stays at 959) vi.stubGlobal('innerWidth', 800) - // getMaxPaneWidth reads window.innerWidth dynamically - expect(result.current.getMaxPaneWidth()).toBe(289) + // getMaxPaneWidth reads window.innerWidth dynamically: max(256, 800 - 959) = 256 + expect(result.current.getMaxPaneWidth()).toBe(256) }) it('should return custom max regardless of viewport for custom widths', () => { @@ -1161,6 +1162,23 @@ describe('helper functions', () => { }) }) + describe('getMaxWidthDiffFromViewport', () => { + it('should return default value below the breakpoint', () => { + vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1024) + expect(getMaxWidthDiffFromViewport()).toBe(511) + }) + + it('should return wide value at the breakpoint', () => { + vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1280) + expect(getMaxWidthDiffFromViewport()).toBe(959) + }) + + it('should return wide value above the breakpoint', () => { + vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1920) + expect(getMaxWidthDiffFromViewport()).toBe(959) + }) + }) + describe('updateAriaValues', () => { it('should set ARIA attributes on element', () => { const handle = document.createElement('div') diff --git a/packages/react/src/PageLayout/usePaneWidth.ts b/packages/react/src/PageLayout/usePaneWidth.ts index a27cb50eb04..b366a5fa257 100644 --- a/packages/react/src/PageLayout/usePaneWidth.ts +++ b/packages/react/src/PageLayout/usePaneWidth.ts @@ -75,6 +75,9 @@ export const DEFAULT_MAX_WIDTH_DIFF = Number(cssExports.paneMaxWidthDiffDefault) */ export const DEFAULT_SIDEBAR_MAX_WIDTH_DIFF = Number(cssExports.sidebarMaxWidthDiffDefault) +// Value for --pane-max-width-diff at/above the wide breakpoint. +const WIDE_MAX_WIDTH_DIFF = Number(cssExports.paneMaxWidthDiffWide) + // --pane-max-width-diff changes at this breakpoint in PageLayout.module.css. const DEFAULT_PANE_MAX_WIDTH_DIFF_BREAKPOINT = Number(cssExports.paneMaxWidthDiffBreakpoint) /** @@ -126,6 +129,15 @@ export function getPaneMaxWidthDiff(paneElement: HTMLElement | null, isSidebar = return value > 0 ? value : defaultValue } +/** + * Derives the --pane-max-width-diff value from viewport width alone. + * Avoids the expensive getComputedStyle call that forces a synchronous layout recalc. + * The CSS only defines two breakpoint-dependent values, so a simple width check is equivalent. + */ +export function getMaxWidthDiffFromViewport(): number { + return window.innerWidth >= DEFAULT_PANE_MAX_WIDTH_DIFF_BREAKPOINT ? WIDE_MAX_WIDTH_DIFF : DEFAULT_MAX_WIDTH_DIFF +} + // Helper to update ARIA slider attributes via direct DOM manipulation // This avoids re-renders when values change during drag or on viewport resize export const updateAriaValues = ( @@ -338,7 +350,7 @@ export function usePaneWidth({ const syncAll = () => { const currentViewportWidth = window.innerWidth - // Only call getComputedStyle if we crossed the breakpoint (expensive) + // Only update the cached diff value if we crossed the breakpoint const crossedBreakpoint = (lastViewportWidth < DEFAULT_PANE_MAX_WIDTH_DIFF_BREAKPOINT && currentViewportWidth >= DEFAULT_PANE_MAX_WIDTH_DIFF_BREAKPOINT) || @@ -347,7 +359,7 @@ export function usePaneWidth({ lastViewportWidth = currentViewportWidth if (crossedBreakpoint) { - maxWidthDiffRef.current = getPaneMaxWidthDiff(paneRef.current, constrainToViewport) + maxWidthDiffRef.current = getMaxWidthDiffFromViewport() } const actualMax = getMaxPaneWidthRef.current() @@ -374,8 +386,10 @@ export function usePaneWidth({ }) } - // Initial calculation on mount - maxWidthDiffRef.current = getPaneMaxWidthDiff(paneRef.current, constrainToViewport) + // Initial calculation on mount — use viewport-based lookup to avoid + // getComputedStyle which forces a synchronous layout recalc on the + // freshly-committed DOM tree (measured at ~614ms on large pages). + maxWidthDiffRef.current = getMaxWidthDiffFromViewport() const initialMax = getMaxPaneWidthRef.current() setMaxPaneWidth(initialMax) paneRef.current?.style.setProperty('--pane-max-width', `${initialMax}px`) From 786ee1b1476f3b9e2c2ccd4dd9d7be2d51a2b57a Mon Sep 17 00:00:00 2001 From: Hector Garcia Date: Thu, 12 Feb 2026 13:50:50 +0000 Subject: [PATCH 2/7] Add changeset --- .changeset/pagelayout-remove-reflow.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/pagelayout-remove-reflow.md diff --git a/.changeset/pagelayout-remove-reflow.md b/.changeset/pagelayout-remove-reflow.md new file mode 100644 index 00000000000..25b3fec052f --- /dev/null +++ b/.changeset/pagelayout-remove-reflow.md @@ -0,0 +1,5 @@ +--- +"@primer/react": patch +--- + +**PageLayout**: Eliminate forced reflow (~614ms) on mount by replacing `getComputedStyle` call with a pure JS viewport width check for the `--pane-max-width-diff` CSS variable. From 4daad29f81527af1640d8c9730dd79b03d6e6ca1 Mon Sep 17 00:00:00 2001 From: Hector Garcia Date: Thu, 12 Feb 2026 16:13:00 +0000 Subject: [PATCH 3/7] fix(PageLayout): use vi.stubGlobal for innerWidth in getMaxWidthDiffFromViewport tests Replace vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(...) with vi.stubGlobal('innerWidth', ...) to prevent spy leaks. The outer describe block's afterEach calls vi.unstubAllGlobals(), which correctly cleans up stubGlobal but does not restore spyOn mocks. This makes the tests consistent with the rest of the file and avoids order-dependent failures. --- packages/react/src/PageLayout/usePaneWidth.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react/src/PageLayout/usePaneWidth.test.ts b/packages/react/src/PageLayout/usePaneWidth.test.ts index 1e9fe02f984..13f8ea32782 100644 --- a/packages/react/src/PageLayout/usePaneWidth.test.ts +++ b/packages/react/src/PageLayout/usePaneWidth.test.ts @@ -1164,17 +1164,17 @@ describe('helper functions', () => { describe('getMaxWidthDiffFromViewport', () => { it('should return default value below the breakpoint', () => { - vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1024) + vi.stubGlobal('innerWidth', 1024) expect(getMaxWidthDiffFromViewport()).toBe(511) }) it('should return wide value at the breakpoint', () => { - vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1280) + vi.stubGlobal('innerWidth', 1280) expect(getMaxWidthDiffFromViewport()).toBe(959) }) it('should return wide value above the breakpoint', () => { - vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1920) + vi.stubGlobal('innerWidth', 1920) expect(getMaxWidthDiffFromViewport()).toBe(959) }) }) From 95843a6d884f9403aee540d1f381775e6d7c97ce Mon Sep 17 00:00:00 2001 From: Hector Garcia Date: Thu, 12 Feb 2026 16:13:07 +0000 Subject: [PATCH 4/7] fix(PageLayout): guard getMaxWidthDiffFromViewport for non-DOM environments Add canUseDOM check so the function returns DEFAULT_MAX_WIDTH_DIFF instead of throwing when window is unavailable (SSR, node tests, build-time evaluation). canUseDOM was already imported in the file. --- packages/react/src/PageLayout/usePaneWidth.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react/src/PageLayout/usePaneWidth.ts b/packages/react/src/PageLayout/usePaneWidth.ts index b366a5fa257..fc0169e07d6 100644 --- a/packages/react/src/PageLayout/usePaneWidth.ts +++ b/packages/react/src/PageLayout/usePaneWidth.ts @@ -135,6 +135,7 @@ export function getPaneMaxWidthDiff(paneElement: HTMLElement | null, isSidebar = * The CSS only defines two breakpoint-dependent values, so a simple width check is equivalent. */ export function getMaxWidthDiffFromViewport(): number { + if (!canUseDOM) return DEFAULT_MAX_WIDTH_DIFF return window.innerWidth >= DEFAULT_PANE_MAX_WIDTH_DIFF_BREAKPOINT ? WIDE_MAX_WIDTH_DIFF : DEFAULT_MAX_WIDTH_DIFF } From 669e0fd2406e5dade7aa2204dc932f4925e38948 Mon Sep 17 00:00:00 2001 From: Hector Garcia Date: Fri, 13 Feb 2026 15:38:31 +0000 Subject: [PATCH 5/7] refactor: remove dead getPaneMaxWidthDiff function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The function is no longer called after replacing it with getMaxWidthDiffFromViewport(). Keeping it around is a footgun — it calls getComputedStyle which forces synchronous layout. Remove it and its associated tests. --- .../react/src/PageLayout/usePaneWidth.test.ts | 21 ------------------- packages/react/src/PageLayout/usePaneWidth.ts | 14 ------------- 2 files changed, 35 deletions(-) diff --git a/packages/react/src/PageLayout/usePaneWidth.test.ts b/packages/react/src/PageLayout/usePaneWidth.test.ts index 13f8ea32782..0f9e75054aa 100644 --- a/packages/react/src/PageLayout/usePaneWidth.test.ts +++ b/packages/react/src/PageLayout/usePaneWidth.test.ts @@ -5,7 +5,6 @@ import { isCustomWidthOptions, isPaneWidth, getDefaultPaneWidth, - getPaneMaxWidthDiff, getMaxWidthDiffFromViewport, updateAriaValues, defaultPaneWidth, @@ -1142,26 +1141,6 @@ describe('helper functions', () => { }) }) - describe('getPaneMaxWidthDiff', () => { - it('should return default pane diff when element is null', () => { - expect(getPaneMaxWidthDiff(null)).toBe(DEFAULT_MAX_WIDTH_DIFF) - }) - - it('should return default sidebar diff when element is null and isSidebar is true', () => { - expect(getPaneMaxWidthDiff(null, true)).toBe(DEFAULT_SIDEBAR_MAX_WIDTH_DIFF) - }) - - it('should return default pane diff when CSS variable is not set', () => { - const element = document.createElement('div') - expect(getPaneMaxWidthDiff(element)).toBe(DEFAULT_MAX_WIDTH_DIFF) - }) - - it('should return default sidebar diff when CSS variable is not set and isSidebar is true', () => { - const element = document.createElement('div') - expect(getPaneMaxWidthDiff(element, true)).toBe(DEFAULT_SIDEBAR_MAX_WIDTH_DIFF) - }) - }) - describe('getMaxWidthDiffFromViewport', () => { it('should return default value below the breakpoint', () => { vi.stubGlobal('innerWidth', 1024) diff --git a/packages/react/src/PageLayout/usePaneWidth.ts b/packages/react/src/PageLayout/usePaneWidth.ts index fc0169e07d6..5913eaaaab8 100644 --- a/packages/react/src/PageLayout/usePaneWidth.ts +++ b/packages/react/src/PageLayout/usePaneWidth.ts @@ -115,20 +115,6 @@ export const getDefaultPaneWidth = (w: PaneWidthValue): number => { return 0 } -/** - * Gets the max-width-diff CSS variable value from a pane element. - * For sidebars, reads --sidebar-max-width-diff (constant across viewports). - * For panes, reads --pane-max-width-diff (changes at 1280px breakpoint). - * Note: This calls getComputedStyle which forces layout - cache the result when possible. - */ -export function getPaneMaxWidthDiff(paneElement: HTMLElement | null, isSidebar = false): number { - const defaultValue = isSidebar ? DEFAULT_SIDEBAR_MAX_WIDTH_DIFF : DEFAULT_MAX_WIDTH_DIFF - const cssVar = isSidebar ? '--sidebar-max-width-diff' : '--pane-max-width-diff' - if (!paneElement) return defaultValue - const value = parseInt(getComputedStyle(paneElement).getPropertyValue(cssVar), 10) - return value > 0 ? value : defaultValue -} - /** * Derives the --pane-max-width-diff value from viewport width alone. * Avoids the expensive getComputedStyle call that forces a synchronous layout recalc. From 545ca7bcb8aed2d26ca3c96d8b31161e4474b4b7 Mon Sep 17 00:00:00 2001 From: Hector Garcia Date: Fri, 13 Feb 2026 15:39:10 +0000 Subject: [PATCH 6/7] test: dispatch resize event in on-demand max calculation test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test previously stubbed innerWidth without firing a resize event, then asserted against a stale maxWidthDiffRef — a scenario that cannot occur in a real browser. Dispatch the resize event so the breakpoint crossing updates the cached diff value, making the assertion realistic. --- .../react/src/PageLayout/usePaneWidth.test.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/react/src/PageLayout/usePaneWidth.test.ts b/packages/react/src/PageLayout/usePaneWidth.test.ts index 0f9e75054aa..5ca90cb5995 100644 --- a/packages/react/src/PageLayout/usePaneWidth.test.ts +++ b/packages/react/src/PageLayout/usePaneWidth.test.ts @@ -976,7 +976,8 @@ describe('usePaneWidth', () => { }) describe('on-demand max calculation', () => { - it('should calculate max dynamically based on current viewport', () => { + it('should calculate max dynamically based on current viewport', async () => { + vi.useFakeTimers() vi.stubGlobal('innerWidth', 1280) const refs = createMockRefs() @@ -993,11 +994,18 @@ describe('usePaneWidth', () => { // Initial max at 1280px: 1280 - 959 (wide diff) = 321 expect(result.current.getMaxPaneWidth()).toBe(321) - // Viewport changes (no resize event fired, so maxWidthDiffRef stays at 959) + // Shrink viewport (crosses 1280 breakpoint, diff switches to 511) vi.stubGlobal('innerWidth', 800) + window.dispatchEvent(new Event('resize')) - // getMaxPaneWidth reads window.innerWidth dynamically: max(256, 800 - 959) = 256 - expect(result.current.getMaxPaneWidth()).toBe(256) + await act(async () => { + await vi.runAllTimersAsync() + }) + + // After resize: 800 - 511 = 289 + expect(result.current.getMaxPaneWidth()).toBe(289) + + vi.useRealTimers() }) it('should return custom max regardless of viewport for custom widths', () => { From 8fdeef72ff3f259a011c6c19887cc93480714a30 Mon Sep 17 00:00:00 2001 From: hectahertz Date: Sat, 14 Feb 2026 13:16:09 +0100 Subject: [PATCH 7/7] fix(PageLayout): add missing canUseDOM import --- packages/react/src/PageLayout/usePaneWidth.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react/src/PageLayout/usePaneWidth.ts b/packages/react/src/PageLayout/usePaneWidth.ts index 5913eaaaab8..0269e003019 100644 --- a/packages/react/src/PageLayout/usePaneWidth.ts +++ b/packages/react/src/PageLayout/usePaneWidth.ts @@ -1,4 +1,5 @@ import React, {startTransition, useMemo} from 'react' +import {canUseDOM} from '../utils/environment' import useIsomorphicLayoutEffect from '../utils/useIsomorphicLayoutEffect' import cssExports from './PageLayout.module.css'