diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 34dd877e18d..f7884b01195 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -775,6 +775,15 @@ importers: '@chakra-ui/react': specifier: workspace:* version: link:../../packages/react + '@emotion/cache': + specifier: ^11.14.0 + version: 11.14.0 + '@emotion/react': + specifier: ^11.14.0 + version: 11.14.0(@types/react@19.2.2)(react@19.2.0) + '@emotion/server': + specifier: ^11.11.0 + version: 11.11.0(@emotion/css@11.13.5) '@react-router/node': specifier: ^7.6.2 version: 7.6.2(react-router@7.9.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(typescript@5.9.3) @@ -1201,10 +1210,6 @@ packages: resolution: {integrity: sha512-LABppdt+Lp/RlBxqrh4qgf1oEH/WxdzQNDJIu5gC/W1GyvPVrOBiItmmM8wan2fm4oYqFuFfkXmlGpLQhPY8CA==} engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.25.9': - resolution: {integrity: sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==} - engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.27.1': resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} engines: {node: '>=6.9.0'} @@ -11028,29 +11033,22 @@ snapshots: '@babel/helper-member-expression-to-functions@7.24.8': dependencies: - '@babel/traverse': 7.28.0 - '@babel/types': 7.28.1 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-imports@7.25.9': - dependencies: - '@babel/traverse': 7.27.7 - '@babel/types': 7.27.7 + '@babel/traverse': 7.28.4 + '@babel/types': 7.28.4 transitivePeerDependencies: - supports-color '@babel/helper-module-imports@7.27.1': dependencies: - '@babel/traverse': 7.28.0 - '@babel/types': 7.28.1 + '@babel/traverse': 7.28.4 + '@babel/types': 7.28.4 transitivePeerDependencies: - supports-color '@babel/helper-module-transforms@7.25.2(@babel/core@7.24.7)': dependencies: '@babel/core': 7.24.7 - '@babel/helper-module-imports': 7.25.9 + '@babel/helper-module-imports': 7.27.1 '@babel/helper-simple-access': 7.24.7 '@babel/helper-validator-identifier': 7.25.9 '@babel/traverse': 7.27.7 @@ -11095,7 +11093,7 @@ snapshots: '@babel/helper-optimise-call-expression@7.24.7': dependencies: - '@babel/types': 7.28.1 + '@babel/types': 7.28.4 '@babel/helper-plugin-utils@7.25.9': {} @@ -11106,7 +11104,7 @@ snapshots: '@babel/core': 7.27.7 '@babel/helper-member-expression-to-functions': 7.24.8 '@babel/helper-optimise-call-expression': 7.24.7 - '@babel/traverse': 7.28.0 + '@babel/traverse': 7.28.4 transitivePeerDependencies: - supports-color @@ -11115,7 +11113,7 @@ snapshots: '@babel/core': 7.28.4 '@babel/helper-member-expression-to-functions': 7.24.8 '@babel/helper-optimise-call-expression': 7.24.7 - '@babel/traverse': 7.28.0 + '@babel/traverse': 7.28.4 transitivePeerDependencies: - supports-color @@ -11791,7 +11789,7 @@ snapshots: '@emotion/babel-plugin@11.13.5': dependencies: - '@babel/helper-module-imports': 7.25.9 + '@babel/helper-module-imports': 7.27.1 '@babel/runtime': 7.28.4 '@emotion/hash': 0.9.2 '@emotion/memoize': 0.9.0 @@ -11839,7 +11837,7 @@ snapshots: '@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0)': dependencies: - '@babel/runtime': 7.25.7 + '@babel/runtime': 7.28.4 '@emotion/babel-plugin': 11.13.5 '@emotion/cache': 11.14.0 '@emotion/serialize': 1.3.3 @@ -16746,7 +16744,7 @@ snapshots: '@typescript-eslint/parser': 8.46.2(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3) eslint: 9.39.1(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@8.46.2(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@8.46.2(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-typescript@3.6.1)(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@2.6.1)) @@ -16778,7 +16776,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@8.46.2(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)): + eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@8.46.2(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)): dependencies: debug: 4.4.3 enhanced-resolve: 5.17.1 @@ -16817,7 +16815,7 @@ snapshots: '@typescript-eslint/parser': 8.46.2(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3) eslint: 9.39.1(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@8.46.2(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@8.46.2(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.2(eslint@9.39.1(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)) transitivePeerDependencies: - supports-color diff --git a/sandbox/react-router/README.md b/sandbox/react-router/README.md index be4259630a0..96b97112bc3 100644 --- a/sandbox/react-router/README.md +++ b/sandbox/react-router/README.md @@ -1,20 +1,84 @@ -# Welcome to React Router! +# Welcome to React Router with Chakra UI! A modern, production-ready template for building full-stack React applications -using React Router. +using React Router and Chakra UI with proper Emotion cache SSR support. [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/remix-run/react-router-templates/tree/main/default) ## Features -- 🚀 Server-side rendering +- 🚀 Server-side rendering with React Router v7 - ⚡️ Hot Module Replacement (HMR) - 📦 Asset bundling and optimization - 🔄 Data loading and mutations - 🔒 TypeScript by default -- 🎉 TailwindCSS for styling +- 🎨 Chakra UI with Emotion cache configured for SSR +- 🌓 Dark mode support with next-themes - 📖 [React Router docs](https://reactrouter.com/) +## Emotion Cache SSR Setup + +This example demonstrates how to properly configure Emotion cache for +server-side rendering with React Router v7, addressing +[issue #10450](https://github.com/chakra-ui/chakra-ui/issues/10450). + +### Architecture + +The Emotion cache setup consists of three main parts: + +#### 1. Emotion Cache Utilities (`app/emotion/`) + +- **`emotion-cache.ts`** - Creates the base Emotion cache instance +- **`emotion-server.tsx`** - Server-side utilities (note: streaming limitations) +- **`emotion-client.tsx`** - Client-side cache provider and style injection + hooks + +#### 2. Entry Files + +- **`app/entry.server.tsx`** - Wraps server rendering with `CacheProvider` +- **`app/entry.client.tsx`** - Wraps client hydration with `ClientCacheProvider` + +#### 3. Root Layout + +- **`app/root.tsx`** - Uses `withEmotionCache` HOC and includes emotion + insertion point + +### Key Implementation Details + +1. **Server Rendering**: The server wraps the React Router `` with + Emotion's `` to ensure styles are tracked during SSR. + +2. **Client Hydration**: The client uses `` to maintain a + consistent cache across hydration and subsequent client-side navigation. + +3. **Emotion Insertion Point**: A `` tag is + placed in the `` to control where Emotion injects styles. + +4. **Style Injection**: The `useInjectStyles` hook ensures server-rendered + styles are properly transferred to the client-side cache during hydration. + +5. **Hydration Warning Fix**: The `` tag includes + `suppressHydrationWarning` to prevent warnings from next-themes modifying the + className during client-side rendering. + +### Known Limitations + +**Emotion + React 18 Streaming SSR**: Emotion doesn't fully support React 18's +`renderToPipeableStream` API yet. This implementation provides a working +solution by: + +- Using `CacheProvider` on both server and client +- Relying on client-side style injection during hydration +- Maintaining style consistency through the emotion insertion point + +While this doesn't provide optimal critical CSS extraction during streaming, it +prevents hydration mismatches and ensures styles render correctly. + +For more information, see: + +- [Emotion Discussion #2859](https://github.com/emotion-js/emotion/discussions/2859) +- [Emotion Issue #2800](https://github.com/emotion-js/emotion/issues/2800) + ## Getting Started ### Installation @@ -83,9 +147,13 @@ Make sure to deploy the output of `npm run build` ## Styling -This template comes with [Tailwind CSS](https://tailwindcss.com/) already -configured for a simple default starting experience. You can use whatever CSS -framework you prefer. +This template comes with [Chakra UI](https://chakra-ui.com/) already configured +with proper Emotion cache SSR support. The styling system includes: + +- Component library with accessible primitives +- Dark mode support via next-themes +- Emotion-based styling with SSR hydration +- Responsive design utilities --- diff --git a/sandbox/react-router/app/emotion/emotion-cache.ts b/sandbox/react-router/app/emotion/emotion-cache.ts new file mode 100644 index 00000000000..93fc48cb7b6 --- /dev/null +++ b/sandbox/react-router/app/emotion/emotion-cache.ts @@ -0,0 +1,5 @@ +import createCache from "@emotion/cache" + +export function createEmotionCache() { + return createCache({ key: "css" }) +} diff --git a/sandbox/react-router/app/emotion/emotion-client.tsx b/sandbox/react-router/app/emotion/emotion-client.tsx new file mode 100644 index 00000000000..d7011ca5484 --- /dev/null +++ b/sandbox/react-router/app/emotion/emotion-client.tsx @@ -0,0 +1,72 @@ +import { CacheProvider } from "@emotion/react" +import type { EmotionCache } from "@emotion/react" +import { + createContext, + useContext, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react" +import { createEmotionCache } from "./emotion-cache" + +export interface ClientStyleContextData { + reset: () => void +} + +export const ClientStyleContext = createContext({ + reset: () => {}, +}) + +export const useClientStyleContext = () => { + return useContext(ClientStyleContext) +} + +interface ClientCacheProviderProps { + children: React.ReactNode +} + +export function ClientCacheProvider({ children }: ClientCacheProviderProps) { + const [cache, setCache] = useState(createEmotionCache()) + + const context = useMemo( + () => ({ + reset() { + setCache(createEmotionCache()) + }, + }), + [], + ) + + return ( + + {children} + + ) +} + +const useSafeLayoutEffect = + typeof window === "undefined" ? () => {} : useLayoutEffect + +export function useInjectStyles(cache: EmotionCache) { + const styles = useClientStyleContext() + const injectRef = useRef(true) + + useSafeLayoutEffect(() => { + if (!injectRef.current) return + + cache.sheet.container = document.head + + const tags = cache.sheet.tags + cache.sheet.flush() + tags.forEach((tag) => { + const sheet = cache.sheet as unknown as { + _insertTag: (tag: HTMLStyleElement) => void + } + sheet._insertTag(tag) + }) + + styles.reset() + injectRef.current = false + }, []) +} diff --git a/sandbox/react-router/app/emotion/emotion-server.tsx b/sandbox/react-router/app/emotion/emotion-server.tsx new file mode 100644 index 00000000000..d368f428b4f --- /dev/null +++ b/sandbox/react-router/app/emotion/emotion-server.tsx @@ -0,0 +1,45 @@ +import { CacheProvider } from "@emotion/react" +import createEmotionServer from "@emotion/server/create-instance" +import { renderToString } from "react-dom/server" +import { Provider } from "../components/ui/provider" +import { createEmotionCache } from "./emotion-cache" + +export function createEmotion() { + const cache = createEmotionCache() + const server = createEmotionServer(cache) + + function injectStyles(html: string) { + const { styles } = server.extractCriticalToChunks(html) + + let stylesHTML = "" + + styles.forEach(({ key, ids, css }) => { + const emotionKey = `${key} ${ids.join(" ")}` + const newStyleTag = `` + stylesHTML = `${stylesHTML}${newStyleTag}` + }) + + // add the emotion style tags after the insertion point meta tag + const markup = html.replace( + //, + `${stylesHTML}`, + ) + + return markup + } + + function _renderToString(element: React.ReactNode) { + return renderToString( + + {element} + , + ) + } + + return { + server, + cache, + injectStyles, + renderToString: _renderToString, + } +} diff --git a/sandbox/react-router/app/entry.client.tsx b/sandbox/react-router/app/entry.client.tsx new file mode 100644 index 00000000000..9013a2b298d --- /dev/null +++ b/sandbox/react-router/app/entry.client.tsx @@ -0,0 +1,15 @@ +import { StrictMode, startTransition } from "react" +import { hydrateRoot } from "react-dom/client" +import { HydratedRouter } from "react-router/dom" +import { ClientCacheProvider } from "./emotion/emotion-client" + +startTransition(() => { + hydrateRoot( + document, + + + + + , + ) +}) diff --git a/sandbox/react-router/app/entry.server.tsx b/sandbox/react-router/app/entry.server.tsx new file mode 100644 index 00000000000..1e5c6d3dbc9 --- /dev/null +++ b/sandbox/react-router/app/entry.server.tsx @@ -0,0 +1,76 @@ +import { CacheProvider } from "@emotion/react" +import { createReadableStreamFromReadable } from "@react-router/node" +import { isbot } from "isbot" +import { PassThrough } from "node:stream" +import type { RenderToPipeableStreamOptions } from "react-dom/server" +import { renderToPipeableStream } from "react-dom/server" +import type { AppLoadContext, EntryContext } from "react-router" +import { ServerRouter } from "react-router" +import { createEmotionCache } from "./emotion/emotion-cache" + +export const streamTimeout = 5_000 + +export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + routerContext: EntryContext, + loadContext: AppLoadContext, + // If you have middleware enabled: + // loadContext: unstable_RouterContextProvider +) { + return new Promise((resolve, reject) => { + let shellRendered = false + let 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 + let readyOption: keyof RenderToPipeableStreamOptions = + (userAgent && isbot(userAgent)) || routerContext.isSpaMode + ? "onAllReady" + : "onShellReady" + + // Create a new emotion cache for this request + const cache = createEmotionCache() + + const { pipe, abort } = renderToPipeableStream( + + + , + { + [readyOption]() { + shellRendered = true + const body = new PassThrough() + const stream = createReadableStreamFromReadable(body) + + responseHeaders.set("Content-Type", "text/html") + + resolve( + new Response(stream, { + headers: responseHeaders, + status: responseStatusCode, + }), + ) + + pipe(body) + }, + 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) + }) +} diff --git a/sandbox/react-router/app/root.tsx b/sandbox/react-router/app/root.tsx index 503c95f433c..3b309400b9c 100644 --- a/sandbox/react-router/app/root.tsx +++ b/sandbox/react-router/app/root.tsx @@ -1,3 +1,4 @@ +import { withEmotionCache } from "@emotion/react" import { Links, Meta, @@ -8,6 +9,7 @@ import { } from "react-router" import type { Route } from "./+types/root" import { Provider } from "./components/ui/provider" +import { useInjectStyles } from "./emotion/emotion-client" export const links: Route.LinksFunction = () => [ { rel: "preconnect", href: "https://fonts.googleapis.com" }, @@ -22,14 +24,26 @@ export const links: Route.LinksFunction = () => [ }, ] -export function Layout({ children }: { children: React.ReactNode }) { +interface LayoutProps { + children: React.ReactNode +} + +export const Layout = withEmotionCache((props: LayoutProps, cache) => { + const { children } = props + + useInjectStyles(cache) + return ( - - + + + @@ -40,7 +54,7 @@ export function Layout({ children }: { children: React.ReactNode }) { ) -} +}) export default function App() { return diff --git a/sandbox/react-router/package.json b/sandbox/react-router/package.json index 5f4defb6286..a8e15470fef 100644 --- a/sandbox/react-router/package.json +++ b/sandbox/react-router/package.json @@ -11,6 +11,9 @@ }, "dependencies": { "@chakra-ui/react": "workspace:*", + "@emotion/cache": "^11.14.0", + "@emotion/react": "^11.14.0", + "@emotion/server": "^11.11.0", "@react-router/node": "^7.6.2", "@react-router/serve": "^7.6.2", "isbot": "^5.1.28",