From ea3f7d4a69216ceea04325592af75e0ae1184bb3 Mon Sep 17 00:00:00 2001
From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com>
Date: Mon, 14 Sep 2026 14:17:53 +0200
Subject: [PATCH] perf(router-core): publish fewer store updates during
navigation
Every synchronous frame between two beforeLoad awaits now publishes as one
store update: `contextualize` settles each hook inside `router.batch`, so
ending a beforeLoad and starting the next hook or the loaders no longer
produces separate `isFetching` publications. Loader starts for a whole lane
publish together, and a superseding navigation clears the previous lane's
presented fetching state in the same update that publishes its new
location. `waitFor` resolves plain values without registering an abort
listener.
Store updates in the full navigation case drop from 7 to 6 (React), 6 to 5
(Solid, Vue). Deterministic publication-sequence tests explain every
remaining update (no fallback, fallback during beforeLoad, fallback during
the loader, ready before pendingMinMs, replacement while the fallback is
visible, superseding navigation) and assert that persistent
useSearch/useParams consumers and whole-state consumers only re-render when
their selection changes. A focused fan-out benchmark and a core regression
for a store subscriber that navigates during the start flush are added.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
(cherry picked from commit b3614be185661383d60bdff9f53aeadfa056fe83)
---
.changeset/fewer-store-updates.md | 5 +
.../store-updates-during-navigation.bench.tsx | 106 ++++++
.../store-updates-during-navigation.test.tsx | 307 +++++++++++++++++-
packages/router-core/src/load-client.ts | 273 +++++++++-------
.../tests/load-client-wait-for.test.ts | 24 +-
.../loader-architecture-regressions.test.ts | 58 ++++
.../store-updates-during-navigation.test.tsx | 3 +-
.../store-updates-during-navigation.test.tsx | 3 +-
8 files changed, 648 insertions(+), 131 deletions(-)
create mode 100644 .changeset/fewer-store-updates.md
create mode 100644 packages/react-router/tests/store-updates-during-navigation.bench.tsx
diff --git a/.changeset/fewer-store-updates.md b/.changeset/fewer-store-updates.md
new file mode 100644
index 00000000000..9d1ca8d1b08
--- /dev/null
+++ b/.changeset/fewer-store-updates.md
@@ -0,0 +1,5 @@
+---
+'@tanstack/router-core': patch
+---
+
+Publish fewer router store updates during a navigation. Every synchronous frame between two `beforeLoad` awaits now publishes once, so ending one `beforeLoad` and starting the next hook or the loaders no longer produces separate `isFetching` updates. Loader starts for a whole lane publish together, and a superseding navigation clears the previous lane's fetching state in the same update that publishes its new location. `waitFor` no longer registers an abort listener for plain values.
diff --git a/packages/react-router/tests/store-updates-during-navigation.bench.tsx b/packages/react-router/tests/store-updates-during-navigation.bench.tsx
new file mode 100644
index 00000000000..eaa7d09f21d
--- /dev/null
+++ b/packages/react-router/tests/store-updates-during-navigation.bench.tsx
@@ -0,0 +1,106 @@
+import { bench, describe, expect } from 'vitest'
+import { render } from '@testing-library/react'
+import {
+ Outlet,
+ RouterProvider,
+ createMemoryHistory,
+ createRootRoute,
+ createRoute,
+ createRouter,
+ useParams,
+ useRouterState,
+ useSearch,
+} from '../src'
+
+// Navigation cost under store-subscriber fan-out. A retained layout mounts
+// `subscribers` broad `useRouterState` consumers plus as many narrow
+// `useSearch`/`useParams` consumers. Every navigation re-runs two retained
+// synchronous `beforeLoad`s and a stale layout loader, so the router publishes
+// fetching transitions on presented matches. All selections are stable, so the
+// measured work is store propagation and selection rather than re-rendering.
+async function setup(subscribers: number) {
+ let notifications = 0
+
+ const Broad = () => {
+ useRouterState({ select: (state) => state.location.pathname })
+ return null
+ }
+ const Narrow = () => {
+ useSearch({ strict: false })
+ useParams({ strict: false })
+ return null
+ }
+ const Counter = () => {
+ useRouterState({
+ select: () => {
+ notifications++
+ },
+ })
+ return null
+ }
+
+ const rootRoute = createRootRoute({
+ beforeLoad: () => ({ root: true }),
+ component: () => (
+ <>
+
+
+ >
+ ),
+ })
+ const layoutRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ id: 'layout',
+ beforeLoad: () => ({ layout: true }),
+ loader: () => ({ items: [] }),
+ staleTime: 0,
+ component: () => (
+ <>
+ {Array.from({ length: subscribers }, (_, index) => (
+
+ ))}
+ {Array.from({ length: subscribers }, (_, index) => (
+
+ ))}
+
+ >
+ ),
+ })
+ const pages = ['a', 'b'].map((path) =>
+ createRoute({
+ getParentRoute: () => layoutRoute,
+ path,
+ beforeLoad: () => ({ page: path }),
+ loader: () => path,
+ component: () =>
{path}
,
+ }),
+ )
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([layoutRoute.addChildren(pages)]),
+ history: createMemoryHistory({ initialEntries: ['/a'] }),
+ })
+ render()
+ await router.load()
+
+ const lap = async () => {
+ await router.navigate({ to: '/b', replace: true })
+ await router.navigate({ to: '/a', replace: true })
+ }
+ await lap()
+ const before = notifications
+ await lap()
+ const perNavigation = (notifications - before) / 2
+ expect(router.state.location.pathname).toBe('/a')
+ expect(perNavigation).toBeGreaterThan(0)
+ return { lap, perNavigation }
+}
+
+for (const subscribers of [0, 50, 200]) {
+ const { lap, perNavigation } = await setup(subscribers)
+ describe(`${subscribers} broad + ${subscribers} narrow subscribers (${perNavigation} store updates per navigation)`, () => {
+ bench('two navigations with retained beforeLoads and a stale loader', lap, {
+ time: 1000,
+ warmupTime: 200,
+ })
+ })
+}
diff --git a/packages/react-router/tests/store-updates-during-navigation.test.tsx b/packages/react-router/tests/store-updates-during-navigation.test.tsx
index 8a10e06339a..3a1c5bb81f1 100644
--- a/packages/react-router/tests/store-updates-during-navigation.test.tsx
+++ b/packages/react-router/tests/store-updates-during-navigation.test.tsx
@@ -10,19 +10,37 @@ import {
Link,
Outlet,
RouterProvider,
+ createControlledPromise,
createRootRoute,
createRoute,
createRouter,
notFound,
redirect,
+ useParams,
useRouterState,
+ useSearch,
} from '../src'
+import type { AnyRouter, RouterState } from '../src'
afterEach(() => {
window.history.replaceState(null, 'root', '/')
cleanup()
})
+/**
+ * Describe a published router state by value, so the trace still explains a
+ * publication after later mutations of the same match objects.
+ */
+function describeState(state: RouterState) {
+ const matches = state.matches
+ .map(
+ (match) =>
+ `${match.routeId}:${match.status}${match.isFetching ? `+${match.isFetching}` : ''}`,
+ )
+ .join(' ')
+ return `${state.status} ${state.location.pathname} → ${state.resolvedLocation?.pathname} [${matches}]`
+}
+
function setup({
beforeLoad,
loader,
@@ -32,6 +50,7 @@ function setup({
defaultPendingMs,
defaultPendingMinMs,
staleTime,
+ rootBeforeLoad,
}: {
beforeLoad?: () => any
loader?: () => any
@@ -41,16 +60,49 @@ function setup({
defaultPendingMs?: number
defaultPendingMinMs?: number
staleTime?: number
+ rootBeforeLoad?: () => any
}) {
- const select = vi.fn()
+ // One entry per store notification (plus one per render of the root).
+ const trace: Array = []
+ const select = vi.fn((state: RouterState) => {
+ trace.push(describeState(state))
+ })
+ const rootRenders = vi.fn()
+ // A genuine whole-state consumer: renders when the router starts and stops
+ // loading, and never for intermediate publications.
+ const indicatorRenders = vi.fn()
+ const Indicator = () => {
+ indicatorRenders(useRouterState({ select: (state) => state.isLoading }))
+ return null
+ }
+ // An object-valued selection with structural sharing keeps its reference
+ // while the selected values are equal.
+ const sharedSelections: Array<{ pathname: string }> = []
+ const Shared = () => {
+ sharedSelections.push(
+ useRouterState({
+ select: (state) => ({ pathname: state.location.pathname }),
+ structuralSharing: true,
+ }),
+ )
+ return null
+ }
const rootRoute = createRootRoute({
+ beforeLoad: rootBeforeLoad,
component: function RootComponent() {
useRouterState({ select })
+ // Persistent narrow consumers: unchanged selections must not re-render.
+ useSearch({ strict: false })
+ useParams({ strict: false })
+ rootRenders()
return (
<>
+
+
Back
Posts
+ Other
>
)
@@ -92,7 +144,22 @@ function setup({
render()
- return { select, router }
+ return {
+ select,
+ router,
+ trace,
+ rootRenders,
+ indicatorRenders,
+ sharedSelections,
+ }
+}
+
+/** Wait until a navigation has been acknowledged and nothing else publishes. */
+async function settled(router: AnyRouter, pathname: string) {
+ await waitFor(() => {
+ expect(router.state.status).toBe('idle')
+ expect(router.state.resolvedLocation?.pathname).toBe(pathname)
+ })
}
async function back() {
@@ -136,7 +203,10 @@ describe("Store doesn't update *too many* times during navigation", () => {
// This number should be as small as possible to minimize the amount of work
// that needs to be done during a navigation.
// Any change that increases this number should be investigated.
- expect(updates).toBe(7)
+ // 1. location + status, 2. pending placeholder after pendingMs,
+ // 3. beforeLoad → loader fetching transition (one synchronous frame),
+ // 4. loader settled, 5. matches committed, 6. resolvedLocation + status.
+ expect(updates).toBe(6)
})
test('redirection in preload', async () => {
@@ -291,3 +361,234 @@ describe("Store doesn't update *too many* times during navigation", () => {
expect(updates).toBe(0)
})
})
+
+describe('every publication during a navigation is explained', () => {
+ const start = 'pending /posts → / [__root__:success /:success]'
+ const committed = 'pending /posts → / [__root__:success /posts:success]'
+ const resolved = 'idle /posts → /posts [__root__:success /posts:success]'
+ const placeholder = (fetching: '' | '+beforeLoad' | '+loader') =>
+ `pending /posts → / [__root__:success /posts:pending${fetching}]`
+
+ async function navigate({
+ router,
+ trace,
+ indicatorRenders,
+ sharedSelections,
+ }: ReturnType) {
+ await settled(router, '/')
+ const from = trace.length
+ indicatorRenders.mockClear()
+ sharedSelections.length = 0
+ fireEvent.click(screen.getByRole('link', { name: 'Posts' }))
+ return () => trace.slice(from)
+ }
+
+ async function finish(params: ReturnType) {
+ await screen.findByRole('heading', { name: 'Posts Title' })
+ await settled(params.router, '/posts')
+ const published = params.trace.length
+ // Nothing else may publish once the navigation is resolved.
+ await new Promise((resolve) => setTimeout(resolve, 30))
+ expect(params.trace.length).toBe(published)
+ expect(params.rootRenders).toHaveBeenCalledTimes(1)
+ // Whole-state consumers only re-render when their selection changes:
+ // the indicator once for pending and once for idle, the shared object
+ // once for the new pathname.
+ expect(params.indicatorRenders.mock.calls).toEqual([[true], [false]])
+ expect(params.sharedSelections).toEqual([{ pathname: '/posts' }])
+ }
+
+ test('no fallback: hooks settle before pendingMs', async () => {
+ const beforeLoad = createControlledPromise()
+ const loader = createControlledPromise()
+ const params = setup({
+ beforeLoad: () => beforeLoad,
+ loader: () => loader,
+ defaultPendingMs: 1000,
+ defaultPendingMinMs: 0,
+ })
+
+ const published = await navigate(params)
+ beforeLoad.resolve()
+ loader.resolve()
+ await finish(params)
+
+ // Nothing is presented while the hooks run, so their fetching transitions
+ // never publish: location, matches, resolution.
+ expect(published()).toEqual([start, committed, resolved])
+ })
+
+ test('fallback during beforeLoad', async () => {
+ const beforeLoad = createControlledPromise()
+ const loader = createControlledPromise()
+ const params = setup({
+ beforeLoad: () => beforeLoad,
+ loader: () => loader,
+ defaultPendingMs: 20,
+ defaultPendingMinMs: 0,
+ })
+
+ const published = await navigate(params)
+ await screen.findByText('Loading...')
+ expect(published()).toEqual([start, placeholder('+beforeLoad')])
+
+ beforeLoad.resolve()
+ // Ending beforeLoad and starting the loader is one synchronous frame.
+ await waitFor(() =>
+ expect(published()).toEqual([
+ start,
+ placeholder('+beforeLoad'),
+ placeholder('+loader'),
+ ]),
+ )
+
+ loader.resolve()
+ await finish(params)
+ expect(published()).toEqual([
+ start,
+ placeholder('+beforeLoad'),
+ placeholder('+loader'),
+ placeholder(''),
+ committed,
+ resolved,
+ ])
+ })
+
+ test('fallback during the loader', async () => {
+ const beforeLoad = createControlledPromise()
+ const loader = createControlledPromise()
+ const params = setup({
+ beforeLoad: () => beforeLoad,
+ loader: () => loader,
+ defaultPendingMs: 20,
+ defaultPendingMinMs: 0,
+ })
+
+ const published = await navigate(params)
+ beforeLoad.resolve()
+ await screen.findByText('Loading...')
+ // The placeholder is revealed with the loader already running.
+ expect(published()).toEqual([start, placeholder('+loader')])
+
+ loader.resolve()
+ await finish(params)
+ expect(published()).toEqual([
+ start,
+ placeholder('+loader'),
+ placeholder(''),
+ committed,
+ resolved,
+ ])
+ })
+
+ test('ready before the pending minimum expires', async () => {
+ const loader = createControlledPromise()
+ const params = setup({
+ loader: () => loader,
+ defaultPendingMs: 20,
+ defaultPendingMinMs: 200,
+ })
+
+ const published = await navigate(params)
+ await screen.findByText('Loading...')
+ loader.resolve()
+ // The loader has settled but the fallback must stay visible: that is a
+ // real presentation delay, so fetching ends before the matches commit.
+ await waitFor(() =>
+ expect(published()).toEqual([
+ start,
+ placeholder('+loader'),
+ placeholder(''),
+ ]),
+ )
+ expect(screen.getByText('Loading...')).toBeInTheDocument()
+
+ await finish(params)
+ expect(published()).toEqual([
+ start,
+ placeholder('+loader'),
+ placeholder(''),
+ committed,
+ resolved,
+ ])
+ })
+
+ test('a replacement while the fallback is visible resets the placeholder with its first publication', async () => {
+ const loader = createControlledPromise()
+ const params = setup({
+ loader: () => loader,
+ defaultPendingMs: 20,
+ defaultPendingMinMs: 0,
+ })
+ const { router, trace } = params
+
+ const published = await navigate(params)
+ await screen.findByText('Loading...')
+ expect(published()).toEqual([start, placeholder('+loader')])
+
+ fireEvent.click(screen.getByRole('link', { name: 'Other' }))
+ await screen.findByRole('heading', { name: 'Other Title' })
+ await settled(router, '/other')
+ const count = trace.length
+ loader.resolve()
+ await new Promise((resolve) => setTimeout(resolve, 30))
+ expect(trace.length).toBe(count)
+
+ expect(published()).toEqual([
+ start,
+ placeholder('+loader'),
+ // The visible placeholder stops fetching in the same update that
+ // publishes the replacement location; the abandoned loader never
+ // publishes again.
+ 'pending /other → / [__root__:success /posts:pending]',
+ 'pending /other → / [__root__:success /other:success]',
+ 'idle /other → /other [__root__:success /other:success]',
+ ])
+ expect(params.rootRenders).toHaveBeenCalledTimes(1)
+ })
+
+ test('a superseding navigation resets fetching state in its first publication', async () => {
+ const rootGate = createControlledPromise()
+ let rootCalls = 0
+ const params = setup({
+ // The presented root re-runs beforeLoad for every navigation; only the
+ // superseded navigation blocks on it.
+ rootBeforeLoad: () => (++rootCalls === 2 ? rootGate : undefined),
+ defaultPendingMs: 1000,
+ })
+ const { router, trace } = params
+
+ await settled(router, '/')
+ const from = trace.length
+ fireEvent.click(screen.getByRole('link', { name: 'Posts' }))
+ await waitFor(() =>
+ expect(trace.slice(from)).toEqual([
+ start,
+ 'pending /posts → / [__root__:success+beforeLoad /:success]',
+ ]),
+ )
+
+ fireEvent.click(screen.getByRole('link', { name: 'Other' }))
+ await screen.findByRole('heading', { name: 'Other Title' })
+ await settled(router, '/other')
+ const published = trace.length
+ rootGate.resolve()
+ // The superseded lane settles as canceled and must not publish anything.
+ await new Promise((resolve) => setTimeout(resolve, 30))
+ expect(trace.length).toBe(published)
+
+ expect(trace.slice(from)).toEqual([
+ start,
+ 'pending /posts → / [__root__:success+beforeLoad /:success]',
+ // Clearing the superseded lane's fetching state and publishing the new
+ // location is one update.
+ 'pending /other → / [__root__:success /:success]',
+ // The new lane runs the root beforeLoad again (synchronously this time).
+ 'pending /other → / [__root__:success+beforeLoad /:success]',
+ 'pending /other → / [__root__:success /:success]',
+ 'pending /other → / [__root__:success /other:success]',
+ 'idle /other → /other [__root__:success /other:success]',
+ ])
+ expect(params.rootRenders).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/packages/router-core/src/load-client.ts b/packages/router-core/src/load-client.ts
index 1994eb2c371..60f16f8ce21 100644
--- a/packages/router-core/src/load-client.ts
+++ b/packages/router-core/src/load-client.ts
@@ -193,6 +193,9 @@ type LoaderOutcome = NonRedirectOutcome | RedirectOutcome
type IndexedOutcome = [index: number, outcome: LoaderOutcome, boundary?: number]
+/** A settled lane failure, a completed frame, or the frame after the next await. */
+type LaneStep = IndexedOutcome | undefined | Promise
+
export type LoaderFlight = [
outcome: Promise,
controller: AbortController,
@@ -288,6 +291,12 @@ export function waitFor(
return Promise.race([Promise.reject(signal), value])
}
return new Promise((resolve, reject) => {
+ // A plain value cannot be interrupted, so it needs no abort listener. A
+ // throwing `then` getter rejects through the executor.
+ if (typeof (value as any)?.then !== 'function') {
+ resolve(value as T)
+ return
+ }
const abort = () => reject(signal)
signal.addEventListener('abort', abort, { once: true })
Promise.resolve(value)
@@ -354,122 +363,145 @@ function normalizeLaneError(
)
}
-async function contextualize(
+function contextualize(
router: AnyRouter,
lane: MatchedLane,
options: ExecuteLaneOptions,
end: number,
planSuccessfulLane: () => void,
retainedEnd: number,
-): Promise {
+): LaneStep {
const [location, matches] = lane
- const signal = options[0 /* controller */].signal
+ const controller = options[0 /* controller */]
+ const signal = controller.signal
const preload = !!options[3 /* preload */]
- for (let index = options[6 /* resolvedPrefix */] ?? 0; index < end; index++) {
- const match = matches[index]!
- const route = getRoute(router, match)
+ let index = options[6 /* resolvedPrefix */] ?? 0
+ // Everything between two beforeLoad awaits runs synchronously, so a single
+ // batch publishes the frame's fetching transitions, pending presentation,
+ // and planned loaders as one store update.
+ const frame = (): LaneStep => {
+ for (; index < end; index++) {
+ const match = matches[index]!
+ const route = getRoute(router, match)
- match.abortController = options[0 /* controller */]
- // Contextualization is serial, so the previous match already contains the
- // complete parent context for this route.
- const parentContext =
- matches[index - 1]?.context ?? router.options.context ?? {}
- const common = {
- params: match.params,
- location,
- navigate: (opts: any) =>
- router.navigate({
- ...opts,
- _fromLocation: location,
- }),
- buildLocation: router.buildLocation,
- cause: preload ? ('preload' as const) : match.cause,
- abortController: options[0 /* controller */],
- preload,
- matches,
- routeId: route.id,
- }
- try {
- // Reuse the route's cached contribution while rebuilding its inheritance.
- const routeContext = (match._ctx ||= route.options.context
- ? route.options.context({
- ...common,
- deps: match.loaderDeps,
- context: parentContext,
- } satisfies RouteContextOptions) || {}
- : undefined)
- match.context = {
- ...parentContext,
- ...routeContext,
+ match.abortController = controller
+ // Contextualization is serial, so the previous match already contains
+ // the complete parent context for this route.
+ const parentContext =
+ matches[index - 1]?.context ?? router.options.context ?? {}
+ const common = {
+ params: match.params,
+ location,
+ navigate: (opts: any) =>
+ router.navigate({
+ ...opts,
+ _fromLocation: location,
+ }),
+ buildLocation: router.buildLocation,
+ cause: preload ? ('preload' as const) : match.cause,
+ abortController: controller,
+ preload,
+ matches,
+ routeId: route.id,
+ }
+ try {
+ // Reuse the route's cached contribution while rebuilding its
+ // inheritance.
+ const routeContext = (match._ctx ||= route.options.context
+ ? route.options.context({
+ ...common,
+ deps: match.loaderDeps,
+ context: parentContext,
+ } satisfies RouteContextOptions) || {}
+ : undefined)
+ match.context = {
+ ...parentContext,
+ ...routeContext,
+ }
+ } catch (cause) {
+ releaseFlight(router, match)
+ return [index, normalizeLaneError(router, lane, route, cause, options)]
}
- } catch (cause) {
- releaseFlight(router, match)
- return [index, normalizeLaneError(router, lane, route, cause, options)]
- }
- if (signal.aborted) {
- return [index, CANCELED_OUTCOME]
- }
- const validationError = match.paramsError ?? match.searchError
- if (validationError !== undefined) {
- releaseFlight(router, match)
- return [
- index,
- normalizeLaneError(router, lane, route, validationError, options),
- ]
- }
- const beforeLoad = route.options.beforeLoad
- if (!beforeLoad) {
- continue
- }
-
- const previousStatus = match.status
- if (index >= retainedEnd) {
- match.status = 'pending'
- options[7 /* onReady */]?.()
- }
- try {
- setFetching(router, match, 'beforeLoad', options[0 /* controller */])
- const value = beforeLoad({
- ...common,
- search: match.search,
- context: match.context,
- ...router.options.additionalContext,
- })
- // Always await to give a queued replacement navigation one microtask to
- // kick in before checking cancellation, even for synchronous context.
- const result = await (typeof value?.then === 'function'
- ? waitFor(value, signal)
- : value)
if (signal.aborted) {
return [index, CANCELED_OUTCOME]
}
- const outcome = materializeRedirect(
- router,
- lane,
- route,
- normalize(result, false, route.id),
- options,
- )
- if (outcome[0 /* kind */] !== SUCCESS) {
+ const validationError = match.paramsError ?? match.searchError
+ if (validationError !== undefined) {
releaseFlight(router, match)
- return [index, outcome]
+ return [
+ index,
+ normalizeLaneError(router, lane, route, validationError, options),
+ ]
}
- match.context = {
- ...match.context,
- ...result,
+ const beforeLoad = route.options.beforeLoad
+ if (!beforeLoad) {
+ continue
}
- } catch (cause) {
- releaseFlight(router, match)
- return [index, normalizeLaneError(router, lane, route, cause, options)]
- } finally {
- match.status = previousStatus
- setFetching(router, match, false, options[0 /* controller */])
+
+ const previousStatus = match.status
+ if (index >= retainedEnd) {
+ match.status = 'pending'
+ options[7 /* onReady */]?.()
+ }
+ setFetching(router, match, 'beforeLoad', controller)
+ // Applies the settled beforeLoad and continues the lane in one frame.
+ const settle = (value: any, rejected?: boolean): LaneStep => {
+ let next: LaneStep
+ router.batch(() => {
+ // The hook has ended, so `onError` and redirect resolution observe the
+ // restored status and cleared fetching state.
+ match.status = previousStatus
+ setFetching(router, match, false, controller)
+ const outcome = signal.aborted
+ ? CANCELED_OUTCOME
+ : materializeRedirect(
+ router,
+ lane,
+ route,
+ rejected
+ ? normalizeError(route, value)
+ : normalize(value, false, route.id),
+ options,
+ )
+ if (outcome[0 /* kind */] === SUCCESS) {
+ match.context = {
+ ...match.context,
+ ...value,
+ }
+ index++
+ next = frame()
+ } else {
+ // Releasing after a cancellation is a no-op: the superseding load
+ // already detached this lane's flights.
+ releaseFlight(router, match)
+ next = [index, outcome]
+ }
+ })
+ return next
+ }
+ let value: any
+ try {
+ value = beforeLoad({
+ ...common,
+ search: match.search,
+ context: match.context,
+ ...router.options.additionalContext,
+ })
+ } catch (cause) {
+ return settle(cause, true)
+ }
+ // Always await, even a synchronous value, to give a queued replacement
+ // navigation one microtask to kick in before cancellation is checked.
+ return waitFor(value, signal).then(settle, (cause) => settle(cause, true))
}
+
+ // Let a synchronous lane claim predecessor flights before this frame
+ // yields.
+ planSuccessfulLane()
+ return
}
- // Let a synchronous lane claim predecessor flights before this frame yields.
- planSuccessfulLane()
- return
+ return frame()
}
function releaseOwnedFlight(
@@ -1345,22 +1377,24 @@ async function executeClientLane(
let semanticParent = start
? Promise.resolve(matches[start - 1] as WorkMatch)
: undefined
- const planSuccessfulLane = () => {
- for (let index = start; index < end; index++) {
- if (signal.aborted) {
- break
+ // Loader starts and pending presentation publish as one store update.
+ const planSuccessfulLane = () =>
+ router.batch(() => {
+ for (let index = start; index < end; index++) {
+ if (signal.aborted) {
+ break
+ }
+ semanticParent = createLoaderTask(
+ router,
+ matched as ContextualizedLane,
+ index,
+ tasks,
+ semanticParent,
+ options,
+ retainedEnd,
+ )
}
- semanticParent = createLoaderTask(
- router,
- matched as ContextualizedLane,
- index,
- tasks,
- semanticParent,
- options,
- retainedEnd,
- )
- }
- }
+ })
// 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.
@@ -2012,14 +2046,6 @@ export async function loadClientRoute(
}
router._tx = tx
if (previousOwner) {
- for (const match of router.stores.matches.get() as Array) {
- if (router._tx !== tx) {
- break
- }
- if (match.isFetching) {
- setFetching(router, match, false)
- }
- }
previousOwner[0 /* controller */].abort()
transferMatchResources(
router,
@@ -2036,6 +2062,13 @@ export async function loadClientRoute(
return
}
router.batch(() => {
+ // A superseded lane can no longer publish, so clear the fetching state it
+ // left on the still-presented matches together with the new status.
+ for (const match of router.stores.matches.get() as Array) {
+ if (match.isFetching) {
+ setFetching(router, match, false)
+ }
+ }
router.stores.status.set('pending')
router.stores.location.set(location)
})
diff --git a/packages/router-core/tests/load-client-wait-for.test.ts b/packages/router-core/tests/load-client-wait-for.test.ts
index 9ae49b89bd9..b75e89d6ad7 100644
--- a/packages/router-core/tests/load-client-wait-for.test.ts
+++ b/packages/router-core/tests/load-client-wait-for.test.ts
@@ -49,7 +49,7 @@ describe('waitFor', () => {
)
})
- test('removes its abort listener when reading a thenable throws', async () => {
+ test('rejects without an abort listener when reading a thenable throws', async () => {
const controller = new AbortController()
const add = vi.spyOn(controller.signal, 'addEventListener')
const remove = vi.spyOn(controller.signal, 'removeEventListener')
@@ -63,11 +63,23 @@ describe('waitFor', () => {
await expect(waitFor(value, controller.signal)).rejects.toBe(error)
await new Promise((resolve) => setTimeout(resolve, 0))
- expect(add).toHaveBeenCalledOnce()
- expect(remove).toHaveBeenCalledExactlyOnceWith(
- 'abort',
- add.mock.calls[0]![1],
- )
+ expect(add).not.toHaveBeenCalled()
+ expect(remove).not.toHaveBeenCalled()
+ })
+
+ test('resolves a plain value without registering an abort listener', async () => {
+ const controller = new AbortController()
+ const add = vi.spyOn(controller.signal, 'addEventListener')
+
+ const result = waitFor(42, controller.signal)
+ // Still asynchronous: a queued replacement navigation gets a microtask.
+ let settled = false
+ void result.then(() => {
+ settled = true
+ })
+ expect(settled).toBe(false)
+ await expect(result).resolves.toBe(42)
+ expect(add).not.toHaveBeenCalled()
})
test('observes a rejected value when the signal is already aborted', async () => {
diff --git a/packages/router-core/tests/loader-architecture-regressions.test.ts b/packages/router-core/tests/loader-architecture-regressions.test.ts
index 97561a9f727..7857a8bc7f6 100644
--- a/packages/router-core/tests/loader-architecture-regressions.test.ts
+++ b/packages/router-core/tests/loader-architecture-regressions.test.ts
@@ -8,6 +8,8 @@ import {
redirect,
} from '../src'
import { createTestRouter, loadServerResponse } from './routerTestUtils'
+import type { Readable } from '@tanstack/store'
+import type { ParsedLocation } from '../src'
test.each([
['error', () => new Error('parent failed')],
@@ -172,6 +174,62 @@ test('superseding a load clears fetching state from the still-presented lane', a
await Promise.all([firstNavigation, secondNavigation])
})
+test('a store subscriber that navigates while the start publication flushes hands the lane over cleanly', async () => {
+ const pageGate = createControlledPromise()
+ const rootRoute = new BaseRootRoute({})
+ const indexRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ })
+ const pageRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/page',
+ beforeLoad: () => pageGate,
+ })
+ const otherRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/other',
+ loader: () => 'other data',
+ })
+ const router = createTestRouter({
+ routeTree: rootRoute.addChildren([indexRoute, pageRoute, otherRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+ await router.load()
+
+ // Publications are batched, so the subscriber runs at the flush and
+ // re-enters navigation before the first lane has done any work.
+ let redirected: Promise | undefined
+ // The test store factory creates reactive atoms.
+ const location = router.stores.location as unknown as Readable
+ const subscription = location.subscribe((next) => {
+ if (next.pathname === '/page' && !redirected) {
+ redirected = router.navigate({ to: '/other' })
+ }
+ })
+ const first = router.navigate({ to: '/page' })
+ await Promise.all([first, redirected])
+ subscription.unsubscribe()
+
+ expect(router.state.location.pathname).toBe('/other')
+ expect(router.state.resolvedLocation?.pathname).toBe('/other')
+ expect(router.state.status).toBe('idle')
+ expect(router.state.matches.map((match) => match.routeId)).toEqual([
+ rootRoute.id,
+ otherRoute.id,
+ ])
+ expect(router.state.matches.at(-1)).toMatchObject({
+ loaderData: 'other data',
+ isFetching: false,
+ })
+
+ // The superseded lane cannot publish once its gate opens.
+ const presented = router.state.matches
+ pageGate.resolve()
+ await new Promise((resolve) => setTimeout(resolve, 0))
+ expect(router.state.matches).toBe(presented)
+})
+
test('an ignored preload flight is released after a navigation has planned its loaders', async () => {
const preloadFailure = createControlledPromise()
const childGate = createControlledPromise()
diff --git a/packages/solid-router/tests/store-updates-during-navigation.test.tsx b/packages/solid-router/tests/store-updates-during-navigation.test.tsx
index 3ef09b437f1..d73e7929b26 100644
--- a/packages/solid-router/tests/store-updates-during-navigation.test.tsx
+++ b/packages/solid-router/tests/store-updates-during-navigation.test.tsx
@@ -136,7 +136,8 @@ describe("Store doesn't update *too many* times during navigation", () => {
// This number should be as small as possible to minimize the amount of work
// that needs to be done during a navigation.
// Any change that increases this number should be investigated.
- expect(updates).toBe(6)
+ // The beforeLoad → loader fetching transition now publishes once.
+ expect(updates).toBe(5)
})
test('redirection in preload', async () => {
diff --git a/packages/vue-router/tests/store-updates-during-navigation.test.tsx b/packages/vue-router/tests/store-updates-during-navigation.test.tsx
index 54d9c2edffe..9a94c043085 100644
--- a/packages/vue-router/tests/store-updates-during-navigation.test.tsx
+++ b/packages/vue-router/tests/store-updates-during-navigation.test.tsx
@@ -143,7 +143,8 @@ describe("Store doesn't update *too many* times during navigation", () => {
// that needs to be done during a navigation.
// Any change that increases this number should be investigated.
// Note: Vue has different update counts than React/Solid due to different reactivity
- expect(updates).toBe(6)
+ // The beforeLoad → loader fetching transition now publishes once.
+ expect(updates).toBe(5)
})
test('redirection in preload', async () => {