Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/improve-theme-provider-perf.md
Original file line number Diff line number Diff line change
@@ -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
86 changes: 42 additions & 44 deletions packages/react/src/ThemeProvider.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -62,63 +61,61 @@ export const ThemeProvider: React.FC<React.PropsWithChildren<ThemeProviderProps>
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<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()
// 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 (
<ThemeContext.Provider
value={{
theme: resolvedTheme,
colorScheme,
colorMode,
resolvedColorMode,
resolvedColorScheme,
dayScheme,
nightScheme,
setColorMode,
setDayScheme,
setNightScheme,
}}
>
<ThemeContext.Provider value={contextValue}>
<div
data-color-mode={colorMode === 'auto' ? 'auto' : colorScheme.includes('dark') ? 'dark' : 'light'}
data-light-theme={dayScheme}
Expand Down Expand Up @@ -164,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)
Expand Down
86 changes: 42 additions & 44 deletions packages/styled-react/src/components/ThemeProvider.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -62,63 +61,61 @@ export const ThemeProvider: React.FC<React.PropsWithChildren<ThemeProviderProps>
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<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()
// 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 (
<ThemeContext.Provider
value={{
theme: resolvedTheme,
colorScheme,
colorMode,
resolvedColorMode,
resolvedColorScheme,
dayScheme,
nightScheme,
setColorMode,
setDayScheme,
setNightScheme,
}}
>
<ThemeContext.Provider value={contextValue}>
<SCThemeProvider theme={resolvedTheme}>
{children}
{props.preventSSRMismatch ? (
Expand Down Expand Up @@ -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)
Expand Down
Loading