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.