From 8a21fb287c821f5a89cfef761c5b712befe54c46 Mon Sep 17 00:00:00 2001 From: Hector Garcia Date: Thu, 12 Feb 2026 13:50:28 +0000 Subject: [PATCH 1/6] 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 5466fce1d67..69aed8353dc 100644 --- a/packages/react/src/PageLayout/PageLayout.module.css +++ b/packages/react/src/PageLayout/PageLayout.module.css @@ -4,6 +4,8 @@ paneMaxWidthDiffBreakpoint: 1280; /* Default value for --pane-max-width-diff below the breakpoint */ paneMaxWidthDiffDefault: 511; + /* 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 570fb6c47ed..5253f74046e 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, @@ -272,7 +273,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({ @@ -284,8 +285,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', () => { @@ -429,10 +430,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) @@ -465,10 +466,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 @@ -553,10 +554,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')) @@ -708,14 +709,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', () => { @@ -789,6 +790,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 4244e94d1e9..994bbab1d7e 100644 --- a/packages/react/src/PageLayout/usePaneWidth.ts +++ b/packages/react/src/PageLayout/usePaneWidth.ts @@ -52,6 +52,9 @@ export type UsePaneWidthResult = { */ export const DEFAULT_MAX_WIDTH_DIFF = Number(cssExports.paneMaxWidthDiffDefault) +// 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) /** @@ -101,6 +104,15 @@ export function getPaneMaxWidthDiff(paneElement: HTMLElement | null): number { return value > 0 ? value : DEFAULT_MAX_WIDTH_DIFF } +/** + * 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 = ( @@ -220,7 +232,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) || @@ -229,7 +241,7 @@ export function usePaneWidth({ lastViewportWidth = currentViewportWidth if (crossedBreakpoint) { - maxWidthDiffRef.current = getPaneMaxWidthDiff(paneRef.current) + maxWidthDiffRef.current = getMaxWidthDiffFromViewport() } const actualMax = getMaxPaneWidthRef.current() @@ -256,8 +268,10 @@ export function usePaneWidth({ }) } - // Initial calculation on mount - maxWidthDiffRef.current = getPaneMaxWidthDiff(paneRef.current) + // 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 4cefcedc95f626a3784938cc27801917b712a1f9 Mon Sep 17 00:00:00 2001 From: Hector Garcia Date: Thu, 12 Feb 2026 13:50:50 +0000 Subject: [PATCH 2/6] 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 408133a9effd7dfa26bd4531aa6f77d660fe769f Mon Sep 17 00:00:00 2001 From: Hector Garcia Date: Thu, 12 Feb 2026 16:13:00 +0000 Subject: [PATCH 3/6] 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 5253f74046e..ca3769b6674 100644 --- a/packages/react/src/PageLayout/usePaneWidth.test.ts +++ b/packages/react/src/PageLayout/usePaneWidth.test.ts @@ -792,17 +792,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 76ed1440a3d2bb3af2110e68b1326b118021dc87 Mon Sep 17 00:00:00 2001 From: Hector Garcia Date: Thu, 12 Feb 2026 16:13:07 +0000 Subject: [PATCH 4/6] 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 994bbab1d7e..2b7e4fda024 100644 --- a/packages/react/src/PageLayout/usePaneWidth.ts +++ b/packages/react/src/PageLayout/usePaneWidth.ts @@ -110,6 +110,7 @@ export function getPaneMaxWidthDiff(paneElement: HTMLElement | null): number { * 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 492997a5bb213521174e2e32705154c018ec9767 Mon Sep 17 00:00:00 2001 From: Hector Garcia Date: Fri, 13 Feb 2026 15:38:31 +0000 Subject: [PATCH 5/6] 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. --- packages/react/src/PageLayout/usePaneWidth.test.ts | 12 ------------ packages/react/src/PageLayout/usePaneWidth.ts | 11 ----------- 2 files changed, 23 deletions(-) diff --git a/packages/react/src/PageLayout/usePaneWidth.test.ts b/packages/react/src/PageLayout/usePaneWidth.test.ts index ca3769b6674..cf19ccd47d6 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, @@ -779,17 +778,6 @@ describe('helper functions', () => { }) }) - describe('getPaneMaxWidthDiff', () => { - it('should return default when element is null', () => { - expect(getPaneMaxWidthDiff(null)).toBe(DEFAULT_MAX_WIDTH_DIFF) - }) - - it('should return default when CSS variable is not set', () => { - const element = document.createElement('div') - expect(getPaneMaxWidthDiff(element)).toBe(DEFAULT_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 2b7e4fda024..1fc7616729c 100644 --- a/packages/react/src/PageLayout/usePaneWidth.ts +++ b/packages/react/src/PageLayout/usePaneWidth.ts @@ -93,17 +93,6 @@ export const getDefaultPaneWidth = (w: PaneWidth | CustomWidthOptions): number = return 0 } -/** - * 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. - * Note: This calls getComputedStyle which forces layout - cache the result when possible. - */ -export function getPaneMaxWidthDiff(paneElement: HTMLElement | null): number { - if (!paneElement) return DEFAULT_MAX_WIDTH_DIFF - const value = parseInt(getComputedStyle(paneElement).getPropertyValue('--pane-max-width-diff'), 10) - return value > 0 ? value : DEFAULT_MAX_WIDTH_DIFF -} - /** * Derives the --pane-max-width-diff value from viewport width alone. * Avoids the expensive getComputedStyle call that forces a synchronous layout recalc. From 629af1c35b776edb637e92ce13323f7df0d8ec82 Mon Sep 17 00:00:00 2001 From: Hector Garcia Date: Fri, 13 Feb 2026 15:39:10 +0000 Subject: [PATCH 6/6] 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 cf19ccd47d6..684ad159c89 100644 --- a/packages/react/src/PageLayout/usePaneWidth.test.ts +++ b/packages/react/src/PageLayout/usePaneWidth.test.ts @@ -694,7 +694,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() @@ -711,11 +712,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', () => {