Commit 6b845f6
authored
fix: router performance (#381)
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.1 parent 249497a commit 6b845f6
41 files changed
Lines changed: 1345 additions & 289 deletions
File tree
- examples
- benchmark
- pages
- (hybrid)
- hybrid
- client
- (rsc)
- (ssr)
- client
- spa-router
- packages/react-server
- cache
- client
- lib/plugins/file-router
- server
- test
- __test__
- apps
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
28 | 28 | | |
29 | 29 | | |
30 | 30 | | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
31 | 40 | | |
32 | 41 | | |
33 | 42 | | |
| |||
161 | 170 | | |
162 | 171 | | |
163 | 172 | | |
164 | | - | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
165 | 215 | | |
166 | 216 | | |
167 | 217 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
0 commit comments