,
+})
diff --git a/e2e/react-start/basic/tests/issue-6221-head.spec.ts b/e2e/react-start/basic/tests/issue-6221-head.spec.ts
new file mode 100644
index 0000000000..96274ce0f9
--- /dev/null
+++ b/e2e/react-start/basic/tests/issue-6221-head.spec.ts
@@ -0,0 +1,41 @@
+import { expect } from '@playwright/test'
+import { test } from '@tanstack/router-e2e-utils'
+import { isPreview } from './utils/isPreview'
+import { isSpaMode } from './utils/isSpaMode'
+
+// Ported to React from the regression app in
+// https://github.com/TanStack/router/pull/6222
+test.skip(
+ isSpaMode || isPreview,
+ 'head() coverage for an ssr:false route requires the SSR test mode',
+)
+
+test('browser back uses fresh loaderData for both the body and head (#6221)', async ({
+ page,
+}) => {
+ await page.goto('/')
+ await page.evaluate(() => localStorage.setItem('issue-6221-auth', 'good'))
+
+ await page.goto('/issue-6221/article/1')
+
+ await expect(page.getByTestId('issue-6221-article')).toHaveText(
+ 'Article content for 1',
+ )
+ await expect(page).toHaveTitle('Article Title for 1')
+
+ await page.evaluate(() => localStorage.removeItem('issue-6221-auth'))
+ await page.reload()
+ await expect(page.getByText('Article Not Accessible.')).toBeVisible()
+ await expect(page).toHaveTitle('title n/a')
+
+ await page.evaluate(() => localStorage.setItem('issue-6221-auth', 'good'))
+ await page.getByTestId('issue-6221-dashboard-link').click()
+ await expect(page.getByTestId('issue-6221-dashboard')).toBeVisible()
+ await expect(page).toHaveTitle('Dashboard')
+
+ await page.goBack()
+ await expect(page.getByTestId('issue-6221-article')).toHaveText(
+ 'Article content for 1',
+ )
+ await expect(page).toHaveTitle('Article Title for 1')
+})
diff --git a/packages/react-router/src/Matches.tsx b/packages/react-router/src/Matches.tsx
index d9977db272..a8c7036617 100644
--- a/packages/react-router/src/Matches.tsx
+++ b/packages/react-router/src/Matches.tsx
@@ -8,7 +8,7 @@ import { CatchBoundary } from './CatchBoundary'
import { useRouter } from './useRouter'
import { useStructuralSharing } from './useMatch'
import { useLayoutEffect } from './utils'
-import { Transitioner } from './Transitioner'
+import { Transitioner, settleOwner } from './Transitioner'
import { matchContext } from './matchContext'
import { Match, renderPending } from './Match'
import { SafeFragment } from './SafeFragment'
@@ -73,17 +73,20 @@ export function Matches() {
function MatchesInner() {
const router = useRouter()
+ const acknowledgement = router._rendered!
const matches =
(isServer ?? router.isServer)
? router.stores.matches.get()
: // eslint-disable-next-line react-hooks/rules-of-hooks
- useStore(router.stores.matches, (value) => value)
+ useStore(router.stores.matches, (value) => acknowledgement[0] ?? value)
const match = matches[0]
const routeId = match?.routeId
useLayoutEffect(() => {
- router._rendered!(matches)
- }, [matches, router])
+ if (acknowledgement[0] === matches) {
+ settleOwner(acknowledgement, true)
+ }
+ }, [acknowledgement, matches])
const matchComponent = routeId ? : null
diff --git a/packages/react-router/src/Transitioner.tsx b/packages/react-router/src/Transitioner.tsx
index bd82a3a493..ed63681db9 100644
--- a/packages/react-router/src/Transitioner.tsx
+++ b/packages/react-router/src/Transitioner.tsx
@@ -4,68 +4,47 @@ import * as React from 'react'
import { getLocationChangeInfo, trimPathRight } from '@tanstack/router-core'
import { useLayoutEffect } from './utils'
import { useRouter } from './useRouter'
-import type { AnyRouteMatch } from '@tanstack/router-core'
+import type { AnyRouter } from '@tanstack/router-core'
+
+export function settleOwner(
+ owner: NonNullable,
+ rendered: boolean,
+) {
+ const settle = owner[1]
+ owner.length = 0
+ settle?.(rendered)
+}
export function Transitioner() {
const router = useRouter()
- const acknowledgement = React.useRef<
- [Array, (rendered: boolean) => void] | undefined
- >(undefined)
+ const acknowledgement = (router._rendered ??= [])
const mounted =
process.env.NODE_ENV !== 'production'
? // eslint-disable-next-line react-hooks/rules-of-hooks
React.useRef(false)
: undefined
- // `` precedes ``, so install the render
- // acknowledgement before the latter can publish a rendered lane.
- router._rendered = (matches) => {
- const current = acknowledgement.current
- if (
- current?.[0].length === matches.length &&
- current[0].every(
- (match, index) =>
- match.id === matches[index]!.id &&
- match.abortController === matches[index]!.abortController &&
- match.status === matches[index]!.status,
- )
- ) {
- acknowledgement.current = undefined
- current[1](true)
- }
- }
- if (process.env.NODE_ENV === 'production') {
- router.startTransition = (fn, expected) =>
- new Promise((resolve) => {
- acknowledgement.current?.[1](false)
- acknowledgement.current = [expected, resolve]
+ router.startTransition = (fn, expected) =>
+ new Promise((resolve, reject) => {
+ settleOwner(acknowledgement, false)
+ acknowledgement.push(expected, resolve)
+ if (process.env.NODE_ENV === 'production') {
React.startTransition(fn)
- })
- } else {
- router.startTransition = (fn, expected) =>
- new Promise((resolve, reject) => {
- acknowledgement.current?.[1](false)
- const next: NonNullable = [
- expected,
- resolve,
- ]
- acknowledgement.current = next
+ } else {
try {
React.startTransition(fn)
} catch (cause) {
- if (acknowledgement.current === next) {
- acknowledgement.current = undefined
+ if (acknowledgement[1] === resolve) {
+ acknowledgement.length = 0
}
reject(cause)
}
- })
+ }
+ })
+ if (process.env.NODE_ENV !== 'production') {
;(
router as typeof router & { _cancelTransition?: () => void }
- )._cancelTransition = () => {
- const current = acknowledgement.current
- acknowledgement.current = undefined
- current?.[1](false)
- }
+ )._cancelTransition = () => settleOwner(acknowledgement, false)
}
// Subscribe before canonicalizing so the initial URL has exactly one load.
@@ -110,17 +89,14 @@ export function Transitioner() {
resolvedLocation?.href === location.href &&
resolvedLocation.state.__TSR_key === location.state.__TSR_key
) {
- acknowledgement.current = [
- router.stores.matches.get(),
- (rendered) => {
- if (rendered) {
- router.emit({
- type: 'onRendered',
- ...getLocationChangeInfo(resolvedLocation, resolvedLocation),
- })
- }
- },
- ]
+ acknowledgement.push(router.stores.matches.get(), (rendered) => {
+ if (rendered) {
+ router.emit({
+ type: 'onRendered',
+ ...getLocationChangeInfo(resolvedLocation, resolvedLocation),
+ })
+ }
+ })
} else if (!router._tx) {
router.load().catch(console.error)
}
diff --git a/packages/react-router/tests/react-render-owner-contract.test.tsx b/packages/react-router/tests/react-render-owner-contract.test.tsx
new file mode 100644
index 0000000000..0797fa5264
--- /dev/null
+++ b/packages/react-router/tests/react-render-owner-contract.test.tsx
@@ -0,0 +1,101 @@
+import { act } from 'react'
+import { cleanup, render, screen, waitFor } from '@testing-library/react'
+import { afterEach, expect, test, vi } from 'vitest'
+import {
+ RouterProvider,
+ createControlledPromise,
+ createMemoryHistory,
+ createRootRoute,
+ createRouter,
+} from '../src'
+
+const testCleanups: Array<() => void> = []
+
+afterEach(() => {
+ while (testCleanups.length) {
+ testCleanups.pop()!()
+ }
+ cleanup()
+})
+
+test('a suspended same-membership publication cannot acknowledge its successor', async () => {
+ const firstRenderStarted = createControlledPromise()
+ const firstRenderGate = createControlledPromise()
+ let signaledFirstRender = false
+
+ const rootRoute = createRootRoute({
+ validateSearch: (search: Record) => ({
+ revision: Number(search.revision),
+ }),
+ component: () => {
+ const revision = rootRoute.useSearch().revision
+ if (revision === 1 && firstRenderGate.status === 'pending') {
+ if (!signaledFirstRender) {
+ signaledFirstRender = true
+ firstRenderStarted.resolve()
+ }
+ throw firstRenderGate
+ }
+ return
Root revision {revision}
+ },
+ })
+ const router = createRouter({
+ routeTree: rootRoute,
+ history: createMemoryHistory({ initialEntries: ['/?revision=0'] }),
+ })
+
+ render()
+ expect(await screen.findByText('Root revision 0')).toBeInTheDocument()
+ await waitFor(() => expect(router.state.status).toBe('idle'))
+ const initialIds = router.state.matches.map((match) => match.routeId)
+
+ const renderedRevisions: Array = []
+ const unsubscribe = router.subscribe('onRendered', (event) => {
+ renderedRevisions.push(
+ Number((event.toLocation.search as Record).revision),
+ )
+ })
+ testCleanups.push(unsubscribe)
+
+ let firstNavigation!: Promise
+ await act(async () => {
+ firstNavigation = router.navigate({
+ to: '/',
+ search: { revision: 1 },
+ })
+ await firstRenderStarted
+ })
+
+ const firstSettled = vi.fn()
+ void firstNavigation.then(firstSettled)
+ expect(router.state.matches.map((match) => match.routeId)).toEqual(initialIds)
+ expect(router.state.matches[0]?.search.revision).toBe(1)
+ expect(screen.getByText('Root revision 0')).toBeInTheDocument()
+ expect(firstSettled).not.toHaveBeenCalled()
+ expect(renderedRevisions).toEqual([])
+
+ try {
+ await act(() =>
+ router.navigate({
+ to: '/',
+ search: { revision: 2 },
+ }),
+ )
+ await firstNavigation
+
+ expect(screen.getByText('Root revision 2')).toBeInTheDocument()
+ expect(router.state.matches.map((match) => match.routeId)).toEqual(
+ initialIds,
+ )
+ expect(renderedRevisions).toEqual([2])
+ expect(firstSettled).toHaveBeenCalledOnce()
+ } finally {
+ await act(async () => {
+ firstRenderGate.resolve()
+ await Promise.resolve()
+ })
+ }
+
+ expect(screen.getByText('Root revision 2')).toBeInTheDocument()
+ expect(renderedRevisions).toEqual([2])
+})
diff --git a/packages/router-core/INTERNALS.md b/packages/router-core/INTERNALS.md
index f0dcfd1903..e3ff3f89b1 100644
--- a/packages/router-core/INTERNALS.md
+++ b/packages/router-core/INTERNALS.md
@@ -28,13 +28,13 @@ The main authorities are:
| Authority | What it owns |
| ---------------------------- | ------------------------------------------------------------------- |
-| `_tx` | The one client navigation allowed to commit, redirect, and complete |
+| `_tx` | The client navigation allowed to commit and publish foreground flow |
| `_preflight` | The current client plan or asynchronous hydration reconstruction |
| `_handoff` | The temporary right to transfer one reconstructed SSR prefix |
| `_committed` | The accepted current lane and lifecycle/background CAS base |
| `stores.matches` | The current match presentation exposed to renderers and users |
| `router._cache` | Off-screen loader generations and first same-ID planning seeds |
-| Active preload entry | Cancellation and cache-clearing ownership for one speculative lane |
+| Active preload entry | Cancellation, cache clearing, and private redirect-chain ownership |
| Loader flight registry entry | The latest same-ID loader generation available to new consumers |
| Match flight lease | Ownership keeping that loader work alive |
| Pending session | One reveal/minimum-visible deadline and its current owner |
@@ -279,9 +279,11 @@ signal and can synchronously reenter user code, so an old generation must never
be released while it still appears to be the accepted owner. The committed lane
is also removed from the transaction's private ownership before publication.
-Every asynchronous client publication checks `_tx` immediately before the
-write. Background publication additionally checks the exact committed base
-array from which it was derived.
+Every asynchronous navigation or presentation publication checks `_tx`
+immediately before the write. Background publication additionally checks the
+exact committed base array from which it was derived. Preload cache admission
+and private redirect continuation use their own compare-and-swap and controller
+authorities instead.
## `beforeLoad`: execution and hydration
@@ -301,15 +303,17 @@ Every navigation and every preload rematches and runs its own serial
`beforeLoad` chain. This remains true for identical concurrent preloads and for
a navigation that targets a still-running preload. A call made with
`preload: true` is never accepted as the navigation's call with
-`preload: false`; its context, redirect, error, and not-found result stay private
-to that speculative lane.
-
-`router._preloads` therefore has no semantic adoption role. It tracks active
-speculative match owners only so cache clearing can cancel selected work and
-release their resources safely. Loader results remain independently reusable:
-a completed preload may seed the match cache, and a still-running preload may
-donate its same-ID loader flight after the receiving lane has run its own
-`beforeLoad` and made its reload decision.
+`preload: false`; that call's context and control or terminal outcome stay
+private to its speculative lane. This does not isolate the normalized outcome of
+a same-ID loader flight that multiple lanes deliberately share.
+
+`router._preloads` has no semantic adoption role. Its controller entry owns
+cancellation and cache clearing and proves that a standalone preload remained
+active through normal settlement; successful removal authorizes private redirect
+continuation. Loader results remain independently reusable: a completed preload
+may seed the match cache, and a still-running preload may donate its same-ID
+loader flight after the receiving lane has run its own `beforeLoad` and made its
+reload decision.
### Hydration
@@ -340,12 +344,15 @@ provenance.
Successful loader-backed matches can be cached by match id. Staleness,
invalidation, `shouldReload`, stale reload mode, and GC policy decide whether a
-lane uses that data or runs the loader again.
+lane uses that data or requires a loader generation. A discoverable same-ID
+flight may satisfy that requirement, including when `shouldReload` returns
+`true`.
It is valid for cached loader data to have been produced under context from an
older `beforeLoad` generation. Loaders are the cache boundary; guards are not.
-When a new loader run is needed, it receives the current lane's freshly built
-context.
+Likewise, a shared in-flight invocation may have started with another lane's
+older context. Only a newly started loader invocation receives the current
+lane's freshly built context.
An invalid successful entry may remain in the cache as stale data and an identity
carrier, but it can never satisfy freshness and must reload. Failed, canceled,
@@ -397,27 +404,33 @@ Two facts must remain separate:
- registry membership means new consumers may join the flight;
- a match lease means an existing consumer keeps the flight alive.
-Registry membership normally owns no lease. A settled generation remains
-discoverable while at least one semantic or cached match owns it. Releasing the
-last lease removes that generation only if it is still the current registry
-entry, then aborts its controller. Every copied semantic match that retains a
-flight must acquire a lease, and every discarded match must release one exactly
-once.
+Registry membership normally owns no lease. A successfully settled generation
+may remain discoverable while at least one semantic or cached match owns it. A
+non-success outcome removes the exact current flight from the registry as it
+settles; existing leases keep that outcome alive only for consumers that already
+joined, while later planners start a fresh generation. Releasing the last lease
+removes a successful generation only if it is still the current registry entry,
+then aborts its controller. Every copied semantic match that retains a flight
+must acquire a lease, and every discarded match must release one exactly once.
There is one short reservation phase. The navigation-supersession handoff may
leave a predecessor's same-ID registry flight at zero leases while the successor
contextualizes. An independently owned same-ID flight that loses its last lease
while that lane visibly runs `beforeLoad` joins the same window. Generic releases
after contextualization never infer a reservation merely from `_tx` membership.
-After the navigation's loader tasks are planned, they either acquire the flight
-or a single sweep removes and aborts it. Preloads neither create nor sweep this
-navigation handoff state. No match flag, extra counter, or second completion
-promise represents the phase. An explicit `shouldReload` result does not consume
-a zero-owner reservation: it removes and aborts that generation before
-registering fresh work. Active positive-lease work may still be shared by
-another lane that requests a reload. Invalidation removes discovery entries for
-every selected ID before its superseding load, so it always starts the requested
-generation.
+Successful contextualization invokes loader planning before its async frame
+returns. A lane without `beforeLoad` therefore claims a donor before the first
+promise yield; a lane with an asynchronous guard remains covered by the visible
+`beforeLoad` phase. After the navigation's loader tasks are planned, they either
+acquire the flight or a single sweep removes and aborts it. Preloads neither
+create nor sweep this navigation handoff state. No match flag, extra counter, or
+second completion promise represents the phase. When a lane needs a loader
+generation, any discoverable same-ID flight may satisfy it regardless of whether
+its lease is positive or temporarily reserved at zero. `shouldReload` does not
+bypass this single-flight rule; an explicit `false` declines the donor and lets
+the sweep retire an unconsumed reservation. Invalidation removes discovery
+entries for every selected ID before its superseding load, so it starts the
+requested generation.
Releasing a set of matches is deliberately two-phase. First every outgoing
match drops its `_flight` lease and every zero-owner generation not reserved by
@@ -432,12 +445,14 @@ settled. This keeps the loader's public `AbortSignal` alive for that semantic
generation. The signal aborts when the last active or cached owner is replaced,
unloaded, expired, or discarded; promise settlement alone does not end it.
-Loader error normalization, including route `onError`, runs only while the
-originating match still owns that exact flight. Releasing the match before a
-late rejection makes the generation semantically discarded; abort-triggered
-rejection must not call user error hooks. This is an ownership check, not a
-separate cancellation flag. A loader that aborts its own flight controller while
-its match remains owned can still fulfill or reject normally.
+Loader error normalization, including route `onError`, runs once while the exact
+flight still has a match lease. The terminal flight leaves the discovery
+registry before normalization, so a navigation reentered by `onError` starts a
+fresh generation. Releasing every match before a late rejection makes the
+generation semantically discarded; abort-triggered rejection must not call user
+error hooks. This is an ownership check, not a separate cancellation flag. A
+loader that aborts its own flight controller while its match remains owned can
+still fulfill or reject normally.
A planned match holds only the lease for the accepted generation copied from
cache or committed state. After `beforeLoad` and `shouldReload` decide that a
@@ -452,10 +467,11 @@ Active preload flights need no special handoff. Their speculative matches keep
ordinary positive leases until the preload settles or is canceled, so another
lane can discover and acquire the flight without adopting the preload lane.
-A joining navigation accepts successful shared loader work. A failure,
-not-found, or cancellation produced under another speculative generation is not
-allowed to govern it; the navigation releases that flight and registers its own
-generation. Redirect remains control flow and is never cached as loader data.
+Consumers already joined to one loader flight observe its single normalized
+outcome, including error and not-found. Per-lane cancellation can stop only that
+consumer's wait. A non-success flight retires from discovery at settlement, so
+only lanes planned afterward retry. Redirect remains control flow and is never
+cached as loader data.
### Semantic parent chain
@@ -487,11 +503,11 @@ obsolete import cannot install options afterward.
Normal component readiness is part of route readiness, not merely an asset
prefetch side effect. Client loader and normal component work may run in
parallel, and a blocking match becomes successful only after both are ready.
-Chunk settlement also wakes pending selection, so a `pendingComponent` supplied
-by newly installed lazy options can become visible while the route's loader is
-still running. This does not require retaining a component promise on the
-match: the route's lazy-option owner and the framework/module loader provide the
-necessary work identity.
+When a client lane installs a `pendingComponent` from lazy options, that
+component wakes pending selection once it is itself ready, without waiting for
+the normal component. This does not require retaining a component promise on
+the match: the route's lazy-option owner and the framework/module loader provide
+the necessary work identity.
Client and server not-found boundary searches settle lazy options on each
candidate route before testing for `notFoundComponent`. A lazy rejection while
@@ -679,10 +695,11 @@ There is one pending session in `router._pending` and one absolute deadline:
reveal deadline -> exact render acknowledgement -> minimum-visible deadline
```
-The session also remembers the pending component identity. A normal lazy route
-chunk can install a more specific `pendingComponent` after the default fallback
-has already rendered; chunk settlement re-offers the same lane so the framework
-can replace that fallback without creating another pending session or deadline.
+The session also remembers the pending component identity. An active client
+lane loading a lazy route chunk can install a more specific `pendingComponent`
+after the default fallback has already rendered. Once that pending component is
+ready, core re-offers the same lane so the framework can replace the fallback
+without creating another pending session or deadline.
The reveal deadline is anchored to the transaction's lane-level `startedAt`.
Discovering lazy pending options later, advancing to another boundary, or
@@ -714,39 +731,34 @@ the superseded navigation. Changing the boundary discards the old session.
- `false` means core must finish without emitting `onRendered` or starting a
pending minimum based on that publication.
-React cannot await `React.startTransition` directly. Its adapter has one current
-acknowledgement slot, and `Matches` settles that slot from a layout effect. A new
-expected publication first settles the previous slot as `false`, then installs
-itself before the transition callback runs. This ordering matters because
-publication can invoke a route lifecycle callback that synchronously starts and
-publishes a successor navigation. Installing the expectation after the callback
-would let the superseded publication replace the successor's slot.
-The lifetime-owned Transitioner installs these callbacks during render, before
-the sibling match tree can register its acknowledgement effect.
+React cannot await `React.startTransition` directly. Its adapter keeps one
+router-owned acknowledgement tuple, and `Matches` settles that tuple from a
+layout effect. A new expected publication first settles the previous receipt as
+`false`, then stores the exact offered array before the transition callback
+runs. This ordering matters because publication can invoke a route lifecycle
+callback that synchronously starts and publishes a successor navigation.
+Installing the expectation after the callback would let the superseded
+publication replace the successor's receipt.
An already-settled router mounted into React uses the same acknowledgement slot
for its initial `onRendered` event. It therefore emits only after the exact match
tree and its descendant layout effects have committed, rather than from the
earlier history-subscription effect.
-A generation is its array length plus the ordered sequence of match ID,
-execution-controller identity, and render status. These are existing semantic
-facts rather than a separate generation counter. A new transaction or
-background candidate changes controller identity; pending-to-terminal
-publication changes status. Object reference equality is deliberately not
-required because `isFetching` and per-match store reconciliation can replace
-objects without changing the logical publication. An exact signature settles
-the current slot as `true`. A mismatched render is ignored; only installing a
-successor settles the current slot as `false`.
+While a receipt is pending, the aggregate match subscription selects the exact
+offered array instead of reconstructing an equivalent presentation. The layout
+effect acknowledges only that reference. A suspended older render can therefore
+never satisfy a newer receipt merely because its IDs or statuses look the same.
+No generation counter or structural signature is needed.
Solid awaits its transition, and Vue awaits its render tick. For an
already-settled Solid mount, history subscription remains before the route tree
while a post-match notifier emits the initial `onRendered` event after
-descendant mount effects. The architecture assumes exactly one Transitioner is
-mounted per router and remains mounted for the lifetime of the application. Its
-history subscription and acknowledgement machinery are therefore
-lifetime-owned; there is deliberately no second unmount-time completion
-authority.
+descendant mount effects.
+
+React assumes one provider and one router. Keeping the tuple on that router lets
+the render tree and core share the exact same receipt without component-local
+generation state, structural signatures, or router-swap machinery.
Every core write passed to `startTransition` must notify the aggregate matches
store even if much of the lane is structurally reused. Suppressing the write can
@@ -864,11 +876,14 @@ A preload can also install durable lazy route options through the separate
route-chunk owner described above. That is route definition readiness, not
authority over a completed match lane or `beforeLoad` result.
-A preload's failure, not-found, redirect, cancellation, route context, and
-`beforeLoad` result do not become authority for another preload or a later
-navigation. Standalone preload redirects may be followed only while the preload
-remains current and within its bounded redirect policy. A preload never performs
-a document reload.
+Preload lane-local matching, route context, validation, cancellation, and
+`beforeLoad` outcomes do not become authority for another preload or a later
+navigation. Consumers that join its same-ID loader flight nevertheless share
+that flight's normalized outcome. After a normal standalone preload redirect,
+the private chain continues only if that lane's controller was still present and
+was successfully removed from `_preloads`; replacing an unrelated `_tx` does not
+suppress it. The chain remains depth-bounded, never follows `reloadDocument`,
+and never publishes presentation or history.
The public `preloadRoute` result describes the speculative lane, not merely its
cacheable subset. An ordinary error or not-found therefore resolves with the
@@ -1136,7 +1151,9 @@ continuation authority.
When changing this architecture, verify all of the following:
-1. Only current `_tx` commits, redirects, or completes a client navigation.
+1. Only current `_tx` commits, performs a foreground redirect, or completes a
+ client navigation. Standalone preload redirect continuation is private and
+ authorized by its active-preload controller entry, not `_tx`.
2. Only current `_preflight` may publish a client plan or asynchronous hydration
reconstruction; it is installed before supported reentrant lifecycle events
and route callbacks. After hydration publication, only the exact
@@ -1154,8 +1171,9 @@ When changing this architecture, verify all of the following:
same-ID route-local `_ctx`, but never merged context or `beforeLoad` output.
7. Every ordinary client lane runs its own serial `beforeLoad` chain. Active
preloads may donate same-ID loader flights and completed preloads may donate
- cache entries, but neither can donate merged context, control flow, or a
- `beforeLoad` result.
+ cache entries, but neither can donate merged context or a `beforeLoad`
+ result or control flow. Sharing a loader flight shares its normalized loader
+ outcome.
8. Hydration is the only completed-work exception for `beforeLoad` reuse. It
verifies each transported match identity, reconstructs the accepted ordered
prefix, reruns route context locally, preserves terminal/selective-SSR
@@ -1176,10 +1194,13 @@ When changing this architecture, verify all of the following:
started by navigation, preload, or background work. Registry joinability
and lease ownership remain separate; donor selection happens synchronously
after the reload decision, and accepted signal lifetime may outlive promise
- settlement. A zero-lease reservation exists only while the current `_tx`
- contextualizes a distinct same-ID successor; the explicit supersession
- handoff creates it, contextualizing lanes may retain it, and navigation task
- planning consumes or sweeps it.
+ settlement. Non-success settlement retires the exact current registry entry
+ while existing leases share that outcome; later lanes retry. A zero-lease
+ reservation exists only while the current `_tx` contextualizes a distinct
+ same-ID successor; the explicit supersession handoff creates it,
+ contextualizing lanes may retain it, and navigation task planning consumes
+ or sweeps it. Successful contextualization invokes that planning before
+ returning, so a guard-free lane cannot yield away its claim.
12. Child `parentMatchPromise` follows the fresh semantic parent generation.
13. The first parallel ordinary failure is chronological unless a required
ancestor failure has no accepted loader data or its required chunk fails;
@@ -1202,12 +1223,12 @@ When changing this architecture, verify all of the following:
not recomputed later.
19. `isFetching` clears for success, failure, cancellation, and supersession.
20. Transition acknowledgement settlement gates resolved/idle completion; React
- identifies the exact generation by length plus ordered ID, controller, and
- status, and installs it in one superseding slot before running the possibly
- reentrant transition callback. Only `true` gates `onRendered` and pending
- minimum timing.
-21. The single Transitioner and its acknowledgement machinery live for the
- router's entire application lifetime.
+ installs the exact offered array in one superseding owner before running the
+ possibly reentrant transition callback. Only that reference can settle
+ `true`, which gates `onRendered` and pending minimum timing.
+21. React's acknowledgement tuple belongs to the one provider router; no
+ component-local generation counter, structural signature, or router-swap
+ authority duplicates it.
22. Reentrant callbacks suppress every stale later event or publication.
23. Canonicalization compares browser-facing `publicHref`; semantic `href` is
not a symmetric canonical key across input and output rewrites.
diff --git a/packages/router-core/src/load-client.ts b/packages/router-core/src/load-client.ts
index 229fb9732b..7e21a90e17 100644
--- a/packages/router-core/src/load-client.ts
+++ b/packages/router-core/src/load-client.ts
@@ -41,26 +41,35 @@ function preloadComponent(
return (route.options[type] as any)?.preload?.()
}
-function loadComponents(route: AnyRoute): Promise | undefined {
+function loadComponents(
+ route: AnyRoute,
+ onPendingReady?: () => void,
+): Promise | undefined {
const component = preloadComponent(route, 'component')
const pending = preloadComponent(route, 'pendingComponent')
- if (component && pending) {
- return Promise.all([component, pending]).then(() => {})
+ const pendingReady =
+ onPendingReady && pending ? pending.then(onPendingReady) : pending
+ if (onPendingReady && !pending) {
+ onPendingReady()
+ }
+ if (component && pendingReady) {
+ return Promise.all([component, pendingReady]).then(() => {})
}
- return component ?? pending
+ return component ?? pendingReady
}
export function loadRouteChunk(
route: AnyRoute,
// `false` waits only for lazy route options, before a boundary is selected.
componentType?: 'errorComponent' | 'notFoundComponent' | false,
+ onPendingReady?: () => void,
): Promise | undefined {
const afterLazy = () =>
componentType === false
? undefined
: componentType
? preloadComponent(route, componentType)
- : loadComponents(route)
+ : loadComponents(route, onPendingReady)
const current = route._lazy
if (current) {
return current === true ? afterLazy() : current.then(afterLazy)
@@ -343,6 +352,7 @@ async function contextualize(
lane: MatchedLane,
options: ExecuteLaneOptions,
end: number,
+ planSuccessfulLane: () => void,
): Promise {
const [location, matches] = lane
const signal = options[0].signal
@@ -450,6 +460,8 @@ async function contextualize(
}
}
+ // Let a synchronous lane claim predecessor flights before this frame yields.
+ planSuccessfulLane()
return
}
@@ -624,13 +636,12 @@ async function loadResource(
}
let flight = match._flight
- let joined = !!flight
setFetching(router, match, 'loader', owner)
try {
- for (;;) {
- if (!flight) {
- const controller = new AbortController()
- const outcome = Promise.resolve()
+ if (!flight) {
+ const controller = new AbortController()
+ flight = [
+ Promise.resolve()
.then(() =>
loader(
getLoaderContext(
@@ -649,34 +660,35 @@ async function loadResource(
(cause) => normalize(cause, true, route.id),
)
.then((result): LoaderOutcome => {
- return result[0] === ERROR && match._flight === flight
+ // The registry controls discovery; leases keep current consumers
+ // sharing the same terminal outcome.
+ if (
+ result[0] !== SUCCESS &&
+ router._flights?.get(match.id) === flight
+ ) {
+ router._flights!.delete(match.id)
+ if (!flight![2]) {
+ controller.abort()
+ }
+ }
+ return result[0] === ERROR && flight![2]
? normalizeError(route, result[1])
: result
- })
- flight = [outcome, controller, 1]
- ;(router._flights ??= new Map()).set(match.id, flight)
- }
- match._flight = flight
- match.abortController = flight[1]
- try {
- const outcome = await waitFor(flight[0], signal)
- if (!joined || outcome[0] === SUCCESS || outcome[0] === REDIRECTED) {
- return outcome
- }
- } catch (cause) {
- if (cause === signal) {
- releaseFlight(router, match)
- return [CANCELED]
- }
- throw cause
- }
- releaseFlight(router, match)
- if (signal.aborted) {
- return [CANCELED]
- }
- flight = undefined
- joined = false
+ }),
+ controller,
+ 1,
+ ]
+ ;(router._flights ??= new Map()).set(match.id, flight)
+ }
+ match._flight = flight
+ match.abortController = flight[1]
+ return await waitFor(flight[0], signal)
+ } catch (cause) {
+ if (cause !== signal) {
+ throw cause
}
+ releaseFlight(router, match)
+ return [CANCELED]
} finally {
setFetching(router, match, false, owner)
}
@@ -825,13 +837,6 @@ function createLoaderTask(
: undefined
if (donor === match._flight || reloadFailure) {
donor = undefined
- } else if (donor && !donor[2] && configured !== undefined) {
- // A transaction may temporarily reserve its predecessor's generation
- // while beforeLoad settles. An explicit reload decision starts fresh, so
- // retire the unowned generation before its registry entry is replaced.
- router._flights!.delete(match.id)
- donor[1].abort()
- donor = undefined
} else if (donor && !reload && !preload && configured === undefined) {
// Normal cache policy accepts an already-running generation even when this
// lane itself would not have started another loader.
@@ -853,6 +858,8 @@ function createLoaderTask(
const loaded = reload && (!preload || route.options.preload !== false)
const blocking =
loaded && !background && (match.status !== 'success' || !!routeLoader)
+ const onLazyReady =
+ route.lazyFn && route._lazy !== true ? options[8] : undefined
if (loaded && !routeLoader) {
match.invalid = false
match.updatedAt = Date.now()
@@ -906,13 +913,10 @@ function createLoaderTask(
})
const rawChunkFailure = waitFor(
- Promise.resolve().then(() => loadRouteChunk(route)),
+ Promise.resolve().then(() => loadRouteChunk(route, undefined, onLazyReady)),
options[0].signal,
).then(
- () => {
- options[8]?.()
- return undefined
- },
+ () => undefined,
(cause): IndexedOutcome => [
index,
normalizeLaneError(route, cause, options),
@@ -1256,42 +1260,51 @@ async function executeClientLane(
plannedBoundary = boundary
}
let end = plannedBoundary < 0 ? matches.length : plannedBoundary + 1
- // From here on `matched` is contextualized: `contextualize` communicates
- // through mutation plus a failure return, so the phase brand is asserted at
- // the two use sites below rather than granted by a (byte-costing) return.
- const failure = await contextualize(router, matched, options, end)
- if (failure) {
- options[5] = true
- }
const tasks: Array = []
const start = options[7] ?? 0
let semanticParent = start
? Promise.resolve(matched[1][start - 1]!)
: undefined
- end = failure?.[0] ?? end
- if (failure?.[1][0] === NOT_FOUND) {
- failure[2] = await getNotFoundBoundary(
- router,
- matched[1],
- failure,
- options[0].signal,
- )
- end = Math.min(end, failure[2] + 1)
- } else if ((failure?.[1][0] ?? 0) >= REDIRECTED) {
- end = 0
+ const planSuccessfulLane = () => {
+ for (let index = start; index < end; index++) {
+ if (options[0].signal.aborted) {
+ break
+ }
+ semanticParent = createLoaderTask(
+ router,
+ matched as ContextualizedLane,
+ index,
+ tasks,
+ semanticParent,
+ options,
+ )
+ }
}
- for (let index = start; index < end; index++) {
- if (options[0].signal.aborted) {
- break
+ // From here on `matched` is contextualized: `contextualize` communicates
+ // through mutation plus a failure return, so the phase brand is asserted at
+ // the two use sites below rather than granted by a (byte-costing) return.
+ const failure = await contextualize(
+ router,
+ matched,
+ options,
+ end,
+ planSuccessfulLane,
+ )
+ if (failure) {
+ options[5] = true
+ end = failure[0]
+ if (failure[1][0] === NOT_FOUND) {
+ failure[2] = await getNotFoundBoundary(
+ router,
+ matched[1],
+ failure,
+ options[0].signal,
+ )
+ end = Math.min(end, failure[2] + 1)
+ } else if (failure[1][0] >= REDIRECTED) {
+ end = 0
}
- semanticParent = createLoaderTask(
- router,
- matched as ContextualizedLane,
- index,
- tasks,
- semanticParent,
- options,
- )
+ planSuccessfulLane()
}
if (options[2]() && !options[4]) {
releaseUnownedFlights(router)
@@ -2110,30 +2123,6 @@ export async function refreshClientRoute(
await loadClientRoute(router, { sync: true })
}
-function followPreloadRedirect(
- router: CoordinatorRouter,
- result: ControlOutcome,
- location: ParsedLocation,
- owner: LoadTransaction | undefined,
- redirects: number,
-): Promise | undefined> | undefined {
- if (
- result[0] === REDIRECTED &&
- !result[1].options.reloadDocument &&
- router._tx === owner
- ) {
- return preloadClientRoute(
- router,
- {
- ...result[1].options,
- _fromLocation: location,
- },
- redirects + 1,
- )
- }
- return
-}
-
export async function preloadClientRoute(
router: CoordinatorRouter,
opts: any,
@@ -2142,10 +2131,9 @@ export async function preloadClientRoute(
if (redirects > 20) {
return
}
- const owner = router._tx
if (
process.env.NODE_ENV !== 'production' &&
- (router._refreshNextLoad || owner?.[6])
+ (router._refreshNextLoad || router._tx?.[6])
) {
return
}
@@ -2174,22 +2162,29 @@ export async function preloadClientRoute(
if (isControl(result)) {
controller.abort()
transferMatchResources(router, matches)
- return followPreloadRedirect(router, result, location, owner, redirects)
+ return result[0] === REDIRECTED && !result[1].options.reloadDocument
+ ? preloadClientRoute(
+ router,
+ {
+ ...result[1].options,
+ _fromLocation: location,
+ },
+ redirects + 1,
+ )
+ : undefined
}
transferMatchResources(router, result[1])
controller.abort()
return result[1]
} catch (cause) {
- if (!matches || router._preloads?.delete(controller)) {
- controller.abort()
- if (matches) {
- transferMatchResources(router, matches)
- }
- }
- if (router._tx !== owner) {
+ if (matches && !router._preloads?.delete(controller)) {
return
}
+ controller.abort()
+ if (matches) {
+ transferMatchResources(router, matches)
+ }
if (!isNotFound(cause)) {
console.error(cause)
}
diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts
index cdc38a8ac2..71730788ad 100644
--- a/packages/router-core/src/router.ts
+++ b/packages/router-core/src/router.ts
@@ -1034,8 +1034,11 @@ export interface RouterCore<
_pending?: PendingSession
/** Result of the latest server load, used to render or redirect. */
_serverResult?: ServerLoadResult
- /** Framework callback that acknowledges an exact matches publication. */
- _rendered?: (matches: Array) => void
+ /** Framework publication waiting for an exact render acknowledgement. */
+ _rendered?: [
+ offered?: Array,
+ settle?: (rendered: boolean) => void,
+ ]
/** Development-only HMR reload for a route and its descendants. */
_refreshRoute: (() => Promise) | undefined
/** Development-only replacement for a route's lazy chunk owner. */
@@ -2605,7 +2608,10 @@ export class RouterCore<
}
}
- loadRouteChunk = loadRouteChunk
+ loadRouteChunk: (
+ route: AnyRoute,
+ componentType?: 'errorComponent' | 'notFoundComponent' | false,
+ ) => Promise | undefined = loadRouteChunk
preloadRoute: PreloadRouteFn<
TRouteTree,
diff --git a/packages/router-core/tests/issue-3928-rapid-reload-abort.test.ts b/packages/router-core/tests/issue-3928-rapid-reload-abort.test.ts
index 6462971f17..a16258be0b 100644
--- a/packages/router-core/tests/issue-3928-rapid-reload-abort.test.ts
+++ b/packages/router-core/tests/issue-3928-rapid-reload-abort.test.ts
@@ -38,13 +38,15 @@ const createAbortableInvocation = (
// https://github.com/TanStack/router/issues/3928
describe('issue #3928: rapid reloads of a reused parent', () => {
- test('an aborted parent execution cannot clear the replacement load', async () => {
+ test('one parent flight can serve rapid successors without clearing the latest child load', async () => {
const indexAbort = vi.fn()
const rootInvocations = new Map()
const indexInvocations = new Map()
const rootRoute = new BaseRootRoute({
shouldReload: true,
+ // Search is deliberately not part of this loader's key. Its active
+ // same-ID flight may serve successors while the child keys each filter.
loader: ({ abortController, location }) => {
const { invocation, promise } =
createAbortableInvocation(abortController)
@@ -100,31 +102,30 @@ describe('issue #3928: rapid reloads of a reused parent', () => {
const navAB = router.navigate({ to: '/', search: { filter: 'ab' } })
await vi.waitFor(() => {
- expect(rootInvocations.has('ab')).toBe(true)
expect(indexInvocations.has('ab')).toBe(true)
- expect(rootInvocations.get('a')!.signal.aborted).toBe(true)
expect(indexInvocations.get('a')!.signal.aborted).toBe(true)
+ expect(rootInvocations.get('a')!.signal.aborted).toBe(false)
})
const navABC = router.navigate({ to: '/', search: { filter: 'abc' } })
await vi.waitFor(() => {
- expect(rootInvocations.has('abc')).toBe(true)
expect(indexInvocations.has('abc')).toBe(true)
- expect(rootInvocations.get('ab')!.signal.aborted).toBe(true)
expect(indexInvocations.get('ab')!.signal.aborted).toBe(true)
+ expect(rootInvocations.get('a')!.signal.aborted).toBe(false)
})
- rootInvocations.get('abc')!.resolve('root:abc')
+ rootInvocations.get('a')!.resolve('root:a')
indexInvocations.get('abc')!.resolve('abc')
await Promise.all([navA, navAB, navABC])
expect(indexAbort).toHaveBeenCalledTimes(2)
- expect(rootInvocations.get('abc')!.signal.aborted).toBe(false)
+ expect(rootInvocations.size).toBe(2)
+ expect(rootInvocations.get('a')!.signal.aborted).toBe(false)
expect(indexInvocations.get('abc')!.signal.aborted).toBe(false)
expect(router.state.location.search).toEqual({ filter: 'abc' })
expect(router.state.matches[0]).toMatchObject({
status: 'success',
- loaderData: 'root:abc',
+ loaderData: 'root:a',
})
expect(router.state.matches[1]).toMatchObject({
status: 'success',
diff --git a/packages/router-core/tests/load.test.ts b/packages/router-core/tests/load.test.ts
index d422fefa70..53a6444f0b 100644
--- a/packages/router-core/tests/load.test.ts
+++ b/packages/router-core/tests/load.test.ts
@@ -3,6 +3,7 @@ import { createMemoryHistory } from '@tanstack/history'
import {
BaseRootRoute,
BaseRoute,
+ createControlledPromise,
notFound,
redirect,
rootRouteId,
@@ -457,21 +458,35 @@ describe('loader skip or exec', () => {
expect(loader).toHaveBeenCalledTimes(2)
})
- test('exec if pending preload returns notFound', async () => {
+ test('navigation shares a pending preload loader notFound', async () => {
+ const loaderGate = createControlledPromise()
const loader: Loader = vi.fn(async ({ preload }) => {
- await sleep(100)
+ await loaderGate
if (preload) throw notFound()
})
const router = setup({
loader,
})
- router.preloadRoute({ to: '/foo' })
+ const preload = router.preloadRoute({ to: '/foo' })
+ await vi.waitFor(() => expect(loader).toHaveBeenCalledOnce())
+
+ const navigation = router.navigate({ to: '/foo' })
await Promise.resolve()
- await router.navigate({ to: '/foo' })
+ expect(loader).toHaveBeenCalledOnce()
+
+ loaderGate.resolve()
+ await Promise.all([preload, navigation])
expect(router.state.location.pathname).toBe('/foo')
- expect(router.state.matches.at(-1)?.status).toBe('success')
- expect(loader).toHaveBeenCalledTimes(2)
+ expect(router.state.matches.at(-1)).toMatchObject({
+ routeId: '/foo',
+ status: 'notFound',
+ error: {
+ isNotFound: true,
+ routeId: '/foo',
+ },
+ })
+ expect(loader).toHaveBeenCalledOnce()
})
test('exec if rejected preload (redirect)', async () => {
@@ -523,21 +538,33 @@ describe('loader skip or exec', () => {
expect(loader).toHaveBeenCalledTimes(2)
})
- test('exec if pending preload errors', async () => {
+ test('navigation shares a pending preload loader error', async () => {
+ const loaderGate = createControlledPromise()
+ const failure = new Error('preload failed')
const loader: Loader = vi.fn(async ({ preload }) => {
- await sleep(100)
- if (preload) throw new Error('error')
+ await loaderGate
+ if (preload) throw failure
})
const router = setup({
loader,
})
- router.preloadRoute({ to: '/foo' })
+ const preload = router.preloadRoute({ to: '/foo' })
+ await vi.waitFor(() => expect(loader).toHaveBeenCalledOnce())
+
+ const navigation = router.navigate({ to: '/foo' })
await Promise.resolve()
- await router.navigate({ to: '/foo' })
+ expect(loader).toHaveBeenCalledOnce()
+
+ loaderGate.resolve()
+ await Promise.all([preload, navigation])
expect(router.state.location.pathname).toBe('/foo')
- expect(router.state.matches.at(-1)?.status).toBe('success')
- expect(loader).toHaveBeenCalledTimes(2)
+ expect(router.state.matches.at(-1)).toMatchObject({
+ routeId: '/foo',
+ status: 'error',
+ error: failure,
+ })
+ expect(loader).toHaveBeenCalledOnce()
})
})
diff --git a/packages/router-core/tests/loader-architecture-regressions.test.ts b/packages/router-core/tests/loader-architecture-regressions.test.ts
index 0ce0ea6a6f..d849305244 100644
--- a/packages/router-core/tests/loader-architecture-regressions.test.ts
+++ b/packages/router-core/tests/loader-architecture-regressions.test.ts
@@ -698,6 +698,98 @@ test('a joined descendant loader redirect still wins after an ancestor loader fa
expect(childLoads).toBe(1)
})
+test('an unrelated preload redirect does not override a navigation error', async () => {
+ const preloadChildStarted = createControlledPromise()
+ const releasePreloadRedirect = createControlledPromise()
+ const navigationFailure = new Error('navigation parent failed')
+
+ const rootRoute = new BaseRootRoute({})
+ const indexRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ })
+ const parentRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/parent',
+ validateSearch: (search: Record) => ({
+ mode: String(search.mode ?? ''),
+ }),
+ loaderDeps: ({ search }) => ({ mode: search.mode }),
+ loader: ({ deps }) => {
+ if (deps.mode === 'navigation') {
+ throw navigationFailure
+ }
+ return 'preload parent data'
+ },
+ errorComponent: () => null,
+ })
+ const childLoader = vi.fn(async ({ deps }: { deps: { mode: string } }) => {
+ if (deps.mode === 'preload') {
+ preloadChildStarted.resolve()
+ await releasePreloadRedirect
+ throw redirect({ to: '/target' })
+ }
+ return 'navigation child data'
+ })
+ const childRoute = new BaseRoute({
+ getParentRoute: () => parentRoute,
+ path: '/child',
+ loaderDeps: ({ search }) => ({ mode: search.mode }),
+ loader: childLoader,
+ })
+ const targetRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/target',
+ })
+ const router = createTestRouter({
+ routeTree: rootRoute.addChildren([
+ indexRoute,
+ parentRoute.addChildren([childRoute]),
+ targetRoute,
+ ]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+
+ await router.load()
+ const preload = router.preloadRoute({
+ to: '/parent/child',
+ search: { mode: 'preload' },
+ })
+ await preloadChildStarted
+
+ await router.navigate({
+ to: '/parent/child',
+ search: { mode: 'navigation' },
+ })
+
+ expect(router.state.location).toMatchObject({
+ pathname: '/parent/child',
+ search: { mode: 'navigation' },
+ })
+ expect(
+ router.state.matches.find((match) => match.routeId === parentRoute.id),
+ ).toMatchObject({
+ status: 'error',
+ error: navigationFailure,
+ })
+ expect(childLoader).toHaveBeenCalledTimes(2)
+
+ releasePreloadRedirect.resolve()
+ const preloaded = await preload
+
+ expect(preloaded?.at(-1)?.routeId).toBe(targetRoute.id)
+ expect(router.state.location).toMatchObject({
+ pathname: '/parent/child',
+ search: { mode: 'navigation' },
+ })
+ expect(
+ router.state.matches.find((match) => match.routeId === parentRoute.id),
+ ).toMatchObject({
+ status: 'error',
+ error: navigationFailure,
+ })
+})
+
test('a loaderDeps error is handled by the route error boundary', async () => {
const failure = new Error('loader deps failed')
const rootRoute = new BaseRootRoute({})
diff --git a/packages/router-core/tests/preload-adoption.test.ts b/packages/router-core/tests/preload-adoption.test.ts
index 46d3f6269f..94d31e2e94 100644
--- a/packages/router-core/tests/preload-adoption.test.ts
+++ b/packages/router-core/tests/preload-adoption.test.ts
@@ -245,13 +245,12 @@ describe('preload adoption', () => {
expect(match?.loaderData).toBeUndefined()
})
- test('navigation retries a failed joined preload without starving sibling work', async () => {
+ test('navigation shares a failed pending preload without starving sibling work', async () => {
const preloadParentStarted = createControlledPromise()
const preloadFailureGate = createControlledPromise()
- const navigationStarted = createControlledPromise()
- const navigationParentRetryStarted = createControlledPromise()
const childStarted = createControlledPromise()
const childGate = createControlledPromise()
+ const parentFailure = new Error('preload failed')
let parentLoads = 0
let childLoads = 0
const rootRoute = new BaseRootRoute({})
@@ -262,19 +261,13 @@ describe('preload adoption', () => {
const parentRoute = new BaseRoute({
getParentRoute: () => rootRoute,
path: '/parent',
- beforeLoad: ({ preload }) => {
- if (!preload) {
- navigationStarted.resolve()
- }
- },
loader: async ({ preload }) => {
parentLoads++
if (preload) {
preloadParentStarted.resolve()
await preloadFailureGate
- throw new Error('preload failed')
+ throw parentFailure
}
- navigationParentRetryStarted.resolve()
return 'navigation data'
},
})
@@ -308,27 +301,24 @@ describe('preload adoption', () => {
expect(childLoads).toBe(1)
preloadFailureGate.resolve()
- // An ordinary ancestor failure waits for every started descendant so a
- // later redirect can still win before navigation retries.
childGate.resolve('child data')
- await navigationStarted
- await navigationParentRetryStarted
- expect(parentLoads).toBe(2)
- expect(childLoads).toBe(1)
-
await Promise.all([navigation, preload])
- expect(parentLoads).toBe(2)
+ expect(parentLoads).toBe(1)
expect(childLoads).toBe(1)
expect(router.state.location.pathname).toBe('/parent/child')
expect(
- router.state.matches.find((match) => match.routeId === parentRoute.id)
- ?.loaderData,
- ).toBe('navigation data')
+ router.state.matches.find((match) => match.routeId === parentRoute.id),
+ ).toMatchObject({
+ status: 'error',
+ error: parentFailure,
+ })
expect(
- router.state.matches.find((match) => match.routeId === childRoute.id)
- ?.loaderData,
- ).toBe('child data')
+ router.state.matches.find((match) => match.routeId === childRoute.id),
+ ).toMatchObject({
+ status: 'success',
+ loaderData: 'child data',
+ })
})
test('a route with preload disabled does not discard its preloaded ancestor', async () => {
diff --git a/packages/router-core/tests/public-client-loading-contract.test.ts b/packages/router-core/tests/public-client-loading-contract.test.ts
index 5df38ee0db..c3cb0c7577 100644
--- a/packages/router-core/tests/public-client-loading-contract.test.ts
+++ b/packages/router-core/tests/public-client-loading-contract.test.ts
@@ -95,6 +95,67 @@ describe('public client loading contracts', () => {
)
})
+ test('a successor does not fall back to older data while a background reload is in flight', async () => {
+ const backgroundResult = createControlledPromise()
+ const successorPlanned = createControlledPromise()
+ let loaderCalls = 0
+ const loader = vi.fn(() => {
+ loaderCalls++
+ if (loaderCalls === 1) {
+ return 'cached data'
+ }
+ if (loaderCalls === 2) {
+ return backgroundResult
+ }
+ return 'updated data'
+ })
+ const rootRoute = new BaseRootRoute({})
+ const pageRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/page',
+ validateSearch: (search: Record) => ({
+ phase: String(search.phase ?? 'initial'),
+ }),
+ shouldReload: ({ location }) => {
+ const phase = (location.search as { phase: string }).phase
+ if (phase === 'successor') {
+ successorPlanned.resolve()
+ }
+ return phase === 'reload' ? true : undefined
+ },
+ staleTime: Infinity,
+ loader: {
+ staleReloadMode: 'background',
+ handler: loader,
+ },
+ })
+ const router = createTestRouter({
+ routeTree: rootRoute.addChildren([pageRoute]),
+ history: createMemoryHistory({
+ initialEntries: ['/page?phase=initial'],
+ }),
+ })
+
+ await router.load()
+ await router.navigate({
+ to: '/page',
+ search: { phase: 'reload' },
+ })
+ await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(2))
+
+ const successor = router.navigate({
+ to: '/page',
+ search: { phase: 'successor' },
+ })
+ await successorPlanned
+ backgroundResult.resolve('updated data')
+ await successor
+
+ await vi.waitFor(() => {
+ expect(router.state.matches.at(-1)?.loaderData).toBe('updated data')
+ })
+ })
+
test('a background loader can fulfill after aborting its controller', async () => {
let loaderCalls = 0
const loader = vi.fn(({ abortController }) => {
diff --git a/packages/router-core/tests/public-preload-lane-contract.test.ts b/packages/router-core/tests/public-preload-lane-contract.test.ts
index 5c041d695f..9be86c1b03 100644
--- a/packages/router-core/tests/public-preload-lane-contract.test.ts
+++ b/packages/router-core/tests/public-preload-lane-contract.test.ts
@@ -900,8 +900,9 @@ describe('public preload lane contracts', () => {
})
})
- test('a retry reuses successful loader data from the failed preload lane', async () => {
+ test('a later retry reuses successful loader data from the failed preload lane', async () => {
const childGate = createControlledPromise()
+ const childFailure = new Error('preload child failed')
const beforeLoad = vi.fn(({ preload }: { preload: boolean }) => ({
source: preload ? 'preload' : 'navigation',
}))
@@ -913,7 +914,7 @@ describe('public preload lane contracts', () => {
const childLoader = vi.fn(async ({ preload }: { preload: boolean }) => {
if (preload) {
await childGate
- throw new Error('preload child failed')
+ throw childFailure
}
return 'child data'
})
@@ -944,10 +945,24 @@ describe('public preload lane contracts', () => {
await router.load()
const preload = router.preloadRoute({ to: '/parent/child' })
await vi.waitFor(() => expect(childLoader).toHaveBeenCalledTimes(1))
- const navigation = router.navigate({ to: '/parent/child' })
childGate.resolve()
- await Promise.all([preload, navigation])
+ const terminal = await preload
+
+ expect(
+ terminal?.find((match) => match.routeId === parentRoute.id),
+ ).toMatchObject({
+ status: 'success',
+ loaderData: 'parent data',
+ })
+ expect(
+ terminal?.find((match) => match.routeId === childRoute.id),
+ ).toMatchObject({
+ status: 'error',
+ error: childFailure,
+ })
+
+ await router.navigate({ to: '/parent/child' })
expect(beforeLoad.mock.calls.map(([context]) => context.preload)).toEqual([
true,
diff --git a/packages/router-core/tests/same-destination-navigation-join.test.ts b/packages/router-core/tests/same-destination-navigation-join.test.ts
index 22460cf505..c6d65fe525 100644
--- a/packages/router-core/tests/same-destination-navigation-join.test.ts
+++ b/packages/router-core/tests/same-destination-navigation-join.test.ts
@@ -68,6 +68,172 @@ describe('same-destination navigation while one is in flight', () => {
})
})
+ test('a successor joining a loader error normalizes the shared generation once', async () => {
+ const gate = createControlledPromise()
+ const failure = new Error('shared failure')
+ const loader = vi.fn(async () => {
+ await gate
+ throw failure
+ })
+ const onError = vi.fn()
+ const rootRoute = new BaseRootRoute({})
+ const indexRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ })
+ const targetRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/target',
+ loader,
+ onError,
+ })
+ const router = createTestRouter({
+ routeTree: rootRoute.addChildren([indexRoute, targetRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+ await router.load()
+
+ const first = router.navigate({ to: '/target' })
+ await vi.waitFor(() => expect(loader).toHaveBeenCalledOnce())
+ const second = router.navigate({ to: '/target' })
+ await Promise.resolve()
+ expect(loader).toHaveBeenCalledOnce()
+
+ gate.resolve()
+ await Promise.all([first, second])
+
+ expect(loader).toHaveBeenCalledOnce()
+ expect(onError).toHaveBeenCalledOnce()
+ expect(onError).toHaveBeenCalledWith(failure)
+ expect(router.state.matches.at(-1)).toMatchObject({
+ routeId: targetRoute.id,
+ status: 'error',
+ error: failure,
+ })
+ })
+
+ test('a navigation started by onError runs a fresh loader generation', async () => {
+ const gate = createControlledPromise()
+ const failure = new Error('first generation failed')
+ let successor: Promise | undefined
+ let router: ReturnType
+ const loader = vi.fn(async () => {
+ if (loader.mock.calls.length === 1) {
+ await gate
+ throw failure
+ }
+ return 'recovered'
+ })
+ const rootRoute = new BaseRootRoute({})
+ const indexRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ })
+ const targetRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/target',
+ loader,
+ onError: () => {
+ successor ??= router.navigate({ to: '/target' })
+ },
+ })
+ router = createTestRouter({
+ routeTree: rootRoute.addChildren([indexRoute, targetRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+ await router.load()
+
+ const first = router.navigate({ to: '/target' })
+ await vi.waitFor(() => expect(loader).toHaveBeenCalledOnce())
+ gate.resolve()
+ await first
+ await successor
+
+ expect(loader).toHaveBeenCalledTimes(2)
+ expect(router.state.matches.at(-1)).toMatchObject({
+ routeId: targetRoute.id,
+ status: 'success',
+ loaderData: 'recovered',
+ })
+ })
+
+ test('a superseded loader failure cannot poison a successor delayed in beforeLoad', async () => {
+ const firstResult = createControlledPromise()
+ const successorBeforeLoadStarted = createControlledPromise()
+ const successorBeforeLoadGate = createControlledPromise()
+ const failure = new Error('reserved generation failed')
+ const signals: Array = []
+ const onError = vi.fn()
+ const loader = vi.fn(
+ ({ abortController }: { abortController: AbortController }) => {
+ signals.push(abortController.signal)
+ return loader.mock.calls.length === 1 ? firstResult : 'fresh'
+ },
+ )
+ const rootRoute = new BaseRootRoute({})
+ const indexRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ })
+ const targetRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/target',
+ validateSearch: (search: Record) => ({
+ phase: String(search.phase ?? ''),
+ }),
+ beforeLoad: async ({ search }) => {
+ if (search.phase === 'successor') {
+ successorBeforeLoadStarted.resolve()
+ await successorBeforeLoadGate
+ }
+ },
+ loader,
+ onError,
+ })
+ const router = createTestRouter({
+ routeTree: rootRoute.addChildren([indexRoute, targetRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+ await router.load()
+
+ const first = router.navigate({
+ to: '/target',
+ search: { phase: 'predecessor' },
+ })
+ await vi.waitFor(() => expect(loader).toHaveBeenCalledOnce())
+
+ const second = router.navigate({
+ to: '/target',
+ search: { phase: 'successor' },
+ })
+ await successorBeforeLoadStarted
+
+ expect(loader).toHaveBeenCalledOnce()
+ expect(signals[0]?.aborted).toBe(false)
+
+ firstResult.reject(failure)
+ await vi.waitFor(() => expect(signals[0]?.aborted).toBe(true))
+
+ expect(onError).not.toHaveBeenCalled()
+ expect(loader).toHaveBeenCalledOnce()
+
+ successorBeforeLoadGate.resolve()
+ await Promise.all([first, second])
+
+ expect(loader).toHaveBeenCalledTimes(2)
+ expect(onError).not.toHaveBeenCalled()
+ expect(signals[1]?.aborted).toBe(false)
+ expect(router.state.location).toMatchObject({
+ pathname: '/target',
+ search: { phase: 'successor' },
+ })
+ expect(router.state.matches.at(-1)).toMatchObject({
+ routeId: targetRoute.id,
+ status: 'success',
+ loaderData: 'fresh',
+ })
+ })
+
test('invalidate reruns the loader after the navigation settles', async () => {
const { router, loader, gate } = setup()
gate.resolve('data')
diff --git a/packages/solid-router/tests/createLazyRoute.test.tsx b/packages/solid-router/tests/createLazyRoute.test.tsx
index 6a19e1494c..4dcbbbfc2c 100644
--- a/packages/solid-router/tests/createLazyRoute.test.tsx
+++ b/packages/solid-router/tests/createLazyRoute.test.tsx
@@ -101,12 +101,17 @@ describe('preload: matched routes', { timeout: 20000 }, () => {
it('replaces default pending UI when delayed lazy options provide pending UI', async () => {
const loader = createControlledPromise()
const componentPreload = createControlledPromise()
+ const pendingComponentPreload = createControlledPromise()
const preloadComponent = vi.fn(() => componentPreload)
+ const preloadPendingComponent = vi.fn(() => pendingComponentPreload)
const Page = Object.assign(() =>