fix: match loading — review fixes and #7713 edge cases#7744
Conversation
Covers: boundary component chunk isolation, chunk failure lifecycle, superseded load await, server headers surviving head() failure, preload during pending publication, background asset projection failure, Solid same-route pending blanking, and SSR hydration of server-capped match lists (e2e). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ds join pending-published owners - loadClientRouter now awaits the superseding load chain before its public await settles, restoring the router.load()/invalidate() synchronization contract for superseded and redirected loads. - joinPreloadedActiveMatch waits for the foreground load when the borrowed owner is a render-ready pending publication (active store, status 'pending', pending pool cleared) instead of dropping descendant preloads. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…, not the pending pool The pendingReplacement branch swapped committed content for the pending fallback (or null) the moment setPending published at load start, bypassing pendingMs entirely and stamping pendingUntil at navigation start. Core already owns pending timing: the render-ready lane is published into the active store after pendingMs, which is what React renders from. Solid now does the same. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…restore _displayPending guard Vue renders pending UI directly (it never suspends on the load promise), so a settled/absent match-local promise is a legitimate stale-snapshot state (hydration display matches, cancelMatches) — not an invariant violation. Stamp pendingUntil only on a still-pending local promise, mirroring React, and restore main's _displayPending early-return that kept pending UI up after ClientOnly stops gating. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Aborting every active match's controller at load start killed deferred/ streamed data tied to a success stay-match's loader signal on any child navigation or invalidate(), landing AbortError in <Await> boundaries. Restore main's filter (pending or actively fetching) for active matches; pending-pool matches are still fully cancelled and settled. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- loadClientRouter's outer catch re-surfaces non-redirect, non-sentinel failures (asset projection, view transitions, lifecycle hooks) as unhandled rejections instead of silently dropping them. - commitFinalMatches wraps each lifecycle hook so a throwing onEnter/onStay/onLeave stays observable without skipping the remaining hooks or corrupting the committed transition. - React's startTransition clears isTransitioning in a finally block so a throwing commit cannot permanently block onResolved/idle. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Publication evicts the previous active lane from every pool; if the load is then superseded (back navigation), the final commit that would have cached those matches never runs and fresh within-staleTime data is lost, forcing a spurious loader re-run. Cache exiting success matches at publication (mirroring main's onReady) and dedupe against them in commitFinalMatches. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rver, background, and hydration - Server projection commits head/scripts/headers independently so a failing decorative hook no longer drops route headers (response behavior). Sync throws still abandon pending async hooks without blocking the response, owning their rejections. - Background reloads only publish when asset projection for the fresh data succeeded, keeping the data+assets commit atomic instead of exposing new loaderData under stale meta. - Hydration no longer executes head()/scripts() for ssr:false matches, including children the server intentionally omitted at an error/ notFound boundary; the follow-up client load projects assets for the lane it actually commits. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… normalize chunk failures - loadRouteChunk(route, componentType) now tracks per-component-type in-flight preloads: targeted boundary requests join their own type's chunk instead of awaiting (and inheriting failures from) the unrelated whole-route _componentsPromise. Full preloads start fresh so a new load generation never couples to a stale generation's boundary chunk, and rejections are evicted instead of cached forever. - Primary route chunk failures (lazyFn/component preload) go through normalizeRouteFailure like loader failures, so route onError fires and thrown redirects/notFounds are honored; boundary component chunk failures still commit directly (recursion guard). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A descendant notFound/error no longer discards successful expensive ancestor loads: the leading run of success matches is projected and cached (owned matches only), so repeated hovers and the eventual navigation reuse them within preloadStaleTime. Failed, pending, and borrowed entries still never enter the cache. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… code - commitMatch and getLoader move to the shared load-matches.ts (they were byte-identical in the client/server twins; getLoader was additionally re-inlined in the server loader path). - loadServerMatches no longer copies preload/background/onReady/forceReload into its context — those are client-side semantics the server pipeline never reads. - loadServerRouter's catch no longer duplicates the 404/500/200 derivation that the committed-match pass below already owns. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ent load When the server caps the match lane at a notFound/error boundary, the follow-up client load after hydration skipped the dehydrated parent's beforeLoad and happily loaded, committed, and asset-projected the child routes the server omitted — injecting head/scripts computed from missing loader data. A dehydrated failure boundary now replays as the pass's serial failure, capping the lane exactly like the server did. Also excludes /hydration-capped-assets from the e2e prerender crawl (the route 404s by design). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
|
View your CI Pipeline Execution ↗ for commit bcc23d8
☁️ Nx Cloud last updated this comment at |
🚀 Changeset Version Preview6 package(s) bumped directly, 17 bumped as dependents. 🟩 Patch bumps
|
Bundle Size Benchmarks
Current gzip tracks all emitted client JS chunks. Initial gzip tracks only the entry/import graph. Trend sparkline is historical current gzip ending with this PR measurement; lower is better. |
The exiting/cached id scans operate on small arrays; linear some() scans drop two Set allocations per final commit and per pending publication. Bundle-size impact is neutral (gzip); measured across all targets. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Merging this PR will improve performance by 4.08%
|
| Mode | Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|---|
| ❌ | Simulation | ssr dehydrate plain control (vue) |
144.2 ms | 155.6 ms | -7.33% |
| ❌ | Simulation | ssr server-fn multipart (solid) |
58.6 ms | 62.2 ms | -5.88% |
| ⚡ | Simulation | ssr dehydrate rich types (solid) |
70.1 ms | 60.9 ms | +15.09% |
| ⚡ | Simulation | client-side navigation loop (vue) |
73.9 ms | 66 ms | +12% |
| ⚡ | Simulation | client-side navigation loop (solid) |
100.8 ms | 92.9 ms | +8.44% |
| ⚡ | Simulation | ssr server-fn during document ssr (solid) |
83.4 ms | 79.8 ms | +4.5% |
| ⚡ | Simulation | ssr control-flow unmatched 404 (solid) |
71.1 ms | 68.2 ms | +4.17% |
| ⚡ | Simulation | ssr selective (solid) |
69 ms | 66.3 ms | +4.04% |
| ⚡ | Simulation | ssr control-flow route headers (solid) |
74.5 ms | 71.8 ms | +3.82% |
Tip
Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.
Comparing fix-match-loading-ready (f89dc95) with fix-match-loading (e9acd3a)
Footnotes
-
48 benchmarks were run, but are now archived. If they were deleted in another branch, consider rebasing to remove them from the report. Instead if they were added back, click here to restore them. ↩
- Server asset projection awaits a pending async headers() even when a decorative head()/scripts() hook throws synchronously — route headers are response behavior and are never dropped for a decorative failure. - cancelMatches treats any fetching marker (beforeLoad included) as in-flight work when deciding to abort an active match. - A rejected lazy-route chunk promise is evicted so the next load generation retries the import instead of replaying the failure forever. - commitFinalMatches isolates onLeave from the same index's onEnter/onStay, so a throwing leave hook cannot skip the entered route's hooks; exiting-match cache preservation is shared between pending publication and final commit via one helper. - joinPreloadedActiveMatch's wait/re-read stanzas are unified into one loop that also survives a newer load re-publishing a pending lane. - HMR route updates clear the new per-type component chunk cache. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- handle-route-update.ts referenced oldRoute._componentPromises (added in the self-review commit to also clear the per-component chunk-load cache on HMR) but the plugin's local AnyRouteWithPrivateProps shadow type only declared _componentsPromise, breaking router-plugin's build and test:types (TS2551). - route-assets.server.ts no longer needs the `headers as Promise<any>` cast after the isPromise() type-guard already narrows the `any`-typed headers variable; eslint's no-unnecessary-type-assertion flagged it as an error, failing router-core's test:eslint. - background-assets-stale.test.ts accessed loaderData.title without a null check inside an inline head() callback, where TS strictly types AssetFnContextOptions.loaderData as optional (it's only assigned once the loader settles). Other tests dodge this because they wrap head in vi.fn(), which loses the contextual typing; this test defines head inline so the strict type applied, breaking test:types and test:unit's typecheck pass. The loader in this test always resolves before head runs, so a non-null assertion is safe. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… loads A load that settles before the Transitioner can observe the isLoading flip (started before mount, or completing within the mount effect's batch) left router status stuck at 'pending' forever — onResolved and onRendered never fired. This deadlock pre-dates this branch (present on fix-match-loading, absent on main) and hung the memory-client:react CodSpeed job for 6 hours. Repair after the mount load exactly like ssr-client does after its follow-up load. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…loadPromise matchRoutesInternal's lane copy kept EITHER the existing loadPromise OR the dehydrated marker. A dehydrated match can legitimately hold a pending loadPromise during hydration, and dropping the marker made the same-href follow-up load treat it as an ordinary stale stay match — re-running server loaders client-side (selective-ssr/spa-mode e2e: root-loader showed 'client' instead of 'server'). The base branch never hit this only because its unconditional cancelMatches settled every active match's promise before rematching. Adds a hydrate-level regression test for server data preservation across the follow-up load and a client navigation, plus a react-router contract test that a load settling before RouterProvider mounts still resolves router status and fires onRendered (the deterministic guard for the original 6h hang remains the memory-client:react benchmark). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…k, fix pendingMs-0 blank frame Issue sweep across open issues/PRs overlapping this branch's scope. Each issue got a repro test asserting the desired behavior: - Fixed by this work (tests pass): #7602, #7457, #7367, #7635, #4684, #6221, #7379, #4696, #4444, #6107, #5106, #7638, #4572, #3179, #4112, #2980, #2905, #2182, #3928, #6371 — plus test ports pinning the intent of external PRs #7003, #7051, #6645, #7114 (via #7110 repro) and #4614. - Fixed here: #4759 — a pendingMs-0 pending publication deferred to a macrotask painted one blank frame on bare initial loads; publication is now synchronous when nothing is rendered yet (the timer path stays for displayed content, whose later snapshot is fresher). - Known gap kept as an expected failure: #5778 — preloads borrowing an active ancestor derive context from its committed snapshot, not live router.options.context; fixing means changing the read-only borrow protocol (dedicated follow-up). The documented invalidate() workaround is pinned. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d ClientOnly slot The outer _displayPending branch made the hydrating client render the pending fallback at a different structural depth than the server (which renders it through ClientOnly's fallback), so Solid's dev runtime threw 'Hydration Mismatch' and cascaded into 'template is not a function' via the root CatchBoundary — dev-mode-only because prod silently falls back to client rendering. This regressed data-only+pendingComponent routes vs main and was also the mechanism behind the pre-existing #7283 (ssr:false+pendingComponent, broken on main too). _displayPending now renders inside MatchInner, which only mounts after ClientOnly hydrates — outside the hydration key sequence — fixing both cases. Adds a dev-server e2e spec covering both. Fixes #7283 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Important
At least one additional CI pipeline execution has run since the conclusion below was written and it may no longer be applicable.
Nx Cloud is proposing a fix for your failed CI:
We removed two unnecessary type assertions in the newly added issue-7638-invalidate-transition-error.test.tsx that caused the ESLint @typescript-eslint/no-unnecessary-type-assertion rule to error. ErrorComponentProps.error is already typed as Error so the as Error cast is redundant, and the call args from vi.spyOn are already any[] so each arg is any, making as any redundant. Removing both assertions restores the lint suite to a clean pass.
Tip
✅ We verified this fix by re-running @tanstack/react-router:test:eslint.
Warning
The suggested diff is too large to display here, but you can view it on Nx Cloud ↗
Or Apply changes locally with:
npx nx-cloud apply-locally t7tu-mBPk
Apply fix locally with your editor ↗ View interactive diff ↗
🎓 Learn more about Self-Healing CI on nx.dev
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Nine corrections from a full consistency pass: pendingMs-0 synchronous publication, the preload borrow wait/re-read loop and failure-path prefix caching, load-chain settlement for superseded loads, per-type chunk cache with settled-preload eviction (incl. lazyFn rejections), narrowed cancelMatches, per-kind server asset commit with async headers always awaited, hydration's ssr:false asset skip, loadPromise AND dehydrated marker both surviving rematch, and the previously undocumented dehydrated-boundary replay. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pendingMs-0 synchronous publication, narrowed cancelMatches, exiting- match cache preservation at pending publication, dehydrated boundary replay, and async headers() always awaited. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- joinPreloadedActiveMatch's foreground wait is now bounded: a
speculative preload drops its pass (ownership sentinel) instead of
outwaiting sustained navigation churn, where every iteration picked up
the replacement latestLoadPromise and awaiters never settled. Covered
by a churn regression test with signal-aware loaders.
- The react Transitioner stuck-pending repair now also emits
onLoad/onBeforeRouteMount/onResolved — deferred one macrotask so the
normal effect pipeline (which sets status idle) always wins arbitration
and events cannot double-fire; only genuinely unobserved loads repair.
- Server asset projection no longer abandons a pending async head()/
scripts() when a sync throw coincides with an async headers(): the
response waits on headers regardless, so that case now flows through
the generic per-kind branch and commits everything that settles. This
also removes the duplicated commit+recurse continuation.
- Restores solid-router's server-mode test suite ('vitest --mode
server'), accidentally dropped from test:unit by a concurrent agent
edit swept into an earlier commit; the suite passes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the counter-capped wait loop with the deterministic contract the protocol originally had: if the borrowed owner is mid-navigation, await the current foreground load exactly once and re-read. An owner that still has not committed after that belongs to a newer navigation, and the speculative pass yields via the ownership sentinel. Zero or one await by construction — no arbitrary iteration bound. INTERNALS and the churn regression test updated to match. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Complexity sweep over the combined #7637+#7744 diff (23 verified simplifications; every candidate adversarially checked for behavior preservation against INTERNALS and the test suite): - One flag instead of two for pending publication (pendingPublished folded into rendered); serial failures live on inner.serialFailure instead of a local mirrored by a temporary background-null bracket (retained ancestors now gate background selection explicitly). - Provably-dead code deleted: finalizeRouteFailure's never-passed componentFailure parameter, an unreachable lane-shortening guard, the join's pre-wait aborted check, three sync-gap currentness re-checks in the background path, the server-side latestLoadPromise clear, two unreachable isServer branches in react Match, and the supersession drain loop's impossible-state guards (with a new multi-supersession pinning test). - One owner per rule: the serial-failure loader-prefix cap is a shared helper used by both client and server (new foreground-lane pinning test); matchRoutesInternal derives context through getMatchContext; invalidate() uses one parameterized updater; cancelMatches reads its known pool directly; the server reduction uses Promise.allSettled. - Vestigial indirection dropped: hydrate()'s follow-up load microtask deferral, solid's createResource microtask yield and armPending wrapper, ssr-client's duplicate route.options.ssr write, the loadedMatches/matches aliases, and stores.ts's initial-state object for five compile-time constants. Bundle: −95 to −150 B gzip (−300 to −494 B raw) on every measured target across react/solid/vue and vite/rsbuild. All suites green (router-core 1492, react 941, solid 821+server-mode, vue 791) plus react/solid selective-ssr e2e. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Transitioner carried two emission pipelines: render-observed usePrevious edges (lossy by design — React batching can hide a flip inside one commit) and the mount-load repair, with a setTimeout(0) arbitrating between them. Both are replaced by a single emitter driven by synchronous store subscriptions (isLoading/hasPending), which cannot miss an edge, plus one render-coupled signal for onResolved: a commit tick bumped inside the transition and observed by a layout effect, so resolution still lands after React committed the transition render. A counter rather than a boolean so nested transitions (invalidate inside a user startTransition) can never coalesce into an unobservable no-change. No timer, no arbitration, no repair block, and the mount load reduces to a bare router.load() — its own isLoading toggle through the armed subscriptions is the deterministic signal even for loads that settled before mount (the memory-benchmark case, re-verified: no hang). Net smaller than the file it replaces. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| const subscriptions = [ | ||
| router.stores.isLoading.subscribe(emitEdges), | ||
| router.stores.hasPending.subscribe(emitEdges), | ||
| ] | ||
| return () => { | ||
| for (const subscription of subscriptions) { | ||
| subscription.unsubscribe() | ||
| } | ||
| } |
There was a problem hiding this comment.
| const subscriptions = [ | |
| router.stores.isLoading.subscribe(emitEdges), | |
| router.stores.hasPending.subscribe(emitEdges), | |
| ] | |
| return () => { | |
| for (const subscription of subscriptions) { | |
| subscription.unsubscribe() | |
| } | |
| } | |
| const loadingSub = router.stores.isLoading.subscribe(emitEdges) | |
| const pendingSub = router.stores.hasPending.subscribe(emitEdges) | |
| return () => { | |
| loadingSub.unsubscribe() | |
| pendingSub.unsubscribe() | |
| } |
…es (core) Trust-the-model sweep over the combined diff, core half — every deletion adversarially verified against INTERNALS and the suites: - Dead mechanism: the server redirect path's write-only match._.error (vestige of main's removed 'redirected' status; its only reader is the client-only preload borrow protocol), the reloadDocument carve-out in the server redirect short-circuit (client-navigation concept), and the rendered field's never-used promise arm — onReady is now a void callback and publication is synchronous by contract. - Duplicate signal writers: finalizeRouteFailure's notFound branch no longer sets requiresCommit (this branch always throws; the loadClientRouter catch owns the flag for thrown outcomes), and backgroundOnly drops its !rendered condition (pending publication implies requiresCommit). - Unreachable defenses: the serial loop's badIndex condition (badIndex is loader-phase-only by construction), the server finalizer's missing-match guard (the linear server lane cannot shorten mid- finalizer), redundant ownership re-checks with no async gap, and the background chunk catches' sentinel classification that duplicated the following isCurrentOrCancel. - One owner per rule: recordBeforeLoadFailure takes the pass controller instead of re-deriving it, and commitServerNotFoundBoundary now uses the shared getNotFoundBoundaryPatch. Each deleted hedge leaves a one-line invariant comment where a future reader might be tempted to re-add it. Full router-core suite green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…es (frameworks) Framework half of the trust-the-model sweep: - Vue's Transitioner adopts the single-emitter shape react uses: synchronous store subscriptions drive one emitEdges instead of three watch pipelines over usePrevious edges; the transition edge stays flush-coupled via nextTick for the onResolved event, while the idle/resolvedLocation commit happens on the first falling edge — joining the same reactive flush as the matches commit, so matchRoute/status consumers can never render one flush behind the committed lane. Navigations now produce one fewer store flush across the board (pinned counts updated downward). - Vue and Solid useMatch drop the hasPendingMatch throw-suppression guards and the pendingMatchContext/pendingRouteIds plumbing that fed them: atomic pending publication means a match the store publishes is always judgeable, matching react's semantics (changeset documents the Vue behavior change). Full suites green (router-core 1492, react 938, solid 818+server-mode, vue 789 incl. the conditional-Link regression this rework surfaced and fixed), react selective-ssr e2e green. Bundle: −30 to −218 B gzip on every measured target. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Restores the essential hover-preload contract — click during an in-flight preload executes the loader ONCE — without resurrecting the old join's bug classes: - Data only, never control flow: a donor preload that ends in redirect/ notFound/error is ignored and the navigation runs its own loader, so preload-computed redirects can no longer hijack navigations (the redirect regression test now pins non-leakage AND the retry). - The navigation always runs its own beforeLoad with preload:false semantics (the old join's context-mismatch class stays dead). - Private preload lanes register in router._preloadLanes; matchRoutes attaches a join handle; the loader phase adopts via the shared loadPromise. Joining is gated on the donor's loader being in flight — before that, the preload's serial phase may itself be waiting on this navigation through the borrow protocol, and joining would deadlock the pair (pinned by a serial-phase test). Concurrent sibling preloads of the same route also dedupe to one loader run. - No cache pollution: lanes stay private until success, unchanged. The adoption await is gated on the join handle so the synchronous loader-start contract is untouched for the no-preload case. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… a join handle Inspired by main's original adoption shape (the join point was the match's own discoverable state, not plumbing): the loader phase now looks the donor up directly in router._preloadLanes, deleting the _.preloadLane private field, the Matches type addition, and the per-match lane scan in matchRoutesInternal's hot path. The adoption gate excludes a preload pass's own registered lane so the synchronous loader-start contract holds for solo preloads too. A preload completing in the microtask window between matchRoutes and the loader phase is deliberately NOT adopted — the navigation runs its own loader, which is correct, just not deduplicated; guarding that window was a hedge whose failure mode is a duplicate fetch, not a defect. Net vs the handle version: two files instead of four, no hot-path work, and marginally smaller bundles. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review follow-up for #7637: fixes every issue found in review (including all edge cases reproduced in #7713) so that branch + this PR is the ready-to-merge version of
fix-match-loading.Tests imported from #7713
All unit repros land as regular regression tests (descriptive names, internal issue numbering stripped), plus the
hydration-capped-assetse2e app/spec. The view-transition repro (issue 6) was deliberately not imported: disabling typed view transitions on browsers without:active-view-transition-typesupport is accepted behavior.Fixes
await router.load()/invalidate()settled while a superseding load was still in flight; preloads borrowing a pending-published owner were dropped (joinPreloadedActiveMatchsaw a stuck'pending'snapshot)pendingReplacement()swapped committed content for the pending fallback (ornull!) att=0, bypassingpendingMs, and stampedpendingUntilat navigation start (delaying fast commits by up topendingMinMs)MatchInnerdev-invarianted on a pending match whose localloadPromisewas settled (hydration display matches,cancelMatches); main's_displayPendingguard had been droppedpendingUntilstamps only a still-pending local promise;_displayPendingearly-return restoredcancelMatchesscopeinvalidate()status === 'pending' || isFetching === 'loader'matches are cancelled (pending-pool cancellation unchanged), matching the documented signal contractonEnter/onStay/onLeavethrows via the awaited view-transition callback); React'sisTransitioningcould stick foreverstartTransitionclearsisTransitioninginfinallystaleTimeloaderscommitReadypreserves exiting success matches incachedMatches(deduped incommitFinalMatches)head()failure dropped route headers); background reloads published freshloaderDataunder stale meta when projection failed; hydration executedhead()/scripts()for matches the server omittedssr: falsematches_componentsPromise; rejected preloads were cached forever; primary chunk failures bypassedonError/redirect/notFoundnormalizeRouteFailure(boundary-chunk failures still commit directly as the recursion guard)beforeLoad, so children the server omitted at a notFound/error boundary were loaded, committed, and asset-projected with missing loader dataBehavior decisions
skip if pending preload (redirect)test codified landing on the wrong page — plus the preload: undefined context in loader if navigation happens before beforeLoad is done #1886 undefined-context class), adoption is data-only: the navigation always runs its ownbeforeLoad, and a preload ending in redirect/notFound/error is never adopted — the navigation retries with its own loader. Joining is deadlock-safe by construction (gated on the donor's loader being in flight, past the borrow-protocol serial phase). Concurrent preloads of the same route dedupe to one loader run.Cleanup
Byte-identical twin helpers (
commitMatch,getLoader) moved to sharedload-matches.ts; dead client-only fields no longer copied into the server load context; duplicated status-code derivation removed fromloadServerRouter's catch. INTERNALS.md and the changeset updated to match all semantic changes.Verification
react-start/basice2e green in all three modes (SSR/prerender/SPA).main: net smaller on every target except react-start rsbuild (+150–250 B); fixes cost ~250–620 B over the raw PR.main: −4 % to −17 % on document scenarios (no regressions beyond noise); client-nav −2 to −4.5 % vs main.🤖 Generated with Claude Code
Community issues addressed (issue/PR sweep)
A triage of open issues and PRs overlapping this work. Every integrated issue has a regression test asserting the desired behavior.
Already fixed by this branch — now pinned with tests (each
issue-<n>-*.testfile):#7602, #7457, #7367, #7635, #4684, #6221, #7379, #4696, #4444, #6107, #5106, #7638, #4572, #3179, #4112, #2980, #2905, #2182, #3928, #6371 (#7120 is covered by the base PR's dedicated e2e suite).
Fixed in this PR:
pendingMs: 0deferred pending publication to a macrotask, painting one blank frame on bare initial loads; publication is now synchronous when nothing is rendered yet.ssr: false/data-only+pendingComponent: the_displayPendingbranch rendered the fallback at a different structural depth than the server. It now renders through the sharedClientOnlyslot (this also repairs a data-only regression the base PR introduced vs main). Covered by a dev-server e2e spec.Fixes #7367, fixes #4684, fixes #7379, fixes #4696, fixes #4444, fixes #6107, fixes #5106, fixes #7638, fixes #4572, fixes #3179, fixes #4112, fixes #2980, fixes #2905, fixes #2182, fixes #3928, fixes #6371.
Known gap, documented as an expected-fail test (follow-up needed):
contexton preload (intent) #5778 — a preload borrowing an active ancestor derives context from its committed snapshot, not liverouter.options.context. Fixing requires recomputing borrowed ancestors' contexts from their stored layers — a change to the read-only borrow protocol that deserves its own PR. The documentedinvalidate()workaround is pinned as passing.External PRs:
Route.useRouteContext()returns an empty object instead of null #7110 repro), fix(router-core): handle rejected promise in loadRouteChunk #6645.