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] Optimize theme changes when enabling CSS theme variables #44588

Merged
merged 2 commits into from
Nov 29, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
27 changes: 27 additions & 0 deletions packages/mui-material/src/styles/ThemeProviderWithVars.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -408,4 +408,31 @@ describe('[Material UI] ThemeProviderWithVars', () => {
fireEvent.click(screen.getByText('Dark'));
}).not.toErrorDev();
});

it('theme should remain the same when ThemeProvider rerenders', () => {
const theme = createTheme({ cssVariables: true });

function Inner() {
const upperTheme = useTheme();
const [count, setCount] = React.useState(0);
React.useEffect(() => {
setCount((prev) => prev + 1);
}, [upperTheme]);
return <span data-testid="inner">{count}</span>;
}
function App() {
const [count, setCount] = React.useState(0);
return (
<ThemeProvider theme={theme}>
<button onClick={() => setCount(count + 1)}>rerender</button>
<Inner />
</ThemeProvider>
);
}
render(<App />);

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

expect(screen.getByTestId('inner')).to.have.text('2'); // due to double rendering
});
Copy link
Member

Choose a reason for hiding this comment

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

Should theme and upperTheme be referentially equal? I was about to suggest that we could use another approach for the test: instead of counting renders, check that theme === upperTheme, but I found that they're not referentially equal. Not sure if it's possible to do, just wondering if that's something we should aim for.

it('theme should remain the same when ThemeProvider rerenders', () => {
  const theme = createTheme({ cssVariables: true });

  function Inner() {
    const upperTheme = useTheme();
    return <span data-testid="inner">{(theme === upperTheme).toString()}</span>;
  }
  function App() {
    return (
      <ThemeProvider theme={theme}>
        <Inner />
      </ThemeProvider>
    );
  }
  render(<App />);

  expect(screen.getByTestId('inner')).to.have.text('true');
});

Copy link
Member Author

Choose a reason for hiding this comment

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

Good point but const theme = createTheme({ cssVariables: true }); is not equal to const upperTheme = useTheme(); because it's recalculated within ThemeProvider.

I could write a different test using React.useRef to compare the previous and the incoming theme so that it's not rely on count.

Copy link
Member

Choose a reason for hiding this comment

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

Sounds good!

Copy link
Member Author

Choose a reason for hiding this comment

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

Updated.

});
92 changes: 49 additions & 43 deletions packages/mui-system/src/cssVars/createCssVarsProvider.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ export default function createCssVarsProvider(options) {

const useColorScheme = () => React.useContext(ColorSchemeContext) || defaultContext;

const defaultColorSchemes = {};
const defaultComponents = {};

function CssVarsProvider(props) {
const {
children,
Expand Down Expand Up @@ -75,12 +78,12 @@ export default function createCssVarsProvider(options) {
return typeof defaultTheme === 'function' ? defaultTheme() : defaultTheme;
}, [themeProp]);
const scopedTheme = initialTheme[themeId];
const restThemeProp = scopedTheme || initialTheme;
const {
colorSchemes = {},
components = {},
colorSchemes = defaultColorSchemes,
components = defaultComponents,
cssVarPrefix,
...restThemeProp
} = scopedTheme || initialTheme;
} = restThemeProp;
const joinedColorSchemes = Object.keys(colorSchemes)
.filter((k) => !!colorSchemes[k])
.join(',');
Expand Down Expand Up @@ -126,42 +129,46 @@ export default function createCssVarsProvider(options) {
colorScheme = ctx.colorScheme;
}

// `colorScheme` is undefined on the server and hydration phase
const calculatedColorScheme = colorScheme || restThemeProp.defaultColorScheme;
const memoTheme = React.useMemo(() => {
// `colorScheme` is undefined on the server and hydration phase
const calculatedColorScheme = colorScheme || restThemeProp.defaultColorScheme;

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

// 3. Start composing the theme object
const theme = {
...restThemeProp,
components,
colorSchemes,
cssVarPrefix,
vars: themeVars,
};
if (typeof theme.generateSpacing === 'function') {
theme.spacing = theme.generateSpacing();
}
// 3. Start composing the theme object
const theme = {
...restThemeProp,
components,
colorSchemes,
cssVarPrefix,
vars: themeVars,
};
if (typeof theme.generateSpacing === 'function') {
theme.spacing = theme.generateSpacing();
}

// 4. Resolve the color scheme and merge it to the theme
if (calculatedColorScheme) {
const scheme = colorSchemes[calculatedColorScheme];
if (scheme && typeof scheme === 'object') {
// 4.1 Merge the selected color scheme to the theme
Object.keys(scheme).forEach((schemeKey) => {
if (scheme[schemeKey] && typeof scheme[schemeKey] === 'object') {
// shallow merge the 1st level structure of the theme.
theme[schemeKey] = {
...theme[schemeKey],
...scheme[schemeKey],
};
} else {
theme[schemeKey] = scheme[schemeKey];
}
});
// 4. Resolve the color scheme and merge it to the theme
if (calculatedColorScheme) {
const scheme = colorSchemes[calculatedColorScheme];
if (scheme && typeof scheme === 'object') {
// 4.1 Merge the selected color scheme to the theme
Object.keys(scheme).forEach((schemeKey) => {
if (scheme[schemeKey] && typeof scheme[schemeKey] === 'object') {
// shallow merge the 1st level structure of the theme.
theme[schemeKey] = {
...theme[schemeKey],
...scheme[schemeKey],
};
} else {
theme[schemeKey] = scheme[schemeKey];
}
});
}
}
}

return resolveTheme ? resolveTheme(theme) : theme;
}, [restThemeProp, colorScheme, 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 @@ -248,7 +255,7 @@ export default function createCssVarsProvider(options) {
process.env.NODE_ENV === 'production'
? setMode
: (newMode) => {
if (theme.colorSchemeSelector === 'media') {
if (memoTheme.colorSchemeSelector === 'media') {
console.error(
[
'MUI: The `setMode` function has no effect if `colorSchemeSelector` is `media` (`media` is the default value).',
Expand All @@ -270,7 +277,7 @@ export default function createCssVarsProvider(options) {
setColorScheme,
setMode,
systemMode,
theme.colorSchemeSelector,
memoTheme.colorSchemeSelector,
],
);

Expand All @@ -285,13 +292,12 @@ export default function createCssVarsProvider(options) {

const element = (
<React.Fragment>
<ThemeProvider
themeId={scopedTheme ? themeId : undefined}
theme={resolveTheme ? resolveTheme(theme) : theme}
>
<ThemeProvider themeId={scopedTheme ? themeId : undefined} theme={memoTheme}>
{children}
</ThemeProvider>
{shouldGenerateStyleSheet && <GlobalStyles styles={theme.generateStyleSheets?.() || []} />}
{shouldGenerateStyleSheet && (
<GlobalStyles styles={memoTheme.generateStyleSheets?.() || []} />
)}
</React.Fragment>
);

Expand Down