Skip to content

feat: use client no-ssr#413

Merged
lazarv merged 1 commit intomainfrom
feat/use-client-no-ssr
Apr 29, 2026
Merged

feat: use client no-ssr#413
lazarv merged 1 commit intomainfrom
feat/use-client-no-ssr

Conversation

@lazarv
Copy link
Copy Markdown
Owner

@lazarv lazarv commented Apr 29, 2026

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.

@cloudflare-workers-and-pages
Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
react-server-docs 62fe116 Apr 29 2026, 12:07 PM

@github-actions
Copy link
Copy Markdown

⚡ Flight Protocol Benchmark

Commit: ec285ff

Serialization (renderToReadableStream)

Scenario @lazarv/rsc webpack vs webpack
react: minimal element 218.1K 27.5K 🟢 +691.8%
react: shallow wide (1000) 2.0K 332 🟢 +513.9%
react: deep nested (100) 17.2K 5.5K 🟢 +214.1%
react: product list (50) 6.1K 2.0K 🟢 +209.6%
react: large table (500x10) 280 88 🟢 +219.7%
data: primitives 178.1K 35.0K 🟢 +408.4%
data: large string (100KB) 7.2K 7.2K ⚪ -0.6%
data: nested objects (20) 57.9K 25.8K 🟢 +124.7%
data: large array (10K) 115 110 🟢 +4.8%
data: Map & Set 10.9K 5.6K 🟢 +96.6%
data: Date/BigInt/Symbol 162.6K 33.6K 🟢 +384.4%
data: typed arrays 32.6K 13.2K 🟢 +146.4%
data: mixed payload 8.2K 4.1K 🟢 +100.0%

Prerender (prerender)

Scenario @lazarv/rsc ops/s mean
react: minimal element 234.5K 4.3 µs
react: shallow wide (1000) 2.0K 504.8 µs
react: deep nested (100) 15.8K 63.2 µs
react: product list (50) 5.5K 182.9 µs
react: large table (500x10) 265 3.77 ms
data: primitives 188.8K 5.3 µs
data: large string (100KB) 682 1.47 ms
data: nested objects (20) 56.5K 17.7 µs
data: large array (10K) 112 8.92 ms
data: Map & Set 11.0K 91.2 µs
data: Date/BigInt/Symbol 180.6K 5.5 µs
data: typed arrays 675 1.48 ms
data: mixed payload 7.5K 134.2 µs

Deserialization (createFromReadableStream)

Scenario @lazarv/rsc webpack vs webpack
react: minimal element 168.3K 129.9K 🟢 +29.5%
react: shallow wide (1000) 22.7K 1.9K 🟢 +1114.7%
react: deep nested (100) 101.2K 19.0K 🟢 +433.8%
react: product list (50) 52.6K 14.3K 🟢 +268.9%
react: large table (500x10) 4.0K 2.1K 🟢 +87.5%
data: primitives 138.3K 125.5K 🟢 +10.2%
data: large string (100KB) 35.6K 34.2K 🟢 +4.2%
data: nested objects (20) 83.1K 71.2K 🟢 +16.6%
data: large array (10K) 280 249 🟢 +12.7%
data: Map & Set 16.5K 14.4K 🟢 +14.3%
data: Date/BigInt/Symbol 134.5K 109.0K 🟢 +23.4%
data: typed arrays 54.8K 43.2K 🟢 +26.7%
data: mixed payload 25.8K 14.0K 🟢 +84.8%

Roundtrip (serialize + deserialize)

Scenario @lazarv/rsc webpack vs webpack
react: minimal element 102.1K 20.7K 🟢 +392.6%
react: shallow wide (1000) 1.8K 266 🟢 +564.1%
react: deep nested (100) 14.5K 4.3K 🟢 +240.1%
react: product list (50) 5.4K 1.7K 🟢 +222.4%
react: large table (500x10) 257 85 🟢 +203.8%
data: primitives 79.4K 29.8K 🟢 +166.8%
data: large string (100KB) 6.1K 6.6K 🔴 -7.7%
data: nested objects (20) 33.7K 17.9K 🟢 +88.2%
data: large array (10K) 82 76 🟢 +7.1%
data: Map & Set 6.2K 3.8K 🟢 +62.2%
data: Date/BigInt/Symbol 70.7K 20.8K 🟢 +239.9%
data: typed arrays 26.0K 11.1K 🟢 +134.3%
data: mixed payload 5.9K 3.1K 🟢 +94.5%
Legend & methodology

Indicators: 🟢 > 1% faster | 🔴 > 1% slower | ⚪ within noise margin

vs webpack: compares @lazarv/rsc against react-server-dom-webpack within the same run.
vs baseline: compares @lazarv/rsc against the previous main branch run.

Values shown are operations/second (higher is better). Each scenario runs for at least 100 iterations with warmup.

Benchmarks run on GitHub Actions runners (shared infrastructure) — expect ~5% variance between runs. Consistent directional changes across multiple scenarios are more meaningful than any single number.

@github-actions
Copy link
Copy Markdown

⚡ Benchmark Results

PR 62fe116 main 6f0104d
Config 50 connections, 10s/test 50 connections, 10s/test
Benchmark Req/s vs main Avg Latency vs main P99 Latency Throughput
minimal 1480 🟢 +13.9% 33.19 ms 🟢 -12.3% 70 ms 1.0 MB/s
small 1548 🟢 +20.4% 31.7 ms 🟢 -17.1% 60 ms 1.6 MB/s
medium 404 🟢 +2.0% 122.6 ms 🟢 -1.3% 180 ms 6.0 MB/s
large 48 🟢 +4.0% 1009.16 ms 🔴 +1.1% 1903 ms 4.9 MB/s
deep 991 🟢 +5.3% 49.77 ms 🟢 -5.2% 84 ms 3.4 MB/s
wide 69 🟢 +2.5% 682.29 ms 🟢 -2.6% 1057 ms 3.8 MB/s
cached 4182 🟢 +20.3% 11.42 ms 🟢 -17.2% 24 ms 61.6 MB/s
client-min 580 🟢 +26.0% 85.03 ms 🟢 -20.5% 139 ms 2.5 MB/s
client-small 605 🟢 +23.0% 81.82 ms 🟢 -18.5% 126 ms 2.9 MB/s
client-med 414 🟢 +17.5% 119.29 ms 🟢 -14.4% 172 ms 7.7 MB/s
client-large 83 🔴 -2.0% 572.76 ms 🟢 -2.3% 1001 ms 8.7 MB/s
client-deep 536 🟢 +20.3% 91.9 ms 🟢 -17.1% 138 ms 3.8 MB/s
client-wide 142 ⚪ -0.7% 349.39 ms 🔴 +1.8% 690 ms 8.3 MB/s
rsc-client-large 1270 🟢 +17.1% 38.75 ms 🟢 -14.7% 60 ms 3.3 MB/s
rsc-client-wide 1298 🟢 +16.2% 37.88 ms 🟢 -14.0% 59 ms 3.4 MB/s
static-json 12407 🟢 +61.9% 3.65 ms 🟢 -41.1% 13 ms 5.2 MB/s
static-js 12314 🟢 +57.0% 3.63 ms 🟢 -40.1% 13 ms 15.4 MB/s
404-miss 6699 🟢 +36.7% 6.81 ms 🟢 -29.4% 14 ms 0.8 MB/s
hybrid-min 560 🟢 +19.6% 88.15 ms 🟢 -16.6% 137 ms 2.7 MB/s
hybrid-small 547 🟢 +25.7% 90.71 ms 🟢 -20.4% 137 ms 3.2 MB/s
hybrid-medium 261 🟢 +10.2% 189.36 ms 🟢 -8.9% 274 ms 11.1 MB/s
hybrid-large 42 🟢 +1.3% 1091.53 ms 🟢 -3.2% 2067 ms 13.7 MB/s
hybrid-deep 420 🟢 +16.1% 117.74 ms 🟢 -13.9% 170 ms 5.8 MB/s
hybrid-wide 63 ⚪ -0.9% 768.7 ms ⚪ -0.2% 1335 ms 12.4 MB/s
hybrid-cached 3224 🟢 +6.3% 14.96 ms 🟢 -6.4% 29 ms 137.3 MB/s
hybrid-client-min 610 🟢 +27.9% 80.95 ms 🟢 -22.1% 123 ms 2.7 MB/s
hybrid-client-small 605 🟢 +23.5% 81.84 ms 🟢 -19.1% 126 ms 2.9 MB/s
hybrid-client-medium 411 🟢 +13.6% 120.04 ms 🟢 -11.8% 177 ms 7.6 MB/s
hybrid-client-large 80 ⚪ -0.5% 587.4 ms 🟢 -1.0% 1096 ms 8.4 MB/s
hybrid-client-deep 521 🟢 +16.9% 94.86 ms 🟢 -14.3% 142 ms 3.8 MB/s
hybrid-client-wide 136 🔴 -3.6% 361.39 ms 🔴 +4.0% 633 ms 8.0 MB/s
Legend

🟢 > 1% improvement | 🔴 > 1% regression | ⚪ within noise margin

Benchmarks run on GitHub Actions runners (shared infrastructure) — expect ~5% variance between runs. Consistent directional changes across multiple routes are more meaningful than any single number.

@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented Apr 29, 2026

❌ 1 Tests Failed:

Tests completed Failed Passed Skipped
973 1 972 3
View the top 3 failed test(s) by shortest run time
__test__/deno.spec.mjs > create-react-server: deno runtime (pnpm) > preset: get-started-ts > starts in production mode
Stack Traces | 0.000641s run time
AssertionError: production start should work: expected false to be true // Object.is equality

- Expected
+ Received

- true
+ false

 ❯ __test__/deno.spec.mjs:74:66
__test__/deno.spec.mjs > create-react-server: deno runtime (pnpm) > preset: get-started-ts > builds the app
Stack Traces | 0.000816s run time
AssertionError: build should succeed: expected false to be true // Object.is equality

- Expected
+ Received

- true
+ false

 ❯ __test__/deno.spec.mjs:70:58
__test__/deno.spec.mjs > create-react-server: deno runtime (npm) > preset: router > starts in production mode
Stack Traces | 0.00127s run time
AssertionError: production start should work: expected false to be true // Object.is equality

- Expected
+ Received

- true
+ false

 ❯ __test__/deno.spec.mjs:74:66
__test__/deno.spec.mjs > create-react-server: deno runtime (pnpm) > preset: get-started-ts > dev mode starts and serves the app
Stack Traces | 0.00454s run time
AssertionError: dev mode should work: expected false to be true // Object.is equality

- Expected
+ Received

- true
+ false

 ❯ __test__/deno.spec.mjs:66:56
__test__/deno.spec.mjs > create-react-server: deno runtime (npm) > preset: router > builds the app
Stack Traces | 0.00933s run time
AssertionError: build should succeed: expected false to be true // Object.is equality

- Expected
+ Received

- true
+ false

 ❯ __test__/deno.spec.mjs:70:58
__test__/scroll-restoration.spec.mjs > scroll restoration: multiple back/forward preserves positions
Stack Traces | 27.8s run time
AssertionError: expected 0 to be greater than 400
 ❯ __test__/scroll-restoration.spec.mjs:158:25
__test__/deno.spec.mjs > preset: get-started
Stack Traces | 300s run time
Error: Hook timed out in 300000ms.
If this is a long-running hook, pass a timeout value as the last argument or configure it globally with "hookTimeout".
 ❯ __test__/deno.spec.mjs:46:18

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

@lazarv lazarv merged commit 1c22b36 into main Apr 29, 2026
109 of 112 checks passed
@lazarv lazarv deleted the feat/use-client-no-ssr branch April 29, 2026 12:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants