Skip to content
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

Merged
merged 10 commits into from
Feb 23, 2022

Conversation

siddharthkp
Copy link
Member

@siddharthkp siddharthkp commented Feb 17, 2022

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 with ThemeProvider,

<ThemeProvider colorMode="auto">
  <Flash variant="warning">Warning Flash</Flash>
</ThemeProvider>

colorMode resolves differently on the server and client:

function getSystemColorMode() {
  if (typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)')?.matches) {
    return 'night'
  } else {
    return 'day'
  }
}

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 different resolvedColorMode 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:

  1. Remove the mismatch of output between client and server: Pass the resolvedColorMode from the server to the client and use that as the initial color mode. diff
  2. Apply the correct theme after resolving color mode on client: Run an useEffect on the client to see if the resolvedColorMode on the client should be different than what we initially set and fix it. diff

I 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:

<ThemeProvider colorMode="auto" preventSSRMismatch>
  <Flash variant="warning">Warning Flash</Flash>
</ThemeProvider>

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.

 

@changeset-bot
Copy link

changeset-bot bot commented Feb 17, 2022

🦋 Changeset detected

Latest commit: 1a30ee2

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@primer/react Patch

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

@github-actions
Copy link
Contributor

github-actions bot commented Feb 17, 2022

size-limit report 📦

Path Size
dist/browser.esm.js 62.78 KB (+0.25% 🔺)
dist/browser.umd.js 63.17 KB (+0.26% 🔺)

@@ -17,6 +17,7 @@ export type ThemeProviderProps = {
colorMode?: ColorModeWithAuto
dayScheme?: string
nightScheme?: string
preventServerClientMismatch?: boolean
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.

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.

next step 2/5 →

{children}
{props.preventServerClientMismatch ? (
<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 ↑

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 →

Comment on lines +52 to +56
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
)
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 →

Comment on lines +70 to +89
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]
)
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.

@siddharthkp siddharthkp marked this pull request as ready for review February 17, 2022 11:32
@siddharthkp siddharthkp requested review from a team, pksjce and colebemis and removed request for pksjce February 17, 2022 11:32
@siddharthkp siddharthkp self-assigned this Feb 17, 2022
@siddharthkp siddharthkp added bug Something isn't working react labels Feb 17, 2022
@siddharthkp siddharthkp modified the milestone: FY22 - Q3 Feb 17, 2022
@siddharthkp siddharthkp changed the title Improve ssr theming Fixes mismatch for theming with server side rendering Feb 17, 2022
@siddharthkp siddharthkp changed the title Fixes mismatch for theming with server side rendering Fixes mismatch in rendered output for theming with server side rendering Feb 17, 2022
Copy link
Contributor

@colebemis colebemis left a 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 ❤️

@colebemis
Copy link
Contributor

Let's update the ThemeProvider docs in this PR too

@siddharthkp
Copy link
Member Author

@colebemis Added docs, also renamed the prop to a nicer preventSSRMismatch

@pksjce
Copy link
Collaborator

pksjce commented Feb 22, 2022

Great explanation of the issue! I've seen similar fixes for rehydration issues where we pass data btw server and client through the html.
One thing is I don't understand the need for a boolean. I guess you want to use it as a feature flag?
Either ways great job! ✅

@siddharthkp
Copy link
Member Author

One thing is I don't understand the need for a boolean. I guess you want to use it as a feature flag?

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)
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.

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.

@rezrah
Copy link
Contributor

rezrah commented Feb 22, 2022

Agree with the others. Great PR @siddharthkp.. really appreciated the detailed descriptions too 🙇

Copy link
Contributor

@colebemis colebemis left a comment

Choose a reason for hiding this comment

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

🚢

docs/content/theming.md Outdated Show resolved Hide resolved
siddharthkp and others added 2 commits February 23, 2022 12:46
Co-authored-by: Cole Bemis <colebemis@github.com>
@mikesurowiec
Copy link

@siddharthkp great job on this! I really appreciate the fix 🙏

@siddharthkp siddharthkp changed the title Fixes mismatch in rendered output for theming with server side rendering ThemeProvider: Fixes mismatch in rendered output for theming with server side rendering Jun 3, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working react
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Flash component renders differently in server vs client
5 participants