diff --git a/CHANGELOG.old.md b/CHANGELOG.old.md index cc0beb5bf4942d..cf53c1039f0f0e 100644 --- a/CHANGELOG.old.md +++ b/CHANGELOG.old.md @@ -98,7 +98,7 @@ A big thanks to the 16 contributors who made this release possible. - Fix incorrect indentation in migration guide (#47571) @sai6855 - Enable MUIΒ chat on MaterialΒ UI demos (#46837) @siriwatknp - Add docs and website banner for Dev survey'25 (#47521) @prakhargupta1 -- Update Tailwind CSS v4 + Next.js Pages Router docs (#47546) @atharva3333 +- Update TailwindΒ CSS v4 + Next.js Pages Router docs (#47546) @atharva3333 - Add warning callout to Sync plugin doc (#47511) @mapache-salvaje - Update typo in TailwindCSS v4 integration with Next.js docs (#47512) @TimKraemer - Fix link to contributing guide (#47473) @oliviertassinari @@ -3738,7 +3738,7 @@ This release was mostly about πŸ› bug fixes and πŸ“š documentation improvements - Link to pnpm installation docs (#42420) @aarongarciah - Remove LocalMonero (@oliviertassinari) (#42315) @github-actions[bot] -- [material-ui] Fix typo in style interoperability with Tailwind CSS docs (@ZeeshanTamboli) (#42312) @github-actions[bot] +- [material-ui] Fix typo in style interoperability with TailwindΒ CSS docs (@ZeeshanTamboli) (#42312) @github-actions[bot] - [material-ui][Pagination] Clarify pagination `page` prop API (@Mandar-Pandya) (#42265) @github-actions[bot] - [material-ui][Tabs] Improve the Basic Tabs demo (@MatheusEli) (#42426) @github-actions[bot] - [pigment-css] Fix duplication of content (#42410) @oliviertassinari diff --git a/STYLING_V8_TODO.md b/STYLING_V8_TODO.md new file mode 100644 index 00000000000000..70e6b3f01b0a4a --- /dev/null +++ b/STYLING_V8_TODO.md @@ -0,0 +1,297 @@ +# Styling v8 β€” Emotion-free path TODO + +## Goal + +Material UI components ship their **own base styles as plain CSS files** (`@layer mui.default`, +driven by `--mui-*` CSS variables). This base layer is shared by all consumers. Users who want +Emotion (`sx`, `styled()`) keep using it without any changes. Users who don't want Emotion +swap the engine and use one of two theme delivery paths. + +--- + +## Path overview + +| | **Emotion path** | **Path A β€” CssThemeProvider** | **Path B β€” Static CSS** | +|---|---|---|---| +| Engine | `@mui/styled-engine` (Emotion) | `@mui/styled-engine-noop` | `@mui/styled-engine-noop` | +| Base styles | `Component.css` | same | same | +| Theme delivery | `ThemeProvider` (runtime) | `CssThemeProvider` (runtime) | `default-theme.css` (build-time) | +| Custom theme | `createTheme()` β†’ `ThemeProvider` | `createTheme({ cssVariables: true })` β†’ `CssThemeProvider` | `generateThemeCss(createTheme(...))` β†’ import `.css` | +| `useColorScheme()` | βœ… via `ThemeProvider` | βœ… via `CssThemeProvider` | βœ… standalone hook (no provider) | +| SSR flash prevention | βœ… `InitColorSchemeScript` | βœ… `InitColorSchemeScript` | βœ… `InitColorSchemeScript` | +| `useTheme()` (JS values) | βœ… | βœ… | ❌ no theme context | +| `sx` prop | βœ… | ❌ ignored + dev warning | ❌ ignored + dev warning | +| `styled()` overrides | βœ… | ❌ | ❌ | +| Analogy | β€” | Mantine | Radix Themes | + +**Breaking changes policy:** The Emotion path has zero breaking changes. Paths A and B are +opt-in via the engine alias. `sx` is intentionally unsupported on both non-Emotion paths. + +--- + +## Shared prerequisite β€” Component CSS conversion _(blocks both non-Emotion paths)_ + +Both non-Emotion paths require components to have their styles extracted to co-located `.css` +files. Without this, components have no base styles on the noop engine. This is the largest +body of work and is independent of the provider/static choice. + +### The pattern (established by Slider) + +- `Slider.js` β€” `styled()` calls with empty payloads: `styled('span', { name, slot })({})`. + The engine alias determines at bundle time whether this is Emotion or noop. +- `Slider.css` β€” all base styles inside `@layer mui.default`, using `--mui-*` CSS variables. + Auto-imported via a side-effect import at the top of `Slider.js`. Users never import it manually. +- Both Emotion and noop users get the same visual output from the same component file. + +### CSS file convention + +- `@layer mui.default` wrapper for all rules. +- Class naming: `.MuiX-slotName`, `.MuiX-variantName`, `.Mui-stateClass`. +- Logical properties (`inset-block-start`, `inset-inline-start`) instead of `top`/`left`/`right`/`bottom`. +- Media queries: `forced-colors`, `pointer: coarse`, `prefers-reduced-motion`. +- Print: `print-color-adjust: exact` (not the deprecated `color-adjust`). + +### TODOs β€” component conversion + +- [ ] Verify `Slider.js` is a full drop-in for `SliderEmotion.js` under the Emotion engine. +- [ ] Remove `SliderEmotion.js`, `SliderEmotion.d.ts`, and the `SliderEmotion` re-exports once confirmed. +- [ ] Convert `Button` as the second pilot (variants Γ— colors Γ— states, ripple, focus-visible). +- [ ] Capture a repeatable conversion checklist from the Slider + Button work. +- [ ] Inventory remaining components and sequence the rollout. + +--- + +## Shared prerequisite β€” Engine alias _(blocks both non-Emotion paths)_ + +Aliasing `@mui/styled-engine` to `@mui/styled-engine-noop` is the single opt-in step for both +non-Emotion paths. The noop engine exists and works. The gap is documentation. + +### TODOs β€” engine alias + +- [ ] Document per-bundler alias config (Vite, webpack, Next.js, esbuild). +- [ ] Decide whether to also offer Option B or Option C for zero-config setup, or defer: + - **Option A (chosen approach, documented in `test/noop-vite-sandbox/vite.config.ts`)** β€” + bundler alias: user adds one alias rule to their bundler config (`@mui/styled-engine` β†’ + `@mui/styled-engine-noop`). Works with any bundler, no import changes required, but requires + touching build config. End-user documentation is still a TODO above. + - **Option B β€” subpath imports** (e.g. `@mui/material/css/Slider`): a separate set of package + subpaths that resolve directly to the noop variant without any bundler config. Zero setup, but + users must change every component import in their app (`/Slider` β†’ `/css/Slider`), making + migration tedious and mixing Emotion and noop imports in the same tree possible by accident. + - **Option C β€” `exports` condition**: `package.json` `exports` supports custom conditions (e.g. + `"mui-noop"`). Users activate it with a single bundler option (e.g. `resolve.conditions: + ['mui-noop']` in Vite), and all `@mui/material` imports automatically resolve to the noop + variant β€” no alias, no import changes. Cleanest DX, but custom conditions have uneven bundler + support and add permanent complexity to the package's `exports` map. +- [ ] Add a CI test: build the non-Emotion entry point, assert **zero** `@emotion/*` modules. +- [ ] Improve the noop engine dev warning: link to the migration guide, fire once per + component type rather than per instance. + +### Done β€” engine alias + +- βœ… `@mui/styled-engine-noop` β€” zero-runtime passthrough, warns on `sx`, stubs `keyframes`/`css`. +- βœ… Confirmed: production build of `test/noop-vite-sandbox` contains zero `@emotion/*` runtime. + (The `__emotion_real` string in the output is a property-name guard in `@mui/system/createStyled`, + not an import.) +- βœ… Two gotchas documented for Vite: `process.env.NODE_ENV` must be defined via `vite.config.ts` + `define`; a JSX-in-`.js` transform plugin is required because MUI source uses `.js` + JSX. + +--- + +## Path A β€” CssThemeProvider _(non-Emotion, runtime)_ + +The Mantine analogy. Users write `createTheme()` in JS, pass it to a provider. The provider +renders CSS variables into the document at runtime β€” no Emotion, no build step. API-identical +to `ThemeProvider` minus `sx` and `styled()`. + +### What users need to do (Path A) + +```tsx +// 1. Alias engine in bundler config (see Β§Engine alias above) + +// 2. Create a theme (must use cssVariables: true) +import { createTheme } from '@mui/material/styles'; +const theme = createTheme({ cssVariables: true, colorSchemes: { light: true, dark: true } }); + +// 3. Wrap the app β€” no ThemeProvider, no Emotion +import { CssThemeProvider } from '@mui/material/styles'; + + + + +// 4. Dark mode toggle +import { useColorScheme } from '@mui/material/styles'; // from CssThemeProvider context +const { mode, setMode } = useColorScheme(); +setMode('dark'); + +// 5. JS theme values (breakpoints, transitions, spacing) +import { useTheme } from '@mui/material/styles'; +const theme = useTheme(); // theme.breakpoints.up('md'), etc. +``` + +SSR flash prevention works the same as with `ThemeProvider`: place `` +in `` before React hydrates. No extra steps specific to `CssThemeProvider` β€” once +`useCurrentColorScheme` is wired inside it with the same default storage keys and attribute, +`InitColorSchemeScript` is automatically compatible. + +### TODOs β€” Path A + +- [ ] Build `CssThemeProvider` as a real provider: + - Wire `useCurrentColorScheme` internally using the same defaults as `ThemeProvider` + (`modeStorageKey: 'mui-mode'`, `colorSchemeStorageKey: 'mui-color-scheme'`, + `attribute: 'data-mui-color-scheme'`) so `InitColorSchemeScript` works out of the box + without any extra user steps. + - Provide a theme context so `useTheme()` works (currently missing despite JSDoc claiming otherwise). + - `disableTransitionOnChange` to suppress CSS transitions during color scheme switch. + - `defaultMode` prop (`'light' | 'dark' | 'system'`). + - `noSsr` prop to skip the hydration-safe default and read localStorage on first render, + matching `CssVarsProvider`'s `noSsr` behavior. +- [ ] Fix `CssVarsInjector` for nested providers: use scoped injection (not a global + ` + + + ); +} diff --git a/test/css-theme-provider-vite-sandbox/src/main.tsx b/test/css-theme-provider-vite-sandbox/src/main.tsx new file mode 100644 index 00000000000000..033af594370fdc --- /dev/null +++ b/test/css-theme-provider-vite-sandbox/src/main.tsx @@ -0,0 +1,14 @@ +import * as React from 'react'; +import * as ReactDOMClient from 'react-dom/client'; +import App from './App'; + +const container = document.getElementById('root'); +if (!container) { + throw new Error('Missing #root element'); +} + +ReactDOMClient.createRoot(container).render( + + + , +); diff --git a/test/css-theme-provider-vite-sandbox/src/vite-env.d.ts b/test/css-theme-provider-vite-sandbox/src/vite-env.d.ts new file mode 100644 index 00000000000000..11f02fe2a0061d --- /dev/null +++ b/test/css-theme-provider-vite-sandbox/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/test/css-theme-provider-vite-sandbox/tsconfig.json b/test/css-theme-provider-vite-sandbox/tsconfig.json new file mode 100644 index 00000000000000..7b1f33c4f6a88a --- /dev/null +++ b/test/css-theme-provider-vite-sandbox/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src", "vite.config.ts"] +} diff --git a/test/css-theme-provider-vite-sandbox/vite.config.ts b/test/css-theme-provider-vite-sandbox/vite.config.ts new file mode 100644 index 00000000000000..e8bec75d11c49e --- /dev/null +++ b/test/css-theme-provider-vite-sandbox/vite.config.ts @@ -0,0 +1,69 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig, transformWithEsbuild } from 'vite'; +import react from '@vitejs/plugin-react'; + +const dirname = path.dirname(fileURLToPath(import.meta.url)); +// Two levels up: test/css-theme-provider-vite-sandbox β†’ test β†’ monorepo root +const MONOREPO_ROOT = path.resolve(dirname, '../..'); + +// https://vite.dev/config/ +// Use the function form so `mode` is available for the NODE_ENV define. +export default defineConfig(({ mode }) => ({ + // MUI source files reference process.env.NODE_ENV for dev-only warnings. + // Vite doesn't polyfill `process` in browser builds, so we define it explicitly. + define: { + 'process.env.NODE_ENV': JSON.stringify(mode === 'production' ? 'production' : 'development'), + }, + plugins: [ + // @mui/material source files use .js extension but contain JSX. + // This plugin re-parses them as JSX so Rollup/esbuild can handle them. + // enforce: 'pre' ensures this plugin runs before Vite's internal esbuild + // pre-transform, which would otherwise reject JSX in .js files. + { + name: 'treat-js-files-as-jsx', + enforce: 'pre' as const, + transform(code, id) { + if (/\/node_modules\//.test(id)) { + return null; + } + if (id.startsWith('\0')) { + return null; + } + if (!/.*\.js$/.test(id)) { + return null; + } + return transformWithEsbuild(code, id, { loader: 'jsx' }); + }, + }, + react(), + ], + resolve: { + alias: [ + // Resolve all @mui/* packages from monorepo source so we test the branch + // code, not a published release. + { + find: '@mui/material', + replacement: path.resolve(MONOREPO_ROOT, 'packages/mui-material/src'), + }, + { + find: '@mui/system', + replacement: path.resolve(MONOREPO_ROOT, 'packages/mui-system/src'), + }, + { + find: '@mui/utils', + replacement: path.resolve(MONOREPO_ROOT, 'packages/mui-utils/src'), + }, + { + find: '@mui/private-theming', + replacement: path.resolve(MONOREPO_ROOT, 'packages/mui-private-theming/src'), + }, + // THE KEY ALIAS: swap Emotion for the zero-runtime noop engine. + // This is exactly what non-Emotion users configure in their own bundler. + { + find: '@mui/styled-engine', + replacement: path.resolve(MONOREPO_ROOT, 'packages/mui-styled-engine-noop/src'), + }, + ], + }, +})); diff --git a/test/emotion-vite-sandbox/README.md b/test/emotion-vite-sandbox/README.md new file mode 100644 index 00000000000000..21aa48fe3ce1c6 --- /dev/null +++ b/test/emotion-vite-sandbox/README.md @@ -0,0 +1,74 @@ +# emotion-vite-sandbox + +A minimal Vite + React app that exercises the **Emotion (default) path** for +Material UI components. + +## Purpose + +Verifies that: + +1. `@mui/styled-engine` resolves to the real Emotion-backed engine (bundle + contains `@emotion/*`). +2. `Slider` renders correctly via `ThemeProvider` + Emotion-generated styles. +3. The `sx` prop works and applies styles at runtime via Emotion. +4. `className`-based overrides work alongside Emotion-generated class names. +5. Dark mode works via `ThemeProvider`'s `colorSchemes` / `CssVarsProvider` + by flipping `data-mui-color-scheme="dark"` on `document.documentElement`. + +### Why only Slider? + +Only `Slider` is used because it is the first component converted to the +CSS-base pattern. It serves as the comparison baseline against +`test/noop-vite-sandbox` β€” same component, same visual output, different +engine. Bundle size delta between the two sandboxes measures the Emotion +overhead. + +## Dev server + +```bash +pnpm -F @mui-internal/emotion-vite-sandbox dev +``` + +Then open . + +## Production build + +```bash +pnpm -F @mui-internal/emotion-vite-sandbox build +``` + +## Verifying Emotion is bundled + +After a production build, confirm `@emotion/*` runtime is present: + +```bash +# Should print matches +grep -r "@emotion" test/emotion-vite-sandbox/dist/ +``` + +## Bundle size comparison + +Build all three sandboxes, then compare JS output sizes to measure the overhead +of each approach: + +```bash +pnpm -F @mui-internal/noop-vite-sandbox build +pnpm -F @mui-internal/css-theme-provider-vite-sandbox build +pnpm -F @mui-internal/emotion-vite-sandbox build + +# Compare JS sizes +ls -lh test/noop-vite-sandbox/dist/assets/*.js +ls -lh test/css-theme-provider-vite-sandbox/dist/assets/*.js +ls -lh test/emotion-vite-sandbox/dist/assets/*.js + +# Compare CSS sizes +ls -lh test/noop-vite-sandbox/dist/assets/*.css +ls -lh test/css-theme-provider-vite-sandbox/dist/assets/*.css +ls -lh test/emotion-vite-sandbox/dist/assets/*.css +``` + +## Relation to the TODO + +This sandbox maps to **Β§8 (Bundle size validation)** in `STYLING_V8_TODO.md`. +It is the Emotion-path counterpart to `test/noop-vite-sandbox`, used to +measure the JS bundle delta between the two engines. diff --git a/test/emotion-vite-sandbox/index.html b/test/emotion-vite-sandbox/index.html new file mode 100644 index 00000000000000..c6217cd200816f --- /dev/null +++ b/test/emotion-vite-sandbox/index.html @@ -0,0 +1,12 @@ + + + + + + MUI emotion Vite sandbox + + +
+ + + diff --git a/test/emotion-vite-sandbox/package.json b/test/emotion-vite-sandbox/package.json new file mode 100644 index 00000000000000..93e2b152f820c0 --- /dev/null +++ b/test/emotion-vite-sandbox/package.json @@ -0,0 +1,28 @@ +{ + "name": "@mui-internal/emotion-vite-sandbox", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.0", + "@mui/material": "workspace:*", + "@mui/system": "workspace:*", + "@mui/utils": "workspace:*", + "@mui/styled-engine": "workspace:*", + "react": "^19.2.6", + "react-dom": "^19.2.6" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^5.1.1", + "typescript": "^6.0.3", + "vite": "^7.3.1" + } +} diff --git a/test/emotion-vite-sandbox/src/App.tsx b/test/emotion-vite-sandbox/src/App.tsx new file mode 100644 index 00000000000000..701deefaf5f4b8 --- /dev/null +++ b/test/emotion-vite-sandbox/src/App.tsx @@ -0,0 +1,97 @@ +/** + * emotion-engine sandbox β€” Slider only. + * + * Mirror of noop-vite-sandbox but using the Emotion engine (the default MUI + * setup). No engine alias is applied β€” @mui/styled-engine resolves to the + * real Emotion-backed package. + * + * Verifies that: + * 1. @mui/styled-engine resolves to the Emotion engine (bundle contains @emotion/*). + * 2. Slider renders correctly via ThemeProvider + Emotion-generated styles. + * 3. The `sx` prop works and applies styles at runtime via Emotion. + * 4. Dark mode works via ThemeProvider's colorSchemes / CssVarsProvider. + * 5. useTheme() returns live JS theme values. + * + * To confirm Emotion IS bundled, run: + * pnpm -F @mui-internal/emotion-vite-sandbox build + * Then: + * grep -r "@emotion" dist/ + * # should print matches + */ +import * as React from 'react'; +import { createTheme, ThemeProvider } from '@mui/material/styles'; +import Slider from '@mui/material/Slider'; + +const theme = createTheme({ + cssVariables: true, + colorSchemes: { light: true, dark: true }, +}); + +type Mode = 'light' | 'dark'; + +export default function App() { + const [value, setValue] = React.useState(40); + const [mode, setMode] = React.useState('light'); + + function toggleMode() { + const next: Mode = mode === 'light' ? 'dark' : 'light'; + document.documentElement.setAttribute('data-mui-color-scheme', next); + setMode(next); + } + + return ( + +
+

emotion-engine sandbox

+

+ Engine: @mui/styled-engine (Emotion) β€” Emotion is bundled here. +

+ + {/* Dark mode toggle */} + + +
+

Slider value: {value}

+ + {/* sx prop β€” works with Emotion, applies color override at runtime */} + setValue(v as number)} + sx={{ color: 'secondary.main' }} + aria-label="Sandbox slider" + /> + +

+ className override (thumb should be purple, overriding the default via plain CSS): +

+ + {/* className override */} + setValue(v as number)} + className="custom-slider" + aria-label="Custom class slider" + /> +
+ + +
+
+ ); +} diff --git a/test/emotion-vite-sandbox/src/main.tsx b/test/emotion-vite-sandbox/src/main.tsx new file mode 100644 index 00000000000000..033af594370fdc --- /dev/null +++ b/test/emotion-vite-sandbox/src/main.tsx @@ -0,0 +1,14 @@ +import * as React from 'react'; +import * as ReactDOMClient from 'react-dom/client'; +import App from './App'; + +const container = document.getElementById('root'); +if (!container) { + throw new Error('Missing #root element'); +} + +ReactDOMClient.createRoot(container).render( + + + , +); diff --git a/test/emotion-vite-sandbox/src/vite-env.d.ts b/test/emotion-vite-sandbox/src/vite-env.d.ts new file mode 100644 index 00000000000000..11f02fe2a0061d --- /dev/null +++ b/test/emotion-vite-sandbox/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/test/emotion-vite-sandbox/tsconfig.json b/test/emotion-vite-sandbox/tsconfig.json new file mode 100644 index 00000000000000..7b1f33c4f6a88a --- /dev/null +++ b/test/emotion-vite-sandbox/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src", "vite.config.ts"] +} diff --git a/test/emotion-vite-sandbox/vite.config.ts b/test/emotion-vite-sandbox/vite.config.ts new file mode 100644 index 00000000000000..21ab7ee68ee16c --- /dev/null +++ b/test/emotion-vite-sandbox/vite.config.ts @@ -0,0 +1,67 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig, transformWithEsbuild } from 'vite'; +import react from '@vitejs/plugin-react'; + +const dirname = path.dirname(fileURLToPath(import.meta.url)); +// Two levels up: test/emotion-vite-sandbox β†’ test β†’ monorepo root +const MONOREPO_ROOT = path.resolve(dirname, '../..'); + +// https://vite.dev/config/ +// Use the function form so `mode` is available for the NODE_ENV define. +export default defineConfig(({ mode }) => ({ + // MUI source files reference process.env.NODE_ENV for dev-only warnings. + // Vite doesn't polyfill `process` in browser builds, so we define it explicitly. + define: { + 'process.env.NODE_ENV': JSON.stringify(mode === 'production' ? 'production' : 'development'), + }, + plugins: [ + // @mui/material source files use .js extension but contain JSX. + // This plugin re-parses them as JSX so Rollup/esbuild can handle them. + // enforce: 'pre' ensures this plugin runs before Vite's internal esbuild + // pre-transform, which would otherwise reject JSX in .js files. + { + name: 'treat-js-files-as-jsx', + enforce: 'pre' as const, + transform(code, id) { + if (/\/node_modules\//.test(id)) { + return null; + } + if (id.startsWith('\0')) { + return null; + } + if (!/.*\.js$/.test(id)) { + return null; + } + return transformWithEsbuild(code, id, { loader: 'jsx' }); + }, + }, + react(), + ], + resolve: { + alias: [ + // Resolve all @mui/* packages from monorepo source so we test the branch + // code, not a published release. + { + find: '@mui/material', + replacement: path.resolve(MONOREPO_ROOT, 'packages/mui-material/src'), + }, + { + find: '@mui/system', + replacement: path.resolve(MONOREPO_ROOT, 'packages/mui-system/src'), + }, + { + find: '@mui/utils', + replacement: path.resolve(MONOREPO_ROOT, 'packages/mui-utils/src'), + }, + { + find: '@mui/private-theming', + replacement: path.resolve(MONOREPO_ROOT, 'packages/mui-private-theming/src'), + }, + { + find: '@mui/styled-engine', + replacement: path.resolve(MONOREPO_ROOT, 'packages/mui-styled-engine/src'), + }, + ], + }, +})); diff --git a/test/noop-vite-sandbox/README.md b/test/noop-vite-sandbox/README.md new file mode 100644 index 00000000000000..e63d6e270ee0b0 --- /dev/null +++ b/test/noop-vite-sandbox/README.md @@ -0,0 +1,119 @@ +# noop-vite-sandbox + +A minimal Vite + React app that exercises the **non-Emotion (CSS-only) path** for +Material UI components. + +## Purpose + +Verifies that: + +1. `@mui/styled-engine` can be aliased to `@mui/styled-engine-noop` with zero + Emotion in the output bundle. +2. `Slider` renders correctly using `Slider.css` + `default-theme.css` (CSS + variables, no runtime theming). +3. The `sx` prop fires a dev-only `console.error` and is otherwise ignored (open + the browser DevTools console to see it). +4. `className`-based overrides beat `@layer mui.default` without any + `!important`. +5. Dark mode works by flipping `data-mui-color-scheme="dark"` on + `document.documentElement` β€” no provider, no React context. + +### Why only Slider? + +Only components that have been converted to the CSS-base pattern are safe to use +here. Non-converted components (e.g. `Box`, `Typography`) still contain +theme-dependent style functions that call `useTheme()` at render time. Without a +`ThemeProvider` in the tree, `useTheme()` returns `undefined` and the first +`theme.palette.*` access throws, crashing the render. As more components are +converted to CSS base styles, they can be added to this sandbox. + +## Dev server + +```bash +pnpm -F @mui-internal/noop-vite-sandbox dev +``` + +Then open . + +## Production build + +```bash +pnpm -F @mui-internal/noop-vite-sandbox build +``` + +Expected output (approximate): + +``` +dist/assets/index-*.css ~32 kB (default-theme.css + Slider.css) +dist/assets/index-*.js ~280 kB (~90 kB gzip) +``` + +## Verifying zero Emotion + +After a production build, check that no `@emotion/*` runtime is bundled: + +```bash +# Should print nothing +grep -r "@emotion/react\|@emotion/styled\|@emotion/cache\|EmotionCacheContext\|insertStyles\|createCache" \ + test/noop-vite-sandbox/dist/ +``` + +The string `__emotion_real` appears once β€” it is a property-name guard inside +`@mui/system/createStyled` (`style.__emotion_real === style`), not an Emotion +package import. It is safe to ignore. + +## How the engine alias works + +`vite.config.ts` uses an **array-form alias** (order matters): + +```ts +alias: [ + // 1. Exact CSS path must come before the package catch-all + { find: '@mui/material/default-theme.css', replacement: '…/packages/mui-material/default-theme.css' }, + // 2. Package source (monorepo) + { find: '@mui/material', replacement: '…/packages/mui-material/src' }, + // 3. THE KEY SWAP β€” replaces Emotion with the zero-runtime noop engine + { find: '@mui/styled-engine', replacement: '…/packages/mui-styled-engine-noop/src' }, + // 4. Other @mui/* from monorepo source + … +] +``` + +This mirrors exactly what a non-Emotion user would add to their own +`vite.config.ts`: + +```ts +// User's project vite.config.ts +resolve: { + alias: { + '@mui/styled-engine': '@mui/styled-engine-noop', + }, +}, +``` + +## Bundle size comparison + +Build all three sandboxes, then compare JS output sizes to measure the overhead +of each approach: + +```bash +pnpm -F @mui-internal/noop-vite-sandbox build +pnpm -F @mui-internal/css-theme-provider-vite-sandbox build +pnpm -F @mui-internal/emotion-vite-sandbox build + +# Compare JS sizes +ls -lh test/noop-vite-sandbox/dist/assets/*.js +ls -lh test/css-theme-provider-vite-sandbox/dist/assets/*.js +ls -lh test/emotion-vite-sandbox/dist/assets/*.js + +# Compare CSS sizes +ls -lh test/noop-vite-sandbox/dist/assets/*.css +ls -lh test/css-theme-provider-vite-sandbox/dist/assets/*.css +ls -lh test/emotion-vite-sandbox/dist/assets/*.css +``` + +## Relation to the TODO + +This sandbox maps to **Β§8 (Bundle size validation)** in `STYLING_V8_TODO.md`. +It serves as both a live demo of the non-Emotion path and the harness for +measuring JS bundle deltas between the Emotion and noop engines. diff --git a/test/noop-vite-sandbox/index.html b/test/noop-vite-sandbox/index.html new file mode 100644 index 00000000000000..a9b11af9400052 --- /dev/null +++ b/test/noop-vite-sandbox/index.html @@ -0,0 +1,12 @@ + + + + + + MUI noop-engine Vite sandbox + + +
+ + + diff --git a/test/noop-vite-sandbox/package.json b/test/noop-vite-sandbox/package.json new file mode 100644 index 00000000000000..66cfd5bb21e156 --- /dev/null +++ b/test/noop-vite-sandbox/package.json @@ -0,0 +1,26 @@ +{ + "name": "@mui-internal/noop-vite-sandbox", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@mui/material": "workspace:*", + "@mui/system": "workspace:*", + "@mui/utils": "workspace:*", + "@mui/styled-engine-noop": "workspace:*", + "react": "^19.2.6", + "react-dom": "^19.2.6" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^5.1.1", + "typescript": "^6.0.3", + "vite": "^7.3.1" + } +} diff --git a/test/noop-vite-sandbox/src/App.tsx b/test/noop-vite-sandbox/src/App.tsx new file mode 100644 index 00000000000000..18a2e70280f7e0 --- /dev/null +++ b/test/noop-vite-sandbox/src/App.tsx @@ -0,0 +1,104 @@ +/** + * noop-engine sandbox β€” Slider only. + * + * Only Slider is used here because it is the only component converted to the + * CSS-base pattern so far. Other components (Box, Typography, etc.) still use + * theme-dependent style functions that call useTheme() at render time. Without + * a ThemeProvider in the tree, those calls return undefined and crash the + * render. Stick to Slider until more components are converted. + * + * Verifies that: + * 1. @mui/styled-engine is aliased to @mui/styled-engine-noop (no Emotion). + * 2. Slider renders correctly using Slider.css + default-theme.css (CSS vars). + * 3. The `sx` prop fires a console.error in dev and is otherwise ignored + * (open the browser DevTools console to see the warning). + * 4. className-based overrides beat @layer mui.default without !important. + * 5. Dark mode works by toggling data-mui-color-scheme on . + * + * To confirm no Emotion is bundled, run: + * pnpm -F @mui-internal/noop-vite-sandbox build + * Then: + * grep -r "@emotion/react\|@emotion/styled\|@emotion/cache\|EmotionCacheContext" dist/ + * # should print nothing + */ +import * as React from 'react'; + +// No ThemeProvider. This CSS file provides all --mui-* custom properties. +// Generated by: pnpm -F @mui/material build:theme-css +import '@mui/material/default-theme.css'; + +// Only import components that have been converted to the CSS-base pattern. +import Slider from '@mui/material/Slider'; + +type Mode = 'light' | 'dark'; + +export default function App() { + const [value, setValue] = React.useState(40); + const [mode, setMode] = React.useState('light'); + + function toggleMode() { + const next: Mode = mode === 'light' ? 'dark' : 'light'; + document.documentElement.setAttribute('data-mui-color-scheme', next); + setMode(next); + } + + return ( +
+

noop-engine sandbox

+

+ Engine: @mui/styled-engine-noop β€” no Emotion in this bundle. +

+ + {/* Dark mode toggle β€” pure DOM attribute flip, no ThemeProvider */} + + +
+

Slider value: {value}

+ + {/* + sx prop intentionally passed to trigger the noop dev warning. + Open the browser console β€” you should see: + "MUI: The `sx` prop was used on but `@mui/styled-engine-noop` is active." + */} + setValue(v as number)} + // @ts-ignore β€” sx is valid on Slider types but deliberately passed to trigger the noop warning + sx={{ color: 'red' }} + aria-label="Sandbox slider" + /> + +

+ className override (thumb should be purple, overriding the default via + plain CSS that beats @layer mui.default): +

+ + {/* className override β€” unlayered CSS always wins over @layer mui.default */} + setValue(v as number)} + className="custom-slider" + aria-label="Custom class slider" + /> +
+ + +
+ ); +} diff --git a/test/noop-vite-sandbox/src/main.tsx b/test/noop-vite-sandbox/src/main.tsx new file mode 100644 index 00000000000000..033af594370fdc --- /dev/null +++ b/test/noop-vite-sandbox/src/main.tsx @@ -0,0 +1,14 @@ +import * as React from 'react'; +import * as ReactDOMClient from 'react-dom/client'; +import App from './App'; + +const container = document.getElementById('root'); +if (!container) { + throw new Error('Missing #root element'); +} + +ReactDOMClient.createRoot(container).render( + + + , +); diff --git a/test/noop-vite-sandbox/src/vite-env.d.ts b/test/noop-vite-sandbox/src/vite-env.d.ts new file mode 100644 index 00000000000000..726765f1199e0c --- /dev/null +++ b/test/noop-vite-sandbox/src/vite-env.d.ts @@ -0,0 +1,2 @@ +// Allows `import '*.css'` without TypeScript errors. +declare module '*.css' {} diff --git a/test/noop-vite-sandbox/tsconfig.json b/test/noop-vite-sandbox/tsconfig.json new file mode 100644 index 00000000000000..7b1f33c4f6a88a --- /dev/null +++ b/test/noop-vite-sandbox/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src", "vite.config.ts"] +} diff --git a/test/noop-vite-sandbox/vite.config.ts b/test/noop-vite-sandbox/vite.config.ts new file mode 100644 index 00000000000000..a807346f97e33a --- /dev/null +++ b/test/noop-vite-sandbox/vite.config.ts @@ -0,0 +1,77 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig, transformWithEsbuild } from 'vite'; +import react from '@vitejs/plugin-react'; + +const dirname = path.dirname(fileURLToPath(import.meta.url)); +// Two levels up: test/noop-vite-sandbox β†’ test β†’ monorepo root +const MONOREPO_ROOT = path.resolve(dirname, '../..'); + +// https://vite.dev/config/ +// Use the function form so `mode` is available for the NODE_ENV define. +export default defineConfig(({ mode }) => ({ + // MUI source files reference process.env.NODE_ENV for dev-only warnings. + // Vite doesn't polyfill `process` in browser builds, so we define it explicitly. + define: { + 'process.env.NODE_ENV': JSON.stringify(mode === 'production' ? 'production' : 'development'), + }, + plugins: [ + // @mui/material source files use .js extension but contain JSX. + // This plugin re-parses them as JSX so Rollup/esbuild can handle them. + // transformWithEsbuild must be imported at the top level β€” a dynamic + // import inside the transform hook breaks the Vite dev server. + // enforce: 'pre' ensures this plugin runs before Vite's internal esbuild + // pre-transform, which would otherwise reject JSX in .js files. + { + name: 'treat-js-files-as-jsx', + enforce: 'pre' as const, + transform(code, id) { + if (/\/node_modules\//.test(id)) { + return null; + } + if (id.startsWith('\0')) { + return null; + } + if (!/.*\.js$/.test(id)) { + return null; + } + return transformWithEsbuild(code, id, { loader: 'jsx' }); + }, + }, + react(), + ], + resolve: { + alias: [ + // CSS entry must come before the @mui/material package alias so the + // catch-all doesn't rewrite it to packages/mui-material/src/default-theme.css + { + find: '@mui/material/default-theme.css', + replacement: path.resolve(MONOREPO_ROOT, 'packages/mui-material/default-theme.css'), + }, + // Resolve all @mui/* packages from monorepo source so we test the branch + // code, not a published release. + { + find: '@mui/material', + replacement: path.resolve(MONOREPO_ROOT, 'packages/mui-material/src'), + }, + { + find: '@mui/system', + replacement: path.resolve(MONOREPO_ROOT, 'packages/mui-system/src'), + }, + { + find: '@mui/utils', + replacement: path.resolve(MONOREPO_ROOT, 'packages/mui-utils/src'), + }, + { + find: '@mui/private-theming', + replacement: path.resolve(MONOREPO_ROOT, 'packages/mui-private-theming/src'), + }, + // THE KEY ALIAS: swap Emotion for the zero-runtime noop engine. + // This is exactly what non-Emotion users configure in their own bundler. + { + find: '@mui/styled-engine', + replacement: path.resolve(MONOREPO_ROOT, 'packages/mui-styled-engine-noop/src'), + }, + ], + }, +})); diff --git a/tsconfig.json b/tsconfig.json index 11af6fb5ec18bb..4277b51acfe290 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,6 +26,10 @@ "@mui/internal-markdown/prism": ["./packages-internal/markdown/prism.mjs"], "@mui/styled-engine": ["./packages/mui-styled-engine/src"], "@mui/styled-engine/*": ["./packages/mui-styled-engine/src/*"], + "@mui/styled-engine-noop": ["./packages/mui-styled-engine-noop/src"], + "@mui/styled-engine-noop/*": ["./packages/mui-styled-engine-noop/src/*"], + "@mui/tailwind": ["./packages/mui-tailwind/src/preset.js"], + "@mui/tailwind/*": ["./packages/mui-tailwind/src/*"], "@mui/styled-engine-sc": ["./packages/mui-styled-engine-sc/src"], "@mui/styled-engine-sc/*": ["./packages/mui-styled-engine-sc/src/*"], "@mui/system": ["./packages/mui-system/src"],