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

[system] Add ThemeProvider noSsr to prevent double rendering #44451

Merged
merged 11 commits into from
Nov 26, 2024
15 changes: 15 additions & 0 deletions docs/data/material/customization/dark-mode/dark-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,21 @@
</ThemeProvider>
```

## Disable double rendering

By default, the `ThemeProvider` rerenders when the theme contains light **and** dark color schemes to prevent SSR hydration mismatches.

To disable this behavior, use the `noSsr` prop:

```jsx
<ThemeProvider theme={theme} noSsr>
```

`noSsr` is useful if you are building:

- A client-only application, such as a single-page application (SPA). This prop will optimize the performance and prevent the dark mode flickering when users refresh the page.

Check warning on line 147 in docs/data/material/customization/dark-mode/dark-mode.md

View workflow job for this annotation

GitHub Actions / runner / vale

[vale] reported by reviewdog 🐶 [Google.Will] Avoid using 'will'. Raw Output: {"message": "[Google.Will] Avoid using 'will'.", "location": {"path": "docs/data/material/customization/dark-mode/dark-mode.md", "range": {"start": {"line": 147, "column": 81}}}, "severity": "WARNING"}
- A server-rendered application with [Suspense](https://react.dev/reference/react/Suspense). However, you must ensure that the server render output matches the initial render output on the client.

## Setting the default mode

When `colorSchemes` is provided, the default mode is `system`, which means the app uses the system preference when users first visit the site.
Expand Down
6 changes: 6 additions & 0 deletions packages/mui-material/src/styles/ThemeProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ export interface ThemeProviderProps<Theme = DefaultTheme> extends ThemeProviderC
* @default 'mui-color-scheme'
*/
colorSchemeStorageKey?: string;
/*
* If `true`, ThemeProvider will not rerender and the initial value of `mode` comes from the local storage.
* For SSR applications, you must ensure that the server render output must match the initial render output on the client.
Comment on lines +61 to +62
Copy link
Member

@oliviertassinari oliviertassinari Dec 4, 2024

Choose a reason for hiding this comment

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

Should we rewrite this prop description?

* @default false
*/
noSsr?: boolean;
/**
* Disable CSS transitions when switching between modes or color schemes
* @default false
Expand Down
7 changes: 7 additions & 0 deletions packages/mui-system/src/cssVars/createCssVarsProvider.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export default function createCssVarsProvider(options) {
disableNestedContext = false,
disableStyleSheetGeneration = false,
defaultMode: initialMode = 'system',
noSsr,
} = props;
const hasMounted = React.useRef(false);
const upperTheme = muiUseTheme();
Expand Down Expand Up @@ -114,6 +115,7 @@ export default function createCssVarsProvider(options) {
colorSchemeStorageKey,
defaultMode,
storageWindow,
noSsr,
});

let mode = stateMode;
Expand Down Expand Up @@ -342,6 +344,11 @@ export default function createCssVarsProvider(options) {
* The key in the local storage used to store current color scheme.
*/
modeStorageKey: PropTypes.string,
/**
* If `true`, the mode will be the same value as the storage without an extra rerendering after the hydration.
* You should use this option in conjuction with `InitColorSchemeScript` component.
*/
noSsr: PropTypes.bool,
/**
* The window that attaches the 'storage' event listener.
* @default window
Expand Down
44 changes: 44 additions & 0 deletions packages/mui-system/src/cssVars/useCurrentColorScheme.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,50 @@ describe('useCurrentColorScheme', () => {
expect(container.firstChild.textContent).to.equal('dark:0');
});

it('trigger a re-render for a multi color schemes', () => {
function Data() {
const { mode } = useCurrentColorScheme({
supportedColorSchemes: ['light', 'dark'],
defaultLightColorScheme: 'light',
defaultDarkColorScheme: 'dark',
});
const count = React.useRef(0);
React.useEffect(() => {
count.current += 1;
});
return (
<div>
{mode}:{count.current}
</div>
);
}
const { container } = render(<Data />);

expect(container.firstChild.textContent).to.equal('light:2'); // 2 because of double render within strict mode
});

it('[noSsr] does not trigger a re-render', () => {
function Data() {
const { mode } = useCurrentColorScheme({
defaultMode: 'dark',
supportedColorSchemes: ['light', 'dark'],
noSsr: true,
});
const count = React.useRef(0);
React.useEffect(() => {
count.current += 1;
});
return (
<div>
{mode}:{count.current}
</div>
);
}
const { container } = render(<Data />);

expect(container.firstChild.textContent).to.equal('dark:0');
});

describe('getColorScheme', () => {
it('use lightColorScheme given mode=light', () => {
expect(getColorScheme({ mode: 'light', lightColorScheme: 'light' })).to.equal('light');
Expand Down
19 changes: 8 additions & 11 deletions packages/mui-system/src/cssVars/useCurrentColorScheme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ interface UseCurrentColoSchemeOptions<SupportedColorScheme extends string> {
modeStorageKey?: string;
colorSchemeStorageKey?: string;
storageWindow?: Window | null;
noSsr?: boolean;
}

export default function useCurrentColorScheme<SupportedColorScheme extends string>(
Expand All @@ -133,6 +134,7 @@ export default function useCurrentColorScheme<SupportedColorScheme extends strin
modeStorageKey = DEFAULT_MODE_STORAGE_KEY,
colorSchemeStorageKey = DEFAULT_COLOR_SCHEME_STORAGE_KEY,
storageWindow = typeof window === 'undefined' ? undefined : window,
noSsr = false,
} = options;

const joinedColorSchemes = supportedColorSchemes.join(',');
Expand All @@ -155,15 +157,10 @@ export default function useCurrentColorScheme<SupportedColorScheme extends strin
darkColorScheme,
} as State<SupportedColorScheme>;
});
// This could be improved with `React.useSyncExternalStore` in the future.
const [, setHasMounted] = React.useState(false);
const hasMounted = React.useRef(false);
const [isClient, setIsClient] = React.useState(noSsr || !isMultiSchemes);
React.useEffect(() => {
if (isMultiSchemes) {
setHasMounted(true); // to rerender the component after hydration
}
hasMounted.current = true;
}, [isMultiSchemes]);
setIsClient(true); // to rerender the component after hydration
}, []);

const colorScheme = getColorScheme(state);

Expand Down Expand Up @@ -350,9 +347,9 @@ export default function useCurrentColorScheme<SupportedColorScheme extends strin

return {
...state,
mode: hasMounted.current || !isMultiSchemes ? state.mode : undefined,
systemMode: hasMounted.current || !isMultiSchemes ? state.systemMode : undefined,
colorScheme: hasMounted.current || !isMultiSchemes ? colorScheme : undefined,
mode: isClient ? state.mode : undefined,
systemMode: isClient ? state.systemMode : undefined,
colorScheme: isClient ? colorScheme : undefined,
setMode,
setColorScheme,
};
Expand Down
Loading