Skip to content

ThemeProvider: Fixes mismatch in rendered output for theming with server side rendering #1868

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Feb 23, 2022
Merged
5 changes: 5 additions & 0 deletions .changeset/themeprovider-ssr-auto.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': patch
---

Fixes a bug for theming with server side rendering where the output of the server and client mismatch [#1773](https://github.com/primer/react/issues/1773)
10 changes: 10 additions & 0 deletions docs/content/theming.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,16 @@ function Example() {
}
```

#### `preventSSRMismatch` prop

If you are doing server-side rendering, pass the `preventSSRMismatch` prop to ensure the rendered output from the server and browser match even when they resolve "auto" color mode differently.

```jsx
<ThemeProvider colorMode="auto" preventSSRMismatch>
...
</ThemeProvider>
```

### Setting color schemes

To choose which color schemes will be displayed in `day` and `night` mode, use the `dayScheme` and `nightScheme` props on `ThemeProvider` or the `setDayScheme` and `setNightScheme` functions from the `useTheme` hook:
Expand Down
41 changes: 38 additions & 3 deletions src/ThemeProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export type ThemeProviderProps = {
colorMode?: ColorModeWithAuto
dayScheme?: string
nightScheme?: string
preventSSRMismatch?: boolean
}

const ThemeContext = React.createContext<{
Expand Down Expand Up @@ -47,21 +48,50 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({children, ...props}

// Initialize state
const theme = props.theme ?? fallbackTheme ?? defaultTheme

const resolvedColorModePassthrough = React.useRef(
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore This custom variable does not exist on window because we set it outselves
typeof window !== 'undefined' ? window.__PRIMER_RESOLVED_SERVER_COLOR_MODE : undefined
)
Comment on lines +52 to +56
Copy link
Member Author

@siddharthkp siddharthkp Feb 17, 2022

Choose a reason for hiding this comment

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

Step 3/5: On the client side, check if there was a variable set by the server, pick it up and set it in a ref. We will use this later.

next step 4/5 →


const [colorMode, setColorMode] = React.useState(props.colorMode ?? fallbackColorMode ?? defaultColorMode)
const [dayScheme, setDayScheme] = React.useState(props.dayScheme ?? fallbackDayScheme ?? defaultDayScheme)
const [nightScheme, setNightScheme] = React.useState(props.nightScheme ?? fallbackNightScheme ?? defaultNightScheme)
const systemColorMode = useSystemColorMode()
const resolvedColorMode = resolveColorMode(colorMode, systemColorMode)
const resolvedColorMode = resolvedColorModePassthrough.current || resolveColorMode(colorMode, systemColorMode)
Copy link
Member Author

@siddharthkp siddharthkp Feb 17, 2022

Choose a reason for hiding this comment

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

Step 4/5: For rehydration on the client, if there was a resolvedColorModePassthrough passed on from the server, just use that instead of resolving color mode on the client. This will ensure consistent output between client and server.

last step 5/5 →

const colorScheme = chooseColorScheme(resolvedColorMode, dayScheme, nightScheme)
const {resolvedTheme, resolvedColorScheme} = React.useMemo(
() => applyColorScheme(theme, colorScheme),
[theme, colorScheme]
)

// this effect will only run on client
React.useEffect(
function updateColorModeAfterServerPassthorugh() {
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(() => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Do you need to clear this timeout?

Copy link
Member Author

Choose a reason for hiding this comment

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

Don't think so :) This is only called once, ever.

// 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)
Copy link
Contributor

Choose a reason for hiding this comment

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

Would this stop working when we eventually upgrade to React 18 with its batched updates?

Copy link
Contributor

Choose a reason for hiding this comment

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

Also just curious, this feels like a potential race condition. Rather than relying on the same effect to trigger the second rerender, could this have its own?

Copy link
Member Author

@siddharthkp siddharthkp Feb 22, 2022

Choose a reason for hiding this comment

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

I honestly don't know, something to watch out for when the time comes 🤔

The fix would be to use flushSync then. See "What if I don’t want to batch?" under reactwg/react-18#21

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah, I suspect we (and everyone else) won't have a choice to begin with. We've been working around it for years now. In longer term, I imagine we'd want to enable it though.

})
}

resolvedColorModePassthrough.current = null
}
},
[colorMode, systemColorMode]
)
Comment on lines +70 to +89
Copy link
Member Author

@siddharthkp siddharthkp Feb 17, 2022

Choose a reason for hiding this comment

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

Step 5/5: After mount - If the resolvedColorMode passed from the server and the resolvedColorMode resolved on the client do not match, update the color mode to respect the client's settings (example: If the color mode is set to "auto" and it resolved to "night" on the client side but was sent as "day" from the server)

Note: After 'fixing" the color mode, we switch over to "client mode". To do this, we unset the ref set in step 3 and reset the colorMode to what the application has set, so that the UI can respond to changes in OS settings immediately.


// Update state if props change
React.useEffect(() => {
setColorMode(props.colorMode ?? fallbackColorMode ?? defaultColorMode)
}, [props.colorMode, fallbackColorMode])
}, [props.colorMode, resolvedColorMode, fallbackColorMode])

React.useEffect(() => {
setDayScheme(props.dayScheme ?? fallbackDayScheme ?? defaultDayScheme)
Expand All @@ -86,7 +116,12 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({children, ...props}
setNightScheme
}}
>
<SCThemeProvider theme={resolvedTheme}>{children}</SCThemeProvider>
<SCThemeProvider theme={resolvedTheme}>
{children}
{props.preventSSRMismatch ? (
<script dangerouslySetInnerHTML={{__html: `__PRIMER_RESOLVED_SERVER_COLOR_MODE='${resolvedColorMode}'`}} />
) : null}
Copy link
Member Author

@siddharthkp siddharthkp Feb 17, 2022

Choose a reason for hiding this comment

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

Step 2/5: If the prop is present, inject a tiny script into server-rendered html to set a variable for the client side ThemeProvider to read after rehydration.

next step 3/5 ↑

</SCThemeProvider>
</ThemeContext.Provider>
)
}
Expand Down