From 8bb81bcaad8fcf3735ddb5f2653d71fc1747b15f Mon Sep 17 00:00:00 2001 From: mnajdova Date: Wed, 13 Nov 2024 17:21:52 +0100 Subject: [PATCH 001/130] [poc] Use noop package for styling purposes --- packages/mui-icons-material/test/generated-types/tsconfig.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/mui-icons-material/test/generated-types/tsconfig.json b/packages/mui-icons-material/test/generated-types/tsconfig.json index 48346071bcbb3d..5c2f3db9d9d1b0 100644 --- a/packages/mui-icons-material/test/generated-types/tsconfig.json +++ b/packages/mui-icons-material/test/generated-types/tsconfig.json @@ -33,6 +33,8 @@ "@mui/styled-engine-sc/*": ["./mui-styled-engine-sc/src/*"], "@mui/styled-engine": ["./mui-styled-engine/src"], "@mui/styled-engine/*": ["./mui-styled-engine/src/*"], + "@mui/styled-engine-noop": ["./mui-styled-engine-noop/src"], + "@mui/styled-engine-noop/*": ["./mui-styled-engine-noop/src/*"], "@mui/styles": ["./mui-styles/src"], "@mui/styles/*": ["./mui-styles/src/*"], "@mui/system": ["./mui-system/src"], From d909c66271b1aeab5fce02306e83c33abd3c45ba Mon Sep 17 00:00:00 2001 From: mnajdova Date: Wed, 13 Nov 2024 17:22:20 +0100 Subject: [PATCH 002/130] [poc] Use noop package for styling purposes --- .codesandbox/ci.json | 2 + babel.config.js | 1 + docs/next.config.mjs | 1 + docs/package.json | 1 + docs/pages/index.tsx | 69 +------------------ packages/mui-material/src/Header/Header.js | 12 ++++ .../mui-material/src/Header/Header.module.css | 3 + packages/mui-material/src/Header/index.js | 2 + packages/mui-styled-engine-noop/README.md | 11 +++ packages/mui-styled-engine-noop/package.json | 66 ++++++++++++++++++ .../src/GlobalStyles/GlobalStyles.d.ts | 11 +++ .../src/GlobalStyles/GlobalStyles.js | 6 ++ .../src/GlobalStyles/index.d.ts | 2 + .../src/GlobalStyles/index.js | 1 + .../StyledEngineProvider.d.ts | 8 +++ .../StyledEngineProvider.js | 7 ++ .../src/StyledEngineProvider/index.d.ts | 2 + .../src/StyledEngineProvider/index.js | 1 + .../mui-styled-engine-noop/src/index.d.ts | 57 +++++++++++++++ packages/mui-styled-engine-noop/src/index.js | 31 +++++++++ packages/mui-styled-engine-noop/tsconfig.json | 4 ++ packages/mui-system/package.json | 2 +- .../src/Container/createContainer.tsx | 2 +- .../src/GlobalStyles/GlobalStyles.tsx | 2 +- .../src/ThemeProvider/ThemeProvider.js | 2 +- .../src/ThemeProvider/ThemeProvider.test.js | 2 +- .../src/breakpoints/breakpoints.d.ts | 2 +- .../mui-system/src/createBox/createBox.js | 2 +- .../src/createStyled/createStyled.d.ts | 2 +- .../src/createStyled/createStyled.js | 2 +- .../mui-system/src/createTheme/applyStyles.ts | 2 +- .../src/createTheme/createTheme.d.ts | 2 +- .../src/cssVars/createCssVarsProvider.js | 2 +- packages/mui-system/src/index.d.ts | 2 +- packages/mui-system/src/index.js | 2 +- packages/mui-system/src/memoTheme.ts | 2 +- packages/mui-system/src/preprocessStyles.ts | 2 +- packages/mui-system/src/style/style.d.ts | 2 +- .../src/styleFunctionSx/styleFunctionSx.d.ts | 2 +- .../useThemeWithoutDefault.js | 2 +- pnpm-lock.yaml | 45 +++++++++++- tsconfig.json | 2 + webpackBaseConfig.js | 1 + 43 files changed, 297 insertions(+), 87 deletions(-) create mode 100644 packages/mui-material/src/Header/Header.js create mode 100644 packages/mui-material/src/Header/Header.module.css create mode 100644 packages/mui-material/src/Header/index.js create mode 100644 packages/mui-styled-engine-noop/README.md create mode 100644 packages/mui-styled-engine-noop/package.json create mode 100644 packages/mui-styled-engine-noop/src/GlobalStyles/GlobalStyles.d.ts create mode 100644 packages/mui-styled-engine-noop/src/GlobalStyles/GlobalStyles.js create mode 100644 packages/mui-styled-engine-noop/src/GlobalStyles/index.d.ts create mode 100644 packages/mui-styled-engine-noop/src/GlobalStyles/index.js create mode 100644 packages/mui-styled-engine-noop/src/StyledEngineProvider/StyledEngineProvider.d.ts create mode 100644 packages/mui-styled-engine-noop/src/StyledEngineProvider/StyledEngineProvider.js create mode 100644 packages/mui-styled-engine-noop/src/StyledEngineProvider/index.d.ts create mode 100644 packages/mui-styled-engine-noop/src/StyledEngineProvider/index.js create mode 100644 packages/mui-styled-engine-noop/src/index.d.ts create mode 100644 packages/mui-styled-engine-noop/src/index.js create mode 100644 packages/mui-styled-engine-noop/tsconfig.json diff --git a/.codesandbox/ci.json b/.codesandbox/ci.json index 2881171258056b..d93e7589516476 100644 --- a/.codesandbox/ci.json +++ b/.codesandbox/ci.json @@ -15,6 +15,7 @@ "packages/mui-material-pigment-css", "packages/mui-private-theming", "packages/mui-styled-engine-sc", + "packages/mui-styled-engine-noop", "packages/mui-styled-engine", "packages/mui-styles", "packages/mui-system", @@ -42,6 +43,7 @@ "@mui/material-pigment-css": "packages/mui-material-pigment-css/build", "@mui/private-theming": "packages/mui-private-theming/build", "@mui/styled-engine": "packages/mui-styled-engine/build", + "@mui/styled-engine-noop": "packages/mui-styled-engine-noop/build", "@mui/styled-engine-sc": "packages/mui-styled-engine-sc/build", "@mui/styles": "packages/mui-styles/build", "@mui/system": "packages/mui-system/build", diff --git a/babel.config.js b/babel.config.js index 0b0e796d325601..9f69ca9f1b7136 100644 --- a/babel.config.js +++ b/babel.config.js @@ -35,6 +35,7 @@ module.exports = function getBabelConfig(api) { '@mui/lab': resolveAliasPath('./packages/mui-lab/src'), '@mui/internal-markdown': resolveAliasPath('./packages/markdown'), '@mui/styled-engine': resolveAliasPath('./packages/mui-styled-engine/src'), + '@mui/styled-engine-noop': resolveAliasPath('./packages/mui-styled-engine-noop/src'), '@mui/styled-engine-sc': resolveAliasPath('./packages/mui-styled-engine-sc/src'), '@mui/styles': resolveAliasPath('./packages/mui-styles/src'), '@mui/system': resolveAliasPath('./packages/mui-system/src'), diff --git a/docs/next.config.mjs b/docs/next.config.mjs index aaf5919d1c1f26..7f204d5b04cc40 100644 --- a/docs/next.config.mjs +++ b/docs/next.config.mjs @@ -111,6 +111,7 @@ export default withDocsInfra({ '@mui/icons-material': path.resolve(workspaceRoot, 'packages/mui-icons-material/lib/esm'), '@mui/lab': path.resolve(workspaceRoot, 'packages/mui-lab/src'), '@mui/styled-engine': path.resolve(workspaceRoot, 'packages/mui-styled-engine/src'), + '@mui/styled-engine-noop': path.resolve(workspaceRoot, 'packages/mui-styled-engine-noop/src'), '@mui/styles': path.resolve(workspaceRoot, 'packages/mui-styles/src'), '@mui/system': path.resolve(workspaceRoot, 'packages/mui-system/src'), '@mui/private-theming': path.resolve(workspaceRoot, 'packages/mui-private-theming/src'), diff --git a/docs/package.json b/docs/package.json index d7e31250766e9a..3b3863ef13bc26 100644 --- a/docs/package.json +++ b/docs/package.json @@ -37,6 +37,7 @@ "@mui/joy": "workspace:*", "@mui/lab": "workspace:*", "@mui/material": "workspace:^", + "@mui/styled-engine-noop": "workspace:^", "@mui/styled-engine": "workspace:^", "@mui/styled-engine-sc": "workspace:^", "@mui/styles": "workspace:^", diff --git a/docs/pages/index.tsx b/docs/pages/index.tsx index 0bb1798a14d507..6317724f2b687f 100644 --- a/docs/pages/index.tsx +++ b/docs/pages/index.tsx @@ -1,71 +1,8 @@ import * as React from 'react'; -import NoSsr from '@mui/material/NoSsr'; -import Divider from '@mui/material/Divider'; -import Head from 'docs/src/modules/components/Head'; -import AppHeader from 'docs/src/layouts/AppHeader'; -import Hero from 'docs/src/components/home/Hero'; -import References, { CORE_CUSTOMERS } from 'docs/src/components/home/References'; -import ProductSuite from 'docs/src/components/home/ProductSuite'; -import ValueProposition from 'docs/src/components/home/ValueProposition'; -import DesignSystemComponents from 'docs/src/components/home/DesignSystemComponents'; -import Testimonials from 'docs/src/components/home/Testimonials'; -import Sponsors from 'docs/src/components/home/Sponsors'; -import HeroEnd from 'docs/src/components/home/HeroEnd'; -import AppFooter from 'docs/src/layouts/AppFooter'; -import BrandingCssVarsProvider from 'docs/src/BrandingCssVarsProvider'; -import NewsletterToast from 'docs/src/components/home/NewsletterToast'; -import AppHeaderBanner from 'docs/src/components/banner/AppHeaderBanner'; +import Header from '@mui/material/Header'; export default function Home() { return ( - - - + + 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..905036bd7f9baa --- /dev/null +++ b/test/noop-vite-sandbox/vite.config.ts @@ -0,0 +1,74 @@ +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. + { + name: 'treat-js-files-as-jsx', + 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'), + }, + ], + }, +})); From 141ee379cfbe5709d3e0093599d6d6d6ccabe467 Mon Sep 17 00:00:00 2001 From: Silviu Alexandru Avram Date: Sat, 6 Jun 2026 12:47:31 +0300 Subject: [PATCH 126/130] refresh todo list --- STYLING_V8_TODO.md | 355 +++++++++++++++++++++++++++------------------ 1 file changed, 210 insertions(+), 145 deletions(-) diff --git a/STYLING_V8_TODO.md b/STYLING_V8_TODO.md index ee368211f4276b..e5d737009c2109 100644 --- a/STYLING_V8_TODO.md +++ b/STYLING_V8_TODO.md @@ -2,212 +2,277 @@ ## 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 **both** consumers: - -- **Emotion users** — keep using `sx` / `styled()` for _further_ customization on top of the CSS base. **No breaking changes.** Everything that works today must keep working. -- **Non-Emotion users** — customize via plain CSS / Tailwind / CSS Modules on top of the same CSS base. The **only** opt-in requirement is aliasing `@mui/styled-engine` to `@mui/styled-engine-noop` in the bundler. - -Both paths must render identical base styling. The **non-Emotion path must never import Emotion** (no `@emotion/react`, `@emotion/styled`, `@emotion/cache`, `@emotion/serialize` anywhere in its module graph). - -### Breaking changes policy - -- **Emotion path:** zero breaking changes. Existing `ThemeProvider`, `sx`, `styled()`, `useColorScheme`, `getInitColorSchemeScript`, custom themes — all unchanged. -- **Non-Emotion path:** users explicitly opt in by setting the engine alias. Within that path, the only intentional limitations are: `sx` is ignored (with a dev warning), and `useColorScheme` / `getInitColorSchemeScript` are not available from `@mui/material` (they pull in Emotion). All other component APIs — props, slots, `slotProps`, `className` — work identically. +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. --- -## Architecture summary - -### Two paths, not three - -The architecture is two paths. `CssThemeProvider` is **removed** from the non-Emotion path. The reason: it does not deliver a theme context (no `useTheme()` despite JSDoc claiming otherwise), has no color scheme switching, no `useColorScheme()`, and no SSR flash prevention — meaning every feature it is missing would have to be rebuilt from scratch, duplicating `ThemeProvider`. The value proposition does not hold up. - -| | **Emotion path** | **Non-Emotion path** | -|---|---|---| -| Engine | `@mui/styled-engine` (Emotion) | `@mui/styled-engine-noop` | -| Base styles | `Component.css` (`@layer mui.default`) | same | -| Theme provider | `ThemeProvider` | none required, or `ThemeProvider` | -| Custom theme | `createTheme()` → `ThemeProvider` | `createTheme({ cssVariables: true })` → `generateThemeCss()` → import `.css` | -| Color scheme | `useColorScheme()`, localStorage | standalone `useColorScheme` hook (no Emotion), `ColorSchemeScript` | -| Dark mode | `data-mui-color-scheme` attribute (via ThemeProvider) | same attribute, toggled by the standalone hook | -| `sx` prop | ✅ full runtime evaluation | ❌ ignored with dev warning | -| `styled()` overrides | ✅ | ❌ (use `className`, Tailwind, CSS) | -| Custom theme at runtime | `ThemeProvider theme={}` | not supported — theme is CSS-only, no runtime JS theme object | - -### Non-Emotion user setup steps - -Users switching to the non-Emotion path need to do the following. Several pieces already exist today (marked ✅); the rest are gaps (marked ❌). +## Path overview -#### Step 1 — Swap the engine (required) - -Alias `@mui/styled-engine` to `@mui/styled-engine-noop` in the bundler. There are three options: - -| Option | Mechanism | User impact | Status | +| | **Emotion path** | **Path A — CssThemeProvider** | **Path B — Static CSS** | |---|---|---|---| -| **A** (recommended for POC) | Bundler alias | No import changes; transparent to all component imports | ✅ engine exists, ❌ no per-bundler docs | -| **B** | Separate subpath e.g. `@mui/material/css` | Requires changing every component import | ❌ not built | -| **C** | `package.json` `exports` condition | Requires bundler condition config; less universal | ❌ not built | +| 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. -Option A is the only one implemented in this POC and the only one documented here until a decision is made. +--- -#### Step 2 — Import the theme CSS (required) +## Shared prerequisite — Component CSS conversion _(blocks both non-Emotion paths)_ -```js -import '@mui/material/default-theme.css'; -``` +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. -`./default-theme.css` is already a named export in `@mui/material/package.json`. ✅ The file is pre-generated and committed. +### The pattern (established by Slider) -Component `.css` files (e.g. `Slider.css`) are **auto-imported** by each component JS file via a side-effect import. Users do not need to import them manually. ✅ +- `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. -#### Step 3 — SSR flash prevention (recommended if using SSR) +### CSS file convention -Add `` to ``. This already exists, is already Emotion-free, and is already exported from `@mui/material`. It uses the same `data-mui-color-scheme` attribute and same localStorage keys (`mui-mode`, `mui-color-scheme`) as `default-theme.css`. ✅ No new component needed — it just needs to be documented as standalone. +- `@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`). -#### Step 4 — Dark mode toggle +### TODOs — component conversion -Set `data-mui-color-scheme="dark"` on `document.documentElement`. No React state, no provider. +- [ ] 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. -The existing `useColorScheme()` from `@mui/material` reads React context set up by `ThemeProvider`. Non-Emotion users never render `ThemeProvider`, so the hook returns nothing. ❌ A standalone `useColorScheme` hook is needed — DOM-based, no context, no Emotion. +--- -#### Step 5 — Custom theme (optional) +## Shared prerequisite — Engine alias _(blocks both non-Emotion paths)_ -Generate a custom theme CSS file at build time and import it instead of `default-theme.css`: +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. -```js -// my-theme.css — generated at build time -import './my-theme.css'; -``` +### TODOs — engine alias -`buildDefaultThemeCss.mts` exists as an internal build script but is not a public API. ❌ A `generateThemeCss(themeOptions)` helper needs to be extracted and published. +- [ ] Document per-bundler alias config (Vite, webpack, Next.js, esbuild). +- [ ] Decide whether to also offer Option B (subpath e.g. `@mui/material/css/Slider`) or + Option C (`exports` condition) for zero-config setup, or defer. +- [ ] 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. -`CssThemeProvider` is **not** part of any step. It is removed. +### Done — engine alias -### What happens to ThemeProvider for non-Emotion users? +- ✅ `@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. -`ThemeProvider` is Emotion-coupled (it calls `styleFunctionSx`, `resolveTheme`, and wires `sx` runtime evaluation). Non-Emotion users do not use it. If they somehow pass a `ThemeProvider` wrapper, components will render fine — the CSS base styles are independent of the theme context — but `useColorScheme()` and `sx` will still be Emotion features. +--- -### Single component file per component +## Path A — CssThemeProvider _(non-Emotion, runtime)_ -`styled()` calls carry empty payloads: `styled('span', { name, slot })({})`. All base styling lives in a co-located `.css` file. The engine alias determines at bundle time which `styled` implementation is used — Emotion or noop. `SliderEmotion.js` is kept temporarily as a reference and will be removed once `Slider.js` is confirmed to be a full drop-in. +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) -## Comparison with Mantine and Radix Themes +```tsx +// 1. Alias engine in bundler config (see §Engine alias above) -### Mantine +// 2. Create a theme (must use cssVariables: true) +import { createTheme } from '@mui/material/styles'; +const theme = createTheme({ cssVariables: true, colorSchemes: { light: true, dark: true } }); -Mantine moved entirely away from CSS-in-JS in v7 (2023). All component styles are plain **CSS Modules** compiled at build time. Theme tokens are CSS custom properties injected by `MantineProvider`. The optional `@layer mantine` wrapper (`styles.layer.css`) solves the same import-order problem MUI's `@layer mui.default` solves. +// 3. Wrap the app — no ThemeProvider, no Emotion +import { CssThemeProvider } from '@mui/material/styles'; + + + -**Key difference:** Mantine's CSS Modules are scoped and hashed — no stable `.Mantine*` class names as a customization surface. MUI keeps stable `.Mui*` names on purpose; `@layer` preserves this contract while solving import-order. +// 4. SSR flash prevention (Next.js / Remix — put in ) +import InitColorSchemeScript from '@mui/material/InitColorSchemeScript'; + -### Radix Themes +// 5. Dark mode toggle +import { useColorScheme } from '@mui/material/styles'; // from CssThemeProvider context +const { mode, setMode } = useColorScheme(); +setMode('dark'); -Radix Themes ships **all styles as static CSS files** — no runtime, no CSS-in-JS. The full token system is CSS variables defined by the `` component's class. No `sx`, no runtime theming, fixed token space. +// 6. JS theme values (breakpoints, transitions, spacing) +import { useTheme } from '@mui/material/styles'; +const theme = useTheme(); // theme.breakpoints.up('md'), etc. +``` -**Key difference:** Radix Themes is entirely static. MUI's `default-theme.css` covers the zero-runtime case, while `generateThemeCss()` + the standalone hook cover custom themes and dark mode — without any JS theming engine. +### TODOs — Path A -### MUI's advantage +- [ ] Build `CssThemeProvider` as a real provider: + - Provide a theme context so `useTheme()` works (currently missing despite JSDoc claiming otherwise). + - Wire `useColorScheme()` — manage mode state, read/write `data-mui-color-scheme` attribute, + persist to localStorage, subscribe to `prefers-color-scheme` for `system` mode. + - `disableTransitionOnChange` to suppress CSS transitions during color scheme switch. + - `defaultMode` prop (`'light' | 'dark' | 'system'`). +- [ ] 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 index 818a7bcfab6ab4..e63d6e270ee0b0 100644 --- a/test/noop-vite-sandbox/README.md +++ b/test/noop-vite-sandbox/README.md @@ -91,13 +91,29 @@ resolve: { }, ``` +## 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. - -To compare against the Emotion path, a second sandbox -(`test/emotion-vite-sandbox`) would use the standard alias -(`@mui/styled-engine` → `packages/mui-styled-engine/src`) plus -`@emotion/react` and `@emotion/styled` as dependencies. diff --git a/test/noop-vite-sandbox/vite.config.ts b/test/noop-vite-sandbox/vite.config.ts index 905036bd7f9baa..a807346f97e33a 100644 --- a/test/noop-vite-sandbox/vite.config.ts +++ b/test/noop-vite-sandbox/vite.config.ts @@ -20,8 +20,11 @@ export default defineConfig(({ mode }) => ({ // 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;