Skip to content

Commit 1c22b36

Browse files
authored
feat: use client no-ssr (#413)
## 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.
1 parent 6f0104d commit 1c22b36

11 files changed

Lines changed: 331 additions & 22 deletions

File tree

docs/src/components/ReactViteScene.jsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
1-
"use client";
1+
"use client; no-ssr";
22

33
import { useRef, useEffect } from "react";
4+
45
import * as THREE from "three";
56
import { EffectComposer } from "three/examples/jsm/postprocessing/EffectComposer.js";
7+
import { OutputPass } from "three/examples/jsm/postprocessing/OutputPass.js";
68
import { RenderPass } from "three/examples/jsm/postprocessing/RenderPass.js";
79
import { UnrealBloomPass } from "three/examples/jsm/postprocessing/UnrealBloomPass.js";
8-
import { OutputPass } from "three/examples/jsm/postprocessing/OutputPass.js";
10+
11+
// `"use client; no-ssr"` keeps three.js out of the SSR/edge bundle: the
12+
// SSR build replaces this module with a null-stub (no imports) and the
13+
// client build wraps the export in <ClientOnly>, so the heavy WebGL
14+
// shaders and post-processing helpers only ever ship — and only ever
15+
// execute — in the browser. Static imports here are deliberate; the
16+
// directive handles the "don't bundle on the server" half.
917

1018
function isDarkMode() {
1119
return document.documentElement.classList.contains("dark");

docs/src/pages/en/(pages)/guide/client-components.mdx

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,3 +327,63 @@ export default function MyServerComponent() {
327327
);
328328
}
329329
```
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+
<Link name="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 * as THREE from "three";
349+
350+
export default function Scene() {
351+
const ref = useRef(null);
352+
353+
useEffect(() => {
354+
const renderer = new THREE.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+
import Scene from "./components/Scene.jsx";
368+
369+
export default function Page() {
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.

docs/src/pages/ja/(pages)/guide/client-components.mdx

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,3 +255,63 @@ export default function MyServerComponent() {
255255
);
256256
}
257257
```
258+
259+
`ClientOnly`*レンダリング*ガードです — 子コンポーネントがいつレンダリングされるかは制御しますが、何がバンドルされるかは制御しません。ラップされたコンポーネントとそのインポートは依然としてSSRバンドルに含まれ、サーバーレンダリング中に実行されます。ブラウザ専用の重い依存関係(WebGL、Canvas、IntersectionObserverベースのライブラリ、モジュールスコープで`window`に触れるものすべて)を読み込むコンポーネントには、以下の[`"use client; no-ssr"`](#no-ssr-client-components)ディレクティブを使用してください — 実装そのものをSSRバンドルから完全に取り除きます。
260+
261+
<Link name="no-ssr-client-components">
262+
## SSRなしのクライアントコンポーネント
263+
</Link>
264+
265+
`"use client"`コンポーネントは、SSR中に依然としてサーバーでレンダリングされます — クライアントに延期されるのはそのインタラクティブ性だけです。そのモジュールグラフはSSRバンドルの一部であり、取り込まれるすべてのインポートはサーバーでバンドルされ評価されます。依存関係がブラウザでしか意味を持たないコンポーネント — Three.js、チャートライブラリ、コードエディタ、モジュールスコープで`window``document`に触れるもの — の場合、これは空のラッパーをレンダリングするためだけに数百KiBのコードをエッジワーカーに配信し(そして解析する)ことを意味します。
266+
267+
`"use client; no-ssr"`ディレクティブはこれを回避します。ランタイムに対して、モジュールを環境ごとに異なる方法でコンパイルするよう指示します:
268+
269+
- **サーバービルド**: モジュールはnullを返すスタブに置き換えられます。元のコードもそのインポートも、SSRバンドルには現れません。
270+
- **クライアントビルド**: モジュールは`ClientOnly`で自動的にラップされます。実コンポーネントは通常通り依存関係をインポートしますが、ハイドレーション後にのみレンダリングされます — サーバーのnull出力と一致し、ハイドレーション不一致を回避します。
271+
272+
```jsx filename="src/components/Scene.jsx"
273+
"use client; no-ssr";
274+
275+
import { useEffect, useRef } from "react";
276+
import * as THREE from "three";
277+
278+
export default function Scene() {
279+
const ref = useRef(null);
280+
281+
useEffect(() => {
282+
const renderer = new THREE.WebGLRenderer();
283+
ref.current.appendChild(renderer.domElement);
284+
// ...
285+
return () => renderer.dispose();
286+
}, []);
287+
288+
return <div ref={ref} />;
289+
}
290+
```
291+
292+
サーバーコンポーネントから使用すると、インポートは他のクライアントコンポーネントとまったく同じに見えます:
293+
294+
```jsx
295+
import Scene from "./components/Scene.jsx";
296+
297+
export default function Page() {
298+
return (
299+
<main>
300+
<h1>Welcome</h1>
301+
<Scene />
302+
</main>
303+
);
304+
}
305+
```
306+
307+
サーバーは`<main><h1>Welcome</h1></main>`をレンダリングします — `<Scene />`のマークアップも、Three.jsの評価も、SSRバンドル内のThree.jsのコードもありません。ページがハイドレーションされた後、ブラウザは`Scene`チャンクを読み込み、エフェクトを実行し、その場所にcanvasをマウントします。
308+
309+
次の場合に`"use client; no-ssr"`を使用してください:
310+
311+
- コンポーネントがモジュールスコープでブラウザ専用のグローバル(`window``document``navigator`、WebGL/Canvasコンテキスト)に依存する。
312+
- 依存関係グラフが大きく、ブラウザでのみ意味を持つ(3Dシーン、リッチテキストエディタ、動画プレイヤー、DOM計測を伴うチャートライブラリ)。
313+
- そのモジュールを、コンポーネントが現れるページでのみ読み込まれる別のクライアントチャンクにしたい。
314+
315+
ブラウザ専用の依存関係を持たないインタラクティブなコンポーネントには、通常の`"use client"`が適切なデフォルトです — ハイドレーションが完了するまでにユーザーが目にするSSRマークアップを無料で得られます。
316+
317+
> **注意:** `ClientOnly`と同様に、`"use client; no-ssr"`コンポーネントはハイドレーション後までは何もレンダリングしません。代替(SSRマークアップなし)が許容できるコンポーネントに留めて、レイアウトの安定性のために周囲にプレースホルダをレンダリングすることを検討してください。

packages/react-server/lib/build/server.mjs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { serverReferenceMapVirtual } from "../plugins/server-reference-map.mjs";
3636
import { clientReferenceMapVirtual } from "../plugins/client-reference-map.mjs";
3737
import * as sys from "../sys.mjs";
3838
import { parse as parseAst } from "../utils/ast.mjs";
39+
import { parseClientDirective } from "../utils/directives.mjs";
3940
import { makeResolveAlias } from "../utils/config.mjs";
4041
import merge from "../utils/merge.mjs";
4142
import {
@@ -337,8 +338,13 @@ export default async function serverBuild(root, options, clientManifestBus) {
337338
let isGlobalErrorClientComponent = false;
338339
try {
339340
const code = await readFile(globalError, "utf8");
341+
// Substring check is intentionally loose: it matches both the bare
342+
// `"use client"` directive and any modifier form (`"use client;
343+
// no-ssr"`, `"use client; deferred"`, …) by leaving the closing
344+
// quote off. Refining further would require a full parse just to
345+
// set a single boolean — not worth it for the global error file.
340346
isGlobalErrorClientComponent =
341-
code.includes(`"use client"`) || code.includes(`'use client'`);
347+
code.includes('"use client') || code.includes("'use client");
342348
} catch {
343349
// ignore
344350
}
@@ -373,13 +379,14 @@ export default async function serverBuild(root, options, clientManifestBus) {
373379
if (rootModulePath && !rootModulePath.startsWith("virtual:")) {
374380
try {
375381
const code = await readFile(rootModulePath, "utf8");
376-
if (code.includes(`"use client"`) || code.includes(`'use client'`)) {
382+
if (code.includes('"use client') || code.includes("'use client")) {
377383
const ast = await parseAst(code, rootModulePath);
378384
if (ast) {
379385
const directives = ast.body
380386
.filter((node) => node.type === "ExpressionStatement")
381387
.map(({ directive }) => directive);
382-
isClientRootBuild = directives.includes("use client");
388+
isClientRootBuild =
389+
parseClientDirective(directives)?.isClient ?? false;
383390
}
384391
}
385392
} catch {

packages/react-server/lib/plugins/file-router/plugin.mjs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { basename, dirname, extname, join, relative } from "node:path";
66
import { fileURLToPath, pathToFileURL } from "node:url";
77

88
import { parse as parseAST } from "../../utils/ast.mjs";
9+
import { parseClientDirective } from "../../utils/directives.mjs";
910
import { loadConfig } from "@lazarv/react-server/config";
1011
import { forChild, forRoot } from "@lazarv/react-server/config/context.mjs";
1112
import * as sys from "@lazarv/react-server/lib/sys.mjs";
@@ -168,7 +169,7 @@ async function isClientPageSource(src) {
168169
const directives = ast.body
169170
.filter((node) => node.type === "ExpressionStatement")
170171
.map(({ directive }) => directive);
171-
if (!directives.includes("use client")) {
172+
if (!parseClientDirective(directives)?.isClient) {
172173
clientPageCache.set(src, false);
173174
return false;
174175
}
@@ -276,7 +277,7 @@ async function isClientSource(src) {
276277
const directives = ast.body
277278
.filter((node) => node.type === "ExpressionStatement")
278279
.map(({ directive }) => directive);
279-
return directives.includes("use client");
280+
return parseClientDirective(directives)?.isClient ?? false;
280281
}
281282

282283
/**

packages/react-server/lib/plugins/use-client-inline.mjs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { parseClientDirective } from "../utils/directives.mjs";
2+
13
// Inject captured scope variables as destructured props for client components.
24
function injectCapturedParams(fnSource, targetFn, capturedVars) {
35
const capturedList = capturedVars.join(", ");
@@ -45,7 +47,8 @@ function injectCapturedParams(fnSource, targetFn, capturedVars) {
4547
export const useClientInlineConfig = {
4648
directive: "use client",
4749
queryKey: "use-client-inline",
48-
skipIfModuleDirective: ["use client"],
50+
skipIfModuleDirective: (moduleDirectives) =>
51+
parseClientDirective(moduleDirectives)?.isClient ?? false,
4952
injectCapturedParams,
5053
buildCallSiteReplacement(importName, inlineId, capturedVars) {
5154
if (capturedVars.length === 0) return null; // use default (inline import)

0 commit comments

Comments
 (0)