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] Disable theme recalculation as default behavior #45405

Merged
merged 17 commits into from
Mar 11, 2025
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -307,3 +307,13 @@ To disable CSS transitions when switching between modes, apply the `disableTrans
```

{{"demo": "DisableTransitionOnChange.js"}}

## Force theme recalculation between modes

By default, the `ThemeProvider` does not re-render when switching between light and dark modes when `cssVariables: true` is set in the theme.

If you want to opt-out from this behavior, use the `forceThemeRerender` prop in the ThemeProvider:

```js
<ThemeProvider forceThemeRerender />
```
85 changes: 85 additions & 0 deletions docs/data/material/migration/upgrade-to-v7/upgrade-to-v7.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,91 @@

The default `data-testid` prop has been removed from the icons in `@mui/icons-material` in production bundles. This change ensures that the `data-testid` prop is only defined where needed, reducing the potential for naming clashes and removing unnecessary properties in production.

### Theme behavior changes

When CSS theme variables is enabled with built-in light and dark color schemes, the theme no longer changes between modes.
The snippet below demonstrates this behavior when users toggle the dark mode, the `mode` state from `useColorScheme` changes, but the theme object no longer changes:

```js
import {
ThemeProvider,
createTheme,
useTheme,
useColorScheme,
} from '@mui/material/styles';

const theme = createTheme({
cssVariables: {
colorSchemeSelector: 'class',
},
colorSchemes: {
light: true,
dark: true,
},
});
console.log(theme.palette.mode); // 'light' is the default mode

function ColorModeToggle() {
const { setMode, mode } = useColorScheme();
const theme = useTheme();

React.useEffect(() => {
console.log(mode); // logged 'light' at first render, and 'dark' after the button click
}, [mode]);

React.useEffect(() => {
console.log(theme.palette.mode); // logged 'light' at first render, no log after the button click
}, [theme]);

return <button onClick={() => setMode('dark')}>Toggle dark mode</button>;
}

function App() {
return (
<ThemeProvider theme={theme}>
<ColorModeToggle />
</ThemeProvider>
);
}
```

This default behavior was made to improve performance by avoiding unnecessary re-renders when the mode changes.

It's recommended to use the `theme.vars.*` as values in your styles to refer to the CSS variables directly:

```js
const Custom = styled('div')(({ theme }) => ({
color: theme.vars.palette.text.primary,
background: theme.vars.palette.primary.main,
}));
```

If you need to do runtime calculations, we recommend using CSS instead of JavaScript whenever possible.

Check warning on line 222 in docs/data/material/migration/upgrade-to-v7/upgrade-to-v7.md

View workflow job for this annotation

GitHub Actions / runner / vale

[vale] reported by reviewdog 🐶 [Google.We] Try to avoid using first-person plural like 'we'. Raw Output: {"message": "[Google.We] Try to avoid using first-person plural like 'we'.", "location": {"path": "docs/data/material/migration/upgrade-to-v7/upgrade-to-v7.md", "range": {"start": {"line": 222, "column": 41}}}, "severity": "WARNING"}
For example, adjusting the alpha channel of a color can be done using the [`color-mix` function](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/color-mix):

```js
const Custom = styled('div')(({ theme }) => ({
color: `color-mix(in srgb, ${theme.vars.palette.text.primary}, transparent 50%)`,
}));
```

However, if CSS approach is not possible, you can access the value directly from the `theme.colorSchemes` object, then apply both light and dark styles:

```js
const Custom = styled('div')(({ theme }) => ({
color: alpha(theme.colorSchemes.light.palette.text.primary, 0.5),
...theme.applyStyles('dark', {
color: alpha(theme.colorSchemes.dark.palette.text.primary, 0.5),
}),
}));
```

If any of the methods above do not suit your project, you can opt out from this behavior by passing the `forceThemeRerender` prop to the ThemeProvider component:

```js
<ThemeProvider forceThemeRerender />
```

### Deprecated APIs removed

APIs that were deprecated in v5 have been removed in v7.
Expand Down
2 changes: 1 addition & 1 deletion docs/src/BrandingCssVarsProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export default function BrandingCssVarsProvider(props: { children: React.ReactNo
const { children } = props;
return (
// need to use deprecated API because MUI X repo still on Material UI v5
<ThemeVarsProvider theme={theme} disableTransitionOnChange>
<ThemeVarsProvider theme={theme} disableTransitionOnChange forceThemeRerender>
<NextNProgressBar />
<CssBaseline />
<SkipLink />
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 @@ -26,6 +26,12 @@ type ThemeProviderCssVariablesProps = CssThemeVariables extends { enabled: true
* @default false
*/
disableStyleSheetGeneration?: boolean;
/**
* If `true`, theme values are recalculated when the mode changes.
* The `theme.colorSchemes.{mode}.*` nodes will be shallow merged to the top-level of the theme.
* @default false
*/
forceThemeRerender?: boolean;
}
: {};

Expand Down
76 changes: 74 additions & 2 deletions packages/mui-material/src/styles/ThemeProviderWithVars.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,14 @@ import {
describe('[Material UI] ThemeProviderWithVars', () => {
let originalMatchmedia;
const { render } = createRenderer();
const storage = {};
let storage = {};

beforeEach(() => {
originalMatchmedia = window.matchMedia;
// clear the localstorage
storage = {};
// Create mocks of localStorage getItem and setItem functions
Object.defineProperty(global, 'localStorage', {
Object.defineProperty(window, 'localStorage', {
value: {
getItem: (key) => storage[key],
setItem: (key, value) => {
Expand Down Expand Up @@ -439,4 +441,74 @@ describe('[Material UI] ThemeProviderWithVars', () => {

expect(screen.queryByTestId('theme-changed')).to.equal(null);
});

it('theme does not change with CSS variables', () => {
function Toggle() {
const [count, setCount] = React.useState(0);
const { setMode } = useColorScheme();
const theme = useTheme();
React.useEffect(() => {
setCount((prev) => prev + 1);
}, [theme]);
return (
<button onClick={() => setMode('dark')}>
{count} {theme.palette.mode}
</button>
);
}

const theme = createTheme({
cssVariables: { colorSchemeSelector: 'class' },
colorSchemes: { light: true, dark: true },
});
function App() {
return (
<ThemeProvider theme={theme}>
<Toggle />
</ThemeProvider>
);
}
const { container } = render(<App />);

expect(container).to.have.text('1 light');

fireEvent.click(screen.getByRole('button'));

expect(container).to.have.text('1 light');
});

it('`forceThemeRerender` recalculates the theme', () => {
function Toggle() {
const [count, setCount] = React.useState(0);
const { setMode } = useColorScheme();
const theme = useTheme();
React.useEffect(() => {
setCount((prev) => prev + 1);
}, [theme]);
return (
<button onClick={() => setMode('dark')}>
{count} {theme.palette.mode}
</button>
);
}

const theme = createTheme({
cssVariables: { colorSchemeSelector: 'class' },
colorSchemes: { light: true, dark: true },
});
function App() {
return (
<ThemeProvider theme={theme} forceThemeRerender>
<Toggle />
</ThemeProvider>
);
}
const { container } = render(<App />);

expect(container).to.have.text('1 light');

fireEvent.click(screen.getByRole('button'));

expect(container).to.have.text('2 dark');
});
});
6 changes: 6 additions & 0 deletions packages/mui-system/src/cssVars/createCssVarsProvider.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ export interface CssVarsProviderConfig<ColorScheme extends string> {
* @default false
*/
disableTransitionOnChange?: boolean;
/**
* If `true`, theme values are recalculated when the mode changes.
* The `theme.colorSchemes.{mode}.*` nodes will be shallow merged to the top-level of the theme.
* @default false
*/
forceThemeRerender?: boolean;
}

type Identify<I extends string | undefined, T> = I extends string ? T | { [k in I]: T } : T;
Expand Down
27 changes: 23 additions & 4 deletions packages/mui-system/src/cssVars/createCssVarsProvider.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export default function createCssVarsProvider(options) {
disableNestedContext = false,
disableStyleSheetGeneration = false,
defaultMode: initialMode = 'system',
forceThemeRerender = false,
noSsr,
} = props;
const hasMounted = React.useRef(false);
Expand Down Expand Up @@ -133,10 +134,24 @@ export default function createCssVarsProvider(options) {
colorScheme = ctx.colorScheme;
}

const memoTheme = React.useMemo(() => {
// `colorScheme` is undefined on the server and hydration phase
const calculatedColorScheme = colorScheme || restThemeProp.defaultColorScheme;
if (process.env.NODE_ENV !== 'production') {
if (forceThemeRerender && !restThemeProp.vars) {
console.warn(
[
'MUI: The `forceThemeRerender` prop should only be used with CSS theme variables.',
'Note that it will slow down the app when changing between modes, so only do this when you cannot find a better solution.',
].join('\n'),
);
}
}

const calculatedColorScheme =
forceThemeRerender && restThemeProp.vars
? // `colorScheme` is undefined on the server and hydration phase
colorScheme || restThemeProp.defaultColorScheme
: restThemeProp.defaultColorScheme;

const memoTheme = React.useMemo(() => {
// 2. get the `vars` object that refers to the CSS custom properties
const themeVars = restThemeProp.generateThemeVars?.() || restThemeProp.vars;

Expand Down Expand Up @@ -172,7 +187,7 @@ export default function createCssVarsProvider(options) {
}

return resolveTheme ? resolveTheme(theme) : theme;
}, [restThemeProp, colorScheme, components, colorSchemes, cssVarPrefix]);
}, [restThemeProp, calculatedColorScheme, components, colorSchemes, cssVarPrefix]);

// 5. Declaring effects
// 5.1 Updates the selector value to use the current color scheme which tells CSS to use the proper stylesheet.
Expand Down Expand Up @@ -350,6 +365,10 @@ export default function createCssVarsProvider(options) {
* The document to attach the attribute to.
*/
documentNode: PropTypes.any,
/**
* If `true`, theme values are recalculated when the mode changes.
*/
forceThemeRerender: PropTypes.bool,
/**
* The key in the local storage used to store current color scheme.
*/
Expand Down
Loading