Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions examples/material-ui-react-router-ts/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.DS_Store
/node_modules/

# React Router
/.react-router/
/build/
41 changes: 41 additions & 0 deletions examples/material-ui-react-router-ts/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Material UI - React Router example in TypeScript

## How to use

Download the example [or clone the repo](https://github.com/mui/material-ui):

<!-- #target-branch-reference -->

```bash
curl https://codeload.github.com/mui/material-ui/tar.gz/master | tar -xz --strip=2 material-ui-master/examples/material-ui-react-router-ts
cd material-ui-react-router-ts
```

Install it and run:

```bash
npm install
npm run dev
```

or:

<!-- #target-branch-reference -->

[![Edit on CodeSandbox](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/p/sandbox/github/mui/material-ui/tree/master/examples/material-ui-react-router-ts)

[![Edit on StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/mui/material-ui/tree/master/examples/material-ui-react-router-ts)

## The idea behind the example

<!-- #host-reference -->

This example demonstrates how you can use Material UI with [React Router](https://reactrouter.com/) in [TypeScript](https://github.com/Microsoft/TypeScript).
It includes `@mui/material` and its peer dependencies, including [Emotion](https://emotion.sh/docs/introduction), the default style engine in Material UI.

## What's next?

<!-- #host-reference -->

You now have a working example project.
You can head back to the documentation and continue by browsing the [templates](https://mui.com/material-ui/getting-started/templates/) section.
21 changes: 21 additions & 0 deletions examples/material-ui-react-router-ts/app/components/Copyright.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import * as React from 'react';
import Typography from '@mui/material/Typography';
import MuiLink from '@mui/material/Link';

export default function Copyright() {
return (
<Typography
variant="body2"
align="center"
sx={{
color: 'text.secondary',
}}
>
{'Copyright © '}
<MuiLink color="inherit" href="https://mui.com/">
Your Website
</MuiLink>{' '}
{new Date().getFullYear()}.
</Typography>
);
}
23 changes: 23 additions & 0 deletions examples/material-ui-react-router-ts/app/components/ProTip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import * as React from 'react';
import Link from '@mui/material/Link';
import SvgIcon, { type SvgIconProps } from '@mui/material/SvgIcon';
import Typography from '@mui/material/Typography';

function LightBulbIcon(props: SvgIconProps) {
return (
<SvgIcon {...props}>
<path d="M9 21c0 .55.45 1 1 1h4c.55 0 1-.45 1-1v-1H9v1zm3-19C8.14 2 5 5.14 5 9c0 2.38 1.19 4.47 3 5.74V17c0 .55.45 1 1 1h6c.55 0 1-.45 1-1v-2.26c1.81-1.27 3-3.36 3-5.74 0-3.86-3.14-7-7-7zm2.85 11.1l-.85.6V16h-4v-2.3l-.85-.6C7.8 12.16 7 10.63 7 9c0-2.76 2.24-5 5-5s5 2.24 5 5c0 1.63-.8 3.16-2.15 4.1z" />
</SvgIcon>
);
}

export default function ProTip() {
return (
<Typography sx={{ mt: 6, mb: 3, color: 'text.secondary' }}>
<LightBulbIcon sx={{ mr: 1, verticalAlign: 'middle' }} />
{'Pro tip: See more '}
<Link href="https://mui.com/material-ui/getting-started/templates/">templates</Link>
{' in the Material UI documentation.'}
</Typography>
);
}
15 changes: 15 additions & 0 deletions examples/material-ui-react-router-ts/app/createCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import createCache from '@emotion/cache';

export default function createEmotionCache(options?: Parameters<typeof createCache>[0]) {
const emotionCache = createCache({ key: 'mui', ...options });
const prevInsert = emotionCache.insert;
emotionCache.insert = (...args) => {
// ignore styles that contain layer order (`@layer ...` without `{`)
if (!args[1].styles.match(/^@layer\s+[^{]*$/)) {
args[1].styles = `@layer mui {${args[1].styles}}`;
}
return prevInsert(...args);
};

return emotionCache;
}
12 changes: 12 additions & 0 deletions examples/material-ui-react-router-ts/app/entry.client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import * as React from 'react';
import * as ReactDOM from 'react-dom/client';
import { HydratedRouter } from 'react-router/dom';

React.startTransition(() => {
ReactDOM.hydrateRoot(
document,
<React.StrictMode>
<HydratedRouter />
</React.StrictMode>,
);
});
101 changes: 101 additions & 0 deletions examples/material-ui-react-router-ts/app/entry.server.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { Transform } from 'node:stream';

import * as React from 'react';
import * as ReactDOMServer from 'react-dom/server';
import type { EntryContext } from 'react-router';
import { ServerRouter } from 'react-router';
import { createReadableStreamFromReadable } from '@react-router/node';
import { isbot } from 'isbot';
import createEmotionServer from '@emotion/server/create-instance';
import { CacheProvider } from '@emotion/react';
import createEmotionCache from './createCache';

export const streamTimeout = 5_000;

export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
) {
const cache = createEmotionCache();
const { extractCriticalToChunks, constructStyleTagsFromChunks } = createEmotionServer(cache);

return new Promise((resolve, reject) => {
let shellRendered = false;
const userAgent = request.headers.get('user-agent');

// Ensure requests from bots and SPA Mode renders wait for all content to load before responding
// https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
const readyOption: keyof ReactDOMServer.RenderToPipeableStreamOptions =
(userAgent && isbot(userAgent)) || routerContext.isSpaMode ? 'onAllReady' : 'onShellReady';

const { pipe, abort } = ReactDOMServer.renderToPipeableStream(
<CacheProvider value={cache}>
<ServerRouter context={routerContext} url={request.url} />
</CacheProvider>,
{
[readyOption]() {
shellRendered = true;

// Collect the HTML chunks
const chunks: Buffer[] = [];

// Create transform stream to collect HTML and inject styles
const transformStream = new Transform({
transform(chunk, _encoding, callback) {
// Collect chunks, don't pass them through yet
chunks.push(chunk);
callback();
},
flush(callback) {
// Combine all chunks into HTML string
const html = Buffer.concat(chunks).toString();

// Extract emotion styles from the collected HTML
const styles = constructStyleTagsFromChunks(extractCriticalToChunks(html));

if (styles) {
const injectedHtml = html.replace('</head>', `${styles}</head>`);
this.push(injectedHtml);
} else {
this.push(html);
}

callback();
},
});

const stream = createReadableStreamFromReadable(transformStream);

responseHeaders.set('Content-Type', 'text/html');

resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
}),
);

pipe(transformStream);
},
onShellError(error: unknown) {
reject(error);
},
onError(error: unknown) {
responseStatusCode = 500;
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error);
}
},
},
);

// Abort the rendering stream after the `streamTimeout` so it has time to
// flush down the rejected boundaries
setTimeout(abort, streamTimeout + 1000);
});
}
92 changes: 92 additions & 0 deletions examples/material-ui-react-router-ts/app/root.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import * as React from 'react';
import {
isRouteErrorResponse,
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from 'react-router';
import { CacheProvider } from '@emotion/react';
import Box from '@mui/material/Box';
import AppTheme from './theme';
import createEmotionCache from './createCache';

import type { Route } from './+types/root';

export const links: Route.LinksFunction = () => [
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
{
rel: 'preconnect',
href: 'https://fonts.gstatic.com',
crossOrigin: 'anonymous',
},
{
rel: 'stylesheet',
href: 'https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap',
},
];

export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}

const cache = createEmotionCache();

export default function App() {
if (typeof window !== 'undefined') {
return (
<CacheProvider value={cache}>
<AppTheme>
<Outlet />
</AppTheme>
</CacheProvider>
);
}
return (
<AppTheme>
<Outlet />
</AppTheme>
);
}

export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
let message = 'Oops!';
let details = 'An unexpected error occurred.';
let stack: string | undefined;

if (isRouteErrorResponse(error)) {
message = error.status === 404 ? '404' : 'Error';
details =
error.status === 404 ? 'The requested page could not be found.' : error.statusText || details;
} else if (import.meta.env.DEV && error && error instanceof Error) {
details = error.message;
stack = error.stack;
}

return (
<Box component="main" sx={{ pt: 8, p: 2, maxWidth: 'lg', mx: 'auto' }}>
<h1>{message}</h1>
<p>{details}</p>
{stack && (
<Box component="pre" sx={{ width: '100%', p: 2, overflowX: 'auto' }}>
<code>{stack}</code>
</Box>
)}
</Box>
);
}
6 changes: 6 additions & 0 deletions examples/material-ui-react-router-ts/app/routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { type RouteConfig, index, route } from '@react-router/dev/routes';

export default [
index('routes/home.tsx'),
route('/about', 'routes/about.tsx'),
] satisfies RouteConfig;
45 changes: 45 additions & 0 deletions examples/material-ui-react-router-ts/app/routes/about.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import * as React from 'react';
import Container from '@mui/material/Container';
import Typography from '@mui/material/Typography';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import { Link as ReactRouterLink } from 'react-router';
import ProTip from '~/components/ProTip';
import Copyright from '~/components/Copyright';

export function meta() {
return [
{ title: 'About' },
{
name: 'description',
content: 'About the project',
},
];
}

export default function About() {
return (
<Container maxWidth="lg">
<Box
sx={{
my: 4,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
}}
>
<Typography variant="h4" component="h1" sx={{ mb: 2 }}>
Material UI - Next.js example in TypeScript
</Typography>
<Box sx={{ maxWidth: 'sm' }}>
<Button variant="contained" component={ReactRouterLink} to="/">
Go to the home page
</Button>
</Box>
<ProTip />
<Copyright />
</Box>
</Container>
);
}
Loading