Skip to content

[material-nextjs] Add option to enable CSS layers for pages router #45596

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

Merged
merged 48 commits into from
Apr 11, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
d2ec62b
able to create cache with css layers
siriwatknp Mar 17, 2025
56b72aa
fix hydration
siriwatknp Mar 17, 2025
646056a
pass the rest options to emotion
siriwatknp Mar 17, 2025
fc2d016
update Tailwind CSS to v4
siriwatknp Mar 17, 2025
19ac097
Merge branch 'master' of https://github.com/mui/material-ui into feat…
siriwatknp Mar 17, 2025
fe57c42
pnpm dedupe
siriwatknp Mar 17, 2025
e36200d
restore
siriwatknp Mar 18, 2025
a7af7b5
Merge branch 'master' of https://github.com/mui/material-ui into feat…
siriwatknp Mar 18, 2025
9c88c09
install packages
siriwatknp Mar 18, 2025
6373bab
use mjs
siriwatknp Mar 18, 2025
2e811e8
restore
siriwatknp Mar 18, 2025
0a76ffd
Merge branch 'master' of https://github.com/mui/material-ui into feat…
siriwatknp Mar 18, 2025
8c07657
update deps
siriwatknp Mar 18, 2025
604e862
wrap docsearch with layer
siriwatknp Mar 18, 2025
be1a0fd
update heading level
siriwatknp Mar 20, 2025
1c4785b
Merge branch 'master' of https://github.com/mui/material-ui into feat…
siriwatknp Mar 20, 2025
5ca6b0f
Merge branch 'master' of https://github.com/mui/material-ui into feat…
siriwatknp Mar 21, 2025
f1b3a02
fix lint
siriwatknp Mar 21, 2025
9e3645b
Merge branch 'master' of https://github.com/mui/material-ui into feat…
siriwatknp Mar 21, 2025
7d27586
Merge branch 'master' of https://github.com/mui/material-ui into feat…
siriwatknp Mar 27, 2025
d333d47
restore
siriwatknp Mar 27, 2025
1b56c05
pnpm install
siriwatknp Mar 27, 2025
81b2126
pnpm dedupe
siriwatknp Mar 27, 2025
50c5f39
Merge branch 'master' of https://github.com/mui/material-ui into feat…
siriwatknp Apr 8, 2025
0f5d09b
restore
siriwatknp Apr 8, 2025
3837d27
pnpm install
siriwatknp Apr 8, 2025
8542947
Merge branch 'master' of https://github.com/mui/material-ui into feat…
siriwatknp Apr 8, 2025
a2f908a
restore
siriwatknp Apr 8, 2025
84436db
pnpm install
siriwatknp Apr 8, 2025
1c77676
Merge branch 'master' of https://github.com/mui/material-ui into feat…
siriwatknp Apr 9, 2025
1cfac08
restore
siriwatknp Apr 9, 2025
86aafd9
fix merge conflict
siriwatknp Apr 9, 2025
bebad83
pnpm install
siriwatknp Apr 9, 2025
55d3241
simplify
siriwatknp Apr 9, 2025
3f36e9c
restore
siriwatknp Apr 9, 2025
2db9972
Merge branch 'master' of https://github.com/mui/material-ui into feat…
siriwatknp Apr 9, 2025
f1affa3
pnpm install
siriwatknp Apr 9, 2025
d76e43a
Merge branch 'master' into feat/nextjs-pages-csslayers
siriwatknp Apr 9, 2025
2900052
Merge branch 'master' into feat/nextjs-pages-csslayers
siriwatknp Apr 9, 2025
e6cabc2
Merge branch 'master' into feat/nextjs-pages-csslayers
siriwatknp Apr 10, 2025
ce472d9
Apply suggestions from code review
siriwatknp Apr 10, 2025
6734245
restore
siriwatknp Apr 10, 2025
b10d10d
Merge branch 'master' of https://github.com/mui/material-ui into feat…
siriwatknp Apr 10, 2025
d38d776
run install
siriwatknp Apr 10, 2025
850ce7b
Merge branch 'master' into feat/nextjs-pages-csslayers
siriwatknp Apr 10, 2025
a4f11c6
pnpm dedupe
siriwatknp Apr 10, 2025
b79c535
Merge branch 'master' of https://github.com/mui/material-ui into feat…
siriwatknp Apr 10, 2025
e7de780
Merge branch 'feat/nextjs-pages-csslayers' of github.com:siriwatknp/m…
siriwatknp Apr 10, 2025
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
38 changes: 36 additions & 2 deletions docs/data/material/integrations/nextjs/nextjs.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ Finally, in `src/app/layout.tsx`, pass the theme to the `ThemeProvider`:

To learn more about theming, check out the [theming guide](/material-ui/customization/theming/) page.

#### CSS theme variables
### CSS theme variables

To use [CSS theme variables](/material-ui/customization/css-theme-variables/overview/), enable the `cssVariables` flag:

Expand Down Expand Up @@ -243,6 +243,40 @@ To use a custom [Emotion cache](https://emotion.sh/docs/@emotion/cache), pass it
};
```

#### Cascade layers (optional)

To enable [cascade layers](https://developer.mozilla.org/en-US/docs/Learn_web_development/Core/Styling_basics/Cascade_layers) (`@layer`), create a new cache with `enableCssLayer: true` and pass it to the `emotionCache` property in both `_document.tsx` and `_app.tsx`:

```diff title="pages/_document.tsx"
+import { createEmotionCache } from '@mui/material-nextjs/v15-pagesRouter';
...

MyDocument.getInitialProps = async (ctx) => {
const finalProps = await documentGetInitialProps(ctx, {
+ emotionCache: createEmotionCache({ enableCssLayer: true }),
});
return finalProps;
};
```

```diff title="pages/_app.tsx"
+import { createEmotionCache } from '@mui/material-nextjs/v15-pagesRouter';
...

const clientCache = createEmotionCache({ enableCssLayer: true });

+ export default function MyApp({ emotionCache = clientCache }) {
return (
+ <AppCacheProvider emotionCache={emotionCache}>
<Head>
...
</Head>
...
</AppCacheProvider>
);
}
```

#### App enhancement (optional)

Pass an array to the `plugins` property to enhance the app with additional features, like server-side-rendered styles if you're using JSS and styled-components.
Expand Down Expand Up @@ -361,7 +395,7 @@ To integrate [Next.js font optimization](https://nextjs.org/docs/pages/building-

To learn more about theming, check out the [Theming guide](/material-ui/customization/theming/).

#### CSS theme variables
### CSS theme variables

To use [CSS theme variables](/material-ui/customization/css-theme-variables/overview/), enable the `cssVariables` flag:

Expand Down
3 changes: 2 additions & 1 deletion docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"@mui/x-tree-view": "7.28.1",
"@popperjs/core": "^2.11.8",
"@react-spring/web": "^9.7.5",
"@tailwindcss/postcss": "^4.1.3",
"@toolpad/core": "^0.12.1",
"autoprefixer": "^10.4.21",
"autosuggest-highlight": "^3.3.4",
Expand Down Expand Up @@ -139,7 +140,7 @@
"marked": "^15.0.7",
"playwright": "^1.51.1",
"prettier": "^3.5.3",
"tailwindcss": "^3.4.17",
"tailwindcss": "^4.1.3",
"yargs": "^17.7.2"
}
}
6 changes: 3 additions & 3 deletions docs/pages/global.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import 'tailwindcss/theme.css' layer(theme);
@import 'tailwindcss/utilities.css' layer(utilities);
@config '../tailwind.config.mjs';
Comment on lines +1 to +2
Copy link
Member Author

@siriwatknp siriwatknp Mar 21, 2025

Choose a reason for hiding this comment

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

Based on the Tailwind CSS docs for disabling preflight

3 changes: 1 addition & 2 deletions docs/postcss.config.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
module.exports = {
plugins: {
'postcss-import': {},
'tailwindcss/nesting': {},
tailwindcss: {},
'@tailwindcss/postcss': {},
Copy link
Member Author

Choose a reason for hiding this comment

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

Required for Tailwind CSS v4

},
};
9 changes: 7 additions & 2 deletions docs/src/createEmotionCache.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import createCache from '@emotion/cache';
import { createEmotionCache as createCache } from '@mui/material-nextjs/v15-pagesRouter';
import { prefixer } from 'stylis';
import globalSelector from './modules/utils/globalSelector';

export default function createEmotionCache() {
// TODO remove prepend: true once JSS is out
return createCache({ key: 'css', prepend: true, stylisPlugins: [prefixer, globalSelector] });
return createCache({
key: 'css',
prepend: true,
enableCssLayer: true,
stylisPlugins: [prefixer, globalSelector],
});
}
1 change: 1 addition & 0 deletions docs/src/modules/components/AppSearch.js
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,7 @@ export default function AppSearch(props) {
useLazyCSS(
'https://cdn.jsdelivr.net/npm/@docsearch/css@3.0.0-alpha.40/dist/style.min.css',
'#app-search',
{ layer: 'docsearch' },
);
const FADE_DURATION = 120; // ms
const t = useTranslate();
Expand Down
9 changes: 7 additions & 2 deletions docs/src/modules/utils/StyledEngineProvider.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,18 @@ import * as React from 'react';
import PropTypes from 'prop-types';
import { StyleSheetManager } from 'styled-components';
import { CacheProvider } from '@emotion/react';
import createCache from '@emotion/cache';
import { createEmotionCache as createCache } from '@mui/material-nextjs/v15-pagesRouter';
import { prefixer } from 'stylis';
import rtlPlugin from 'stylis-plugin-rtl';
import GlobalStyles from '@mui/material/GlobalStyles';
import { ThemeOptionsContext } from 'docs/src/modules/components/ThemeContext';
import globalSelector from './globalSelector';

// Cache for the rtl version of the styles
const cacheRtl = createCache({
key: 'rtl',
prepend: true,
enableCssLayer: true,
stylisPlugins: [prefixer, rtlPlugin, globalSelector],
});

Expand All @@ -24,7 +26,10 @@ export default function StyledEngineProvider(props) {

return (
<StyleSheetManager stylisPlugins={rtl ? [rtlPlugin] : []}>
<CacheProvider value={emotionCache}>{children}</CacheProvider>
<CacheProvider value={emotionCache}>
<GlobalStyles styles="@layer theme, docsearch, mui, utilities;" />
{children}
</CacheProvider>
</StyleSheetManager>
);
}
Expand Down
68 changes: 61 additions & 7 deletions docs/src/modules/utils/useLazyCSS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,70 @@ import * as React from 'react';
import { loadCSS } from 'fg-loadcss';

/**
* Convenience wrapper around fgLoadCSS for hooks usage
* @param {string} href
* @param {string} before - CSS selector
* Enhanced lazy CSS loader that wraps CSS in a layer using fetch to avoid CORS issues
* @param {string} href - URL of the CSS file to load
* @param {string} before - CSS selector to insert before
* @param {object} options - Additional options
* @param {string} options.layer - Optional CSS layer name to wrap the CSS in
* @returns {() => void} cleanup function
*/
export default function useLazyCSS(href: string, before: string) {
export default function useLazyCSS(href: string, before: string, options: { layer?: string } = {}) {
React.useEffect(() => {
const link = loadCSS(href, document.querySelector(before) as HTMLElement);
// If no layer is specified, add style and clean it on unmount
if (!options.layer) {
const link = loadCSS(href, document.querySelector(before) as HTMLElement);
return () => {
link.parentElement?.removeChild(link);
};
}

// With layer option, we need to fetch the CSS content and wrap it
let styleElement: HTMLStyleElement | null = null;
const abortController = new AbortController();

// Fetch the CSS content directly to avoid CORS issues with cssRules
Copy link
Member

Choose a reason for hiding this comment

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

I don't understand how we could get CORS issues. Everything comes from the same origin 🤔

But more security do not hurt

fetch(href, { signal: abortController.signal })
.then((response) => {
if (!response.ok) {
throw new Error(`Failed to fetch CSS: ${response.statusText}`);
}
return response.text();
})
.then((cssText) => {
// Create a style element with the CSS wrapped in the specified layer
styleElement = document.createElement('style');
styleElement.setAttribute('data-href', href);
styleElement.textContent = `@layer ${options.layer} {\n${cssText}\n}`;

// Insert at the specified position
const beforeElement = document.querySelector(before);
if (beforeElement?.parentNode) {
beforeElement.parentNode.insertBefore(styleElement, beforeElement);
} else {
document.head.appendChild(styleElement);
}
})
.catch((error) => {
// Ignore abort errors, log others
if (error.name !== 'AbortError') {
if (process.env.NODE_ENV !== 'production') {
console.error('Error loading CSS with layer:', error);
}

// Fall back to regular link element if fetch fails
styleElement = loadCSS(href, document.querySelector(before) as HTMLElement);
}
});

// Cleanup function
return () => {
link.parentElement?.removeChild(link);
// Cancel any pending fetch
abortController.abort();

// Remove the style element if it was created
if (styleElement && styleElement.parentElement) {
styleElement.parentElement.removeChild(styleElement);
}
};
}, [href, before]);
}, [href, before, options.layer]);
}
8 changes: 2 additions & 6 deletions docs/tailwind.config.js → docs/tailwind.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
// eslint-disable-next-line import/no-import-module-exports
import plugin from 'tailwindcss/plugin';

const defaultTheme = require('tailwindcss/defaultTheme');
import defaultTheme from 'tailwindcss/defaultTheme';

/** @type {import('tailwindcss').Config} */
module.exports = {
export default {
Copy link
Member Author

Choose a reason for hiding this comment

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

Switch to mjs to make the lint passed.

darkMode: ['class', '[data-mui-color-scheme="dark"]'],
content: [
'./data/**/*.{js,ts,jsx,tsx,mdx}',
Expand Down Expand Up @@ -55,10 +55,6 @@ module.exports = {
},
},
},
corePlugins: {
// Remove the Tailwind CSS preflight styles so it can use Material UI's preflight instead (CssBaseline).
preflight: false,
},
plugins: [
plugin(({ addVariant }) => {
[
Expand Down
21 changes: 18 additions & 3 deletions packages/mui-material-nextjs/src/v13-pagesRouter/createCache.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import createCache, { Options } from '@emotion/cache';
import createCache from '@emotion/cache';

const isBrowser = typeof document !== 'undefined';

// On the client side, Create a meta tag at the top of the <head> and set it as insertionPoint.
// This assures that MUI styles are loaded first.
// It allows developers to easily override MUI styles with other styling solutions, like CSS modules.
export default function createEmotionCache(options?: Partial<Options>) {
export default function createEmotionCache(
options?: { enableCssLayer?: boolean } & Parameters<typeof createCache>[0],
) {
let insertionPoint;

if (isBrowser) {
Expand All @@ -15,5 +17,18 @@ export default function createEmotionCache(options?: Partial<Options>) {
insertionPoint = emotionInsertionPoint ?? undefined;
}

return createCache({ key: 'mui', insertionPoint, ...options });
const { enableCssLayer, ...rest } = options ?? {};

const emotionCache = createCache({ key: 'mui', insertionPoint, ...rest });
if (enableCssLayer) {
const prevInsert = emotionCache.insert;
emotionCache.insert = (...args) => {
if (!args[1].styles.startsWith('@layer')) {
// avoid nested @layer
args[1].styles = `@layer mui {${args[1].styles}}`;
}
return prevInsert(...args);
};
}
return emotionCache;
}
Original file line number Diff line number Diff line change
Expand Up @@ -102,17 +102,23 @@ export async function documentGetInitialProps(
const { styles } = extractCriticalToChunks(initialProps.html);
return {
...initialProps,
emotionStyleTags: styles.map((style) =>
style.css.trim() ? (
emotionStyleTags: styles.map((style) => {
if (!style.css.trim()) {
return null;
}
const isLayerOrderRule = style.css.startsWith('@layer') && !style.css.match(/\{.*\}/);
return (
<style
data-emotion={`${style.key} ${style.ids.join(' ')}`}
// If the style is a layer order rule, prefix with the cache key to let Emotion hydrate this node.
// Otherwise, Emotion will hydrate only the non-global styles and they will override the layer order rule.
data-emotion={`${isLayerOrderRule ? `${cache.key} ` : ''}${style.key} ${style.ids.join(' ')}`}
key={style.key}
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: style.css }}
nonce={cache.nonce}
/>
) : null,
),
);
}),
};
},
},
Expand Down
Loading
Loading