Skip to content

fix: router performance#381

Open
lazarv wants to merge 5 commits intomainfrom
fix/router-performance
Open

fix: router performance#381
lazarv wants to merge 5 commits intomainfrom
fix/router-performance

Conversation

@lazarv
Copy link
Copy Markdown
Owner

@lazarv lazarv commented Apr 7, 2026

PR #372 ("enhanced router") introduced a regression where every <Route> in a layout caused the SSR worker to import the target page module and the browser to preload its chunk, even for non-matching siblings. On large layouts this dominated
request latency and forced every client chunk into the initial download. This PR removes that cost without giving up the correctness or DX of the new router.

The core idea is to keep live client references out of the Flight payload for routes that don't match. The file router now emits <Route componentId="…" componentLoader={() => Page} /> instead of <Route element={<Page/>} />. The live
reference only exists inside the closure body, so React's RSC encoder walks past it for non-matching siblings and never registers the chunk. Only the matching route calls componentLoader() and instantiates the page, which produces exactly
one client-reference registration per request. For non-matching routes we resolve the source-relative $id to the built chunk URL via clientReferenceMap (with a try/catch fallback to the source module so dev still works without the
.react-server/ build output) and pass the chunk id to the client.

On the client, ClientRouteRegistration builds a small LazyChunkComponent wrapper around the deferred chunk. It deliberately does not use React.lazy, because lazy always schedules a microtask before re-rendering and causes a one-frame
fallback flash even when the module is already in the __webpack_require__ cache. Instead the wrapper reads p.value synchronously on cache hits and falls through to React 19's use(p) hook only when the import is genuinely in flight. It
also patches .value/.status onto the import promise itself, since the prod polyfill in render-rsc.jsx does this on the server but Vite's dev __webpack_require__ does not. There is a load-bearing invariant: in lazy mode the wrapper is
only instantiated when the route is active, because Activity hidden subtrees still render and would otherwise eagerly fire the dynamic import for every sibling. The lazy render path also intentionally has no local Suspense boundary, so an
active route's suspension propagates to the navigation transition and React keeps the previous page visible until the new chunk resolves, instead of flashing a blank fallback.

The second half of this PR is an unrelated scroll-restoration bug surfaced by the new lazy navigation timing. ScrollRestoration initialised its lastY snapshot from window.scrollY at effect setup time, but on popstate the browser
carries the previous page's scroll position over because we set history.scrollRestoration = "manual". If a fast follow-up navigation ran the cleanup before any real scroll event refreshed the snapshot, the cleanup would write the previous
route's scroll value under the current route's key, silently corrupting saved positions. The fix introduces a module-level scrollObserved flag that the window scroll listener and every container scroll listener flip on first event, and the
cleanup only persists if a real scroll has been observed for that route. If nothing scrolled, the existing storage entry is correct and we leave it alone. This affects real users on any quick back/forward navigation, not just tests.

The scroll restoration test file was also rewritten to boot the dev server once via beforeAll (the previous per-test await server(...) was rebuilding the dev server before every test and dominating the suite duration — 63s down to 17s),
with a beforeEach that navigates to the fixture origin first and then clears sessionStorage, since storage is per-origin and clearing on about:blank is a no-op for the test origin.

Finally, the benchmark example was restructured into (rsc), (ssr), and (hybrid) route groups, and bench.mjs now exposes a --filter flag for local iteration plus a new set of hybrid benchmarks that exercise the
layout-with-many-client-siblings shape this PR is designed to make fast. Original benchmark URLs are unchanged (route groups are transparent), so the CI baseline comparison still works without modification.

@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 7, 2026

⚡ Benchmark Results

PR a9aa138 main 249497a
Config 50 connections, 10s/test 50 connections, 10s/test
Benchmark Req/s vs main Avg Latency vs main P99 Latency Throughput
minimal 1215 🟢 +155.3% 40.58 ms 🟢 -61.1% 82 ms 0.7 MB/s
small 1391 🟢 +177.8% 35.41 ms 🟢 -64.1% 65 ms 1.3 MB/s
medium 330 🟢 +56.0% 150.95 ms 🟢 -34.9% 223 ms 4.8 MB/s
large 29 🟢 +19.0% 1520.73 ms 🟢 -16.7% 3679 ms 2.9 MB/s
deep 875 🟢 +134.8% 56.58 ms 🟢 -57.5% 96 ms 2.9 MB/s
wide 37 🟢 +19.0% 1185.49 ms 🟢 -16.1% 2397 ms 2.0 MB/s
cached 3443 🟢 +9.1% 14.03 ms 🟢 -8.5% 31 ms 50.2 MB/s
client-min 453 🔴 -21.4% 109.35 ms 🔴 +26.8% 173 ms 2.3 MB/s
client-small 475 🔴 -16.4% 104.5 ms 🔴 +19.5% 157 ms 2.6 MB/s
client-med 359 🔴 -14.1% 138.58 ms 🔴 +16.4% 199 ms 6.9 MB/s
client-large 85 🔴 -1.7% 580.28 ms 🔴 +4.4% 1135 ms 9.0 MB/s
client-deep 447 🔴 -14.4% 110.79 ms 🔴 +16.3% 163 ms 3.5 MB/s
client-wide 142 🟢 +2.4% 341.13 ms 🟢 -2.7% 598 ms 8.4 MB/s
static-json 6968 🔴 -36.2% 6.64 ms 🔴 +69.8% 16 ms 2.9 MB/s
static-js 7111 🔴 -35.3% 6.45 ms 🔴 +65.4% 16 ms 8.5 MB/s
404-miss 4902 🔴 -19.2% 9.8 ms 🔴 +28.4% 21 ms 0.6 MB/s
hybrid-min 464 106.96 ms 162 ms 2.5 MB/s
hybrid-small 432 114.75 ms 164 ms 2.8 MB/s
hybrid-medium 196 251.23 ms 374 ms 8.8 MB/s
hybrid-large 25 1830.22 ms 3822 ms 8.4 MB/s
hybrid-deep 351 141.28 ms 211 ms 5.2 MB/s
hybrid-wide 34 1425.95 ms 2726 ms 7.3 MB/s
hybrid-cached 2939 16.52 ms 32 ms 131.6 MB/s
hybrid-client-min 480 103.11 ms 151 ms 2.5 MB/s
hybrid-client-small 477 103.34 ms 160 ms 2.6 MB/s
hybrid-client-medium 370 133.75 ms 195 ms 7.1 MB/s
hybrid-client-large 87 554.05 ms 1042 ms 9.2 MB/s
hybrid-client-deep 451 110.41 ms 160 ms 3.6 MB/s
hybrid-client-wide 149 329.13 ms 554 ms 8.8 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 7, 2026

❌ 14 Tests Failed:

Tests completed Failed Passed Skipped
913 14 899 3
View the top 3 failed test(s) by shortest run time
__test__/apps/mantine.spec.mjs > mantine > setup > start server
Stack Traces | 1.07s run time
Error: Cannot find module '.../examples/mantine/.react-server-build--560696597-1978da89d2284638047febb59993c1832973155bd8acccc03cfd20defd7a0301/server/edge.mjs' imported from .../react-server/test/server.edge.mjs
 ❯ vitestSetup.mjs:398:22
 ❯ settle vitestSetup.mjs:117:11
 ❯ ChildProcess.<anonymous> vitestSetup.mjs:395:13
__test__/scroll-restoration.spec.mjs > scroll restoration: query-param-only change preserves scroll
Stack Traces | 2.58s run time
AssertionError: expected 0 to be greater than 500
 ❯ __test__/scroll-restoration.spec.mjs:174:19
__test__/apps/mantine.spec.mjs > mantine > home page > renders home page with Mantine UI
Stack Traces | 3.4s run time
Error: page.goto: url: expected string, got undefined
 ❯ __test__/apps/mantine.spec.mjs:60:18
__test__/apps/mantine.spec.mjs > mantine > home page > increment button updates count
Stack Traces | 3.43s run time
Error: page.goto: url: expected string, got undefined
 ❯ __test__/apps/mantine.spec.mjs:68:18
__test__/apps/mantine.spec.mjs > mantine > dates > locale select changes date format
Stack Traces | 3.44s run time
TypeError: Invalid URL
 ❯ __test__/apps/mantine.spec.mjs:114:23

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
Serialized Error: { code: 'ERR_INVALID_URL', input: '/dates' }
__test__/apps/mantine.spec.mjs > mantine > form > shows validation error on empty submit
Stack Traces | 3.69s run time
TypeError: Invalid URL
 ❯ __test__/apps/mantine.spec.mjs:84:23

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
Serialized Error: { code: 'ERR_INVALID_URL', input: '/form' }
__test__/apps/mantine.spec.mjs > mantine > dates > date input formats value
Stack Traces | 3.7s run time
TypeError: Invalid URL
 ❯ __test__/apps/mantine.spec.mjs:100:23

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
Serialized Error: { code: 'ERR_INVALID_URL', input: '/dates' }
__test__/apps/mantine.spec.mjs > mantine > charts > renders chart SVGs
Stack Traces | 3.85s run time
TypeError: Invalid URL
 ❯ __test__/apps/mantine.spec.mjs:138:23

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
Serialized Error: { code: 'ERR_INVALID_URL', input: '/charts' }
__test__/apps/mantine.spec.mjs > mantine > navigation progress > shows progress bar on start
Stack Traces | 3.86s run time
TypeError: Invalid URL
 ❯ __test__/apps/mantine.spec.mjs:236:23

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
Serialized Error: { code: 'ERR_INVALID_URL', input: '/navigationprogress' }
__test__/apps/mantine.spec.mjs > mantine > notification system > shows notification on button click
Stack Traces | 3.89s run time
TypeError: Invalid URL
 ❯ __test__/apps/mantine.spec.mjs:151:23

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
Serialized Error: { code: 'ERR_INVALID_URL', input: '/notification-system' }
__test__/apps/mantine.spec.mjs > mantine > spotlight > opens spotlight and searches for items
Stack Traces | 3.91s run time
TypeError: Invalid URL
 ❯ __test__/apps/mantine.spec.mjs:169:23

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
Serialized Error: { code: 'ERR_INVALID_URL', input: '/spotlight' }
__test__/apps/mantine.spec.mjs > mantine > carousel > navigates carousel slides
Stack Traces | 3.93s run time
TypeError: Invalid URL
 ❯ __test__/apps/mantine.spec.mjs:207:23

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
Serialized Error: { code: 'ERR_INVALID_URL', input: '/carousel' }
__test__/apps/mantine.spec.mjs > mantine > modals manager > opens and confirms modal
Stack Traces | 3.95s run time
TypeError: Invalid URL
 ❯ __test__/apps/mantine.spec.mjs:259:23

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
Serialized Error: { code: 'ERR_INVALID_URL', input: '/modalsmanager' }
__test__/apps/mantine.spec.mjs > mantine > rich text editor > renders rich text editor content
Stack Traces | 4.01s run time
TypeError: Invalid URL
 ❯ __test__/apps/mantine.spec.mjs:298:23

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
Serialized Error: { code: 'ERR_INVALID_URL', input: '/rte' }
__test__/apps/mantine.spec.mjs > mantine > setup > build
Stack Traces | 240s run time
Error: Test timed out in 60000ms.
If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout".
 ❯ __test__/apps/mantine.spec.mjs:31:9

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

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