You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
## Summary
Introduces a new react-server directive, `"use client; no-ssr"`, for
client components whose dependency graph only makes sense in the
browser. A plain `"use client"` module still renders during SSR — its
imports get bundled and evaluated on the server even though only the
interactivity ships to the client. For components that pull in WebGL
contexts, charting libraries, code editors, or anything else that
touches `window` at module scope, that means hundreds of KiB of
JavaScript landing in the SSR/edge worker bundle to render an empty
wrapper.
The new directive replaces the module with a null-rendering stub in the
SSR build (no imports of the implementation graph at all) and emits a
wrapper in the client build that imports the original through a virtual
id and renders it inside `<ClientOnly>`. The two halves match up at
hydration: SSR sends nothing, the client renders nothing on the first
pass, then transitions to the real component after `useEffect`. No
hydration mismatch, no heavy code in the worker.
## How it's wired
The transform lives in `lib/plugins/use-client.mjs`. The RSC build
branch is unchanged (`registerClientReference`). The SSR branch, when it
sees a no-ssr module, walks the AST for the default and named exports
and emits stubs that all return `null`. The client branch, gated on
`enforce: "pre"` in the build mode only, emits a `"use client"` wrapper
module that imports the original via
`virtual:no-ssr-original:<absolute-path>`. The corresponding
`resolveId`/`load` pair on the same plugin returns the file content
verbatim; an early-return at the top of the transform handler skips the
virtual id so the wrapper logic doesn't recurse on itself.
Directive matching is centralised in a new `lib/utils/directives.mjs`
helper, `parseClientDirective(directives)`. The grammar is permissive —
it splits on `;` and trims segments — so `"use client"`, `"use
client;no-ssr"`, `"use client; no-ssr"`, `"use client; no-ssr"`, and
`"use client ; no-ssr"` all parse to the same `{ isClient, isNoSSR }`
shape. Every directive-aware site in the runtime now goes through the
parser instead of enumerating string variants: `use-client.mjs`,
`use-server.mjs`, `file-router/plugin.mjs` (`isClientPageSource` and
`isClientSource`), and `build/server.mjs` root detection.
`use-directive-inline.mjs` was extended so `skipIfModuleDirective` can
be a predicate, and `use-client-inline.mjs` switched to that form to
handle whitespace variants without enumeration. Substring fast-paths in
`lib/utils/module.mjs` and `lib/build/server.mjs` had their closing
quotes dropped (`'"use client'` instead of `'"use client"'`) so any
modifier form is captured by the cheap pre-filter.
## Demo: docs site
`docs/src/components/ReactViteScene.jsx` was the canary. It previously
routed Three.js through dynamic `import()` inside `useEffect` to keep
~520 KiB of WebGL out of the worker bundle. With the new directive in
place, the dynamic-import workaround is gone and the imports are static
again. Verified against the produced bundle: the Cloudflare worker
output (`.cloudflare/worker/.react-server/server/edge.mjs` and the
surrounding chunks) has zero references to `WebGLRenderer`,
`MeshPhysicalMaterial`, `EffectComposer`, `UnrealBloomPass`,
`PMREMGenerator`, `ACESFilmicToneMapping`, or any other Three.js
identifier — the SSR-side `ReactViteScene` chunk is the 116-byte null
stub. The client bundle keeps the full 566 KiB Three.js chunk and only
loads it on pages that actually mount the scene.
Copy file name to clipboardExpand all lines: docs/src/pages/en/(pages)/guide/client-components.mdx
+60Lines changed: 60 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -327,3 +327,63 @@ export default function MyServerComponent() {
327
327
);
328
328
}
329
329
```
330
+
331
+
`ClientOnly` is a *rendering* guard — it controls when its children render, but it does not control what gets bundled. The wrapped component and its imports still ship in the SSR bundle and run during server rendering. For components that pull in heavy browser-only dependencies (WebGL, Canvas, IntersectionObserver-based libraries, anything that touches `window` at module scope), use the [`"use client; no-ssr"`](#no-ssr-client-components) directive below — it removes the implementation from the SSR bundle entirely.
332
+
333
+
<Linkname="no-ssr-client-components">
334
+
## No-SSR client components
335
+
</Link>
336
+
337
+
A `"use client"` component still renders on the server during SSR — only its interactivity is deferred to the client. Its module graph is part of the SSR bundle, so any imports it pulls in are bundled and evaluated on the server. For components whose dependencies only make sense in the browser — Three.js, charting libraries, code editors, anything that touches `window` or `document` at module scope — that means shipping (and parsing) hundreds of KiB of code into your edge worker just to render an empty wrapper.
338
+
339
+
The `"use client; no-ssr"` directive avoids this. It tells the runtime to compile the module differently for each environment:
340
+
341
+
-**Server build**: the module is replaced with a null-rendering stub. None of the original code or its imports appear in the SSR bundle.
342
+
-**Client build**: the module is wrapped in `ClientOnly` automatically. The real component imports its dependencies as normal, but only renders after hydration — matching the server's null output and avoiding hydration mismatch.
343
+
344
+
```jsx filename="src/components/Scene.jsx"
345
+
"use client; no-ssr";
346
+
347
+
import { useEffect, useRef } from"react";
348
+
import*asTHREEfrom"three";
349
+
350
+
exportdefaultfunctionScene() {
351
+
constref=useRef(null);
352
+
353
+
useEffect(() => {
354
+
constrenderer=newTHREE.WebGLRenderer();
355
+
ref.current.appendChild(renderer.domElement);
356
+
// ...
357
+
return () =>renderer.dispose();
358
+
}, []);
359
+
360
+
return<div ref={ref} />;
361
+
}
362
+
```
363
+
364
+
Used from a server component, the import looks identical to any other client component:
365
+
366
+
```jsx
367
+
importScenefrom"./components/Scene.jsx";
368
+
369
+
exportdefaultfunctionPage() {
370
+
return (
371
+
<main>
372
+
<h1>Welcome</h1>
373
+
<Scene />
374
+
</main>
375
+
);
376
+
}
377
+
```
378
+
379
+
The server renders `<main><h1>Welcome</h1></main>` — no `<Scene />` markup, no Three.js evaluation, no Three.js code in the SSR bundle. After the page hydrates, the browser loads the `Scene` chunk, runs the effect, and mounts the canvas in place.
380
+
381
+
Reach for `"use client; no-ssr"` when:
382
+
383
+
- The component depends on browser-only globals (`window`, `document`, `navigator`, WebGL/Canvas contexts) at module scope.
384
+
- The dependency graph is large and only meaningful in the browser (3D scenes, rich-text editors, video players, charting libraries with DOM measurement).
385
+
- You want the module to be a separate client chunk that loads only on pages where the component appears.
386
+
387
+
For interactive components that have no browser-only dependencies, plain `"use client"` is the right default — it gives you SSR markup for free, which is what users see before hydration completes.
388
+
389
+
> **Note:** Like `ClientOnly`, a `"use client; no-ssr"` component renders nothing until after hydration. Reserve it for components where the alternative (no SSR markup) is acceptable, and consider rendering a placeholder around it for layout stability.
0 commit comments