-
Notifications
You must be signed in to change notification settings - Fork 526
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
ThemeProvider: Fixes mismatch in rendered output for theming with server side rendering #1868
Conversation
🦋 Changeset detectedLatest commit: 1a30ee2 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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 |
size-limit report 📦
|
src/ThemeProvider.tsx
Outdated
@@ -17,6 +17,7 @@ export type ThemeProviderProps = { | |||
colorMode?: ColorModeWithAuto | |||
dayScheme?: string | |||
nightScheme?: string | |||
preventServerClientMismatch?: boolean |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Start here, Step: 1/5. Add opt-in feature to prevent output mismatch between server and client. If it's a client only application, they don't need to bother.
{children} | ||
{props.preventServerClientMismatch ? ( | ||
<script dangerouslySetInnerHTML={{__html: `__PRIMER_RESOLVED_SERVER_COLOR_MODE='${resolvedColorMode}'`}} /> | ||
) : null} |
There was a problem hiding this comment.
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.
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) |
There was a problem hiding this comment.
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.
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 | ||
) |
There was a problem hiding this comment.
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.
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(() => { | ||
// 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 | ||
} | ||
}, | ||
[colorMode, systemColorMode] | ||
) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a great short-term fix! Thanks for the thorough PR description and comments. They were really helpful ❤️
Let's update the ThemeProvider docs in this PR too |
@colebemis Added docs, also renamed the prop to a nicer |
Great explanation of the issue! I've seen similar fixes for rehydration issues where we pass data btw server and client through the html. |
Yep, making it opt-in instead of enabling it by default for the imperfect short term solution. |
// 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) |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
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(() => { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
Agree with the others. Great PR @siddharthkp.. really appreciated the detailed descriptions too 🙇 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚢
Co-authored-by: Cole Bemis <colebemis@github.com>
@siddharthkp great job on this! I really appreciate the fix 🙏 |
There's a lot going on here visually, which is hard to explain in text, so here's a Loom (12mins)
Bug
When using
colorMode=auto
withThemeProvider
,colorMode
resolves differently on the server and client:On the client, we have access to the system preferences, so "auto" resolves to the OS settings. On the server, however, we don't have any of that so it always resolves to
day
. So if you have dark mode on your OS (or it's after sunset on automatic) you get differentresolvedColorMode
leading to the infamous warning of mismatch between server and client.In development environment, this would simply break the page. On production, the error would be suppressed and the page would be stuck on what the server provided (always day/light mode).
Before:
color-mode-auto-before.mov
Short term fix (this pull request):
The short term fix for this to:
resolvedColorMode
from the server to the client and use that as the initial color mode. diffuseEffect
on the client to see if theresolvedColorMode
on the client should be different than what we initially set and fix it. diffI have made this feature opt in instead of the default so that we don't inject anything into the DOM by default, would like some feedback if that makes sense and what the prop should be called:
After:
color-mode-auto-after.mov
Long term fix (not in this PR):
The core of the problem is that we are not able to respond the client's resolved color mode instantly because we need to wait for the javascript bundle to download and generate new styles on runtime.
The fix for that would be to move towards css variables in components which can respond to changes in theme instantly without waiting for javascript.
To trigger that change, we would need to ship a tiny chunk of javascript embedded in the initial server rendered html that updates the color mode the moment it reaches the browser.