Skip to content

feat: typed outlets#409

Merged
lazarv merged 1 commit into
mainfrom
feat/typed-outlets
Apr 28, 2026
Merged

feat: typed outlets#409
lazarv merged 1 commit into
mainfrom
feat/typed-outlets

Conversation

@lazarv
Copy link
Copy Markdown
Owner

@lazarv lazarv commented Apr 28, 2026

Summary

Adds typed, per-outlet bound ReactServerComponent exposed through a new virtual module @lazarv/react-server/outlets, alongside the existing @lazarv/react-server/routes. Where @lazarv/react-server/routes types the consuming side of outlets (a layout's createLayout props), this PR types and packages the producing side, so call sites no longer hand-write <ReactServerComponent outlet="sidebar" url="/dashboard/nav" /> — instead they import a namespace per outlet and write <sidebar.Outlet url="/dashboard/nav" />.

The bound component closes over its outlet name (no stringly-typed prop), accepts a url typed against the same RouteImpl<T> union as Link.to (so typos are rejected and dynamic segments must be concrete), and returns a branded Outlet<"sidebar"> so the value can satisfy a createLayout slot of the same name without casts.

By default the outlet preloads on the server. When the bound component is rendered in a server component, the runtime resolves the url against the file-router manifest, locates the matching @outletName/…page.tsx (or the @outletName.default.tsx fallback), renders it, and passes the result as children to ReactServerComponent — so the SSR HTML contains the outlet content on first paint, no client round-trip. defer={true} and explicit children opt out (client-only fetch and full override respectively). To make this work cleanly, Outlet<Name> from the routes module is now exported so the outlets module can produce values that satisfy the same brand consumed by createLayout.

Implementation notes

The file-router plugin now collects unique outlet names from the manifest and generates two artifacts: a virtual @lazarv/react-server/outlets runtime module and a react-server-outlets.d.ts declaration written into .react-server/. The runtime module ships in two flavours selected by Vite environment — an async preloading variant for any server-side environment (RSC and SSR), and a sync forwarder for the client environment where the preload's server-only imports would crash a browser bundle. Build-mode SSR/client builds receive the simple variant through the resources fallback plugin's store, the same way @lazarv/react-server/routes is delivered today. Outlet names that aren't valid JavaScript identifiers (e.g. hyphenated directory names) are skipped from the module with a logger warning and stay reachable via the bare <ReactServerComponent outlet="…" /> form. Both generateRoutesDts and generateOutletsDts were rewritten as single template literals composed via .map(...).join("\n\n"), replacing the previous lines.push chains; the emitted output is byte-identical apart from now-exported Outlet<Name>.

Adjacent fix: default <Link> navigation with named outlets active

Surfacing this feature exposed a pre-existing bug in ClientProvider.navigate(). With outlets.size > 1 (which now happens any time a page mounts a <*.Outlet />), a default <Link> click — where target/local/root are unset and Link.jsx therefore passes outlet: undefined — entered the broadcast branch and fanned the navigation out to every active non-root outlet, skipping PAGE_ROOT. The user stayed on the previous page with the outlet slots rendering null. Fixed by narrowing the implicit broadcast: when options.outlet is undefined and the target pathname differs from the current pathname, default to PAGE_ROOT. Same-pathname updates (?filter=active-style URL changes) still broadcast, preserving the multi-pane filter use case the broadcast was designed for.

@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 2cab3e5 Apr 28 2026, 05:07 PM

@github-actions
Copy link
Copy Markdown

⚡ Flight Protocol Benchmark

Commit: 8ab9ae9

Serialization (renderToReadableStream)

Scenario @lazarv/rsc webpack vs webpack
react: minimal element 219.9K 26.0K 🟢 +744.0%
react: shallow wide (1000) 2.1K 311 🟢 +583.6%
react: deep nested (100) 17.4K 5.4K 🟢 +224.9%
react: product list (50) 6.1K 1.9K 🟢 +221.7%
react: large table (500x10) 287 104 🟢 +177.2%
data: primitives 173.6K 34.5K 🟢 +403.3%
data: large string (100KB) 7.2K 6.6K 🟢 +9.0%
data: nested objects (20) 57.2K 24.8K 🟢 +130.7%
data: large array (10K) 117 112 🟢 +4.4%
data: Map & Set 10.7K 5.6K 🟢 +90.8%
data: Date/BigInt/Symbol 161.9K 36.1K 🟢 +348.6%
data: typed arrays 32.7K 12.5K 🟢 +162.3%
data: mixed payload 8.1K 3.9K 🟢 +108.8%

Prerender (prerender)

Scenario @lazarv/rsc ops/s mean
react: minimal element 241.4K 4.1 µs
react: shallow wide (1000) 2.0K 510.7 µs
react: deep nested (100) 15.9K 62.8 µs
react: product list (50) 5.7K 176.6 µs
react: large table (500x10) 274 3.65 ms
data: primitives 188.8K 5.3 µs
data: large string (100KB) 675 1.48 ms
data: nested objects (20) 56.3K 17.8 µs
data: large array (10K) 115 8.70 ms
data: Map & Set 11.1K 90.2 µs
data: Date/BigInt/Symbol 181.0K 5.5 µs
data: typed arrays 660 1.51 ms
data: mixed payload 7.4K 135.3 µs

Deserialization (createFromReadableStream)

Scenario @lazarv/rsc webpack vs webpack
react: minimal element 163.5K 130.6K 🟢 +25.1%
react: shallow wide (1000) 22.9K 1.5K 🟢 +1460.4%
react: deep nested (100) 93.5K 19.3K 🟢 +385.4%
react: product list (50) 50.4K 11.8K 🟢 +326.9%
react: large table (500x10) 3.9K 1.8K 🟢 +113.7%
data: primitives 128.8K 112.0K 🟢 +14.9%
data: large string (100KB) 38.0K 35.7K 🟢 +6.4%
data: nested objects (20) 82.9K 69.8K 🟢 +18.9%
data: large array (10K) 285 257 🟢 +11.2%
data: Map & Set 16.3K 14.2K 🟢 +14.7%
data: Date/BigInt/Symbol 136.6K 109.9K 🟢 +24.2%
data: typed arrays 54.4K 42.6K 🟢 +27.8%
data: mixed payload 25.8K 14.6K 🟢 +76.8%

Roundtrip (serialize + deserialize)

Scenario @lazarv/rsc webpack vs webpack
react: minimal element 102.0K 17.9K 🟢 +469.8%
react: shallow wide (1000) 1.7K 287 🟢 +507.6%
react: deep nested (100) 14.7K 4.3K 🟢 +241.3%
react: product list (50) 5.3K 1.6K 🟢 +221.4%
react: large table (500x10) 268 82 🟢 +228.7%
data: primitives 81.9K 27.4K 🟢 +199.3%
data: large string (100KB) 6.5K 6.3K 🟢 +3.2%
data: nested objects (20) 33.3K 17.5K 🟢 +90.1%
data: large array (10K) 82 73 🟢 +13.0%
data: Map & Set 6.0K 3.1K 🟢 +91.9%
data: Date/BigInt/Symbol 71.6K 20.9K 🟢 +242.7%
data: typed arrays 24.6K 10.6K 🟢 +131.4%
data: mixed payload 5.9K 3.0K 🟢 +97.7%
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.

@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented Apr 28, 2026

❌ 3 Tests Failed:

Tests completed Failed Passed Skipped
956 3 953 19
View the top 3 failed test(s) by shortest run time
__test__/deno.spec.mjs > create-react-server: deno runtime (npm) > preset: router > starts in production mode
Stack Traces | 0.000875s 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: router > starts in production mode
Stack Traces | 0.00094s 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: router > builds the app
Stack Traces | 0.00105s 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 > builds the app
Stack Traces | 0.00197s 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 (pnpm) > preset: router > dev mode starts and serves the app
Stack Traces | 0.00916s 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 > dev mode starts and serves the app
Stack Traces | 0.00932s 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__/scroll-restoration.spec.mjs > scroll restoration: multiple back/forward preserves positions
Stack Traces | 28s run time
AssertionError: expected 0 to be greater than 400
 ❯ __test__/scroll-restoration.spec.mjs:158:25

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

@github-actions
Copy link
Copy Markdown

⚡ Benchmark Results

PR 2cab3e5 main 84abefc
Config 50 connections, 10s/test 50 connections, 10s/test
Benchmark Req/s vs main Avg Latency vs main P99 Latency Throughput
minimal 1072 🔴 -1.6% 45.96 ms 🔴 +2.0% 90 ms 0.7 MB/s
small 1088 🔴 -8.7% 45.15 ms 🔴 +9.5% 77 ms 1.1 MB/s
medium 356 🔴 -4.7% 138.33 ms 🔴 +4.5% 203 ms 5.3 MB/s
large 48 🟢 +12.9% 1026.99 ms 🟢 -1.7% 2228 ms 4.9 MB/s
deep 784 🔴 -5.0% 62.96 ms 🔴 +5.4% 97 ms 2.7 MB/s
wide 64 🔴 -4.3% 748.43 ms 🔴 +5.5% 1338 ms 3.5 MB/s
cached 3280 🟢 +11.4% 14.68 ms 🟢 -10.8% 31 ms 48.4 MB/s
client-min 403 🔴 -4.7% 122.53 ms 🔴 +5.3% 196 ms 1.8 MB/s
client-small 426 🔴 -3.2% 115.52 ms 🔴 +3.2% 170 ms 2.0 MB/s
client-med 321 🔴 -3.3% 153.62 ms 🔴 +3.8% 231 ms 5.9 MB/s
client-large 74 🔴 -4.7% 643.58 ms 🔴 +3.7% 1108 ms 7.7 MB/s
client-deep 405 🔴 -6.0% 122.42 ms 🔴 +6.4% 178 ms 2.9 MB/s
client-wide 126 🔴 -6.0% 386.38 ms 🔴 +4.6% 711 ms 7.3 MB/s
rsc-client-large 1006 🔴 -2.5% 49.01 ms 🔴 +2.6% 70 ms 2.6 MB/s
rsc-client-wide 1033 🔴 -3.8% 47.64 ms 🔴 +3.8% 72 ms 2.7 MB/s
static-json 6445 🔴 -9.0% 7.25 ms 🔴 +11.0% 19 ms 2.7 MB/s
static-js 6602 🔴 -8.0% 7 ms 🔴 +7.0% 19 ms 3.9 MB/s
404-miss 4480 🔴 -4.0% 10.68 ms 🔴 +4.8% 23 ms 0.6 MB/s
hybrid-min 426 🔴 -7.7% 116.39 ms 🔴 +8.9% 169 ms 2.1 MB/s
hybrid-small 398 🔴 -12.4% 123.63 ms 🔴 +13.7% 180 ms 2.4 MB/s
hybrid-medium 215 🔴 -8.5% 228.2 ms 🔴 +9.0% 326 ms 9.2 MB/s
hybrid-large 39 🔴 -5.1% 1243.45 ms 🔴 +10.2% 2276 ms 12.6 MB/s
hybrid-deep 329 🔴 -9.1% 150.77 ms 🔴 +11.1% 211 ms 4.6 MB/s
hybrid-wide 56 🔴 -12.1% 842.58 ms 🔴 +12.2% 1516 ms 11.1 MB/s
hybrid-cached 2778 🔴 -3.7% 17.43 ms 🔴 +3.6% 32 ms 118.4 MB/s
hybrid-client-min 450 🔴 -6.4% 110 ms 🔴 +7.5% 160 ms 2.0 MB/s
hybrid-client-small 458 🔴 -3.5% 107.79 ms 🔴 +3.7% 163 ms 2.2 MB/s
hybrid-client-medium 346 🔴 -2.9% 142.31 ms 🔴 +3.2% 213 ms 6.4 MB/s
hybrid-client-large 79 🔴 -2.1% 622.75 ms 🔴 +4.1% 1078 ms 8.3 MB/s
hybrid-client-deep 411 🔴 -4.1% 120.06 ms 🔴 +3.9% 172 ms 3.0 MB/s
hybrid-client-wide 131 🔴 -8.7% 372.51 ms 🔴 +8.0% 648 ms 7.7 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.

@lazarv lazarv merged commit 5353c12 into main Apr 28, 2026
109 of 112 checks passed
@lazarv lazarv deleted the feat/typed-outlets branch April 28, 2026 18:04
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