From 4209ddd8ab7639e38b3ab04ae49ba644b5cdac7c Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Tue, 24 Mar 2026 01:50:55 +0000 Subject: [PATCH 1/3] improve theme provider --- packages/react/src/ThemeProvider.tsx | 79 +++++++++---------- .../src/components/ThemeProvider.tsx | 79 +++++++++---------- 2 files changed, 76 insertions(+), 82 deletions(-) diff --git a/packages/react/src/ThemeProvider.tsx b/packages/react/src/ThemeProvider.tsx index 75a10b07b2b..3631a4395e2 100644 --- a/packages/react/src/ThemeProvider.tsx +++ b/packages/react/src/ThemeProvider.tsx @@ -1,5 +1,4 @@ import React from 'react' -import ReactDOM from 'react-dom' import defaultTheme from './theme' import deepmerge from 'deepmerge' import {useId} from './hooks' @@ -62,63 +61,61 @@ export const ThemeProvider: React.FC const theme = fallbackTheme ?? defaultTheme const uniqueDataId = useId() - const {resolvedServerColorMode} = getServerHandoff(uniqueDataId) - const resolvedColorModePassthrough = React.useRef(resolvedServerColorMode) + // Lazy initializer reads DOM + parses JSON once instead of every render + const [serverColorMode, setServerColorMode] = React.useState( + () => 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() - // eslint-disable-next-line react-hooks/refs - const resolvedColorMode = resolvedColorModePassthrough.current || resolveColorMode(colorMode, systemColorMode) + const resolvedColorMode = serverColorMode ?? resolveColorMode(colorMode, systemColorMode) const colorScheme = chooseColorScheme(resolvedColorMode, dayScheme, nightScheme) const {resolvedTheme, resolvedColorScheme} = React.useMemo( () => applyColorScheme(theme, colorScheme), [theme, colorScheme], ) - // this effect will only run on client + // After hydration, clear the server passthrough so client-side color mode takes over React.useEffect( - function updateColorModeAfterServerPassthrough() { - const resolvedColorModeOnClient = resolveColorMode(colorMode, systemColorMode) - - if (resolvedColorModePassthrough.current) { - // if the resolved color mode passed on from the server is not the resolved color mode on client, change it! - if (resolvedColorModePassthrough.current !== resolvedColorModeOnClient) { - window.setTimeout(() => { - // use ReactDOM.flushSync to prevent automatic batching of state updates since React 18 - // ref: https://github.com/reactwg/react-18/discussions/21 - ReactDOM.flushSync(() => { - // override colorMode to whatever is resolved on the client to get a re-render - setColorMode(resolvedColorModeOnClient) - }) - - // immediately after that, set the colorMode to what the user passed to respond to system color mode changes - setColorMode(colorMode) - }) - } - - resolvedColorModePassthrough.current = null + function clearServerPassthrough() { + if (serverColorMode !== undefined) { + setServerColorMode(undefined) } }, - [colorMode, systemColorMode, setColorMode], + [serverColorMode], + ) + + const contextValue = React.useMemo( + () => ({ + theme: resolvedTheme, + colorScheme, + colorMode, + resolvedColorMode, + resolvedColorScheme, + dayScheme, + nightScheme, + setColorMode, + setDayScheme, + setNightScheme, + }), + [ + resolvedTheme, + colorScheme, + colorMode, + resolvedColorMode, + resolvedColorScheme, + dayScheme, + nightScheme, + setColorMode, + setDayScheme, + setNightScheme, + ], ) return ( - +
const theme = props.theme ?? fallbackTheme ?? defaultTheme const uniqueDataId = useId() - const {resolvedServerColorMode} = getServerHandoff(uniqueDataId) - const resolvedColorModePassthrough = React.useRef(resolvedServerColorMode) + // Lazy initializer reads DOM + parses JSON once instead of every render + const [serverColorMode, setServerColorMode] = React.useState( + () => 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() - // eslint-disable-next-line react-hooks/refs - const resolvedColorMode = resolvedColorModePassthrough.current || resolveColorMode(colorMode, systemColorMode) + const resolvedColorMode = serverColorMode ?? resolveColorMode(colorMode, systemColorMode) const colorScheme = chooseColorScheme(resolvedColorMode, dayScheme, nightScheme) const {resolvedTheme, resolvedColorScheme} = React.useMemo( () => applyColorScheme(theme, colorScheme), [theme, colorScheme], ) - // this effect will only run on client + // After hydration, clear the server passthrough so client-side color mode takes over React.useEffect( - function updateColorModeAfterServerPassthrough() { - const resolvedColorModeOnClient = resolveColorMode(colorMode, systemColorMode) - - if (resolvedColorModePassthrough.current) { - // if the resolved color mode passed on from the server is not the resolved color mode on client, change it! - if (resolvedColorModePassthrough.current !== resolvedColorModeOnClient) { - window.setTimeout(() => { - // use ReactDOM.flushSync to prevent automatic batching of state updates since React 18 - // ref: https://github.com/reactwg/react-18/discussions/21 - ReactDOM.flushSync(() => { - // override colorMode to whatever is resolved on the client to get a re-render - setColorMode(resolvedColorModeOnClient) - }) - - // immediately after that, set the colorMode to what the user passed to respond to system color mode changes - setColorMode(colorMode) - }) - } - - resolvedColorModePassthrough.current = null + function clearServerPassthrough() { + if (serverColorMode !== undefined) { + setServerColorMode(undefined) } }, - [colorMode, systemColorMode, setColorMode], + [serverColorMode], + ) + + const contextValue = React.useMemo( + () => ({ + theme: resolvedTheme, + colorScheme, + colorMode, + resolvedColorMode, + resolvedColorScheme, + dayScheme, + nightScheme, + setColorMode, + setDayScheme, + setNightScheme, + }), + [ + resolvedTheme, + colorScheme, + colorMode, + resolvedColorMode, + resolvedColorScheme, + dayScheme, + nightScheme, + setColorMode, + setDayScheme, + setNightScheme, + ], ) return ( - + {children} {props.preventSSRMismatch ? ( From bc01f0d2658fa53cdc4f783ba6e1117101d0375b Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Tue, 24 Mar 2026 01:59:52 +0000 Subject: [PATCH 2/3] guard setSystemColorMode to avoid redundant state update on mount --- packages/react/src/ThemeProvider.tsx | 7 ++++--- packages/styled-react/src/components/ThemeProvider.tsx | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/react/src/ThemeProvider.tsx b/packages/react/src/ThemeProvider.tsx index 3631a4395e2..746c8f772b5 100644 --- a/packages/react/src/ThemeProvider.tsx +++ b/packages/react/src/ThemeProvider.tsx @@ -161,9 +161,10 @@ function useSystemColorMode() { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (media) { - // just in case the preference changed before the event listener was attached - const isNight = media.matches - setSystemColorMode(matchesMediaToColorMode(isNight)) + // Only update if preference changed between useState init and effect + const currentMode = matchesMediaToColorMode(media.matches) + setSystemColorMode(prev => (prev === currentMode ? prev : currentMode)) + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (media.addEventListener !== undefined) { media.addEventListener('change', handleChange) diff --git a/packages/styled-react/src/components/ThemeProvider.tsx b/packages/styled-react/src/components/ThemeProvider.tsx index cecec8075e6..8ef5bfa8ab4 100644 --- a/packages/styled-react/src/components/ThemeProvider.tsx +++ b/packages/styled-react/src/components/ThemeProvider.tsx @@ -157,9 +157,10 @@ function useSystemColorMode() { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (media) { - // just in case the preference changed before the event listener was attached - const isNight = media.matches - setSystemColorMode(matchesMediaToColorMode(isNight)) + // Only update if preference changed between useState init and effect + const currentMode = matchesMediaToColorMode(media.matches) + setSystemColorMode(prev => (prev === currentMode ? prev : currentMode)) + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (media.addEventListener !== undefined) { media.addEventListener('change', handleChange) From 8cb69d13c85938f99a6daf52938fb30fc31a8ecb Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Tue, 24 Mar 2026 02:00:38 +0000 Subject: [PATCH 3/3] add changeset --- .changeset/improve-theme-provider-perf.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .changeset/improve-theme-provider-perf.md diff --git a/.changeset/improve-theme-provider-perf.md b/.changeset/improve-theme-provider-perf.md new file mode 100644 index 00000000000..bcee0ddb628 --- /dev/null +++ b/.changeset/improve-theme-provider-perf.md @@ -0,0 +1,11 @@ +--- +"@primer/react": patch +"@primer/styled-react": patch +--- + +perf(ThemeProvider): Reduce unnecessary renders and effect cascades + +- Replace per-render DOM read + JSON.parse for SSR handoff with a lazy `useState` initializer (runs once) +- Replace complex SSR hydration effect (`setTimeout` → `flushSync` → two cascading `setColorMode` calls) with a single `setServerColorMode(undefined)` on mount +- Memoize context value object to prevent unnecessary re-renders of all consumers +- Guard `setSystemColorMode` in `useSystemColorMode` to avoid redundant state update on mount