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
12 changes: 10 additions & 2 deletions docs/src/components/ReactViteScene.jsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
"use client";
"use client; no-ssr";

import { useRef, useEffect } from "react";

import * as THREE from "three";
import { EffectComposer } from "three/examples/jsm/postprocessing/EffectComposer.js";
import { OutputPass } from "three/examples/jsm/postprocessing/OutputPass.js";
import { RenderPass } from "three/examples/jsm/postprocessing/RenderPass.js";
import { UnrealBloomPass } from "three/examples/jsm/postprocessing/UnrealBloomPass.js";
import { OutputPass } from "three/examples/jsm/postprocessing/OutputPass.js";

// `"use client; no-ssr"` keeps three.js out of the SSR/edge bundle: the
// SSR build replaces this module with a null-stub (no imports) and the
// client build wraps the export in <ClientOnly>, so the heavy WebGL
// shaders and post-processing helpers only ever ship — and only ever
// execute — in the browser. Static imports here are deliberate; the
// directive handles the "don't bundle on the server" half.

function isDarkMode() {
return document.documentElement.classList.contains("dark");
Expand Down
60 changes: 60 additions & 0 deletions docs/src/pages/en/(pages)/guide/client-components.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -327,3 +327,63 @@ export default function MyServerComponent() {
);
}
```

`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.

<Link name="no-ssr-client-components">
## No-SSR client components
</Link>

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.

The `"use client; no-ssr"` directive avoids this. It tells the runtime to compile the module differently for each environment:

- **Server build**: the module is replaced with a null-rendering stub. None of the original code or its imports appear in the SSR bundle.
- **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.

```jsx filename="src/components/Scene.jsx"
"use client; no-ssr";

import { useEffect, useRef } from "react";
import * as THREE from "three";

export default function Scene() {
const ref = useRef(null);

useEffect(() => {
const renderer = new THREE.WebGLRenderer();
ref.current.appendChild(renderer.domElement);
// ...
return () => renderer.dispose();
}, []);

return <div ref={ref} />;
}
```

Used from a server component, the import looks identical to any other client component:

```jsx
import Scene from "./components/Scene.jsx";

export default function Page() {
return (
<main>
<h1>Welcome</h1>
<Scene />
</main>
);
}
```

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.

Reach for `"use client; no-ssr"` when:

- The component depends on browser-only globals (`window`, `document`, `navigator`, WebGL/Canvas contexts) at module scope.
- The dependency graph is large and only meaningful in the browser (3D scenes, rich-text editors, video players, charting libraries with DOM measurement).
- You want the module to be a separate client chunk that loads only on pages where the component appears.

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.

> **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.
60 changes: 60 additions & 0 deletions docs/src/pages/ja/(pages)/guide/client-components.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -255,3 +255,63 @@ export default function MyServerComponent() {
);
}
```

`ClientOnly`は*レンダリング*ガードです — 子コンポーネントがいつレンダリングされるかは制御しますが、何がバンドルされるかは制御しません。ラップされたコンポーネントとそのインポートは依然としてSSRバンドルに含まれ、サーバーレンダリング中に実行されます。ブラウザ専用の重い依存関係(WebGL、Canvas、IntersectionObserverベースのライブラリ、モジュールスコープで`window`に触れるものすべて)を読み込むコンポーネントには、以下の[`"use client; no-ssr"`](#no-ssr-client-components)ディレクティブを使用してください — 実装そのものをSSRバンドルから完全に取り除きます。

<Link name="no-ssr-client-components">
## SSRなしのクライアントコンポーネント
</Link>

`"use client"`コンポーネントは、SSR中に依然としてサーバーでレンダリングされます — クライアントに延期されるのはそのインタラクティブ性だけです。そのモジュールグラフはSSRバンドルの一部であり、取り込まれるすべてのインポートはサーバーでバンドルされ評価されます。依存関係がブラウザでしか意味を持たないコンポーネント — Three.js、チャートライブラリ、コードエディタ、モジュールスコープで`window`や`document`に触れるもの — の場合、これは空のラッパーをレンダリングするためだけに数百KiBのコードをエッジワーカーに配信し(そして解析する)ことを意味します。

`"use client; no-ssr"`ディレクティブはこれを回避します。ランタイムに対して、モジュールを環境ごとに異なる方法でコンパイルするよう指示します:

- **サーバービルド**: モジュールはnullを返すスタブに置き換えられます。元のコードもそのインポートも、SSRバンドルには現れません。
- **クライアントビルド**: モジュールは`ClientOnly`で自動的にラップされます。実コンポーネントは通常通り依存関係をインポートしますが、ハイドレーション後にのみレンダリングされます — サーバーのnull出力と一致し、ハイドレーション不一致を回避します。

```jsx filename="src/components/Scene.jsx"
"use client; no-ssr";

import { useEffect, useRef } from "react";
import * as THREE from "three";

export default function Scene() {
const ref = useRef(null);

useEffect(() => {
const renderer = new THREE.WebGLRenderer();
ref.current.appendChild(renderer.domElement);
// ...
return () => renderer.dispose();
}, []);

return <div ref={ref} />;
}
```

サーバーコンポーネントから使用すると、インポートは他のクライアントコンポーネントとまったく同じに見えます:

```jsx
import Scene from "./components/Scene.jsx";

export default function Page() {
return (
<main>
<h1>Welcome</h1>
<Scene />
</main>
);
}
```

サーバーは`<main><h1>Welcome</h1></main>`をレンダリングします — `<Scene />`のマークアップも、Three.jsの評価も、SSRバンドル内のThree.jsのコードもありません。ページがハイドレーションされた後、ブラウザは`Scene`チャンクを読み込み、エフェクトを実行し、その場所にcanvasをマウントします。

次の場合に`"use client; no-ssr"`を使用してください:

- コンポーネントがモジュールスコープでブラウザ専用のグローバル(`window`、`document`、`navigator`、WebGL/Canvasコンテキスト)に依存する。
- 依存関係グラフが大きく、ブラウザでのみ意味を持つ(3Dシーン、リッチテキストエディタ、動画プレイヤー、DOM計測を伴うチャートライブラリ)。
- そのモジュールを、コンポーネントが現れるページでのみ読み込まれる別のクライアントチャンクにしたい。

ブラウザ専用の依存関係を持たないインタラクティブなコンポーネントには、通常の`"use client"`が適切なデフォルトです — ハイドレーションが完了するまでにユーザーが目にするSSRマークアップを無料で得られます。

> **注意:** `ClientOnly`と同様に、`"use client; no-ssr"`コンポーネントはハイドレーション後までは何もレンダリングしません。代替(SSRマークアップなし)が許容できるコンポーネントに留めて、レイアウトの安定性のために周囲にプレースホルダをレンダリングすることを検討してください。
13 changes: 10 additions & 3 deletions packages/react-server/lib/build/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { serverReferenceMapVirtual } from "../plugins/server-reference-map.mjs";
import { clientReferenceMapVirtual } from "../plugins/client-reference-map.mjs";
import * as sys from "../sys.mjs";
import { parse as parseAst } from "../utils/ast.mjs";
import { parseClientDirective } from "../utils/directives.mjs";
import { makeResolveAlias } from "../utils/config.mjs";
import merge from "../utils/merge.mjs";
import {
Expand Down Expand Up @@ -337,8 +338,13 @@ export default async function serverBuild(root, options, clientManifestBus) {
let isGlobalErrorClientComponent = false;
try {
const code = await readFile(globalError, "utf8");
// Substring check is intentionally loose: it matches both the bare
// `"use client"` directive and any modifier form (`"use client;
// no-ssr"`, `"use client; deferred"`, …) by leaving the closing
// quote off. Refining further would require a full parse just to
// set a single boolean — not worth it for the global error file.
isGlobalErrorClientComponent =
code.includes(`"use client"`) || code.includes(`'use client'`);
code.includes('"use client') || code.includes("'use client");
} catch {
// ignore
}
Expand Down Expand Up @@ -373,13 +379,14 @@ export default async function serverBuild(root, options, clientManifestBus) {
if (rootModulePath && !rootModulePath.startsWith("virtual:")) {
try {
const code = await readFile(rootModulePath, "utf8");
if (code.includes(`"use client"`) || code.includes(`'use client'`)) {
if (code.includes('"use client') || code.includes("'use client")) {
const ast = await parseAst(code, rootModulePath);
if (ast) {
const directives = ast.body
.filter((node) => node.type === "ExpressionStatement")
.map(({ directive }) => directive);
isClientRootBuild = directives.includes("use client");
isClientRootBuild =
parseClientDirective(directives)?.isClient ?? false;
}
}
} catch {
Expand Down
5 changes: 3 additions & 2 deletions packages/react-server/lib/plugins/file-router/plugin.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { basename, dirname, extname, join, relative } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";

import { parse as parseAST } from "../../utils/ast.mjs";
import { parseClientDirective } from "../../utils/directives.mjs";
import { loadConfig } from "@lazarv/react-server/config";
import { forChild, forRoot } from "@lazarv/react-server/config/context.mjs";
import * as sys from "@lazarv/react-server/lib/sys.mjs";
Expand Down Expand Up @@ -168,7 +169,7 @@ async function isClientPageSource(src) {
const directives = ast.body
.filter((node) => node.type === "ExpressionStatement")
.map(({ directive }) => directive);
if (!directives.includes("use client")) {
if (!parseClientDirective(directives)?.isClient) {
clientPageCache.set(src, false);
return false;
}
Expand Down Expand Up @@ -276,7 +277,7 @@ async function isClientSource(src) {
const directives = ast.body
.filter((node) => node.type === "ExpressionStatement")
.map(({ directive }) => directive);
return directives.includes("use client");
return parseClientDirective(directives)?.isClient ?? false;
}

/**
Expand Down
5 changes: 4 additions & 1 deletion packages/react-server/lib/plugins/use-client-inline.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { parseClientDirective } from "../utils/directives.mjs";

// Inject captured scope variables as destructured props for client components.
function injectCapturedParams(fnSource, targetFn, capturedVars) {
const capturedList = capturedVars.join(", ");
Expand Down Expand Up @@ -45,7 +47,8 @@ function injectCapturedParams(fnSource, targetFn, capturedVars) {
export const useClientInlineConfig = {
directive: "use client",
queryKey: "use-client-inline",
skipIfModuleDirective: ["use client"],
skipIfModuleDirective: (moduleDirectives) =>
parseClientDirective(moduleDirectives)?.isClient ?? false,
injectCapturedParams,
buildCallSiteReplacement(importName, inlineId, capturedVars) {
if (capturedVars.length === 0) return null; // use default (inline import)
Expand Down
Loading
Loading