From c1ba06ab3ffeb637036751840e2c7d80fe7dc4a6 Mon Sep 17 00:00:00 2001
From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com>
Date: Thu, 23 Jul 2026 22:45:31 +0200
Subject: [PATCH 1/5] failing regression test for #4614
---
.../selective-ssr/src/routes/__root.tsx | 37 +++++-------
.../selective-ssr/src/routes/issue-4614.tsx | 7 +--
.../selective-ssr/tests/app.spec.ts | 57 ++++---------------
3 files changed, 29 insertions(+), 72 deletions(-)
diff --git a/e2e/react-start/selective-ssr/src/routes/__root.tsx b/e2e/react-start/selective-ssr/src/routes/__root.tsx
index 3465abf298..d4e96a27e6 100644
--- a/e2e/react-start/selective-ssr/src/routes/__root.tsx
+++ b/e2e/react-start/selective-ssr/src/routes/__root.tsx
@@ -32,9 +32,7 @@ export const Route = createRootRoute({
}),
validateSearch: z.object({
root: ssrSchema,
- issue4614: z.string().optional(),
}),
- loaderDeps: ({ search }) => ({ issue4614: search.issue4614 }),
ssr: ({ search }) => {
if (typeof window !== 'undefined') {
const error = `ssr() for ${Route.id} should not be called on the client`
@@ -57,21 +55,23 @@ export const Route = createRootRoute({
console.error(error)
throw new Error(error)
}
- const root = typeof window === 'undefined' ? 'server' : 'client'
- const issue4614Context = `${root}:${search.issue4614 ?? 'cached'}`
+ const isClient = typeof window !== 'undefined'
+ const isServer = !isClient
+ const root = isServer ? 'server' : 'client'
if (typeof window !== 'undefined' && location.pathname === '/issue-4614') {
const calls = ((globalThis as any).__issue4614RootBeforeLoads ??= [])
calls.push({
cause,
preload,
- root,
- issue4614Context,
+ isClient,
+ isServer,
})
}
return {
root,
search,
- issue4614Context,
+ isClient,
+ isServer,
}
},
loader: ({ context }) => {
@@ -120,6 +120,12 @@ export const Route = createRootRoute({
context:{' '}
{Route.useRouteContext().root}
+
+ issue #4614 context:{' '}
+
+ {`${Route.useRouteContext().isClient}:${Route.useRouteContext().isServer}`}
+
+
@@ -149,21 +155,8 @@ function RootDocument({ children }: { children: React.ReactNode }) {
>
Home
-
- Issue 4614 cached parent
-
-
- Issue 4614 reloaded parent
+
+ Issue 4614
diff --git a/e2e/react-start/selective-ssr/src/routes/issue-4614.tsx b/e2e/react-start/selective-ssr/src/routes/issue-4614.tsx
index 7e59b8d0ac..5bf4b72a5f 100644
--- a/e2e/react-start/selective-ssr/src/routes/issue-4614.tsx
+++ b/e2e/react-start/selective-ssr/src/routes/issue-4614.tsx
@@ -2,13 +2,12 @@ import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/issue-4614')({
ssr: false,
- beforeLoad: ({ context, cause, preload, search }) => {
+ beforeLoad: ({ context, cause, preload }) => {
;(globalThis as any).__issue4614TargetBeforeLoad = {
cause,
preload,
- rootContext: context.root,
- issue4614Context: context.issue4614Context,
- scenario: search.issue4614 ?? 'cached',
+ isClient: context.isClient,
+ isServer: context.isServer,
}
},
component: () => Issue 4614
,
diff --git a/e2e/react-start/selective-ssr/tests/app.spec.ts b/e2e/react-start/selective-ssr/tests/app.spec.ts
index 89eff32cae..e926f57354 100644
--- a/e2e/react-start/selective-ssr/tests/app.spec.ts
+++ b/e2e/react-start/selective-ssr/tests/app.spec.ts
@@ -4,49 +4,15 @@ import { test } from '@tanstack/router-e2e-utils'
const testCount = 7
test.describe('selective ssr', () => {
- test('#4614: cached parent loader data does not cache its beforeLoad context', async ({
+ test('propagates client root context to an ssr:false child during intent preload (#4614)', async ({
page,
}) => {
await page.goto('/')
- await page.getByTestId('issue-4614-cached-link').hover()
+ await expect(page.getByTestId('issue-4614-root-context')).toHaveText(
+ 'false:true',
+ )
- await expect
- .poll(() =>
- page.evaluate(() => (globalThis as any).__issue4614TargetBeforeLoad),
- )
- .not.toBeUndefined()
-
- const { rootBeforeLoads, targetBeforeLoad } = await page.evaluate(() => {
- const rootBeforeLoads =
- (globalThis as any).__issue4614RootBeforeLoads ?? []
- return {
- rootBeforeLoads,
- targetBeforeLoad: (globalThis as any).__issue4614TargetBeforeLoad,
- }
- })
-
- expect(rootBeforeLoads).toEqual([
- {
- cause: 'preload',
- preload: true,
- root: 'client',
- issue4614Context: 'client:cached',
- },
- ])
- expect(targetBeforeLoad).toEqual({
- cause: 'preload',
- preload: true,
- rootContext: 'client',
- issue4614Context: 'client:cached',
- scenario: 'cached',
- })
- })
-
- test('new loaderDeps match generation propagates fresh parent context to child (control)', async ({
- page,
- }) => {
- await page.goto('/')
- await page.getByTestId('issue-4614-reload-link').hover()
+ await page.getByTestId('issue-4614-link').hover()
await expect
.poll(() =>
@@ -54,10 +20,10 @@ test.describe('selective ssr', () => {
)
.toEqual([
{
- cause: 'preload',
- preload: true,
- root: 'client',
- issue4614Context: 'client:reload',
+ cause: 'enter',
+ preload: false,
+ isClient: true,
+ isServer: false,
},
])
await expect
@@ -67,9 +33,8 @@ test.describe('selective ssr', () => {
.toEqual({
cause: 'preload',
preload: true,
- rootContext: 'client',
- issue4614Context: 'client:reload',
- scenario: 'reload',
+ isClient: true,
+ isServer: false,
})
})
From 185fcccbf0c712c5b456f6acc6f6f7d325cfa7bc Mon Sep 17 00:00:00 2001
From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com>
Date: Fri, 24 Jul 2026 00:04:38 +0200
Subject: [PATCH 2/5] failing regressiontest for #6221
---
e2e/react-start/basic/src/routeTree.gen.ts | 42 +++++
.../src/routes/issue-6221.article.$id.tsx | 42 +++++
.../basic/src/routes/issue-6221.dashboard.tsx | 6 +
.../basic/tests/issue-6221-head.spec.ts | 41 +++++
.../issue-6221-head-waits-for-loader.test.ts | 162 ------------------
5 files changed, 131 insertions(+), 162 deletions(-)
create mode 100644 e2e/react-start/basic/src/routes/issue-6221.article.$id.tsx
create mode 100644 e2e/react-start/basic/src/routes/issue-6221.dashboard.tsx
create mode 100644 e2e/react-start/basic/tests/issue-6221-head.spec.ts
delete mode 100644 packages/router-core/tests/issue-6221-head-waits-for-loader.test.ts
diff --git a/e2e/react-start/basic/src/routeTree.gen.ts b/e2e/react-start/basic/src/routeTree.gen.ts
index 10a40bc044..ea09864d8e 100644
--- a/e2e/react-start/basic/src/routeTree.gen.ts
+++ b/e2e/react-start/basic/src/routeTree.gen.ts
@@ -29,6 +29,7 @@ import { Route as TypeOnlyReexportRouteImport } from './routes/type-only-reexpor
import { Route as UsersRouteImport } from './routes/users'
import { Route as LayoutLayout2RouteImport } from './routes/_layout/_layout-2'
import { Route as ApiUsersRouteImport } from './routes/api.users'
+import { Route as Issue6221DashboardRouteImport } from './routes/issue-6221.dashboard'
import { Route as MultiCookieRedirectIndexRouteImport } from './routes/multi-cookie-redirect/index'
import { Route as MultiCookieRedirectTargetRouteImport } from './routes/multi-cookie-redirect/target'
import { Route as NotFoundIndexRouteImport } from './routes/not-found/index'
@@ -61,6 +62,7 @@ import { Route as UsersUserIdRouteImport } from './routes/users.$userId'
import { Route as LayoutLayout2LayoutARouteImport } from './routes/_layout/_layout-2/layout-a'
import { Route as LayoutLayout2LayoutBRouteImport } from './routes/_layout/_layout-2/layout-b'
import { Route as ApiUsersIdRouteImport } from './routes/api/users.$id'
+import { Route as Issue6221ArticleIdRouteImport } from './routes/issue-6221.article.$id'
import { Route as NotFoundDeepIndexRouteImport } from './routes/not-found/deep/index'
import { Route as NotFoundDeepBRouteRouteImport } from './routes/not-found/deep/b/route'
import { Route as NotFoundParentBoundaryIndexRouteImport } from './routes/not-found/parent-boundary/index'
@@ -179,6 +181,11 @@ const ApiUsersRoute = ApiUsersRouteImport.update({
path: '/api/users',
getParentRoute: () => rootRouteImport,
} as any)
+const Issue6221DashboardRoute = Issue6221DashboardRouteImport.update({
+ id: '/issue-6221/dashboard',
+ path: '/issue-6221/dashboard',
+ getParentRoute: () => rootRouteImport,
+} as any)
const MultiCookieRedirectIndexRoute =
MultiCookieRedirectIndexRouteImport.update({
id: '/multi-cookie-redirect/',
@@ -346,6 +353,11 @@ const ApiUsersIdRoute = ApiUsersIdRouteImport.update({
path: '/$id',
getParentRoute: () => ApiUsersRoute,
} as any)
+const Issue6221ArticleIdRoute = Issue6221ArticleIdRouteImport.update({
+ id: '/issue-6221/article/$id',
+ path: '/issue-6221/article/$id',
+ getParentRoute: () => rootRouteImport,
+} as any)
const NotFoundDeepIndexRoute = NotFoundDeepIndexRouteImport.update({
id: '/',
path: '/',
@@ -468,6 +480,7 @@ export interface FileRoutesByFullPath {
'/not-found/parent-boundary': typeof NotFoundParentBoundaryRouteRouteWithChildren
'/specialChars/malformed': typeof SpecialCharsMalformedRouteRouteWithChildren
'/api/users': typeof ApiUsersRouteWithChildren
+ '/issue-6221/dashboard': typeof Issue6221DashboardRoute
'/multi-cookie-redirect/target': typeof MultiCookieRedirectTargetRoute
'/not-found/via-beforeLoad': typeof NotFoundViaBeforeLoadRoute
'/not-found/via-beforeLoad-target-root': typeof NotFoundViaBeforeLoadTargetRootRoute
@@ -498,6 +511,7 @@ export interface FileRoutesByFullPath {
'/layout-a': typeof LayoutLayout2LayoutARoute
'/layout-b': typeof LayoutLayout2LayoutBRoute
'/api/users/$id': typeof ApiUsersIdRoute
+ '/issue-6221/article/$id': typeof Issue6221ArticleIdRoute
'/not-found/parent-boundary/via-beforeLoad': typeof NotFoundParentBoundaryViaBeforeLoadRoute
'/posts/$postId/deep': typeof PostsPostIdDeepRoute
'/redirect/$target/via-beforeLoad': typeof RedirectTargetViaBeforeLoadRoute
@@ -531,6 +545,7 @@ export interface FileRoutesByTo {
'/type-only-reexport': typeof TypeOnlyReexportRoute
'/specialChars/malformed': typeof SpecialCharsMalformedRouteRouteWithChildren
'/api/users': typeof ApiUsersRouteWithChildren
+ '/issue-6221/dashboard': typeof Issue6221DashboardRoute
'/multi-cookie-redirect/target': typeof MultiCookieRedirectTargetRoute
'/not-found/via-beforeLoad': typeof NotFoundViaBeforeLoadRoute
'/not-found/via-beforeLoad-target-root': typeof NotFoundViaBeforeLoadTargetRootRoute
@@ -560,6 +575,7 @@ export interface FileRoutesByTo {
'/layout-a': typeof LayoutLayout2LayoutARoute
'/layout-b': typeof LayoutLayout2LayoutBRoute
'/api/users/$id': typeof ApiUsersIdRoute
+ '/issue-6221/article/$id': typeof Issue6221ArticleIdRoute
'/not-found/parent-boundary/via-beforeLoad': typeof NotFoundParentBoundaryViaBeforeLoadRoute
'/posts/$postId/deep': typeof PostsPostIdDeepRoute
'/redirect/$target/via-beforeLoad': typeof RedirectTargetViaBeforeLoadRoute
@@ -602,6 +618,7 @@ export interface FileRoutesById {
'/specialChars/malformed': typeof SpecialCharsMalformedRouteRouteWithChildren
'/_layout/_layout-2': typeof LayoutLayout2RouteWithChildren
'/api/users': typeof ApiUsersRouteWithChildren
+ '/issue-6221/dashboard': typeof Issue6221DashboardRoute
'/multi-cookie-redirect/target': typeof MultiCookieRedirectTargetRoute
'/not-found/via-beforeLoad': typeof NotFoundViaBeforeLoadRoute
'/not-found/via-beforeLoad-target-root': typeof NotFoundViaBeforeLoadTargetRootRoute
@@ -632,6 +649,7 @@ export interface FileRoutesById {
'/_layout/_layout-2/layout-a': typeof LayoutLayout2LayoutARoute
'/_layout/_layout-2/layout-b': typeof LayoutLayout2LayoutBRoute
'/api/users/$id': typeof ApiUsersIdRoute
+ '/issue-6221/article/$id': typeof Issue6221ArticleIdRoute
'/not-found/parent-boundary/via-beforeLoad': typeof NotFoundParentBoundaryViaBeforeLoadRoute
'/posts_/$postId/deep': typeof PostsPostIdDeepRoute
'/redirect/$target/via-beforeLoad': typeof RedirectTargetViaBeforeLoadRoute
@@ -674,6 +692,7 @@ export interface FileRouteTypes {
| '/not-found/parent-boundary'
| '/specialChars/malformed'
| '/api/users'
+ | '/issue-6221/dashboard'
| '/multi-cookie-redirect/target'
| '/not-found/via-beforeLoad'
| '/not-found/via-beforeLoad-target-root'
@@ -704,6 +723,7 @@ export interface FileRouteTypes {
| '/layout-a'
| '/layout-b'
| '/api/users/$id'
+ | '/issue-6221/article/$id'
| '/not-found/parent-boundary/via-beforeLoad'
| '/posts/$postId/deep'
| '/redirect/$target/via-beforeLoad'
@@ -737,6 +757,7 @@ export interface FileRouteTypes {
| '/type-only-reexport'
| '/specialChars/malformed'
| '/api/users'
+ | '/issue-6221/dashboard'
| '/multi-cookie-redirect/target'
| '/not-found/via-beforeLoad'
| '/not-found/via-beforeLoad-target-root'
@@ -766,6 +787,7 @@ export interface FileRouteTypes {
| '/layout-a'
| '/layout-b'
| '/api/users/$id'
+ | '/issue-6221/article/$id'
| '/not-found/parent-boundary/via-beforeLoad'
| '/posts/$postId/deep'
| '/redirect/$target/via-beforeLoad'
@@ -807,6 +829,7 @@ export interface FileRouteTypes {
| '/specialChars/malformed'
| '/_layout/_layout-2'
| '/api/users'
+ | '/issue-6221/dashboard'
| '/multi-cookie-redirect/target'
| '/not-found/via-beforeLoad'
| '/not-found/via-beforeLoad-target-root'
@@ -837,6 +860,7 @@ export interface FileRouteTypes {
| '/_layout/_layout-2/layout-a'
| '/_layout/_layout-2/layout-b'
| '/api/users/$id'
+ | '/issue-6221/article/$id'
| '/not-found/parent-boundary/via-beforeLoad'
| '/posts_/$postId/deep'
| '/redirect/$target/via-beforeLoad'
@@ -876,10 +900,12 @@ export interface RootRouteChildren {
TypeOnlyReexportRoute: typeof TypeOnlyReexportRoute
UsersRoute: typeof UsersRouteWithChildren
ApiUsersRoute: typeof ApiUsersRouteWithChildren
+ Issue6221DashboardRoute: typeof Issue6221DashboardRoute
MultiCookieRedirectTargetRoute: typeof MultiCookieRedirectTargetRoute
RedirectTargetRoute: typeof RedirectTargetRouteWithChildren
MultiCookieRedirectIndexRoute: typeof MultiCookieRedirectIndexRoute
RedirectIndexRoute: typeof RedirectIndexRoute
+ Issue6221ArticleIdRoute: typeof Issue6221ArticleIdRoute
PostsPostIdDeepRoute: typeof PostsPostIdDeepRoute
FooBarQuxHereRoute: typeof FooBarQuxHereRouteWithChildren
}
@@ -1026,6 +1052,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiUsersRouteImport
parentRoute: typeof rootRouteImport
}
+ '/issue-6221/dashboard': {
+ id: '/issue-6221/dashboard'
+ path: '/issue-6221/dashboard'
+ fullPath: '/issue-6221/dashboard'
+ preLoaderRoute: typeof Issue6221DashboardRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/multi-cookie-redirect/': {
id: '/multi-cookie-redirect/'
path: '/multi-cookie-redirect'
@@ -1250,6 +1283,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiUsersIdRouteImport
parentRoute: typeof ApiUsersRoute
}
+ '/issue-6221/article/$id': {
+ id: '/issue-6221/article/$id'
+ path: '/issue-6221/article/$id'
+ fullPath: '/issue-6221/article/$id'
+ preLoaderRoute: typeof Issue6221ArticleIdRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/not-found/deep/': {
id: '/not-found/deep/'
path: '/'
@@ -1648,10 +1688,12 @@ const rootRouteChildren: RootRouteChildren = {
TypeOnlyReexportRoute: TypeOnlyReexportRoute,
UsersRoute: UsersRouteWithChildren,
ApiUsersRoute: ApiUsersRouteWithChildren,
+ Issue6221DashboardRoute: Issue6221DashboardRoute,
MultiCookieRedirectTargetRoute: MultiCookieRedirectTargetRoute,
RedirectTargetRoute: RedirectTargetRouteWithChildren,
MultiCookieRedirectIndexRoute: MultiCookieRedirectIndexRoute,
RedirectIndexRoute: RedirectIndexRoute,
+ Issue6221ArticleIdRoute: Issue6221ArticleIdRoute,
PostsPostIdDeepRoute: PostsPostIdDeepRoute,
FooBarQuxHereRoute: FooBarQuxHereRouteWithChildren,
}
diff --git a/e2e/react-start/basic/src/routes/issue-6221.article.$id.tsx b/e2e/react-start/basic/src/routes/issue-6221.article.$id.tsx
new file mode 100644
index 0000000000..c923a5f1cb
--- /dev/null
+++ b/e2e/react-start/basic/src/routes/issue-6221.article.$id.tsx
@@ -0,0 +1,42 @@
+import { Link, createFileRoute } from '@tanstack/react-router'
+import { createClientOnlyFn } from '@tanstack/react-start'
+
+const isAuthed = createClientOnlyFn(
+ () => localStorage.getItem('issue-6221-auth') === 'good',
+)
+
+const fetchArticle = async (id: string) => {
+ await new Promise((resolve) => setTimeout(resolve, 1000))
+ return isAuthed()
+ ? {
+ title: `Article Title for ${id}`,
+ content: `Article content for ${id}`,
+ }
+ : null
+}
+
+export const Route = createFileRoute('/issue-6221/article/$id')({
+ ssr: false,
+ loader: ({ params }) => fetchArticle(params.id),
+ head: ({ loaderData }) => ({
+ meta: [{ title: loaderData?.title ?? 'title n/a' }],
+ }),
+ component: Article,
+})
+
+function Article() {
+ const article = Route.useLoaderData()
+
+ return (
+
+
+ Dashboard
+
+ {article ? (
+
{article.content}
+ ) : (
+
Article Not Accessible.
+ )}
+
+ )
+}
diff --git a/e2e/react-start/basic/src/routes/issue-6221.dashboard.tsx b/e2e/react-start/basic/src/routes/issue-6221.dashboard.tsx
new file mode 100644
index 0000000000..8c93da669c
--- /dev/null
+++ b/e2e/react-start/basic/src/routes/issue-6221.dashboard.tsx
@@ -0,0 +1,6 @@
+import { createFileRoute } from '@tanstack/react-router'
+
+export const Route = createFileRoute('/issue-6221/dashboard')({
+ head: () => ({ meta: [{ title: 'Dashboard' }] }),
+ component: () => Dashboard
,
+})
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/router-core/tests/issue-6221-head-waits-for-loader.test.ts b/packages/router-core/tests/issue-6221-head-waits-for-loader.test.ts
deleted file mode 100644
index 55cf11c277..0000000000
--- a/packages/router-core/tests/issue-6221-head-waits-for-loader.test.ts
+++ /dev/null
@@ -1,162 +0,0 @@
-import { createMemoryHistory } from '@tanstack/history'
-import { describe, expect, test, vi } from 'vitest'
-import {
- BaseRootRoute,
- BaseRoute,
- createControlledPromise,
- notFound,
-} from '../src'
-import { createTestRouter } from './routerTestUtils'
-
-// https://github.com/TanStack/router/issues/6221
-//
-// head() must never compute from loaderData older than the data the lane
-// commits. Two reported shapes:
-// 1. Revisiting a route whose previous visit ended in notFound — the head
-// must see the fresh successful loaderData, not run before the loader.
-// 2. Revisiting a cached success match that gets a stale reload — the head
-// title must not lag one loaderData run behind.
-describe('issue #6221: head does not run before loader data is ready', () => {
- test('existing-behavior control: head sees fresh loaderData after a notFound visit', async () => {
- let authed = false
- const articleResponse = createControlledPromise<{ title: string }>()
- const successfulLoadStarted = createControlledPromise()
-
- const articleLoader = async () => {
- if (!authed) {
- await Promise.resolve()
- throw notFound()
- }
-
- successfulLoadStarted.resolve()
- return articleResponse
- }
-
- const rootRoute = new BaseRootRoute({})
- const dashboardRoute = new BaseRoute({
- getParentRoute: () => rootRoute,
- path: '/dashboard',
- })
- const articleRoute = new BaseRoute({
- getParentRoute: () => rootRoute,
- path: '/article',
- loader: articleLoader,
- notFoundComponent: () => 'Not found',
- head: ({ loaderData }) => ({
- meta: [{ title: loaderData?.title ?? 'Generic title' }],
- }),
- })
-
- const router = createTestRouter({
- routeTree: rootRoute.addChildren([dashboardRoute, articleRoute]),
- history: createMemoryHistory({ initialEntries: ['/article'] }),
- })
- const backLoadFinished = createControlledPromise()
- let backLoadStarted = false
- let unsubscribe: (() => void) | undefined
-
- try {
- await router.load()
- const notFoundMatch = router.state.matches.find(
- (match) => match.routeId === articleRoute.id,
- )
- expect(notFoundMatch?.status).toBe('notFound')
- expect(notFoundMatch?.meta).toEqual([{ title: 'Generic title' }])
-
- // Model the reported auth redirect and browser Back without relying on a
- // wall-clock loader delay.
- authed = true
- await router.navigate({ to: '/dashboard' })
- unsubscribe = router.history.subscribe(() => {
- backLoadStarted = true
- void router.load().then(
- () => backLoadFinished.resolve(),
- (error) => backLoadFinished.reject(error),
- )
- })
- router.history.back()
- await successfulLoadStarted
- expect(articleResponse.status).toBe('pending')
-
- articleResponse.resolve({ title: 'Article 123' })
- await backLoadFinished
-
- const articleMatch = router.state.matches.find(
- (match) => match.routeId === articleRoute.id,
- )
- expect(articleMatch?.status).toBe('success')
- expect(articleMatch?.loaderData).toEqual({ title: 'Article 123' })
- expect(articleMatch?.meta).toEqual([{ title: 'Article 123' }])
- } finally {
- articleResponse.resolve({ title: 'Article 123' })
- if (backLoadStarted) {
- await backLoadFinished.catch(() => undefined)
- }
- unsubscribe?.()
- }
- })
-
- test('head title does not lag one loaderData run behind on revisits', async () => {
- const secondLoaderStarted = createControlledPromise()
- const secondLoaderResponse = createControlledPromise<{ author: string }>()
- let version = 0
- const quoteLoader = () => {
- version += 1
- if (version === 2) {
- secondLoaderStarted.resolve()
- return secondLoaderResponse
- }
-
- return { author: 'author-1' }
- }
-
- const rootRoute = new BaseRootRoute({})
- const indexRoute = new BaseRoute({
- getParentRoute: () => rootRoute,
- path: '/',
- })
- const quoteRoute = new BaseRoute({
- getParentRoute: () => rootRoute,
- path: '/quote',
- loader: quoteLoader,
- staleTime: 0,
- head: ({ loaderData }) => ({
- meta: [{ title: `Quote by ${loaderData?.author ?? '...'}` }],
- }),
- })
-
- const router = createTestRouter({
- routeTree: rootRoute.addChildren([indexRoute, quoteRoute]),
- history: createMemoryHistory({ initialEntries: ['/'] }),
- })
-
- const getQuoteMatch = () =>
- router.state.matches.find((match) => match.routeId === quoteRoute.id)
- try {
- await router.load()
-
- await router.navigate({ to: '/quote' })
- expect(getQuoteMatch()?.loaderData).toEqual({ author: 'author-1' })
- expect(getQuoteMatch()?.meta).toEqual([{ title: 'Quote by author-1' }])
-
- await router.navigate({ to: '/' })
- const revisit = router.navigate({ to: '/quote' })
- await secondLoaderStarted
- await revisit
-
- // The cached generation remains internally consistent while its stale
- // loader is pending in the background.
- expect(secondLoaderResponse.status).toBe('pending')
- expect(getQuoteMatch()?.loaderData).toEqual({ author: 'author-1' })
- expect(getQuoteMatch()?.meta).toEqual([{ title: 'Quote by author-1' }])
-
- secondLoaderResponse.resolve({ author: 'author-2' })
- await vi.waitFor(() => {
- expect(getQuoteMatch()?.loaderData).toEqual({ author: 'author-2' })
- expect(getQuoteMatch()?.meta).toEqual([{ title: 'Quote by author-2' }])
- })
- } finally {
- secondLoaderResponse.resolve({ author: 'author-2' })
- }
- })
-})
From aba4ddc4bed7adb5908307e98f0fc6a75dfcdefc Mon Sep 17 00:00:00 2001
From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com>
Date: Fri, 24 Jul 2026 00:13:01 +0200
Subject: [PATCH 3/5] tests
---
.../selective-ssr/tests/app.spec.ts | 4 +-
.../tests/transitioner-render-ack.test.tsx | 94 ++++++++++++--
.../src/tests/hydrateStart.test.ts | 18 ++-
.../public-preload-lane-contract.test.ts | 118 +++++++++++++-----
.../src/tests/hydrateStart.test.ts | 18 ++-
5 files changed, 207 insertions(+), 45 deletions(-)
diff --git a/e2e/react-start/selective-ssr/tests/app.spec.ts b/e2e/react-start/selective-ssr/tests/app.spec.ts
index e926f57354..ad85bf3e22 100644
--- a/e2e/react-start/selective-ssr/tests/app.spec.ts
+++ b/e2e/react-start/selective-ssr/tests/app.spec.ts
@@ -20,8 +20,8 @@ test.describe('selective ssr', () => {
)
.toEqual([
{
- cause: 'enter',
- preload: false,
+ cause: 'preload',
+ preload: true,
isClient: true,
isServer: false,
},
diff --git a/packages/react-router/tests/transitioner-render-ack.test.tsx b/packages/react-router/tests/transitioner-render-ack.test.tsx
index 535a1e0e71..b533c225b3 100644
--- a/packages/react-router/tests/transitioner-render-ack.test.tsx
+++ b/packages/react-router/tests/transitioner-render-ack.test.tsx
@@ -21,7 +21,7 @@ afterEach(() => {
vi.useRealTimers()
})
-test('same-location invalidation resolves after its refreshed DOM commits', async () => {
+test('same-location invalidation emits onResolved after its refreshed DOM commits', async () => {
let generation = 0
const rootRoute = createRootRoute({ component: Outlet })
const indexRoute = createRoute({
@@ -57,31 +57,103 @@ test('same-location invalidation resolves after its refreshed DOM commits', asyn
expect(refreshedDomWasVisible).toEqual([true])
})
-test('an immediately completed background refresh cannot replace the acknowledged foreground generation', async () => {
- let generation = 0
+test('a late background refresh is discarded after foreground navigation commits', async () => {
+ const backgroundRefresh = createControlledPromise()
+ let sourceGeneration = 0
+ const itemLoader = vi.fn((itemId: string) => {
+ if (itemId === 'source') {
+ sourceGeneration++
+ if (sourceGeneration === 1) {
+ return 'initial source data'
+ }
+ return backgroundRefresh
+ }
+ return 'foreground target data'
+ })
const rootRoute = createRootRoute({ component: Outlet })
- const indexRoute = createRoute({
+ const itemRoute = createRoute({
getParentRoute: () => rootRoute,
- path: '/',
+ path: '/items/$itemId',
loader: {
staleReloadMode: 'background',
- handler: () => ++generation,
+ handler: ({ params }) => itemLoader(params.itemId),
},
- component: () => Generation {indexRoute.useLoaderData()}
,
+ component: () => (
+
+ Item {itemRoute.useParams().itemId}: {itemRoute.useLoaderData()}
+
+ ),
})
const router = createRouter({
- routeTree: rootRoute.addChildren([indexRoute]),
- history: createMemoryHistory({ initialEntries: ['/'] }),
+ routeTree: rootRoute.addChildren([itemRoute]),
+ history: createMemoryHistory({ initialEntries: ['/items/source'] }),
})
render()
- expect(await screen.findByText('Generation 1')).toBeInTheDocument()
+ expect(
+ await screen.findByText('Item source: initial source data'),
+ ).toBeInTheDocument()
await waitFor(() => expect(router.state.status).toBe('idle'))
+ const sourceMatchId = router.state.matches.at(-1)!.id
+
+ const eventLog: Array = []
+ const unsubscribers = [
+ router.subscribe('onResolved', (event) => {
+ eventLog.push(`onResolved:${event.toLocation.pathname}`)
+ }),
+ router.subscribe('onRendered', (event) => {
+ eventLog.push(`onRendered:${event.toLocation.pathname}`)
+ }),
+ ]
+ testCleanups.push(...unsubscribers)
await act(() => router.invalidate())
+ expect(itemLoader).toHaveBeenCalledTimes(2)
+
+ await act(() =>
+ router.navigate({
+ to: '/items/$itemId',
+ params: { itemId: 'target' },
+ }),
+ )
+ expect(
+ screen.getByText('Item target: foreground target data'),
+ ).toBeInTheDocument()
+ expect(eventLog.filter((event) => event.endsWith('/items/target'))).toEqual([
+ 'onResolved:/items/target',
+ 'onRendered:/items/target',
+ ])
+ expect(router.state.resolvedLocation?.pathname).toBe('/items/target')
+ const eventCountAfterForegroundCommit = eventLog.length
+
+ await act(async () => {
+ backgroundRefresh.resolve('obsolete source data')
+ await Promise.resolve()
+ })
+ await waitFor(() => {
+ expect(router._flights?.has(sourceMatchId) ?? false).toBe(false)
+ const cachedSourceMatch = router._cache.get(sourceMatchId)
+ expect(cachedSourceMatch?.loaderData).toBe('initial source data')
+ expect(cachedSourceMatch?.isFetching).toBe(false)
+ })
- expect(await screen.findByText('Generation 2')).toBeInTheDocument()
+ expect(
+ screen.getByText('Item target: foreground target data'),
+ ).toBeInTheDocument()
+ expect(
+ screen.queryByText('Item source: obsolete source data'),
+ ).not.toBeInTheDocument()
+ expect(router.state.location.pathname).toBe('/items/target')
+ expect(router.state.resolvedLocation?.pathname).toBe('/items/target')
+ expect(router.state.matches.at(-1)?.routeId).toBe(itemRoute.id)
+ expect(router.state.matches.at(-1)?.params).toEqual({ itemId: 'target' })
+ expect(router.state.matches.at(-1)?.loaderData).toBe('foreground target data')
expect(router.state.status).toBe('idle')
+ expect(
+ eventLog
+ .slice(eventCountAfterForegroundCommit)
+ .every((event) => event.endsWith('/items/target')),
+ ).toBe(true)
})
test('a navigation started by route lifecycle keeps the pending minimum of its own render', async () => {
diff --git a/packages/react-start-client/src/tests/hydrateStart.test.ts b/packages/react-start-client/src/tests/hydrateStart.test.ts
index 2d34400009..51de4981ea 100644
--- a/packages/react-start-client/src/tests/hydrateStart.test.ts
+++ b/packages/react-start-client/src/tests/hydrateStart.test.ts
@@ -14,11 +14,23 @@ afterEach(() => {
test('signals streaming cleanup after hydration succeeds', async () => {
const router = {}
- coreHydrateStart.mockResolvedValue(router)
+ let resolveCoreHydration!: (value: object) => void
+ coreHydrateStart.mockReturnValue(
+ new Promise((resolve) => {
+ resolveCoreHydration = resolve
+ }),
+ )
const hydrated = vi.fn()
window.$_TSR = { h: hydrated } as any
- await expect(hydrateStart()).resolves.toBe(router)
+ const hydration = hydrateStart()
+ await Promise.resolve()
+
+ expect(coreHydrateStart).toHaveBeenCalledOnce()
+ expect(hydrated).not.toHaveBeenCalled()
+
+ resolveCoreHydration(router)
+ await expect(hydration).resolves.toBe(router)
expect(hydrated).toHaveBeenCalledTimes(1)
})
@@ -29,5 +41,7 @@ test('signals streaming cleanup without hiding a hydration failure', async () =>
window.$_TSR = { h: hydrated } as any
await expect(hydrateStart()).rejects.toBe(error)
+
+ expect(coreHydrateStart).toHaveBeenCalledOnce()
expect(hydrated).toHaveBeenCalledTimes(1)
})
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 42170003ad..7b13e6a0c9 100644
--- a/packages/router-core/tests/public-preload-lane-contract.test.ts
+++ b/packages/router-core/tests/public-preload-lane-contract.test.ts
@@ -245,6 +245,12 @@ describe('public preload lane contracts', () => {
navigationMask: { to: '/mask-b' },
preloadPathname: '/mask-a',
navigationPathname: '/mask-b',
+ preloadUnmaskOnReload: undefined,
+ navigationUnmaskOnReload: undefined,
+ preloadSearchSource: undefined,
+ navigationSearchSource: undefined,
+ preloadStateSource: undefined,
+ navigationStateSource: undefined,
},
{
difference: 'unmaskOnReload',
@@ -252,6 +258,12 @@ describe('public preload lane contracts', () => {
navigationMask: { to: '/mask-a', unmaskOnReload: true },
preloadPathname: '/mask-a',
navigationPathname: '/mask-a',
+ preloadUnmaskOnReload: undefined,
+ navigationUnmaskOnReload: true,
+ preloadSearchSource: undefined,
+ navigationSearchSource: undefined,
+ preloadStateSource: undefined,
+ navigationStateSource: undefined,
},
{
difference: 'search',
@@ -259,6 +271,12 @@ describe('public preload lane contracts', () => {
navigationMask: { to: '/mask-a', search: { source: 'navigation' } },
preloadPathname: '/mask-a',
navigationPathname: '/mask-a',
+ preloadUnmaskOnReload: undefined,
+ navigationUnmaskOnReload: undefined,
+ preloadSearchSource: 'preload',
+ navigationSearchSource: 'navigation',
+ preloadStateSource: undefined,
+ navigationStateSource: undefined,
},
{
difference: 'state',
@@ -266,6 +284,12 @@ describe('public preload lane contracts', () => {
navigationMask: { to: '/mask-a', state: { source: 'navigation' } },
preloadPathname: '/mask-a',
navigationPathname: '/mask-a',
+ preloadUnmaskOnReload: undefined,
+ navigationUnmaskOnReload: undefined,
+ preloadSearchSource: undefined,
+ navigationSearchSource: undefined,
+ preloadStateSource: 'preload',
+ navigationStateSource: 'navigation',
},
])(
'navigation reruns beforeLoad for a different explicit mask $difference on the same destination',
@@ -274,20 +298,16 @@ describe('public preload lane contracts', () => {
navigationMask,
preloadPathname,
navigationPathname,
+ preloadUnmaskOnReload,
+ navigationUnmaskOnReload,
+ preloadSearchSource,
+ navigationSearchSource,
+ preloadStateSource,
+ navigationStateSource,
}) => {
const beforeLoadGate = createControlledPromise()
const loaderGate = createControlledPromise()
- const beforeLoad = vi.fn(
- async (context: {
- location: { maskedLocation?: { pathname: string } }
- preload: boolean
- }) => {
- if (context.preload) {
- await beforeLoadGate
- }
- return { source: context.preload ? 'preload' : 'navigation' }
- },
- )
+ const beforeLoadCalls = vi.fn()
const loader = vi.fn(() => loaderGate)
const rootRoute = new BaseRootRoute({})
const indexRoute = new BaseRoute({
@@ -297,7 +317,24 @@ describe('public preload lane contracts', () => {
const targetRoute = new BaseRoute({
getParentRoute: () => rootRoute,
path: '/target',
- beforeLoad,
+ beforeLoad: async (context) => {
+ const maskedLocation = context.location.maskedLocation
+ beforeLoadCalls({
+ preload: context.preload,
+ pathname: maskedLocation?.pathname,
+ unmaskOnReload: maskedLocation?.unmaskOnReload,
+ searchSource: (
+ maskedLocation?.search as { source?: string } | undefined
+ )?.source,
+ stateSource: (
+ maskedLocation?.state as { source?: string } | undefined
+ )?.source,
+ })
+ if (context.preload) {
+ await beforeLoadGate
+ }
+ return { source: context.preload ? 'preload' : 'navigation' }
+ },
loader,
})
const maskARoute = new BaseRoute({
@@ -327,7 +364,7 @@ describe('public preload lane contracts', () => {
to: '/target',
mask: preloadMask as any,
})
- await vi.waitFor(() => expect(beforeLoad).toHaveBeenCalledTimes(1))
+ await vi.waitFor(() => expect(beforeLoadCalls).toHaveBeenCalledTimes(1))
beforeLoadGate.resolve()
await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(1))
@@ -337,14 +374,21 @@ describe('public preload lane contracts', () => {
mask: navigationMask as any,
})
await vi.waitFor(() =>
- expect(
- beforeLoad.mock.calls.map(([callContext]) => ({
- preload: callContext.preload,
- pathname: callContext.location.maskedLocation?.pathname,
- })),
- ).toEqual([
- { preload: true, pathname: preloadPathname },
- { preload: false, pathname: navigationPathname },
+ expect(beforeLoadCalls.mock.calls.map(([call]) => call)).toEqual([
+ {
+ preload: true,
+ pathname: preloadPathname,
+ unmaskOnReload: preloadUnmaskOnReload,
+ searchSource: preloadSearchSource,
+ stateSource: preloadStateSource,
+ },
+ {
+ preload: false,
+ pathname: navigationPathname,
+ unmaskOnReload: navigationUnmaskOnReload,
+ searchSource: navigationSearchSource,
+ stateSource: navigationStateSource,
+ },
]),
)
@@ -1151,20 +1195,24 @@ describe('public preload lane contracts', () => {
expect(router.state.matches.at(-1)?.loaderData).toBe('loader data 1')
})
- test('filtered invalidation reloads every generation of the selected match id', async () => {
+ test('filtered invalidation invalidates active and cached generations of the selected route', async () => {
const loader = vi.fn(() => `loader data ${loader.mock.calls.length}`)
const rootRoute = new BaseRootRoute({})
const targetRoute = new BaseRoute({
getParentRoute: () => rootRoute,
path: '/target',
+ staleTime: Infinity,
+ preloadStaleTime: Infinity,
+ preloadGcTime: Infinity,
+ gcTime: Infinity,
validateSearch: (search: Record) => ({
revision: Number(search.revision ?? 1),
}),
- shouldReload: ({ location }) =>
- (location.search as { revision: number }).revision === 2
- ? true
- : undefined,
- loader,
+ loaderDeps: ({ search }) => ({ revision: search.revision }),
+ loader: {
+ staleReloadMode: 'blocking',
+ handler: loader,
+ },
})
const router = createTestRouter({
routeTree: rootRoute.addChildren([targetRoute]),
@@ -1178,12 +1226,26 @@ describe('public preload lane contracts', () => {
to: '/target',
search: { revision: 2 },
})
+ expect(loader).toHaveBeenCalledTimes(2)
+
await router.invalidate({
- filter: (match) => (match.search as { revision?: number }).revision === 1,
+ filter: (match) => match.routeId === targetRoute.id,
})
expect(loader).toHaveBeenCalledTimes(3)
expect(router.state.matches.at(-1)?.loaderData).toBe('loader data 3')
+
+ await router.navigate({
+ to: '/target',
+ search: { revision: 2 },
+ })
+
+ expect(loader).toHaveBeenCalledTimes(4)
+ expect(router.state.matches.at(-1)).toMatchObject({
+ invalid: false,
+ loaderData: 'loader data 4',
+ search: { revision: 2 },
+ })
})
test('a hidden terminal suffix does not evict a newer same-id preload', async () => {
diff --git a/packages/solid-start-client/src/tests/hydrateStart.test.ts b/packages/solid-start-client/src/tests/hydrateStart.test.ts
index 2d34400009..51de4981ea 100644
--- a/packages/solid-start-client/src/tests/hydrateStart.test.ts
+++ b/packages/solid-start-client/src/tests/hydrateStart.test.ts
@@ -14,11 +14,23 @@ afterEach(() => {
test('signals streaming cleanup after hydration succeeds', async () => {
const router = {}
- coreHydrateStart.mockResolvedValue(router)
+ let resolveCoreHydration!: (value: object) => void
+ coreHydrateStart.mockReturnValue(
+ new Promise((resolve) => {
+ resolveCoreHydration = resolve
+ }),
+ )
const hydrated = vi.fn()
window.$_TSR = { h: hydrated } as any
- await expect(hydrateStart()).resolves.toBe(router)
+ const hydration = hydrateStart()
+ await Promise.resolve()
+
+ expect(coreHydrateStart).toHaveBeenCalledOnce()
+ expect(hydrated).not.toHaveBeenCalled()
+
+ resolveCoreHydration(router)
+ await expect(hydration).resolves.toBe(router)
expect(hydrated).toHaveBeenCalledTimes(1)
})
@@ -29,5 +41,7 @@ test('signals streaming cleanup without hiding a hydration failure', async () =>
window.$_TSR = { h: hydrated } as any
await expect(hydrateStart()).rejects.toBe(error)
+
+ expect(coreHydrateStart).toHaveBeenCalledOnce()
expect(hydrated).toHaveBeenCalledTimes(1)
})
From 8aa727f415d6c1d49f7ecb126c6f70be152cb065 Mon Sep 17 00:00:00 2001
From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com>
Date: Mon, 27 Jul 2026 23:00:49 +0200
Subject: [PATCH 4/5] fixes
---
.changeset/lane-match-loader-rewrite.md | 8 +-
docs/router/api/router/RouterType.md | 9 +-
docs/router/guide/preloading.md | 9 +-
.../src/routeTree.gen.ts | 21 +
.../src/routes/__root.tsx | 1 +
.../src/routes/preload-disabled.tsx | 6 +
.../tests/preload.spec.ts | 25 +
.../basic-file-based/src/main.tsx | 4 +
.../basic-file-based/tests/mask.spec.ts | 65 +
.../issue-7120/src/routeTree.gen.ts | 26 +-
.../issue-7457/src/routeTree.gen.ts | 26 +-
e2e/react-start/basic/src/routeTree.gen.ts | 21 +
.../src/routes/client-context-failure.tsx | 23 +
.../tests/client-context-failure.spec.ts | 29 +
.../selective-ssr/src/routeTree.gen.ts | 62 +-
packages/react-router/src/Matches.tsx | 27 +-
packages/react-router/src/Transitioner.tsx | 221 ++-
.../react-router/src/headContentUtils.tsx | 129 +-
.../react-router/src/lazyRouteComponent.tsx | 39 +-
.../tests/component-preload-retry.test.tsx | 66 +
.../tests/errorComponent.test.tsx | 46 +-
.../issue-4467-lazy-route-pending.test.tsx | 29 +-
.../react-render-owner-contract.test.tsx | 140 ++
.../router-client-context-retry.test.tsx | 88 ++
packages/react-router/tests/router.test.tsx | 8 +-
.../tests/transitioner-remount.test.tsx | 52 +-
.../tests/transitioner-render-ack.test.tsx | 180 ++-
packages/router-core/INTERNALS.md | 412 +++---
packages/router-core/src/load-client.ts | 1250 ++++++++---------
packages/router-core/src/router.ts | 186 ++-
.../router-core/src/ssr/handlerCallback.ts | 153 +-
packages/router-core/src/ssr/server.ts | 1 +
.../src/ssr/transformStreamWithRouter.ts | 5 +-
packages/router-core/src/stores.ts | 6 +-
packages/router-core/src/utils.ts | 9 -
.../router-core/tests/build-location.test.ts | 8 +-
packages/router-core/tests/callbacks.test.ts | 99 +-
.../tests/hmr-refresh-lifecycle.test.ts | 48 +-
...search-default-normalization-abort.test.ts | 109 --
packages/router-core/tests/load.test.ts | 23 +-
.../loader-architecture-regressions.test.ts | 577 +++++++-
.../masked-location-state-commit.test.ts | 2 +-
.../tests/pending-terminal-boundary.test.ts | 103 ++
.../tests/preload-adoption.test.ts | 133 +-
.../tests/preload-beforeload-reuse.test.ts | 78 +-
.../tests/preload-navigation-adoption.test.ts | 66 +-
.../preload-public-cache-behavior.test.ts | 226 ++-
.../preload-public-signal-lifetime.test.ts | 20 +-
.../tests/public-hydration-contract.test.ts | 167 ++-
.../public-preload-lane-contract.test.ts | 1177 ++++++++++------
.../tests/ssr-server-cleanup.test.ts | 270 +++-
.../solid-router/src/lazyRouteComponent.tsx | 77 +-
.../tests/component-preload-retry.test.tsx | 66 +
.../src/client-rpc/frame-decoder.ts | 462 +++---
.../src/client-rpc/serverFnFetcher.ts | 157 +--
packages/start-client-core/src/constants.ts | 8 +-
packages/start-client-core/src/index.tsx | 2 +
.../tests/frame-decoder.test.ts | 354 ++++-
.../tests/serverFnFetcher.test.ts | 395 ++++++
.../src/createStartHandler.ts | 286 ++--
.../start-server-core/src/frame-protocol.ts | 290 ++--
.../src/server-functions-handler.ts | 256 ++--
.../tests/createStartHandler.test.ts | 569 +++++++-
.../tests/frame-protocol.test.ts | 456 +++---
.../tests/server-functions-handler.test.ts | 247 ++++
packages/vue-router/src/CatchBoundary.tsx | 45 +-
packages/vue-router/src/headContentUtils.tsx | 58 +-
.../vue-router/src/lazyRouteComponent.tsx | 118 +-
.../vue-router/tests/CatchBoundary.test.tsx | 144 ++
.../tests/component-preload-retry.test.tsx | 234 ++-
70 files changed, 7402 insertions(+), 3310 deletions(-)
create mode 100644 e2e/react-router/basic-file-based-code-splitting/src/routes/preload-disabled.tsx
create mode 100644 e2e/react-start/basic/src/routes/client-context-failure.tsx
create mode 100644 e2e/react-start/basic/tests/client-context-failure.spec.ts
create mode 100644 packages/react-router/tests/react-render-owner-contract.test.tsx
create mode 100644 packages/react-router/tests/router-client-context-retry.test.tsx
delete mode 100644 packages/router-core/tests/issue-6371-search-default-normalization-abort.test.ts
create mode 100644 packages/router-core/tests/pending-terminal-boundary.test.ts
create mode 100644 packages/start-client-core/tests/serverFnFetcher.test.ts
create mode 100644 packages/start-server-core/tests/server-functions-handler.test.ts
create mode 100644 packages/vue-router/tests/CatchBoundary.test.tsx
diff --git a/.changeset/lane-match-loader-rewrite.md b/.changeset/lane-match-loader-rewrite.md
index ddd71fa4c5..3833ca60bc 100644
--- a/.changeset/lane-match-loader-rewrite.md
+++ b/.changeset/lane-match-loader-rewrite.md
@@ -3,12 +3,18 @@
'@tanstack/react-router': patch
'@tanstack/solid-router': patch
'@tanstack/vue-router': patch
+'@tanstack/router-devtools-core': patch
+'@tanstack/react-router-devtools': patch
+'@tanstack/solid-router-devtools': patch
+'@tanstack/vue-router-devtools': patch
+'@tanstack/router-devtools': patch
---
Rewrite match loading around a lane-based scheduler that tracks each navigation, preload, and background reload as an ordered unit of work. This fixes pending/redirect/retry state leaking between overlapping navigations, restores correct SSR status codes for redirects, errors, and not-found responses, and closes hydration gaps where the client re-ran work the server had already completed.
- Route `headers()` now only runs on the server, matching the documented behavior — it is no longer invoked during client-side asset projection.
- Default `gcTime` and `preloadGcTime` are reduced from 30 minutes (`1_800_000`) to 5 minutes (`300_000`).
+- Devtools packages are released alongside the router packages so they consume the rewritten router store interface.
**Removed / changed public API**
@@ -26,4 +32,4 @@ Rewrite match loading around a lane-based scheduler that tracks each navigation,
- Removed the exported `GetMatchFn` and `UpdateMatchFn` types, along with the methods they typed.
- Removed the standalone `getMatchedRoutes()` export from `@tanstack/router-core` — use the `router.getMatchedRoutes()` instance method instead.
- `MatchRoutesOpts.preload` and `MatchRoutesOpts.dest` have been removed.
-- `StartTransitionFn` is now `(fn, expected, urgent?) => Promise` (previously `(fn) => void`). This only affects custom framework adapters that implement `startTransition`.
+- `StartTransitionFn` is now `(fn, expected) => Promise` (previously `(fn) => void`). This only affects custom framework adapters that implement `startTransition`.
diff --git a/docs/router/api/router/RouterType.md b/docs/router/api/router/RouterType.md
index 0252eb5b80..9c659fa02c 100644
--- a/docs/router/api/router/RouterType.md
+++ b/docs/router/api/router/RouterType.md
@@ -134,8 +134,8 @@ protocol.
- Type: `(opts?: {filter?: (d: MakeRouteMatchUnion) => boolean, sync?: boolean, forcePending?: boolean }) => Promise`
- This is useful any time your loader data might be out of date or stale. For example, if you have a route that displays a list of posts, and you have a loader function that fetches the list of posts from an API, you might want to invalidate the route matches for that route any time a new post is created so that the list of posts is always up-to-date.
-- If `filter` is not supplied, all committed and cached match generations are invalidated.
-- If `filter` is supplied, it is evaluated against committed and cached matches. Selecting one generation invalidates every committed or cached generation with the same match ID.
+- If `filter` is not supplied, all committed and cached match generations are invalidated and all active preloads are canceled.
+- If `filter` is supplied, it is evaluated against the available committed, cached, loading, and preloading matches. Selecting one match invalidates every generation with the same match ID. An active preload is canceled when its lane contains a selected match ID.
- Invalidation reruns `beforeLoad`; reusable loader data is marked stale and reloads through the normal loading protocol. Route-level `context` remains reusable while the match ID is unchanged.
- If `sync` is `true`, stale loader work is blocking and the returned promise resolves after it finishes instead of leaving a background refresh detached.
- If `forcePending` is `true`, selected routes that need loading enter the normal pending protocol even when successful data was already available.
@@ -169,9 +169,8 @@ presentation. Successful loader data can enter the normal in-memory route cache
and remain reusable according to `preloadStaleTime` and `preloadGcTime`.
Completed preload `beforeLoad` context is not cached. A later navigation reruns
-`beforeLoad` unless it adopts an identical whole-route preload that is still
-active. Adoption also requires unchanged router context, additional context,
-and user-supplied location state.
+`beforeLoad` with navigation semantics. It can still join a pending loader for
+the same match ID or reuse completed cached loader data.
- Type: `(opts: NavigateOptions) => Promise`
- Properties
diff --git a/docs/router/guide/preloading.md b/docs/router/guide/preloading.md
index 669a11175d..1b13b438b6 100644
--- a/docs/router/guide/preloading.md
+++ b/docs/router/guide/preloading.md
@@ -26,9 +26,8 @@ two independent lifetimes:
- **Unused retention defaults to 5 minutes.** Configure it with
`defaultPreloadGcTime` or a route's `preloadGcTime`.
- **The speculative lane is never promoted into router state.** Navigation
- creates its own presentation and can reuse cached loader data. It reruns
- `beforeLoad` unless it adopts the same whole lane while that preload is still
- active.
+ creates its own presentation and reruns `beforeLoad`, but it can reuse cached
+ loader data or join a loader for the same match that is still pending.
If you need more control over preloading, caching and/or garbage collection of preloaded data, you should use an external caching library like [TanStack Query](https://tanstack.com/query).
@@ -145,9 +144,7 @@ export const Route = createFileRoute('/posts/$postId')({
Client-side preloading also runs each new route's `beforeLoad` with `preload: true`. A completed preload never caches the context returned by `beforeLoad`; a later navigation calls `beforeLoad` again with `preload: false`, even when it reuses the preload's cached loader data.
-There is one exception: if navigation starts while an identical whole-route preload is still running, it can adopt that active work. Compatibility includes the complete ordered route lane, params, search, router context, additional context, user-supplied location state, redirect depth, and any explicit mask's href, search, state, and `unmaskOnReload` value. Router-managed history and temporary-mask keys are ignored. In that case navigation can use the active preload's `beforeLoad` result, or follow its redirect, without calling `beforeLoad` again. A completed, failed, not-found, or canceled preload does not donate `beforeLoad` context to a later navigation. The `shouldReload` option remains loader-only.
-
-If any route in the lane has `preload: false`, navigation does not adopt the active preload. It reruns the `beforeLoad` chain and performs the loader work that speculation skipped.
+A navigation never adopts speculative `beforeLoad` context or control flow. If a loader for the same match ID is already pending, navigation can join that loader promise after running its own `beforeLoad`. The joined promise shares its loader outcome, including an error, not-found result, or redirect. Cancellation remains lane-local: canceling one waiter releases its lease without canceling a generation that another lane still owns. Only successful loader data persists in the loader cache; after a failed preload loader has settled, a later navigation runs a new generation. Invalidation or a `shouldReload` decision can also require a new generation. A route with `preload: false` skips its speculative loader work and loads normally during navigation.
## Preloading with External Libraries
diff --git a/e2e/react-router/basic-file-based-code-splitting/src/routeTree.gen.ts b/e2e/react-router/basic-file-based-code-splitting/src/routeTree.gen.ts
index c7a15cd84e..613c5a7dae 100644
--- a/e2e/react-router/basic-file-based-code-splitting/src/routeTree.gen.ts
+++ b/e2e/react-router/basic-file-based-code-splitting/src/routeTree.gen.ts
@@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
import { Route as LayoutRouteImport } from './routes/_layout'
import { Route as PostsRouteImport } from './routes/posts'
+import { Route as PreloadDisabledRouteImport } from './routes/preload-disabled'
import { Route as SharedSingletonRouteImport } from './routes/shared-singleton'
import { Route as ViewportTestRouteImport } from './routes/viewport-test'
import { Route as WithoutLoaderRouteImport } from './routes/without-loader'
@@ -35,6 +36,11 @@ const PostsRoute = PostsRouteImport.update({
path: '/posts',
getParentRoute: () => rootRouteImport,
} as any)
+const PreloadDisabledRoute = PreloadDisabledRouteImport.update({
+ id: '/preload-disabled',
+ path: '/preload-disabled',
+ getParentRoute: () => rootRouteImport,
+} as any)
const SharedSingletonRoute = SharedSingletonRouteImport.update({
id: '/shared-singleton',
path: '/shared-singleton',
@@ -78,6 +84,7 @@ const LayoutLayout2LayoutBRoute = LayoutLayout2LayoutBRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/posts': typeof PostsRouteWithChildren
+ '/preload-disabled': typeof PreloadDisabledRoute
'/shared-singleton': typeof SharedSingletonRoute
'/viewport-test': typeof ViewportTestRoute
'/without-loader': typeof WithoutLoaderRoute
@@ -88,6 +95,7 @@ export interface FileRoutesByFullPath {
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
+ '/preload-disabled': typeof PreloadDisabledRoute
'/shared-singleton': typeof SharedSingletonRoute
'/viewport-test': typeof ViewportTestRoute
'/without-loader': typeof WithoutLoaderRoute
@@ -101,6 +109,7 @@ export interface FileRoutesById {
'/': typeof IndexRoute
'/_layout': typeof LayoutRouteWithChildren
'/posts': typeof PostsRouteWithChildren
+ '/preload-disabled': typeof PreloadDisabledRoute
'/shared-singleton': typeof SharedSingletonRoute
'/viewport-test': typeof ViewportTestRoute
'/without-loader': typeof WithoutLoaderRoute
@@ -115,6 +124,7 @@ export interface FileRouteTypes {
fullPaths:
| '/'
| '/posts'
+ | '/preload-disabled'
| '/shared-singleton'
| '/viewport-test'
| '/without-loader'
@@ -125,6 +135,7 @@ export interface FileRouteTypes {
fileRoutesByTo: FileRoutesByTo
to:
| '/'
+ | '/preload-disabled'
| '/shared-singleton'
| '/viewport-test'
| '/without-loader'
@@ -137,6 +148,7 @@ export interface FileRouteTypes {
| '/'
| '/_layout'
| '/posts'
+ | '/preload-disabled'
| '/shared-singleton'
| '/viewport-test'
| '/without-loader'
@@ -151,6 +163,7 @@ export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
LayoutRoute: typeof LayoutRouteWithChildren
PostsRoute: typeof PostsRouteWithChildren
+ PreloadDisabledRoute: typeof PreloadDisabledRoute
SharedSingletonRoute: typeof SharedSingletonRoute
ViewportTestRoute: typeof ViewportTestRoute
WithoutLoaderRoute: typeof WithoutLoaderRoute
@@ -179,6 +192,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PostsRouteImport
parentRoute: typeof rootRouteImport
}
+ '/preload-disabled': {
+ id: '/preload-disabled'
+ path: '/preload-disabled'
+ fullPath: '/preload-disabled'
+ preLoaderRoute: typeof PreloadDisabledRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/shared-singleton': {
id: '/shared-singleton'
path: '/shared-singleton'
@@ -279,6 +299,7 @@ const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
LayoutRoute: LayoutRouteWithChildren,
PostsRoute: PostsRouteWithChildren,
+ PreloadDisabledRoute: PreloadDisabledRoute,
SharedSingletonRoute: SharedSingletonRoute,
ViewportTestRoute: ViewportTestRoute,
WithoutLoaderRoute: WithoutLoaderRoute,
diff --git a/e2e/react-router/basic-file-based-code-splitting/src/routes/__root.tsx b/e2e/react-router/basic-file-based-code-splitting/src/routes/__root.tsx
index cb2a94ee19..f42053dad0 100644
--- a/e2e/react-router/basic-file-based-code-splitting/src/routes/__root.tsx
+++ b/e2e/react-router/basic-file-based-code-splitting/src/routes/__root.tsx
@@ -51,6 +51,7 @@ function RootComponent() {
>
without-loader
{' '}
+ preload-disabled{' '}
preload disabled
,
+})
diff --git a/e2e/react-router/basic-file-based-code-splitting/tests/preload.spec.ts b/e2e/react-router/basic-file-based-code-splitting/tests/preload.spec.ts
index 0bc3265760..96f4d834bf 100644
--- a/e2e/react-router/basic-file-based-code-splitting/tests/preload.spec.ts
+++ b/e2e/react-router/basic-file-based-code-splitting/tests/preload.spec.ts
@@ -40,3 +40,28 @@ test('scrolling into viewport a link with preload=viewport to a route should pre
: '/assets/viewport-test'
expect(request.url()).toEqual(expect.stringContaining(expectedString))
})
+
+test('preload: false skips an auto-code-split component chunk until navigation', async ({
+ page,
+}) => {
+ await page.waitForLoadState('networkidle')
+ const expectedString =
+ process.env.NODE_ENV === 'development'
+ ? 'preload-disabled.tsx?tsr-split'
+ : '/assets/preload-disabled'
+ const matchingRequests: Array = []
+ page.on('request', (request) => {
+ if (request.url().includes(expectedString)) {
+ matchingRequests.push(request.url())
+ }
+ })
+
+ const link = page.getByRole('link', { name: 'preload-disabled' })
+ await link.hover()
+ await page.waitForTimeout(300)
+ expect(matchingRequests).toEqual([])
+
+ await link.click()
+ await expect(page.getByTestId('preload-disabled')).toBeVisible()
+ expect(matchingRequests.length).toBeGreaterThan(0)
+})
diff --git a/e2e/react-router/basic-file-based/src/main.tsx b/e2e/react-router/basic-file-based/src/main.tsx
index 12e202dae4..a35a10673e 100644
--- a/e2e/react-router/basic-file-based/src/main.tsx
+++ b/e2e/react-router/basic-file-based/src/main.tsx
@@ -24,8 +24,12 @@ const router = createRouter({
defaultStaleTime: 5000,
scrollRestoration: true,
routeMasks: [mask],
+ unmaskOnReload: true,
})
+// Test hook for browser-history integration tests in this fixture.
+;(window as any).__TSR_TEST_ROUTER__ = router
+
// Register things for typesafety
declare module '@tanstack/react-router' {
interface Register {
diff --git a/e2e/react-router/basic-file-based/tests/mask.spec.ts b/e2e/react-router/basic-file-based/tests/mask.spec.ts
index 1b77760de6..2af13beed2 100644
--- a/e2e/react-router/basic-file-based/tests/mask.spec.ts
+++ b/e2e/react-router/basic-file-based/tests/mask.spec.ts
@@ -1,4 +1,17 @@
import { expect, test } from '@playwright/test'
+import type { Page } from '@playwright/test'
+
+async function readLocation(page: Page) {
+ return page.evaluate(() => {
+ const location = (window as any).__TSR_TEST_ROUTER__.state.location
+ return {
+ pathname: location.pathname,
+ maskedPathname: location.maskedLocation?.pathname,
+ maskedSearch: location.maskedLocation?.search,
+ maskedStateSource: location.maskedLocation?.state?.source,
+ }
+ })
+}
test('route masks transform params and expose masked pathname in the browser (react)', async ({
page,
@@ -24,3 +37,55 @@ test('route masks transform params and expose masked pathname in the browser (re
'/masks/public/user-42',
)
})
+
+test('native history restores a reversible mask across traversal and reload', async ({
+ page,
+}) => {
+ await page.goto('/masks')
+
+ await page.evaluate(async () => {
+ await (window as any).__TSR_TEST_ROUTER__.navigate({
+ to: '/masks/admin/$userId',
+ params: { userId: '42' },
+ mask: {
+ to: '/masks/public/$username',
+ params: { username: 'user-42' },
+ search: { source: 'reversible' },
+ state: { source: 'state' },
+ unmaskOnReload: false,
+ },
+ })
+ })
+
+ await page.waitForURL('/masks/public/user-42?source=reversible')
+ await expect(page.getByTestId('admin-user-id')).toHaveText('42')
+ expect(await readLocation(page)).toEqual({
+ pathname: '/masks/admin/42',
+ maskedPathname: '/masks/public/user-42',
+ maskedSearch: { source: 'reversible' },
+ maskedStateSource: 'state',
+ })
+
+ await page.evaluate(async () => {
+ await (window as any).__TSR_TEST_ROUTER__.navigate({ to: '/masks' })
+ })
+ await page.waitForURL('/masks')
+ await page.goBack()
+ await expect(page.getByTestId('admin-user-id')).toHaveText('42')
+ expect(await readLocation(page)).toMatchObject({
+ pathname: '/masks/admin/42',
+ maskedPathname: '/masks/public/user-42',
+ maskedSearch: { source: 'reversible' },
+ maskedStateSource: 'state',
+ })
+
+ await page.reload()
+ await expect(page).toHaveURL('/masks/public/user-42?source=reversible')
+ await expect(page.getByTestId('admin-user-id')).toHaveText('42')
+ expect(await readLocation(page)).toMatchObject({
+ pathname: '/masks/admin/42',
+ maskedPathname: '/masks/public/user-42',
+ maskedSearch: { source: 'reversible' },
+ maskedStateSource: 'state',
+ })
+})
diff --git a/e2e/react-router/issue-7120/src/routeTree.gen.ts b/e2e/react-router/issue-7120/src/routeTree.gen.ts
index fc671589c7..e8c6d92110 100644
--- a/e2e/react-router/issue-7120/src/routeTree.gen.ts
+++ b/e2e/react-router/issue-7120/src/routeTree.gen.ts
@@ -9,19 +9,19 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
-import { Route as PostsRouteImport } from './routes/posts'
import { Route as IndexRouteImport } from './routes/index'
+import { Route as PostsRouteImport } from './routes/posts'
-const PostsRoute = PostsRouteImport.update({
- id: '/posts',
- path: '/posts',
- getParentRoute: () => rootRouteImport,
-} as any).lazy(() => import('./routes/posts.lazy').then((d) => d.Route))
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
+const PostsRoute = PostsRouteImport.update({
+ id: '/posts',
+ path: '/posts',
+ getParentRoute: () => rootRouteImport,
+} as any).lazy(() => import('./routes/posts.lazy').then((d) => d.Route))
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
@@ -51,13 +51,6 @@ export interface RootRouteChildren {
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
- '/posts': {
- id: '/posts'
- path: '/posts'
- fullPath: '/posts'
- preLoaderRoute: typeof PostsRouteImport
- parentRoute: typeof rootRouteImport
- }
'/': {
id: '/'
path: '/'
@@ -65,6 +58,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
+ '/posts': {
+ id: '/posts'
+ path: '/posts'
+ fullPath: '/posts'
+ preLoaderRoute: typeof PostsRouteImport
+ parentRoute: typeof rootRouteImport
+ }
}
}
diff --git a/e2e/react-router/issue-7457/src/routeTree.gen.ts b/e2e/react-router/issue-7457/src/routeTree.gen.ts
index 72a8f23384..04a2c94ce8 100644
--- a/e2e/react-router/issue-7457/src/routeTree.gen.ts
+++ b/e2e/react-router/issue-7457/src/routeTree.gen.ts
@@ -9,19 +9,19 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
-import { Route as AnotherRouteImport } from './routes/another'
import { Route as IndexRouteImport } from './routes/index'
+import { Route as AnotherRouteImport } from './routes/another'
-const AnotherRoute = AnotherRouteImport.update({
- id: '/another',
- path: '/another',
- getParentRoute: () => rootRouteImport,
-} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
+const AnotherRoute = AnotherRouteImport.update({
+ id: '/another',
+ path: '/another',
+ getParentRoute: () => rootRouteImport,
+} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
@@ -51,13 +51,6 @@ export interface RootRouteChildren {
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
- '/another': {
- id: '/another'
- path: '/another'
- fullPath: '/another'
- preLoaderRoute: typeof AnotherRouteImport
- parentRoute: typeof rootRouteImport
- }
'/': {
id: '/'
path: '/'
@@ -65,6 +58,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
+ '/another': {
+ id: '/another'
+ path: '/another'
+ fullPath: '/another'
+ preLoaderRoute: typeof AnotherRouteImport
+ parentRoute: typeof rootRouteImport
+ }
}
}
diff --git a/e2e/react-start/basic/src/routeTree.gen.ts b/e2e/react-start/basic/src/routeTree.gen.ts
index ea09864d8e..27b10415d7 100644
--- a/e2e/react-start/basic/src/routeTree.gen.ts
+++ b/e2e/react-start/basic/src/routeTree.gen.ts
@@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
import { Route as LayoutRouteImport } from './routes/_layout'
import { Route as AsyncScriptsRouteImport } from './routes/async-scripts'
+import { Route as ClientContextFailureRouteImport } from './routes/client-context-failure'
import { Route as ClientOnlyRouteImport } from './routes/client-only'
import { Route as DeferredRouteImport } from './routes/deferred'
import { Route as InlineScriptsRouteImport } from './routes/inline-scripts'
@@ -96,6 +97,11 @@ const AsyncScriptsRoute = AsyncScriptsRouteImport.update({
path: '/async-scripts',
getParentRoute: () => rootRouteImport,
} as any)
+const ClientContextFailureRoute = ClientContextFailureRouteImport.update({
+ id: '/client-context-failure',
+ path: '/client-context-failure',
+ getParentRoute: () => rootRouteImport,
+} as any)
const ClientOnlyRoute = ClientOnlyRouteImport.update({
id: '/client-only',
path: '/client-only',
@@ -464,6 +470,7 @@ export interface FileRoutesByFullPath {
'/search-params': typeof SearchParamsRouteRouteWithChildren
'/specialChars': typeof SpecialCharsRouteRouteWithChildren
'/async-scripts': typeof AsyncScriptsRoute
+ '/client-context-failure': typeof ClientContextFailureRoute
'/client-only': typeof ClientOnlyRoute
'/deferred': typeof DeferredRoute
'/inline-scripts': typeof InlineScriptsRoute
@@ -534,6 +541,7 @@ export interface FileRoutesByTo {
'/': typeof IndexRoute
'/specialChars': typeof SpecialCharsRouteRouteWithChildren
'/async-scripts': typeof AsyncScriptsRoute
+ '/client-context-failure': typeof ClientContextFailureRoute
'/client-only': typeof ClientOnlyRoute
'/deferred': typeof DeferredRoute
'/inline-scripts': typeof InlineScriptsRoute
@@ -601,6 +609,7 @@ export interface FileRoutesById {
'/specialChars': typeof SpecialCharsRouteRouteWithChildren
'/_layout': typeof LayoutRouteWithChildren
'/async-scripts': typeof AsyncScriptsRoute
+ '/client-context-failure': typeof ClientContextFailureRoute
'/client-only': typeof ClientOnlyRoute
'/deferred': typeof DeferredRoute
'/inline-scripts': typeof InlineScriptsRoute
@@ -676,6 +685,7 @@ export interface FileRouteTypes {
| '/search-params'
| '/specialChars'
| '/async-scripts'
+ | '/client-context-failure'
| '/client-only'
| '/deferred'
| '/inline-scripts'
@@ -746,6 +756,7 @@ export interface FileRouteTypes {
| '/'
| '/specialChars'
| '/async-scripts'
+ | '/client-context-failure'
| '/client-only'
| '/deferred'
| '/inline-scripts'
@@ -812,6 +823,7 @@ export interface FileRouteTypes {
| '/specialChars'
| '/_layout'
| '/async-scripts'
+ | '/client-context-failure'
| '/client-only'
| '/deferred'
| '/inline-scripts'
@@ -887,6 +899,7 @@ export interface RootRouteChildren {
SpecialCharsRouteRoute: typeof SpecialCharsRouteRouteWithChildren
LayoutRoute: typeof LayoutRouteWithChildren
AsyncScriptsRoute: typeof AsyncScriptsRoute
+ ClientContextFailureRoute: typeof ClientContextFailureRoute
ClientOnlyRoute: typeof ClientOnlyRoute
DeferredRoute: typeof DeferredRoute
InlineScriptsRoute: typeof InlineScriptsRoute
@@ -933,6 +946,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AsyncScriptsRouteImport
parentRoute: typeof rootRouteImport
}
+ '/client-context-failure': {
+ id: '/client-context-failure'
+ path: '/client-context-failure'
+ fullPath: '/client-context-failure'
+ preLoaderRoute: typeof ClientContextFailureRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/client-only': {
id: '/client-only'
path: '/client-only'
@@ -1675,6 +1695,7 @@ const rootRouteChildren: RootRouteChildren = {
SpecialCharsRouteRoute: SpecialCharsRouteRouteWithChildren,
LayoutRoute: LayoutRouteWithChildren,
AsyncScriptsRoute: AsyncScriptsRoute,
+ ClientContextFailureRoute: ClientContextFailureRoute,
ClientOnlyRoute: ClientOnlyRoute,
DeferredRoute: DeferredRoute,
InlineScriptsRoute: InlineScriptsRoute,
diff --git a/e2e/react-start/basic/src/routes/client-context-failure.tsx b/e2e/react-start/basic/src/routes/client-context-failure.tsx
new file mode 100644
index 0000000000..412cc00e48
--- /dev/null
+++ b/e2e/react-start/basic/src/routes/client-context-failure.tsx
@@ -0,0 +1,23 @@
+import { createFileRoute } from '@tanstack/react-router'
+
+export const Route = createFileRoute('/client-context-failure')({
+ context: () => {
+ if (typeof window !== 'undefined') {
+ throw new Error('Client context reconstruction failed')
+ }
+ return { serverContext: true }
+ },
+ component: ClientContextSuccess,
+ errorComponent: ({ error }) => (
+ {error.message}
+ ),
+})
+
+function ClientContextSuccess() {
+ if (typeof window !== 'undefined') {
+ const testWindow = window as any
+ testWindow.__clientContextSuccessRenders =
+ (testWindow.__clientContextSuccessRenders ?? 0) + 1
+ }
+ return server success
+}
diff --git a/e2e/react-start/basic/tests/client-context-failure.spec.ts b/e2e/react-start/basic/tests/client-context-failure.spec.ts
new file mode 100644
index 0000000000..feb36ed1bd
--- /dev/null
+++ b/e2e/react-start/basic/tests/client-context-failure.spec.ts
@@ -0,0 +1,29 @@
+import { expect, test } from '@playwright/test'
+
+test('client context failure never hydrates transported success', async ({
+ page,
+ request,
+ baseURL,
+}) => {
+ const response = await request.get(`${baseURL}/client-context-failure`)
+ expect(response.ok()).toBe(true)
+ const serverHtml = await response.text()
+ expect(serverHtml).toContain('data-testid="client-context-success"')
+ expect(serverHtml).toContain('>server success')
+
+ const pageErrors: Array = []
+ page.on('pageerror', (error) => pageErrors.push(error))
+ await page.addInitScript(() => {
+ ;(window as any).__clientContextSuccessRenders = 0
+ })
+ await page.goto('/client-context-failure')
+
+ await expect(page.getByTestId('client-context-error')).toHaveText(
+ 'Client context reconstruction failed',
+ )
+ await expect(page.getByTestId('client-context-success')).toHaveCount(0)
+ expect(
+ await page.evaluate(() => (window as any).__clientContextSuccessRenders),
+ ).toBe(0)
+ expect(pageErrors).toEqual([])
+})
diff --git a/e2e/solid-start/selective-ssr/src/routeTree.gen.ts b/e2e/solid-start/selective-ssr/src/routeTree.gen.ts
index 8fb128de38..d0ea8db3a2 100644
--- a/e2e/solid-start/selective-ssr/src/routeTree.gen.ts
+++ b/e2e/solid-start/selective-ssr/src/routeTree.gen.ts
@@ -9,20 +9,15 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
-import { Route as SsrFalsePendingMinRouteImport } from './routes/ssr-false-pending-min'
-import { Route as PostsRouteImport } from './routes/posts'
-import { Route as DataOnlyPendingComponentRouteImport } from './routes/data-only-pending-component'
import { Route as IndexRouteImport } from './routes/index'
+import { Route as DataOnlyPendingComponentRouteImport } from './routes/data-only-pending-component'
+import { Route as PostsRouteImport } from './routes/posts'
+import { Route as SsrFalsePendingMinRouteImport } from './routes/ssr-false-pending-min'
import { Route as PostsPostIdRouteImport } from './routes/posts.$postId'
-const SsrFalsePendingMinRoute = SsrFalsePendingMinRouteImport.update({
- id: '/ssr-false-pending-min',
- path: '/ssr-false-pending-min',
- getParentRoute: () => rootRouteImport,
-} as any)
-const PostsRoute = PostsRouteImport.update({
- id: '/posts',
- path: '/posts',
+const IndexRoute = IndexRouteImport.update({
+ id: '/',
+ path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const DataOnlyPendingComponentRoute =
@@ -31,9 +26,14 @@ const DataOnlyPendingComponentRoute =
path: '/data-only-pending-component',
getParentRoute: () => rootRouteImport,
} as any)
-const IndexRoute = IndexRouteImport.update({
- id: '/',
- path: '/',
+const PostsRoute = PostsRouteImport.update({
+ id: '/posts',
+ path: '/posts',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const SsrFalsePendingMinRoute = SsrFalsePendingMinRouteImport.update({
+ id: '/ssr-false-pending-min',
+ path: '/ssr-false-pending-min',
getParentRoute: () => rootRouteImport,
} as any)
const PostsPostIdRoute = PostsPostIdRouteImport.update({
@@ -97,18 +97,11 @@ export interface RootRouteChildren {
declare module '@tanstack/solid-router' {
interface FileRoutesByPath {
- '/ssr-false-pending-min': {
- id: '/ssr-false-pending-min'
- path: '/ssr-false-pending-min'
- fullPath: '/ssr-false-pending-min'
- preLoaderRoute: typeof SsrFalsePendingMinRouteImport
- parentRoute: typeof rootRouteImport
- }
- '/posts': {
- id: '/posts'
- path: '/posts'
- fullPath: '/posts'
- preLoaderRoute: typeof PostsRouteImport
+ '/': {
+ id: '/'
+ path: '/'
+ fullPath: '/'
+ preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/data-only-pending-component': {
@@ -118,11 +111,18 @@ declare module '@tanstack/solid-router' {
preLoaderRoute: typeof DataOnlyPendingComponentRouteImport
parentRoute: typeof rootRouteImport
}
- '/': {
- id: '/'
- path: '/'
- fullPath: '/'
- preLoaderRoute: typeof IndexRouteImport
+ '/posts': {
+ id: '/posts'
+ path: '/posts'
+ fullPath: '/posts'
+ preLoaderRoute: typeof PostsRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/ssr-false-pending-min': {
+ id: '/ssr-false-pending-min'
+ path: '/ssr-false-pending-min'
+ fullPath: '/ssr-false-pending-min'
+ preLoaderRoute: typeof SsrFalsePendingMinRouteImport
parentRoute: typeof rootRouteImport
}
'/posts/$postId': {
diff --git a/packages/react-router/src/Matches.tsx b/packages/react-router/src/Matches.tsx
index d9977db272..a555848535 100644
--- a/packages/react-router/src/Matches.tsx
+++ b/packages/react-router/src/Matches.tsx
@@ -8,10 +8,11 @@ import { CatchBoundary } from './CatchBoundary'
import { useRouter } from './useRouter'
import { useStructuralSharing } from './useMatch'
import { useLayoutEffect } from './utils'
-import { Transitioner } from './Transitioner'
+import { Transitioner, clearOwner } from './Transitioner'
import { matchContext } from './matchContext'
import { Match, renderPending } from './Match'
import { SafeFragment } from './SafeFragment'
+import type { ReactRenderOwner } from './Transitioner'
import type {
StructuralSharingOption,
ValidateSelected,
@@ -47,6 +48,9 @@ declare module '@tanstack/router-core' {
*/
export function Matches() {
const router = useRouter()
+ // Stable render receipt: Transitioner offers a generation and MatchesInner
+ // acknowledges that exact array after it commits.
+ const [owner] = React.useState([])
const rootRoute: AnyRoute = router.routesById[rootRouteId]
const pendingElement = renderPending(router, rootRoute)
@@ -57,9 +61,9 @@ export function Matches() {
const inner = (
<>
- {!(isServer ?? router.isServer) && }
+ {!(isServer ?? router.isServer) && }
-
+
>
)
@@ -71,19 +75,24 @@ export function Matches() {
)
}
-function MatchesInner() {
+function MatchesInner({ owner }: { owner: ReactRenderOwner }) {
const router = useRouter()
- const matches =
+ const presentation =
(isServer ?? router.isServer)
? router.stores.matches.get()
: // eslint-disable-next-line react-hooks/rules-of-hooks
- useStore(router.stores.matches, (value) => value)
- const match = matches[0]
+ useStore(
+ router.stores.ids,
+ () => owner[0] ?? router.stores.matches.get(),
+ )
+ const match = presentation[0]
const routeId = match?.routeId
useLayoutEffect(() => {
- router._rendered!(matches)
- }, [matches, router])
+ if (owner[0] === presentation) {
+ clearOwner(owner)?.(true)
+ }
+ }, [owner, presentation])
const matchComponent = routeId ? : null
diff --git a/packages/react-router/src/Transitioner.tsx b/packages/react-router/src/Transitioner.tsx
index acbb60b5bf..e65d76012e 100644
--- a/packages/react-router/src/Transitioner.tsx
+++ b/packages/react-router/src/Transitioner.tsx
@@ -6,137 +6,130 @@ import { useLayoutEffect } from './utils'
import { useRouter } from './useRouter'
import type { AnyRouteMatch } from '@tanstack/router-core'
-export function Transitioner() {
- const router = useRouter()
- const acknowledgement = React.useRef<
- [Array, (rendered: boolean) => void] | undefined
- >(undefined)
- const mounted =
- process.env.NODE_ENV !== 'production'
- ? // eslint-disable-next-line react-hooks/rules-of-hooks
- React.useRef(false)
- : undefined
+export type ReactRenderOwner = [
+ offered?: Array,
+ settle?: (rendered: boolean) => void,
+ active?: boolean,
+]
- // `` 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)
- }
+export function clearOwner(
+ owner: ReactRenderOwner,
+ current = owner[1],
+): typeof current {
+ if (owner[1] === current) {
+ owner[0] = owner[1] = undefined
+ return current
}
- if (process.env.NODE_ENV === 'production') {
- router.startTransition = (fn, expected, urgent) =>
- new Promise((resolve) => {
- acknowledgement.current?.[1](false)
- acknowledgement.current = [expected, resolve]
- if (urgent) {
- fn()
- } else {
+ return undefined
+}
+
+export function Transitioner({ owner }: { owner: ReactRenderOwner }) {
+ const router = useRouter()
+
+ // Subscribe before canonicalizing so the initial URL has exactly one load.
+ useLayoutEffect(() => {
+ // Suspense and Activity can reconnect effects without rendering again.
+ const initialized = owner[2] !== undefined
+ owner[2] = true
+ router.startTransition = async (fn, expected) => {
+ if (!owner[2]) {
+ fn()
+ return false
+ }
+
+ return new Promise((resolve, reject) => {
+ clearOwner(owner)?.(false)
+ owner[0] = expected
+ owner[1] = resolve
+ if (process.env.NODE_ENV === 'production') {
React.startTransition(fn)
+ } else {
+ // React captures transition-action errors, so HMR must catch inside it.
+ React.startTransition(() => {
+ try {
+ fn()
+ } catch (cause) {
+ clearOwner(owner, resolve)
+ reject(cause)
+ }
+ })
}
})
- } else {
- router.startTransition = (fn, expected, urgent) =>
- new Promise((resolve, reject) => {
- acknowledgement.current?.[1](false)
- const next: NonNullable = [
- expected,
- resolve,
- ]
- acknowledgement.current = next
- try {
- if (urgent) {
- fn()
- } else {
- React.startTransition(fn)
- }
- } catch (cause) {
- if (acknowledgement.current === next) {
- acknowledgement.current = undefined
- }
- reject(cause)
- }
- })
+ }
;(
router as typeof router & { _cancelTransition?: () => void }
- )._cancelTransition = () => {
- const current = acknowledgement.current
- acknowledgement.current = undefined
- current?.[1](false)
- }
- }
-
- // Subscribe before canonicalizing so the initial URL has exactly one load.
- useLayoutEffect(() => {
+ )._cancelTransition = () => clearOwner(owner)?.(false)
const unsub = router.history.subscribe(router.load)
- if (mounted?.current) {
- return unsub
- }
- if (mounted) {
- mounted.current = true
- }
-
- router.updateLatestLocation()
- const location = router.latestLocation
- const nextLocation = router.buildLocation({
- to: location.pathname,
- search: true,
- params: true,
- hash: true,
- state: true,
- _includeValidateSearch: true,
- })
-
- // Check if the current URL matches the canonical form.
- // Compare publicHref (browser-facing URL) consistently with server
- // canonicalization.
if (
- trimPathRight(location.publicHref) !==
- trimPathRight(nextLocation.publicHref)
+ !initialized ||
+ router.history.location.state.__TSR_key !==
+ router.latestLocation.state.__TSR_key
) {
- router.commitLocation({
- ...nextLocation,
- replace: true,
- ignoreBlocker: true,
+ router.updateLatestLocation()
+ const location = router.latestLocation
+ const nextLocation = router.buildLocation({
+ to: location.pathname,
+ search: true,
+ params: true,
+ hash: true,
+ state: true,
+ _includeValidateSearch: true,
})
- return unsub
- }
- const resolvedLocation = router.stores.resolvedLocation.get()
- if (
- 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),
- })
+ // Check if the current URL matches the canonical form.
+ // Compare publicHref (browser-facing URL) consistently with server
+ // canonicalization.
+ if (
+ trimPathRight(location.publicHref) !==
+ trimPathRight(nextLocation.publicHref)
+ ) {
+ router.commitLocation({
+ ...nextLocation,
+ replace: true,
+ ignoreBlocker: true,
+ })
+ } else {
+ const resolvedLocation = router.stores.resolvedLocation.get()
+ if (
+ resolvedLocation?.href === location.href &&
+ resolvedLocation.state.__TSR_key === location.state.__TSR_key
+ ) {
+ if (!owner[1]) {
+ owner[0] = router.stores.matches.get()
+ owner[1] = (rendered) => {
+ if (rendered) {
+ router.emit({
+ type: 'onRendered',
+ ...getLocationChangeInfo(resolvedLocation, resolvedLocation),
+ })
+ }
+ }
}
- },
- ]
- } else if (!router._tx) {
- router.load().catch(console.error)
+ } else if (initialized || !router._tx) {
+ router.load().catch(console.error)
+ }
+ }
}
- return unsub
- // `mounted` exists only in development and is a stable ref when present.
+ return () => {
+ unsub()
+ owner[2] = false
+ if (process.env.NODE_ENV === 'production') {
+ clearOwner(owner)?.(false)
+ } else {
+ const current = owner[1]
+ // StrictMode replays effects without another render. Its second setup
+ // reactivates this owner before the microtask; a real unmount does not.
+ queueMicrotask(() => {
+ if (!owner[2]) {
+ clearOwner(owner, current)?.(false)
+ }
+ })
+ }
+ }
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [router, router.history])
+ }, [owner, router, router.history])
return null
}
diff --git a/packages/react-router/src/headContentUtils.tsx b/packages/react-router/src/headContentUtils.tsx
index d61ca829db..2c91ba7e62 100644
--- a/packages/react-router/src/headContentUtils.tsx
+++ b/packages/react-router/src/headContentUtils.tsx
@@ -24,25 +24,24 @@ function buildTagsFromMatches(
assetCrossOrigin?: AssetCrossOriginConfig,
): Array {
matches = _getAssetMatches(matches)
- const routeMeta = matches
- .map((match) => match.meta)
- .filter((meta) => meta !== undefined)
-
const resultMeta: Array = []
const metaByAttribute: Record = {}
let title: RouterManagedTag | undefined
- for (let i = routeMeta.length - 1; i >= 0; i--) {
- const metas = routeMeta[i]!
+ for (let i = matches.length - 1; i >= 0; i--) {
+ const metas = matches[i]!.meta
+ if (!metas) {
+ continue
+ }
for (let j = metas.length - 1; j >= 0; j--) {
const m = metas[j]
- if (!m) continue
+ if (!m) {
+ continue
+ }
if (m.title) {
- if (!title) {
- title = {
- tag: 'title',
- children: m.title,
- }
+ title ??= {
+ tag: 'title',
+ children: m.title,
}
} else if ('script:ld+json' in m) {
try {
@@ -62,9 +61,8 @@ function buildTagsFromMatches(
if (attribute) {
if (metaByAttribute[attribute]) {
continue
- } else {
- metaByAttribute[attribute] = true
}
+ metaByAttribute[attribute] = true
}
resultMeta.push({
@@ -93,23 +91,24 @@ function buildTagsFromMatches(
}
resultMeta.reverse()
- const constructedLinks = matches
- .flatMap((match) => match.links ?? [])
- .filter((link) => link !== undefined)
- .map((link) => ({
- tag: 'link',
- attrs: {
- ...link,
- nonce,
- },
- })) satisfies Array
-
const manifest = router.ssr?.manifest
+ const constructedLinks: Array = []
const manifestCssTags: Array = []
- if (manifest) {
- matches.forEach((match) => {
- const css = manifest.routes[match.routeId]?.css
- css?.forEach((link) => {
+ const preloadLinks: Array = []
+ const styles: Array = []
+ const headScripts: Array = []
+ for (const match of matches) {
+ for (const link of match.links ?? []) {
+ if (link) {
+ constructedLinks.push({
+ tag: 'link',
+ attrs: { ...link, nonce },
+ })
+ }
+ }
+ const manifestRoute = manifest?.routes[match.routeId]
+ if (manifestRoute) {
+ for (const link of manifestRoute.css ?? []) {
const resolvedLink = resolveManifestCssLink(link)
manifestCssTags.push({
tag: 'link',
@@ -123,26 +122,8 @@ function buildTagsFromMatches(
nonce,
},
})
- })
- })
-
- if (manifest.inlineStyle) {
- manifestCssTags.push({
- tag: 'style',
- attrs: {
- ...manifest.inlineStyle.attrs,
- nonce,
- },
- children: manifest.inlineStyle.children,
- inlineCss: true,
- })
- }
- }
-
- const preloadLinks: Array = []
- if (manifest) {
- matches.forEach((match) => {
- manifest.routes[match.routeId]?.preloads?.forEach((preload) => {
+ }
+ for (const preload of manifestRoute.preloads ?? []) {
preloadLinks.push({
tag: 'link',
attrs: {
@@ -150,33 +131,41 @@ function buildTagsFromMatches(
nonce,
},
})
- })
- })
+ }
+ }
+ for (const style of match.styles ?? []) {
+ if (style) {
+ const { children, ...attrs } = style
+ styles.push({
+ tag: 'style',
+ attrs: { ...attrs, nonce },
+ children: children as string | undefined,
+ })
+ }
+ }
+ for (const script of match.headScripts ?? []) {
+ if (script) {
+ const { children, ...attrs } = script
+ headScripts.push({
+ tag: 'script',
+ attrs: { ...attrs, nonce },
+ children: children as string | undefined,
+ })
+ }
+ }
}
- const styles = matches
- .flatMap((match) => match.styles ?? [])
- .filter((style) => style !== undefined)
- .map(({ children, ...attrs }) => ({
+ if (manifest?.inlineStyle) {
+ manifestCssTags.push({
tag: 'style',
attrs: {
- ...attrs,
- nonce,
- },
- children: children as string | undefined,
- })) satisfies Array
-
- const headScripts = matches
- .flatMap((match) => match.headScripts ?? [])
- .filter((script) => script !== undefined)
- .map(({ children, ...script }) => ({
- tag: 'script',
- attrs: {
- ...script,
+ ...manifest.inlineStyle.attrs,
nonce,
},
- children: children as string | undefined,
- })) satisfies Array
+ children: manifest.inlineStyle.children,
+ inlineCss: true,
+ })
+ }
const tags: Array = []
appendUniqueUserTags(tags, resultMeta)
diff --git a/packages/react-router/src/lazyRouteComponent.tsx b/packages/react-router/src/lazyRouteComponent.tsx
index 7c387e0e31..d9352ce763 100644
--- a/packages/react-router/src/lazyRouteComponent.tsx
+++ b/packages/react-router/src/lazyRouteComponent.tsx
@@ -22,31 +22,32 @@ export function lazyRouteComponent<
): T[TKey] extends (props: infer TProps) => any
? AsyncRouteComponent
: never {
- let loadPromise: Promise | undefined
+ let loadPromise: Promise | undefined
let comp: T[TKey] | T['default']
let error: any
const load = () => {
- if (!loadPromise) {
- error = undefined
- loadPromise = importer()
- .then((res) => {
- // Keep browser preload behavior unchanged; SSR can reuse the import.
- if (!(isServer ?? typeof window === 'undefined')) {
- loadPromise = undefined
- }
- comp = res[exportName ?? 'default']
- })
- .catch((err) => {
- loadPromise = undefined
- // We don't want an error thrown from preload in this case, because
- // there's nothing we want to do about module not found during preload.
- // Record the error, the rest is handled during the render path.
- error = err
- })
+ if (loadPromise) {
+ return loadPromise
}
- return loadPromise
+ error = undefined
+ return (loadPromise = importer()
+ .then((res) => {
+ // Keep browser preload behavior unchanged; SSR can reuse the import.
+ if (!(isServer ?? typeof window === 'undefined')) {
+ loadPromise = undefined
+ }
+ comp = res[exportName ?? 'default']
+ })
+ .catch((err) => {
+ loadPromise = undefined
+ error = err
+
+ if (!isModuleNotFoundError(err)) {
+ throw err
+ }
+ }))
}
const lazyComp = function Lazy(props: any) {
diff --git a/packages/react-router/tests/component-preload-retry.test.tsx b/packages/react-router/tests/component-preload-retry.test.tsx
index 2ce819f1bb..26cf472602 100644
--- a/packages/react-router/tests/component-preload-retry.test.tsx
+++ b/packages/react-router/tests/component-preload-retry.test.tsx
@@ -3,6 +3,7 @@ import { afterEach, expect, test, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import {
RouterProvider,
+ createControlledPromise,
createMemoryHistory,
createRootRoute,
createRoute,
@@ -31,6 +32,21 @@ test('a successful server component download is reused', async () => {
expect(importer).toHaveBeenCalledTimes(1)
})
+test('concurrent component preloads share the import', async () => {
+ const componentImport = createControlledPromise<{
+ default: () => null
+ }>()
+ const importer = vi.fn(() => componentImport)
+ const Page = lazyRouteComponent(importer)
+
+ const first = Page.preload?.()
+ expect(Page.preload?.()).toBe(first)
+ expect(importer).toHaveBeenCalledOnce()
+
+ componentImport.resolve({ default: () => null })
+ await first
+})
+
test('a failed component download is retried from the route error UI', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
@@ -112,3 +128,53 @@ test('renders after retrying a module download that failed during preload', asyn
expect(await screen.findByText('Page content')).toBeInTheDocument()
expect(importer).toHaveBeenCalledTimes(2)
})
+
+test('retries a module download from the error UI after reloading once', async () => {
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+
+ const failure = new TypeError(
+ 'Failed to fetch dynamically imported module: /assets/page.js',
+ )
+ sessionStorage.setItem(`tanstack_router_reload:${failure.message}`, '1')
+
+ const PageContent = () => Page content
+ const importer = vi
+ .fn<() => Promise<{ default: typeof PageContent }>>()
+ .mockRejectedValueOnce(failure)
+ .mockResolvedValue({ default: PageContent })
+ const Page = lazyRouteComponent(importer)
+
+ function RouteError({ reset }: ErrorComponentProps) {
+ const router = useRouter()
+ return (
+
+ )
+ }
+
+ const rootRoute = createRootRoute()
+ const pageRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/page',
+ component: Page,
+ errorComponent: RouteError,
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([pageRoute]),
+ history: createMemoryHistory({ initialEntries: ['/page'] }),
+ })
+
+ render()
+
+ fireEvent.click(await screen.findByRole('button', { name: 'Retry' }))
+
+ expect(await screen.findByText('Page content')).toBeInTheDocument()
+ expect(importer).toHaveBeenCalledTimes(2)
+})
diff --git a/packages/react-router/tests/errorComponent.test.tsx b/packages/react-router/tests/errorComponent.test.tsx
index b5ee4b6709..25e52259a4 100644
--- a/packages/react-router/tests/errorComponent.test.tsx
+++ b/packages/react-router/tests/errorComponent.test.tsx
@@ -774,12 +774,25 @@ test('#4684: SSR renders head content when beforeLoad throws', async () => {
})
describe('notFoundComponent is rendered when an error is thrown in params.parse', () => {
- test('displays notFoundComponent when error is thrown in params.parse', async () => {
+ test('renders a params.parse notFound without joining a pending intent-preload ancestor loader', async () => {
const history = createMemoryHistory({ initialEntries: ['/'] })
- const rootLoader = vi.fn(async () => {
- await new Promise((resolve) => setTimeout(resolve, 1))
- return { ok: true }
- })
+ const preloadGate = createControlledPromise()
+ let preloadController: AbortController | undefined
+ const rootLoader = vi.fn(
+ async ({
+ preload,
+ abortController,
+ }: {
+ preload: boolean
+ abortController: AbortController
+ }) => {
+ if (preload) {
+ preloadController = abortController
+ await preloadGate
+ }
+ return { ok: true, preload }
+ },
+ )
const rootRoute = createRootRoute({
component: function Root() {
@@ -804,7 +817,7 @@ describe('notFoundComponent is rendered when an error is thrown in params.parse'
component: function Home() {
return (
-
+
link to rotten pizza
@@ -859,12 +872,31 @@ describe('notFoundComponent is rendered when an error is thrown in params.parse'
expect(rootLoader).toHaveBeenCalledTimes(1)
expect(linkToRottenPizza).toBeInTheDocument()
await act(() => fireEvent.mouseOver(linkToRottenPizza))
+ await vi.waitFor(() => expect(rootLoader).toHaveBeenCalledTimes(2))
await act(() => fireEvent.click(linkToRottenPizza))
+ await vi.waitFor(() => expect(router.state.status).toBe('pending'))
+
+ expect(preloadGate.status).toBe('pending')
+ expect(preloadController?.signal.aborted).toBe(false)
+
+ await vi.waitFor(() => expect(rootLoader).toHaveBeenCalledTimes(3))
+ expect(rootLoader.mock.calls.map(([context]) => context.preload)).toEqual([
+ false,
+ true,
+ false,
+ ])
const notFoundComponent = await screen.findByText('No pizza', undefined, {
timeout: 750,
})
- expect(rootLoader).toHaveBeenCalledTimes(2)
+ expect(router.state.status).toBe('idle')
+ expect(preloadGate.status).toBe('pending')
+ expect(preloadController?.signal.aborted).toBe(false)
expect(notFoundComponent).toBeInTheDocument()
+
+ await act(async () => {
+ preloadGate.resolve()
+ await Promise.resolve()
+ })
})
})
diff --git a/packages/react-router/tests/issue-4467-lazy-route-pending.test.tsx b/packages/react-router/tests/issue-4467-lazy-route-pending.test.tsx
index e05d2ee792..074816153f 100644
--- a/packages/react-router/tests/issue-4467-lazy-route-pending.test.tsx
+++ b/packages/react-router/tests/issue-4467-lazy-route-pending.test.tsx
@@ -87,9 +87,19 @@ test('a lazy pending component is offered while the eager loader is still pendin
component: () => Index page
,
})
const loader = createControlledPromise()
+ const componentPreload = createControlledPromise()
+ const pendingComponentPreload = createControlledPromise()
+ const preloadComponent = vi.fn(() => componentPreload)
+ const preloadPendingComponent = vi.fn(() => pendingComponentPreload)
+ const Page = Object.assign(() => Page
, {
+ preload: preloadComponent,
+ })
+ const Pending = Object.assign(() => Loading lazy page
, {
+ preload: preloadPendingComponent,
+ })
const lazyPageOptions = createLazyRoute('/page')({
- pendingComponent: () => Loading lazy page
,
- component: () => Page
,
+ pendingComponent: Pending,
+ component: Page,
})
const lazyOptions = createControlledPromise()
const pageRoute = createRoute({
@@ -121,6 +131,18 @@ test('a lazy pending component is offered while the eager loader is still pendin
await act(async () => {
lazyOptions.resolve(lazyPageOptions)
+ await Promise.resolve()
+ })
+
+ expect(preloadComponent).toHaveBeenCalledOnce()
+ expect(preloadPendingComponent).toHaveBeenCalledOnce()
+ expect(componentPreload.status).toBe('pending')
+ expect(pendingComponentPreload.status).toBe('pending')
+ expect(screen.getByRole('status')).toHaveTextContent('Loading default')
+
+ await act(async () => {
+ pendingComponentPreload.resolve()
+ await Promise.resolve()
})
expect(await screen.findByRole('status')).toHaveTextContent(
@@ -131,6 +153,7 @@ test('a lazy pending component is offered while the eager loader is still pendin
).not.toBeInTheDocument()
await act(async () => {
+ componentPreload.resolve()
loader.resolve()
await navigation
})
@@ -139,6 +162,8 @@ test('a lazy pending component is offered while the eager loader is still pendin
} finally {
await act(async () => {
lazyOptions.resolve(lazyPageOptions)
+ componentPreload.resolve()
+ pendingComponentPreload.resolve()
loader.resolve()
await navigation
})
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..0324ae38c3
--- /dev/null
+++ b/packages/react-router/tests/react-render-owner-contract.test.tsx
@@ -0,0 +1,140 @@
+import { act } from 'react'
+import { cleanup, render, screen, waitFor } from '@testing-library/react'
+import { afterEach, expect, test, vi } from 'vitest'
+import {
+ Outlet,
+ RouterProvider,
+ createControlledPromise,
+ createMemoryHistory,
+ createRootRoute,
+ createRoute,
+ 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 = []
+ testCleanups.push(
+ router.subscribe('onRendered', (event) => {
+ renderedRevisions.push(
+ Number((event.toLocation.search as Record).revision),
+ )
+ }),
+ )
+
+ 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])
+})
+
+test('a development HMR lifecycle failure rolls back before recovery', async () => {
+ let generation = 1
+ const rootRoute = createRootRoute({ component: Outlet })
+ const pageRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/page',
+ loader: () => generation,
+ component: () => Page generation {pageRoute.useLoaderData()}
,
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([pageRoute]),
+ history: createMemoryHistory({ initialEntries: ['/page'] }),
+ })
+
+ render()
+ expect(await screen.findByText('Page generation 1')).toBeInTheDocument()
+ await waitFor(() => expect(router.state.status).toBe('idle'))
+
+ generation = 2
+ pageRoute.options.onStay = () => {
+ throw new Error('hot route failed')
+ }
+ await act(() => router._refreshRoute!())
+
+ expect(screen.getByText('Page generation 1')).toBeInTheDocument()
+ expect(router.state.matches.at(-1)?.loaderData).toBe(1)
+ expect(router.state.status).toBe('idle')
+
+ pageRoute.options.onStay = undefined
+ generation = 3
+ await act(() => router._refreshRoute!())
+
+ expect(screen.getByText('Page generation 3')).toBeInTheDocument()
+ expect(router.state.matches.at(-1)?.loaderData).toBe(3)
+})
diff --git a/packages/react-router/tests/router-client-context-retry.test.tsx b/packages/react-router/tests/router-client-context-retry.test.tsx
new file mode 100644
index 0000000000..580d529a6d
--- /dev/null
+++ b/packages/react-router/tests/router-client-context-retry.test.tsx
@@ -0,0 +1,88 @@
+import { cleanup, render, screen } from '@testing-library/react'
+import { afterEach, expect, test, vi } from 'vitest'
+import { createMemoryHistory } from '@tanstack/history'
+import { hydrate } from '@tanstack/router-core/ssr/client'
+import { dehydrateSsrMatchId } from '../../router-core/src/ssr/ssr-match-id'
+import {
+ Outlet,
+ RouterProvider,
+ createRootRoute,
+ createRoute,
+ createRouter,
+} from '../src'
+
+afterEach(() => {
+ cleanup()
+ delete window.$_TSR
+})
+
+test('a RouterProvider mounted after router hydration never renders transported success after client context reconstruction fails', async () => {
+ const serverRootRoute = createRootRoute({ component: Outlet })
+ const serverPageRoute = createRoute({
+ getParentRoute: () => serverRootRoute,
+ path: '/page',
+ context: () => ({ clientReady: true }),
+ component: () => Server page
,
+ })
+ const serverRouter = createRouter({
+ routeTree: serverRootRoute.addChildren([serverPageRoute]),
+ history: createMemoryHistory({ initialEntries: ['/page'] }),
+ })
+ serverRouter.isServer = true
+ await serverRouter.load()
+ const serverMatches = serverRouter.stores.matches.get()
+ expect(serverMatches.at(-1)).toMatchObject({ status: 'success' })
+
+ const contextError = new Error('Client context failed')
+ const pageRenders = vi.fn()
+ const clientRootRoute = createRootRoute({ component: Outlet })
+ const clientPageRoute = createRoute({
+ getParentRoute: () => clientRootRoute,
+ path: '/page',
+ context: () => {
+ throw contextError
+ },
+ component: () => {
+ const context = clientRouter.state.matches.at(-1)?.context
+ pageRenders(context)
+ return context?.clientReady ? (
+ Client page
+ ) : (
+ Missing client context
+ )
+ },
+ errorComponent: ({ error }) => {error.message}
,
+ })
+ const clientRouter = createRouter({
+ routeTree: clientRootRoute.addChildren([clientPageRoute]),
+ history: createMemoryHistory({ initialEntries: ['/page'] }),
+ })
+ window.$_TSR = {
+ router: {
+ manifest: { routes: {} },
+ dehydratedData: {},
+ matches: serverMatches.map((match) => ({
+ i: dehydrateSsrMatchId(match.id),
+ u: match.updatedAt,
+ s: match.status,
+ l: match.loaderData,
+ e: match.error,
+ ssr: match.ssr,
+ })),
+ },
+ h: vi.fn(),
+ e: vi.fn(),
+ c: vi.fn(),
+ p: vi.fn(),
+ buffer: [],
+ initialized: false,
+ }
+
+ await hydrate(clientRouter)
+ render()
+
+ expect(await screen.findByText(contextError.message)).toBeInTheDocument()
+ expect(screen.queryByText('Missing client context')).not.toBeInTheDocument()
+ expect(screen.queryByText('Client page')).not.toBeInTheDocument()
+ expect(pageRenders).not.toHaveBeenCalled()
+})
diff --git a/packages/react-router/tests/router.test.tsx b/packages/react-router/tests/router.test.tsx
index 43c4d47423..bfa89a73c1 100644
--- a/packages/react-router/tests/router.test.tsx
+++ b/packages/react-router/tests/router.test.tsx
@@ -2701,10 +2701,16 @@ describe('notFound in beforeLoad with pendingComponent', () => {
}
} finally {
try {
+ // Navigation resolves from the final React layout acknowledgement.
+ // Do not await it inside the act callback that first makes that
+ // render possible, or fake timers and the concurrent render deadlock.
+ vi.useRealTimers()
await act(async () => {
beforeLoad.resolve()
- await navigation
+ await Promise.resolve()
})
+ await act(async () => {})
+ await navigation
} finally {
vi.useRealTimers()
}
diff --git a/packages/react-router/tests/transitioner-remount.test.tsx b/packages/react-router/tests/transitioner-remount.test.tsx
index 76cc6393ce..e0b6122ec6 100644
--- a/packages/react-router/tests/transitioner-remount.test.tsx
+++ b/packages/react-router/tests/transitioner-remount.test.tsx
@@ -1,5 +1,7 @@
+import { useState } from 'react'
+import { flushSync } from 'react-dom'
import { afterEach, describe, expect, it, vi } from 'vitest'
-import { cleanup, render, screen, waitFor } from '@testing-library/react'
+import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
import {
Outlet,
RouterProvider,
@@ -11,6 +13,7 @@ import {
afterEach(() => {
cleanup()
+ vi.unstubAllEnvs()
})
function setup() {
@@ -88,4 +91,51 @@ describe('Transitioner remount', () => {
loadSpy.mockRestore()
})
+
+ it.each(['development', 'production'] as const)(
+ 'settles an outstanding render when the provider unmounts in %s',
+ async (nodeEnv) => {
+ vi.stubEnv('NODE_ENV', nodeEnv)
+
+ const { router } = setup()
+ let setMounted!: (mounted: boolean) => void
+ function Harness() {
+ const [mounted, set] = useState(true)
+ setMounted = set
+ return mounted ? : null
+ }
+
+ render()
+ expect(await screen.findByText('Index')).toBeInTheDocument()
+ await waitFor(() => expect(router.state.status).toBe('idle'))
+
+ const rendered = vi.fn()
+ const navigationSettled = vi.fn()
+ const unsubscribers = [
+ router.subscribe('onRendered', (event) => {
+ if (event.toLocation.pathname === '/next') {
+ rendered()
+ }
+ }),
+ router.subscribe('onBeforeRouteMount', (event) => {
+ if (event.toLocation.pathname === '/next') {
+ expect(navigationSettled).not.toHaveBeenCalled()
+ flushSync(() => setMounted(false))
+ }
+ }),
+ ]
+
+ void router.navigate({ to: '/next' }).then(navigationSettled)
+ await waitFor(() => expect(navigationSettled).toHaveBeenCalledOnce())
+ expect(rendered).not.toHaveBeenCalled()
+
+ await act(() => setMounted(true))
+ expect(await screen.findByText('Next')).toBeInTheDocument()
+ await waitFor(() => expect(rendered).toHaveBeenCalledOnce())
+
+ for (const unsubscribe of unsubscribers) {
+ unsubscribe()
+ }
+ },
+ )
})
diff --git a/packages/react-router/tests/transitioner-render-ack.test.tsx b/packages/react-router/tests/transitioner-render-ack.test.tsx
index b533c225b3..29c3b05554 100644
--- a/packages/react-router/tests/transitioner-render-ack.test.tsx
+++ b/packages/react-router/tests/transitioner-render-ack.test.tsx
@@ -1,4 +1,4 @@
-import { StrictMode, act } from 'react'
+import { Activity, StrictMode, act, lazy } from 'react'
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import { afterEach, expect, test, vi } from 'vitest'
import {
@@ -19,6 +19,7 @@ afterEach(() => {
}
cleanup()
vi.useRealTimers()
+ vi.unstubAllEnvs()
})
test('same-location invalidation emits onResolved after its refreshed DOM commits', async () => {
@@ -74,6 +75,7 @@ test('a late background refresh is discarded after foreground navigation commits
const itemRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/items/$itemId',
+ staleTime: Infinity,
loader: {
staleReloadMode: 'background',
handler: ({ params }) => itemLoader(params.itemId),
@@ -94,7 +96,6 @@ test('a late background refresh is discarded after foreground navigation commits
await screen.findByText('Item source: initial source data'),
).toBeInTheDocument()
await waitFor(() => expect(router.state.status).toBe('idle'))
- const sourceMatchId = router.state.matches.at(-1)!.id
const eventLog: Array = []
const unsubscribers = [
@@ -130,12 +131,6 @@ test('a late background refresh is discarded after foreground navigation commits
backgroundRefresh.resolve('obsolete source data')
await Promise.resolve()
})
- await waitFor(() => {
- expect(router._flights?.has(sourceMatchId) ?? false).toBe(false)
- const cachedSourceMatch = router._cache.get(sourceMatchId)
- expect(cachedSourceMatch?.loaderData).toBe('initial source data')
- expect(cachedSourceMatch?.isFetching).toBe(false)
- })
expect(
screen.getByText('Item target: foreground target data'),
@@ -154,6 +149,18 @@ test('a late background refresh is discarded after foreground navigation commits
.slice(eventCountAfterForegroundCommit)
.every((event) => event.endsWith('/items/target')),
).toBe(true)
+
+ await act(() =>
+ router.navigate({
+ to: '/items/$itemId',
+ params: { itemId: 'source' },
+ }),
+ )
+ expect(
+ screen.getByText('Item source: initial source data'),
+ ).toBeInTheDocument()
+ expect(screen.queryByText('Item source: obsolete source data')).toBeNull()
+ expect(itemLoader).toHaveBeenCalledTimes(3)
})
test('a navigation started by route lifecycle keeps the pending minimum of its own render', async () => {
@@ -190,6 +197,7 @@ test('a navigation started by route lifecycle keeps the pending minimum of its o
render()
expect(await screen.findByText('Index')).toBeInTheDocument()
await waitFor(() => expect(router.state.status).toBe('idle'))
+
vi.useFakeTimers()
let firstNavigation!: Promise
@@ -266,3 +274,159 @@ test('StrictMode effect replay preserves renderer commit sequencing', async () =
expect(screen.getByText('Next')).toBeInTheDocument()
expect(screen.queryByText('Index')).not.toBeInTheDocument()
})
+
+test('Activity reconnects render acknowledgement before the next navigation', async () => {
+ vi.stubEnv('NODE_ENV', 'production')
+
+ const rootRoute = createRootRoute({ component: Outlet })
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => Index
,
+ })
+ const nextRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/next',
+ component: () => Next
,
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([indexRoute, nextRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+ const provider =
+ const view = render({provider})
+
+ expect(await screen.findByText('Index')).toBeInTheDocument()
+ await waitFor(() => expect(router.state.status).toBe('idle'))
+
+ const rendered = vi.fn()
+ const unsubscribe = router.subscribe('onRendered', (event) => {
+ if (event.toLocation.pathname === '/next') {
+ rendered(screen.queryByText('Next') !== null)
+ }
+ })
+ testCleanups.push(unsubscribe)
+
+ view.rerender({provider})
+ expect(screen.getByText('Index')).not.toBeVisible()
+ view.rerender({provider})
+ expect(screen.getByText('Index')).toBeVisible()
+
+ await act(() => router.navigate({ to: '/next' }))
+
+ expect(screen.getByText('Next')).toBeVisible()
+ expect(rendered).toHaveBeenCalledOnce()
+ expect(rendered).toHaveBeenCalledWith(true)
+
+ view.unmount()
+ vi.unstubAllEnvs()
+})
+
+test('Activity catches up with history changes made while hidden', async () => {
+ const rootRoute = createRootRoute({ component: Outlet })
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => Index
,
+ })
+ const nextRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/next',
+ component: () => Next
,
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([indexRoute, nextRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+ const provider =
+ const view = render({provider})
+
+ expect(await screen.findByText('Index')).toBeInTheDocument()
+ await waitFor(() => expect(router.state.status).toBe('idle'))
+
+ view.rerender({provider})
+ expect(screen.getByText('Index')).not.toBeVisible()
+
+ await act(() => router.history.push('/next'))
+ expect(router.history.location.href).toBe('/next')
+ expect(router.latestLocation.pathname).toBe('/')
+
+ view.rerender({provider})
+
+ expect(await screen.findByText('Next')).toBeVisible()
+ expect(router.latestLocation.pathname).toBe('/next')
+})
+
+test('Activity does not restart a navigation observed before it was hidden', async () => {
+ const next = createControlledPromise()
+ const loader = vi.fn(() => next)
+ const rootRoute = createRootRoute({ component: Outlet })
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => Index
,
+ })
+ const nextRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/next',
+ loader,
+ component: () => Next
,
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([indexRoute, nextRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+ const provider =
+ const view = render({provider})
+
+ expect(await screen.findByText('Index')).toBeInTheDocument()
+ await waitFor(() => expect(router.state.status).toBe('idle'))
+
+ let navigation!: Promise
+ act(() => {
+ navigation = router.navigate({ to: '/next' })
+ })
+ await waitFor(() => expect(loader).toHaveBeenCalledOnce())
+
+ view.rerender({provider})
+ view.rerender({provider})
+ expect(loader).toHaveBeenCalledOnce()
+
+ await act(async () => {
+ next.resolve()
+ await navigation
+ })
+ expect(screen.getByText('Next')).toBeVisible()
+})
+
+test('an initial canceled pending publication restores the empty presentation', async () => {
+ let offeredRouteId: string | undefined
+ const offeredRoute = { readId: (): string | undefined => undefined }
+ const suspendedPending = createControlledPromise()
+ const Pending = lazy(async () => {
+ await suspendedPending
+ return { default: () => Initial pending
}
+ })
+ const rootRoute = createRootRoute({
+ pendingMs: 0,
+ pendingMinMs: 0,
+ pendingComponent: Pending,
+ beforeLoad: ({ abortController }) => {
+ offeredRouteId = offeredRoute.readId()
+ abortController.abort()
+ },
+ component: () => Ready
,
+ })
+ const router = createRouter({
+ routeTree: rootRoute,
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+ offeredRoute.readId = () => router.state.matches[0]?.routeId
+
+ render()
+
+ await waitFor(() => expect(offeredRouteId).toBe(rootRoute.id))
+ await waitFor(() => expect(router.state.status).toBe('idle'))
+ expect(router.state.matches).toEqual([])
+ expect(screen.queryByText('Something went wrong!')).not.toBeInTheDocument()
+})
diff --git a/packages/router-core/INTERNALS.md b/packages/router-core/INTERNALS.md
index d8efe633cf..5a8dc15597 100644
--- a/packages/router-core/INTERNALS.md
+++ b/packages/router-core/INTERNALS.md
@@ -34,31 +34,29 @@ The main authorities are:
| `_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 | One still-running speculative whole lane |
-| Loader flight registry entry | The latest same-ID loader generation available to new consumers |
-| Match flight lease | Ownership keeping that loader work alive |
+| `router._flights` | The discoverable loader generation currently producing a cache key |
+| Active preload entry | One running speculative lane registered for cancellation |
+| Match flight lease | Ownership keeping one match's loader generation alive |
| Pending session | One reveal/minimum-visible deadline and its current owner |
| React acknowledgement slot | The one requested publication whose render may settle a transition |
| Request signal | Lifetime of one server request and any accepted SSR stream |
| Accepted SSR stream response | Cleanup ownership transferred from the handler to the response body |
These authorities are related, but none is a substitute for another. In
-particular, presentation is not semantic authority, registry membership is not
-resource ownership, and a promise settling is not permission to publish.
+particular, presentation is not semantic authority, a cached generation is not
+permission to skip a requested reload, and a promise settling is not permission
+to publish.
## Code map
- `src/router.ts` matches locations, creates match objects, owns public router
state, history, cache operations, and entry points into loading.
- `src/stores.ts` reconciles route-keyed presentation stores and their ordered
- aggregate.
+ aggregate. It owns no React acknowledgement pointer.
- `src/load-client.ts` owns client planning, transactions, preloads, loader
- flights, reduction, pending presentation, background reloads, and commits.
+ flights, route/component readiness, reduction, pending presentation,
+ background reloads, hydration reconstruction, and commits.
- `src/load-server.ts` runs the request-local server lane.
-- `src/route-chunks.ts` owns lazy route-option installation and component
- readiness without adding a JavaScript-module cache.
-- `src/hydrate.ts` reconstructs the server-resolved prefix and the initial
- selective-SSR presentation.
- `src/ssr/createRequestHandler.ts` connects request lifetime, server loading,
dehydration, redirects, rendering, and cleanup.
- `src/ssr/handlerCallback.ts`, `ssr-server.ts`, and
@@ -121,8 +119,8 @@ across match generations and A-to-B-to-A membership changes. `ids` is published
before departure tombstones so framework trees stop reading a leaving route
before its atom is cleared. The pool retains atoms for route IDs encountered
during the router's lifetime; it is not an LRU or a cache of match generations.
-Semantic caches and loader flights remain keyed by match ID and must not use the
-presentation pool as their authority.
+Semantic cache entries remain keyed by match ID; loader flights belong to exact
+match generations. Neither may use the presentation pool as its authority.
This distinction matters for user code. Once the destination is presented, a
selector can inspect every destination match and observe `isFetching` while the
@@ -202,8 +200,7 @@ reuse an older merged context or `beforeLoad` contribution.
Server requests have fresh matches and execute route context normally.
Hydration executes each accepted route context locally, stores its `_ctx`, and
-then merges transported `beforeLoad` output. An active identical preload donates
-the whole lane it is still contextualizing.
+then merges transported `beforeLoad` output.
### Reduced
@@ -282,7 +279,7 @@ Every asynchronous client publication checks `_tx` immediately before the
write. Background publication additionally checks the exact committed base
array from which it was derived.
-## `beforeLoad`: execution, active adoption, and hydration
+## `beforeLoad`: execution and hydration
`beforeLoad` context is not a cache.
@@ -292,59 +289,16 @@ same-ID route-local `_ctx` may remain reusable. A later navigation rebuilds the
merged context from the current parent and `_ctx`, then reruns `beforeLoad`, even
when it reuses completed loader data.
-There are two deliberate exceptions.
-
-### Identical active whole-lane adoption
-
-A navigation may latch onto an entry in `router._preloads` that is still running
-only when the whole lane is identical. Admission is decided before rematching
-from the preload's href and redirect depth, route-tree identity, semantic owner,
-parsed search, router root context, additional context, user-supplied location
-state, and any explicit mask's href, search, user state, and
-`unmaskOnReload`. Router-managed history and temporary-mask keys do not
-participate. With deterministic route planning, those inputs imply the same
-ordered routes, params, validated search, and global-path-miss meaning. They may
-be observed by `beforeLoad` even when the route lane is unchanged.
-Adoption inputs are treated as immutable values: replace a context/state object
-to express a generation change; in-place mutation is not detected.
-Adoption is all-or-nothing. There is no prefix donation and no
-completed-preload freshness window for `beforeLoad`. Matching derives
-global-path-miss semantics for every lane, including preloads, so an otherwise
-identical candidate cannot erase that terminal meaning during adoption.
-
-The active preload also captures the identity of `_committed` from which
-it was planned. Whole-lane adoption requires that semantic owner to remain
-current. Invalidation replaces the committed generation, so an older preload
-may still donate an identical loader flight but cannot donate its pre-invalidation
-`beforeLoad` context.
-
-The adopting navigation takes ownership of the active lane and its controller.
-The original public preload call must then stop releasing that lane. Individual
-successful loader tasks may still publish cache entries through their original
-per-match compare-and-swap; that publication belongs to the transferred lane,
-not to a second whole-lane owner.
-
-If the adopted speculative lane succeeds, the navigation may use the
-`beforeLoad` calls that ran with `preload: true`. A redirect from that same
-active lane is also accepted as navigation control flow. An ordinary failure,
-not-found, or cancellation is not authoritative for navigation: the real
-navigation replans and reruns `beforeLoad` with `preload: false`. Successful
-loader work anywhere in the settled lane remains eligible for reuse, including
-a descendant that completed after an ancestor loader failed. Each successful
-loader generation has already attempted its own cache publication at settlement,
-so retry does not scan the terminal lane or create another cache handoff. It
-discards the failed speculative semantic lane and replans cache-first. The retry
-rebuilds the serial context chain and reruns `beforeLoad`; only independently
-successful loader generations cross that boundary through the normal
-cache/flight rules.
-
-Routes with `preload: false` do not permit active whole-lane adoption. Their
-speculative `beforeLoad` may run with `preload: true`, but navigation reruns the
-serial chain and performs the skipped loader work.
-
-Rejecting whole-lane adoption does not require discarding loader work before
-the real lane reaches its reload decisions. The rejected preload may remain a
-resource-only donor until that lane settles.
+Navigation always builds its own context chain and calls `beforeLoad` with
+`preload: false`, even while an identical preload is still running. This keeps
+guard context and control flow owned by the operation that will publish them.
+A same-ID loader generation is the narrower sharing boundary: navigation may
+join its pending promise or reuse its accepted cache result without inheriting
+the preload lane's `beforeLoad` context or control outcome. Joining a loader
+promise does observe that loader generation's complete normalized outcome,
+including error, not-found, or redirect. Cancellation is lane-local: a canceled
+waiter releases its lease without canceling a generation that another lane
+still owns.
### Hydration
@@ -358,8 +312,8 @@ Hydration is not a general `beforeLoad` cache. Its temporary handoff is valid
only for the initial client load of the same document entry and exact committed
owner; rejection, invalidation, or any later load returns to normal serial
execution. The claim also requires the same route tree, root/additional context,
-search, user history state, and exact browser history generation. That initial
-load may transfer the accepted hydration prefix and
+internal href, browser-facing public href, and parsed history-state object. That
+initial load may transfer the accepted hydration prefix and
keep its transported context while it completes a selective-SSR suffix. A
preload never claims this prefix. Frameworks must start the initial client load
before descendant route code can preload; invoking a preload in the gap after
@@ -391,7 +345,7 @@ generations when its error or not-found came from `beforeLoad`, validation, or
another route. Each preload loader success attempts cache admission immediately,
before whole-lane reduction. The cache receives a non-terminal copy and an
additional flight lease; the speculative lane keeps its own lease and terminal
-meaning until it is adopted or discarded. This works even below the eventual
+meaning until it is discarded. This works even below the eventual
render boundary and does not preserve the speculative parent chain: merged
context and `beforeLoad` output are cleared. Same-ID `_ctx`, loader identity,
and successful loader data remain reusable by design.
@@ -417,36 +371,31 @@ been removed; the merged chain is rebuilt from current parents and same-ID
route-local context before `beforeLoad` reruns. Committing a lane removes cache
entries for its IDs.
-### Same-id in-flight work
-
-`router._flights` is the only registry from which a new consumer discovers
-same-ID loader work. Every new loader generation registers there, whether it
-was started by navigation, preload, or background refresh. A newer generation
-replaces the registry entry synchronously. A flight has its own abort
-controller; it does not use one consumer transaction's controller as its
-lifetime.
-
-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 itself 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. Consequently, a flight referenced by a match or discoverable in the
-registry always has a positive lease count. Acquisition code must not recover
-from a referenced zero-lease flight; that would hide an ownership bug.
+### Match-owned loader work
+
+Every loader execution creates a flight containing its outcome promise,
+controller, lease count, and discovery registry. The originating match owns the
+first lease. A semantic or cached copy that keeps the same generation alive
+acquires another; every discarded copy releases exactly once. The last release
+removes that exact generation from discovery and aborts its controller.
+
+`router._flights` is the pending analogue of `router._cache`. Both are keyed by
+match ID: a distinct lane rebuilds context and reruns `beforeLoad`, then may
+lease the pending same-ID loader generation if its reload policy would accept
+that work. A lease joins the complete pending loader outcome, including error,
+not-found, or redirect; the recipient does not rerun that loader generation.
+Lane cancellation only releases that lane's lease. Only success persists as
+reusable cache data. Once a terminal generation has settled and left discovery,
+a later lane runs its own loader.
+Public invalidation and cache clearing revoke affected discovery entries before
+old owners are detached, so pre-boundary work cannot supply a later generation.
Releasing a set of matches is deliberately two-phase. First every outgoing
-match drops its `_flight` lease and every zero-owner generation is removed from
-the discovery registry. Only after the entire outgoing set is detached are the
-collected flight controllers aborted. Do not interleave one flight abort with
-detaching later matches in the same replacement: an abort listener can
-synchronously reenter, and that load must observe every logically removed lease
-and registry entry as already gone.
+match drops its `_flight` lease. Only after the entire outgoing set is detached
+are the collected flight controllers aborted. Do not interleave one flight
+abort with detaching later matches in the same replacement: an abort listener
+can synchronously reenter, and that load must observe every logically removed
+lease as already gone.
A successful accepted match may keep its lease after the loader promise has
settled. This keeps the loader's public `AbortSignal` alive for that semantic
@@ -460,26 +409,17 @@ 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
-loader will run, it may synchronously acquire the registry's latest different
-same-ID generation. It never reacquires its own accepted generation merely to
-avoid a requested reload. A blocking reload replaces the accepted lease with
-the donor; a background reload keeps accepted data visible and gives the donor
-lease to its private candidate. This one lookup covers work started by active
-preloads, navigation, and background refresh without scanning those owners.
-
-When a different lane supersedes an active preload at the same href, the old
-lane stops being adoptable but retains its resources until the successor lane
-settles. Its leased flights therefore remain discoverable for the successor's
-late reload decisions without giving every planned match a second reservation
-lease. The successor then releases the old lane; any borrowed flight remains
-alive through the successor match's ordinary `_flight` lease.
-
-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.
+A planned match may hold a lease for an accepted generation copied from cache or
+committed state. Once `beforeLoad` and `shouldReload` decide that a loader must
+run, the lane releases that accepted lease and creates its own flight. A
+blocking reload owns the new flight on the planned match; a background reload
+keeps accepted data visible and owns the new flight on its private candidate.
+
+Each `preloadRoute` call owns its context and `beforeLoad` lane. Active entries
+exist only so invalidation and cache clearing can retire those lanes. They do
+not deduplicate by href. Pending same-ID loader work is shared independently
+through `_flights`, including a loader redirect. `beforeLoad` control flow stays
+lane-local, and redirects are never cached as loader data.
### Semantic parent chain
@@ -658,11 +598,12 @@ Each reentrant callback can start another navigation. A currentness check after
each publication boundary suppresses stale later events. In particular, an
`onResolved` navigation suppresses the old transaction's `onRendered`.
-Route lifecycle callbacks are invoked directly and are expected not to throw.
-The coordinator does not carry a second error-handling path for callback
-failures. All `onLeave` callbacks run before callbacks for retained or newly
-entered routes; the relative ordering of `onEnter` and `onStay` is not a public
-contract.
+Ordinary route lifecycle callback failures are logged and isolated per callback,
+so one user callback cannot interrupt the remaining callbacks or leave a commit
+half-resolved. HMR refresh deliberately propagates lifecycle failures so its
+publication checkpoint can roll the generation back. All `onLeave` callbacks
+run before callbacks for retained or newly entered routes; the relative ordering
+of `onEnter` and `onStay` is not a public contract.
Server render results are published request-locally and run the documented
route `onLeave`/`onEnter`/`onStay` callbacks against the previous server
@@ -734,10 +675,17 @@ the superseded navigation. Changing the boundary discards the old session.
- settlement means the framework transition can no longer block navigation
completion;
-- `true` means the exact requested match publication rendered;
+- `true` means the exact requested match publication selected by React reached
+ the `MatchesInner` layout-commit phase;
- `false` means core must finish without emitting `onRendered` or starting a
pending minimum based on that publication.
+This is publication-shell exactness, not proof that every nested Suspense
+boundary revealed new child content. A route-owned Suspense boundary can retain
+its previous child DOM while the surrounding `MatchesInner` publication commits
+and acknowledges. Review code that uses `onRendered` for descendant DOM work
+with that distinction in mind.
+
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
@@ -745,36 +693,55 @@ 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.
+The mounted-tree-owned Transitioner precedes the sibling match tree, so its
+layout effect installs these callbacks before the match tree can acknowledge.
+
+In development HMR, the transition callback is wrapped by a `try`/`catch` inside
+the function passed to `React.startTransition`. React 19 captures callback
+throws instead of rethrowing them from the outer `React.startTransition` call.
+The development-only wrapper therefore clears only its exact owner and rejects
+the adapter promise so the HMR transaction can restore `_committed`. Ordinary
+production lifecycle and router-event callbacks are already isolated, so the
+production adapter calls `React.startTransition` directly.
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`.
+for its initial `onRendered` event. It therefore emits from the matching
+`MatchesInner` layout commit rather than from the earlier history-subscription
+effect. Descendant layout effects in that commit run first, subject to the
+nested-Suspense qualification above.
+
+`stores.matches` is the public presented match view, while `stores.ids` is the
+ordered route-membership and publication notification used by framework
+renderers. Every publication writes its already-created route-ID array, even
+when membership is unchanged. Per-route stores can still replace live match
+objects for `isFetching` without acknowledging another semantic publication.
+
+React keeps the exact offered match-array pointer in a small owner local to the
+mounted `Matches` tree. It captures that pointer during render and compares it
+by identity in a layout effect. An exact render settles the current slot as
+`true`; a mismatched render is ignored. This pointer is neither public match
+state nor semantic authority, and it does not exist in router-core.
+
+Direct core restoration clears an outstanding React offer before publishing
+the restored stores. This matters on a canceled first load: the accepted lane
+is empty, so leaving the offered root armed while tombstoning its route atom
+would make React try to render a route that no longer has a presented match.
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.
-
-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
-strand the acknowledgement promise.
+descendant mount effects. The architecture supports one committed `Matches`
+owner per router at a time. Unmounting it unsubscribes from history and retires
+any outstanding acknowledgement as `false`; a later mount creates a new owner
+and synchronizes with the router's current presented matches. Development
+defers only that retirement through the StrictMode effect replay turn, while
+production cleanup is synchronous. Effect setup always reactivates the owner:
+React Suspense and Activity can disconnect and reconnect layout effects without
+rendering the preserved child tree again.
+
+Every core publication passed to `startTransition` must publish the route-ID
+generation signal even if route membership is unchanged. Suppressing that
+notification can strand React's acknowledgement promise.
## `isFetching` and background reloads
@@ -790,8 +757,8 @@ presented lane and therefore expose their phase immediately.
A background reload keeps successful loader data visible while a private
candidate runs. The full presented lane remains installed and the affected
match reports the active phase. Completion clears fetching state whether the
-candidate publishes, fails, or is superseded. A successor may join its loader
-flight, but never adopts the private candidate lane.
+candidate publishes, fails, or is superseded. A successor never adopts or joins
+the private candidate lane.
Background loader and chunk work may begin and settle while the foreground
publication renders. Background reduction, projection, and publication do not
@@ -850,12 +817,19 @@ generation from escaping invalidation merely because a different generation
was the one passed to the filter. Route context needs no invalidation marker;
every new lane rebuilds it regardless.
+Invalidation also retires active preloads planned under the old generation.
+Unfiltered invalidation retires every active preload; filtered invalidation
+retires a preload when any match in its lane passes the filter. The retained
+preload collection is installed before discarded match leases are released and
+their lane controllers are aborted, so late speculative work cannot regain
+cache publication authority after invalidation.
+
Invalidated successful data may remain visible until pending or terminal
publication, depending on reload mode. Error/not-found generations reset through
the same loading protocol rather than becoming cache successes.
-Cache clearing derives the retained active-preload and cache maps, installs both
-replacement authorities, bulk-detaches removed leases, lets that operation
+Cache clearing derives the retained active-preload collection and cache map,
+installs both replacement authorities, bulk-detaches removed leases, lets that operation
abort zero-owner flight controllers, and only then aborts selected preload lane
controllers. A public loader signal can synchronously reenter from its abort
listener; that reentrant load must observe the cleared authorities and every
@@ -864,11 +838,12 @@ independent, and every later cache publication must still pass the per-match
cache-entry identity check captured during planning.
Development refresh is deliberately aggressive: it aborts flights, discards
-active preloads and caches, drops the committed semantic lane, and rematches
-from the current route definitions. This prevents same-ID reuse from retaining
-obsolete params, context, loader data, or projected assets. Correct ownership
-and eventual usable state matter more than preserving speculative HMR work.
-Flight discovery entries are removed before their controllers are aborted.
+active preloads and caches, and prevents the committed lane from being reused
+while rematching current route definitions. The old committed lane remains
+installed for rendering and rollback until the replacement publishes. This
+prevents same-ID reuse from retaining obsolete params, context, loader data, or
+projected assets. Correct ownership and eventual usable state matter more than
+preserving speculative HMR work.
## Speculative preloading
@@ -877,9 +852,8 @@ navigation, but it never becomes `_tx` and never publishes match presentation.
Its match/cache ownership effects are limited to:
-- an active identical whole lane that a navigation may adopt while it is still
- running;
-- joinable same-id loader flights; and
+- each active lane being registered so invalidation and cache clearing can
+ cancel it;
- individual successful preload loader generations entering the loader cache as
they settle.
@@ -887,12 +861,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 completed preload's failure, not-found, redirect, cancellation, and route
-context do not become authority for a later navigation. An adopted still-active
-identical lane is the exception described above, including its redirect control
-flow. 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.
+A preload's route context and `beforeLoad` control outcome do not become
+authority for a navigation, and terminal outcomes do not become reusable cache
+entries. A navigation that joins the preload's still-pending same-ID loader does
+share that loader generation's complete outcome. A later navigation starts a
+new loader after a terminal generation leaves discovery. A still-active
+standalone preload follows redirects within its bounded policy even if an
+unrelated navigation runs; this only starts more speculative work and cannot
+publish location or presentation. A preload never performs a document reload.
The public `preloadRoute` result describes the speculative lane, not merely its
cacheable subset. An ordinary error or not-found therefore resolves with the
@@ -905,8 +881,7 @@ the entry captured when that task was planned. It cannot overwrite a cache
generation installed since that plan. Admission happens at loader settlement,
without waiting for whole-lane success. A distinct successful generation may
coexist with an older committed same-ID generation; this changes future
-planning precedence, not current presentation. A duplicate sharing the already
-accepted flight is discarded.
+planning precedence, not current presentation.
`preloadRoute` also works on a server router. It runs the same speculative
protocol with `preload: true`, can return matches and populate that router's
@@ -957,18 +932,26 @@ swallowed.
Until a response is accepted, the request handler owns router SSR cleanup. A
non-stream response, redirect, or failure leaves cleanup with the handler. An
accepted SSR stream transfers that ownership to the response and is immediately
-bound to the request signal, so an abort after handoff still disposes it.
-
-Disposal is idempotent and severs router SSR ownership before best-effort body
-cancellation. This order matters because a custom or framework stream may ignore
-or indefinitely delay cancellation. Replacing a stream response, resolving a
-redirect from it, or stripping a HEAD body disposes the old stream under the
-same request signal before accepting the replacement.
-
-Framework renderers connect request abort to their upstream renderer as well as
-the router stream transform. Normal completion, downstream cancellation,
-request abort, renderer failure, and stream lifetime timeout all converge on the
-same cleanup authority; none creates a second response owner.
+bound to the request signal, so an abort after handoff still disposes it. An
+ordinary response body has no router cleanup ownership, but is handed off
+through an abortable stream pipe so a later request abort still cancels its
+upstream body.
+
+Disposal is idempotent and severs router SSR ownership before body cancellation.
+Start's internal response-retirement and HEAD paths detach cancellation so a
+custom or framework stream cannot delay request settlement. The public
+router-core replacement and body-stripping helpers instead await a custom tagged
+response disposer, so callers know that the old owner has finished cleanup.
+
+Framework renderers connect request abort to their upstream renderer when the
+renderer exposes cancellation ownership, as well as to the router stream
+transform. The current Solid and Vue renderer APIs expose no disposer for a
+pending render: abort still releases router state and stops outbound bytes, but
+cannot prove that the hidden framework render graph was retired. Hosting must
+therefore enforce a request/render deadline until those upstream APIs expose
+that ownership. Normal completion, downstream cancellation, request abort,
+renderer failure, and stream lifetime timeout otherwise converge on the same
+cleanup authority; none creates a second response owner.
Once a server lane is accepted for rendering, its match controllers are
registered with that same SSR cleanup authority. They remain live through
@@ -1126,8 +1109,8 @@ The two-phase transfer proceeds as follows:
2. It installs its own `_preflight`, emits the events, matches a private lane,
and asks the capability to finish the transfer.
3. Finish revalidates capability identity, transaction absence, hydration
- controller liveness, captured location identity, and the exact committed
- owner.
+ controller liveness, captured semantic location inputs, and the exact
+ committed owner.
4. On rejection, the capability clears itself before aborting its controller,
so abort listeners cannot reenter and claim a rejected handoff.
5. On acceptance, the prefix replaces the planner's private copies. A terminal
@@ -1176,22 +1159,20 @@ When changing this architecture, verify all of the following:
6. Each successful preload loader generation may enter the cache as it settles,
even if its whole lane later becomes terminal or is canceled. It may retain
same-ID route-local `_ctx`, but never merged context or `beforeLoad` output.
-7. Active preload adoption is identical-whole-lane only, including router
- context, additional context, and user-supplied location state. It also
- requires the semantic committed owner captured during planning to remain
- current; rejected lanes may donate loader flights, never `beforeLoad`
- context. Retrying a terminal adopted lane may retain every independently
- successful loader generation, but rebuilds merged context and reruns the
- serial `beforeLoad` chain.
+7. Every preload and navigation rebuilds merged context and reruns the serial
+ `beforeLoad` chain. Same-ID loader promises and accepted loader data remain
+ reusable independently through `_flights` and `_cache`.
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
authority, and presents the full local branch while executing only the
accepted prefix. Its two-phase handoff is claimable only by the supported
- initial document load and revalidates the live controller, captured location,
- and exact committed owner. The framework's exact-document contract remains
- the URL authority; the captured location rejects supported reentrant
- successors, while compact serialized match IDs guard prefix compatibility.
+ initial document load and revalidates the live controller, captured route
+ tree and contexts, both internal and browser-facing hrefs, the parsed state
+ object, and the exact committed owner. The framework's exact-document
+ contract remains the URL authority; those semantic inputs reject supported
+ reentrant successors, while compact serialized match IDs guard prefix
+ compatibility.
It clears before abort on rejection and remains reclaimable until `_tx`
exists. Framework adapters start that load before descendant route code can
create unsupported speculative work in the handoff gap.
@@ -1199,15 +1180,16 @@ When changing this architecture, verify all of the following:
10. Cache publication retains only the generation allowed by its planning CAS;
same-ID committed and cached generations invalidate together, and accepted
recipients are installed before replaced resources are released.
-11. The registry is the sole discovery authority for the latest same-ID flight
- 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.
+11. Loader flights are match-owned. Same-ID lanes may lease the currently
+ discoverable generation after independently deciding loader policy, but
+ a lease observes the complete pending outcome. Only successful data persists
+ in the cache. Explicit invalidation and requested `shouldReload` generations
+ do not borrow older work. Lease ownership may outlive promise settlement so
+ the accepted public signal retains the semantic generation's lifetime.
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;
- any started descendant redirect can still win as control flow.
+ any started descendant redirect in that lane can still win as control flow.
14. Projection errors are logged and swallowed, and global path misses retain
their `_notFound` representation, which may be a successful fuzzy/root
match without an attached error.
@@ -1216,30 +1198,31 @@ When changing this architecture, verify all of the following:
exclusion derive the same cutoff, while SSR transport may cap its payload
at the terminal prefix.
16. Every discarded semantic match and background candidate releases resources
- exactly once; bulk release detaches every lease and registry entry before
- aborting any collected flight controller.
-17. A background write checks both writer and exact base identity. A successor
- may join its flight but never adopts its candidate lane.
+ exactly once; bulk release detaches every lease before aborting any
+ collected flight controller.
+17. A background write checks both writer and exact base identity. Its private
+ candidate flight is not discoverable by a successor lane.
18. Background publication waits for the foreground acknowledgement, then
remains detached from foreground completion. Its eager settlement witness
is retained across that wait so chronological failure/control selection is
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.
+ identifies the exact generation by presentation-array identity and installs
+ it in one superseding slot before running the possibly reentrant transition
+ callback. Only `true` gates `onRendered` and pending minimum timing.
+21. Exactly one mounted `Matches` tree owns a router's React acknowledgement at
+ a time. Final unmount retires it, and a later mount creates a new owner from
+ the router's current presentation.
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.
24. Request abort can settle every awaited server phase; late work cannot regain
response or injection authority.
25. Exactly one of the request handler or an accepted stream owns SSR cleanup.
- Accepted streams remain bound to request abort, and disposal releases router
- state before best-effort body cancellation.
+ Every handed-off response body remains bound to request abort. Disposal
+ releases router state before best-effort body cancellation, and response
+ replacement never waits for that cancellation to settle.
26. Cleanup prevents a still-pending custom dehydration from beginning
serialization.
@@ -1260,7 +1243,8 @@ Useful assertions include:
events, without asserting a relative `onEnter`/`onStay` order;
- preload/cache reuse observable through user loader calls;
- HTTP status, headers, redirects, and absence of renderer invocation;
-- request-abort settlement, late-stream disposal, and single stream cleanup;
+- request-abort settlement after body handoff, detached replacement disposal,
+ and single stream cleanup;
- hydration output, completion on rejection, and absence of client reruns below
server terminal boundaries;
- absence of stale data/assets after supersession; and
diff --git a/packages/router-core/src/load-client.ts b/packages/router-core/src/load-client.ts
index 897d479e09..f0c7432128 100644
--- a/packages/router-core/src/load-client.ts
+++ b/packages/router-core/src/load-client.ts
@@ -2,12 +2,7 @@
// can rewrite relative imports for both ESM and CJS.
import { isNotFound } from './not-found'
import { isRedirect } from './redirect'
-import {
- _getUserHistoryState,
- getLocationChangeInfo,
- runRouteLifecycle,
-} from './router'
-import { deepEqual } from './utils'
+import { getLocationChangeInfo, runRouteLifecycle } from './router'
import { hydrateSsrMatchId } from './ssr/ssr-match-id'
import type { GLOBAL_SEROVAL, GLOBAL_TSR } from './ssr/constants'
import type { AnySerializationAdapter } from './ssr/serializer/transformer'
@@ -25,12 +20,6 @@ import type {
import type { AnyRedirect } from './redirect'
import type { AnyRouter } from './router'
-type RouteComponentType =
- | 'component'
- | 'pendingComponent'
- | 'errorComponent'
- | 'notFoundComponent'
-
export function replaceRouteChunk(
route: AnyRoute,
lazyFn: AnyRoute['lazyFn'],
@@ -39,33 +28,29 @@ export function replaceRouteChunk(
route._lazy = undefined
}
-function preloadComponent(
- route: AnyRoute,
- type: RouteComponentType,
-): Promise | undefined {
- return (route.options[type] as any)?.preload?.()
-}
-
-function loadComponents(route: AnyRoute): Promise | undefined {
- const component = preloadComponent(route, 'component')
- const pending = preloadComponent(route, 'pendingComponent')
- if (component && pending) {
- return Promise.all([component, pending]).then(() => {})
- }
- return component ?? pending
-}
-
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)
+ const afterLazy = () => {
+ if (componentType === false) {
+ return
+ }
+ if (componentType) {
+ return (route.options[componentType] as any)?.preload?.()
+ }
+ const component = (route.options.component as any)?.preload?.()
+ const pending = (route.options.pendingComponent as any)?.preload?.()
+ if (onPendingReady) {
+ pending ? pending.then(onPendingReady, () => {}) : onPendingReady()
+ }
+ if (component && pending) {
+ return Promise.all([component, pending]).then(() => {})
+ }
+ return component ?? pending
+ }
const current = route._lazy
if (current) {
return current === true ? afterLazy() : current.then(afterLazy)
@@ -114,7 +99,8 @@ export function _getAssetMatches(
const match = matches[index]!
// `_assetEnd` is only ever set on hydration presentation clones that are
// `status: 'pending'`, `ssr: 'data-only'`, error-free, and not not-found
- // (see hydrate.ts), and commits clear it — so its presence alone is the guard.
+ // (see hydration reconstruction below), and commits clear it — so its
+ // presence alone is the guard.
if (match._assetEnd !== undefined) {
end = Math.min(end, Math.max(index + 1, match._assetEnd))
continue
@@ -138,7 +124,7 @@ type LanePhase = 'matched' | 'contextualized' | 'reduced' | 'projected'
* matches). The brand is phantom — it never exists at runtime.
*/
type LaneMatches = Array & {
- readonly [lanePhase]?: TPhase
+ readonly [lanePhase]: TPhase
}
type Lane = [
@@ -146,7 +132,7 @@ type Lane = [
matches: LaneMatches,
background?: Array,
backgroundSettlement?: Promise,
-] & { readonly [lanePhase]?: TPhase }
+] & { readonly [lanePhase]: TPhase }
type MatchedLane = Lane<'matched'>
type ContextualizedLane = Lane<'contextualized'>
@@ -160,61 +146,51 @@ const NOT_FOUND = 2
const REDIRECTED = 3
const CANCELED = 4
-type LoaderOutcome =
- | [typeof SUCCESS, data: unknown]
+type SharedLoaderFailure =
| [typeof ERROR, error: unknown]
| [typeof NOT_FOUND, error: NotFoundError]
| [typeof REDIRECTED, redirect: AnyRedirect]
- | [typeof CANCELED]
-type IndexedOutcome = [index: number, outcome: LoaderOutcome, boundary?: number]
+type LoaderFailure = SharedLoaderFailure | [typeof CANCELED]
+
+type LoaderOutcome =
+ | [
+ typeof SUCCESS,
+ data: unknown,
+ updatedAt?: number,
+ ]
+ | SharedLoaderFailure
+
+type LoaderSuccess = [
+ typeof SUCCESS,
+ data: unknown,
+ updatedAt: number,
+]
+
+type LoaderResult = LoaderSuccess | LoaderFailure
+type LoaderFlightResult = LoaderSuccess | SharedLoaderFailure
+
+type IndexedOutcome = [index: number, outcome: LoaderFailure, boundary?: number]
+
+declare const flightGeneration: unique symbol
export type LoaderFlight = [
- outcome: Promise,
+ outcome: Promise,
controller: AbortController,
leases: number,
-]
+ registry: Map,
+] & { readonly [flightGeneration]: true }
type WorkMatch = AnyRouteMatch & {
_flight?: LoaderFlight
+ _claimedFlight?: LoaderFlight
}
declare const matchPhase: unique symbol
-/**
- * A match whose loader outcome has been applied by `settleInto`, which is the
- * sole granter of this brand (phantom, zero-runtime). Consumers that require
- * it — e.g. `cacheLoaderMatch` — can only be reached after settlement, so the
- * compiler enforces the loader→settle→cache ordering. Sources that arrive
- * already settled (dehydrated server data) must cast at a named boundary.
- */
+/** A match whose loader outcome has been applied by `settle`. */
type SettledMatch = WorkMatch & { readonly [matchPhase]: 'settled' }
-export type LaneInputs = [
- routeTree: AnyRoute,
- context: unknown,
- additionalContext: unknown,
- state: object,
- search: object,
- maskedLocation:
- | [
- href: string,
- state: object,
- search: object,
- unmaskOnReload: boolean | undefined,
- ]
- | undefined,
-]
-
-export type ActivePreload = [
- matches: Array,
- controller: AbortController,
- result: Promise,
- semanticOwner: Array,
- inputs: LaneInputs,
- redirects: number,
-]
-
export type LoadTransaction = [
controller: AbortController,
redirects: number,
@@ -244,8 +220,6 @@ export type PendingSession = [
]
type CoordinatorRouter = AnyRouter & {
- /** Whole speculative lanes that a matching navigation may adopt. */
- _preloads?: Map
_refreshNextLoad?: boolean
_rollbackRefresh?: () => void
_cancelTransition?: () => void
@@ -261,14 +235,14 @@ type PublicationCheckpoint = {
type LoaderTask = [
index: number,
- outcome: Promise,
+ outcome: Promise,
ready: Promise,
candidate?: WorkMatch,
]
type BackgroundLoaderTask = [
index: number,
- outcome: Promise,
+ outcome: Promise,
ready: Promise,
candidate: WorkMatch,
]
@@ -278,7 +252,7 @@ type ExecuteLaneOptions = [
redirects: number,
isCurrent: () => boolean,
base: Array,
- preload?: boolean,
+ preload: boolean,
sync?: boolean,
forceStaleReload?: boolean,
resolvedPrefix?: number,
@@ -317,6 +291,16 @@ export function getRoute(router: AnyRouter, match: WorkMatch): AnyRoute {
return (router.routesById as Record)[match.routeId]!
}
+function normalize(
+ value: unknown,
+ rejected: true,
+ routeId?: string,
+): SharedLoaderFailure
+function normalize(
+ value: unknown,
+ rejected: false,
+ routeId?: string,
+): LoaderOutcome
function normalize(
value: unknown,
rejected: boolean,
@@ -335,7 +319,7 @@ function normalize(
return rejected ? [ERROR, value] : [SUCCESS, value]
}
-function normalizeError(route: AnyRoute, cause: unknown): LoaderOutcome {
+function normalizeError(route: AnyRoute, cause: unknown): SharedLoaderFailure {
let outcome = normalize(cause, true, route.id)
if (outcome[0] !== ERROR) {
return outcome
@@ -352,7 +336,7 @@ function normalizeLaneError(
route: AnyRoute,
cause: unknown,
options: ExecuteLaneOptions,
-): LoaderOutcome {
+): LoaderFailure {
if (options[0].signal.aborted || !options[2]()) {
options[0].abort()
return [CANCELED]
@@ -376,7 +360,7 @@ async function contextualize(
): Promise {
const [location, matches] = lane
const signal = options[0].signal
- const preload = !!options[4]
+ const preload = options[4]
for (let index = options[7] ?? 0; index < end; index++) {
const match = matches[index]!
const route = getRoute(router, match)
@@ -414,7 +398,7 @@ async function contextualize(
}
match.context = context
} catch (cause) {
- releaseFlight(router, match)
+ releaseFlight(match)
return [index, normalizeLaneError(route, cause, options)]
}
if (signal.aborted || !options[2]()) {
@@ -423,7 +407,7 @@ async function contextualize(
}
const validationError = match.paramsError ?? match.searchError
if (validationError !== undefined) {
- releaseFlight(router, match)
+ releaseFlight(match)
return [index, normalizeLaneError(route, validationError, options)]
}
const beforeLoad = route.options.beforeLoad
@@ -462,7 +446,7 @@ async function contextualize(
}
const outcome = normalize(result, false, route.id)
if (outcome[0] !== SUCCESS) {
- releaseFlight(router, match)
+ releaseFlight(match)
return [index, outcome]
}
match.context = {
@@ -470,7 +454,7 @@ async function contextualize(
...result,
}
} catch (cause) {
- releaseFlight(router, match)
+ releaseFlight(match)
return [index, normalizeLaneError(route, cause, options)]
} finally {
if (previousStatus === 'success' && match.status === 'pending') {
@@ -483,79 +467,57 @@ async function contextualize(
return
}
-function releaseOwnedFlight(
- router: AnyRouter,
- id: string,
- flight?: LoaderFlight,
+function detachOwnedFlight(
+ matchId: string,
+ flight: LoaderFlight | undefined,
): AbortController | undefined {
- if (!flight || --flight[2]) {
- return
- }
- if (router._flights?.get(id) === flight) {
- router._flights.delete(id)
+ if (flight && !--flight[2]) {
+ if (flight[3].get(matchId) === flight) {
+ flight[3].delete(matchId)
+ }
+ return flight[1]
}
- return flight[1]
+ return
}
-function releaseFlight(router: AnyRouter, match: WorkMatch): void {
+function detachFlight(match: WorkMatch): AbortController | undefined {
const flight = match._flight
match._flight = undefined
- releaseOwnedFlight(router, match.id, flight)?.abort()
+ return detachOwnedFlight(match.id, flight)
}
-export function laneInputs(
- router: AnyRouter,
- location: ParsedLocation,
-): LaneInputs {
- const masked = location.maskedLocation
- return [
- router.routeTree,
- router.options.context ?? {},
- router.options.additionalContext,
- _getUserHistoryState(location.state),
- location.search,
- masked && [
- masked.href,
- _getUserHistoryState(masked.state),
- masked.search,
- masked.unmaskOnReload,
- ],
- ]
+
+function detachClaim(match: WorkMatch): AbortController | undefined {
+ const flight = match._claimedFlight
+ match._claimedFlight = undefined
+ return detachOwnedFlight(match.id, flight)
}
-function samePreloadLane(
- preload: ActivePreload,
- router: AnyRouter,
- location: ParsedLocation,
- redirects: number,
-): boolean {
- return (
- preload[3] === router._committed &&
- preload[5] === redirects &&
- deepEqual(preload[4], laneInputs(router, location)) &&
- !preload[0].some(
- (match) => getRoute(router, match as WorkMatch).options.preload === false,
- )
- )
+function releaseFlight(match: WorkMatch): void {
+ const claimed = detachClaim(match)
+ const accepted = detachFlight(match)
+ claimed?.abort()
+ accepted?.abort()
}
-/**
- * Not passing in a `next` ownership recipient
- * is equivalent to discarding the match resources
- */
+function retireFlight(matchId: string, flight: LoaderFlight): void {
+ if (flight[3].get(matchId) === flight) {
+ flight[3].delete(matchId)
+ }
+}
+
+/** Release every loader-flight lease owned by a discarded match set. */
export function transferMatchResources(
- router: AnyRouter,
previous: Array,
- next?: Array,
): void {
const abort: Array = []
for (const match of previous as Array) {
- if (!next?.includes(match)) {
- const flight = match._flight
- match._flight = undefined
- const controller = releaseOwnedFlight(router, match.id, flight)
- if (controller) {
- abort.push(controller)
- }
+ const claimed = detachClaim(match)
+ const accepted = detachFlight(match)
+ if (claimed) {
+ abort.push(claimed)
+ }
+ if (accepted) {
+ abort.push(accepted)
}
}
for (const controller of abort) {
@@ -563,16 +525,15 @@ export function transferMatchResources(
}
}
-function discardPreload(router: AnyRouter, preload: ActivePreload): void {
- preload[1].abort()
- transferMatchResources(router, preload[0])
-}
-
-function acquireMatchResources(matches: Array): void {
+function claimPendingFlights(
+ router: AnyRouter,
+ matches: Array,
+): void {
for (const match of matches as Array) {
- const flight = match._flight
- if (flight) {
+ const flight = router._flights.get(match.id)
+ if (flight && flight !== match._flight) {
flight[2]++
+ match._claimedFlight = flight
}
}
}
@@ -624,97 +585,96 @@ async function loadResource(
lane: ContextualizedLane,
match: WorkMatch,
route: AnyRoute,
- loader: RouteLoaderFn | undefined,
+ loader: RouteLoaderFn,
parentMatchPromise: Promise | undefined,
preload: boolean,
owner: AbortController,
-): Promise {
+): Promise {
const signal = owner.signal
if (signal.aborted) {
return [CANCELED]
}
- if (!loader) {
- return [SUCCESS, undefined]
- }
- let flight = match._flight
- let joined = !!flight
setFetching(router, match, 'loader', owner)
+ const registry = router._flights
try {
- for (;;) {
- if (!flight) {
- const controller = new AbortController()
- const outcome = Promise.resolve()
- .then(() =>
- loader(
- getLoaderContext(
- router,
- lane,
- match,
- route,
- controller,
- parentMatchPromise,
- preload,
- ),
- ),
- )
- .then(
- (value) => normalize(value, false, route.id),
- (cause) => normalize(cause, true, route.id),
- )
- .then((result): LoaderOutcome => {
- return result[0] === ERROR && match._flight === flight
- ? normalizeError(route, result[1])
- : result
- })
- flight = [outcome, controller, 1]
- ;(router._flights ??= new Map()).set(match.id, flight)
+ let flight = match._claimedFlight
+ match._claimedFlight = undefined
+ if (!flight) {
+ flight = registry.get(match.id)
+ if (flight) {
+ flight[2]++
}
- 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]
- }
+ }
+ if (!flight) {
+ const controller = new AbortController()
+ const outcome = Promise.resolve()
+ .then(() =>
+ loader(
+ getLoaderContext(
+ router,
+ lane,
+ match,
+ route,
+ controller,
+ parentMatchPromise,
+ preload,
+ ),
+ ),
+ )
+ .then(
+ (value): LoaderFlightResult => {
+ const result = normalize(value, false, route.id)
+ if (result[0] === SUCCESS) {
+ result[2] = Date.now()
+ } else {
+ retireFlight(match.id, flight!)
+ }
+ return result as LoaderFlightResult
+ },
+ (cause) => {
+ retireFlight(match.id, flight!)
+ return flight![2]
+ ? normalizeError(route, cause)
+ : normalize(cause, true, route.id)
+ },
+ )
+ flight = [outcome, controller, 1, registry] as LoaderFlight
+ registry.set(match.id, flight)
+ }
+ match._flight = flight
+ match.abortController = flight[1]
+ try {
+ return await waitFor(flight[0], signal)
+ } catch (cause) {
+ if (cause !== signal) {
throw cause
}
- releaseFlight(router, match)
- if (signal.aborted) {
- return [CANCELED]
- }
- flight = undefined
- joined = false
+ releaseFlight(match)
+ return [CANCELED]
}
} finally {
setFetching(router, match, false, owner)
}
}
-function settleInto(
+function settle(
match: WorkMatch,
- result: LoaderOutcome,
+ result: LoaderResult,
preload: boolean,
-): asserts match is SettledMatch {
- if (result[0] === SUCCESS) {
- match.loaderData = result[1]
+): match is SettledMatch {
+ const success = result[0] === SUCCESS
+ if (result[0] < REDIRECTED) {
match.error = undefined
match.status = 'success'
- match.invalid = false
- match.updatedAt = Date.now()
- match.preload = preload
- } else if (result[0] !== REDIRECTED) {
- // Reduction installs only the selected terminal failure. Every other
- // settled attempt remains a renderable, stale match in that lane.
- match.status = 'success'
- match.error = undefined
- match.invalid = true
+ match.invalid = !success
+ if (success) {
+ match.loaderData = result[1]
+ match.updatedAt = result[2]
+ match.preload = preload
+ }
}
+ return success
}
export function cacheLoaderMatch(
@@ -723,13 +683,16 @@ export function cacheLoaderMatch(
planned: AnyRouteMatch | undefined,
): void {
const current = router._cache.get(match.id) as WorkMatch | undefined
+ const flight = match._flight
if (
current !== planned ||
- router._committed.some(
- (candidate) =>
- candidate.id === match.id &&
- (candidate as WorkMatch)._flight === match._flight,
- )
+ (flight &&
+ (flight[3].get(match.id) !== flight ||
+ (
+ router._committed.find((candidate) => candidate.id === match.id) as
+ | WorkMatch
+ | undefined
+ )?._flight === flight))
) {
return
}
@@ -743,13 +706,13 @@ export function cacheLoaderMatch(
}
router._cache.set(match.id, cached)
if (current) {
- releaseFlight(router, current)
+ releaseFlight(current)
}
}
function getParentSnapshot(
match: WorkMatch,
- outcome: LoaderOutcome,
+ outcome: LoaderResult,
): WorkMatch {
if (outcome[0] === ERROR || outcome[0] === NOT_FOUND) {
return {
@@ -772,12 +735,16 @@ function createLoaderTask(
): Promise {
const match = lane[1][index]!
const route = getRoute(router, match)
- const preload = !!options[4]
+ const preload = options[4]
+ if (preload && route.options.preload === false) {
+ match.isFetching = false
+ return Promise.resolve(match)
+ }
const plannedCacheMatch = preload ? router._cache.get(match.id) : undefined
+ let configured
let reload = false
- let reloadFailure: LoaderOutcome | undefined
+ let reloadFailure: LoaderFailure | undefined
try {
- let configured
if (match.status === 'success') {
configured = route.options.shouldReload
if (typeof configured === 'function') {
@@ -803,11 +770,13 @@ function createLoaderTask(
reload = true
} else {
const staleAge =
- options[4] || match.preload
+ preload || match.preload
? (route.options.preloadStaleTime ??
router.options.defaultPreloadStaleTime ??
30_000)
- : (route.options.staleTime ?? router.options.defaultStaleTime ?? 0)
+ : (route.options.staleTime ??
+ router.options.defaultStaleTime ??
+ 0)
reload = !!(
match.invalid ||
configured ||
@@ -825,14 +794,23 @@ function createLoaderTask(
}
} catch (cause) {
match.invalid = true
- releaseFlight(router, match)
+ releaseFlight(match)
reloadFailure = normalizeLaneError(route, cause, options)
}
const routeLoader = route.options.loader
const loader =
typeof routeLoader === 'function' ? routeLoader : routeLoader?.handler
+ let claimed = match._claimedFlight
+ if (claimed) {
+ if (configured === undefined && !reloadFailure && loader) {
+ reload = true
+ } else {
+ detachClaim(match)?.abort()
+ claimed = undefined
+ }
+ }
const background = !!(
- routeLoader &&
+ loader &&
reload &&
match.status === 'success' &&
!preload &&
@@ -842,23 +820,29 @@ function createLoaderTask(
: routeLoader?.staleReloadMode) ??
router.options.defaultStaleReloadMode) !== 'blocking'
)
- const loaded = reload && (!preload || route.options.preload !== false)
const blocking =
- loaded && !background && (match.status !== 'success' || !!routeLoader)
- if (loaded && !routeLoader) {
+ reload && !background && (match.status !== 'success' || !!loader)
+ if (!reload) {
+ match.isFetching = false
+ } else if (!loader) {
match.invalid = false
match.updatedAt = Date.now()
- }
- let donor = loaded && routeLoader ? router._flights?.get(match.id) : undefined
- if (donor === match._flight) {
- donor = undefined
- } else if (donor) {
- donor[2]++
+ } else if (!claimed) {
+ if (configured === undefined) {
+ if (match._flight) {
+ retireFlight(match.id, match._flight)
+ }
+ } else {
+ router._flights.delete(match.id)
+ }
}
if (blocking) {
- const acceptedFlight = match._flight
- match._flight = donor
- releaseOwnedFlight(router, match.id, acceptedFlight)?.abort()
+ if (claimed) {
+ const accepted = detachFlight(match)
+ accepted?.abort()
+ } else {
+ releaseFlight(match)
+ }
// A successful route without a loader has no blocking work to present. It
// still gets a task so its chunk and derived assets participate in the
// lane, but putting it back into pending would hide an already-rendered
@@ -868,13 +852,14 @@ function createLoaderTask(
}
options[8]?.()
}
- if (!loaded) {
- match.isFetching = false
- }
- const rawOutcome = reloadFailure
+ const rawOutcome: Promise = reloadFailure
? Promise.resolve(reloadFailure)
- : !blocking
- ? Promise.resolve([SUCCESS, match.loaderData])
+ : !blocking || !loader
+ ? Promise.resolve([
+ SUCCESS,
+ match.loaderData,
+ match.updatedAt,
+ ])
: loadResource(
router,
lane,
@@ -886,33 +871,28 @@ function createLoaderTask(
options[0],
)
const outcome = rawOutcome.then((result) => {
- if (blocking) {
- settleInto(match, result, preload)
- if (result[0] === SUCCESS) {
- if (preload && routeLoader) {
- cacheLoaderMatch(router, match, plannedCacheMatch)
- }
- // A route is renderable only after both its data and normal component
- // chunk are ready. Its loader data is already available to descendants.
- match.status = 'pending'
+ if (blocking && settle(match, result, preload)) {
+ if (preload && loader) {
+ cacheLoaderMatch(router, match, plannedCacheMatch)
}
+ // A route is renderable only after both its data and normal component
+ // chunk are ready. Its loader data is already available to descendants.
+ match.status = 'pending'
}
return result
})
const rawChunkFailure = waitFor(
- Promise.resolve().then(() => loadRouteChunk(route)),
+ Promise.resolve().then(() =>
+ loadRouteChunk(route, undefined, options[8]),
+ ),
options[0].signal,
- ).then(
- () => {
- options[8]?.()
- return undefined
- },
+ ).catch(
(cause): IndexedOutcome => [
index,
normalizeLaneError(route, cause, options),
],
- )
+ ) as Promise
const chunkFailure = rawChunkFailure.then((failure) =>
outcome.then((result) => {
if (
@@ -936,8 +916,10 @@ function createLoaderTask(
...match,
status: 'pending',
preload: false,
- _flight: donor,
+ _flight: undefined,
+ _claimedFlight: claimed,
}
+ match._claimedFlight = undefined
match.invalid = false
match.isFetching = 'loader'
const backgroundOutcome = loadResource(
@@ -951,7 +933,10 @@ function createLoaderTask(
options[0],
).then((result) => {
match.isFetching = false
- settleInto(candidate, result, false)
+ if (result[0] === SUCCESS) {
+ retireFlight(match.id, candidate._flight!)
+ }
+ settle(candidate, result, false)
return result
})
;(lane[2] ??= []).push([index, backgroundOutcome, chunkFailure, candidate])
@@ -966,6 +951,7 @@ async function getNotFoundBoundary(
indexed: IndexedOutcome | undefined,
signal: AbortSignal,
fallback = 0,
+ preload?: boolean,
): Promise {
const cause = indexed?.[1][1] as NotFoundError | undefined
let index = cause?.routeId
@@ -976,13 +962,15 @@ async function getNotFoundBoundary(
}
for (let i = index; i >= 0; i--) {
const route = getRoute(router, matches[i]!)
- const loading = loadRouteChunk(route, false)
- if (loading) {
- try {
- await waitFor(loading, signal)
- } catch (cause) {
- if (cause === signal) {
- throw cause
+ if (!preload || route.options.preload !== false) {
+ const loading = loadRouteChunk(route, false)
+ if (loading) {
+ try {
+ await waitFor(loading, signal)
+ } catch (cause) {
+ if (cause === signal) {
+ throw cause
+ }
}
}
}
@@ -993,16 +981,30 @@ async function getNotFoundBoundary(
return cause?.routeId ? index : fallback
}
-function discardBackground(router: AnyRouter, lane: Lane): void {
+function discardBackground(lane: Lane): void {
if (lane[2]) {
- transferMatchResources(
- router,
- lane[2].map((task) => task[3]),
- )
+ transferMatchResources(lane[2].map((task) => task[3]))
lane[2] = undefined
}
}
+function waitForDescendantRedirects(
+ tasks: Array | undefined,
+ boundary: number,
+): Promise {
+ return Promise.all(
+ (tasks ?? []).map(
+ (task) =>
+ task[0] > boundary &&
+ task[1].then((outcome) => {
+ if (outcome[0] === REDIRECTED) {
+ throw [task[0], outcome] as IndexedOutcome
+ }
+ }),
+ ),
+ )
+}
+
async function settleTasks(
tasks: Array,
serialFailure?: IndexedOutcome,
@@ -1022,22 +1024,11 @@ async function settleTasks(
if (outcome[0] >= REDIRECTED) {
throw [taskIndex, outcome] as IndexedOutcome
}
- if (!loaderFailure && outcome[0] !== SUCCESS) {
- loaderFailure = [taskIndex, outcome]
+ if (outcome[0] !== SUCCESS) {
+ loaderFailure ||= [taskIndex, outcome]
// Every started descendant must settle before an ordinary failure
// wins because a redirect from any of them remains control flow.
- await Promise.all(
- (redirectTasks ?? []).map((nextTask) => {
- if (nextTask[0] <= taskIndex) {
- return
- }
- return nextTask[1].then((nextOutcome) => {
- if (nextOutcome[0] === REDIRECTED) {
- throw [nextTask[0], nextOutcome] as IndexedOutcome
- }
- })
- }),
- )
+ await waitForDescendantRedirects(redirectTasks, taskIndex)
}
}),
),
@@ -1056,6 +1047,7 @@ async function reduceLane(
redirects: number,
settlement: Promise,
onReady?: () => void,
+ preload?: boolean,
): Promise {
const matches = lane[1]
let failure = await settlement
@@ -1063,7 +1055,14 @@ async function reduceLane(
const plannedBoundary = matches.findIndex((match) => match._notFound)
const boundaryOf = (found: IndexedOutcome) =>
found[1][0] === NOT_FOUND
- ? getNotFoundBoundary(router, matches, found, controller.signal)
+ ? getNotFoundBoundary(
+ router,
+ matches,
+ found,
+ controller.signal,
+ 0,
+ preload,
+ )
: found[0]
let readinessEnd = plannedBoundary < 0 ? matches.length : plannedBoundary
@@ -1101,6 +1100,17 @@ async function reduceLane(
break
}
+ if (failure && failure[1][0] < REDIRECTED) {
+ try {
+ await waitForDescendantRedirects(
+ lane[2],
+ (failure[2] ??= await boundaryOf(failure)),
+ )
+ } catch (cause) {
+ failure = cause as IndexedOutcome
+ }
+ }
+
if ((failure?.[1][0] ?? 0) >= REDIRECTED) {
const outcome = failure![1]
if (
@@ -1108,7 +1118,7 @@ async function reduceLane(
outcome[1].options.reloadDocument ||
redirects < 20
) {
- discardBackground(router, lane)
+ discardBackground(lane)
return outcome as ControlOutcome
}
redirectLimitExceeded = true
@@ -1123,44 +1133,44 @@ async function reduceLane(
const kind = outcome?.[0]
const match = matches[boundary]!
const cause = outcome?.[1]
- const install = () => {
- if (outcome) {
- match._notFound = undefined
- if (kind === ERROR) {
- match.status = 'error'
+ const route = getRoute(router, match)
+ if (outcome) {
+ match._notFound = undefined
+ if (kind === ERROR) {
+ match.status = 'error'
+ } else {
+ ;(cause as NotFoundError).routeId = match.routeId
+ if (match.routeId === router.routeTree.id) {
+ match.status = 'success'
+ match._notFound = true
} else {
- ;(cause as NotFoundError).routeId = match.routeId
- if (match.routeId === router.routeTree.id) {
- match.status = 'success'
- match._notFound = true
- } else {
- match.status = 'notFound'
- }
+ match.status = 'notFound'
}
- match.error = cause
- match.isFetching = false
}
+ match.error = cause
+ match.isFetching = false
}
- install()
- try {
- await waitFor(
- outcome
- ? Promise.resolve().then(() =>
- loadRouteChunk(
- getRoute(router, match),
- kind === ERROR ? 'errorComponent' : 'notFoundComponent',
- ),
- )
- : Promise.all([
- loadRouteChunk(getRoute(router, match)),
- loadRouteChunk(getRoute(router, match), 'notFoundComponent'),
- ]),
- controller.signal,
- )
- } catch (cause) {
- if (cause === controller.signal) {
- discardBackground(router, lane)
- return [CANCELED]
+ if (!preload || route.options.preload !== false) {
+ try {
+ await waitFor(
+ outcome
+ ? Promise.resolve().then(() =>
+ loadRouteChunk(
+ route,
+ kind === ERROR ? 'errorComponent' : 'notFoundComponent',
+ ),
+ )
+ : Promise.all([
+ loadRouteChunk(route),
+ loadRouteChunk(route, 'notFoundComponent'),
+ ]),
+ controller.signal,
+ )
+ } catch (cause) {
+ if (cause === controller.signal) {
+ discardBackground(lane)
+ return [CANCELED]
+ }
}
}
if (!outcome) {
@@ -1173,13 +1183,12 @@ async function reduceLane(
...tasks.map((task) => task[2]),
...(lane[2] ?? []).map((task) => task[1]),
])
- discardBackground(router, lane)
- transferMatchResources(router, matches)
- install()
+ discardBackground(lane)
+ transferMatchResources(matches)
}
}
- return lane as ReducedLane
+ return lane as unknown as ReducedLane
}
export async function projectLane(
@@ -1225,7 +1234,7 @@ export async function projectLane(
break
}
}
- return lane as ProjectedLane
+ return lane as unknown as ProjectedLane
}
async function executeClientLane(
@@ -1243,6 +1252,7 @@ async function executeClientLane(
undefined,
options[0].signal,
plannedBoundary,
+ options[4],
)
if (boundary !== plannedBoundary) {
matches[plannedBoundary]!._notFound = undefined
@@ -1270,6 +1280,8 @@ async function executeClientLane(
matched[1],
failure,
options[0].signal,
+ 0,
+ options[4],
)
end = Math.min(end, failure[2] + 1)
} else if ((failure?.[1][0] ?? 0) >= REDIRECTED) {
@@ -1281,23 +1293,27 @@ async function executeClientLane(
}
semanticParent = createLoaderTask(
router,
- matched as ContextualizedLane,
+ matched as unknown as ContextualizedLane,
index,
tasks,
semanticParent,
options,
)
}
+ for (const match of matched[1]) {
+ detachClaim(match)?.abort()
+ }
let reduced: ReducedLane | ControlOutcome
try {
const reduction = reduceLane(
router,
- matched as ContextualizedLane,
+ matched as unknown as ContextualizedLane,
tasks,
options[0],
options[1],
settleTasks(tasks, failure, matched[2]),
options[8],
+ options[4],
)
if (matched[2]?.length) {
matched[3] = settleTasks(
@@ -1315,7 +1331,7 @@ async function executeClientLane(
}
reduced = await reduction
} catch (cause) {
- discardBackground(router, matched)
+ discardBackground(matched)
throw cause
}
if (isControl(reduced)) {
@@ -1329,49 +1345,6 @@ async function executeClientLane(
)
}
-/**
- * Finds the first route that should show pending UI and its two timing values.
- * A fallback already on screen remains selected after its route loads, so we
- * do not jump to a child fallback. Matches put back into pending by invalidation
- * skip pendingMs, and a route without a usable fallback blocks pending UI for deeper routes.
- */
-function pendingConfig(
- router: AnyRouter,
- matches: Array,
-):
- | [delay: number, boundary: number, min: number, component: unknown]
- | undefined
- | void {
- const presented = router.stores.matches.get()
- for (let index = 0; index < matches.length; index++) {
- const match = matches[index]!
- const success = match.status === 'success'
- const visible =
- success &&
- presented[index]?.id === match.id &&
- presented[index]?.status === 'pending'
- if (success && !visible) {
- continue
- }
- const route = getRoute(router, match as WorkMatch)
- const delay =
- visible || match.invalid
- ? 0
- : (route.options.pendingMs ?? router.options.defaultPendingMs)
- const component =
- route.options.pendingComponent ??
- (router.options as any).defaultPendingComponent
- return component && typeof delay === 'number' && delay !== Infinity
- ? [
- delay,
- index,
- route.options.pendingMinMs ?? router.options.defaultPendingMinMs ?? 0,
- component,
- ]
- : undefined
- }
-}
-
/**
* Waits for `pendingMs`, then presents the complete lane. Rendering applies the
* selected boundary cutoff while retaining every match's structural state.
@@ -1394,18 +1367,49 @@ function offerPending(router: CoordinatorRouter, tx: LoadTransaction): void {
router._pending = session = undefined
}
}
- const config = pendingConfig(router, tx[3])
- if (!config) {
+ // The first unresolved match chooses the fallback. A visible fallback stays
+ // selected after its route resolves, and a route without one blocks children.
+ const presented = router.stores.matches.get()
+ let boundary = 0
+ let delay: number | undefined
+ let min = 0
+ let component: unknown
+ for (; boundary < tx[3].length; boundary++) {
+ const match = tx[3][boundary]!
+ const success = match.status === 'success'
+ const visible =
+ success &&
+ presented[boundary]?.id === match.id &&
+ presented[boundary]?.status === 'pending'
+ if (success && !visible) {
+ continue
+ }
+ const route = getRoute(router, match as WorkMatch)
+ delay =
+ visible || match.invalid
+ ? 0
+ : (route.options.pendingMs ?? router.options.defaultPendingMs)
+ component =
+ route.options.pendingComponent ??
+ (router.options as any).defaultPendingComponent
+ if (!component || typeof delay !== 'number' || delay === Infinity) {
+ return
+ }
+ min =
+ route.options.pendingMinMs ?? router.options.defaultPendingMinMs ?? 0
+ break
+ }
+ if (delay === undefined) {
return
}
- const [delay, boundary, min, component] = config
const matchId = tx[3][boundary]!.id
if (!session || session[1] !== boundary || sessionMatchId !== matchId) {
// Hydration and redirects can preserve pending presentation without a session.
// Do not delay it again; conservatively start pendingMinMs from now.
clearTimeout(session?.[3])
- const presented = router.stores.matches.get()[boundary]
- const visible = presented?.id === matchId && presented.status === 'pending'
+ const presentedMatch = presented[boundary]
+ const visible =
+ presentedMatch?.id === matchId && presentedMatch.status === 'pending'
router._pending = session = [
tx,
boundary,
@@ -1436,7 +1440,7 @@ function offerPending(router: CoordinatorRouter, tx: LoadTransaction): void {
}))
offered[boundary]!.status = 'pending'
const ack = router
- .startTransition(() => router.stores.setMatches(offered), offered, true)
+ .startTransition(() => router.stores.setMatches(offered), offered)
.then((rendered) => {
if (
rendered &&
@@ -1471,9 +1475,9 @@ function publishMatches(
router.stores.setMatches(matches)
}
-function discardLane(router: AnyRouter, lane: ProjectedLane): void {
- transferMatchResources(router, lane[1])
- discardBackground(router, lane)
+function discardLane(lane: ProjectedLane): void {
+ transferMatchResources(lane[1])
+ discardBackground(lane)
}
function commitMatches(
@@ -1492,6 +1496,7 @@ function commitMatches(
}
const cut = _getRenderedMatches(matches).length
const cached = new Map()
+ const discarded: Array = []
const now = Date.now()
for (const match of [...previous, ...previousCached.values()]) {
// Rendered-prefix ids and settled successes anywhere in the lane are
@@ -1506,6 +1511,7 @@ function commitMatches(
(index < cut || candidate.status === 'success'),
)
) {
+ discarded.push(match)
continue
}
const work = match as WorkMatch
@@ -1519,29 +1525,26 @@ function commitMatches(
300_000)
: (route.options.gcTime ?? router.options.defaultGcTime ?? 300_000))
) {
+ discarded.push(match)
continue
}
- cached.set(
- match.id,
- previousCached.get(match.id) === match
- ? match
- : ({
- ...match,
- _flight: undefined,
- isFetching: false,
- context: {},
- } as WorkMatch),
- )
+ if (previousCached.get(match.id) === match) {
+ cached.set(match.id, match)
+ } else {
+ cached.set(match.id, {
+ ...match,
+ _flight: undefined,
+ isFetching: false,
+ context: {},
+ } as WorkMatch)
+ discarded.push(match)
+ }
}
// The lane becomes committed before publication can synchronously reenter.
tx[3] = []
router._cache = cached
publishMatches(router, matches)
- transferMatchResources(
- router,
- [...previousCached.values(), ...previous],
- [...matches, ...cached.values()],
- )
+ transferMatchResources(discarded)
runRouteLifecycle(router, previous, matches, () => router._tx === tx)
}
@@ -1568,7 +1571,7 @@ function commitRefreshMatches(
if (!checkpoint.published || router._tx !== tx) {
return
}
- runRouteLifecycle(router, previous, matches, () => router._tx === tx)
+ runRouteLifecycle(router, previous, matches, () => router._tx === tx, true)
}
function settlePublication(
@@ -1579,10 +1582,11 @@ function settlePublication(
return
}
checkpoint.published = false
+ const retained = [...router._cache.values(), ...router._committed]
transferMatchResources(
- router,
- [...checkpoint.previousCache.values(), ...checkpoint.previousMatches],
- [...router._cache.values(), ...router._committed],
+ [...checkpoint.previousCache.values(), ...checkpoint.previousMatches].filter(
+ (match) => !retained.includes(match),
+ ),
)
}
@@ -1601,6 +1605,7 @@ function rollbackPublication(
return false
}
+ router._cancelTransition?.()
const discarded = [...router._cache.values(), ...router._committed]
const restored = [
...checkpoint.previousCache.values(),
@@ -1610,24 +1615,16 @@ function rollbackPublication(
router._committed = checkpoint.previousMatches
checkpoint.published = false
- for (const match of discarded as Array) {
- if (
- !restored.includes(match) &&
- match._flight &&
- router._flights?.get(match.id) === match._flight
- ) {
- router._flights.delete(match.id)
- }
- }
-
finishPending(router, tx)
router.batch(() => {
router.stores.status.set('idle')
router.stores.setMatches(checkpoint.previousPresentation)
})
tx[0].abort()
- transferMatchResources(router, discarded, restored)
- discardBackground(router, lane)
+ transferMatchResources(
+ discarded.filter((match) => !restored.includes(match)),
+ )
+ discardBackground(lane)
if (router._tx === tx && router._commitPromise === checkpoint.commitPromise) {
router._commitPromise?.resolve()
router._commitPromise = undefined
@@ -1664,9 +1661,7 @@ async function transitionRefresh(
if (router._rollbackRefresh === rollback) {
router._rollbackRefresh = undefined
}
- const restored = rollbackPublication(router, tx, lane, checkpoint)
- router._cancelTransition?.()
- return restored
+ return rollbackPublication(router, tx, lane, checkpoint)
}
try {
const rendered = await router.startTransition(commit, lane[1])
@@ -1725,11 +1720,12 @@ function restoreCommitted(
): void {
finishPending(router, tx)
tx[0].abort()
- transferMatchResources(router, tx[3])
+ transferMatchResources(tx[3])
tx[3] = []
if (router._tx !== tx) {
return
}
+ router._cancelTransition?.()
router.batch(() => {
router.stores.status.set('idle')
router.stores.setMatches(router._committed)
@@ -1747,12 +1743,21 @@ async function runBackground(
tasks: Array,
settlement: Promise,
): Promise {
- const next = base.map((match) => ({ ...match }))
- acquireMatchResources(next)
- for (const task of tasks) {
- releaseFlight(router, next[task[0]]!)
- next[task[0]] = task[3]
- }
+ // Tasks are appended in route order, so candidates can transfer ownership
+ // while untouched matches are cloned in the same pass.
+ let taskIndex = 0
+ const next = base.map((match, index) => {
+ const task = tasks[taskIndex]
+ if (task?.[0] === index) {
+ taskIndex++
+ return task[3]
+ }
+ const clone = { ...match } as WorkMatch
+ if (clone._flight) {
+ clone._flight[2]++
+ }
+ return clone
+ })
// Phase jump: the clones inherit beforeLoad context from the committed
// foreground lane, which already ran `contextualize` for these matches.
const lane = [tx[2], next] as ContextualizedLane
@@ -1760,11 +1765,11 @@ async function runBackground(
try {
reduced = await reduceLane(router, lane, tasks, tx[0], tx[1], settlement)
} catch (cause) {
- transferMatchResources(router, next)
+ transferMatchResources(next)
throw cause
}
if (isControl(reduced)) {
- transferMatchResources(router, next)
+ transferMatchResources(next)
if (
reduced[0] === REDIRECTED &&
router._tx === tx &&
@@ -1776,18 +1781,19 @@ async function runBackground(
}
const projected = await projectLane(router, reduced, tx[0].signal)
if (router._tx !== tx || router._committed !== base) {
- transferMatchResources(router, projected[1])
+ transferMatchResources(projected[1])
return
}
- for (const match of projected[1] as Array) {
+ for (const task of tasks) {
+ const match = task[3]
const cached = router._cache.get(match.id) as WorkMatch | undefined
if (cached?._flight && cached._flight === match._flight) {
router._cache.delete(match.id)
- releaseFlight(router, cached)
+ releaseFlight(cached)
}
}
publishMatches(router, projected[1])
- transferMatchResources(router, base, projected[1])
+ transferMatchResources(base)
}
async function runClientTransaction(
@@ -1797,60 +1803,24 @@ async function runClientTransaction(
onReady?: () => void,
sync?: boolean,
resolvedPrefix?: number,
- adopted?: ActivePreload,
- retained?: ActivePreload,
): Promise {
const options: ExecuteLaneOptions = [
tx[0],
tx[1],
() => router._tx === tx && !!tx[3].length,
router._committed,
- undefined,
+ false,
sync,
forceStaleReload,
resolvedPrefix,
onReady,
]
- let result: LaneResult
- try {
- result = adopted
- ? await adopted[2]
- : await executeClientLane(router, tx[2], tx[3], options)
- } finally {
- if (retained) {
- discardPreload(router, retained)
- }
- }
- if (
- adopted &&
- router._tx === tx &&
- ((isControl(result) && result[0] === CANCELED) ||
- (!isControl(result) &&
- result[1].some(
- (match) => match.status !== 'success' || match._notFound,
- )))
- ) {
- // Successful loaders already seeded the cache; retry only the guard lane.
- const donors = tx[3] as Array
- tx[3] = []
- transferMatchResources(router, donors)
- tx[0].abort()
- if (router._tx !== tx) {
- return
- }
- const controller = new AbortController()
- tx[0] = options[0] = controller
- tx[3] = router.matchRoutes(tx[2], {
- _controller: controller,
- })
- acquireMatchResources(tx[3])
- result = await executeClientLane(router, tx[2], tx[3], options)
- }
+ const result = await executeClientLane(router, tx[2], tx[3], options)
if (isControl(result)) {
if (result[0] === REDIRECTED && router._tx === tx) {
finishPending(router, tx)
- transferMatchResources(router, tx[3])
+ transferMatchResources(tx[3])
tx[3] = []
if (router._tx === tx) {
if (process.env.NODE_ENV !== 'production' && tx[6]) {
@@ -1900,7 +1870,7 @@ async function runClientTransaction(
}
if (router._tx !== tx) {
finishPending(router, tx)
- discardLane(router, result)
+ discardLane(result)
return
}
const toLocation = tx[2]
@@ -1911,7 +1881,7 @@ async function runClientTransaction(
const background = result[2]
await router.startViewTransition(async () => {
if (router._tx !== tx) {
- discardLane(router, result)
+ discardLane(result)
return
}
const commit = () => {
@@ -1937,7 +1907,7 @@ async function runClientTransaction(
return
}
if (router._tx !== tx) {
- discardBackground(router, result)
+ discardBackground(result)
return
}
if (background?.length) {
@@ -2028,87 +1998,52 @@ export async function loadClientRoute(
return
}
const sameHref = previousLocation.href === location.href
- let adopted = router._preloads?.get(location.href)
- let retained: ActivePreload | undefined
- if (rematerialize && adopted) {
- router._preloads!.delete(location.href)
- discardPreload(router, adopted)
- adopted = undefined
- if (preflight.signal.aborted || router._tx !== previousOwner) {
- preflight.abort()
- await awaitCurrent(router, previousOwner)
- return
- }
- }
- if (
- adopted &&
- (hydrationController ||
- !samePreloadLane(
- adopted,
- router,
- pendingLocation?.href === location.href ? pendingLocation : location,
- redirects,
- ))
- ) {
- router._preloads!.delete(location.href)
- // Keep incompatible loader flights alive through the real lane's reload
- // decisions so matching generations can still donate their work.
- retained = adopted
- adopted = undefined
- }
let matches: Array
let controller = preflight
let resolvedPrefix: number | undefined
- if (adopted) {
- controller = adopted[1]
- matches = adopted[0]
- router._preloads!.delete(location.href)
- } else {
- try {
- matches =
- process.env.NODE_ENV !== 'production' && rematerialize
- ? router.matchRoutes(location, {
- _controller: preflight,
- _rematerialize: true,
- })
- : router.matchRoutes(location, { _controller: preflight })
- acquireMatchResources(matches)
- } catch (cause) {
- preflight.abort()
- if (retained) {
- discardPreload(router, retained)
- }
- if (!isRedirect(cause)) {
- if (process.env.NODE_ENV !== 'production' && rematerialize) {
- router._refreshNextLoad = undefined
- }
- await awaitCurrent(router)
- router._commitPromise?.resolve()
- router._commitPromise = undefined
- return
+ try {
+ matches =
+ process.env.NODE_ENV !== 'production' && rematerialize
+ ? router.matchRoutes(location, {
+ _controller: preflight,
+ _rematerialize: true,
+ })
+ : router.matchRoutes(location, { _controller: preflight })
+ } catch (cause) {
+ preflight.abort()
+ if (!isRedirect(cause)) {
+ if (process.env.NODE_ENV !== 'production' && rematerialize) {
+ router._refreshNextLoad = undefined
}
- await router.navigate({
- ...cause.options,
- replace: true,
- ignoreBlocker: true,
- })
- await awaitCurrent(router, previousOwner)
+ await awaitCurrent(router)
+ router._commitPromise?.resolve()
+ router._commitPromise = undefined
return
}
- resolvedPrefix = hydrationController ? handoff : undefined
- if (resolvedPrefix) {
- controller = hydrationController!
- } else {
- hydrationController?.abort()
- }
+ await router.navigate({
+ ...cause.options,
+ replace: true,
+ ignoreBlocker: true,
+ })
+ await awaitCurrent(router, previousOwner)
+ return
+ }
+ resolvedPrefix = hydrationController ? handoff : undefined
+ if (resolvedPrefix) {
+ controller = hydrationController!
+ } else {
+ hydrationController?.abort()
}
if (router._preflight !== preflight || router._tx !== previousOwner) {
preflight.abort()
- transferMatchResources(router, matches)
+ transferMatchResources(matches)
await awaitCurrent(router, previousOwner)
return
}
router._preflight = undefined
+ if (!rematerialize) {
+ claimPendingFlights(router, matches)
+ }
const tx: LoadTransaction = [
controller,
@@ -2125,8 +2060,6 @@ export async function loadClientRoute(
() => offerPending(router, tx),
opts?.sync,
resolvedPrefix,
- adopted,
- retained,
),
)
.catch(() => {
@@ -2154,10 +2087,10 @@ export async function loadClientRoute(
}
}
previousOwner[0].abort()
- transferMatchResources(router, previousOwner[3])
+ transferMatchResources(previousOwner[3])
}
if (router._tx !== tx) {
- transferMatchResources(router, tx[3])
+ transferMatchResources(tx[3])
tx[3] = []
await awaitCurrent(router, tx)
return
@@ -2178,6 +2111,7 @@ export async function loadClientRoute(
export async function refreshClientRoute(
router: CoordinatorRouter,
): Promise {
+ router._refreshNextLoad = true
router._rollbackRefresh?.()
const pending = router._tx
if (pending && !pending[6] && router.stores.status.get() === 'pending') {
@@ -2186,129 +2120,74 @@ export async function refreshClientRoute(
await awaitCurrent(router, pending)
}
}
- // Existing owners remain alive for rollback but cannot donate stale work.
- router._flights?.clear()
+ if (process.env.NODE_ENV !== 'production') {
+ router._flights.clear()
+ }
router.clearCache()
- router._refreshNextLoad = true
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,
redirects = 0,
): Promise | undefined> {
- if (redirects > 20) {
- return
- }
- const owner = router._tx
if (
process.env.NODE_ENV !== 'production' &&
- (router._refreshNextLoad || owner?.[6])
+ (router._refreshNextLoad || router._tx?.[6])
) {
return
}
const location = opts._builtLocation ?? router.buildLocation(opts)
const base = router._committed
const controller = new AbortController()
- let matches: Array | undefined
- let preload: ActivePreload | undefined
- let replaced: ActivePreload | undefined
+ let matches: Array
try {
- const pending = router._preloads?.get(location.href)
- if (pending) {
- if (samePreloadLane(pending, router, location, redirects)) {
- const result = await pending[2]
- return isControl(result)
- ? followPreloadRedirect(router, result, location, owner, redirects)
- : result[1]
- }
- router._preloads!.delete(location.href)
- // Keep the superseded lane alive until this lane has made its reload
- // decisions. Its active flights are the synchronous donor authority.
- replaced = pending
- }
matches = router.matchRoutes(location, {
_controller: controller,
})
- acquireMatchResources(matches)
- const promise = Promise.resolve()
- .then(() =>
- executeClientLane(router, location, matches!, [
- controller,
- redirects,
- // Preload lanes run to completion even when unrelated navigations
- // commit: finished work seeds the cache, and adoption safety is
- // enforced independently by samePreloadLane's base identity check.
- () => true,
- base,
- true,
- ]),
- )
- .finally(() => {
- if (replaced) {
- discardPreload(router, replaced)
- }
- })
- preload = [
- matches,
+ } catch (cause) {
+ controller.abort()
+ if (!isNotFound(cause)) {
+ console.error(cause)
+ }
+ return
+ }
+ router._preloads.set(controller, matches)
+ try {
+ const result = await executeClientLane(router, location, matches, [
controller,
- promise,
- base,
- laneInputs(router, location),
redirects,
- ]
- ;(router._preloads ??= new Map()).set(location.href, preload)
- const result = await promise
- if (router._preloads?.get(location.href) !== preload) {
- return isControl(result) ? undefined : result[1]
- }
- router._preloads.delete(location.href)
- if (isControl(result)) {
- controller.abort()
- transferMatchResources(router, matches)
- return followPreloadRedirect(router, result, location, owner, redirects)
+ // Preload lanes run to completion even when unrelated navigations
+ // commit: finished work seeds the cache.
+ () => true,
+ base,
+ true,
+ ])
+ const control = isControl(result)
+ if (!router._preloads.delete(controller)) {
+ return control ? undefined : matches
}
-
- transferMatchResources(router, result[1])
+ transferMatchResources(matches)
controller.abort()
- return result[1]
+ if (!control) {
+ return matches
+ }
+ if (result[0] === REDIRECTED && !result[1].options.reloadDocument) {
+ return preloadClientRoute(
+ router,
+ {
+ ...result[1].options,
+ _fromLocation: location,
+ },
+ redirects + 1,
+ )
+ }
+ return
} catch (cause) {
- if (!preload || router._preloads?.get(location.href) === preload) {
- if (preload) {
- router._preloads!.delete(location.href)
- }
+ if (router._preloads.delete(controller)) {
+ transferMatchResources(matches)
controller.abort()
- if (matches) {
- transferMatchResources(router, matches)
- }
- }
- if (router._tx !== owner) {
- return
}
if (!isNotFound(cause)) {
console.error(cause)
@@ -2380,7 +2259,8 @@ export async function hydrate(router: AnyRouter): Promise {
let location!: AnyRouter['latestLocation']
let candidates!: Array
- let handoffInputs!: ReturnType
+ let handoffHistoryHref!: string
+ let handoffHistoryState: unknown
try {
await waitFor(
router.options.hydrate?.(dehydratedRouter!.dehydratedData),
@@ -2389,10 +2269,12 @@ export async function hydrate(router: AnyRouter): Promise {
if (!isCurrent()) {
return
}
+ const historyLocation = router.history.location
+ handoffHistoryHref = historyLocation.href
+ handoffHistoryState = historyLocation.state
router.updateLatestLocation()
location = router.latestLocation
router.stores.location.set(location)
- handoffInputs = laneInputs(router, location)
candidates = router.matchRoutes(location, {
_controller: controller,
})
@@ -2405,13 +2287,16 @@ export async function hydrate(router: AnyRouter): Promise {
if (!isCurrent()) {
return
}
- const committed: Array = []
+ let committedEnd = 0
let pendingBoundary: number | undefined
let verifiedAssetEnd = 0
- const retryFrom = (index: number) => {
- // The failing route's identity is still verified, but no descendant is.
- verifiedAssetEnd = Math.min(verifiedAssetEnd, index + 1)
- const removed = committed.splice(index)
+ const retryFrom = (index: number, assetEnd = index + 1) => {
+ // The failing route's identity is still verified, but its client context
+ // and assets are not. Present it as the boundary for the client retry.
+ pendingBoundary = Math.min(pendingBoundary ?? index, index)
+ verifiedAssetEnd = Math.min(verifiedAssetEnd, assetEnd)
+ const removed = candidates.slice(index, committedEnd)
+ committedEnd = index
for (const match of removed) {
if (
getRoute(router, match).options.loader &&
@@ -2433,7 +2318,7 @@ export async function hydrate(router: AnyRouter): Promise {
)
}
}
- transferMatchResources(router, removed)
+ transferMatchResources(removed)
}
// A longer server lane is valid only when the local match already caps the
@@ -2476,7 +2361,7 @@ export async function hydrate(router: AnyRouter): Promise {
candidate._notFound
if (terminal) {
isTerminal = true
- committed.push(candidate)
+ committedEnd = index + 1
if (candidate.ssr === false || candidate.ssr === 'data-only') {
pendingBoundary ??= index
}
@@ -2487,24 +2372,20 @@ export async function hydrate(router: AnyRouter): Promise {
break
}
- committed.push(candidate)
+ committedEnd = index + 1
if (candidate.ssr === 'data-only') {
pendingBoundary ??= index
}
}
let verifiedContextEnd = verifiedAssetEnd
- if (
- !isTerminal &&
- committed.length === shared &&
- shared < candidates.length
- ) {
+ if (!isTerminal && committedEnd === shared && shared < candidates.length) {
pendingBoundary = shared
}
// Hooks observe structural membership. Execution remains limited to
- // `committed`, the accepted server prefix.
- const chunks = committed.map(async (match) => {
+ // the accepted server prefix.
+ const chunks = candidates.slice(0, committedEnd).map(async (match) => {
try {
const route = getRoute(router, match)
if (match._notFound) {
@@ -2542,7 +2423,7 @@ export async function hydrate(router: AnyRouter): Promise {
if (!isCurrent()) {
return
}
- if (chunkFailure < committed.length) {
+ if (chunkFailure < committedEnd) {
verifiedContextEnd = Math.min(verifiedContextEnd, chunkFailure)
retryFrom(chunkFailure)
}
@@ -2550,9 +2431,7 @@ export async function hydrate(router: AnyRouter): Promise {
// The first pending match is already visible, so prepare its route context
// without granting its beforeLoad or loader any hydration authority.
const contextEnd = Math.max(
- pendingBoundary === committed.length
- ? committed.length + 1
- : committed.length,
+ pendingBoundary === committedEnd ? committedEnd + 1 : committedEnd,
verifiedContextEnd,
)
for (let index = 0; index < contextEnd; index++) {
@@ -2586,7 +2465,7 @@ export async function hydrate(router: AnyRouter): Promise {
match.status !== 'notFound' &&
!match._notFound
) {
- retryFrom(index)
+ retryFrom(index, index)
break
}
}
@@ -2597,7 +2476,7 @@ export async function hydrate(router: AnyRouter): Promise {
match.context = {
...parentContext,
...routeContext,
- ...(committed[index] && dehydratedMatches[index]!.b),
+ ...(index < committedEnd && dehydratedMatches[index]!.b),
}
}
@@ -2611,10 +2490,11 @@ export async function hydrate(router: AnyRouter): Promise {
if (!isCurrent()) {
return
}
- const needsClientLoad =
- pendingBoundary !== undefined || committed.length < shared
+ const needsClientLoad = pendingBoundary !== undefined || committedEnd < shared
const committedMatches =
- isTerminal && committed.length === shared ? candidates : committed
+ isTerminal && committedEnd === shared
+ ? candidates
+ : candidates.slice(0, committedEnd)
let presented = needsClientLoad ? candidates : committedMatches
let dataOnlyAssetEnd: number | undefined
if (needsClientLoad && pendingBoundary !== undefined) {
@@ -2636,16 +2516,18 @@ export async function hydrate(router: AnyRouter): Promise {
}
}
- const claim = () =>
- needsClientLoad &&
- !router._tx &&
- router.latestLocation.state === location.state &&
- deepEqual(handoffInputs, laneInputs(router, router.latestLocation)) &&
- router._committed === committedMatches &&
- committedMatches.length &&
- !controller.signal.aborted
+ const claim = () => {
+ const historyLocation = router.history.location
+ return needsClientLoad &&
+ !router._tx &&
+ historyLocation.href === handoffHistoryHref &&
+ historyLocation.state === handoffHistoryState &&
+ router._committed === committedMatches &&
+ committedMatches.length &&
+ !controller.signal.aborted
? controller
: undefined
+ }
const handoff: NonNullable = [
claim,
(matches) => {
@@ -2676,7 +2558,7 @@ export async function hydrate(router: AnyRouter): Promise {
if (handoffAssetEnd !== undefined) {
clones[pendingBoundary!]!._assetEnd = handoffAssetEnd
}
- transferMatchResources(router, matches.splice(0, prefix, ...clones))
+ transferMatchResources(matches.splice(0, prefix, ...clones))
for (let index = prefix; index < matches.length; index++) {
const match = matches[index]!
const hydrated = candidates[index]
diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts
index e2c9da3d25..e18269802a 100644
--- a/packages/router-core/src/router.ts
+++ b/packages/router-core/src/router.ts
@@ -58,7 +58,6 @@ import type {
import type { SearchParser, SearchSerializer } from './searchParams'
import type { AnyRedirect, ResolvedRedirect } from './redirect'
import type {
- ActivePreload,
LoadTransaction,
LoaderFlight,
PendingSession,
@@ -778,7 +777,6 @@ export type CommitLocationFn = ({
export type StartTransitionFn = (
fn: () => void,
expected: Array,
- urgent?: boolean,
) => Promise
export interface MatchRoutesFn {
@@ -936,21 +934,41 @@ export function _getUserHistoryState({
return state
}
+function runCallback(
+ callback: ((arg: T) => void) | undefined,
+ arg: T,
+ throwOnError?: boolean,
+): void {
+ if (throwOnError && process.env.NODE_ENV !== 'production') {
+ callback?.(arg)
+ return
+ }
+ try {
+ callback?.(arg)
+ } catch (error) {
+ console.error(error)
+ }
+}
+
/** Run route lifecycle callbacks in leave/enter/stay phases. */
export function runRouteLifecycle(
router: AnyRouter,
previous: Array,
matches: Array,
isCurrent?: () => boolean,
+ throwOnError?: boolean,
): void {
for (const match of previous) {
if (isCurrent?.() === false) {
return
}
if (!matches.some((candidate) => candidate.routeId === match.routeId)) {
- ;(router.routesById as Record)[
- match.routeId
- ]!.options.onLeave?.(match)
+ runCallback(
+ (router.routesById as Record)[match.routeId]!.options
+ .onLeave,
+ match,
+ throwOnError,
+ )
}
}
for (const match of matches) {
@@ -960,11 +978,15 @@ export function runRouteLifecycle(
const route = (router.routesById as Record)[
match.routeId
]!
- route.options[
- previous.some((candidate) => candidate.routeId === match.routeId)
- ? 'onStay'
- : 'onEnter'
- ]?.(match)
+ runCallback(
+ route.options[
+ previous.some((candidate) => candidate.routeId === match.routeId)
+ ? 'onStay'
+ : 'onEnter'
+ ],
+ match,
+ throwOnError,
+ )
}
}
@@ -1025,10 +1047,10 @@ export interface RouterCore<
shouldViewTransition?: boolean | ViewTransitionOptions
/** Current client load transaction and owner of navigation writes. */
_tx?: LoadTransaction
- /** Joinable in-flight loader generations keyed by match ID. */
- _flights?: Map
- /** Whole speculative lanes that an identical navigation may adopt. */
- _preloads?: Map
+ /** Joinable loader generations keyed by match ID. */
+ _flights: Map
+ /** Active speculative lane matches keyed by their cancellation owner. */
+ _preloads: Map>
/** Owns cancellable work before a client transaction publishes. */
_preflight?: AbortController
/** Transfers one reconstructed SSR prefix into its initial client load. */
@@ -1037,8 +1059,6 @@ 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
/** Development-only HMR reload for a route and its descendants. */
_refreshRoute: (() => Promise) | undefined
/** Development-only replacement for a route's lazy chunk owner. */
@@ -1083,9 +1103,12 @@ export class RouterCore<
subscribers = new Set>()
/** Accepted off-screen loader generations keyed by match ID. */
_cache = new Map()
+ /** Joinable loader generations keyed by match ID. */
+ _flights = new Map()
+ /** Active speculative lane matches keyed by their cancellation owner. */
+ _preloads = new Map>()
/** Accepted semantic lane, excluding temporary pending presentation. */
_committed: Array = []
-
// Must build in constructor
stores!: RouterStores
private getStoreConfig!: GetStoreConfig
@@ -1377,11 +1400,7 @@ export class RouterCore<
emit: EmitFn = (routerEvent) => {
for (const listener of this.subscribers) {
if (listener.eventType === routerEvent.type) {
- try {
- listener.fn(routerEvent)
- } catch (e) {
- console.error(e)
- }
+ runCallback(listener.fn, routerEvent)
}
}
}
@@ -1463,7 +1482,6 @@ export class RouterCore<
parsedTempLocation.state.__TSR_key = location.state.__TSR_key
delete parsedTempLocation.state.__tempLocation
-
return {
...parsedTempLocation,
maskedLocation: location,
@@ -1541,14 +1559,7 @@ export class RouterCore<
const matches = new Array(matchedRoutes.length)
const committed = this._committed
- const previousAt = (route: AnyRoute, index: number) => {
- const match = committed[index]
- return match?.routeId === route.id
- ? match
- : route === this.options.notFoundRoute
- ? committed.find((candidate) => candidate.routeId === route.id)
- : undefined
- }
+ const controller = opts?._controller
for (let index = 0; index < matchedRoutes.length; index++) {
const route = matchedRoutes[index]!
@@ -1633,7 +1644,13 @@ export class RouterCore<
// explicit deps
loaderDepsHash
- const previousMatch = previousAt(route, index)
+ let previousMatch = committed[index]
+ if (previousMatch?.routeId !== route.id) {
+ previousMatch =
+ route === this.options.notFoundRoute
+ ? committed.find((candidate) => candidate.routeId === route.id)
+ : undefined
+ }
const existingMatch =
process.env.NODE_ENV !== 'production' && opts?._rematerialize
? undefined
@@ -1702,7 +1719,7 @@ export class RouterCore<
error: undefined,
paramsError,
context: {},
- abortController: opts?._controller ?? new AbortController(),
+ abortController: controller ?? new AbortController(),
cause,
loaderDeps: previousMatch
? replaceEqualDeep(previousMatch.loaderDeps, loaderDeps)
@@ -1730,8 +1747,13 @@ export class RouterCore<
match.cause === 'stay'
? nullReplaceEqualDeep(match.params, routeParams)
: routeParams
- if (opts?._controller) {
+ if (controller) {
match.context = {}
+ const flight = (match as AnyRouteMatch & { _flight?: LoaderFlight })
+ ._flight
+ if (flight) {
+ flight[2]++
+ }
}
}
@@ -2469,13 +2491,37 @@ export class RouterCore<
> = (opts) => {
const committedMatches = this._committed
const filter = opts?.filter
- const invalidIds = filter
- ? new Set(
- [...committedMatches, ...this._cache.values()]
- .filter((match) => filter(match as MakeRouteMatchUnion))
- .map((match) => match.id),
- )
- : undefined
+ let invalidIds: Set | undefined
+ if (filter) {
+ invalidIds = new Set()
+ const collect = (matches: Iterable) => {
+ for (const match of matches) {
+ if (filter(match as MakeRouteMatchUnion)) {
+ invalidIds!.add(match.id)
+ }
+ }
+ }
+ collect(committedMatches)
+ collect(this._cache.values())
+ for (const matches of this._preloads.values()) {
+ collect(matches)
+ }
+ if (this._tx) {
+ collect(this._tx[3])
+ }
+ }
+ const discardedPreloads: Array = []
+ const preloadControllers: Array = []
+ for (const [controller, matches] of this._preloads) {
+ if (
+ !invalidIds ||
+ matches.some((match) => invalidIds.has(match.id))
+ ) {
+ this._preloads.delete(controller)
+ discardedPreloads.push(...matches)
+ preloadControllers.push(controller)
+ }
+ }
const invalidate = (d: MakeRouteMatch) => {
if (!invalidIds || invalidIds.has(d.id)) {
const route = this.routesById[d.routeId] as AnyRoute
@@ -2498,11 +2544,20 @@ export class RouterCore<
const committed = committedMatches.map(invalidate)
this._committed = committed
- const cache = new Map()
for (const [id, match] of this._cache) {
- cache.set(id, invalidate(match))
+ this._cache.set(id, invalidate(match))
+ }
+ if (invalidIds) {
+ for (const id of invalidIds) {
+ this._flights.delete(id)
+ }
+ } else {
+ this._flights.clear()
+ }
+ transferMatchResources(discardedPreloads)
+ for (const controller of preloadControllers) {
+ controller.abort()
}
- this._cache = cache
this.shouldViewTransition = false
return this.load({ sync: opts?.sync })
@@ -2551,36 +2606,33 @@ export class RouterCore<
}
clearCache: ClearCacheFn = (opts) => {
- const cached = this._cache
- const preloads = this._preloads
const filter = opts?.filter
- const retained = new Map()
const discarded: Array = []
- for (const [id, match] of cached) {
- if (filter && !filter(match as MakeRouteMatchUnion)) {
- retained.set(id, match)
- } else {
+ const cacheIds: Array = []
+ for (const [id, match] of this._cache) {
+ if (!filter || filter(match as MakeRouteMatchUnion)) {
+ cacheIds.push(id)
discarded.push(match)
}
}
- const retainedPreloads = new Map()
- const discardedPreloads: Array = []
- for (const [href, preload] of preloads ?? []) {
- if (!filter || preload[0].some(filter as any)) {
- discardedPreloads.push(preload)
- discarded.push(...preload[0])
- } else {
- retainedPreloads.set(href, preload)
+ const preloadControllers: Array = []
+ for (const [controller, matches] of this._preloads) {
+ if (!filter || matches.some(filter as any)) {
+ discarded.push(...matches)
+ preloadControllers.push(controller)
}
}
-
- // Install both replacement authorities before releasing a public loader
- // signal, whose abort listeners can synchronously reenter the router.
- this._cache = retained
- this._preloads = retainedPreloads
- transferMatchResources(this, discarded)
- for (const preload of discardedPreloads) {
- preload[1].abort()
+ // Update both authorities before releasing a public loader signal, whose
+ // abort listeners can synchronously reenter the router.
+ for (const id of cacheIds) {
+ this._cache.delete(id)
+ }
+ for (const controller of preloadControllers) {
+ this._preloads.delete(controller)
+ }
+ transferMatchResources(discarded)
+ for (const controller of preloadControllers) {
+ controller.abort()
}
}
diff --git a/packages/router-core/src/ssr/handlerCallback.ts b/packages/router-core/src/ssr/handlerCallback.ts
index 368c8a2a61..ac06c45f3b 100644
--- a/packages/router-core/src/ssr/handlerCallback.ts
+++ b/packages/router-core/src/ssr/handlerCallback.ts
@@ -30,17 +30,12 @@ export function normalizeSsrResponse(
: { response: result, serverSsrCleanup: 'none' }
}
-export function disposeSsrResponse(
+export async function disposeSsrResponse(
response: SsrResponse,
reason?: unknown,
): Promise {
- if (response.serverSsrCleanup !== 'stream') {
- return Promise.resolve()
- }
- try {
- return Promise.resolve(response.dispose(reason))
- } catch (error) {
- return Promise.reject(error)
+ if (response.serverSsrCleanup === 'stream') {
+ await response.dispose(reason)
}
}
@@ -55,12 +50,78 @@ export function disposeSsrResponseDetached(
return
}
- if (ssrResponse.response.body) {
- try {
- void ssrResponse.response.body.cancel(reason).catch(onError)
- } catch (error) {
- onError(error)
+ const body = ssrResponse.response.body
+ if (body && !body.locked) {
+ void body.cancel(reason).catch(onError)
+ }
+}
+
+function ownResponseBody(
+ response: Response,
+ onTerminal: (reason?: unknown) => void,
+): [Response, (reason?: unknown) => void] {
+ const reader = response.body!.getReader()
+ let controller!: ReadableStreamDefaultController
+ let terminal = false
+ const finish = (reason?: unknown) => {
+ terminal = true
+ onTerminal(reason)
+ }
+ const cancel = (reason?: unknown): Promise | undefined => {
+ if (!terminal) {
+ finish(reason)
+ return reader.cancel(reason)
}
+ return
+ }
+ const body = new ReadableStream({
+ start(ctrl) {
+ controller = ctrl
+ },
+ async pull(ctrl) {
+ try {
+ const { done, value } = await reader.read()
+ if (terminal) {
+ return
+ }
+ if (done) {
+ finish()
+ ctrl.close()
+ } else {
+ ctrl.enqueue(value)
+ }
+ } catch (error) {
+ if (!terminal) {
+ finish(error)
+ ctrl.error(error)
+ }
+ }
+ },
+ cancel,
+ })
+ return [
+ new Response(body, response),
+ (reason) => {
+ if (!terminal) {
+ controller.error(reason)
+ finish(reason)
+ void reader.cancel(reason).catch(console.error)
+ }
+ },
+ ]
+}
+
+function ownSsrResponse(
+ response: Response,
+ onTerminal: (reason?: unknown) => void,
+): Extract {
+ const [ownedResponse, dispose] = ownResponseBody(response, onTerminal)
+ return {
+ response: ownedResponse,
+ serverSsrCleanup: 'stream',
+ async dispose(reason?: unknown) {
+ dispose(reason)
+ },
}
}
@@ -72,27 +133,16 @@ export function createSsrStreamResponse(
throw new Error('Invariant failed: SSR stream response requires a body')
}
- let disposed = false
- return {
- response,
- serverSsrCleanup: 'stream',
- async dispose(reason?: unknown) {
- if (disposed) {
- return
- }
- disposed = true
-
- // Sever router ownership before asking user/renderer stream machinery to
- // cancel. A custom stream is allowed to ignore cancellation forever.
- router.serverSsr?.cleanup()
+ return ownSsrResponse(response, () => router.serverSsr?.cleanup())
+}
- try {
- await response.body!.cancel(reason)
- } catch {
- // Cleanup above already released router SSR state.
- }
- },
- }
+export function _transferSsrResponse(
+ owner: Extract,
+ response: Response & { body: ReadableStream },
+): SsrResponse {
+ return ownSsrResponse(response, (reason) => {
+ disposeSsrResponseDetached(owner, reason)
+ })
}
export function bindSsrResponseToRequest(
@@ -104,14 +154,28 @@ export function bindSsrResponseToRequest(
if (ssrResponse.serverSsrCleanup !== 'stream') {
if (signal.aborted) {
disposeSsrResponseDetached(result, signal.reason)
+ return ssrResponse
}
- return ssrResponse
+ const body = ssrResponse.response.body
+ if (!body) {
+ return ssrResponse
+ }
+ let abort = () => {}
+ const [response, dispose] = ownResponseBody(ssrResponse.response, () => {
+ signal.removeEventListener('abort', abort)
+ })
+ abort = () => dispose(signal.reason)
+ signal.addEventListener('abort', abort, { once: true })
+ return { response, serverSsrCleanup: 'none' }
}
-
const failed = (error: unknown) => {
router?.serverSsr?.cleanup()
console.error(error)
}
+ if (!ssrResponse.response.body) {
+ disposeSsrResponseDetached(ssrResponse, signal.reason, failed)
+ return { response: ssrResponse.response, serverSsrCleanup: 'none' }
+ }
const abort = () => {
disposeSsrResponseDetached(ssrResponse, signal.reason, failed)
}
@@ -127,13 +191,25 @@ export function bindSsrResponseToRequest(
return ssrResponse
}
+async function disposeReplacedSsrResponse(
+ result: HandlerCallbackResult,
+ reason?: unknown,
+): Promise {
+ const ssrResponse = normalizeSsrResponse(result)
+ if (ssrResponse.serverSsrCleanup === 'stream') {
+ await ssrResponse.dispose(reason)
+ } else {
+ disposeSsrResponseDetached(ssrResponse, reason)
+ }
+ return ssrResponse
+}
+
export async function replaceSsrResponse(
result: HandlerCallbackResult,
response: Response,
reason?: unknown,
): Promise {
- const ssrResponse = normalizeSsrResponse(result)
- await disposeSsrResponse(ssrResponse, reason)
+ await disposeReplacedSsrResponse(result, reason)
return { response, serverSsrCleanup: 'none' }
}
@@ -141,8 +217,7 @@ export async function stripSsrResponseBody(
result: HandlerCallbackResult,
reason?: unknown,
): Promise {
- const ssrResponse = normalizeSsrResponse(result)
- await disposeSsrResponse(ssrResponse, reason)
+ const ssrResponse = await disposeReplacedSsrResponse(result, reason)
return {
response: new Response(null, ssrResponse.response),
serverSsrCleanup: 'none',
diff --git a/packages/router-core/src/ssr/server.ts b/packages/router-core/src/ssr/server.ts
index 93973999cb..415cead0ef 100644
--- a/packages/router-core/src/ssr/server.ts
+++ b/packages/router-core/src/ssr/server.ts
@@ -10,6 +10,7 @@ export {
normalizeSsrResponse,
replaceSsrResponse,
stripSsrResponseBody,
+ _transferSsrResponse,
} from './handlerCallback'
export type {
HandlerCallback,
diff --git a/packages/router-core/src/ssr/transformStreamWithRouter.ts b/packages/router-core/src/ssr/transformStreamWithRouter.ts
index 03c6ecf2e1..68a9f3a00b 100644
--- a/packages/router-core/src/ssr/transformStreamWithRouter.ts
+++ b/packages/router-core/src/ssr/transformStreamWithRouter.ts
@@ -796,10 +796,11 @@ function makeMainStream(
safeError(reason)
cleanup(reason)
})
- if (cleanedUp || isDone())
+ if (cleanedUp || isDone()) {
return stream
+ }
- // Transform the appStream
+ // Transform the appStream
;(async () => {
try {
while (true) {
diff --git a/packages/router-core/src/stores.ts b/packages/router-core/src/stores.ts
index 8c2beeb2e7..c3c5770d07 100644
--- a/packages/router-core/src/stores.ts
+++ b/packages/router-core/src/stores.ts
@@ -1,4 +1,4 @@
-import { arraysEqual, functionalUpdate } from './utils'
+import { functionalUpdate } from './utils'
import type { AnyRoute } from './route'
import type { RouterState } from './router'
@@ -157,9 +157,7 @@ export function createRouterStores(
batch(() => {
// Publish lane membership first so framework trees reconcile departures
// before observers of a leaving route receive its tombstone.
- if (!arraysEqual(previousIds, nextIds)) {
- ids.set(nextIds)
- }
+ ids.set(nextIds)
for (const id of previousIds) {
if (!nextIds.includes(id)) {
diff --git a/packages/router-core/src/utils.ts b/packages/router-core/src/utils.ts
index 91011bfa7f..5d15d9a839 100644
--- a/packages/router-core/src/utils.ts
+++ b/packages/router-core/src/utils.ts
@@ -716,12 +716,3 @@ export function buildDevStylesUrl(
const normalizedBasepath = trimmedBasepath === '' ? '' : `/${trimmedBasepath}`
return `${normalizedBasepath}/@tanstack-start/styles.css?routes=${encodeURIComponent(routeIds.join(','))}`
}
-
-export function arraysEqual(a: Array, b: Array) {
- if (a === b) return true
- if (a.length !== b.length) return false
- for (let i = 0; i < a.length; i++) {
- if (a[i] !== b[i]) return false
- }
- return true
-}
diff --git a/packages/router-core/tests/build-location.test.ts b/packages/router-core/tests/build-location.test.ts
index ce0d5c3c98..dabfa42629 100644
--- a/packages/router-core/tests/build-location.test.ts
+++ b/packages/router-core/tests/build-location.test.ts
@@ -9,7 +9,7 @@ import {
import { _getUserHistoryState } from '../src/router'
import { createTestRouter } from './routerTestUtils'
-test('_getUserHistoryState removes volatile router bookkeeping but keeps mask payloads', () => {
+test('_getUserHistoryState removes volatile router bookkeeping', () => {
expect(
_getUserHistoryState({
key: 'legacy-key',
@@ -20,7 +20,11 @@ test('_getUserHistoryState removes volatile router bookkeeping but keeps mask pa
__tempKey: 'temp-key',
user: 'state',
} as any),
- ).toEqual({ user: 'state', __tempLocation: {}, __tempKey: 'temp-key' })
+ ).toEqual({
+ __tempLocation: {},
+ __tempKey: 'temp-key',
+ user: 'state',
+ })
})
describe('buildLocation - params function receives parsed params', () => {
diff --git a/packages/router-core/tests/callbacks.test.ts b/packages/router-core/tests/callbacks.test.ts
index 7a1b062d4f..c320dc7bd7 100644
--- a/packages/router-core/tests/callbacks.test.ts
+++ b/packages/router-core/tests/callbacks.test.ts
@@ -1,7 +1,7 @@
import { describe, expect, it, test, vi } from 'vitest'
import { createMemoryHistory } from '@tanstack/history'
import { BaseRootRoute, BaseRoute } from '../src'
-import { createTestRouter } from './routerTestUtils'
+import { createTestRouter, loadServerResponse } from './routerTestUtils'
describe('callbacks', () => {
const setup = ({
@@ -159,6 +159,103 @@ describe('callbacks', () => {
})
})
+ test('a throwing lifecycle callback cannot interrupt later callbacks or leave a client commit half-resolved', async () => {
+ const lifecycleError = new Error('root onStay failed')
+ const log = vi.spyOn(console, 'error').mockImplementation(() => {})
+ const rootOnStay = vi.fn(() => {
+ throw lifecycleError
+ })
+ const targetOnEnter = vi.fn()
+ const sourceOnLeave = vi.fn()
+ const rootRoute = new BaseRootRoute({ onStay: rootOnStay })
+ const sourceRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/source',
+ onLeave: sourceOnLeave,
+ })
+ const targetRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/target',
+ onEnter: targetOnEnter,
+ })
+ const router = createTestRouter({
+ routeTree: rootRoute.addChildren([sourceRoute, targetRoute]),
+ history: createMemoryHistory(),
+ })
+
+ // Establish the source lane through the same public navigation path used
+ // by the lifecycle contract tests above.
+ await router.navigate({ to: '/source' })
+ rootOnStay.mockClear()
+ const onLoad = vi.fn()
+ const onResolved = vi.fn()
+ const unsubscribers = [
+ router.subscribe('onLoad', onLoad),
+ router.subscribe('onResolved', onResolved),
+ ]
+
+ try {
+ await router.navigate({ to: '/target' })
+
+ // Prove the throwing callback was reached before checking the work that
+ // must remain isolated from it.
+ expect(sourceOnLeave).toHaveBeenCalledOnce()
+ expect(rootOnStay).toHaveBeenCalledOnce()
+ expect(targetOnEnter).toHaveBeenCalledOnce()
+ expect(onLoad).toHaveBeenCalledOnce()
+ expect(onResolved).toHaveBeenCalledOnce()
+ expect(router.state).toMatchObject({
+ status: 'idle',
+ location: { pathname: '/target' },
+ resolvedLocation: { pathname: '/target' },
+ })
+ expect(router.state.matches.at(-1)?.routeId).toBe(targetRoute.id)
+ expect(log).toHaveBeenCalledWith(lifecycleError)
+ } finally {
+ for (const unsubscribe of unsubscribers) {
+ unsubscribe()
+ }
+ log.mockRestore()
+ }
+ })
+
+ test('a throwing lifecycle callback cannot reject an otherwise committed server load', async () => {
+ const lifecycleError = new Error('root onEnter failed')
+ const log = vi.spyOn(console, 'error').mockImplementation(() => {})
+ const childOnEnter = vi.fn()
+ const rootRoute = new BaseRootRoute({
+ onEnter: () => {
+ throw lifecycleError
+ },
+ })
+ const childRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/child',
+ onEnter: childOnEnter,
+ })
+ const router = createTestRouter({
+ routeTree: rootRoute.addChildren([childRoute]),
+ history: createMemoryHistory({ initialEntries: ['/child'] }),
+ isServer: true,
+ })
+
+ try {
+ const response = await loadServerResponse(router, '/child')
+
+ expect(response.status).toBe(200)
+ expect(log).toHaveBeenCalledWith(lifecycleError)
+ expect(childOnEnter).toHaveBeenCalledOnce()
+ expect(router.state).toMatchObject({
+ status: 'idle',
+ location: { pathname: '/child' },
+ resolvedLocation: { pathname: '/child' },
+ })
+ expect(router.state.matches.at(-1)?.routeId).toBe(childRoute.id)
+ } finally {
+ log.mockRestore()
+ }
+ })
+
// Regression tests: switching lifecycle hooks to use routeId must NOT break
// match-level caching, which still relies on match.id (routeId + params + loaderDeps).
describe('same-route match caching', () => {
diff --git a/packages/router-core/tests/hmr-refresh-lifecycle.test.ts b/packages/router-core/tests/hmr-refresh-lifecycle.test.ts
index 2807c05951..cac5e942d1 100644
--- a/packages/router-core/tests/hmr-refresh-lifecycle.test.ts
+++ b/packages/router-core/tests/hmr-refresh-lifecycle.test.ts
@@ -433,7 +433,7 @@ describe('HMR route refresh', () => {
expect(router.state.matches.at(-1)?.loaderData).toBe(3)
})
- test('does not adopt a preload created by HMR preflight hooks', async () => {
+ test('ignores preloads triggered by HMR preflight hooks', async () => {
let generation = 1
const rootRoute = new BaseRootRoute({})
const pageRoute = new BaseRoute({
@@ -459,4 +459,50 @@ describe('HMR route refresh', () => {
expect(router.state.matches.at(-1)?.loaderData).toBe(2)
})
+
+ test('ignores a preload reentered from cache cleanup during HMR', async () => {
+ let reentrantPreload: Promise | undefined
+ let router!: ReturnType
+ const reentrantLoader = vi.fn(() => 'reentrant data')
+ const rootRoute = new BaseRootRoute({})
+ const pageRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/page',
+ })
+ const cachedRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/cached',
+ loader: ({ abortController }) => {
+ abortController.signal.addEventListener(
+ 'abort',
+ () => {
+ reentrantPreload = router.preloadRoute({ to: '/reentrant' })
+ },
+ { once: true },
+ )
+ return 'cached data'
+ },
+ })
+ const reentrantRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/reentrant',
+ loader: reentrantLoader,
+ })
+ router = createTestRouter({
+ routeTree: rootRoute.addChildren([
+ pageRoute,
+ cachedRoute,
+ reentrantRoute,
+ ]),
+ history: createMemoryHistory({ initialEntries: ['/page'] }),
+ })
+
+ await router.load()
+ await router.preloadRoute({ to: '/cached' })
+ await router._refreshRoute!()
+ expect(reentrantPreload).toBeDefined()
+ await reentrantPreload
+
+ expect(reentrantLoader).not.toHaveBeenCalled()
+ })
})
diff --git a/packages/router-core/tests/issue-6371-search-default-normalization-abort.test.ts b/packages/router-core/tests/issue-6371-search-default-normalization-abort.test.ts
deleted file mode 100644
index a52a7f183d..0000000000
--- a/packages/router-core/tests/issue-6371-search-default-normalization-abort.test.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-import { describe, expect, test, vi } from 'vitest'
-import { createMemoryHistory } from '@tanstack/history'
-import { BaseRootRoute, BaseRoute, trimPathRight } from '../src'
-import { createTestRouter } from './routerTestUtils'
-
-/**
- * Repro for https://github.com/TanStack/router/issues/6371
- *
- * Entering a route directly via URL without search params, while
- * validateSearch supplies defaults, makes the framework Transitioner commit a
- * canonical replace-navigation (URL with the defaulted search) right after
- * the mount load already started. That replace supersedes the first load
- * pass and aborts its match's abortController. A loader that forwarded that
- * signal to fetch() re-throws an AbortError ("signal is aborted without
- * reason"): it belongs to the abandoned pass and must not surface as a route
- * error; the follow-up canonical load must complete normally.
- */
-
-const waitForMacrotask = () =>
- new Promise((resolve) => {
- setTimeout(resolve, 0)
- })
-
-describe('issue #6371: search default normalization aborts the mount load silently', () => {
- test('canonical replace after validateSearch defaults does not surface AbortError', async () => {
- const invocations: Array<{ resolve: () => void; signal: AbortSignal }> = []
-
- const rootRoute = new BaseRootRoute({})
- const aboutRoute = new BaseRoute({
- getParentRoute: () => rootRoute,
- path: '/about',
- validateSearch: (search: Record) => ({
- page: typeof search.page === 'number' ? search.page : 1,
- }),
- loader: ({ abortController }: { abortController: AbortController }) =>
- new Promise((resolve, reject) => {
- invocations.push({
- resolve: () => resolve('about data'),
- signal: abortController.signal,
- })
- abortController.signal.addEventListener('abort', () => {
- const abortError = new Error('signal is aborted without reason')
- abortError.name = 'AbortError'
- reject(abortError)
- })
- }),
- })
-
- const router = createTestRouter({
- routeTree: rootRoute.addChildren([aboutRoute]),
- history: createMemoryHistory({ initialEntries: ['/about'] }),
- })
-
- // Mount load for the raw URL (no search params in the href yet).
- const initialLoad = router.load()
- await vi.waitFor(() => expect(invocations.length).toBe(1))
-
- // What the framework Transitioner does on mount: rebuild the canonical
- // location with validated search and replace if the public href differs.
- const nextLocation = router.buildLocation({
- to: router.latestLocation.pathname,
- search: true,
- params: true,
- hash: true,
- state: true,
- _includeValidateSearch: true,
- } as any)
-
- expect(nextLocation.publicHref).toContain('page=1')
- expect(trimPathRight(router.latestLocation.publicHref)).not.toBe(
- trimPathRight(nextLocation.publicHref),
- )
-
- // In core (no history subscribers) commitLocation starts the second load.
- void router.commitLocation({ ...nextLocation, replace: true } as any)
-
- await vi.waitFor(() => expect(invocations.length).toBe(2))
-
- // The superseded mount-load generation was aborted (its fetch canceled).
- expect(invocations[0]!.signal.aborted).toBe(true)
-
- // Give the aborted rejection time to (incorrectly) commit as an error.
- await waitForMacrotask()
- await waitForMacrotask()
-
- for (const match of router.state.matches) {
- expect(match.status).not.toBe('error')
- expect((match.error as Error | undefined)?.name).not.toBe('AbortError')
- }
-
- invocations[1]!.resolve()
- // Awaiting the superseded mount load also awaits the superseding chain.
- await initialLoad
-
- // No runaway normalization loop: exactly one aborted + one canonical run.
- expect(invocations.length).toBe(2)
-
- const aboutMatch = router.state.matches.find(
- (match) => match.routeId === aboutRoute.id,
- )!
- expect(aboutMatch.status).toBe('success')
- expect(aboutMatch.error).toBeUndefined()
- expect(aboutMatch.loaderData).toBe('about data')
- expect(aboutMatch.search).toEqual({ page: 1 })
-
- expect(router.state.location.search).toEqual({ page: 1 })
- expect(router.latestLocation.publicHref).toContain('page=1')
- })
-})
diff --git a/packages/router-core/tests/load.test.ts b/packages/router-core/tests/load.test.ts
index 7e465c20cb..62d919f1bd 100644
--- a/packages/router-core/tests/load.test.ts
+++ b/packages/router-core/tests/load.test.ts
@@ -215,8 +215,7 @@ describe('beforeLoad skip or exec', () => {
await router.navigate({ to: '/foo' })
expect(router.state.location.pathname).toBe('/foo')
- // An identical navigation may latch onto the still-active whole lane.
- expect(beforeLoad).toHaveBeenCalledTimes(1)
+ expect(beforeLoad).toHaveBeenCalledTimes(2)
})
test('exec if rejected preload (notFound)', async () => {
@@ -278,9 +277,9 @@ describe('beforeLoad skip or exec', () => {
await Promise.resolve()
await router.navigate({ to: '/foo' })
- expect(router.state.location.pathname).toBe('/bar')
+ expect(router.state.location.pathname).toBe('/foo')
expect(router.state.matches.at(-1)?.status).toBe('success')
- expect(beforeLoad).toHaveBeenCalledTimes(1)
+ expect(beforeLoad).toHaveBeenCalledTimes(2)
})
test('exec if rejected preload (error)', async () => {
@@ -458,7 +457,7 @@ describe('loader skip or exec', () => {
expect(loader).toHaveBeenCalledTimes(2)
})
- test('exec if pending preload returns notFound', async () => {
+ test('shares a pending preload notFound with navigation', async () => {
const loader: Loader = vi.fn(async ({ preload }) => {
await sleep(100)
if (preload) throw notFound()
@@ -471,8 +470,12 @@ describe('loader skip or exec', () => {
await router.navigate({ to: '/foo' })
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).toHaveBeenCalledTimes(1)
})
test('exec if rejected preload (redirect)', async () => {
@@ -524,7 +527,7 @@ describe('loader skip or exec', () => {
expect(loader).toHaveBeenCalledTimes(2)
})
- test('exec if pending preload errors', async () => {
+ test('shares a pending preload error with navigation', async () => {
const loader: Loader = vi.fn(async ({ preload }) => {
await sleep(100)
if (preload) throw new Error('error')
@@ -537,8 +540,8 @@ describe('loader skip or exec', () => {
await router.navigate({ to: '/foo' })
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)?.status).toBe('error')
+ expect(loader).toHaveBeenCalledTimes(1)
})
})
diff --git a/packages/router-core/tests/loader-architecture-regressions.test.ts b/packages/router-core/tests/loader-architecture-regressions.test.ts
index 1efc42cc73..823b760311 100644
--- a/packages/router-core/tests/loader-architecture-regressions.test.ts
+++ b/packages/router-core/tests/loader-architecture-regressions.test.ts
@@ -279,37 +279,6 @@ test('invalidation reruns the loader with same-id route context', async () => {
})
})
-test('a preload cannot abort its controller after navigation adopts its lane', async () => {
- const suspended = createControlledPromise()
- let adoptedSignal: AbortSignal | undefined
- const rootRoute = new BaseRootRoute({})
- const indexRoute = new BaseRoute({
- getParentRoute: () => rootRoute,
- path: '/',
- })
- const guardedRoute = new BaseRoute({
- getParentRoute: () => rootRoute,
- path: '/guarded',
- beforeLoad: ({ abortController }) => {
- adoptedSignal = abortController.signal
- throw suspended
- },
- })
- const router = createTestRouter({
- routeTree: rootRoute.addChildren([indexRoute, guardedRoute]),
- history: createMemoryHistory({ initialEntries: ['/'] }),
- })
-
- await router.load()
- const preload = router.preloadRoute({ to: '/guarded' })
- const navigation = router.navigate({ to: '/guarded' })
-
- await preload
- expect(adoptedSignal?.aborted).toBe(false)
-
- await navigation
-})
-
test('a fast background candidate remains private when a reentrant navigation wins', async () => {
let parentLoads = 0
let childLoads = 0
@@ -387,6 +356,464 @@ test('a fast background candidate remains private when a reentrant navigation wi
expect(replacementSignal?.aborted).toBe(true)
})
+test('a successor reload does not join a settled background candidate awaiting render acknowledgement', async () => {
+ let generation = 0
+ const signals: Array = []
+ const rootRoute = new BaseRootRoute({})
+ const route = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/page',
+ validateSearch: (search: Record) => ({
+ reload: Number(search.reload ?? 0),
+ }),
+ shouldReload: ({ location }) => {
+ return (location.search as { reload: number }).reload > 0
+ },
+ loader: ({ abortController }) => {
+ signals.push(abortController.signal)
+ return ++generation
+ },
+ })
+ const router = createTestRouter({
+ routeTree: rootRoute.addChildren([route]),
+ history: createMemoryHistory({ initialEntries: ['/page?reload=0'] }),
+ })
+
+ await router.load()
+
+ let transitionCount = 0
+ let acknowledgeFirst!: () => void
+ router.startTransition = (commit) => {
+ commit()
+ transitionCount++
+ if (transitionCount === 1) {
+ return new Promise((resolve) => {
+ acknowledgeFirst = () => resolve(false)
+ })
+ }
+ return Promise.resolve(true)
+ }
+
+ const first = router.navigate({
+ to: '/page',
+ search: { reload: 1 },
+ })
+ await vi.waitFor(() => {
+ expect(generation).toBe(2)
+ expect(transitionCount).toBe(1)
+ })
+
+ await router.navigate({
+ to: '/page',
+ search: { reload: 2 },
+ })
+
+ await vi.waitFor(() => {
+ expect(generation).toBe(3)
+ expect(router.state.matches.at(-1)?.loaderData).toBe(3)
+ })
+
+ acknowledgeFirst()
+ await first
+
+ expect(signals[1]?.aborted).toBe(true)
+ expect(signals[2]?.aborted).toBe(false)
+})
+
+test('a superseding navigation adopts a pending background loader before async beforeLoad', async () => {
+ const backgroundResult = createControlledPromise()
+ const beforeLoadStarted = createControlledPromise()
+ const beforeLoadGate = createControlledPromise()
+ const signals: Array = []
+ let generation = 0
+
+ const rootRoute = new BaseRootRoute({})
+ const route = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/page',
+ validateSearch: (search: Record) => ({
+ reload: Number(search.reload ?? 0),
+ }),
+ shouldReload: ({ location }) => {
+ return (location.search as { reload: number }).reload === 1
+ ? true
+ : undefined
+ },
+ beforeLoad: async ({ search }) => {
+ if (search.reload === 2) {
+ beforeLoadStarted.resolve()
+ await beforeLoadGate
+ }
+ },
+ loader: {
+ staleReloadMode: 'background',
+ handler: ({ abortController }) => {
+ signals.push(abortController.signal)
+ generation++
+ return generation === 2 ? backgroundResult : generation
+ },
+ },
+ })
+ const router = createTestRouter({
+ routeTree: rootRoute.addChildren([route]),
+ history: createMemoryHistory({ initialEntries: ['/page?reload=0'] }),
+ })
+
+ await router.load()
+ await router.navigate({
+ to: '/page',
+ search: { reload: 1 },
+ })
+ await vi.waitFor(() => expect(generation).toBe(2))
+
+ const successor = router.navigate({
+ to: '/page',
+ search: { reload: 2 },
+ })
+ await beforeLoadStarted
+
+ backgroundResult.resolve(2)
+ beforeLoadGate.resolve()
+ await successor
+
+ await vi.waitFor(() => {
+ expect(router.state.matches.at(-1)?.loaderData).toBe(2)
+ })
+ expect(generation).toBe(2)
+ expect(signals[1]?.aborted).toBe(false)
+ expect(router.state.matches.at(-1)?.preload).toBe(false)
+})
+
+test('rejecting a pending background loader preserves the active loader owner', async () => {
+ const backgroundResult = createControlledPromise()
+ const signals: Array = []
+ let generation = 0
+
+ const rootRoute = new BaseRootRoute({})
+ const pageRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/page',
+ validateSearch: (search: Record) => ({
+ reload: Number(search.reload ?? 0),
+ }),
+ shouldReload: ({ location }) => {
+ return (location.search as { reload: number }).reload === 1
+ },
+ loader: {
+ staleReloadMode: 'background',
+ handler: ({ abortController }) => {
+ signals.push(abortController.signal)
+ generation++
+ return generation === 2 ? backgroundResult : generation
+ },
+ },
+ })
+ const otherRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/other',
+ })
+ const router = createTestRouter({
+ routeTree: rootRoute.addChildren([pageRoute, otherRoute]),
+ history: createMemoryHistory({ initialEntries: ['/page?reload=0'] }),
+ })
+
+ await router.load()
+ await router.navigate({ to: '/page', search: { reload: 1 } })
+ await vi.waitFor(() => expect(generation).toBe(2))
+
+ await router.navigate({ to: '/page', search: { reload: 2 } })
+
+ expect(generation).toBe(2)
+ expect(signals[0]?.aborted).toBe(false)
+ expect(signals[1]?.aborted).toBe(true)
+ expect(router.state.matches.at(-1)?.loaderData).toBe(1)
+
+ await router.navigate({ to: '/other' })
+ expect(signals[0]?.aborted).toBe(true)
+})
+
+test('an ancestor beforeLoad failure releases an unconsumed background claim', async () => {
+ const backgroundResult = createControlledPromise()
+ const signals: Array = []
+ let generation = 0
+
+ const rootRoute = new BaseRootRoute({})
+ const parentRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/parent',
+ validateSearch: (search: Record) => ({
+ phase: String(search.phase ?? 'initial'),
+ }),
+ beforeLoad: ({ search }) => {
+ if (search.phase === 'fail') {
+ throw new Error('parent failed')
+ }
+ },
+ })
+ const childRoute = new BaseRoute({
+ getParentRoute: () => parentRoute,
+ path: '/child',
+ shouldReload: ({ location }) => {
+ return (location.search as { phase: string }).phase === 'refresh'
+ ? true
+ : undefined
+ },
+ staleTime: Infinity,
+ loader: {
+ staleReloadMode: 'background',
+ handler: ({ abortController }) => {
+ signals.push(abortController.signal)
+ generation++
+ return generation === 2 ? backgroundResult : generation
+ },
+ },
+ })
+ const router = createTestRouter({
+ routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]),
+ history: createMemoryHistory({
+ initialEntries: ['/parent/child?phase=initial'],
+ }),
+ })
+
+ await router.load()
+ await router.navigate({
+ to: '/parent/child',
+ search: { phase: 'refresh' },
+ })
+ await vi.waitFor(() => expect(generation).toBe(2))
+
+ await router.navigate({
+ to: '/parent/child',
+ search: { phase: 'fail' },
+ })
+
+ expect(
+ router.state.matches.find((match) => match.routeId === parentRoute.id),
+ ).toMatchObject({ status: 'error' })
+ expect(signals[1]?.aborted).toBe(true)
+
+ backgroundResult.resolve(2)
+ await router.navigate({
+ to: '/parent/child',
+ search: { phase: 'recovered' },
+ })
+
+ expect(generation).toBe(2)
+ expect(router.state.matches.at(-1)?.loaderData).toBe(1)
+})
+
+test('a background redirect beats a slower ancestor failure promoted over a child failure', async () => {
+ const parentFailureGate = createControlledPromise()
+ const middleRedirectGate = createControlledPromise()
+ const middleStarted = createControlledPromise()
+ const childFailureStarted = createControlledPromise()
+
+ const rootRoute = new BaseRootRoute({
+ validateSearch: (search: Record) => ({
+ revision: Number(search.revision ?? 0),
+ }),
+ })
+ const parentRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/parent',
+ loaderDeps: ({ search }) => ({ revision: search.revision }),
+ loader: async ({ location }) => {
+ if ((location.search as { revision: number }).revision === 1) {
+ await parentFailureGate
+ throw new Error('parent failed')
+ }
+ return 'parent data'
+ },
+ })
+ const middleRoute = new BaseRoute({
+ getParentRoute: () => parentRoute,
+ path: '/middle',
+ shouldReload: ({ location }) =>
+ (location.search as { revision: number }).revision === 1,
+ loader: {
+ staleReloadMode: 'background',
+ handler: async ({ location }) => {
+ if ((location.search as { revision: number }).revision === 0) {
+ return 'middle data'
+ }
+ middleStarted.resolve()
+ await middleRedirectGate
+ throw redirect({ to: '/target' })
+ },
+ },
+ })
+ const childRoute = new BaseRoute({
+ getParentRoute: () => middleRoute,
+ path: '/child',
+ loaderDeps: ({ search }) => ({ revision: search.revision }),
+ loader: ({ location }) => {
+ if ((location.search as { revision: number }).revision === 1) {
+ childFailureStarted.resolve()
+ throw new Error('child failed')
+ }
+ return 'child data'
+ },
+ })
+ const targetRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/target',
+ })
+ const router = createTestRouter({
+ routeTree: rootRoute.addChildren([
+ parentRoute.addChildren([middleRoute.addChildren([childRoute])]),
+ targetRoute,
+ ]),
+ history: createMemoryHistory({
+ initialEntries: ['/parent/middle/child?revision=0'],
+ }),
+ })
+
+ await router.load()
+ const navigation = router.navigate({
+ to: '/parent/middle/child',
+ search: { revision: 1 },
+ })
+ await Promise.all([middleStarted, childFailureStarted])
+
+ middleRedirectGate.resolve()
+ parentFailureGate.resolve()
+ await navigation
+
+ expect(router.state.location.pathname).toBe('/target')
+})
+
+test('navigation retries a failed preload loader while its error boundary is still loading', async () => {
+ const boundaryGate = createControlledPromise()
+ const failure = new Error('preload failed')
+ const loader = vi.fn(() => {
+ if (loader.mock.calls.length === 1) {
+ throw failure
+ }
+ return 'navigation data'
+ })
+ const errorComponent = Object.assign(() => null, {
+ preload: vi.fn(() => boundaryGate),
+ })
+ const rootRoute = new BaseRootRoute({})
+ const indexRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ })
+ const targetRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/target',
+ loader,
+ errorComponent,
+ })
+ const router = createTestRouter({
+ routeTree: rootRoute.addChildren([indexRoute, targetRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+
+ await router.load()
+ const preload = router.preloadRoute({ to: '/target' })
+ await vi.waitFor(() => {
+ expect(errorComponent.preload).toHaveBeenCalledOnce()
+ })
+
+ const navigation = router.navigate({ to: '/target' })
+ await vi.waitFor(() => {
+ expect(loader).toHaveBeenCalledTimes(2)
+ })
+
+ boundaryGate.resolve()
+ await Promise.all([preload, navigation])
+
+ expect(router.state.matches.at(-1)).toMatchObject({
+ routeId: targetRoute.id,
+ status: 'success',
+ loaderData: 'navigation data',
+ })
+})
+
+test('a preload started from onError retries the rejected loader flight', async () => {
+ const failure = new Error('first preload failed')
+ const loader = vi.fn(() => {
+ if (loader.mock.calls.length === 1) {
+ throw failure
+ }
+ return 'retry data'
+ })
+ let retry: ReturnType | undefined
+
+ const rootRoute = new BaseRootRoute({})
+ const indexRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ })
+ const targetRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/target',
+ loader,
+ onError: () => {
+ retry ??= router.preloadRoute({ to: '/target' })
+ },
+ })
+ const router = createTestRouter({
+ routeTree: rootRoute.addChildren([indexRoute, targetRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+
+ await router.load()
+ await router.preloadRoute({ to: '/target' })
+ const retried = await retry
+
+ expect(loader).toHaveBeenCalledTimes(2)
+ expect(retried?.at(-1)?.loaderData).toBe('retry data')
+})
+
+test('a preload started after a returned terminal result gets a fresh loader flight', async () => {
+ const firstResult = createControlledPromise>()
+ const loader = vi.fn(() => {
+ if (loader.mock.calls.length === 1) {
+ return firstResult
+ }
+ return 'retry data'
+ })
+ let retry: ReturnType | undefined
+
+ const rootRoute = new BaseRootRoute({})
+ const indexRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ })
+ const targetRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/target',
+ loader,
+ })
+ const router = createTestRouter({
+ routeTree: rootRoute.addChildren([indexRoute, targetRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+
+ await router.load()
+ const preload = router.preloadRoute({ to: '/target' })
+ await vi.waitFor(() => expect(loader).toHaveBeenCalledOnce())
+
+ // A real application can react to the same request promise in a later
+ // microtask, after the router has normalized its result but before a loader
+ // lane waiter resumes.
+ firstResult.then(() => {
+ queueMicrotask(() => {
+ retry = router.preloadRoute({ to: '/target' })
+ })
+ })
+ firstResult.resolve(notFound())
+
+ await preload
+ await vi.waitFor(() => expect(retry).toBeDefined())
+ const retried = await retry
+
+ expect(loader).toHaveBeenCalledTimes(2)
+ expect(retried?.at(-1)?.loaderData).toBe('retry data')
+})
+
test('reentrant navigation from onError cancels stale serial work', async () => {
const failure = new Error('stale context failed')
const parentLoader = vi.fn(() => 'parent data')
@@ -437,7 +864,7 @@ test('reentrant navigation from onError cancels stale serial work', async () =>
expect(parentLoader).not.toHaveBeenCalled()
})
-test('a joined descendant loader redirect still wins after an ancestor loader fails', async () => {
+test('an unrelated preload redirect does not override a navigation error', async () => {
const childStarted = createControlledPromise()
const childRedirect = createControlledPromise()
const ancestorFailure = new Error('navigation parent failed')
@@ -466,6 +893,7 @@ test('a joined descendant loader redirect still wins after an ancestor loader fa
const childRoute = new BaseRoute({
getParentRoute: () => parentRoute,
path: '/child',
+ loaderDeps: ({ search }) => ({ mode: search.mode }),
loader: async () => {
childLoads++
if (childLoads === 1) {
@@ -503,12 +931,15 @@ test('a joined descendant loader redirect still wins after an ancestor loader fa
childRedirect.resolve()
await Promise.all([preload, navigation])
- expect(router.state.location.pathname).toBe('/target')
- expect(router.state.matches.at(-1)).toMatchObject({
- routeId: targetRoute.id,
- status: 'success',
+ expect(router.state.location.pathname).toBe('/parent/child')
+ expect(router.state.location.search).toEqual({ mode: 'navigation' })
+ expect(
+ router.state.matches.find((match) => match.routeId === parentRoute.id),
+ ).toMatchObject({
+ status: 'error',
+ error: ancestorFailure,
})
- expect(childLoads).toBe(1)
+ expect(childLoads).toBe(2)
})
test('a loaderDeps error is handled by the route error boundary', async () => {
@@ -906,3 +1337,75 @@ test('an onLeave navigation to the in-flight destination joins it and enters onc
expect(firstEnter).toHaveBeenCalledOnce()
expect(firstStay).not.toHaveBeenCalled()
})
+
+test('a delayed legacy notFound preload cannot replace committed cache policy at another match index', async () => {
+ const preloadBeforeLoadStarted = createControlledPromise()
+ const releasePreloadBeforeLoad = createControlledPromise()
+ let generation = 0
+ const loader = vi.fn(({ preload }: { preload: boolean }) => ({
+ generation: ++generation,
+ preload,
+ }))
+ const rootRoute = new BaseRootRoute({})
+ const indexRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ })
+ const parentRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/parent',
+ })
+ const legacyNotFoundRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/404',
+ staleTime: Infinity,
+ gcTime: Infinity,
+ preloadStaleTime: 0,
+ preloadGcTime: Infinity,
+ beforeLoad: async ({ preload }) => {
+ if (preload) {
+ preloadBeforeLoadStarted.resolve()
+ await releasePreloadBeforeLoad
+ }
+ },
+ loader,
+ })
+ const router = createTestRouter({
+ routeTree: rootRoute.addChildren([indexRoute, parentRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ notFoundRoute: legacyNotFoundRoute,
+ })
+
+ await router.load()
+
+ const delayedPreload = router.preloadRoute({
+ to: '/parent/missing',
+ } as any)
+ try {
+ await preloadBeforeLoadStarted
+ await router.navigate({ to: '/missing' } as any)
+
+ expect(loader).toHaveBeenCalledOnce()
+ expect(router.state.matches.at(-1)?.loaderData).toEqual({
+ generation: 1,
+ preload: false,
+ })
+
+ releasePreloadBeforeLoad.resolve()
+ await delayedPreload
+
+ expect(loader).toHaveBeenCalledOnce()
+
+ await router.navigate({ to: '/' })
+ await router.navigate({ to: '/missing' } as any)
+
+ expect(loader).toHaveBeenCalledOnce()
+ expect(router.state.matches.at(-1)?.loaderData).toEqual({
+ generation: 1,
+ preload: false,
+ })
+ } finally {
+ releasePreloadBeforeLoad.resolve()
+ await Promise.allSettled([delayedPreload])
+ }
+})
diff --git a/packages/router-core/tests/masked-location-state-commit.test.ts b/packages/router-core/tests/masked-location-state-commit.test.ts
index 9738b42261..fae2f275b2 100644
--- a/packages/router-core/tests/masked-location-state-commit.test.ts
+++ b/packages/router-core/tests/masked-location-state-commit.test.ts
@@ -8,7 +8,7 @@ afterEach(() => {
})
describe('masked location remnants in history state', () => {
- test('a same-href navigation clears an expired mask from history state', async () => {
+ test('a same-href navigation clears an expired mask payload from history state', async () => {
const makeRoutes = () => {
const rootRoute = new BaseRootRoute({})
const indexRoute = new BaseRoute({
diff --git a/packages/router-core/tests/pending-terminal-boundary.test.ts b/packages/router-core/tests/pending-terminal-boundary.test.ts
new file mode 100644
index 0000000000..3d7959998d
--- /dev/null
+++ b/packages/router-core/tests/pending-terminal-boundary.test.ts
@@ -0,0 +1,103 @@
+import { expect, test } from 'vitest'
+import { createMemoryHistory } from '@tanstack/history'
+import {
+ BaseRootRoute,
+ BaseRoute,
+ createControlledPromise,
+} from '../src'
+import { createTestRouter } from './routerTestUtils'
+
+test('a known parent error does not offer a descendant pending boundary while its error component loads', async () => {
+ const failure = new Error('parent failed')
+ const errorComponentStarted = createControlledPromise()
+ const errorComponentGate = createControlledPromise()
+ const childPendingStarted = createControlledPromise()
+ const childPendingGate = createControlledPromise()
+ const childComponentGate = createControlledPromise()
+
+ const errorComponent = Object.assign(() => null, {
+ preload: () => {
+ errorComponentStarted.resolve()
+ return errorComponentGate
+ },
+ })
+ const childPendingComponent = Object.assign(() => null, {
+ preload: () => {
+ childPendingStarted.resolve()
+ return childPendingGate
+ },
+ })
+ const childComponent = Object.assign(() => null, {
+ preload: () => childComponentGate,
+ })
+
+ const rootRoute = new BaseRootRoute({})
+ const indexRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ })
+ const parentRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/parent',
+ loader: () => {
+ throw failure
+ },
+ errorComponent,
+ })
+ const childRoute = new BaseRoute({
+ getParentRoute: () => parentRoute,
+ path: '/child',
+ loader: () => 'child data',
+ component: childComponent,
+ pendingComponent: childPendingComponent,
+ pendingMs: 0,
+ })
+ const router = createTestRouter({
+ routeTree: rootRoute.addChildren([
+ indexRoute,
+ parentRoute.addChildren([childRoute]),
+ ]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+
+ await router.load()
+
+ const transitions: Array<
+ Array<{ routeId: string; status: string; error: unknown }>
+ > = []
+ const startTransition = router.startTransition
+ router.startTransition = (commit, expected) => {
+ transitions.push(
+ expected.map((match) => ({
+ routeId: match.routeId,
+ status: match.status,
+ error: match.error,
+ })),
+ )
+ return startTransition(commit, expected)
+ }
+
+ const navigation = router.navigate({ to: '/parent/child' })
+ await Promise.all([errorComponentStarted, childPendingStarted])
+
+ childPendingGate.resolve()
+ await new Promise((resolve) => setTimeout(resolve, 0))
+
+ expect(
+ transitions.some((matches) => {
+ const parent = matches.find(
+ (match) => match.routeId === parentRoute.id,
+ )
+ const child = matches.find((match) => match.routeId === childRoute.id)
+ return parent?.status === 'success' && child?.status === 'pending'
+ }),
+ ).toBe(false)
+
+ errorComponentGate.resolve()
+ childComponentGate.resolve()
+ await navigation
+
+ expect(
+ router.state.matches.find((match) => match.routeId === parentRoute.id),
+ ).toMatchObject({ status: 'error', error: failure })
+})
diff --git a/packages/router-core/tests/preload-adoption.test.ts b/packages/router-core/tests/preload-adoption.test.ts
index 18ec7385d5..dcc7548225 100644
--- a/packages/router-core/tests/preload-adoption.test.ts
+++ b/packages/router-core/tests/preload-adoption.test.ts
@@ -8,16 +8,65 @@ afterEach(() => {
})
/**
- * Preload adoption edge cases. The happy path (navigation adopts an
- * in-flight preload's successful loader run) and the control-flow
- * non-leakage path are pinned in load.test.ts; this file pins the
- * adoption boundary: a donor must have its loader genuinely in flight. A
- * preload still in its serial phase can itself be waiting on the navigation
- * through the borrow protocol, while a stale successful donor can still have
- * fresh loader work pending behind its cached snapshot.
+ * Preload concurrency edge cases. A navigation owns a fresh serial context
+ * lane while same-ID loader work can still be shared with an active preload.
+ * Concurrent public preloads own independent context lanes while sharing
+ * same-ID loader work.
*/
-describe('preload adoption', () => {
+describe('preload concurrency', () => {
+ test('a superseded preload cannot cache over a newer navigation generation', async () => {
+ const oldPreload = createControlledPromise()
+ const newerNavigation = createControlledPromise()
+ let reload = true
+ let loaderCalls = 0
+
+ const rootRoute = new BaseRootRoute({})
+ const targetRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/target',
+ shouldReload: () => reload,
+ loader: {
+ staleReloadMode: 'blocking',
+ handler: () => {
+ loaderCalls++
+ if (loaderCalls === 1) {
+ return 'initial'
+ }
+ return loaderCalls === 2 ? oldPreload : newerNavigation
+ },
+ },
+ })
+ const otherRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/other',
+ })
+ const router = createTestRouter({
+ routeTree: rootRoute.addChildren([targetRoute, otherRoute]),
+ history: createMemoryHistory({ initialEntries: ['/target'] }),
+ })
+
+ await router.load()
+
+ const preload = router.preloadRoute({ to: '/target' })
+ await vi.waitFor(() => expect(loaderCalls).toBe(2))
+
+ const navigation = router.load()
+ await vi.waitFor(() => expect(loaderCalls).toBe(3))
+ newerNavigation.resolve('newer navigation')
+ await navigation
+
+ oldPreload.resolve('older preload')
+ await preload
+
+ reload = false
+ await router.navigate({ to: '/other' })
+ await router.navigate({ to: '/target' })
+
+ expect(loaderCalls).toBe(3)
+ expect(router.state.matches.at(-1)?.loaderData).toBe('newer navigation')
+ })
+
test('navigation waits for fresh data from an in-flight stale preload revalidation', async () => {
vi.useFakeTimers()
vi.setSystemTime(0)
@@ -78,17 +127,15 @@ describe('preload adoption', () => {
})
await Promise.resolve()
- // The navigation may share the revalidation, but it must not treat the
- // stale cached snapshot as the completed result while fresh work runs.
+ // The navigation must share the pending revalidation rather than treating
+ // the stale cached snapshot as complete or starting a third generation.
expect(revalidationGate.status).toBe('pending')
expect(navigationSettled).toBe(false)
revalidationGate.resolve({ notifications: ['fresh'] })
await Promise.all([revalidation, navigation])
- // A fix may either share the fresh revalidation or run a navigation
- // loader of its own; correctness only requires publishing fresh data.
- expect(loaderCalls).toBeGreaterThanOrEqual(2)
+ expect(loaderCalls).toBe(2)
expect(
router.state.matches.find(
(match) => match.routeId === notificationsRoute.id,
@@ -96,7 +143,7 @@ describe('preload adoption', () => {
).toEqual({ notifications: ['fresh'] })
})
- test('navigation adopts an identical preload still in its serial phase', async () => {
+ test('navigation does not wait for a pending preload beforeLoad and shares its loader', async () => {
const beforeLoadGate = createControlledPromise()
const preloadSerialStarted = createControlledPromise()
let beforeLoadCalls = 0
@@ -110,13 +157,14 @@ describe('preload adoption', () => {
const fooRoute = new BaseRoute({
getParentRoute: () => rootRoute,
path: '/foo',
- beforeLoad: async () => {
+ beforeLoad: async ({ preload }) => {
beforeLoadCalls++
if (beforeLoadCalls === 1) {
// Keep the shared lane's serial phase in flight.
preloadSerialStarted.resolve()
await beforeLoadGate
}
+ return { source: preload ? 'preload' : 'navigation' }
},
loader,
})
@@ -134,20 +182,23 @@ describe('preload adoption', () => {
expect(beforeLoadCalls).toBe(1)
const navigation = router.navigate({ to: '/foo' })
- await Promise.resolve()
- expect(loader).not.toHaveBeenCalled()
+ await vi.waitFor(() => expect(beforeLoadCalls).toBe(2))
+ expect(loader).toHaveBeenCalledTimes(1)
beforeLoadGate.resolve()
await Promise.all([navigation, preload])
- expect(beforeLoadCalls).toBe(1)
+ expect(beforeLoadCalls).toBe(2)
expect(loader).toHaveBeenCalledTimes(1)
expect(
- router.state.matches.find((match) => match.routeId === fooRoute.id)
- ?.status,
- ).toBe('success')
+ router.state.matches.find((match) => match.routeId === fooRoute.id),
+ ).toMatchObject({
+ status: 'success',
+ context: { source: 'navigation' },
+ loaderData: 'data',
+ })
})
- test("a sibling preload adopts another preload lane's in-flight loader", async () => {
+ test('identical preloads rerun beforeLoad while sharing the active loader', async () => {
const loaderGate = createControlledPromise()
const loaderStarted = createControlledPromise()
let preloadBeforeLoadCalls = 0
@@ -185,7 +236,7 @@ describe('preload adoption', () => {
const second = router.preloadRoute({ to: '/foo' } as any)
await Promise.resolve()
- expect(preloadBeforeLoadCalls).toBe(1)
+ expect(preloadBeforeLoadCalls).toBe(2)
expect(loader).toHaveBeenCalledTimes(1)
loaderGate.resolve('once')
@@ -201,7 +252,7 @@ describe('preload adoption', () => {
).toBe('once')
})
- test('a fulfilled undefined loader result is adopted as success', async () => {
+ test('a fulfilled undefined loader result is shared as success', async () => {
const loaderGate = createControlledPromise()
const loaderStarted = createControlledPromise()
const loader = vi.fn(() => {
@@ -246,13 +297,13 @@ describe('preload adoption', () => {
expect(match?.loaderData).toBeUndefined()
})
- test('navigation retries a failed joined preload without starving sibling work', async () => {
+ test('navigation shares a pending preload loader failure without starving sibling work', async () => {
const preloadParentStarted = createControlledPromise()
const preloadFailureGate = createControlledPromise()
const navigationStarted = createControlledPromise()
- const navigationParentRetryStarted = createControlledPromise()
const childStarted = createControlledPromise()
const childGate = createControlledPromise()
+ const sharedFailure = new Error('shared loader failed')
let parentLoads = 0
let childLoads = 0
const rootRoute = new BaseRootRoute({})
@@ -268,15 +319,11 @@ describe('preload adoption', () => {
navigationStarted.resolve()
}
},
- loader: async ({ preload }) => {
+ loader: async () => {
parentLoads++
- if (preload) {
- preloadParentStarted.resolve()
- await preloadFailureGate
- throw new Error('preload failed')
- }
- navigationParentRetryStarted.resolve()
- return 'navigation data'
+ preloadParentStarted.resolve()
+ await preloadFailureGate
+ throw sharedFailure
},
})
const childRoute = new BaseRoute({
@@ -303,33 +350,27 @@ describe('preload adoption', () => {
expect(childLoads).toBe(1)
const navigation = router.navigate({ to: '/parent/child' })
- await Promise.resolve()
+ await navigationStarted
expect(preloadFailureGate.status).toBe('pending')
expect(parentLoads).toBe(1)
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.
+ // later redirect can still win, while both lanes keep sharing the failed
+ // parent generation.
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')
+ ).toMatchObject({ status: 'error', error: sharedFailure })
expect(
router.state.matches.find((match) => match.routeId === childRoute.id)
- ?.loaderData,
- ).toBe('child data')
+ ).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/preload-beforeload-reuse.test.ts b/packages/router-core/tests/preload-beforeload-reuse.test.ts
index 84cdb5bfde..b061233fb9 100644
--- a/packages/router-core/tests/preload-beforeload-reuse.test.ts
+++ b/packages/router-core/tests/preload-beforeload-reuse.test.ts
@@ -90,56 +90,9 @@ describe('preloaded loader reuse with fresh beforeLoad context', () => {
})
})
- test('adopts beforeLoad and loader from an identical active preload', async () => {
- const loaderGate = createControlledPromise()
- const beforeLoad = vi.fn(({ preload }: { preload: boolean }) => ({
- guard: preload ? 'preloaded' : 'loaded',
- }))
- const loader = vi.fn(() => loaderGate)
- const rootRoute = new BaseRootRoute({})
- const indexRoute = new BaseRoute({
- getParentRoute: () => rootRoute,
- path: '/',
- })
- const guardedRoute = new BaseRoute({
- getParentRoute: () => rootRoute,
- path: '/guarded',
- preloadStaleTime: Infinity,
- beforeLoad,
- loader,
- })
- const router = createTestRouter({
- routeTree: rootRoute.addChildren([indexRoute, guardedRoute]),
- history: createMemoryHistory({ initialEntries: ['/'] }),
- })
-
- await router.load()
- const preload = router.preloadRoute({ to: '/guarded' })
- await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(1))
-
- const navigation = router.navigate({ to: '/guarded' })
- await vi.waitFor(() => expect(beforeLoad).toHaveBeenCalledTimes(1))
- expect(beforeLoad.mock.calls.map(([context]) => context.preload)).toEqual([
- true,
- ])
- expect(loader).toHaveBeenCalledTimes(1)
-
- loaderGate.resolve('shared loader data')
- await Promise.all([preload, navigation])
-
- expect(loader).toHaveBeenCalledTimes(1)
- expect(router.state.matches.at(-1)?.context).toEqual({ guard: 'preloaded' })
- expect(router.state.matches.at(-1)?.loaderData).toBe('shared loader data')
- })
-
- test.each([
- { age: 50, expected: [false, true, false], guard: 'loaded' },
- { age: 100, expected: [false, true, false], guard: 'loaded' },
- ])(
- 'reruns completed beforeLoad while retaining navigation-owned loader data at age $age',
- async ({ age, expected, guard }) => {
- vi.useFakeTimers()
- vi.setSystemTime(1_000)
+ test(
+ 'reruns completed beforeLoad while retaining navigation-owned loader data',
+ async () => {
const beforeLoad = vi.fn(({ preload }: { preload: boolean }) => ({
guard: preload ? 'preloaded' : 'loaded',
}))
@@ -152,8 +105,6 @@ describe('preloaded loader reuse with fresh beforeLoad context', () => {
const guardedRoute = new BaseRoute({
getParentRoute: () => rootRoute,
path: '/guarded',
- staleTime: Infinity,
- preloadStaleTime: 100,
beforeLoad,
shouldReload: false,
loader,
@@ -166,18 +117,16 @@ describe('preloaded loader reuse with fresh beforeLoad context', () => {
await router.load()
await router.navigate({ to: '/guarded' })
await router.navigate({ to: '/' })
- vi.setSystemTime(5_000)
await router.preloadRoute({ to: '/guarded' })
- vi.setSystemTime(5_000 + age)
await router.navigate({ to: '/guarded' })
expect(beforeLoad.mock.calls.map(([context]) => context.preload)).toEqual(
- expected,
+ [false, true, false],
)
expect(loader.mock.calls.map(([context]) => context.preload)).toEqual([
false,
])
- expect(router.state.matches.at(-1)?.context).toEqual({ guard })
+ expect(router.state.matches.at(-1)?.context).toEqual({ guard: 'loaded' })
},
)
@@ -399,14 +348,18 @@ describe('preloaded loader reuse with fresh beforeLoad context', () => {
expect(seen).toEqual([true, false])
})
- test('invalidation prevents adoption of context from an active preload', async () => {
- const loaderGate = createControlledPromise()
+ test('invalidation prevents joining an active preload loader generation', async () => {
+ const preloadLoaderGate = createControlledPromise()
let generation = 1
const beforeLoad = vi.fn(({ preload }: { preload: boolean }) => ({
generation,
preload,
}))
- const loader = vi.fn(() => loaderGate)
+ const loader = vi.fn(({ context }: { context: { generation: number } }) =>
+ context.generation === 1
+ ? preloadLoaderGate
+ : `generation ${context.generation} data`,
+ )
const rootRoute = new BaseRootRoute({})
const indexRoute = new BaseRoute({
getParentRoute: () => rootRoute,
@@ -433,19 +386,20 @@ describe('preloaded loader reuse with fresh beforeLoad context', () => {
await router.invalidate()
const navigation = router.navigate({ to: '/guarded' })
await vi.waitFor(() => expect(beforeLoad).toHaveBeenCalledTimes(2))
- loaderGate.resolve('shared loader data')
+ await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(2))
+ preloadLoaderGate.resolve('generation 1 data')
await Promise.all([preload, navigation])
expect(beforeLoad.mock.calls.map(([context]) => context.preload)).toEqual([
true,
false,
])
- expect(loader).toHaveBeenCalledOnce()
+ expect(loader).toHaveBeenCalledTimes(2)
expect(router.state.matches.at(-1)?.context).toEqual({
generation: 2,
preload: false,
})
- expect(router.state.matches.at(-1)?.loaderData).toBe('shared loader data')
+ expect(router.state.matches.at(-1)?.loaderData).toBe('generation 2 data')
})
test('preload false does not cache beforeLoad context for navigation', async () => {
diff --git a/packages/router-core/tests/preload-navigation-adoption.test.ts b/packages/router-core/tests/preload-navigation-adoption.test.ts
index 675a69b037..589ea36496 100644
--- a/packages/router-core/tests/preload-navigation-adoption.test.ts
+++ b/packages/router-core/tests/preload-navigation-adoption.test.ts
@@ -3,12 +3,11 @@ import { createMemoryHistory } from '@tanstack/history'
import { BaseRootRoute, BaseRoute, createControlledPromise } from '../src'
import { createTestRouter } from './routerTestUtils'
-// A navigation that adopts an in-flight preload's loader run must reuse that
-// run: it must not abort the preload's loader signal or rerun the loader. This
-// is generic router adoption coverage; issue #4476 is covered separately with
-// the React Query observer-unmount sequence from its reproduction.
-describe('navigation adopting an in-flight preload', () => {
- test('adopted preload loader runs once and its signal is not aborted', async () => {
+// Navigation owns its beforeLoad context, but a matching pending loader remains
+// shared with speculation. Issue #4476 is covered separately with the React
+// Query observer-unmount sequence from its reproduction.
+describe('navigation joining an in-flight preload loader', () => {
+ test('reruns beforeLoad, reuses the loader, and keeps its signal alive', async () => {
const loaderGate = createControlledPromise()
const loaderStarted = createControlledPromise()
const beforeLoad = vi.fn()
@@ -50,12 +49,13 @@ describe('navigation adopting an in-flight preload', () => {
expect(preloadSignal).toBeDefined()
expect(preloadSignal?.aborted).toBe(false)
- // The identical navigation adopts the whole active lane, including its
- // already-completed beforeLoad context.
+ // The click is a fresh semantic lane even when its speculative loader is
+ // already pending.
const navigation = router.navigate({ to: '/foo' })
- await Promise.resolve()
+ await vi.waitFor(() => expect(beforeLoad).toHaveBeenCalledTimes(2))
expect(beforeLoad.mock.calls.map(([context]) => context.preload)).toEqual([
true,
+ false,
])
expect(loaderGate.status).toBe('pending')
expect(loader).toHaveBeenCalledTimes(1)
@@ -65,18 +65,18 @@ describe('navigation adopting an in-flight preload', () => {
loaderGate.resolve('adopted')
await Promise.all([navigation, preload])
- // Loader ran exactly once; the adopted run's signal was never aborted.
+ // Loader ran exactly once; the joined run's signal was never aborted.
expect(loader).toHaveBeenCalledTimes(1)
expect(preloadSignal?.aborted).toBe(false)
- // Navigation committed with the adopted loaderData.
+ // Navigation committed with the shared loaderData.
const committed = router.state.matches.find(
(match) => match.routeId === fooRoute.id,
)
expect(committed?.status).toBe('success')
expect(committed?.loaderData).toBe('adopted')
- // Adoption transfers loader-data lifetime ownership to the active match.
+ // Publication transfers loader-data lifetime ownership to the active match.
// The shared request remains alive while rendered, then aborts on unload.
await router.navigate({ to: '/' })
expect(preloadSignal?.aborted).toBe(true)
@@ -125,12 +125,13 @@ describe('navigation adopting an in-flight preload', () => {
])
expect(loader).toHaveBeenCalledTimes(1)
- loaderGate.resolve('shared')
+ loaderGate.resolve('shared data')
await Promise.all([preload, navigation])
expect(router.state.matches.at(-1)?.context).toEqual({
auth: true,
authorization: 'true:false',
})
+ expect(router.state.matches.at(-1)?.loaderData).toBe('shared data')
})
test('reruns beforeLoad when user location state changes at the same href', async () => {
@@ -176,46 +177,11 @@ describe('navigation adopting an in-flight preload', () => {
])
expect(loader).toHaveBeenCalledTimes(1)
- loaderGate.resolve('shared')
+ loaderGate.resolve('shared data')
await Promise.all([preload, navigation])
expect(router.state.matches.at(-1)?.context).toMatchObject({
authorization: 'new:false',
})
- })
-
- test('reruns beforeLoad when the route tree changes during an active preload', async () => {
- const loaderGate = createControlledPromise()
- const beforeLoad = vi.fn()
- const loader = vi.fn(() => loaderGate)
- const createRouteTree = () => {
- const rootRoute = new BaseRootRoute({})
- const indexRoute = new BaseRoute({
- getParentRoute: () => rootRoute,
- path: '/',
- })
- const guardedRoute = new BaseRoute({
- getParentRoute: () => rootRoute,
- path: '/guarded',
- beforeLoad,
- loader,
- })
- return rootRoute.addChildren([indexRoute, guardedRoute])
- }
- const router = createTestRouter({
- routeTree: createRouteTree(),
- history: createMemoryHistory({ initialEntries: ['/'] }),
- })
-
- await router.load()
- const preload = router.preloadRoute({ to: '/guarded' })
- await vi.waitFor(() => expect(loader).toHaveBeenCalledOnce())
-
- router.update({ ...router.options, routeTree: createRouteTree() })
- const navigation = router.navigate({ to: '/guarded' })
- await vi.waitFor(() => expect(beforeLoad).toHaveBeenCalledTimes(2))
-
- loaderGate.resolve('shared')
- await Promise.all([preload, navigation])
- expect(loader).toHaveBeenCalledOnce()
+ expect(router.state.matches.at(-1)?.loaderData).toBe('shared data')
})
})
diff --git a/packages/router-core/tests/preload-public-cache-behavior.test.ts b/packages/router-core/tests/preload-public-cache-behavior.test.ts
index 5a7b6b2b98..54acdca68c 100644
--- a/packages/router-core/tests/preload-public-cache-behavior.test.ts
+++ b/packages/router-core/tests/preload-public-cache-behavior.test.ts
@@ -9,8 +9,8 @@ afterEach(() => {
})
// https://github.com/TanStack/router/issues/2980
-// Repeated child preloads must borrow a stale active parent instead of rerunning
-// its loader.
+// Repeated child preloads must reuse data from the accepted active parent
+// generation instead of rerunning its loader.
test('#2980: repeated child preloads do not rerun a stale active parent loader', async () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
@@ -115,7 +115,7 @@ test('fresh navigation data keeps its gc policy when a preload reuses it', async
// Public contract: clearCache must remain authoritative even if an older
// preload finishes unrelated asset work after the clear.
-test('clearCache during an in-flight preload cannot resurrect its borrowed data', async () => {
+test('clearCache during an in-flight preload cannot resurrect reused accepted data', async () => {
const headGate = createControlledPromise<{
meta: Array<{ title: string }>
}>()
@@ -321,6 +321,128 @@ test('clearCache detaches every discarded flight before abort listeners reenter'
expect(router.state.matches.at(-1)?.loaderData).toBe('generation 2')
})
+test('a throwing clearCache filter leaves every preload owned', async () => {
+ const firstGate = createControlledPromise()
+ const secondGate = createControlledPromise()
+ let firstSignal: AbortSignal | undefined
+ let secondSignal: AbortSignal | undefined
+ const rootRoute = new BaseRootRoute({})
+ const homeRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ })
+ const firstRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/first',
+ loader: ({ abortController }) => {
+ firstSignal = abortController.signal
+ return firstGate
+ },
+ })
+ const secondRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/second',
+ loader: ({ abortController }) => {
+ secondSignal = abortController.signal
+ return secondGate
+ },
+ })
+ const router = createTestRouter({
+ routeTree: rootRoute.addChildren([homeRoute, firstRoute, secondRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+
+ await router.load()
+ const firstPreload = router.preloadRoute({ to: '/first' })
+ const secondPreload = router.preloadRoute({ to: '/second' })
+ await vi.waitFor(() => {
+ expect(firstSignal).toBeDefined()
+ expect(secondSignal).toBeDefined()
+ })
+
+ const failure = new Error('filter failed')
+ expect(() =>
+ router.clearCache({
+ filter: (match) => {
+ if (match.routeId === firstRoute.id) {
+ return true
+ }
+ if (match.routeId === secondRoute.id) {
+ throw failure
+ }
+ return false
+ },
+ }),
+ ).toThrow(failure)
+
+ router.clearCache()
+ expect(firstSignal?.aborted).toBe(true)
+ expect(secondSignal?.aborted).toBe(true)
+
+ firstGate.resolve('first')
+ secondGate.resolve('second')
+ await Promise.all([firstPreload, secondPreload])
+})
+
+test('filtered clearCache preserves a parent flight owned by another preload', async () => {
+ const parentGate = createControlledPromise