feat: typed outlets#409
Conversation
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
react-server-docs | 2cab3e5 | Apr 28 2026, 05:07 PM |
⚡ Flight Protocol BenchmarkCommit: Serialization (
|
| 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.
❌ 3 Tests Failed:
View the top 3 failed test(s) by shortest run time
To view more test analytics, go to the Test Analytics Dashboard |
⚡ Benchmark Results
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. |
Summary
Adds typed, per-outlet bound
ReactServerComponentexposed through a new virtual module@lazarv/react-server/outlets, alongside the existing@lazarv/react-server/routes. Where@lazarv/react-server/routestypes the consuming side of outlets (a layout'screateLayoutprops), 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
urltyped against the sameRouteImpl<T>union asLink.to(so typos are rejected and dynamic segments must be concrete), and returns a brandedOutlet<"sidebar">so the value can satisfy acreateLayoutslot 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
urlagainst the file-router manifest, locates the matching@outletName/…page.tsx(or the@outletName.default.tsxfallback), renders it, and passes the result aschildrentoReactServerComponent— so the SSR HTML contains the outlet content on first paint, no client round-trip.defer={true}and explicitchildrenopt 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 bycreateLayout.Implementation notes
The file-router plugin now collects unique outlet names from the manifest and generates two artifacts: a virtual
@lazarv/react-server/outletsruntime module and areact-server-outlets.d.tsdeclaration 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/routesis 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. BothgenerateRoutesDtsandgenerateOutletsDtswere rewritten as single template literals composed via.map(...).join("\n\n"), replacing the previouslines.pushchains; the emitted output is byte-identical apart from now-exportedOutlet<Name>.Adjacent fix: default
<Link>navigation with named outlets activeSurfacing this feature exposed a pre-existing bug in
ClientProvider.navigate(). Withoutlets.size > 1(which now happens any time a page mounts a<*.Outlet />), a default<Link>click — wheretarget/local/rootare unset andLink.jsxtherefore passesoutlet: undefined— entered the broadcast branch and fanned the navigation out to every active non-root outlet, skippingPAGE_ROOT. The user stayed on the previous page with the outlet slots rendering null. Fixed by narrowing the implicit broadcast: whenoptions.outletisundefinedand the target pathname differs from the current pathname, default toPAGE_ROOT. Same-pathname updates (?filter=active-style URL changes) still broadcast, preserving the multi-pane filter use case the broadcast was designed for.