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 diff --git a/packages/react/src/ThemeProvider.tsx b/packages/react/src/ThemeProvider.tsx index 75a10b07b2b..746c8f772b5 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 ( - +
(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 ef42f0e8897..8ef5bfa8ab4 100644 --- a/packages/styled-react/src/components/ThemeProvider.tsx +++ b/packages/styled-react/src/components/ThemeProvider.tsx @@ -1,5 +1,4 @@ import React from 'react' -import ReactDOM from 'react-dom' import {ThemeProvider as SCThemeProvider} from 'styled-components' import {theme as defaultTheme, useId, useSyncedState} from '@primer/react' import deepmerge from 'deepmerge' @@ -62,63 +61,61 @@ export const ThemeProvider: React.FC 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 ? ( @@ -160,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)