material-ui icon indicating copy to clipboard operation
material-ui copied to clipboard

[material-ui] Composing a theme with multiple `experimental_extendTheme` calls throws error

Open jcohen14 opened this issue 1 year ago • 8 comments

Steps to reproduce

Link to live example: https://codesandbox.io/p/sandbox/mui-extendtheme-chaining-bug-6j9mps

Steps:

  1. create any new react project & install MUI
  2. call experimental_extendTheme more than once, and passing in a Theme object for any of the calls

Current behavior

Chaining calls to experimental_extendTheme results in the following error:

MUI: `vars` is a private field used for CSS variables support. Please use another name.

Expected behavior

Should work without throwing an error. Matching createTheme functionality.

Context

Chaining calls to "compose" a theme object as described in the 2nd example here (but with the new extendTheme experimental feature instead)

Your environment

I just copied the codesandbox files to my machine to get these specs, not sure if that matters. Error still shows up when running it on my machine instead of codesandbox.

System: OS: macOS 14.5 Binaries: Node: 20.9.0 - ~/.nvm/versions/node/v20.9.0/bin/node npm: 10.1.0 - ~/.nvm/versions/node/v20.9.0/bin/npm pnpm: 8.5.0 - /usr/local/bin/pnpm Browsers: Chrome: 125.0.6422.113 Edge: Not Found Safari: 17.5 npmPackages: @emotion/react: 11.11.4 => 11.11.4 @emotion/styled: 11.11.5 => 11.11.5 @mui/base: 5.0.0-beta.40 @mui/core-downloads-tracker: 5.15.19 @mui/material: 5.15.19 => 5.15.19 @mui/private-theming: 5.15.14 @mui/styled-engine: 5.15.14 @mui/system: 5.15.15 @mui/types: 7.2.14 @mui/utils: 5.15.14 => 5.15.14 @types/react: 18.2.38 => 18.2.38 react: 18.2.0 => 18.2.0 react-dom: 18.2.0 => 18.2.0 typescript: 4.4.4 => 4.4.4

Search keywords: cssvars experimental_extendTheme extendtheme css variables experimental

jcohen14 avatar Jun 03 '24 23:06 jcohen14

Why do you need to call extendTheme based on the existing theme? It's not designed to be used like this. If you need multiple themes, they should be independent or you should extract shared tokens.

const tokens = { … };
cons theme1 = extendTheme(tokens);
const theme2 = extendTheme(tokens);

siriwatknp avatar Jun 09 '24 05:06 siriwatknp

It's not designed to be used like this

Is it not supposed to mimic createTheme? According to the docs here:

When the value for a theme option is dependent on another theme option, you should compose the theme in steps.


let theme = createTheme({
  palette: {
    primary: {
      main: '#0052cc',
    },
    secondary: {
      main: '#edf2ff',
    },
  },
});

theme = createTheme(theme, {
  palette: {
    info: {
      main: theme.palette.secondary.main,
    },
  },
});

The app I'm trying to migrate from createTheme to extendTheme uses this idea of constructing the theme in steps. I know it's still experimental but the docs kind of imply that extendTheme and createTheme can be 1:1 swapped for each other

jcohen14 avatar Jun 10 '24 19:06 jcohen14

Having the same issue over here. My use case is that I have a function that is used to automatically configure locales for various themes. So it takes a language and an existing theme object and adds the locales to it. Works with createTheme, but getting the error above when trying to switch to the new extendTheme.

export const useThemeWithLocale = (
  language: LanguageType,
  theme: CssVarsTheme,
  ...args: object[]
): CssVarsTheme => {
  const muiLanguage = language.replace(/-/g, '');
  return useMemo(
    () =>
      extendTheme(
        // current theme
        theme,
        // core translations
        muiBaseLocales[muiLanguage],
        // current language
        { language },
        ...args,
      ),
    [args, language, muiLanguage, theme],
  );
};

My current workaround is to take a CssVarsThemeOptions argument instead, but this is less than ideal since not all of the themes that we are dealing with have a separate options object created for them (we don't manage the code for all of the themes that we accept into this function).

joebochill avatar Jul 08 '24 02:07 joebochill

@jcohen14 @joebochill Before I propose any solution, can you provide more details on the context of your apps? Ideally (with Material UI v6, you don't need to create multiple themes. It produces unnecessary complexity to your apps.

siriwatknp avatar Aug 28 '24 08:08 siriwatknp

I can speak to my use case:

Our company has a design system, built around MUI. Our team provides standard themes for multiple other product-development teams to use for their applications. But not all products use the exact same theme. For example. some products are sold under a different brand name (white-labeled) — in this case, they made need to change things like the color palette, but they want to preserve all of the other aspects of our theme (component style overrides, etc.).

The localization case I mentioned in my other comment is related to a utility we provide to add localization to an existing theme. So if one of the teams we support built their own theme, but wanted to use the same localization behavior, we wouldn't be able to extend their theme if they had already called extendTheme to create it.

I think both of these cases can be covered following your recommended approach of splitting out shared tokens/config and then only calling extendTheme once. It just shifts some of the burden of knowing this nuance/behavior to the 'application' teams rather than our core team.

It seems like a bit of a misnomer to call it extendTheme when you can't extend an existing theme...you can only extend a set of ThemeOptions/config.

joebochill avatar Aug 28 '24 12:08 joebochill

My company has set up our app to split the config object that gets passed to extendTheme (currently we're still using createTheme) into separate files for improved maintainability. So we rely heavily on the idea of building one singular theme in sequence. We first build a theme that contains all our tokens (palette, typography, etc) then use that theme to build the rest of our theme with access to utils like theme.spacing.

i also agree with @joebochill that if it cannot extend an existing theme, extendTheme is a bit of a misnomer.

jcohen14 avatar Aug 28 '24 15:08 jcohen14

We have a similar issue -- prior to v6, we were also using theme composition to access custom palette colors in component overrides.

For us, the solution was refactoring our component override files -- but I agree that extendTheme is a misnomer, and the docs on theme composition should be updated.

A component file:

const MuiCard: Components<Omit<Theme, "components" | "palette"> & CssVarsTheme> = {
  MuiCard: {
    styleOverrides: {
      root: ({ theme }) => ({
        borderRadius: 8,
        border: `1px solid ${theme.palette.divider}`,
        "& .card-more-options": {
          marginTop: theme.spacing(-1),
          marginRight: theme.spacing(-3),
        },
        "& .MuiTableContainer-root, & .MuiDataGrid-root, & .MuiDataGrid-columnHeaders":
          {
            borderRadius: 0,
          },
      }),
    },
    defaultProps: {
      elevation: 0,
    },
  }
}

Theme creation:

  let theme = extendTheme(
    {
      colorSchemeSelector: "class",
      colorSchemes: {
        dark: {
          palette: darkPalette,
        },
        light: {
          palette: lightPalette,
        },
      },
      direction,
      typography,
      breakpoints,
      shape: {
        borderRadius: 3,
      },
      components: {
        ...MuiCard,
        ...MuiDataGrid,
        [ETC]
      },
    },
  );

akattow avatar Aug 28 '24 17:08 akattow

After the release of v6 and trying to implement variable css it also throws the same error Error: MUI: vars is a private field used for CSS variables support.

Full error here
⨯ Error: MUI: `vars` is a private field used for CSS variables support.
Please use another name.
    at eval (./src/providers/theme/navTheme.tsx:16:118)
    at (ssr)/./src/providers/theme/navTheme.tsx (/Users/jairalexandretorrescaviedes/Desktop/BODYTECH_PROJECT/FRONT/Web-Corporate-Portal/.next/server/app/page.js:1336:1)
    at __webpack_require__ (/Users/jairalexandretorrescaviedes/Desktop/BODYTECH_PROJECT/FRONT/Web-Corporate-Portal/.next/server/webpack-runtime.js:33:43)
    at eval (./src/components/Header/index.tsx:16:83)
    at (ssr)/./src/components/Header/index.tsx (/Users/jairalexandretorrescaviedes/Desktop/BODYTECH_PROJECT/FRONT/Web-Corporate-Portal/.next/server/app/page.js:896:1)
    at Object.__webpack_require__ [as require] (/Users/jairalexandretorrescaviedes/Desktop/BODYTECH_PROJECT/FRONT/Web-Corporate-Portal/.next/server/webpack-runtime.js:33:43)
digest: "2385216222"

When you create a normal theme and create a new one using the first one as a base, it works fine but once variable css is enabled it stops working

This is how it is implemented:

// src/providers/theme/theme.tsx
'use client'
import { Chip, IconButton, createTheme } from '@mui/material'
import { esES } from '@mui/material/locale'
import { esES as esESX } from '@mui/x-date-pickers/locales'
import type {} from '@mui/x-date-pickers/themeAugmentation'

import * as assets from '@/assets/icons'
import { CALENDAR_MAX_DATE, CALENDAR_MIN_DATE } from '@/constants/misc'

import { Gotham } from '../../app/ui/fonts'

/**
 * Represents the theme configuration for the application.
 */
export const theme = createTheme(
  {
    cssVariables: { cssVarPrefix: 'bt' },
    components: {...},
    palette: {
      mode: 'light',
      action: {
        active: '#E7662B',
        hover: '#ED8C60',
        selected: '#E7662B',
        disabled: '#EAEAEA',
        disabledBackground: '#CBCACA',
        focus: '#FDF0EA',
      },
      divider: '#CBCACA',
      primary: { main: '#E7662B', dark: '#E7662B', light: '#ED8C60', contrastText: '#fff' },
      secondary: { main: '#2B2D2E', dark: '#2B2D2E', light: '#626160', contrastText: '#fff' },
      text: { primary: '#2B2D2E', secondary: '#626160', disabled: '#CBCACA' },
      success: { main: '#B1CDBC', dark: '#B1CDBC', light: '#94c97a', contrastText: '#2B2D2E' },
      error: { main: '#EC6969', dark: '#EC6969', light: '#F7C3C3', contrastText: '#fff' },
      info: { main: '#a4e0ff', dark: '#C2F0F0', light: '#C2F0F0', contrastText: '#2B2D2E' },
      warning: { main: '#f4e59f', dark: '#FFE4B2', light: '#FFE4B2', contrastText: '#2B2D2E' },
      common: { black: '#2B2D2E', white: '#fff' },
      gray: { main: '#eaeaea', light: '#f5f4f4', dark: '#cbcaca', contrastText: '#2b2d2e' },
    },
    shape: { borderRadius: 10 },
    typography: {...},
  },
  esES,
  esESX
)
// src/providers/theme/navTheme.tsx
'use client'
import { type Theme, createTheme } from '@mui/material'
import { useMemo } from 'react'

import { theme } from './theme'

/**
 * Represents the theme for the nav bar when it is light
 * @extends theme - The base theme
 * @see [theme](../theme/theme.tsx)
 */
const navThemeLight = createTheme(theme, {
  cssVariables: {cssVarPrefix: 'bt-nav-light',},
  components: {...},
  palette: {
    secondary: { main: '#fff', dark: '#fff', light: '#f5f4f4', contrastText: '#2B2D2E' },
    background: { default: '#2b2d2ee6', paper: '#2b2d2ee6' },
  },
})

/**
 * Represents the theme for the nav bar when it is dark
 * @extends theme - The base theme
 * @see [theme](../theme/theme.tsx)
 */
const navThemeDark = createTheme(theme, {
  cssVariables: {cssVarPrefix: 'bt-nav-dark',},
  components: {...},
  palette: {
    mode: 'dark',
    secondary: { main: '#2B2D2E', dark: '#2B2D2E', light: '#626160', contrastText: '#fff' },
    background: { default: '#ffffffe6', paper: '#ffffffe6' },
  },
})

/**
 * Hook to get the theme for the nav bar
 * @param isDark to determine if the theme is dark or light
 * @returns the theme for the nav bar
 */
export const useNavTheme = (isDark: boolean): Theme => {
  const theme = useMemo(() => (isDark ? navThemeDark : navThemeLight), [isDark])

  return theme
}
// src/app/layout.tsx
import { GlobalStyles, ThemeProvider } from '@mui/material'

import { Header } from '@/components/Header'
import { ErrorBoundary } from '@/components/helpers'

export default function RootLayout({ children }: LayoutBaseProps): React.JSX.Element {
  return (
    <html lang={LANGUAGE}>
      <ThemeProvider theme={theme}>
        <body className={Gotham.className}>
          <GlobalStyles
            styles={{
              '@keyframes mui-auto-fill': { from: { display: 'block' } },
              '@keyframes mui-auto-fill-cancel': { from: { display: 'block' } },
            }}
          />
          <ErrorBoundary fallback={null}>
            <Header />
            <main>{children}</main>
          </ErrorBoundary>
        </body>
      </ThemeProvider>
    </html>
  )
}
// src/components/Header/index.tsx
'use client'
import { ThemeProvider } from '@mui/material'
import React, { type FC } from 'react'

import { useDevice } from '@/hooks/useDevice'
import { type ComponentBaseProps } from '@/interfaces/BaseProps'
import { useNavTheme } from '@/providers/theme/navTheme'

export const Header: FC<ComponentBaseProps> = ({ ... }) => {
  const device = useDevice()

  const isMobileOrTablet = device.isMobile || device.isTablet
  const theme = useNavTheme(isMobileOrTablet)

  return (
    <ThemeProvider theme={theme}>
      {/* More code */}
    </ThemeProvider>
  )
}

JairTorres1003 avatar Sep 13 '24 23:09 JairTorres1003

@jcohen14 @akattow thanks for your feedbacks, they are very helpful.

The extendTheme was added as an experimental API in v5. However, in v6 we learned that there should not be 2 APIs (createTheme and extendTheme) that do the same thing so we move the logic from extendTheme to createTheme.

To summarize, please replace extendTheme with createTheme (the parameters are likely the same except the CSS theme variables)

@jcohen14 As a design system team, you can provide different layers of the theme. For simplicity, you can provide an AppTheme that contains the default design system with a localization prop to change to language:

import { AppTheme } from 'lib/design-system'

function App() {
  const locale = useLocalization(); // the white-labeled logic
  return <AppTheme locale={locale}>
}

With the CSS theme variables feature, the white-labeled app can overrides the theme using plain CSS.

--mui-palette-primary-main: …

If the white-labeled app needs more control of the theme, you can provide lower-level tokens like:

import { themeTokens } from 'lib/design-system';
import { ThemeProvider, createTheme } from '@mui/material/styles';

function App() {
  return <ThemeProvider theme={createTheme(themeTokens)}>

siriwatknp avatar Sep 16 '24 07:09 siriwatknp

@akattow Awesome, we are using the same approach as your with the free templates.

siriwatknp avatar Sep 16 '24 07:09 siriwatknp

I do have the same issue as you @JairTorres1003 Did you find a workaround?

lazybean avatar Nov 15 '24 08:11 lazybean

@jcohen14 As a design system team, you can provide different layers of the theme. For simplicity, you can provide an AppTheme that contains the default design system with a localization prop to change to language:

@siriwatknp This is a cool pattern but I don't think it applies to my use case.

Even after upgrading to v6 I'm still encountering the issue when trying to enable cssVariables in the theme.

The major benefit of my app's approach to building the theme is having access to utilities like theme.spacing and theme.shape when defining component themes. So we're not really creating multiple themes, just adding more and more to a single theme in steps. It looks a lot like this, but more extensive and across multiple files:

let theme = createTheme({
  // tokens
});

theme = createTheme(theme, {
  components: {
    MuiButton: {
      styleOverrides: {
        root: {
          padding: theme.spacing(4), // helper functions like this are a major benefit of this approach
          backgroundColor: theme.palette.primary.main,
        },
      },
    },
  },
});

If this pattern is no longer being supported we can migrate, but the docs for createTheme are now really confusing in v6. Here it states that arguments past the first are basically ignored by createTheme but then in the very next section are examples where multiple arguments are being passed to it, implying it actually does care about arguments after the first.

jcohen14 avatar Nov 22 '24 23:11 jcohen14

@jcohen14 Gotcha. The approach you are using is not optimized because every time the createTheme is called, it's doing deepmerge between arguments. So if you are merging multiple times instead of one, it can be seen like this:

defaultTheme + { tokens }
defaultTheme + theme + { components: … }

I'd recommend creating the theme once and if you want to access theme utilities, use a callback at each styleOverrides slot instead:

{
  components: {
    MuiButton: {
      styleOverrides: {
        root: ({ theme }) => ({
          padding: theme.spacing(4),
          backgroundColor: theme.palette.primary.main,
        }),
      },
    },
  },
}

This way the { component: … } is independent from the theme creation.

siriwatknp avatar Nov 25 '24 06:11 siriwatknp

The docs recommended using multiple createTheme calls to incrementally build the theme. What @siriwatknp recommend doesn't cover all use cases.

For example: Generate Tokens using Augment Color Utility

let theme = createTheme({ ...theme, cssVariables: true });
theme = createTheme(theme, {
      palette: {
        dark: theme.palette.augmentColor({
          color: {
            main: themeOptions.palette.dark.main
          },
          name: 'dark'
        })
      }
});

Turning on css variables like this will break your website.

Uncaught Error: MUI: `vars` is a private field used for CSS variables support.
Please use another name.

Workaround

A workaround I found is to only include the cssVariables on the last call to createTheme. By moving it to the last call, the bug doesn't happen. It only shows up after calling createTheme on a theme that already has the css variables enabled.

matzxrr avatar Jan 21 '25 19:01 matzxrr

This is fixed by #45335

siriwatknp avatar Feb 21 '25 07:02 siriwatknp

This issue has been closed. If you have a similar problem but not exactly the same, please open a new issue. Now, if you have additional information related to this issue or things that could help future readers, feel free to leave a comment.

[!NOTE] @jcohen14 How did we do? Your experience with our support team matters to us. If you have a moment, please share your thoughts in this short Support Satisfaction survey.

github-actions[bot] avatar Feb 21 '25 07:02 github-actions[bot]