diff --git a/examples/nextjs-node-esm-ssr/.gitignore b/examples/nextjs-node-esm-ssr/.gitignore new file mode 100644 index 00000000000000..4b805353335b81 --- /dev/null +++ b/examples/nextjs-node-esm-ssr/.gitignore @@ -0,0 +1,4 @@ +node_modules +.next +next-env.d.ts +*.tsbuildinfo diff --git a/examples/nextjs-node-esm-ssr/.stackblitzrc b/examples/nextjs-node-esm-ssr/.stackblitzrc new file mode 100644 index 00000000000000..8812c06a591915 --- /dev/null +++ b/examples/nextjs-node-esm-ssr/.stackblitzrc @@ -0,0 +1,3 @@ +{ + "startCommand": "npm run build && npm run start" +} diff --git a/examples/nextjs-node-esm-ssr/README.md b/examples/nextjs-node-esm-ssr/README.md new file mode 100644 index 00000000000000..84e375bac3bfbf --- /dev/null +++ b/examples/nextjs-node-esm-ssr/README.md @@ -0,0 +1,24 @@ +# Material UI - Next.js Node ESM SSR example + +Minimal Next.js Pages Router app for checking issue 48636. The server render +loads `@mui/material` through `@mui/x-date-pickers`, using Node's ESM package +resolution path. + +## How to use + +From the repository root: + +```bash +pnpm --dir examples/nextjs-node-esm-ssr install --ignore-workspace +pnpm --dir examples/nextjs-node-esm-ssr build +pnpm --dir examples/nextjs-node-esm-ssr start +``` + +or: + +[![Edit on StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/mj12albert/material-ui/tree/examples-rtg-esm-fix/examples/nextjs-node-esm-ssr) + +Open http://localhost:3000. The build and page render should complete without +`ERR_UNSUPPORTED_DIR_IMPORT`. + +This example installs `@mui/material` from the PR 48645 package preview. diff --git a/examples/nextjs-node-esm-ssr/next.config.mjs b/examples/nextjs-node-esm-ssr/next.config.mjs new file mode 100644 index 00000000000000..d97a3ac2772fff --- /dev/null +++ b/examples/nextjs-node-esm-ssr/next.config.mjs @@ -0,0 +1,21 @@ +// No configuration is needed: the Pages Router server build keeps +// `node_modules` packages external, so at render time Node's own resolver +// loads `@mui/x-date-pickers` and, transitively, `@mui/material`'s published +// files. On current Node versions that resolution uses the package's `import` +// conditions — the exact path that failed with ERR_UNSUPPORTED_DIR_IMPORT in +// https://github.com/mui/material-ui/issues/48636. +// +// Why this example does not externalize `@mui/material` directly: Next.js +// includes it in the built-in `experimental.optimizePackageImports` list, so +// direct imports of it are always bundled, and listing it in +// `serverExternalPackages` is rejected with a `transpilePackages` conflict. +// In the App Router, ESM externals also load without Next.js's React +// require-hook aliasing, so React component libraries cannot be +// server-external there at all. The Pages Router with a server-external +// dependent package, like `@mui/x-date-pickers` here, is the setup that still +// reaches `@mui/material` through Node. + +/** @type {import('next').NextConfig} */ +const nextConfig = {}; + +export default nextConfig; diff --git a/examples/nextjs-node-esm-ssr/package.json b/examples/nextjs-node-esm-ssr/package.json new file mode 100644 index 00000000000000..3a464101cb22fa --- /dev/null +++ b/examples/nextjs-node-esm-ssr/package.json @@ -0,0 +1,23 @@ +{ + "name": "nextjs-node-esm-ssr", + "private": true, + "scripts": { + "build": "next build", + "start": "next start" + }, + "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", + "@mui/material": "https://pkg.pr.new/mui/material-ui/@mui/material@90aef5f59695faaed739e8a394281a03c5741aed", + "@mui/x-date-pickers": "^9.5.0", + "dayjs": "^1.11.13", + "next": "^16.0.7", + "react": "^19.2.6", + "react-dom": "^19.2.6" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/react": "^19.2.14", + "typescript": "^5.9.3" + } +} diff --git a/examples/nextjs-node-esm-ssr/pages/index.tsx b/examples/nextjs-node-esm-ssr/pages/index.tsx new file mode 100644 index 00000000000000..201ab84e68c2e9 --- /dev/null +++ b/examples/nextjs-node-esm-ssr/pages/index.tsx @@ -0,0 +1,30 @@ +import * as React from 'react'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import { DatePicker } from '@mui/x-date-pickers/DatePicker'; +import dayjs from 'dayjs'; + +// Server-render the page on every request so `next start` exercises the same +// Node module resolution path on each reload. +export function getServerSideProps() { + return { props: {} }; +} + +export default function Home() { + return ( + + + + Next.js Node ESM SSR with Material UI + + + The date picker reaches Material UI transition internals through the server-external + @mui/x-date-pickers package. + + + + + ); +} diff --git a/examples/nextjs-node-esm-ssr/tsconfig.json b/examples/nextjs-node-esm-ssr/tsconfig.json new file mode 100644 index 00000000000000..d73edcaea03078 --- /dev/null +++ b/examples/nextjs-node-esm-ssr/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ] + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": ["node_modules"] +} diff --git a/examples/node-esm-ssr/.gitignore b/examples/node-esm-ssr/.gitignore new file mode 100644 index 00000000000000..3c3629e647f5dd --- /dev/null +++ b/examples/node-esm-ssr/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/examples/node-esm-ssr/.stackblitzrc b/examples/node-esm-ssr/.stackblitzrc new file mode 100644 index 00000000000000..ccc731a8edfc40 --- /dev/null +++ b/examples/node-esm-ssr/.stackblitzrc @@ -0,0 +1,3 @@ +{ + "startCommand": "npm run start" +} diff --git a/examples/node-esm-ssr/README.md b/examples/node-esm-ssr/README.md new file mode 100644 index 00000000000000..cf0d6f7e7d82c7 --- /dev/null +++ b/examples/node-esm-ssr/README.md @@ -0,0 +1,28 @@ +# Material UI - Node ESM SSR example + +Minimal Node ESM server-rendered React app for checking issue 48636. It renders +Material UI transition components without a bundler rewriting package imports. + +## How to use + +From the repository root: + +```bash +pnpm --dir examples/node-esm-ssr install --ignore-workspace +pnpm --dir examples/node-esm-ssr render +``` + +or: + +[![Edit on StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/mj12albert/material-ui/tree/examples-rtg-esm-fix/examples/node-esm-ssr) + +To run the HTTP server: + +```bash +pnpm --dir examples/node-esm-ssr start +``` + +Open http://localhost:3000. Rendering should complete without +`ERR_UNSUPPORTED_DIR_IMPORT`. + +This example installs `@mui/material` from the PR 48645 package preview. diff --git a/examples/node-esm-ssr/package.json b/examples/node-esm-ssr/package.json new file mode 100644 index 00000000000000..0e3390d8e4c4df --- /dev/null +++ b/examples/node-esm-ssr/package.json @@ -0,0 +1,17 @@ +{ + "name": "node-esm-ssr", + "private": true, + "type": "module", + "scripts": { + "render": "node ./server.mjs --once", + "start": "node ./server.mjs" + }, + "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", + "@mui/material": "https://pkg.pr.new/mui/material-ui/@mui/material@90aef5f59695faaed739e8a394281a03c5741aed", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-transition-group": "^4.4.5" + } +} diff --git a/examples/node-esm-ssr/server.mjs b/examples/node-esm-ssr/server.mjs new file mode 100644 index 00000000000000..cfa4833d5b78fe --- /dev/null +++ b/examples/node-esm-ssr/server.mjs @@ -0,0 +1,22 @@ +import { createServer } from 'node:http'; +import { renderPage } from './src/renderPage.mjs'; + +const port = Number(process.env.PORT || 3000); +const renderOnce = process.argv.includes('--once'); + +if (renderOnce) { + console.log(renderPage()); +} else { + createServer((request, response) => { + if (request.url !== '/') { + response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); + response.end('Not found'); + return; + } + + response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + response.end(renderPage()); + }).listen(port, () => { + console.log(`Server listening at http://localhost:${port}`); + }); +} diff --git a/examples/node-esm-ssr/src/App.mjs b/examples/node-esm-ssr/src/App.mjs new file mode 100644 index 00000000000000..db9db282aeeadd --- /dev/null +++ b/examples/node-esm-ssr/src/App.mjs @@ -0,0 +1,158 @@ +import * as React from 'react'; +import Box from '@mui/material/Box'; +import Collapse from '@mui/material/Collapse'; +import CssBaseline from '@mui/material/CssBaseline'; +import Fade from '@mui/material/Fade'; +import Grow from '@mui/material/Grow'; +import List from '@mui/material/List'; +import ListItem from '@mui/material/ListItem'; +import ListItemText from '@mui/material/ListItemText'; +import Slide from '@mui/material/Slide'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import Zoom from '@mui/material/Zoom'; +import { createTheme, ThemeProvider } from '@mui/material/styles'; +import { TransitionGroup } from 'react-transition-group'; + +const theme = createTheme({ + palette: { + mode: 'light', + primary: { + main: '#0057b8', + }, + secondary: { + main: '#0f766e', + }, + background: { + default: '#f7f8fa', + }, + }, + shape: { + borderRadius: 8, + }, +}); + +const transitions = [ + ['Fade', Fade], + ['Grow', Grow], + ['Collapse', Collapse], + ['Slide', Slide, { direction: 'up' }], + ['Zoom', Zoom], +]; + +const serverItems = ['Primary navigation', 'Account menu', 'Settings panel']; + +function TransitionPanel({ name, TransitionComponent, transitionProps = {} }) { + return React.createElement( + TransitionComponent, + { in: true, timeout: 0, ...transitionProps }, + React.createElement( + Box, + { + component: 'section', + sx: { + border: '1px solid', + borderColor: 'divider', + borderRadius: 1, + bgcolor: 'background.paper', + px: 2, + py: 1.5, + }, + }, + React.createElement( + Typography, + { component: 'h2', variant: 'h6' }, + `${name} rendered on the server`, + ), + React.createElement( + Typography, + { color: 'text.secondary', variant: 'body2' }, + 'This component imports Material UI transition internals through the published ESM build.', + ), + ), + ); +} + +function TransitionGroupPanel() { + return React.createElement( + Box, + { + component: 'section', + sx: { + border: '1px solid', + borderColor: 'divider', + borderRadius: 1, + bgcolor: 'background.paper', + px: 2, + py: 1.5, + }, + }, + React.createElement( + Typography, + { component: 'h2', variant: 'h6' }, + 'TransitionGroup rendered on the server', + ), + React.createElement( + List, + { dense: true, sx: { mt: 1 } }, + React.createElement( + TransitionGroup, + null, + serverItems.map((item) => + React.createElement( + Collapse, + { key: item, timeout: 0 }, + React.createElement( + ListItem, + { disablePadding: true }, + React.createElement(ListItemText, { primary: item }), + ), + ), + ), + ), + ), + ); +} + +export default function App() { + return React.createElement( + ThemeProvider, + { theme }, + React.createElement(CssBaseline), + React.createElement( + Box, + { + component: 'main', + sx: { + maxWidth: 760, + mx: 'auto', + px: 3, + py: 5, + }, + }, + React.createElement( + Typography, + { component: 'h1', variant: 'h4', gutterBottom: true }, + 'Node ESM SSR with Material UI', + ), + React.createElement( + Typography, + { color: 'text.secondary', sx: { mb: 3 } }, + 'A minimal server-rendered app that runs MUI transition components through Node native ESM.', + ), + React.createElement( + Stack, + { spacing: 2 }, + transitions.map(([name, TransitionComponent, transitionProps]) => + React.createElement(TransitionPanel, { + key: name, + name, + TransitionComponent, + transitionProps, + }), + ), + React.createElement(TransitionGroupPanel, { key: 'TransitionGroup' }), + ), + ), + ); +} diff --git a/examples/node-esm-ssr/src/renderPage.mjs b/examples/node-esm-ssr/src/renderPage.mjs new file mode 100644 index 00000000000000..fda0ef28ed4a01 --- /dev/null +++ b/examples/node-esm-ssr/src/renderPage.mjs @@ -0,0 +1,19 @@ +import * as React from 'react'; +import * as ReactDOMServer from 'react-dom/server'; +import App from './App.mjs'; + +export function renderPage() { + const appHtml = ReactDOMServer.renderToString(React.createElement(App)); + + return ` + + + + + Material UI Node ESM SSR + + +
${appHtml}
+ +`; +} diff --git a/examples/react-router-node-esm-ssr/.gitignore b/examples/react-router-node-esm-ssr/.gitignore new file mode 100644 index 00000000000000..be40ab16504fee --- /dev/null +++ b/examples/react-router-node-esm-ssr/.gitignore @@ -0,0 +1,4 @@ +build +node_modules +.react-router +tsconfig.tsbuildinfo diff --git a/examples/react-router-node-esm-ssr/.stackblitzrc b/examples/react-router-node-esm-ssr/.stackblitzrc new file mode 100644 index 00000000000000..8812c06a591915 --- /dev/null +++ b/examples/react-router-node-esm-ssr/.stackblitzrc @@ -0,0 +1,3 @@ +{ + "startCommand": "npm run build && npm run start" +} diff --git a/examples/react-router-node-esm-ssr/README.md b/examples/react-router-node-esm-ssr/README.md new file mode 100644 index 00000000000000..c327337b9dcae6 --- /dev/null +++ b/examples/react-router-node-esm-ssr/README.md @@ -0,0 +1,24 @@ +# Material UI - React Router Node ESM SSR example + +Minimal React Router SSR app for checking issue 48636. It keeps +`@mui/material` external in the server build so Node resolves the package at +runtime. + +## How to use + +From the repository root: + +```bash +pnpm --dir examples/react-router-node-esm-ssr install --ignore-workspace +pnpm --dir examples/react-router-node-esm-ssr build +pnpm --dir examples/react-router-node-esm-ssr start +``` + +or: + +[![Edit on StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/mj12albert/material-ui/tree/examples-rtg-esm-fix/examples/react-router-node-esm-ssr) + +Open http://localhost:3000. The server build should import and render without +`ERR_UNSUPPORTED_DIR_IMPORT`. + +This example installs `@mui/material` from the PR 48645 package preview. diff --git a/examples/react-router-node-esm-ssr/app/root.tsx b/examples/react-router-node-esm-ssr/app/root.tsx new file mode 100644 index 00000000000000..429e6c3f8b1c38 --- /dev/null +++ b/examples/react-router-node-esm-ssr/app/root.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; +import { Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router'; + +export function Layout({ children }: { children: React.ReactNode }) { + return ( + + + + + + + + + {children} + + + + + ); +} + +export default function App() { + return ; +} diff --git a/examples/react-router-node-esm-ssr/app/routes.ts b/examples/react-router-node-esm-ssr/app/routes.ts new file mode 100644 index 00000000000000..205ff3ccb9fd4e --- /dev/null +++ b/examples/react-router-node-esm-ssr/app/routes.ts @@ -0,0 +1,3 @@ +import { type RouteConfig, index } from '@react-router/dev/routes'; + +export default [index('routes/home.tsx')] satisfies RouteConfig; diff --git a/examples/react-router-node-esm-ssr/app/routes/home.tsx b/examples/react-router-node-esm-ssr/app/routes/home.tsx new file mode 100644 index 00000000000000..829747865c392b --- /dev/null +++ b/examples/react-router-node-esm-ssr/app/routes/home.tsx @@ -0,0 +1,72 @@ +import Box from '@mui/material/Box'; +import Collapse from '@mui/material/Collapse'; +import Fade from '@mui/material/Fade'; +import List from '@mui/material/List'; +import ListItem from '@mui/material/ListItem'; +import ListItemText from '@mui/material/ListItemText'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { TransitionGroup } from 'react-transition-group'; + +const routeItems = ['Dashboard shell', 'Details route', 'Settings route']; + +export function meta() { + return [ + { title: 'React Router Node ESM SSR' }, + { + name: 'description', + content: 'Minimal React Router SSR app using Material UI transitions.', + }, + ]; +} + +export default function Home() { + return ( + + + + React Router SSR with Material UI + + + This route renders through React Router's server build and imports MUI transition + components from the installed package. + + + + + Fade rendered during SSR + + + + + + {routeItems.map((item) => ( + + + + + + ))} + + + + + ); +} diff --git a/examples/react-router-node-esm-ssr/package.json b/examples/react-router-node-esm-ssr/package.json new file mode 100644 index 00000000000000..da8dc0288681ba --- /dev/null +++ b/examples/react-router-node-esm-ssr/package.json @@ -0,0 +1,31 @@ +{ + "name": "react-router-node-esm-ssr", + "private": true, + "type": "module", + "scripts": { + "build": "react-router build", + "start": "react-router-serve ./build/server/index.js", + "typecheck": "react-router typegen && tsc" + }, + "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", + "@mui/material": "https://pkg.pr.new/mui/material-ui/@mui/material@90aef5f59695faaed739e8a394281a03c5741aed", + "@react-router/node": "7.16.0", + "@react-router/serve": "7.16.0", + "isbot": "^5", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-router": "7.16.0", + "react-transition-group": "^4.4.5" + }, + "devDependencies": { + "@react-router/dev": "7.16.0", + "@types/node": "^22.0.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@types/react-transition-group": "^4.4.12", + "typescript": "^5.9.3", + "vite": "^8.0.3" + } +} diff --git a/examples/react-router-node-esm-ssr/react-router.config.ts b/examples/react-router-node-esm-ssr/react-router.config.ts new file mode 100644 index 00000000000000..9d8b4134caa567 --- /dev/null +++ b/examples/react-router-node-esm-ssr/react-router.config.ts @@ -0,0 +1,12 @@ +import type { Config } from '@react-router/dev/config'; + +export default { + ssr: true, + future: { + v8_middleware: true, + v8_passThroughRequests: true, + v8_splitRouteModules: true, + v8_trailingSlashAwareDataRequests: true, + v8_viteEnvironmentApi: true, + }, +} satisfies Config; diff --git a/examples/react-router-node-esm-ssr/tsconfig.json b/examples/react-router-node-esm-ssr/tsconfig.json new file mode 100644 index 00000000000000..90aefcd00b4a53 --- /dev/null +++ b/examples/react-router-node-esm-ssr/tsconfig.json @@ -0,0 +1,18 @@ +{ + "include": [".react-router/types/**/*", "app/**/*"], + "compilerOptions": { + "composite": true, + "strict": true, + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "types": ["vite/client"], + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "rootDirs": [".", "./.react-router/types"], + "skipLibCheck": true, + "noEmit": true, + "esModuleInterop": true, + "resolveJsonModule": true + } +} diff --git a/examples/react-router-node-esm-ssr/vite.config.ts b/examples/react-router-node-esm-ssr/vite.config.ts new file mode 100644 index 00000000000000..263bb8e25747ed --- /dev/null +++ b/examples/react-router-node-esm-ssr/vite.config.ts @@ -0,0 +1,9 @@ +import { reactRouter } from '@react-router/dev/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [reactRouter()], + ssr: { + external: ['@mui/material'], + }, +}); diff --git a/examples/vitest-node-esm/.gitignore b/examples/vitest-node-esm/.gitignore new file mode 100644 index 00000000000000..3c3629e647f5dd --- /dev/null +++ b/examples/vitest-node-esm/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/examples/vitest-node-esm/README.md b/examples/vitest-node-esm/README.md new file mode 100644 index 00000000000000..a8a2c3aa14f02b --- /dev/null +++ b/examples/vitest-node-esm/README.md @@ -0,0 +1,18 @@ +# Material UI - Vitest Node ESM example + +Minimal Vitest + jsdom setup for checking issue 48636. Vitest externalizes +dependencies, so the test loads `@mui/material` through Node's ESM resolver. + +## How to use + +From the repository root: + +```bash +pnpm --dir examples/vitest-node-esm install --ignore-workspace +pnpm --dir examples/vitest-node-esm test +``` + +The tests should pass without `ERR_UNSUPPORTED_DIR_IMPORT` and without inlining +`@mui/material` into Vite. + +This example installs `@mui/material` from the PR 48645 package preview. diff --git a/examples/vitest-node-esm/package.json b/examples/vitest-node-esm/package.json new file mode 100644 index 00000000000000..3a7ee53edb6902 --- /dev/null +++ b/examples/vitest-node-esm/package.json @@ -0,0 +1,22 @@ +{ + "name": "vitest-node-esm", + "private": true, + "type": "module", + "scripts": { + "test": "vitest run" + }, + "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", + "@mui/material": "https://pkg.pr.new/mui/material-ui/@mui/material@90aef5f59695faaed739e8a394281a03c5741aed", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-transition-group": "^4.4.5" + }, + "devDependencies": { + "@testing-library/dom": "^10.4.0", + "@testing-library/react": "^16.3.0", + "jsdom": "^26.1.0", + "vitest": "^4.1.0" + } +} diff --git a/examples/vitest-node-esm/src/App.jsx b/examples/vitest-node-esm/src/App.jsx new file mode 100644 index 00000000000000..5c2a0cf772fa12 --- /dev/null +++ b/examples/vitest-node-esm/src/App.jsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import Button from '@mui/material/Button'; +import Collapse from '@mui/material/Collapse'; +import Fade from '@mui/material/Fade'; +import List from '@mui/material/List'; +import ListItem from '@mui/material/ListItem'; +import ListItemText from '@mui/material/ListItemText'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import { TransitionGroup } from 'react-transition-group'; + +const initialItems = ['Item 1', 'Item 2', 'Item 3']; + +export default function App() { + const [items, setItems] = React.useState(initialItems); + + return ( + + + Vitest with Material UI transitions + + + Faded in on first render + + + + + {items.map((item) => ( + + + + + + ))} + + + + ); +} diff --git a/examples/vitest-node-esm/src/App.test.jsx b/examples/vitest-node-esm/src/App.test.jsx new file mode 100644 index 00000000000000..10decf380aaf13 --- /dev/null +++ b/examples/vitest-node-esm/src/App.test.jsx @@ -0,0 +1,55 @@ +import * as React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import Collapse from '@mui/material/Collapse'; +import { TransitionGroup } from 'react-transition-group'; +import { expect, test, vi } from 'vitest'; +import App from './App.jsx'; + +test('renders the app through the published @mui/material package layout', () => { + render(); + + expect(screen.getByText('Faded in on first render')).toBeDefined(); + expect(screen.getByText('Item 1')).toBeDefined(); +}); + +test('adds a list item to an already-mounted TransitionGroup', async () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Add item' })); + + expect(await screen.findByText('Item 4')).toBeDefined(); +}); + +test('Collapse added to a mounted TransitionGroup enters with isAppearing=false', async () => { + const handleEntered = vi.fn(); + + function Harness() { + const [open, setOpen] = React.useState(false); + return ( + + + + {open ? ( + +

Collapse content

+
+ ) : null} +
+
+ ); + } + + render(); + fireEvent.click(screen.getByRole('button', { name: 'Open' })); + + expect(await screen.findByText('Collapse content')).toBeDefined(); + await waitFor(() => expect(handleEntered).toHaveBeenCalledTimes(1)); + + // Material UI and react-transition-group must share one TransitionGroupContext + // instance for this to hold: a child added after the group mounted reports + // isAppearing=false (the last callback argument). + const enteredArguments = handleEntered.mock.calls[0]; + expect(enteredArguments[enteredArguments.length - 1]).toBe(false); +}); diff --git a/examples/vitest-node-esm/vitest.config.mjs b/examples/vitest-node-esm/vitest.config.mjs new file mode 100644 index 00000000000000..e40eceafff368e --- /dev/null +++ b/examples/vitest-node-esm/vitest.config.mjs @@ -0,0 +1,15 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'jsdom', + // Expose global test hooks so @testing-library/react registers its + // automatic cleanup. + globals: true, + // Intentionally no `server.deps.inline` entries for `@mui/material` or + // `react-transition-group`. Vitest externalizes `node_modules` by default, + // so the tests load `@mui/material` through Node's own ESM resolver — the + // module resolution path from + // https://github.com/mui/material-ui/issues/48636. + }, +}); diff --git a/examples/webpack-cjs-browser/.gitignore b/examples/webpack-cjs-browser/.gitignore new file mode 100644 index 00000000000000..de4d1f007dd195 --- /dev/null +++ b/examples/webpack-cjs-browser/.gitignore @@ -0,0 +1,2 @@ +dist +node_modules diff --git a/examples/webpack-cjs-browser/.stackblitzrc b/examples/webpack-cjs-browser/.stackblitzrc new file mode 100644 index 00000000000000..09e22c0da2fc3a --- /dev/null +++ b/examples/webpack-cjs-browser/.stackblitzrc @@ -0,0 +1,3 @@ +{ + "startCommand": "npm run dev" +} diff --git a/examples/webpack-cjs-browser/README.md b/examples/webpack-cjs-browser/README.md new file mode 100644 index 00000000000000..effcb39f872b9f --- /dev/null +++ b/examples/webpack-cjs-browser/README.md @@ -0,0 +1,26 @@ +# Material UI - Webpack CJS browser example + +Minimal Webpack browser app for checking issue 48636. The app uses CommonJS +`require()` calls and verifies that Material UI and `react-transition-group` +share the same transition context in the browser bundle. + +## How to use + +From the repository root: + +```bash +pnpm --dir examples/webpack-cjs-browser install --ignore-workspace +pnpm --dir examples/webpack-cjs-browser dev +``` + +or: + +[![Edit on StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/mj12albert/material-ui/tree/examples-rtg-esm-fix/examples/webpack-cjs-browser) + +Open http://127.0.0.1:3005, click "Add item", and confirm: + +```text +Last isAppearing: false +``` + +This example installs `@mui/material` from the PR 48645 package preview. diff --git a/examples/webpack-cjs-browser/index.html b/examples/webpack-cjs-browser/index.html new file mode 100644 index 00000000000000..5028f327139f22 --- /dev/null +++ b/examples/webpack-cjs-browser/index.html @@ -0,0 +1,12 @@ + + + + + + Webpack CJS browser smoke test + + +
+ + + diff --git a/examples/webpack-cjs-browser/package.json b/examples/webpack-cjs-browser/package.json new file mode 100644 index 00000000000000..0b18ea125a4097 --- /dev/null +++ b/examples/webpack-cjs-browser/package.json @@ -0,0 +1,22 @@ +{ + "name": "webpack-cjs-browser", + "private": true, + "scripts": { + "dev": "webpack serve --mode=development", + "build": "webpack --mode=production", + "build:dev": "webpack --mode=development" + }, + "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", + "@mui/material": "https://pkg.pr.new/mui/material-ui/@mui/material@90aef5f59695faaed739e8a394281a03c5741aed", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-transition-group": "^4.4.5" + }, + "devDependencies": { + "webpack": "latest", + "webpack-cli": "latest", + "webpack-dev-server": "latest" + } +} diff --git a/examples/webpack-cjs-browser/src/index.cjs b/examples/webpack-cjs-browser/src/index.cjs new file mode 100644 index 00000000000000..fa954fd91b416e --- /dev/null +++ b/examples/webpack-cjs-browser/src/index.cjs @@ -0,0 +1,6 @@ +const React = require('react'); +const ReactDOM = require('react-dom/client'); +const { ListWithTransitions } = require('./internal-ui.cjs'); + +const root = ReactDOM.createRoot(document.getElementById('root')); +root.render(React.createElement(ListWithTransitions)); diff --git a/examples/webpack-cjs-browser/src/internal-ui.cjs b/examples/webpack-cjs-browser/src/internal-ui.cjs new file mode 100644 index 00000000000000..5e6e6b1c7e6700 --- /dev/null +++ b/examples/webpack-cjs-browser/src/internal-ui.cjs @@ -0,0 +1,81 @@ +const React = require('react'); +const Button = require('@mui/material/Button').default; +const Collapse = require('@mui/material/Collapse').default; +const CssBaseline = require('@mui/material/CssBaseline').default; +const List = require('@mui/material/List').default; +const ListItem = require('@mui/material/ListItem').default; +const ListItemText = require('@mui/material/ListItemText').default; +const Stack = require('@mui/material/Stack').default; +const Typography = require('@mui/material/Typography').default; +const { TransitionGroup } = require('react-transition-group'); + +const h = React.createElement; + +function ListWithTransitions() { + const [items, setItems] = React.useState([ + { id: 1, label: 'Initial item' }, + { id: 2, label: 'Second item' }, + ]); + const [lastIsAppearing, setLastIsAppearing] = React.useState('pending'); + + const handleAdd = () => { + setItems((currentItems) => [ + ...currentItems, + { id: currentItems.length + 1, label: `Added item ${currentItems.length + 1}` }, + ]); + }; + + const handleEntered = React.useCallback((node, isAppearing) => { + window.__MUI_WEBPACK_CJS_BROWSER_SMOKE__ = { lastIsAppearing: isAppearing }; + setLastIsAppearing(String(isAppearing)); + }, []); + + return h( + React.Fragment, + null, + h(CssBaseline), + h( + 'main', + { + style: { + maxWidth: 640, + margin: '0 auto', + padding: 32, + fontFamily: 'system-ui, sans-serif', + }, + }, + h( + Stack, + { spacing: 2 }, + h(Typography, { component: 'h1', variant: 'h4' }, 'Webpack CJS browser smoke test'), + h( + Typography, + { color: 'text.secondary' }, + 'This app uses CommonJS require() calls and is bundled for the browser.', + ), + h(Button, { onClick: handleAdd, variant: 'contained' }, 'Add item'), + h(Typography, { component: 'p' }, `Last isAppearing: ${lastIsAppearing}`), + h( + List, + { + dense: true, + sx: { border: '1px solid', borderColor: 'divider', borderRadius: 1 }, + }, + h( + TransitionGroup, + null, + items.map((item) => + h( + Collapse, + { key: item.id, timeout: 0, onEntered: handleEntered }, + h(ListItem, null, h(ListItemText, { primary: item.label })), + ), + ), + ), + ), + ), + ), + ); +} + +module.exports = { ListWithTransitions }; diff --git a/examples/webpack-cjs-browser/webpack.config.cjs b/examples/webpack-cjs-browser/webpack.config.cjs new file mode 100644 index 00000000000000..04d027189e04f1 --- /dev/null +++ b/examples/webpack-cjs-browser/webpack.config.cjs @@ -0,0 +1,28 @@ +const path = require('node:path'); + +module.exports = { + entry: './src/index.cjs', + output: { + path: path.resolve(__dirname, 'dist'), + filename: 'main.js', + publicPath: '/dist/', + clean: true, + }, + devServer: { + static: { + directory: __dirname, + }, + devMiddleware: { + publicPath: '/dist/', + }, + host: '127.0.0.1', + port: 3005, + }, + target: 'web', + devtool: false, + resolve: { + extensions: ['.cjs', '.js'], + aliasFields: ['browser'], + mainFields: ['browser', 'module', 'main'], + }, +};