Skip to content

perf(ThemeProvider): Reduce unnecessary renders and effect cascades#7695

Open
mattcosta7 wants to merge 3 commits intomainfrom
improve-theme-provider-perf
Open

perf(ThemeProvider): Reduce unnecessary renders and effect cascades#7695
mattcosta7 wants to merge 3 commits intomainfrom
improve-theme-provider-perf

Conversation

@mattcosta7
Copy link
Contributor

@mattcosta7 mattcosta7 commented Mar 24, 2026

Summary

Performance improvements to ThemeProvider in both @primer/react and @primer/styled-react. All changes maintain identical user-facing behavior.

Changes

1. Lazy useState for SSR server handoff

getServerHandoff() (DOM read + JSON.parse) was called during every render. Now uses a lazy useState initializer so it runs exactly once.

2. Simplified SSR hydration effect

Before: setTimeoutReactDOM.flushSync(setColorMode(resolved))setColorMode(original) — 3 renders, forced sync flush, temporarily corrupted colorMode state.

After: setServerColorMode(undefined) in a mount effect — 1 state update, no cascading, colorMode is never touched.

This also removes the ReactDOM import entirely.

3. Memoized context value

The ThemeContext.Provider value was a new object literal every render, causing all consumers to re-render even when nothing changed. Now wrapped in useMemo.

4. Guarded setSystemColorMode on mount

useSystemColorMode called setSystemColorMode unconditionally in its effect, even though useState(getSystemColorMode) already read the correct value. Now uses a functional updater so React bails out when the value has not changed.

Scenario analysis

Scenario Before After
No SSR handoff (common) Effect skips (ref falsy) Effect skips (state undefined)
SSR match ref nulled, 0 extra renders 1 ThemeProvider-only render (consumers shielded by memoized context)
SSR mismatch 3 renders via flushSync cascade 2 renders, corrects sooner (no setTimeout)
colorMode prop changes Effect re-runs every change Effect never re-runs after initial clear
React Strict Mode setTimeout from double-invoked effect may fire with stale closure Second invocation sees undefined, skips cleanly
Repeated parent renders DOM read + JSON.parse every time Lazy initializer ran once

Testing

All 49 existing ThemeProvider tests pass (20 in @primer/react, 29 in @primer/styled-react).

@mattcosta7 mattcosta7 requested a review from a team as a code owner March 24, 2026 01:51
@changeset-bot
Copy link

changeset-bot bot commented Mar 24, 2026

🦋 Changeset detected

Latest commit: 8cb69d1

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

This PR includes changesets to release 2 packages
Name Type
@primer/react Patch
@primer/styled-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

@mattcosta7 mattcosta7 self-assigned this Mar 24, 2026
@mattcosta7 mattcosta7 marked this pull request as draft March 24, 2026 01:51
@github-actions
Copy link
Contributor

⚠️ Action required

👋 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. Check the integration testing docs for step-by-step instructions. Or, apply the integration-tests: skipped manually label to skip these checks.

@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 Mar 24, 2026
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 refactors ThemeProvider implementations (both @primer/react and @primer/styled-react) to simplify SSR color-mode handoff handling by removing the ReactDOM-based flushSync workaround and by making context value construction more stable.

Changes:

  • Replace the server color-mode passthrough useRef + ReactDOM.flushSync workaround with a serverColorMode state that is cleared after hydration.
  • Memoize the ThemeContext.Provider value via useMemo to reduce unnecessary context value churn.
  • Remove the unused react-dom import from both ThemeProvider implementations.

Reviewed changes

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

File Description
packages/styled-react/src/components/ThemeProvider.tsx Aligns styled-react ThemeProvider SSR handoff logic with a state-based server passthrough and memoized context value.
packages/react/src/ThemeProvider.tsx Updates core ThemeProvider SSR handoff logic to remove ReactDOM.flushSync usage and memoizes the context value.
Comments suppressed due to low confidence (1)

packages/react/src/ThemeProvider.tsx:88

  • ThemeProvider has an existing unit test suite, but the preventSSRMismatch / server handoff path introduced here isn’t covered. Please add a test that simulates server handoff data (e.g., by mocking useId to a stable value and inserting a matching __PRIMER_DATA_<id>__ script element) and asserts that the provider initially uses the server-resolved color mode and then switches to client resolution after hydration.
  // Lazy initializer reads DOM + parses JSON once instead of every render
  const [serverColorMode, setServerColorMode] = React.useState<ColorMode | undefined>(
    () => getServerHandoff(uniqueDataId).resolvedServerColorMode,
  )

  const [colorMode, setColorMode] = useSyncedState(props.colorMode ?? fallbackColorMode ?? defaultColorMode)
  const [dayScheme, setDayScheme] = useSyncedState(props.dayScheme ?? fallbackDayScheme ?? defaultDayScheme)
  const [nightScheme, setNightScheme] = useSyncedState(props.nightScheme ?? fallbackNightScheme ?? defaultNightScheme)
  const systemColorMode = useSystemColorMode()
  const resolvedColorMode = serverColorMode ?? resolveColorMode(colorMode, systemColorMode)
  const colorScheme = chooseColorScheme(resolvedColorMode, dayScheme, nightScheme)
  const {resolvedTheme, resolvedColorScheme} = React.useMemo(
    () => applyColorScheme(theme, colorScheme),
    [theme, colorScheme],
  )

  // After hydration, clear the server passthrough so client-side color mode takes over
  React.useEffect(
    function clearServerPassthrough() {
      if (serverColorMode !== undefined) {
        setServerColorMode(undefined)
      }
    },
    [serverColorMode],
  )

@mattcosta7 mattcosta7 changed the title improve theme provider perf(ThemeProvider): Reduce unnecessary renders and effect cascades Mar 24, 2026
@github-actions github-actions bot temporarily deployed to storybook-preview-7695 March 24, 2026 02:02 Inactive
@mattcosta7 mattcosta7 marked this pull request as ready for review March 24, 2026 02:17
@primer-integration
Copy link

👋 Hi from github/github-ui! Your integration PR is ready: https://github.com/github/github-ui/pull/16765

@primer-integration
Copy link

Integration test results from github/github-ui:

Failed  CI   Failed
Passed  VRT   Passed
Passed  Projects   Passed

CI check runs linting, type checking, and unit tests. Check the workflow logs for specific failures.

Need help? If you believe this failure is unrelated to your changes, please reach out to the Primer team for assistance.

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.

2 participants