react-server675fbba4
react-servercommit1c22b3619708

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.

Author
Viktor Lázár <lazarv1982@gmail.com>
Date
Commit
1c22b36197088ead241ba1fefb9741e0e59d5a99
11 files changed+331 -22