Skip to content

Conversation

@hectahertz
Copy link
Contributor

@hectahertz hectahertz commented Feb 12, 2026

Overview

Across all three profiled routes under the Files controller on .com, SplitPageLayout's internal usePaneWidth hook is the single largest source of client-side latency. The root cause is getPaneMaxWidthDiff, which calls getComputedStyle() inside a useLayoutEffect on mount, forcing the browser to synchronously resolve all pending layout work before it can return a CSS variable value.

Impact on github.com (production profiling)

Route getPaneMaxWidthDiff Total forced reflow Primer share
Code View (241-line file, SelectPanel.tsx) 614–621ms 745–758ms 82%
Code View (smaller file, File.md) 124ms 370ms 97% (360ms combined Primer)
Repo Overview 553ms 100% (all Primer internals)

On the Code View page, getPaneMaxWidthDiff alone forces the browser to synchronously lay out 430 pending DOM nodes in a single 529ms layout update. On the smaller Code View page, it contributes 124ms with an additional 236ms from other Primer React internals, bringing Primer's combined share to 360ms of 370ms (97%). On the Repo Overview page, Primer React similarly dominates with 379ms of forced reflow — 100% of the reflow budget.

Root cause

getPaneMaxWidthDiff calls getComputedStyle(paneElement).getPropertyValue('--pane-max-width-diff') inside a useLayoutEffect on mount. This forces a synchronous layout recalculation because:

  1. It runs in the commit phase (useLayoutEffect), after React has flushed all DOM mutations but before paint
  2. The browser must resolve every pending style/layout change across the entire document to answer the getComputedStyle query
  3. SplitPageLayout is a top-level layout wrapper, so the dirty subtree is typically the entire page

The fix

The CSS variable --pane-max-width-diff only has two possible values, controlled by a single @media (min-width: 1280px) breakpoint:

  • Below 1280px: 511px
  • At/above 1280px: 959px

This is fully determinable from window.innerWidth — no getComputedStyle needed. This PR replaces the DOM query with a pure JS viewport width check:

// Before (forces synchronous layout — 614ms on large pages)
maxWidthDiffRef.current = getPaneMaxWidthDiff(paneRef.current)

// After (property read + comparison — ~0ms)  
maxWidthDiffRef.current = getMaxWidthDiffFromViewport()

The same replacement is applied to the resize handler's breakpoint-crossing path for consistency (previously guarded but still called getComputedStyle when crossing the 1280px threshold).

Chrome DevTools Performance Trace Results

Profiled using Chrome DevTools traces on the Heavy Content performance story (~5,400 DOM nodes, 1,366 flex containers — representative of a real github.com page).

PageLayout-caused Forced Reflow

Scenario Before (main) After (this PR) Improvement
1x CPU 262 ms (getPaneMaxWidthDiff) 0 ms -262 ms (eliminated)
4x CPU throttle 1,136 ms (getPaneMaxWidthDiff) 0 ms -1,136 ms (eliminated)

Remaining forced reflows after the fix (128ms @ 1x, 566ms @ 4x) are entirely from Storybook's preview runtime — not PageLayout code.

Style Recalculation

Scenario Before After Delta
1x CPU 228 ms (5,456 elements) 110 ms (5,467 elements) -118 ms (-52%)
4x CPU throttle 984 ms (5,456 elements) 480 ms (5,467 elements) -504 ms (-51%)

LCP (4x CPU throttle)

Metric Before After Delta
LCP 5,784 ms 4,602 ms -1,182 ms (-20%)
Render delay 5,409 ms 4,251 ms -1,158 ms

At 1x CPU, LCP is ~3,600ms on both branches (dominated by Storybook bundle eval, not PageLayout).

Drag Performance (unchanged, as expected)

Metric Before After
Forced reflows during drag 0 0
CLS during drag 0.024 0.020

Other Metrics

Metric Before After
CLS on page load 0.00 0.00
DOM nodes 5,541 5,541

Changelog

New

  • getMaxWidthDiffFromViewport() — derives the --pane-max-width-diff value from window.innerWidth without touching the DOM

Changed

  • Mount-time and resize-breakpoint-crossing initialization in usePaneWidth now uses the viewport-based lookup instead of getComputedStyle
  • Exported the wide-breakpoint diff value (959) from PageLayout.module.css via :export to keep JS and CSS in sync

Removed

  • All runtime getComputedStyle calls for --pane-max-width-diff (the function is retained for potential future use but is no longer called)

Rollout strategy

  • Patch release

Testing & Reviewing

  • All 46 existing usePaneWidth tests pass, with expectations corrected to reflect real-world breakpoint behavior at viewport ≥1280px
  • 3 new tests added for getMaxWidthDiffFromViewport covering below, at, and above the 1280px breakpoint
  • The old tests were inadvertently wrong: jsdom cannot evaluate CSS media queries, so getComputedStyle always returned the default (511) even at 1280px viewport. The new viewport-based approach correctly returns 959 at that width, and the test expectations have been updated accordingly

Merge checklist

  • Added/updated tests
  • Added/updated documentation
  • Added/updated previews (Storybook)
  • Changes are SSR compatible
  • Tested in Chrome
  • Tested in Firefox
  • Tested in Safari
  • Tested in Edge
  • (GitHub staff only) Integration tests pass at github/github

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).
@hectahertz hectahertz requested a review from a team as a code owner February 12, 2026 14:02
@changeset-bot
Copy link

changeset-bot bot commented Feb 12, 2026

🦋 Changeset detected

Latest commit: 76ed144

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@primer/react Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions github-actions bot added the integration-tests: recommended This change needs to be tested for breaking changes. See https://arc.net/l/quote/tdmpakpm label Feb 12, 2026
@github-actions
Copy link
Contributor

👋 Hi, this pull request contains changes to the source code that github/github-ui depends on. If you are GitHub staff, test these changes with github/github-ui using the integration workflow. Or, apply the integration-tests: skipped manually label to skip these checks.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR removes a major mount-time performance bottleneck in PageLayout by eliminating a getComputedStyle() call that can force expensive synchronous layout recalculation. It replaces the DOM/CSS variable read with a viewport-width-based lookup derived from the same breakpoint logic already encoded in PageLayout.module.css.

Changes:

  • Add getMaxWidthDiffFromViewport() and use it on mount and when crossing the 1280px breakpoint, avoiding getComputedStyle() forced reflow.
  • Export the wide breakpoint --pane-max-width-diff value (959) from PageLayout.module.css via :export to keep JS/CSS in sync.
  • Update and extend unit tests to reflect correct wide-breakpoint behavior and cover the new helper.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
packages/react/src/PageLayout/usePaneWidth.ts Replaces mount and resize-breakpoint diff calculation with a viewport-only lookup helper.
packages/react/src/PageLayout/usePaneWidth.test.ts Updates expectations for wide breakpoint behavior and adds new helper unit tests.
packages/react/src/PageLayout/PageLayout.module.css Exposes the wide diff value (paneMaxWidthDiffWide) via :export for JS consumption.
.changeset/pagelayout-remove-reflow.md Adds a patch changeset describing the performance fix.

…romViewport 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.
…nments

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.
@github-actions github-actions bot requested a deployment to storybook-preview-7532 February 12, 2026 16:20 Abandoned
Copy link
Member

@francinelucca francinelucca left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looking good! some questions/suggestions

Comment on lines +433 to +436
// 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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Making sure I understand: this value changed because it's correct from the get-go now?

Comment on lines 101 to 105
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
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are we able to delete this function then? 👀

--pane-width-medium: 100%;
--pane-width-large: 100%;
/* NOTE: This value is exported via :export for use in usePaneWidth.ts */
--pane-max-width-diff: 511px;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we don't need this variable anymore then 👀

Comment on lines +715 to +719
// 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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could we dispatch the resized event here instead so the value updates? otherwise I'm not sure what this part of the test is achieving 😅

@hectahertz hectahertz changed the title perf(PageLayout): eliminate 614ms forced reflow from getComputedStyle on mount perf(PageLayout): eliminate ~614ms forced reflow from getComputedStyle on mount Feb 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

integration-tests: recommended This change needs to be tested for breaking changes. See https://arc.net/l/quote/tdmpakpm

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants