From eeedf96847d54165a3957b50dc5bf7550b7d8506 Mon Sep 17 00:00:00 2001
From: Ulrich Stark <8657779+ulrichstark@users.noreply.github.com>
Date: Wed, 17 Jun 2026 21:07:36 +0200
Subject: [PATCH 01/13] fix: match loading
fixes #7602
fixes #7210
fixes #7457
---
.changeset/violet-poets-wait.md | 7 +
docs/router/api/router/RouteMatchType.md | 2 +-
e2e/react-router/issue-7120/index.html | 12 +
e2e/react-router/issue-7120/package.json | 26 +
.../issue-7120/playwright.config.ts | 27 +
e2e/react-router/issue-7120/src/main.tsx | 72 +
.../issue-7120/src/posts.lazy.tsx | 25 +
.../issue-7120/tests/issue-7120.repro.spec.ts | 28 +
e2e/react-router/issue-7120/tsconfig.json | 15 +
e2e/react-router/issue-7120/vite.config.js | 6 +
e2e/react-router/issue-7457/index.html | 10 +
e2e/react-router/issue-7457/package.json | 27 +
.../issue-7457/playwright.config.ts | 27 +
e2e/react-router/issue-7457/src/main.tsx | 28 +
.../issue-7457/src/routeTree.gen.ts | 77 +
.../issue-7457/src/routes/__root.tsx | 16 +
.../issue-7457/src/routes/another.tsx | 9 +
.../issue-7457/src/routes/index.tsx | 15 +
e2e/react-router/issue-7457/src/vite-env.d.ts | 1 +
.../issue-7457/tests/issue-7457.repro.spec.ts | 28 +
e2e/react-router/issue-7457/tsconfig.json | 15 +
e2e/react-router/issue-7457/vite.config.js | 10 +
.../tests/view-transitions.spec.ts | 3 +-
packages/react-router/src/Match.tsx | 32 +-
.../react-router/tests/not-found.test.tsx | 88 ++
packages/react-router/tests/redirect.test.tsx | 46 -
.../react-router/tests/routeContext.test.tsx | 416 ++++++
.../store-updates-during-navigation.test.tsx | 2 +-
packages/router-core/src/Matches.ts | 2 +-
packages/router-core/src/load-matches.ts | 944 +++++++------
packages/router-core/src/redirect.ts | 21 +-
packages/router-core/src/router.ts | 587 ++++----
packages/router-core/src/ssr/ssr-client.ts | 2 +-
packages/router-core/src/utils.ts | 16 +-
packages/router-core/tests/hydrate.test.ts | 163 +++
packages/router-core/tests/load.test.ts | 1249 ++++++++++++++++-
.../router-devtools-core/src/useStyles.tsx | 3 +-
packages/router-devtools-core/src/utils.tsx | 1 -
packages/solid-router/src/Match.tsx | 49 +-
packages/solid-router/tests/redirect.test.tsx | 46 -
.../solid-router/tests/routeContext.test.tsx | 76 +
.../store-updates-during-navigation.test.tsx | 9 +-
packages/vue-router/src/Match.tsx | 28 -
packages/vue-router/src/Transitioner.tsx | 6 +-
packages/vue-router/tests/redirect.test.tsx | 45 -
.../store-updates-during-navigation.test.tsx | 13 +-
pnpm-lock.yaml | 65 +
skills/bundle-size-optimization/SKILL.md | 7 +
48 files changed, 3334 insertions(+), 1068 deletions(-)
create mode 100644 .changeset/violet-poets-wait.md
create mode 100644 e2e/react-router/issue-7120/index.html
create mode 100644 e2e/react-router/issue-7120/package.json
create mode 100644 e2e/react-router/issue-7120/playwright.config.ts
create mode 100644 e2e/react-router/issue-7120/src/main.tsx
create mode 100644 e2e/react-router/issue-7120/src/posts.lazy.tsx
create mode 100644 e2e/react-router/issue-7120/tests/issue-7120.repro.spec.ts
create mode 100644 e2e/react-router/issue-7120/tsconfig.json
create mode 100644 e2e/react-router/issue-7120/vite.config.js
create mode 100644 e2e/react-router/issue-7457/index.html
create mode 100644 e2e/react-router/issue-7457/package.json
create mode 100644 e2e/react-router/issue-7457/playwright.config.ts
create mode 100644 e2e/react-router/issue-7457/src/main.tsx
create mode 100644 e2e/react-router/issue-7457/src/routeTree.gen.ts
create mode 100644 e2e/react-router/issue-7457/src/routes/__root.tsx
create mode 100644 e2e/react-router/issue-7457/src/routes/another.tsx
create mode 100644 e2e/react-router/issue-7457/src/routes/index.tsx
create mode 100644 e2e/react-router/issue-7457/src/vite-env.d.ts
create mode 100644 e2e/react-router/issue-7457/tests/issue-7457.repro.spec.ts
create mode 100644 e2e/react-router/issue-7457/tsconfig.json
create mode 100644 e2e/react-router/issue-7457/vite.config.js
diff --git a/.changeset/violet-poets-wait.md b/.changeset/violet-poets-wait.md
new file mode 100644
index 0000000000..1dbc5af367
--- /dev/null
+++ b/.changeset/violet-poets-wait.md
@@ -0,0 +1,7 @@
+---
+'@tanstack/router-core': patch
+---
+
+Fix context values from a parent route's `beforeLoad` not being propagated to sub-routes in several code paths: while a sub-route's loader reloads in the background, when re-entering a route whose background reload is still in flight, and in a sub-route's error state when its `beforeLoad` throws (the merged context is now committed together with the error status for the errorComponent to consume).
+
+Redirects no longer use a renderable `RouteMatch.status`; `RouteMatch.status` is now `'pending' | 'success' | 'error' | 'notFound'`. Abandoned pending, redirected, or failed matches are dropped from cache and their pending promises are settled so stale suspense work cannot keep rendering suspended.
diff --git a/docs/router/api/router/RouteMatchType.md b/docs/router/api/router/RouteMatchType.md
index 251c2c031e..67fe4f17f6 100644
--- a/docs/router/api/router/RouteMatchType.md
+++ b/docs/router/api/router/RouteMatchType.md
@@ -11,7 +11,7 @@ interface RouteMatch {
routeId: string
pathname: string
params: Route['allParams']
- status: 'pending' | 'success' | 'error' | 'redirected' | 'notFound'
+ status: 'pending' | 'success' | 'error' | 'notFound'
isFetching: false | 'beforeLoad' | 'loader'
showPending: boolean
error: unknown
diff --git a/e2e/react-router/issue-7120/index.html b/e2e/react-router/issue-7120/index.html
new file mode 100644
index 0000000000..de92cad2a3
--- /dev/null
+++ b/e2e/react-router/issue-7120/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Issue 7120
+
+
+
+
+
+
diff --git a/e2e/react-router/issue-7120/package.json b/e2e/react-router/issue-7120/package.json
new file mode 100644
index 0000000000..da30014471
--- /dev/null
+++ b/e2e/react-router/issue-7120/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "tanstack-router-e2e-react-issue-7120",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite --port 3000",
+ "dev:e2e": "vite",
+ "build": "vite build && tsc --noEmit",
+ "preview": "vite preview",
+ "start": "vite",
+ "test:e2e": "rm -rf port*.txt; playwright test --project=chromium"
+ },
+ "dependencies": {
+ "@tanstack/react-router": "workspace:^",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.50.1",
+ "@tanstack/router-e2e-utils": "workspace:^",
+ "@types/react": "^19.0.8",
+ "@types/react-dom": "^19.0.3",
+ "@vitejs/plugin-react": "^6.0.1",
+ "vite": "^8.0.14"
+ }
+}
diff --git a/e2e/react-router/issue-7120/playwright.config.ts b/e2e/react-router/issue-7120/playwright.config.ts
new file mode 100644
index 0000000000..6ba60eaff1
--- /dev/null
+++ b/e2e/react-router/issue-7120/playwright.config.ts
@@ -0,0 +1,27 @@
+import { defineConfig, devices } from '@playwright/test'
+import { getTestServerPort } from '@tanstack/router-e2e-utils'
+import packageJson from './package.json' with { type: 'json' }
+
+const PORT = await getTestServerPort(packageJson.name)
+const baseURL = `http://localhost:${PORT}`
+
+export default defineConfig({
+ testDir: './tests',
+ workers: 1,
+ reporter: [['line']],
+ use: {
+ baseURL,
+ },
+ webServer: {
+ command: `VITE_NODE_ENV="test" VITE_SERVER_PORT=${PORT} pnpm build && pnpm preview --port ${PORT}`,
+ url: baseURL,
+ reuseExistingServer: !process.env.CI,
+ stdout: 'pipe',
+ },
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] },
+ },
+ ],
+})
diff --git a/e2e/react-router/issue-7120/src/main.tsx b/e2e/react-router/issue-7120/src/main.tsx
new file mode 100644
index 0000000000..08c46f529a
--- /dev/null
+++ b/e2e/react-router/issue-7120/src/main.tsx
@@ -0,0 +1,72 @@
+import ReactDOM from 'react-dom/client'
+import {
+ Outlet,
+ RouterProvider,
+ createRootRoute,
+ createRoute,
+ createRouter,
+ redirect,
+} from '@tanstack/react-router'
+
+const posts = [
+ {
+ id: '1',
+ title: 'sunt aut facere repellat provident occaecati',
+ },
+]
+
+const rootRoute = createRootRoute({
+ component: RootComponent,
+ pendingMs: 0,
+ pendingComponent: () => {
+ ;(globalThis as any).__pendingSeen = true
+ return loading
+ },
+ beforeLoad: async ({ matches }) => {
+ if (matches.find((match) => match.routeId === '/posts')) {
+ return
+ }
+
+ await new Promise((resolve) => setTimeout(resolve, 1000))
+ throw redirect({ to: '/posts' })
+ },
+})
+
+function RootComponent() {
+ return
+}
+
+const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => Home
,
+})
+
+const postsRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: 'posts',
+ loader: async () => {
+ await new Promise((resolve) => setTimeout(resolve, 10))
+ return posts
+ },
+}).lazy(() => import('./posts.lazy').then((d) => d.Route))
+
+const routeTree = rootRoute.addChildren([indexRoute, postsRoute])
+
+const router = createRouter({
+ routeTree,
+ defaultViewTransition: true,
+})
+
+declare module '@tanstack/react-router' {
+ interface Register {
+ router: typeof router
+ }
+}
+
+const rootElement = document.getElementById('app')!
+
+if (!rootElement.innerHTML) {
+ const root = ReactDOM.createRoot(rootElement)
+ root.render()
+}
diff --git a/e2e/react-router/issue-7120/src/posts.lazy.tsx b/e2e/react-router/issue-7120/src/posts.lazy.tsx
new file mode 100644
index 0000000000..f2635f2e08
--- /dev/null
+++ b/e2e/react-router/issue-7120/src/posts.lazy.tsx
@@ -0,0 +1,25 @@
+import { Link, createLazyRoute } from '@tanstack/react-router'
+
+export const Route = createLazyRoute('/posts')({
+ component: PostsComponent,
+})
+
+function PostsComponent() {
+ const posts = Route.useLoaderData()
+
+ return (
+
+ )
+}
diff --git a/e2e/react-router/issue-7120/tests/issue-7120.repro.spec.ts b/e2e/react-router/issue-7120/tests/issue-7120.repro.spec.ts
new file mode 100644
index 0000000000..3b4d9a65cb
--- /dev/null
+++ b/e2e/react-router/issue-7120/tests/issue-7120.repro.spec.ts
@@ -0,0 +1,28 @@
+import { expect, test } from '@playwright/test'
+
+test('root beforeLoad redirect does not blank when pending UI and view transitions are enabled (https://github.com/TanStack/router/issues/7120)', async ({
+ page,
+}) => {
+ const pageErrors: Array = []
+ const consoleErrors: Array = []
+
+ page.on('pageerror', (error) => {
+ pageErrors.push(error.message)
+ })
+ page.on('console', (message) => {
+ if (message.type() === 'error') {
+ consoleErrors.push(message.text())
+ }
+ })
+
+ await page.goto('/')
+
+ await expect(page).toHaveURL(/\/posts$/)
+ await expect(page.getByText('sunt aut facere repe')).toBeVisible()
+ await expect
+ .poll(() => page.evaluate(() => (globalThis as any).__pendingSeen))
+ .toBe(true)
+ await expect(page.getByTestId('root-pending')).not.toBeVisible()
+ expect(pageErrors).toEqual([])
+ expect(consoleErrors).toEqual([])
+})
diff --git a/e2e/react-router/issue-7120/tsconfig.json b/e2e/react-router/issue-7120/tsconfig.json
new file mode 100644
index 0000000000..4f6089bc08
--- /dev/null
+++ b/e2e/react-router/issue-7120/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "strict": true,
+ "esModuleInterop": true,
+ "jsx": "react-jsx",
+ "target": "ESNext",
+ "moduleResolution": "Bundler",
+ "module": "ESNext",
+ "resolveJsonModule": true,
+ "allowJs": true,
+ "skipLibCheck": true,
+ "types": ["vite/client"]
+ },
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/e2e/react-router/issue-7120/vite.config.js b/e2e/react-router/issue-7120/vite.config.js
new file mode 100644
index 0000000000..9ffcc67574
--- /dev/null
+++ b/e2e/react-router/issue-7120/vite.config.js
@@ -0,0 +1,6 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+export default defineConfig({
+ plugins: [react()],
+})
diff --git a/e2e/react-router/issue-7457/index.html b/e2e/react-router/issue-7457/index.html
new file mode 100644
index 0000000000..76369b5957
--- /dev/null
+++ b/e2e/react-router/issue-7457/index.html
@@ -0,0 +1,10 @@
+
+
+
+
+
+ Issue 7457
+
+
+
+
diff --git a/e2e/react-router/issue-7457/package.json b/e2e/react-router/issue-7457/package.json
new file mode 100644
index 0000000000..9d9413e213
--- /dev/null
+++ b/e2e/react-router/issue-7457/package.json
@@ -0,0 +1,27 @@
+{
+ "name": "tanstack-router-e2e-react-issue-7457",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite --port 3000",
+ "dev:e2e": "vite",
+ "build": "vite build && tsc --noEmit",
+ "preview": "vite preview",
+ "start": "vite",
+ "test:e2e": "rm -rf port*.txt; playwright test --project=chromium"
+ },
+ "dependencies": {
+ "@tanstack/react-router": "workspace:^",
+ "@tanstack/router-plugin": "workspace:^",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.50.1",
+ "@tanstack/router-e2e-utils": "workspace:^",
+ "@types/react": "^19.0.8",
+ "@types/react-dom": "^19.0.3",
+ "@vitejs/plugin-react": "^6.0.1",
+ "vite": "^8.0.14"
+ }
+}
diff --git a/e2e/react-router/issue-7457/playwright.config.ts b/e2e/react-router/issue-7457/playwright.config.ts
new file mode 100644
index 0000000000..6ba60eaff1
--- /dev/null
+++ b/e2e/react-router/issue-7457/playwright.config.ts
@@ -0,0 +1,27 @@
+import { defineConfig, devices } from '@playwright/test'
+import { getTestServerPort } from '@tanstack/router-e2e-utils'
+import packageJson from './package.json' with { type: 'json' }
+
+const PORT = await getTestServerPort(packageJson.name)
+const baseURL = `http://localhost:${PORT}`
+
+export default defineConfig({
+ testDir: './tests',
+ workers: 1,
+ reporter: [['line']],
+ use: {
+ baseURL,
+ },
+ webServer: {
+ command: `VITE_NODE_ENV="test" VITE_SERVER_PORT=${PORT} pnpm build && pnpm preview --port ${PORT}`,
+ url: baseURL,
+ reuseExistingServer: !process.env.CI,
+ stdout: 'pipe',
+ },
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] },
+ },
+ ],
+})
diff --git a/e2e/react-router/issue-7457/src/main.tsx b/e2e/react-router/issue-7457/src/main.tsx
new file mode 100644
index 0000000000..ccf8ec77b2
--- /dev/null
+++ b/e2e/react-router/issue-7457/src/main.tsx
@@ -0,0 +1,28 @@
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import { RouterProvider, createRouter } from '@tanstack/react-router'
+import { routeTree } from './routeTree.gen'
+
+const router = createRouter({
+ routeTree,
+ defaultPendingComponent: DefaultPendingComponent,
+ defaultPreloadStaleTime: 0,
+})
+
+declare module '@tanstack/react-router' {
+ interface Register {
+ router: typeof router
+ }
+}
+
+function DefaultPendingComponent() {
+ ;(globalThis as any).__pendingSeen = true
+
+ return loading
+}
+
+createRoot(document.body).render(
+
+
+ ,
+)
diff --git a/e2e/react-router/issue-7457/src/routeTree.gen.ts b/e2e/react-router/issue-7457/src/routeTree.gen.ts
new file mode 100644
index 0000000000..72a8f23384
--- /dev/null
+++ b/e2e/react-router/issue-7457/src/routeTree.gen.ts
@@ -0,0 +1,77 @@
+/* eslint-disable */
+
+// @ts-nocheck
+
+// noinspection JSUnusedGlobalSymbols
+
+// This file was automatically generated by TanStack Router.
+// You should NOT make any changes in this file as it will be overwritten.
+// 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'
+
+const AnotherRoute = AnotherRouteImport.update({
+ id: '/another',
+ path: '/another',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const IndexRoute = IndexRouteImport.update({
+ id: '/',
+ path: '/',
+ getParentRoute: () => rootRouteImport,
+} as any)
+
+export interface FileRoutesByFullPath {
+ '/': typeof IndexRoute
+ '/another': typeof AnotherRoute
+}
+export interface FileRoutesByTo {
+ '/': typeof IndexRoute
+ '/another': typeof AnotherRoute
+}
+export interface FileRoutesById {
+ __root__: typeof rootRouteImport
+ '/': typeof IndexRoute
+ '/another': typeof AnotherRoute
+}
+export interface FileRouteTypes {
+ fileRoutesByFullPath: FileRoutesByFullPath
+ fullPaths: '/' | '/another'
+ fileRoutesByTo: FileRoutesByTo
+ to: '/' | '/another'
+ id: '__root__' | '/' | '/another'
+ fileRoutesById: FileRoutesById
+}
+export interface RootRouteChildren {
+ IndexRoute: typeof IndexRoute
+ AnotherRoute: typeof AnotherRoute
+}
+
+declare module '@tanstack/react-router' {
+ interface FileRoutesByPath {
+ '/another': {
+ id: '/another'
+ path: '/another'
+ fullPath: '/another'
+ preLoaderRoute: typeof AnotherRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/': {
+ id: '/'
+ path: '/'
+ fullPath: '/'
+ preLoaderRoute: typeof IndexRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ }
+}
+
+const rootRouteChildren: RootRouteChildren = {
+ IndexRoute: IndexRoute,
+ AnotherRoute: AnotherRoute,
+}
+export const routeTree = rootRouteImport
+ ._addFileChildren(rootRouteChildren)
+ ._addFileTypes()
diff --git a/e2e/react-router/issue-7457/src/routes/__root.tsx b/e2e/react-router/issue-7457/src/routes/__root.tsx
new file mode 100644
index 0000000000..d3dced6736
--- /dev/null
+++ b/e2e/react-router/issue-7457/src/routes/__root.tsx
@@ -0,0 +1,16 @@
+import { Outlet, createRootRoute } from '@tanstack/react-router'
+
+export const Route = createRootRoute({
+ beforeLoad: async () => {
+ await new Promise((resolve) => setTimeout(resolve, 1500))
+ },
+ component: RootComponent,
+})
+
+function RootComponent() {
+ return (
+
+
+
+ )
+}
diff --git a/e2e/react-router/issue-7457/src/routes/another.tsx b/e2e/react-router/issue-7457/src/routes/another.tsx
new file mode 100644
index 0000000000..2274ee634b
--- /dev/null
+++ b/e2e/react-router/issue-7457/src/routes/another.tsx
@@ -0,0 +1,9 @@
+import { createFileRoute } from '@tanstack/react-router'
+
+export const Route = createFileRoute('/another')({
+ component: RouteComponent,
+})
+
+function RouteComponent() {
+ return Hello "/another"!
+}
diff --git a/e2e/react-router/issue-7457/src/routes/index.tsx b/e2e/react-router/issue-7457/src/routes/index.tsx
new file mode 100644
index 0000000000..91f9a1c4eb
--- /dev/null
+++ b/e2e/react-router/issue-7457/src/routes/index.tsx
@@ -0,0 +1,15 @@
+import { createFileRoute, redirect } from '@tanstack/react-router'
+
+export const Route = createFileRoute('/')({
+ beforeLoad: () => {
+ throw redirect({
+ to: '/another',
+ replace: true,
+ })
+ },
+ component: RouteComponent,
+})
+
+function RouteComponent() {
+ return You should never see this!
+}
diff --git a/e2e/react-router/issue-7457/src/vite-env.d.ts b/e2e/react-router/issue-7457/src/vite-env.d.ts
new file mode 100644
index 0000000000..11f02fe2a0
--- /dev/null
+++ b/e2e/react-router/issue-7457/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/e2e/react-router/issue-7457/tests/issue-7457.repro.spec.ts b/e2e/react-router/issue-7457/tests/issue-7457.repro.spec.ts
new file mode 100644
index 0000000000..fd1c582bcf
--- /dev/null
+++ b/e2e/react-router/issue-7457/tests/issue-7457.repro.spec.ts
@@ -0,0 +1,28 @@
+import { expect, test } from '@playwright/test'
+
+test('initial child beforeLoad redirect after async root beforeLoad does not blank (https://github.com/TanStack/router/issues/7457)', async ({
+ page,
+}) => {
+ const pageErrors: Array = []
+ const consoleErrors: Array = []
+
+ page.on('pageerror', (error) => {
+ pageErrors.push(error.message || String(error))
+ })
+ page.on('console', (message) => {
+ if (message.type() === 'error') {
+ consoleErrors.push(message.text())
+ }
+ })
+
+ await page.goto('/')
+
+ await expect(page).toHaveURL(/\/another$/)
+ await expect(page.getByText('Hello "/another"!')).toBeVisible()
+ await expect
+ .poll(() => page.evaluate(() => (globalThis as any).__pendingSeen))
+ .toBe(true)
+ await expect(page.getByTestId('app-pending')).not.toBeVisible()
+ expect(pageErrors).toEqual([])
+ expect(consoleErrors).toEqual([])
+})
diff --git a/e2e/react-router/issue-7457/tsconfig.json b/e2e/react-router/issue-7457/tsconfig.json
new file mode 100644
index 0000000000..4f6089bc08
--- /dev/null
+++ b/e2e/react-router/issue-7457/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "strict": true,
+ "esModuleInterop": true,
+ "jsx": "react-jsx",
+ "target": "ESNext",
+ "moduleResolution": "Bundler",
+ "module": "ESNext",
+ "resolveJsonModule": true,
+ "allowJs": true,
+ "skipLibCheck": true,
+ "types": ["vite/client"]
+ },
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/e2e/react-router/issue-7457/vite.config.js b/e2e/react-router/issue-7457/vite.config.js
new file mode 100644
index 0000000000..9e39ea83d9
--- /dev/null
+++ b/e2e/react-router/issue-7457/vite.config.js
@@ -0,0 +1,10 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+import { tanstackRouter } from '@tanstack/router-plugin/vite'
+
+export default defineConfig({
+ plugins: [
+ tanstackRouter({ target: 'react', autoCodeSplitting: true }),
+ react(),
+ ],
+})
diff --git a/e2e/vue-router/view-transitions/tests/view-transitions.spec.ts b/e2e/vue-router/view-transitions/tests/view-transitions.spec.ts
index 31d8b12b04..d38e059360 100644
--- a/e2e/vue-router/view-transitions/tests/view-transitions.spec.ts
+++ b/e2e/vue-router/view-transitions/tests/view-transitions.spec.ts
@@ -20,7 +20,8 @@ test.beforeEach(async ({ page }) => {
// ignore
}
- return { finished: Promise.resolve() }
+ const updateCallbackDone = Promise.resolve()
+ return { finished: updateCallbackDone, updateCallbackDone }
}
})
diff --git a/packages/react-router/src/Match.tsx b/packages/react-router/src/Match.tsx
index 5de5546aeb..05744ffa0c 100644
--- a/packages/react-router/src/Match.tsx
+++ b/packages/react-router/src/Match.tsx
@@ -7,7 +7,6 @@ import {
getLocationChangeInfo,
invariant,
isNotFound,
- isRedirect,
rootRouteId,
} from '@tanstack/router-core'
import { isServer } from '@tanstack/router-core/isServer'
@@ -306,7 +305,9 @@ export const MatchInner = React.memo(function MatchInnerImpl({
key: 'displayPendingPromise' | 'minPendingPromise' | 'loadPromise',
) => {
return (
- router.getMatch(match.id)?._nonReactive[key] ?? match._nonReactive[key]
+ router.getMatch(match.id)?._nonReactive[key] ??
+ match._nonReactive[key] ??
+ router.latestLoadPromise
)
}
@@ -360,17 +361,6 @@ export const MatchInner = React.memo(function MatchInnerImpl({
return renderRouteNotFound(router, route, match.error)
}
- if (match.status === 'redirected') {
- if (!isRedirect(match.error)) {
- if (process.env.NODE_ENV !== 'production') {
- throw new Error('Invariant failed: Expected a redirect error')
- }
-
- invariant()
- }
- throw getMatchPromise(match, 'loadPromise')
- }
-
if (match.status === 'error') {
const RouteErrorComponent =
(route.options.errorComponent ??
@@ -478,22 +468,6 @@ export const MatchInner = React.memo(function MatchInnerImpl({
return renderRouteNotFound(router, route, match.error)
}
- if (match.status === 'redirected') {
- // A match can be observed as redirected during an in-flight transition,
- // especially when pending UI is already rendering. Suspend on the match's
- // load promise so React can abandon this stale render and continue the
- // redirect transition.
- if (!isRedirect(match.error)) {
- if (process.env.NODE_ENV !== 'production') {
- throw new Error('Invariant failed: Expected a redirect error')
- }
-
- invariant()
- }
-
- throw getMatchPromise(match, 'loadPromise')
- }
-
if (match.status === 'error') {
// If we're on the server, we need to use React's new and super
// wonky api for throwing errors from a server side render inside
diff --git a/packages/react-router/tests/not-found.test.tsx b/packages/react-router/tests/not-found.test.tsx
index 3754785d35..4ea1f00ae9 100644
--- a/packages/react-router/tests/not-found.test.tsx
+++ b/packages/react-router/tests/not-found.test.tsx
@@ -409,6 +409,94 @@ test('beforeLoad notFound with routeId targets parent boundary and preserves par
expect(screen.queryByTestId('child-component')).not.toBeInTheDocument()
})
+test('loader notFound with routeId preserves parent route context for notFoundComponent', async () => {
+ const rootRoute = createRootRoute({
+ component: () => ,
+ })
+
+ const parentRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/parent',
+ beforeLoad: () => ({ number: 42 }),
+ component: () => ,
+ notFoundComponent: () => {
+ const context = parentRoute.useRouteContext()
+ return (
+ {context.number}
+ )
+ },
+ })
+
+ const childRoute = createRoute({
+ getParentRoute: () => parentRoute,
+ path: '/child',
+ loader: () => {
+ throw notFound({ routeId: parentRoute.id })
+ },
+ component: () => Child,
+ })
+
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]),
+ history,
+ notFoundMode: 'fuzzy',
+ })
+
+ render()
+ await router.navigate({ to: '/parent/child' })
+
+ expect(
+ await screen.findByTestId('parent-not-found-context'),
+ ).toHaveTextContent('42')
+ expect(screen.queryByTestId('child-component')).not.toBeInTheDocument()
+})
+
+test('beforeLoad notFound clears stale context before rendering own notFoundComponent', async () => {
+ let shouldThrow = false
+
+ const rootRoute = createRootRoute({
+ component: () => ,
+ })
+
+ const childRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/child',
+ beforeLoad: () => {
+ if (shouldThrow) {
+ throw notFound()
+ }
+
+ return { number: 42 }
+ },
+ component: () => Child,
+ notFoundComponent: () => {
+ const context = childRoute.useRouteContext()
+ return (
+
+ {String(context.number)}
+
+ )
+ },
+ })
+
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([childRoute]),
+ history,
+ notFoundMode: 'fuzzy',
+ })
+
+ render()
+ await router.navigate({ to: '/child' })
+ expect(await screen.findByTestId('child-component')).toBeInTheDocument()
+
+ shouldThrow = true
+ await router.invalidate()
+
+ expect(
+ await screen.findByTestId('child-not-found-context'),
+ ).toHaveTextContent('undefined')
+})
+
test('beforeLoad notFound with non-exact routeId falls back to root notFoundComponent', async () => {
const rootRoute = createRootRoute({
component: () => ,
diff --git a/packages/react-router/tests/redirect.test.tsx b/packages/react-router/tests/redirect.test.tsx
index cc15f0da36..0b2f7079e6 100644
--- a/packages/react-router/tests/redirect.test.tsx
+++ b/packages/react-router/tests/redirect.test.tsx
@@ -113,52 +113,6 @@ describe('redirect', () => {
expect(nestedFooLoaderMock).toHaveBeenCalled()
})
- test('when root `beforeLoad` redirects while root pendingComponent is showing and the target route is lazy', async () => {
- let hasRedirected = false
- const consoleError = vi
- .spyOn(console, 'error')
- .mockImplementation(() => {})
-
- const rootRoute = createRootRoute({
- component: () => ,
- pendingMs: 0,
- pendingComponent: () => loading
,
- beforeLoad: async () => {
- await sleep(WAIT_TIME)
- if (!hasRedirected) {
- hasRedirected = true
- throw redirect({ to: '/posts' })
- }
- },
- })
-
- const indexRoute = createRoute({
- getParentRoute: () => rootRoute,
- path: '/',
- component: () => Index page
,
- })
-
- const postsRoute = createRoute({
- getParentRoute: () => rootRoute,
- path: '/posts',
- }).lazy(() => import('./lazy/normal').then((d) => d.Route('/posts')))
-
- const router = createRouter({
- routeTree: rootRoute.addChildren([indexRoute, postsRoute]),
- history,
- })
-
- render()
-
- // The lazy target route adds the async boundary that exposes the stale
- // redirected-match render path this regression is guarding.
- expect(await screen.findByTestId('lazy-route-page')).toBeInTheDocument()
- expect(screen.queryByTestId('pending')).not.toBeInTheDocument()
- expect(router.state.location.href).toBe('/posts')
- expect(router.state.status).toBe('idle')
- expect(consoleError).not.toHaveBeenCalled()
- })
-
test('when `redirect` is thrown in `loader`', async () => {
const nestedLoaderMock = vi.fn()
const nestedFooLoaderMock = vi.fn()
diff --git a/packages/react-router/tests/routeContext.test.tsx b/packages/react-router/tests/routeContext.test.tsx
index d11f86420e..a9c8b0f1ec 100644
--- a/packages/react-router/tests/routeContext.test.tsx
+++ b/packages/react-router/tests/routeContext.test.tsx
@@ -2796,6 +2796,422 @@ describe('useRouteContext in the component', () => {
expect(content).toBeInTheDocument()
})
+ test('context value from beforeLoad is propagated to a sub-route while its loader reloads in the background', async () => {
+ let sawUndefinedContext = false
+
+ const rootRoute = createRootRoute({
+ component: () => ,
+ })
+ const homeRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => Home page
,
+ })
+ const contextPropagationRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/context-propagation',
+ beforeLoad: () => ({ number: 42 }),
+ component: () => ,
+ })
+ const contextPropagationIndexRoute = createRoute({
+ getParentRoute: () => contextPropagationRoute,
+ path: '/',
+ staleTime: 0,
+ loader: async () => {
+ await sleep(WAIT_TIME)
+ },
+ component: () => {
+ const { number } = contextPropagationIndexRoute.useRouteContext()
+ sawUndefinedContext ||= number === undefined
+
+ return (
+
+ number = {String(number)}, saw undefined ={' '}
+ {String(sawUndefinedContext)}
+
+ )
+ },
+ })
+
+ const routeTree = rootRoute.addChildren([
+ homeRoute,
+ contextPropagationRoute.addChildren([contextPropagationIndexRoute]),
+ ])
+ const router = createRouter({ routeTree, history })
+
+ render()
+
+ await act(() => router.navigate({ to: '/context-propagation' }))
+
+ expect(
+ await screen.findByText('number = 42, saw undefined = false'),
+ ).toBeInTheDocument()
+
+ await act(() => router.navigate({ to: '/' }))
+
+ expect(await screen.findByText('Home page')).toBeInTheDocument()
+
+ act(() => router.history.back())
+
+ expect(
+ await screen.findByText('number = 42, saw undefined = false'),
+ ).toBeInTheDocument()
+
+ // let the background reload settle, the context must survive the
+ // loader's success update
+ await act(() => sleep(WAIT_TIME + 50))
+
+ expect(
+ await screen.findByText('number = 42, saw undefined = false'),
+ ).toBeInTheDocument()
+ })
+
+ test('context value from beforeLoad is propagated to a sub-route when it is re-entered while its loader is still reloading in the background', async () => {
+ let sawUndefinedContext = false
+ const loaderTime = WAIT_TIME * 3
+
+ const rootRoute = createRootRoute({
+ component: () => ,
+ })
+ const homeRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => Home page
,
+ })
+ const reloadInFlightRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/reload-in-flight',
+ beforeLoad: () => ({ number: 42 }),
+ component: () => ,
+ })
+ const reloadInFlightIndexRoute = createRoute({
+ getParentRoute: () => reloadInFlightRoute,
+ path: '/',
+ staleTime: 0,
+ loader: async () => {
+ await sleep(loaderTime)
+ },
+ component: () => {
+ const { number } = reloadInFlightIndexRoute.useRouteContext()
+ sawUndefinedContext ||= number === undefined
+
+ return (
+
+ number = {String(number)}, saw undefined ={' '}
+ {String(sawUndefinedContext)}
+
+ )
+ },
+ })
+
+ const routeTree = rootRoute.addChildren([
+ homeRoute,
+ reloadInFlightRoute.addChildren([reloadInFlightIndexRoute]),
+ ])
+ const router = createRouter({ routeTree, history })
+
+ render()
+
+ await act(() => router.navigate({ to: '/reload-in-flight' }))
+
+ expect(
+ await screen.findByText('number = 42, saw undefined = false'),
+ ).toBeInTheDocument()
+
+ // re-entering the route starts a background reload of the sub-route
+ await act(() => router.navigate({ to: '/' }))
+ expect(await screen.findByText('Home page')).toBeInTheDocument()
+ act(() => router.history.back())
+
+ expect(
+ await screen.findByText('number = 42, saw undefined = false'),
+ ).toBeInTheDocument()
+
+ // re-enter once more while that background reload is still in flight
+ await act(() => router.navigate({ to: '/' }))
+ expect(await screen.findByText('Home page')).toBeInTheDocument()
+ act(() => router.history.back())
+
+ expect(
+ await screen.findByText('number = 42, saw undefined = false'),
+ ).toBeInTheDocument()
+
+ // let the in-flight reload settle, the context must survive its completion
+ await act(() => sleep(loaderTime + 50))
+
+ expect(
+ await screen.findByText('number = 42, saw undefined = false'),
+ ).toBeInTheDocument()
+ })
+
+ test('context value from beforeLoad is kept when the background reload of a sub-route is aborted', async () => {
+ let sawUndefinedContext = false
+ let loaderRuns = 0
+
+ const rootRoute = createRootRoute({
+ component: () => ,
+ })
+ const homeRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => Home page
,
+ })
+ const reloadAbortRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/reload-abort',
+ beforeLoad: () => ({ number: 42 }),
+ component: () => ,
+ })
+ const reloadAbortIndexRoute = createRoute({
+ getParentRoute: () => reloadAbortRoute,
+ path: '/',
+ staleTime: 0,
+ loader: async () => {
+ loaderRuns++
+ await sleep(WAIT_TIME)
+ if (loaderRuns > 1) {
+ const error = new Error('aborted')
+ error.name = 'AbortError'
+ throw error
+ }
+ },
+ component: () => {
+ const { number } = reloadAbortIndexRoute.useRouteContext()
+ sawUndefinedContext ||= number === undefined
+
+ return (
+
+ number = {String(number)}, saw undefined ={' '}
+ {String(sawUndefinedContext)}
+
+ )
+ },
+ })
+
+ const routeTree = rootRoute.addChildren([
+ homeRoute,
+ reloadAbortRoute.addChildren([reloadAbortIndexRoute]),
+ ])
+ const router = createRouter({ routeTree, history })
+
+ render()
+
+ await act(() => router.navigate({ to: '/reload-abort' }))
+
+ expect(
+ await screen.findByText('number = 42, saw undefined = false'),
+ ).toBeInTheDocument()
+
+ // re-entering the route starts a background reload which gets aborted
+ await act(() => router.navigate({ to: '/' }))
+ expect(await screen.findByText('Home page')).toBeInTheDocument()
+ act(() => router.history.back())
+
+ expect(
+ await screen.findByText('number = 42, saw undefined = false'),
+ ).toBeInTheDocument()
+
+ // let the aborted reload settle, the context must survive the abort
+ await act(() => sleep(WAIT_TIME + 50))
+
+ expect(
+ await screen.findByText('number = 42, saw undefined = false'),
+ ).toBeInTheDocument()
+ })
+
+ test("context value from beforeLoad is available in a sub-route's errorComponent when its background reload fails", async () => {
+ let loaderRuns = 0
+
+ const rootRoute = createRootRoute({
+ component: () => ,
+ })
+ const homeRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => Home page
,
+ })
+ const reloadErrorRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/reload-error',
+ beforeLoad: () => ({ number: 42 }),
+ component: () => ,
+ })
+ const reloadErrorIndexRoute = createRoute({
+ getParentRoute: () => reloadErrorRoute,
+ path: '/',
+ staleTime: 0,
+ loader: async () => {
+ loaderRuns++
+ await sleep(WAIT_TIME)
+ if (loaderRuns > 1) {
+ throw new Error('loader failed')
+ }
+ },
+ component: () => {
+ const { number } = reloadErrorIndexRoute.useRouteContext()
+
+ return number = {String(number)}
+ },
+ errorComponent: () => {
+ const { number } = reloadErrorIndexRoute.useRouteContext()
+
+ return error number = {String(number)}
+ },
+ })
+
+ const routeTree = rootRoute.addChildren([
+ homeRoute,
+ reloadErrorRoute.addChildren([reloadErrorIndexRoute]),
+ ])
+ const router = createRouter({ routeTree, history })
+
+ render()
+
+ await act(() => router.navigate({ to: '/reload-error' }))
+
+ expect(await screen.findByText('number = 42')).toBeInTheDocument()
+
+ // re-entering the route starts a background reload which fails
+ await act(() => router.navigate({ to: '/' }))
+ expect(await screen.findByText('Home page')).toBeInTheDocument()
+ act(() => router.history.back())
+
+ expect(await screen.findByText('number = 42')).toBeInTheDocument()
+
+ // once the failed reload settles, the errorComponent must still see
+ // the context value provided by the parent's beforeLoad
+ await act(() => sleep(WAIT_TIME + 50))
+
+ expect(await screen.findByText('error number = 42')).toBeInTheDocument()
+ })
+
+ test("context value from beforeLoad is available in a sub-route's errorComponent when the sub-route's beforeLoad throws", async () => {
+ const rootRoute = createRootRoute({
+ component: () => ,
+ })
+ const homeRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => Home page
,
+ })
+ const beforeLoadErrorRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/before-load-error',
+ beforeLoad: () => ({ number: 42 }),
+ component: () => ,
+ })
+ const beforeLoadErrorIndexRoute = createRoute({
+ getParentRoute: () => beforeLoadErrorRoute,
+ path: '/',
+ beforeLoad: () => {
+ throw new Error('beforeLoad failed')
+ },
+ component: () => never rendered
,
+ errorComponent: () => {
+ const { number } = beforeLoadErrorIndexRoute.useRouteContext()
+
+ return error number = {String(number)}
+ },
+ })
+
+ const routeTree = rootRoute.addChildren([
+ homeRoute,
+ beforeLoadErrorRoute.addChildren([beforeLoadErrorIndexRoute]),
+ ])
+ const router = createRouter({ routeTree, history })
+
+ render()
+
+ await act(() => router.navigate({ to: '/before-load-error' }))
+
+ expect(await screen.findByText('error number = 42')).toBeInTheDocument()
+ })
+
+ test('updated context value from beforeLoad is committed atomically with a blocking reload of the sub-route', async () => {
+ let sawUndefinedContext = false
+ let beforeLoadRuns = 0
+ let loaderRuns = 0
+ let releaseLoader!: () => void
+ const loaderGate = new Promise((resolve) => {
+ releaseLoader = resolve
+ })
+
+ const rootRoute = createRootRoute({
+ component: () => ,
+ })
+ const homeRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => Home page
,
+ })
+ const blockingReloadRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/blocking-reload',
+ beforeLoad: () => ({ number: ++beforeLoadRuns }),
+ component: () => ,
+ })
+ const blockingReloadIndexRoute = createRoute({
+ getParentRoute: () => blockingReloadRoute,
+ path: '/',
+ loader: async () => {
+ loaderRuns++
+ if (loaderRuns > 1) {
+ await loaderGate
+ }
+ },
+ component: () => {
+ const { number } = blockingReloadIndexRoute.useRouteContext()
+ sawUndefinedContext ||= number === undefined
+
+ return (
+
+ number = {String(number)}, saw undefined ={' '}
+ {String(sawUndefinedContext)}
+
+ )
+ },
+ })
+
+ const routeTree = rootRoute.addChildren([
+ homeRoute,
+ blockingReloadRoute.addChildren([blockingReloadIndexRoute]),
+ ])
+ const router = createRouter({
+ routeTree,
+ history,
+ defaultStaleReloadMode: 'blocking',
+ })
+
+ render()
+
+ await act(() => router.navigate({ to: '/blocking-reload' }))
+
+ expect(
+ await screen.findByText('number = 1, saw undefined = false'),
+ ).toBeInTheDocument()
+
+ // invalidating re-runs beforeLoad and blocks on the gated loader
+ act(() => {
+ void router.invalidate()
+ })
+
+ // while the blocking reload is in flight, the visible UI must keep the
+ // old, consistent pass: updates of the in-progress pass stay isolated
+ // in the pending match pool until the whole pass commits
+ await act(() => sleep(25))
+ expect(
+ screen.getByText('number = 1, saw undefined = false'),
+ ).toBeInTheDocument()
+
+ // releasing the loader commits the pass: the updated context must become
+ // visible together with the reload result
+ act(() => releaseLoader())
+
+ expect(
+ await screen.findByText('number = 2, saw undefined = false'),
+ ).toBeInTheDocument()
+ })
+
// Check if context that is updated at the root, is the same in the root route
test('modified route context, present in the root route', async () => {
const rootRoute = createRootRoute({
diff --git a/packages/react-router/tests/store-updates-during-navigation.test.tsx b/packages/react-router/tests/store-updates-during-navigation.test.tsx
index 0c4c1b4146..1e9cbd8e20 100644
--- a/packages/react-router/tests/store-updates-during-navigation.test.tsx
+++ b/packages/react-router/tests/store-updates-during-navigation.test.tsx
@@ -136,7 +136,7 @@ describe("Store doesn't update *too many* times during navigation", () => {
// This number should be as small as possible to minimize the amount of work
// that needs to be done during a navigation.
// Any change that increases this number should be investigated.
- expect(updates).toBe(8)
+ expect(updates).toBe(7)
})
test('redirection in preload', async () => {
diff --git a/packages/router-core/src/Matches.ts b/packages/router-core/src/Matches.ts
index 852d186b67..3e3e6fe888 100644
--- a/packages/router-core/src/Matches.ts
+++ b/packages/router-core/src/Matches.ts
@@ -131,7 +131,7 @@ export interface RouteMatch<
pathname: string
params: TAllParams
_strictParams: TAllParams
- status: 'pending' | 'success' | 'error' | 'redirected' | 'notFound'
+ status: 'pending' | 'success' | 'error' | 'notFound'
isFetching: false | 'beforeLoad' | 'loader'
error: unknown
paramsError: unknown
diff --git a/packages/router-core/src/load-matches.ts b/packages/router-core/src/load-matches.ts
index f901a0c97d..c037e93d63 100644
--- a/packages/router-core/src/load-matches.ts
+++ b/packages/router-core/src/load-matches.ts
@@ -5,6 +5,7 @@ import { isNotFound } from './not-found'
import { rootRouteId } from './root'
import { isRedirect } from './redirect'
import type { NotFoundError } from './not-found'
+import type { AnyRedirect } from './redirect'
import type { ParsedLocation } from './location'
import type {
AnyRoute,
@@ -32,6 +33,7 @@ type InnerLoadContext = {
updateMatch: UpdateMatchFn
matches: Array
preload?: boolean
+ preloadMatchIds?: Set
forceStaleReload?: boolean
onReady?: () => Promise
sync?: boolean
@@ -44,39 +46,90 @@ const triggerOnReady = (inner: InnerLoadContext): void | Promise => {
}
}
-const hasForcePendingActiveMatch = (router: AnyRouter): boolean => {
- return router.stores.matchesId.get().some((matchId) => {
- return router.stores.matchStores.get(matchId)?.get()._forcePending
- })
-}
-
const resolvePreload = (inner: InnerLoadContext, matchId: string): boolean => {
- return !!(inner.preload && !inner.router.stores.matchStores.has(matchId))
+ return !!inner.preload && !inner.router.stores.matchStores.has(matchId)
}
/**
- * Builds the accumulated context from router options and all matches up to (and optionally including) the given index.
+ * Builds the accumulated context from router options and all matches up to the given index.
* Merges __routeContext and __beforeLoadContext from each match.
*/
const buildMatchContext = (
inner: InnerLoadContext,
index: number,
- includeCurrentMatch: boolean = true,
): Record => {
const context: Record = {
...(inner.router.options.context ?? {}),
}
- const end = includeCurrentMatch ? index : index - 1
- for (let i = 0; i <= end; i++) {
- const innerMatch = inner.matches[i]
- if (!innerMatch) continue
- const m = inner.router.getMatch(innerMatch.id)
- if (!m) continue
- Object.assign(context, m.__routeContext, m.__beforeLoadContext)
+ for (let i = 0; i <= index; i++) {
+ const match = inner.matches[i]!
+ Object.assign(context, match.__routeContext, match.__beforeLoadContext)
}
return context
}
+// Commits the merged context exactly when a match's beforeLoad phase settles.
+// Loader-phase updates intentionally leave context alone; loaders cannot change
+// the inputs used by buildMatchContext.
+const commitMatch = (
+ inner: InnerLoadContext,
+ matchId: string,
+ index: number,
+ patch: Partial,
+): void => {
+ inner.updateMatch(matchId, (prev) => ({
+ ...prev,
+ ...patch,
+ context: buildMatchContext(inner, index),
+ }))
+}
+
+const patchMatch = (
+ inner: InnerLoadContext,
+ matchId: string,
+ patch: Partial,
+): void => {
+ inner.updateMatch(matchId, (prev) => ({
+ ...prev,
+ ...patch,
+ }))
+}
+
+const getNavigate = (inner: InnerLoadContext) => (opts: any) =>
+ inner.router.navigate({
+ ...opts,
+ _fromLocation: inner.location,
+ })
+
+const settleBeforeLoadPromise = (match: AnyRouteMatch): void => {
+ match._nonReactive.beforeLoadPromise?.resolve()
+ match._nonReactive.beforeLoadPromise = undefined
+}
+
+const settleLoaderPromise = (match: AnyRouteMatch): void => {
+ match._nonReactive.loaderPromise?.resolve()
+ match._nonReactive.loaderPromise = undefined
+}
+
+const settleLoadPromises = (match: AnyRouteMatch): void => {
+ settleLoaderPromise(match)
+ match._nonReactive.loadPromise?.resolve()
+ match._nonReactive.loadPromise = undefined
+}
+
+const clearPending = (match: AnyRouteMatch): void => {
+ clearTimeout(match._nonReactive.pendingTimeout)
+ match._nonReactive.pendingTimeout = undefined
+ match._nonReactive.minPendingPromise?.resolve()
+ match._nonReactive.minPendingPromise = undefined
+}
+
+export const clearMatchPromises = (match: AnyRouteMatch): void => {
+ clearPending(match)
+ settleBeforeLoadPromise(match)
+ settleLoadPromises(match)
+}
+
const getNotFoundBoundaryIndex = (
inner: InnerLoadContext,
err: NotFoundError,
@@ -86,17 +139,13 @@ const getNotFoundBoundaryIndex = (
}
const requestedRouteId = err.routeId
- const matchedRootIndex = inner.matches.findIndex(
- (m) => m.routeId === inner.router.routeTree.id,
- )
- const rootIndex = matchedRootIndex >= 0 ? matchedRootIndex : 0
let startIndex = requestedRouteId
? inner.matches.findIndex((match) => match.routeId === requestedRouteId)
: (inner.firstBadMatchIndex ?? inner.matches.length - 1)
if (startIndex < 0) {
- startIndex = rootIndex
+ startIndex = 0
}
for (let i = startIndex; i >= 0; i--) {
@@ -109,98 +158,110 @@ const getNotFoundBoundaryIndex = (
// If no boundary component is found, preserve explicit routeId targeting behavior,
// otherwise default to root for untargeted notFounds.
- return requestedRouteId ? startIndex : rootIndex
+ return requestedRouteId ? startIndex : 0
}
-const handleRedirectAndNotFound = (
+const handleRedirect = (
inner: InnerLoadContext,
match: AnyRouteMatch | undefined,
- err: unknown,
+ redirect: AnyRedirect,
): void => {
- if (!isRedirect(err) && !isNotFound(err)) return
-
- if (isRedirect(err) && err.redirectHandled && !err.options.reloadDocument) {
- throw err
+ if (redirect.redirectHandled && !redirect.options.reloadDocument) {
+ throw redirect
}
// in case of a redirecting match during preload, the match does not exist
if (match) {
- match._nonReactive.beforeLoadPromise?.resolve()
- match._nonReactive.loaderPromise?.resolve()
- match._nonReactive.beforeLoadPromise = undefined
- match._nonReactive.loaderPromise = undefined
+ match._nonReactive.error = redirect
+ clearPending(match)
+ settleBeforeLoadPromise(match)
- match._nonReactive.error = err
+ if (inner.preload || inner.router.stores.cachedMatchStores.has(match.id)) {
+ inner.router.clearCache({ filter: (d) => d.id === match.id })
+ settleLoadPromises(match)
+ } else {
+ // A redirect is not renderable navigation state. Keep the current
+ // renderable status (pending or success) until the redirect target
+ // commits, but clear fetching state.
+ settleLoaderPromise(match)
+ patchMatch(inner, match.id, {
+ isFetching: false as const,
+ })
+ }
+ }
- inner.updateMatch(match.id, (prev) => ({
- ...prev,
- status: isRedirect(err)
- ? 'redirected'
- : isNotFound(err)
- ? 'notFound'
- : prev.status === 'pending'
- ? 'success'
- : prev.status,
- context: buildMatchContext(inner, match.index),
- isFetching: false,
- error: err,
- }))
+ inner.rendered = true
+ redirect.options._fromLocation = inner.location
+ redirect.redirectHandled = true
+ throw inner.router.resolveRedirect(redirect)
+}
+
+const handleNotFound = (
+ inner: InnerLoadContext,
+ match: AnyRouteMatch | undefined,
+ notFound: NotFoundError,
+): void => {
+ if (match) {
+ match._nonReactive.error = notFound
+ clearPending(match)
+ settleBeforeLoadPromise(match)
+ settleLoadPromises(match)
- if (isNotFound(err) && !err.routeId) {
+ if (!notFound.routeId) {
// Stamp the throwing match's routeId so that the finalization step in
- // loadMatches knows where the notFound originated. The actual boundary
- // resolution (walking up to the nearest notFoundComponent) is deferred to
- // the finalization step, where firstBadMatchIndex is stable and
- // headMaxIndex can be capped correctly.
- err.routeId = match.routeId
+ // loadMatches knows where the notFound originated. The actual boundary
+ // resolution is deferred until firstBadMatchIndex is stable.
+ notFound.routeId = match.routeId
}
- match._nonReactive.loadPromise?.resolve()
+ patchMatch(inner, match.id, {
+ status: 'notFound',
+ error: notFound,
+ isFetching: false,
+ _forcePending: undefined,
+ })
+
+ if (inner.preload || inner.router.stores.cachedMatchStores.has(match.id)) {
+ inner.router.clearCache({ filter: (d) => d.id === match.id })
+ }
}
+ throw notFound
+}
+
+const handleRedirectOrNotFound = (
+ inner: InnerLoadContext,
+ match: AnyRouteMatch | undefined,
+ err: unknown,
+): void => {
if (isRedirect(err)) {
- inner.rendered = true
- err.options._fromLocation = inner.location
- err.redirectHandled = true
- err = inner.router.resolveRedirect(err)
+ handleRedirect(inner, match, err)
}
- throw err
+ if (isNotFound(err)) {
+ handleNotFound(inner, match, err)
+ }
}
-const shouldSkipLoader = (
+const getLoaderMatch = (
inner: InnerLoadContext,
matchId: string,
-): boolean => {
+): AnyRouteMatch | false | undefined => {
const match = inner.router.getMatch(matchId)
- if (!match) {
- return true
+ if (!match || inner.preloadMatchIds?.has(matchId)) {
+ return
}
+
// upon hydration, we skip the loader if the match has been dehydrated on the server
if (!(isServer ?? inner.router.isServer) && match._nonReactive.dehydrated) {
- return true
+ return false
}
if ((isServer ?? inner.router.isServer) && match.ssr === false) {
- return true
+ return false
}
- return false
-}
-
-const syncMatchContext = (
- inner: InnerLoadContext,
- matchId: string,
- index: number,
-): void => {
- const nextContext = buildMatchContext(inner, index)
-
- inner.updateMatch(matchId, (prev) => {
- return {
- ...prev,
- context: nextContext,
- }
- })
+ return match
}
const handleSerialError = (
@@ -208,7 +269,8 @@ const handleSerialError = (
index: number,
err: any,
): void => {
- const { id: matchId, routeId } = inner.matches[index]!
+ const match = inner.matches[index]!
+ const { id: matchId, routeId } = match
const route = inner.router.looseRoutesById[routeId]!
// Much like suspense, we use a promise here to know if
@@ -219,31 +281,35 @@ const handleSerialError = (
}
inner.firstBadMatchIndex ??= index
- handleRedirectAndNotFound(inner, inner.router.getMatch(matchId), err)
+ match.__beforeLoadContext = undefined
+
+ handleRedirectOrNotFound(inner, inner.router.getMatch(matchId), err)
try {
route.options.onError?.(err)
} catch (errorHandlerErr) {
err = errorHandlerErr
- handleRedirectAndNotFound(inner, inner.router.getMatch(matchId), err)
+ handleRedirectOrNotFound(inner, inner.router.getMatch(matchId), err)
}
- inner.updateMatch(matchId, (prev) => {
- prev._nonReactive.beforeLoadPromise?.resolve()
- prev._nonReactive.beforeLoadPromise = undefined
- prev._nonReactive.loadPromise?.resolve()
-
- return {
- ...prev,
- error: err,
- status: 'error',
- isFetching: false,
- updatedAt: Date.now(),
- abortController: new AbortController(),
- }
+ // A match that errors during the beforeLoad phase never reaches the loader
+ // phase. Settle its promises after committing the error state.
+ commitMatch(inner, matchId, index, {
+ __beforeLoadContext: undefined,
+ error: err,
+ status: 'error',
+ isFetching: false,
+ _forcePending: undefined,
+ updatedAt: Date.now(),
+ abortController: new AbortController(),
})
- if (!inner.preload && !isRedirect(err) && !isNotFound(err)) {
+ const currentMatch = inner.router.getMatch(matchId)
+ if (currentMatch) {
+ clearMatchPromises(currentMatch)
+ }
+
+ if (!inner.preload) {
inner.serialError ??= err
}
}
@@ -339,11 +405,14 @@ const setupPendingTimeout = (
typeof pendingMs === 'number' &&
pendingMs !== Infinity &&
(route.options.pendingComponent ??
- (inner.router.options as any)?.defaultPendingComponent)
+ (inner.router.options as any).defaultPendingComponent)
)
if (shouldPending) {
const pendingTimeout = setTimeout(() => {
+ // the timeout has served its purpose, clear it so that a later load
+ // pass of this match can arm a new one
+ match._nonReactive.pendingTimeout = undefined
// Update the match and prematurely resolve the loadMatches promise so that
// the pending component can start rendering
triggerOnReady(inner)
@@ -352,39 +421,6 @@ const setupPendingTimeout = (
}
}
-const preBeforeLoadSetup = (
- inner: InnerLoadContext,
- matchId: string,
- route: AnyRoute,
-): void | Promise => {
- const existingMatch = inner.router.getMatch(matchId)!
-
- // If we are in the middle of a load, either of these will be present
- // (not to be confused with `loadPromise`, which is always defined)
- if (
- !existingMatch._nonReactive.beforeLoadPromise &&
- !existingMatch._nonReactive.loaderPromise
- )
- return
-
- setupPendingTimeout(inner, matchId, route, existingMatch)
-
- const then = () => {
- const match = inner.router.getMatch(matchId)!
- if (
- match.preload &&
- (match.status === 'redirected' || match.status === 'notFound')
- ) {
- handleRedirectAndNotFound(inner, match, match.error)
- }
- }
-
- // Wait for the previous beforeLoad to resolve before we continue
- return existingMatch._nonReactive.beforeLoadPromise
- ? existingMatch._nonReactive.beforeLoadPromise.then(then)
- : then()
-}
-
const executeBeforeLoad = (
inner: InnerLoadContext,
matchId: string,
@@ -404,19 +440,23 @@ const executeBeforeLoad = (
if (paramsError) {
handleSerialError(inner, index, paramsError)
+ return
}
if (searchError) {
handleSerialError(inner, index, searchError)
+ return
}
setupPendingTimeout(inner, matchId, route, match)
+ const beforeLoad = route.options.beforeLoad
const abortController = new AbortController()
-
let isPending = false
const pending = () => {
- if (isPending) return
+ if (isPending) {
+ return
+ }
isPending = true
inner.updateMatch(matchId, (prev) => ({
...prev,
@@ -429,31 +469,57 @@ const executeBeforeLoad = (
}))
}
- const resolve = () => {
- match._nonReactive.beforeLoadPromise?.resolve()
- match._nonReactive.beforeLoadPromise = undefined
- inner.updateMatch(matchId, (prev) => ({
- ...prev,
- isFetching: false,
- }))
+ // if there is no `beforeLoad` option, just mark as pending and resolve.
+ // The undefined beforeLoad context is still committed here to clear any
+ // stale context from a previous load generation of the same match.
+ if (!beforeLoad) {
+ inner.matches[index]!.__beforeLoadContext = undefined
+ inner.router.batch(() => {
+ pending()
+ commitMatch(inner, matchId, index, {
+ isFetching: false as const,
+ __beforeLoadContext: undefined,
+ })
+ })
+ settleBeforeLoadPromise(match)
+ return
}
- // if there is no `beforeLoad` option, just mark as pending and resolve
- // Context will be updated later in loadRouteMatch after loader completes
- if (!route.options.beforeLoad) {
+ const beforeLoadPromise = createControlledPromise()
+ const isCurrentBeforeLoad = () =>
+ inner.router.getMatch(matchId)?._nonReactive.beforeLoadPromise ===
+ beforeLoadPromise
+
+ // commits the result of the beforeLoad phase and settles its promise
+ const updateContext = (beforeLoadContext: any) => {
+ if (!isCurrentBeforeLoad()) {
+ return
+ }
+
+ if (isRedirect(beforeLoadContext) || isNotFound(beforeLoadContext)) {
+ pending()
+ handleSerialError(inner, index, beforeLoadContext)
+ return
+ }
+
+ inner.matches[index]!.__beforeLoadContext = beforeLoadContext
+
inner.router.batch(() => {
pending()
- resolve()
+ commitMatch(inner, matchId, index, {
+ isFetching: false as const,
+ __beforeLoadContext: beforeLoadContext,
+ })
})
- return
+ settleBeforeLoadPromise(match)
}
- match._nonReactive.beforeLoadPromise = createControlledPromise()
+ match._nonReactive.beforeLoadPromise = beforeLoadPromise
// Build context from all parent matches, excluding current match's __beforeLoadContext
// (since we're about to execute beforeLoad for this match)
const context = {
- ...buildMatchContext(inner, index, false),
+ ...buildMatchContext(inner, index - 1),
...match.__routeContext,
}
const { search, params, cause } = match
@@ -475,11 +541,7 @@ const executeBeforeLoad = (
preload,
context,
location: inner.location,
- navigate: (opts: any) =>
- inner.router.navigate({
- ...opts,
- _fromLocation: inner.location,
- }),
+ navigate: getNavigate(inner),
buildLocation: inner.router.buildLocation,
cause: preload ? 'preload' : cause,
matches: inner.matches,
@@ -487,43 +549,22 @@ const executeBeforeLoad = (
...inner.router.options.additionalContext,
}
- const updateContext = (beforeLoadContext: any) => {
- if (beforeLoadContext === undefined) {
- inner.router.batch(() => {
- pending()
- resolve()
- })
- return
- }
- if (isRedirect(beforeLoadContext) || isNotFound(beforeLoadContext)) {
- pending()
- handleSerialError(inner, index, beforeLoadContext)
- }
-
- inner.router.batch(() => {
- pending()
- inner.updateMatch(matchId, (prev) => ({
- ...prev,
- __beforeLoadContext: beforeLoadContext,
- }))
- resolve()
- })
- }
-
let beforeLoadContext
try {
- beforeLoadContext = route.options.beforeLoad(beforeLoadFnContext)
+ beforeLoadContext = beforeLoad(beforeLoadFnContext)
if (isPromise(beforeLoadContext)) {
pending()
- return beforeLoadContext
- .catch((err) => {
- handleSerialError(inner, index, err)
- })
- .then(updateContext)
+ return beforeLoadContext.then(updateContext, (err) => {
+ if (!isCurrentBeforeLoad()) {
+ return
+ }
+ handleSerialError(inner, index, err)
+ })
}
} catch (err) {
pending()
handleSerialError(inner, index, err)
+ return
}
updateContext(beforeLoadContext)
@@ -537,71 +578,50 @@ const handleBeforeLoad = (
const { id: matchId, routeId } = inner.matches[index]!
const route = inner.router.looseRoutesById[routeId]!
- const serverSsr = () => {
- // on the server, determine whether SSR the current match or not
- if (isServer ?? inner.router.isServer) {
- const maybePromise = isBeforeLoadSsr(inner, matchId, index, route)
- if (isPromise(maybePromise)) return maybePromise.then(queueExecution)
+ const queueExecution = () => {
+ const existingMatch = getLoaderMatch(inner, matchId)
+ if (!existingMatch) {
+ return
}
- return queueExecution()
- }
- const execute = () => executeBeforeLoad(inner, matchId, index, route)
+ // If we are in the middle of a load, either of these will be present
+ // (not to be confused with `loadPromise`, which is always defined)
+ const pendingBeforeLoad = existingMatch._nonReactive.beforeLoadPromise
+ if (pendingBeforeLoad || existingMatch._nonReactive.loaderPromise) {
+ setupPendingTimeout(inner, matchId, route, existingMatch)
- const queueExecution = () => {
- if (shouldSkipLoader(inner, matchId)) return
- const result = preBeforeLoadSetup(inner, matchId, route)
- return isPromise(result) ? result.then(execute) : execute()
- }
+ if (pendingBeforeLoad) {
+ return pendingBeforeLoad.then(() => {
+ const match = inner.router.getMatch(matchId)!
+ if (match.preload && match.status === 'notFound') {
+ handleRedirectOrNotFound(inner, match, match.error)
+ }
- return serverSsr()
-}
+ if (!getLoaderMatch(inner, matchId)) {
+ return
+ }
+ return executeBeforeLoad(inner, matchId, index, route)
+ })
+ }
-const executeHead = (
- inner: InnerLoadContext,
- matchId: string,
- route: AnyRoute,
-): void | Promise<
- Pick<
- AnyRouteMatch,
- 'meta' | 'links' | 'headScripts' | 'headers' | 'scripts' | 'styles'
- >
-> => {
- const match = inner.router.getMatch(matchId)
- // in case of a redirecting match during preload, the match does not exist
- if (!match) {
- return
- }
- if (!route.options.head && !route.options.scripts && !route.options.headers) {
- return
- }
- const assetContext = {
- ssr: inner.router.options.ssr,
- matches: inner.matches,
- match,
- params: match.params,
- loaderData: match.loaderData,
- }
+ const match = inner.router.getMatch(matchId)!
+ if (match.preload && match.status === 'notFound') {
+ handleRedirectOrNotFound(inner, match, match.error)
+ }
+ }
- return Promise.all([
- route.options.head?.(assetContext),
- route.options.scripts?.(assetContext),
- route.options.headers?.(assetContext),
- ]).then(([headFnContent, scripts, headers]) => {
- const meta = headFnContent?.meta
- const links = headFnContent?.links
- const headScripts = headFnContent?.scripts
- const styles = headFnContent?.styles
-
- return {
- meta,
- links,
- headScripts,
- headers,
- scripts,
- styles,
+ if (!getLoaderMatch(inner, matchId)) {
+ return
}
- })
+ return executeBeforeLoad(inner, matchId, index, route)
+ }
+
+ // on the server, determine whether to SSR the current match or not
+ if (isServer ?? inner.router.isServer) {
+ const maybePromise = isBeforeLoadSsr(inner, matchId, index, route)
+ if (isPromise(maybePromise)) return maybePromise.then(queueExecution)
+ }
+ return queueExecution()
}
const getLoaderContext = (
@@ -622,16 +642,12 @@ const getLoaderContext = (
return {
params,
deps: loaderDeps,
- preload: !!preload,
+ preload,
parentMatchPromise,
abortController,
context,
location: inner.location,
- navigate: (opts) =>
- inner.router.navigate({
- ...opts,
- _fromLocation: inner.location,
- }),
+ navigate: getNavigate(inner),
cause: preload ? 'preload' : cause,
route,
...inner.router.options.additionalContext,
@@ -645,6 +661,8 @@ const runLoader = async (
index: number,
route: AnyRoute,
): Promise => {
+ let getCurrentMatch: (() => AnyRouteMatch | undefined) | undefined
+
try {
// If the Matches component rendered
// the pending component and needs to show it for
@@ -653,6 +671,12 @@ const runLoader = async (
// the loadPromise
const match = inner.router.getMatch(matchId)!
+ const loaderBucket = match._nonReactive
+ const loaderPromise = loaderBucket.loaderPromise
+ const isCurrentLoader = () => loaderBucket.loaderPromise === loaderPromise
+ getCurrentMatch = () => {
+ return isCurrentLoader() ? inner.router.getMatch(matchId) : undefined
+ }
// Actually run the loader and handle the result
try {
@@ -667,7 +691,7 @@ const runLoader = async (
const loaderResult = loader?.(
getLoaderContext(inner, matchPromises, matchId, index, route),
)
- const loaderResultIsPromise = !!loader && isPromise(loaderResult)
+ const loaderResultIsPromise = isPromise(loaderResult)
const willLoadSomething = !!(
loaderResultIsPromise ||
@@ -676,14 +700,13 @@ const runLoader = async (
route.options.head ||
route.options.scripts ||
route.options.headers ||
- match._nonReactive.minPendingPromise
+ loaderBucket.minPendingPromise
)
if (willLoadSomething) {
- inner.updateMatch(matchId, (prev) => ({
- ...prev,
+ patchMatch(inner, matchId, {
isFetching: 'loader',
- }))
+ })
}
if (loader) {
@@ -691,16 +714,18 @@ const runLoader = async (
? await loaderResult
: loaderResult
- handleRedirectAndNotFound(
- inner,
- inner.router.getMatch(matchId),
- loaderData,
- )
+ if (!getCurrentMatch()) {
+ return
+ }
+
+ if (isRedirect(loaderData) || isNotFound(loaderData)) {
+ throw loaderData
+ }
+
if (loaderData !== undefined) {
- inner.updateMatch(matchId, (prev) => ({
- ...prev,
+ patchMatch(inner, matchId, {
loaderData,
- }))
+ })
}
}
@@ -708,76 +733,88 @@ const runLoader = async (
// so we need to wait for it to resolve before
// we can use the options
if (route._lazyPromise) await route._lazyPromise
- const pendingPromise = match._nonReactive.minPendingPromise
+ const pendingPromise = loaderBucket.minPendingPromise
if (pendingPromise) await pendingPromise
// Last but not least, wait for the components
// to be preloaded before we resolve the match
if (route._componentsPromise) await route._componentsPromise
- inner.updateMatch(matchId, (prev) => ({
- ...prev,
+ if (!isCurrentLoader()) {
+ return
+ }
+ patchMatch(inner, matchId, {
error: undefined,
- context: buildMatchContext(inner, index),
status: 'success',
- isFetching: false,
+ isFetching: false as const,
updatedAt: Date.now(),
- }))
+ })
} catch (e) {
let error = e
+ if (isRedirect(e) && e.redirectHandled) {
+ throw e
+ }
+
if ((error as any)?.name === 'AbortError') {
if (match.abortController.signal.aborted) {
- match._nonReactive.loaderPromise?.resolve()
- match._nonReactive.loaderPromise = undefined
return
}
+ if (!getCurrentMatch()) {
+ return
+ }
+ // a softly aborted pending match keeps its previous data and is
+ // committed as success
inner.updateMatch(matchId, (prev) => ({
...prev,
status: prev.status === 'pending' ? 'success' : prev.status,
isFetching: false,
- context: buildMatchContext(inner, index),
}))
return
}
- const pendingPromise = match._nonReactive.minPendingPromise
+ const pendingPromise = loaderBucket.minPendingPromise
if (pendingPromise) await pendingPromise
+ let currentMatch = getCurrentMatch()
+ if (!currentMatch) {
+ return
+ }
if (isNotFound(e)) {
await (route.options.notFoundComponent as any)?.preload?.()
+ currentMatch = getCurrentMatch()
+ if (!currentMatch) {
+ return
+ }
}
- handleRedirectAndNotFound(inner, inner.router.getMatch(matchId), e)
+ handleRedirectOrNotFound(inner, currentMatch, e)
try {
route.options.onError?.(e)
} catch (onErrorError) {
error = onErrorError
- handleRedirectAndNotFound(
- inner,
- inner.router.getMatch(matchId),
- onErrorError,
- )
+ handleRedirectOrNotFound(inner, currentMatch, onErrorError)
}
- if (!isRedirect(error) && !isNotFound(error)) {
- await loadRouteChunk(route, ['errorComponent'])
+ await loadRouteChunk(route, ['errorComponent'])
+ if (!isCurrentLoader()) {
+ return
}
- inner.updateMatch(matchId, (prev) => ({
- ...prev,
+ patchMatch(inner, matchId, {
error,
- context: buildMatchContext(inner, index),
status: 'error',
isFetching: false,
- }))
+ })
}
} catch (err) {
- const match = inner.router.getMatch(matchId)
- // in case of a redirecting match during preload, the match does not exist
- if (match) {
- match._nonReactive.loaderPromise = undefined
+ if ((isRedirect(err) && err.redirectHandled) || isNotFound(err)) {
+ throw err
}
- handleRedirectAndNotFound(inner, match, err)
+ const match = getCurrentMatch?.()
+ if (!match) {
+ return
+ }
+ handleRedirectOrNotFound(inner, match, err)
}
}
@@ -786,13 +823,29 @@ const loadRouteMatch = async (
matchPromises: Array>,
index: number,
): Promise => {
- async function handleLoader(
+ const { id: matchId, routeId } = inner.matches[index]!
+ const route = inner.router.looseRoutesById[routeId]!
+ const routeLoader = route.options.loader
+ const shouldReloadInBackground =
+ ((typeof routeLoader === 'function'
+ ? undefined
+ : routeLoader?.staleReloadMode) ??
+ inner.router.options.defaultStaleReloadMode) !== 'blocking'
+ // becomes true when this pass leaves the loader running detached in the
+ // background, in which case finalization is deferred to that detached run
+ let loaderIsRunningAsync = false
+ let loaderGeneration: AnyRouteMatch['_nonReactive']['loaderPromise']
+ let loaderBucket: AnyRouteMatch['_nonReactive'] | undefined
+
+ /**
+ * Decides how the loader runs for this pass and executes it.
+ */
+ const runLoaderPhase = (
preload: boolean,
prevMatch: AnyRouteMatch,
previousRouteMatchId: string | undefined,
match: AnyRouteMatch,
- route: AnyRoute,
- ) {
+ ): void | Promise => {
const age = Date.now() - prevMatch.updatedAt
const staleAge = preload
@@ -813,7 +866,6 @@ const loadRouteMatch = async (
)
: shouldReloadOption
- // If the route is successful and still fresh, just resolve
const { status, invalid } = match
const staleMatchShouldReload =
age >= staleAge &&
@@ -821,62 +873,62 @@ const loadRouteMatch = async (
match.cause === 'enter' ||
(previousRouteMatchId !== undefined &&
previousRouteMatchId !== match.id))
- loaderShouldRunAsync =
+ const loaderShouldRunAsync =
status === 'success' &&
(invalid || (shouldReload ?? staleMatchShouldReload))
+
if (preload && route.options.preload === false) {
// Do nothing
- } else if (
- loaderShouldRunAsync &&
- !inner.sync &&
- shouldReloadInBackground
- ) {
+ return
+ }
+
+ if (loaderShouldRunAsync && !inner.sync && shouldReloadInBackground) {
+ // stale-while-revalidate: leave the loader running detached
loaderIsRunningAsync = true
+ const backgroundGeneration = match._nonReactive.loaderPromise
+ if (match.invalid !== false) {
+ patchMatch(inner, matchId, { invalid: false })
+ }
;(async () => {
try {
await runLoader(inner, matchPromises, matchId, index, route)
- const match = inner.router.getMatch(matchId)!
- match._nonReactive.loaderPromise?.resolve()
- match._nonReactive.loadPromise?.resolve()
- match._nonReactive.loaderPromise = undefined
- match._nonReactive.loadPromise = undefined
} catch (err) {
if (isRedirect(err)) {
await inner.router.navigate(err.options)
+ return
}
}
+ const latestMatch = inner.router.getMatch(matchId)
+ if (
+ latestMatch &&
+ latestMatch._nonReactive.loaderPromise === backgroundGeneration
+ ) {
+ settleLoadPromises(latestMatch)
+ }
})()
- } else if (status !== 'success' || loaderShouldRunAsync) {
- await runLoader(inner, matchPromises, matchId, index, route)
- } else {
- syncMatchContext(inner, matchId, index)
+ return
}
- }
- const { id: matchId, routeId } = inner.matches[index]!
- let loaderShouldRunAsync = false
- let loaderIsRunningAsync = false
- const route = inner.router.looseRoutesById[routeId]!
- const routeLoader = route.options.loader
- const shouldReloadInBackground =
- ((typeof routeLoader === 'function'
- ? undefined
- : routeLoader?.staleReloadMode) ??
- inner.router.options.defaultStaleReloadMode) !== 'blocking'
+ if (status !== 'success' || loaderShouldRunAsync) {
+ return runLoader(inner, matchPromises, matchId, index, route)
+ }
+ }
- if (shouldSkipLoader(inner, matchId)) {
- const match = inner.router.getMatch(matchId)
- if (!match) {
+ const prevMatch = getLoaderMatch(inner, matchId)
+ if (!prevMatch) {
+ // in case of a redirecting match during preload, the match does not exist
+ if (prevMatch === undefined) {
return inner.matches[index]!
}
- syncMatchContext(inner, matchId, index)
+ // the beforeLoad phase (and with it the context commit) does not run for
+ // skipped matches, so commit the merged route context here
+ commitMatch(inner, matchId, index, { invalid: false })
if (isServer ?? inner.router.isServer) {
return inner.router.getMatch(matchId)!
}
} else {
- const prevMatch = inner.router.getMatch(matchId)! // This is where all of the stale-while-revalidate magic happens
const activeIdAtIndex = inner.router.stores.matchesId.get()[index]
const activeAtIndex =
(activeIdAtIndex &&
@@ -891,6 +943,8 @@ const loadRouteMatch = async (
// there is a loaderPromise, so we are in the middle of a load
if (prevMatch._nonReactive.loaderPromise) {
+ loaderBucket = prevMatch._nonReactive
+ loaderGeneration = loaderBucket.loaderPromise
// do not block if we already have stale data we can show
// but only if the ongoing load is not a preload since error handling is different for preloads
// and we don't want to swallow errors
@@ -900,62 +954,72 @@ const loadRouteMatch = async (
!prevMatch.preload &&
shouldReloadInBackground
) {
- return prevMatch
- }
- await prevMatch._nonReactive.loaderPromise
- const match = inner.router.getMatch(matchId)!
- const error = match._nonReactive.error || match.error
- if (error) {
- handleRedirectAndNotFound(inner, match, error)
+ // this load pass hands the match over to the still in-flight reload;
+ // finalization is skipped, so clear invalid here without touching
+ // promises or loader state.
+ if (prevMatch.invalid !== false) {
+ patchMatch(inner, matchId, {
+ invalid: false,
+ })
+ }
+ return inner.router.getMatch(matchId)!
}
+ await loaderGeneration
+ const match = inner.router.getMatch(matchId)
+ if (match) {
+ const error = match._nonReactive.error || match.error
+ if (error) {
+ handleRedirectOrNotFound(inner, match, error)
+ }
- if (match.status === 'pending') {
- await handleLoader(
- preload,
- prevMatch,
- previousRouteMatchId,
- match,
- route,
- )
+ if (match.status === 'pending') {
+ await runLoaderPhase(preload, prevMatch, previousRouteMatchId, match)
+ }
}
} else {
- const nextPreload =
- preload && !inner.router.stores.matchStores.has(matchId)
const match = inner.router.getMatch(matchId)!
+ // a new load generation starts: any settle error stored by a previous
+ // generation no longer applies to this one
+ match._nonReactive.error = undefined
match._nonReactive.loaderPromise = createControlledPromise()
- if (nextPreload !== match.preload) {
- inner.updateMatch(matchId, (prev) => ({
- ...prev,
- preload: nextPreload,
- }))
+ loaderBucket = match._nonReactive
+ loaderGeneration = loaderBucket.loaderPromise
+ if (preload !== match.preload) {
+ patchMatch(inner, matchId, {
+ preload,
+ })
}
- await handleLoader(preload, prevMatch, previousRouteMatchId, match, route)
+ await runLoaderPhase(preload, prevMatch, previousRouteMatchId, match)
}
}
- const match = inner.router.getMatch(matchId)!
- if (!loaderIsRunningAsync) {
- match._nonReactive.loaderPromise?.resolve()
- match._nonReactive.loadPromise?.resolve()
- match._nonReactive.loadPromise = undefined
+
+ let match = inner.router.getMatch(matchId)
+ if (!match) {
+ return inner.matches[index]!
+ }
+ if (loaderGeneration && loaderBucket?.loaderPromise !== loaderGeneration) {
+ return inner.matches[index]!
}
clearTimeout(match._nonReactive.pendingTimeout)
match._nonReactive.pendingTimeout = undefined
- if (!loaderIsRunningAsync) match._nonReactive.loaderPromise = undefined
match._nonReactive.dehydrated = undefined
const nextIsFetching = loaderIsRunningAsync ? match.isFetching : false
if (nextIsFetching !== match.isFetching || match.invalid !== false) {
- inner.updateMatch(matchId, (prev) => ({
- ...prev,
+ patchMatch(inner, matchId, {
isFetching: nextIsFetching,
invalid: false,
- }))
- return inner.router.getMatch(matchId)!
- } else {
- return match
+ })
+ match = inner.router.getMatch(matchId)!
}
+
+ if (!loaderIsRunningAsync) {
+ settleLoadPromises(match)
+ }
+
+ return match
}
export async function loadMatches(arg: {
@@ -963,6 +1027,7 @@ export async function loadMatches(arg: {
location: ParsedLocation
matches: Array
preload?: boolean
+ preloadMatchIds?: Set
forceStaleReload?: boolean
onReady?: () => Promise
updateMatch: UpdateMatchFn
@@ -975,7 +1040,12 @@ export async function loadMatches(arg: {
// the pending component was already rendered on the server and we want to keep it shown on the client until minPendingMs is reached
if (
!(isServer ?? inner.router.isServer) &&
- hasForcePendingActiveMatch(inner.router)
+ inner.router.stores.matchesId
+ .get()
+ .some(
+ (matchId) =>
+ inner.router.stores.matchStores.get(matchId)?.get()._forcePending,
+ )
) {
triggerOnReady(inner)
}
@@ -1050,19 +1120,14 @@ export async function loadMatches(arg: {
}
}
- const notFoundToThrow =
- firstNotFound ??
- (beforeLoadNotFound && !inner.preload ? beforeLoadNotFound : undefined)
-
- let headMaxIndex =
- inner.firstBadMatchIndex !== undefined
- ? inner.firstBadMatchIndex
- : inner.matches.length - 1
-
- if (!notFoundToThrow && beforeLoadNotFound && inner.preload) {
+ if (beforeLoadNotFound && inner.preload) {
return inner.matches
}
+ const notFoundToThrow = firstNotFound ?? beforeLoadNotFound
+
+ let headMaxIndex = inner.firstBadMatchIndex ?? inner.matches.length - 1
+
if (notFoundToThrow) {
// Determine once which matched route will actually render the
// notFoundComponent, then pass this precomputed index through the remaining
@@ -1087,7 +1152,7 @@ export async function loadMatches(arg: {
const boundaryRoute = inner.router.looseRoutesById[boundaryMatch.routeId]!
const defaultNotFoundComponent = (inner.router.options as any)
- ?.defaultNotFoundComponent
+ .defaultNotFoundComponent
// Ensure a notFoundComponent exists on the boundary route
if (!boundaryRoute.options.notFoundComponent && defaultNotFoundComponent) {
@@ -1096,45 +1161,35 @@ export async function loadMatches(arg: {
notFoundToThrow.routeId = boundaryMatch.routeId
- const boundaryIsRoot = boundaryMatch.routeId === inner.router.routeTree.id
-
- inner.updateMatch(boundaryMatch.id, (prev) => ({
- ...prev,
- ...(boundaryIsRoot
+ patchMatch(
+ inner,
+ boundaryMatch.id,
+ boundaryMatch.routeId === inner.router.routeTree.id
? // For root boundary, use globalNotFound so the root component's
// shell still renders and handles the not-found display,
// instead of replacing the entire root shell via status='notFound'.
- { status: 'success' as const, globalNotFound: true, error: undefined }
+ {
+ status: 'success' as const,
+ globalNotFound: true,
+ error: undefined,
+ isFetching: false,
+ _forcePending: undefined,
+ }
: // For non-root boundaries, set status:'notFound' so MatchInner
// renders the notFoundComponent directly.
- { status: 'notFound' as const, error: notFoundToThrow }),
- isFetching: false,
- }))
+ {
+ status: 'notFound' as const,
+ error: notFoundToThrow,
+ isFetching: false,
+ _forcePending: undefined,
+ },
+ )
headMaxIndex = renderedBoundaryIndex
// Ensure the rendering boundary route chunk (and its lazy components, including
// lazy notFoundComponent) is loaded before we continue to head execution/render.
await loadRouteChunk(boundaryRoute, ['notFoundComponent'])
- } else if (!inner.preload) {
- // Clear stale root global-not-found state on normal navigations that do not
- // throw notFound. This must live here (instead of only in runLoader success)
- // because the root loader may be skipped when data is still fresh.
- const rootMatch = inner.matches[0]!
- // `rootMatch` is the next match for this navigation. If it is not global
- // not-found, then any currently stored root global-not-found is stale.
- if (!rootMatch.globalNotFound) {
- // `currentRootMatch` is the current store state (from the previous
- // navigation/load). Update only when a stale flag is actually present.
- const currentRootMatch = inner.router.getMatch(rootMatch.id)
- if (currentRootMatch?.globalNotFound) {
- inner.updateMatch(rootMatch.id, (prev) => ({
- ...prev,
- globalNotFound: false,
- error: undefined,
- }))
- }
- }
}
// When a serial error occurred (e.g. beforeLoad threw a regular Error),
@@ -1154,17 +1209,40 @@ export async function loadMatches(arg: {
const match = inner.matches[i]!
const { id: matchId, routeId } = match
const route = inner.router.looseRoutesById[routeId]!
+ const routeOptions = route.options
try {
- const headResult = executeHead(inner, matchId, route)
- if (headResult) {
- const head = await headResult
- inner.updateMatch(matchId, (prev) => ({
- ...prev,
- ...head,
- }))
+ const headMatch =
+ inner.router.getMatch(matchId) ?? (inner.preload && match)
+ if (
+ headMatch &&
+ (routeOptions.head || routeOptions.scripts || routeOptions.headers)
+ ) {
+ const assetContext = {
+ ssr: inner.router.options.ssr,
+ matches: inner.matches,
+ match: headMatch,
+ params: headMatch.params,
+ loaderData: headMatch.loaderData,
+ }
+
+ const [headFnContent, scripts, headers] = await Promise.all([
+ routeOptions.head?.(assetContext),
+ routeOptions.scripts?.(assetContext),
+ routeOptions.headers?.(assetContext),
+ ])
+ patchMatch(inner, matchId, {
+ meta: headFnContent?.meta,
+ links: headFnContent?.links,
+ headScripts: headFnContent?.scripts,
+ headers,
+ scripts,
+ styles: headFnContent?.styles,
+ })
}
} catch (err) {
- console.error(`Error executing head for route ${routeId}:`, err)
+ if (process.env.NODE_ENV !== 'production') {
+ console.error(`Error executing head for route ${routeId}:`, err)
+ }
}
}
@@ -1177,7 +1255,7 @@ export async function loadMatches(arg: {
throw notFoundToThrow
}
- if (inner.serialError && !inner.preload && !inner.onReady) {
+ if (inner.serialError && !inner.onReady) {
throw inner.serialError
}
@@ -1194,11 +1272,16 @@ function preloadRouteComponents(
route: AnyRoute,
componentTypesToLoad: Array,
): Promise | undefined {
- const preloads = componentTypesToLoad
- .map((type) => (route.options[type] as any)?.preload?.())
- .filter(Boolean)
+ let preloads: Array> | undefined
+ for (const type of componentTypesToLoad) {
+ const preload = (route.options[type] as any)?.preload?.()
+ if (preload) {
+ preloads ||= []
+ preloads.push(preload)
+ }
+ }
- if (preloads.length === 0) return undefined
+ if (!preloads) return undefined
return Promise.all(preloads) as any as Promise
}
@@ -1221,30 +1304,27 @@ export function loadRouteChunk(
}
}
- const runAfterLazy = () =>
- route._componentsLoaded
- ? undefined
- : componentTypesToLoad === componentTypes
- ? (() => {
- if (route._componentsPromise === undefined) {
- const componentsPromise = preloadRouteComponents(
- route,
- componentTypes,
- )
-
- if (componentsPromise) {
- route._componentsPromise = componentsPromise.then(() => {
- route._componentsLoaded = true
- route._componentsPromise = undefined // gc promise, we won't need it anymore
- })
- } else {
- route._componentsLoaded = true
- }
- }
-
- return route._componentsPromise
- })()
- : preloadRouteComponents(route, componentTypesToLoad)
+ const runAfterLazy = () => {
+ if (route._componentsLoaded) {
+ return
+ }
+ if (componentTypesToLoad !== componentTypes) {
+ return preloadRouteComponents(route, componentTypesToLoad)
+ }
+ if (route._componentsPromise === undefined) {
+ const componentsPromise = preloadRouteComponents(route, componentTypes)
+
+ if (componentsPromise) {
+ route._componentsPromise = componentsPromise.then(() => {
+ route._componentsLoaded = true
+ route._componentsPromise = undefined // gc promise, we won't need it anymore
+ })
+ } else {
+ route._componentsLoaded = true
+ }
+ }
+ return route._componentsPromise
+ }
return route._lazyPromise
? route._lazyPromise.then(runAfterLazy)
diff --git a/packages/router-core/src/redirect.ts b/packages/router-core/src/redirect.ts
index b10fac6d7a..220ebd0ed8 100644
--- a/packages/router-core/src/redirect.ts
+++ b/packages/router-core/src/redirect.ts
@@ -1,3 +1,4 @@
+import { isServer } from '@tanstack/router-core/isServer'
import type { NavigateOptions } from './link'
import type { AnyRouter, RegisteredRouter } from './router'
import type { ParsedLocation } from './location'
@@ -134,14 +135,12 @@ export function redirect<
} catch {}
}
- const headers = new Headers(opts.headers)
- if (opts.href && headers.get('Location') === null) {
- headers.set('Location', opts.href)
- }
-
const response = new Response(null, {
status: opts.statusCode,
- headers,
+ headers:
+ (isServer ?? typeof document === 'undefined') && opts.href
+ ? getRedirectHeaders(opts)
+ : opts.headers,
})
;(response as Redirect).options =
@@ -154,6 +153,14 @@ export function redirect<
return response as Redirect
}
+function getRedirectHeaders(opts: { href?: string; headers?: HeadersInit }) {
+ const headers = new Headers(opts.headers)
+ if (headers.get('Location') === null) {
+ headers.set('Location', opts.href!)
+ }
+ return headers
+}
+
/** Check whether a value is a TanStack Router redirect Response. */
/** Check whether a value is a TanStack Router redirect Response. */
export function isRedirect(obj: any): obj is AnyRedirect {
@@ -175,5 +182,5 @@ export function parseRedirect(obj: any) {
return redirect(obj)
}
- return undefined
+ return
}
diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts
index 2197dab737..87be4cebc0 100644
--- a/packages/router-core/src/router.ts
+++ b/packages/router-core/src/router.ts
@@ -36,7 +36,12 @@ import { setupScrollRestoration } from './scroll-restoration'
import { defaultParseSearch, defaultStringifySearch } from './searchParams'
import { rootRouteId } from './root'
import { isRedirect, redirect } from './redirect'
-import { loadMatches, loadRouteChunk, routeNeedsPreload } from './load-matches'
+import {
+ clearMatchPromises,
+ loadMatches,
+ loadRouteChunk,
+ routeNeedsPreload,
+} from './load-matches'
import {
composeRewrites,
executeRewriteInput,
@@ -106,7 +111,6 @@ import type {
} from './manifest'
import type { AnySchema, AnyValidator } from './validators'
import type { NavigateOptions, ResolveRelativePath, ToOptions } from './link'
-import type { NotFoundError } from './not-found'
import type {
AnySerializationAdapter,
ValidateSerializableInput,
@@ -1178,7 +1182,6 @@ export class RouterCore<
}
}
- let needsLocationUpdate = false
const nextBasepath = this.options.basepath ?? '/'
const nextRewriteOption = this.options.rewrite
const basepathChanged = basepathWasUnset || prevBasepath !== nextBasepath
@@ -1201,26 +1204,19 @@ export class RouterCore<
}
this.rewrite =
- rewrites.length === 0
- ? undefined
- : rewrites.length === 1
- ? rewrites[0]
- : composeRewrites(rewrites)
+ rewrites.length > 1 ? composeRewrites(rewrites) : rewrites[0]
if (this.history) {
this.updateLatestLocation()
}
- needsLocationUpdate = true
- }
-
- if (needsLocationUpdate && this.stores) {
- this.stores.location.set(this.latestLocation)
+ if (this.stores) {
+ this.stores.location.set(this.latestLocation)
+ }
}
if (
typeof window !== 'undefined' &&
- 'CSS' in window &&
typeof window.CSS?.supports === 'function'
) {
this.isViewTransitionTypesSupported = window.CSS.supports(
@@ -1250,6 +1246,7 @@ export class RouterCore<
})
},
)
+
if (this.options.routeMasks) {
processRouteMasks(this.options.routeMasks, result.processedTree)
}
@@ -1431,19 +1428,15 @@ export class RouterCore<
}
private getParentContext(parentMatch?: AnyRouteMatch) {
- const parentMatchId = parentMatch?.id
-
- const parentContext = !parentMatchId
- ? ((this.options.context as any) ?? undefined)
- : (parentMatch.context ?? this.options.context ?? undefined)
-
- return parentContext
+ return parentMatch?.context ?? this.options.context ?? undefined
}
private matchRoutesInternal(
next: ParsedLocation,
opts?: MatchRoutesOpts,
): Array {
+ const throwOnError = opts?.throwOnError
+ const preload = opts?.preload
const matchedRoutesResult = this.getMatchedRoutes(next.pathname)
const { foundRoute, routeParams } = matchedRoutesResult
let { matchedRoutes } = matchedRoutesResult
@@ -1518,7 +1511,7 @@ export class RouterCore<
})
}
- if (opts?.throwOnError) {
+ if (throwOnError) {
throw searchParamError
}
@@ -1544,7 +1537,7 @@ export class RouterCore<
path: route.fullPath,
params: routeParams,
decoder: this.pathParamsDecoder,
- server: this.isServer,
+ server: isServer ?? this.isServer,
})
// Waste not, want not. If we already have a match for this route,
@@ -1581,7 +1574,7 @@ export class RouterCore<
})
}
- if (opts?.throwOnError) {
+ if (throwOnError) {
throw paramsError
}
}
@@ -1594,9 +1587,20 @@ export class RouterCore<
let match: AnyRouteMatch
if (existingMatch) {
+ const cachedMatch = this.stores.cachedMatchStores.has(matchId)
+ const promotePreloadedMatch =
+ !preload && cachedMatch && existingMatch.preload
match = {
...existingMatch,
cause,
+ ...(promotePreloadedMatch
+ ? {
+ _nonReactive: {
+ ...existingMatch._nonReactive,
+ loadPromise: createControlledPromise(),
+ },
+ }
+ : undefined),
params: previousMatch?.params ?? routeParams,
_strictParams: strictParams,
search: previousMatch
@@ -1654,7 +1658,7 @@ export class RouterCore<
}
}
- if (!opts?.preload) {
+ if (!preload) {
// If we have a global not found, mark the right match as global not found
match.globalNotFound = globalNotFoundRouteId === route.id
}
@@ -1691,8 +1695,9 @@ export class RouterCore<
// Update the match's context
if (route.options.context) {
- const contextFnContext: RouteContextOptions =
- {
+ // Get the route context
+ match.__routeContext =
+ route.options.context({
deps: match.loaderDeps,
params: match.params,
context: parentContext ?? {},
@@ -1705,10 +1710,7 @@ export class RouterCore<
preload: !!match.preload,
matches,
routeId: route.id,
- }
- // Get the route context
- match.__routeContext =
- route.options.context(contextFnContext) ?? undefined
+ } as RouteContextOptions) ?? undefined
}
match.context = {
@@ -1898,10 +1900,10 @@ export class RouterCore<
lightweightResult.params,
)
- const isAbsoluteTo = destTo?.charCodeAt(0) === 47
- const sourcePath = isAbsoluteTo
- ? '/'
- : this.resolvePathWithBase(defaultedFromPath, '.')
+ const sourcePath =
+ destTo && destTo.charCodeAt(0) === 47
+ ? '/'
+ : this.resolvePathWithBase(defaultedFromPath, '.')
// Resolve the destination. Absolute destinations don't need the source path.
const nextTo = destTo
@@ -1971,7 +1973,7 @@ export class RouterCore<
path: nextTo,
params: nextParams,
decoder: this.pathParamsDecoder,
- server: this.isServer,
+ server: isServer ?? this.isServer,
}).interpolatedPath,
).path
@@ -2015,12 +2017,12 @@ export class RouterCore<
nextSearch = validatedSearch
}
- nextSearch = applySearchMiddleware({
- search: nextSearch,
- dest,
+ nextSearch = buildMiddlewareChain(
destRoutes,
- _includeValidateSearch: opts._includeValidateSearch,
- })
+ nextSearch,
+ dest,
+ opts._includeValidateSearch ?? false,
+ )
// Replace the equal deep
nextSearch = nullReplaceEqualDeep(fromSearch, nextSearch)
@@ -2227,11 +2229,7 @@ export class RouterCore<
},
}
- if (
- nextHistory.unmaskOnReload ??
- this.options.unmaskOnReload ??
- false
- ) {
+ if (nextHistory.unmaskOnReload ?? this.options.unmaskOnReload) {
nextHistory.state.__tempKey = this.tempLocationKey
}
}
@@ -2341,7 +2339,7 @@ export class RouterCore<
if (href) {
try {
- new URL(`${href}`)
+ new URL(href)
hrefIsUrl = true
} catch {}
}
@@ -2463,148 +2461,135 @@ export class RouterCore<
load: LoadFn = async (opts): Promise => {
const historyAction = opts?.action?.type
- let redirect: AnyRedirect | undefined
- let notFound: NotFoundError | undefined
- let loadPromise: Promise
+ const loadPromise = createControlledPromise()
const previousLocation =
this.stores.resolvedLocation.get() ?? this.stores.location.get()
- // eslint-disable-next-line prefer-const
- loadPromise = new Promise((resolve) => {
- this.startTransition(async () => {
- try {
- this.beforeLoad()
- if (historyAction) {
- locationHistoryActions.set(this.latestLocation, historyAction)
- } else {
- locationHistoryActions.delete(this.latestLocation)
- }
- const next = this.latestLocation
- const prevLocation = this.stores.resolvedLocation.get()
- const locationChangeInfo = getLocationChangeInfo(next, prevLocation)
-
- if (!this.stores.redirect.get()) {
- this.emit({
- type: 'onBeforeNavigate',
- ...locationChangeInfo,
- })
- }
+ this.latestLoadPromise = loadPromise
+ this.startTransition(async () => {
+ try {
+ this.beforeLoad()
+ if (historyAction) {
+ locationHistoryActions.set(this.latestLocation, historyAction)
+ } else {
+ locationHistoryActions.delete(this.latestLocation)
+ }
+ const next = this.latestLocation
+ const prevLocation = this.stores.resolvedLocation.get()
+ const locationChangeInfo = getLocationChangeInfo(next, prevLocation)
+
+ if (!this.stores.redirect.get()) {
this.emit({
- type: 'onBeforeLoad',
+ type: 'onBeforeNavigate',
...locationChangeInfo,
})
+ }
- await loadMatches({
- router: this,
- sync: opts?.sync,
- forceStaleReload: previousLocation.href === next.href,
- matches: this.stores.pendingMatches.get(),
- location: next,
- updateMatch: this.updateMatch,
- // eslint-disable-next-line @typescript-eslint/require-await
- onReady: async () => {
+ this.emit({
+ type: 'onBeforeLoad',
+ ...locationChangeInfo,
+ })
+ await loadMatches({
+ router: this,
+ sync: opts?.sync,
+ forceStaleReload: previousLocation.href === next.href,
+ matches: this.stores.pendingMatches.get(),
+ location: next,
+ updateMatch: this.updateMatch,
+ onReady: () =>
+ new Promise((resolve, reject) => {
// Wrap batch in framework-specific transition wrapper (e.g., Solid's startTransition)
this.startTransition(() => {
- this.startViewTransition(async () => {
- // this.viewTransitionPromise = createControlledPromise()
-
- // Commit the pending matches. If a previous match was
- // removed, place it in the cachedMatches
- //
- // exitingMatches uses match.id (routeId + params + loaderDeps) so
- // navigating /foo?page=1 → /foo?page=2 correctly caches the page=1 entry.
- let exitingMatches: Array | null = null
-
- // Lifecycle-hook identity uses routeId only so that navigating between
- // different params/deps of the same route fires onStay (not onLeave+onEnter).
- let hookExitingMatches: Array | null = null
- let hookEnteringMatches: Array | null = null
- let hookStayingMatches: Array | null = null
-
- this.batch(() => {
+ if (this.latestLoadPromise !== loadPromise) {
+ resolve()
+ return
+ }
+
+ try {
+ this.startViewTransition(async () => {
+ if (this.latestLoadPromise !== loadPromise) {
+ return
+ }
+
+ // Commit the pending matches. If a previous match was
+ // removed, place it in the cachedMatches.
+ //
const pendingMatches = this.stores.pendingMatches.get()
- const mountPending = pendingMatches.length
const currentMatches = this.stores.matches.get()
- exitingMatches = mountPending
- ? currentMatches.filter(
- (match) =>
- !this.stores.pendingMatchStores.has(match.id),
+ this.batch(() => {
+ this.stores.isLoading.set(false)
+ this.stores.loadedAt.set(Date.now())
+ /**
+ * Only successful exiting matches are reusable. Everything
+ * else must be dropped and have its stale loadPromise
+ * released so abandoned renders cannot stay suspended.
+ */
+ if (pendingMatches.length) {
+ this.stores.setMatches(pendingMatches)
+ this.stores.setPending([])
+ const nextCachedMatches = [
+ ...this.stores.cachedMatches.get(),
+ ]
+ for (const match of currentMatches) {
+ // Exiting uses match.id (routeId + params + loaderDeps),
+ // so changing loader deps correctly caches the old entry.
+ if (!pendingMatches.some((d) => d.id === match.id)) {
+ if (
+ match.status === 'success' &&
+ !isRedirect(match._nonReactive.error)
+ ) {
+ nextCachedMatches.push(match)
+ } else {
+ clearMatchPromises(match)
+ }
+ }
+ }
+ this.stores.setCached(nextCachedMatches)
+ this.clearExpiredCache()
+ }
+ })
+
+ for (const match of currentMatches) {
+ if (
+ pendingMatches.length &&
+ !pendingMatches.some((d) => d.routeId === match.routeId)
+ ) {
+ this.looseRoutesById[match.routeId]!.options.onLeave?.(
+ match,
)
- : null
-
- // Lifecycle-hook identity: routeId only (route presence in tree)
- // Build routeId sets from pools to avoid derived stores.
- const pendingRouteIds = new Set()
- for (const s of this.stores.pendingMatchStores.values()) {
- if (s.routeId) pendingRouteIds.add(s.routeId)
- }
- const activeRouteIds = new Set()
- for (const s of this.stores.matchStores.values()) {
- if (s.routeId) activeRouteIds.add(s.routeId)
+ }
}
- hookExitingMatches = mountPending
- ? currentMatches.filter(
- (match) => !pendingRouteIds.has(match.routeId),
- )
- : null
- hookEnteringMatches = mountPending
- ? pendingMatches.filter(
- (match) => !activeRouteIds.has(match.routeId),
- )
- : null
- hookStayingMatches = mountPending
- ? pendingMatches.filter((match) =>
- activeRouteIds.has(match.routeId),
- )
- : currentMatches
-
- this.stores.isLoading.set(false)
- this.stores.loadedAt.set(Date.now())
- /**
- * When committing new matches, cache any exiting matches that are still usable.
- * Routes that resolved with `status: 'error'` or `status: 'notFound'` are
- * deliberately excluded from `cachedMatches` so that subsequent invalidations
- * or reloads re-run their loaders instead of reusing the failed/not-found data.
- */
- if (mountPending) {
- this.stores.setMatches(pendingMatches)
- this.stores.setPending([])
- this.stores.setCached([
- ...this.stores.cachedMatches.get(),
- ...exitingMatches!.filter(
- (d) =>
- d.status !== 'error' &&
- d.status !== 'notFound' &&
- d.status !== 'redirected',
- ),
- ])
- this.clearExpiredCache()
- }
- })
-
- //
- for (const [matches, hook] of [
- [hookExitingMatches, 'onLeave'],
- [hookEnteringMatches, 'onEnter'],
- [hookStayingMatches, 'onStay'],
- ] as const) {
- if (!matches) continue
- for (const match of matches as Array) {
+ for (const match of pendingMatches.length
+ ? pendingMatches
+ : currentMatches) {
+ const hook = currentMatches.some(
+ (d) => d.routeId === match.routeId,
+ )
+ ? 'onStay'
+ : 'onEnter'
this.looseRoutesById[match.routeId]!.options[hook]?.(
match,
)
}
- }
- })
+ }).then(resolve, reject)
+ } catch (err) {
+ reject(err)
+ }
})
- },
- })
- } catch (err) {
- if (isRedirect(err)) {
- redirect = err
+ }),
+ })
+ } catch (err) {
+ if (this.latestLoadPromise === loadPromise) {
+ const redirect = isRedirect(err)
+ ? (isServer ?? this.isServer)
+ ? this.resolveRedirect(err)
+ : err
+ : undefined
+
+ if (redirect) {
if (!(isServer ?? this.isServer)) {
this.navigate({
...redirect.options,
@@ -2612,13 +2597,11 @@ export class RouterCore<
ignoreBlocker: true,
})
}
- } else if (isNotFound(err)) {
- notFound = err
}
const nextStatusCode = redirect
? redirect.status
- : notFound
+ : isNotFound(err)
? 404
: this.stores.matches.get().some((d) => d.status === 'error')
? 500
@@ -2629,90 +2612,70 @@ export class RouterCore<
this.stores.redirect.set(redirect)
})
}
+ }
- if (this.latestLoadPromise === loadPromise) {
- this.commitLocationPromise?.resolve()
- this.latestLoadPromise = undefined
- this.commitLocationPromise = undefined
- }
+ if (this.latestLoadPromise === loadPromise) {
+ this.commitLocationPromise?.resolve()
+ this.latestLoadPromise = undefined
+ this.commitLocationPromise = undefined
+ }
- resolve()
- })
+ loadPromise.resolve()
})
- this.latestLoadPromise = loadPromise
-
await loadPromise
- while (
- (this.latestLoadPromise as any) &&
- loadPromise !== this.latestLoadPromise
- ) {
+ while (this.latestLoadPromise && loadPromise !== this.latestLoadPromise) {
await this.latestLoadPromise
}
- let newStatusCode: number | undefined = undefined
- if (this.hasNotFoundMatch()) {
- newStatusCode = 404
- } else if (this.stores.matches.get().some((d) => d.status === 'error')) {
- newStatusCode = 500
- }
- if (newStatusCode !== undefined) {
+ const newStatusCode = this.hasNotFoundMatch()
+ ? 404
+ : this.stores.matches.get().some((d) => d.status === 'error')
+ ? 500
+ : undefined
+ if (newStatusCode) {
this.stores.statusCode.set(newStatusCode)
}
}
- startViewTransition = (fn: () => Promise) => {
- // Determine if we should start a view transition from the navigation
- // or from the router default
+ startViewTransition = (fn: () => Promise): Promise => {
const shouldViewTransition =
this.shouldViewTransition ?? this.options.defaultViewTransition
- // Reset the view transition flag
this.shouldViewTransition = undefined
- // Attempt to start a view transition (or just apply the changes if we can't)
if (
- shouldViewTransition &&
- typeof document !== 'undefined' &&
- 'startViewTransition' in document &&
- typeof document.startViewTransition === 'function'
+ !shouldViewTransition ||
+ typeof document === 'undefined' ||
+ typeof (document as any).startViewTransition !== 'function'
) {
- // lib.dom.ts doesn't support viewTransition types variant yet.
- // TODO: Fix this when dom types are updated
- let startViewTransitionParams: any
-
- if (
- typeof shouldViewTransition === 'object' &&
- this.isViewTransitionTypesSupported
- ) {
- const next = this.latestLocation
- const prevLocation = this.stores.resolvedLocation.get()
-
- const resolvedViewTransitionTypes =
- typeof shouldViewTransition.types === 'function'
- ? shouldViewTransition.types(
- getLocationChangeInfo(next, prevLocation),
- )
- : shouldViewTransition.types
+ return fn()
+ }
- if (resolvedViewTransitionTypes === false) {
- fn()
- return
- }
+ if (
+ typeof shouldViewTransition === 'object' &&
+ this.isViewTransitionTypesSupported
+ ) {
+ const types =
+ typeof shouldViewTransition.types === 'function'
+ ? shouldViewTransition.types(
+ getLocationChangeInfo(
+ this.latestLocation,
+ this.stores.resolvedLocation.get(),
+ ),
+ )
+ : shouldViewTransition.types
- startViewTransitionParams = {
- update: fn,
- types: resolvedViewTransitionTypes,
- }
- } else {
- startViewTransitionParams = fn
+ if (types === false) {
+ return fn()
}
- document.startViewTransition(startViewTransitionParams)
- } else {
- fn()
+ return (document as any).startViewTransition({ update: fn, types })
+ .updateCallbackDone
}
+
+ return (document as any).startViewTransition(fn).updateCallbackDone
}
updateMatch: UpdateMatchFn = (id, updater) => {
@@ -2731,14 +2694,14 @@ export class RouterCore<
const cachedMatch = this.stores.cachedMatchStores.get(id)
if (cachedMatch) {
- const next = updater(cachedMatch.get())
- if (next.status === 'redirected') {
- const deleted = this.stores.cachedMatchStores.delete(id)
- if (deleted) {
- this.stores.cachedIds.set((prev) =>
- prev.filter((matchId) => matchId !== id),
- )
- }
+ const match = cachedMatch.get()
+ const next = updater(match)
+ if (next.status !== 'success' && next.status !== 'pending') {
+ clearMatchPromises(match)
+ this.stores.cachedMatchStores.delete(id)
+ this.stores.cachedIds.set((prev) =>
+ prev.filter((matchId) => matchId !== id),
+ )
} else {
cachedMatch.set(next)
}
@@ -2803,21 +2766,25 @@ export class RouterCore<
}
resolveRedirect = (redirect: AnyRedirect): AnyRedirect => {
- const locationHeader = redirect.headers.get('Location')
+ const options = redirect.options
- if (!redirect.options.href || redirect.options._builtLocation) {
- const location =
- redirect.options._builtLocation ?? this.buildLocation(redirect.options)
+ if (!options.href || options._builtLocation) {
+ const location = options._builtLocation ?? this.buildLocation(options)
const href = this.getParsedLocationHref(location)
- redirect.options.href = href
- redirect.headers.set('Location', href)
- } else if (locationHeader) {
+ options.href = href
+ if (isServer ?? this.isServer) {
+ redirect.headers.set('Location', href)
+ }
+ } else if (isServer ?? this.isServer) {
+ const locationHeader = redirect.headers.get('Location')
try {
- const url = new URL(locationHeader)
- if (this.origin && url.origin === this.origin) {
- const href = url.pathname + url.search + url.hash
- redirect.options.href = href
- redirect.headers.set('Location', href)
+ if (locationHeader) {
+ const url = new URL(locationHeader)
+ if (url.origin === this.origin) {
+ const href = url.pathname + url.search + url.hash
+ options.href = href
+ redirect.headers.set('Location', href)
+ }
}
} catch {
// ignore invalid URLs
@@ -2825,20 +2792,20 @@ export class RouterCore<
}
if (
- redirect.options.href &&
- !redirect.options._builtLocation &&
+ options.href &&
+ !options._builtLocation &&
// Check for dangerous protocols before processing the redirect
- isDangerousProtocol(redirect.options.href, this.protocolAllowlist)
+ isDangerousProtocol(options.href, this.protocolAllowlist)
) {
throw new Error(
process.env.NODE_ENV !== 'production'
- ? `Redirect blocked: unsafe protocol in href "${redirect.options.href}". Allowed protocols: ${Array.from(this.protocolAllowlist).join(', ')}.`
+ ? `Redirect blocked: unsafe protocol in href "${options.href}". Allowed protocols: ${Array.from(this.protocolAllowlist).join(', ')}.`
: 'Redirect blocked: unsafe protocol',
)
}
- if (!redirect.headers.get('Location')) {
- redirect.headers.set('Location', redirect.options.href)
+ if ((isServer ?? this.isServer) && !redirect.headers.get('Location')) {
+ redirect.headers.set('Location', options.href)
}
return redirect
@@ -2846,15 +2813,18 @@ export class RouterCore<
clearCache: ClearCacheFn = (opts) => {
const filter = opts?.filter
- if (filter !== undefined) {
- this.stores.setCached(
- this.stores.cachedMatches
- .get()
- .filter((m) => !filter(m as MakeRouteMatchUnion)),
- )
- } else {
- this.stores.setCached([])
+ const cachedMatches = this.stores.cachedMatches.get()
+ const nextCachedMatches: Array = []
+
+ for (const match of cachedMatches) {
+ if (!filter || filter(match as MakeRouteMatchUnion)) {
+ clearMatchPromises(match)
+ } else {
+ nextCachedMatches.push(match)
+ }
}
+
+ this.stores.setCached(nextCachedMatches)
}
clearExpiredCache = () => {
@@ -2875,11 +2845,9 @@ export class RouterCore<
: (route.options.gcTime ?? this.options.defaultGcTime)) ??
5 * 60 * 1000
- const isError = d.status === 'error'
- if (isError) return true
+ if (d.status === 'error') return true
- const gcEligible = now - d.updatedAt >= gcTime
- return gcEligible
+ return now - d.updatedAt >= gcTime
}
this.clearCache({ filter })
}
@@ -2905,14 +2873,32 @@ export class RouterCore<
...this.stores.pendingIds.get(),
])
- const loadedMatchIds = new Set([
- ...activeMatchIds,
- ...this.stores.cachedIds.get(),
- ])
+ for (let i = 0; i < matches.length; i++) {
+ const id = matches[i]!.id
+ if (!activeMatchIds.has(id)) {
+ continue
+ }
+
+ await this.getMatch(id)?._nonReactive.beforeLoadPromise
+
+ const settledMatch = this.getMatch(id)
+ if (
+ !settledMatch ||
+ settledMatch._nonReactive.error ||
+ settledMatch.status === 'error' ||
+ settledMatch.status === 'notFound'
+ ) {
+ return matches
+ }
+
+ matches[i] = settledMatch
+ }
// If the matches are already loaded, we need to add them to the cached matches.
const matchesToCache = matches.filter(
- (match) => !loadedMatchIds.has(match.id),
+ (match) =>
+ !activeMatchIds.has(match.id) &&
+ !this.stores.cachedMatchStores.has(match.id),
)
if (matchesToCache.length) {
const cachedMatches = this.stores.cachedMatches.get()
@@ -2925,13 +2911,14 @@ export class RouterCore<
matches,
location: next,
preload: true,
+ preloadMatchIds: activeMatchIds,
updateMatch: (id, updater) => {
- // Don't update the match if it's currently loaded
+ // Don't update matches that were active when the preload started.
if (activeMatchIds.has(id)) {
- matches = matches.map((d) => (d.id === id ? updater(d) : d))
- } else {
- this.updateMatch(id, updater)
+ return
}
+
+ this.updateMatch(id, updater)
},
})
@@ -2939,10 +2926,10 @@ export class RouterCore<
} catch (err) {
if (isRedirect(err)) {
if (err.options.reloadDocument) {
- return undefined
+ return
}
- return await this.preloadRoute({
+ return this.preloadRoute({
...err.options,
_fromLocation: next,
})
@@ -2951,7 +2938,7 @@ export class RouterCore<
// Preload errors are not fatal, but we should still log them
console.error(err)
}
- return undefined
+ return
}
}
@@ -3124,28 +3111,14 @@ export function getMatchedRoutes({
return { matchedRoutes, routeParams, foundRoute }
}
-/**
- * TODO: once caches are persisted across requests on the server,
- * we can cache the built middleware chain using `last(destRoutes)` as the key
- */
-function applySearchMiddleware({
- search,
- dest,
- destRoutes,
- _includeValidateSearch,
-}: {
- search: any
- dest: { search?: unknown }
- destRoutes: ReadonlyArray
- _includeValidateSearch: boolean | undefined
-}) {
- const middleware = buildMiddlewareChain(destRoutes)
- return middleware(search, dest, _includeValidateSearch ?? false)
-}
-
-function buildMiddlewareChain(destRoutes: ReadonlyArray) {
- let dest: BuildNextOptions
- let includeValidateSearch: boolean | undefined
+// TODO: once caches are persisted across requests on the server,
+// we can cache the built middleware chain using `last(destRoutes)` as the key
+function buildMiddlewareChain(
+ destRoutes: ReadonlyArray,
+ search: any,
+ dest: BuildNextOptions,
+ includeValidateSearch: boolean,
+) {
const middlewares = [] as Array>
for (const route of destRoutes) {
@@ -3238,15 +3211,7 @@ function buildMiddlewareChain(destRoutes: ReadonlyArray) {
return (middlewares[index]! as any)({ search: currentSearch, next, meta })
}
- return function middleware(
- search: any,
- nextDest: BuildNextOptions,
- _includeValidateSearch: boolean,
- ) {
- dest = nextDest
- includeValidateSearch = _includeValidateSearch
- return applyNext(0, search)
- }
+ return applyNext(0, search)
}
function findGlobalNotFoundRouteId(
diff --git a/packages/router-core/src/ssr/ssr-client.ts b/packages/router-core/src/ssr/ssr-client.ts
index 2aa5358ac0..0d87a18e48 100644
--- a/packages/router-core/src/ssr/ssr-client.ts
+++ b/packages/router-core/src/ssr/ssr-client.ts
@@ -298,10 +298,10 @@ export async function hydrate(router: AnyRouter): Promise {
router.stores.resolvedLocation.set(router.stores.location.get())
}
// hide the pending component once the load is finished
+ match._nonReactive.displayPendingPromise = undefined
router.updateMatch(match.id, (prev) => ({
...prev,
_displayPending: undefined,
- displayPendingPromise: undefined,
}))
})
})
diff --git a/packages/router-core/src/utils.ts b/packages/router-core/src/utils.ts
index f017215b5c..0e48cbe2b4 100644
--- a/packages/router-core/src/utils.ts
+++ b/packages/router-core/src/utils.ts
@@ -471,15 +471,19 @@ export function createControlledPromise(onResolve?: (value: T) => void) {
controlledPromise.status = 'pending'
controlledPromise.resolve = (value: T) => {
- controlledPromise.status = 'resolved'
- controlledPromise.value = value
- resolveLoadPromise(value)
- onResolve?.(value)
+ if (controlledPromise.status === 'pending') {
+ controlledPromise.status = 'resolved'
+ controlledPromise.value = value
+ resolveLoadPromise(value)
+ onResolve?.(value)
+ }
}
controlledPromise.reject = (e) => {
- controlledPromise.status = 'rejected'
- rejectLoadPromise(e)
+ if (controlledPromise.status === 'pending') {
+ controlledPromise.status = 'rejected'
+ rejectLoadPromise(e)
+ }
}
return controlledPromise
diff --git a/packages/router-core/tests/hydrate.test.ts b/packages/router-core/tests/hydrate.test.ts
index efac230e49..41e23774f2 100644
--- a/packages/router-core/tests/hydrate.test.ts
+++ b/packages/router-core/tests/hydrate.test.ts
@@ -517,4 +517,167 @@ describe('hydrate', () => {
consoleSpy.mockRestore()
})
+
+ it('should clear SPA displayPendingPromise when load finishes', async () => {
+ let resolveLoad!: () => void
+ const loadPromise = new Promise((resolve) => {
+ resolveLoad = resolve
+ })
+ vi.spyOn(mockRouter, 'load').mockReturnValue(loadPromise)
+
+ const matches = mockRouter.matchRoutes(mockRouter.stores.location.get())
+ mockRouter.matchRoutes = vi.fn().mockReturnValue(matches)
+
+ mockWindow.$_TSR = {
+ router: {
+ manifest: testManifest,
+ dehydratedData: {},
+ lastMatchId: '/not-the-current-leaf',
+ matches: [
+ {
+ i: matches[0].id,
+ s: 'success',
+ ssr: true,
+ u: Date.now(),
+ },
+ ],
+ },
+ h: vi.fn(),
+ e: vi.fn(),
+ c: vi.fn(),
+ p: vi.fn(),
+ buffer: [],
+ initialized: false,
+ }
+
+ await hydrate(mockRouter)
+ await Promise.resolve()
+
+ const match = mockRouter.stores.matches.get()[1] as AnyRouteMatch
+ const displayPendingPromise = match._nonReactive.displayPendingPromise
+
+ expect(match._displayPending).toBe(true)
+ expect(displayPendingPromise).toBeDefined()
+
+ resolveLoad()
+ await displayPendingPromise
+ await Promise.resolve()
+
+ const updatedMatch = mockRouter.getMatch(match.id) as AnyRouteMatch
+ expect(updatedMatch._displayPending).toBeUndefined()
+ expect(updatedMatch._nonReactive.displayPendingPromise).toBeUndefined()
+ })
+
+ it('should clear SPA displayPendingPromise when load rejects after match exits', async () => {
+ let rejectLoad!: (err: unknown) => void
+ const loadPromise = new Promise((_resolve, reject) => {
+ rejectLoad = reject
+ })
+ vi.spyOn(mockRouter, 'load').mockReturnValue(loadPromise)
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
+
+ const matches = mockRouter.matchRoutes(mockRouter.stores.location.get())
+ mockRouter.matchRoutes = vi.fn().mockReturnValue(matches)
+
+ mockWindow.$_TSR = {
+ router: {
+ manifest: testManifest,
+ dehydratedData: {},
+ lastMatchId: '/not-the-current-leaf',
+ matches: [
+ {
+ i: matches[0].id,
+ s: 'success',
+ ssr: true,
+ u: Date.now(),
+ },
+ ],
+ },
+ h: vi.fn(),
+ e: vi.fn(),
+ c: vi.fn(),
+ p: vi.fn(),
+ buffer: [],
+ initialized: false,
+ }
+
+ await hydrate(mockRouter)
+ await Promise.resolve()
+
+ const match = mockRouter.stores.matches.get()[1] as AnyRouteMatch
+ const displayPendingPromise = match._nonReactive.displayPendingPromise
+
+ expect(match._displayPending).toBe(true)
+ expect(displayPendingPromise).toBeDefined()
+
+ mockRouter.stores.setMatches([mockRouter.stores.matches.get()[0]!])
+ rejectLoad(new Error('load failed'))
+ await displayPendingPromise
+ await Promise.resolve()
+
+ expect(match._nonReactive.displayPendingPromise).toBeUndefined()
+ expect(consoleSpy).toHaveBeenCalledWith(
+ 'Error during router hydration:',
+ expect.any(Error),
+ )
+
+ consoleSpy.mockRestore()
+ })
+
+ it('should clear current SPA displayPendingPromise when load rejects', async () => {
+ let rejectLoad!: (err: unknown) => void
+ const loadPromise = new Promise((_resolve, reject) => {
+ rejectLoad = reject
+ })
+ vi.spyOn(mockRouter, 'load').mockReturnValue(loadPromise)
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
+
+ const matches = mockRouter.matchRoutes(mockRouter.stores.location.get())
+ mockRouter.matchRoutes = vi.fn().mockReturnValue(matches)
+
+ mockWindow.$_TSR = {
+ router: {
+ manifest: testManifest,
+ dehydratedData: {},
+ lastMatchId: '/not-the-current-leaf',
+ matches: [
+ {
+ i: matches[0].id,
+ s: 'success',
+ ssr: true,
+ u: Date.now(),
+ },
+ ],
+ },
+ h: vi.fn(),
+ e: vi.fn(),
+ c: vi.fn(),
+ p: vi.fn(),
+ buffer: [],
+ initialized: false,
+ }
+
+ await hydrate(mockRouter)
+ await Promise.resolve()
+
+ const match = mockRouter.stores.matches.get()[1] as AnyRouteMatch
+ const displayPendingPromise = match._nonReactive.displayPendingPromise
+
+ expect(match._displayPending).toBe(true)
+ expect(displayPendingPromise).toBeDefined()
+
+ rejectLoad(new Error('load failed'))
+ await displayPendingPromise
+ await Promise.resolve()
+
+ const updatedMatch = mockRouter.getMatch(match.id) as AnyRouteMatch
+ expect(updatedMatch._displayPending).toBeUndefined()
+ expect(updatedMatch._nonReactive.displayPendingPromise).toBeUndefined()
+ expect(consoleSpy).toHaveBeenCalledWith(
+ 'Error during router hydration:',
+ expect.any(Error),
+ )
+
+ consoleSpy.mockRestore()
+ })
})
diff --git a/packages/router-core/tests/load.test.ts b/packages/router-core/tests/load.test.ts
index 1ea6fca30e..666b66c180 100644
--- a/packages/router-core/tests/load.test.ts
+++ b/packages/router-core/tests/load.test.ts
@@ -3,12 +3,13 @@ import { createMemoryHistory } from '@tanstack/history'
import {
BaseRootRoute,
BaseRoute,
+ createControlledPromise,
notFound,
redirect,
rootRouteId,
} from '../src'
import { createTestRouter } from './routerTestUtils'
-import { loadMatches } from '../src/load-matches'
+import { loadMatches, loadRouteChunk } from '../src/load-matches'
import type {
AnyRouter,
LoaderStaleReloadMode,
@@ -20,9 +21,10 @@ type AnyRouteOptions = RootRouteOptions
type BeforeLoad = NonNullable
type Loader = NonNullable
type LoaderEntry = Exclude
+type LoaderFn = Exclude
describe('redirect resolution', () => {
- test('resolveRedirect normalizes same-origin Location to path-only', async () => {
+ test('resolveRedirect normalizes same-origin Location to path-only on the server', async () => {
const rootRoute = new BaseRootRoute({})
const fooRoute = new BaseRoute({
getParentRoute: () => rootRoute,
@@ -37,6 +39,7 @@ describe('redirect resolution', () => {
initialEntries: ['https://example.com/foo'],
}),
origin: 'https://example.com',
+ isServer: true,
})
// This redirect already includes an absolute Location header (external-ish),
@@ -53,6 +56,57 @@ describe('redirect resolution', () => {
expect(resolved.options.href).toBe('/foo')
})
+ test('resolveRedirect does not rewrite Location on the client', async () => {
+ const rootRoute = new BaseRootRoute({})
+ const fooRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/foo',
+ })
+
+ const routeTree = rootRoute.addChildren([fooRoute])
+
+ const router = createTestRouter({
+ routeTree,
+ history: createMemoryHistory({
+ initialEntries: ['https://example.com/foo'],
+ }),
+ origin: 'https://example.com',
+ isServer: false,
+ })
+
+ const unresolved = redirect({
+ to: '/foo',
+ headers: { Location: 'https://example.com/foo' },
+ })
+
+ const resolved = router.resolveRedirect(unresolved)
+
+ expect(resolved.headers.get('Location')).toBe('https://example.com/foo')
+ expect(resolved.options.href).toBe('/foo')
+ })
+
+ test('resolveRedirect does not add Location on the client', async () => {
+ const rootRoute = new BaseRootRoute({})
+ const fooRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/foo',
+ })
+
+ const routeTree = rootRoute.addChildren([fooRoute])
+
+ const router = createTestRouter({
+ routeTree,
+ history: createMemoryHistory({ initialEntries: ['/foo'] }),
+ isServer: false,
+ })
+
+ const unresolved = redirect({ to: '/foo' })
+ const resolved = router.resolveRedirect(unresolved)
+
+ expect(resolved.headers.get('Location')).toBe(null)
+ expect(resolved.options.href).toBe('/foo')
+ })
+
test.each(['/$a', '/$toString', '/$__proto__'])(
'server startup redirects initial path %s to /undefined',
async (initialPath) => {
@@ -209,6 +263,100 @@ describe('beforeLoad skip or exec', () => {
expect(thrown).toEqual({ type: 'domain-error' })
})
+ test.each([false, true])(
+ 'handles %s async returned redirects from beforeLoad',
+ async (asyncReturn) => {
+ const beforeLoad = vi.fn(() => {
+ const result = redirect({ to: '/bar' })
+ return asyncReturn ? Promise.resolve(result) : result
+ })
+ const router = setup({ beforeLoad })
+
+ await router.navigate({ to: '/foo' })
+
+ expect(router.state.location.pathname).toBe('/bar')
+ expect(beforeLoad).toHaveBeenCalledTimes(1)
+ },
+ )
+
+ test.each([false, true])(
+ 'handles %s async returned notFounds from beforeLoad',
+ async (asyncReturn) => {
+ const loader = vi.fn()
+ const rootRoute = new BaseRootRoute({})
+ const fooRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/foo',
+ beforeLoad: () => {
+ const result = notFound()
+ return asyncReturn ? Promise.resolve(result) : result
+ },
+ loader,
+ notFoundComponent: () => null,
+ })
+
+ const routeTree = rootRoute.addChildren([fooRoute])
+ const router = createTestRouter({
+ routeTree,
+ history: createMemoryHistory(),
+ })
+
+ await router.navigate({ to: '/foo' })
+
+ const match = router.state.matches.find((m) => m.routeId === fooRoute.id)
+ expect(match?.status).toBe('notFound')
+ expect(router.state.statusCode).toBe(404)
+ expect(loader).not.toHaveBeenCalled()
+ },
+ )
+
+ test.each([false, true])(
+ 'exec if %s async returned preload redirect from beforeLoad',
+ async (asyncReturn) => {
+ const beforeLoad = vi.fn(({ preload }) => {
+ if (preload) {
+ const result = redirect({ to: '/bar' })
+ return asyncReturn ? Promise.resolve(result) : result
+ }
+ return undefined
+ })
+ const router = setup({ beforeLoad })
+
+ await router.preloadRoute({ to: '/foo' })
+ expect(
+ router.stores.cachedMatches.get().some((d) => d.id === '/foo/foo'),
+ ).toBe(false)
+
+ await router.navigate({ to: '/foo' })
+
+ expect(router.state.location.pathname).toBe('/foo')
+ expect(beforeLoad).toHaveBeenCalledTimes(2)
+ },
+ )
+
+ test.each([false, true])(
+ 'exec if %s async returned preload notFound from beforeLoad',
+ async (asyncReturn) => {
+ const beforeLoad = vi.fn(({ preload }) => {
+ if (preload) {
+ const result = notFound()
+ return asyncReturn ? Promise.resolve(result) : result
+ }
+ return undefined
+ })
+ const router = setup({ beforeLoad })
+
+ await router.preloadRoute({ to: '/foo' })
+ expect(
+ router.stores.cachedMatches.get().some((d) => d.id === '/foo/foo'),
+ ).toBe(false)
+ await router.navigate({ to: '/foo' })
+
+ expect(router.state.location.pathname).toBe('/foo')
+ expect(beforeLoad).toHaveBeenCalledTimes(2)
+ },
+ )
+
test('exec if resolved preload (success)', async () => {
const beforeLoad = vi.fn()
const router = setup({ beforeLoad })
@@ -275,14 +423,14 @@ describe('beforeLoad skip or exec', () => {
})
await router.preloadRoute({ to: '/foo' })
expect(
- router.stores.cachedMatches.get().some((d) => d.status === 'redirected'),
+ router.stores.cachedMatches.get().some((d) => d.id === '/foo/foo'),
).toBe(false)
await sleep(10)
await router.navigate({ to: '/foo' })
expect(router.state.location.pathname).toBe('/foo')
expect(
- router.stores.cachedMatches.get().some((d) => d.status === 'redirected'),
+ router.stores.cachedMatches.get().some((d) => d.id === '/foo/foo'),
).toBe(false)
expect(beforeLoad).toHaveBeenCalledTimes(2)
})
@@ -297,14 +445,11 @@ describe('beforeLoad skip or exec', () => {
})
router.preloadRoute({ to: '/foo' })
await Promise.resolve()
- expect(
- router.stores.cachedMatches.get().some((d) => d.status === 'redirected'),
- ).toBe(false)
await router.navigate({ to: '/foo' })
expect(router.state.location.pathname).toBe('/foo')
expect(
- router.stores.cachedMatches.get().some((d) => d.status === 'redirected'),
+ router.stores.cachedMatches.get().some((d) => d.id === '/foo/foo'),
).toBe(false)
expect(beforeLoad).toHaveBeenCalledTimes(2)
})
@@ -359,6 +504,74 @@ describe('beforeLoad skip or exec', () => {
expect(childHead).not.toHaveBeenCalled()
})
+ test('preload descendant waits for active parent beforeLoad context', async () => {
+ const parentBeforeLoadPromise = createControlledPromise<{ auth: string }>()
+ const parentBeforeLoad = vi.fn(() => parentBeforeLoadPromise)
+ const childLoader = vi.fn(({ context }) => context)
+
+ const rootRoute = new BaseRootRoute({})
+ const parentRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/parent',
+ beforeLoad: parentBeforeLoad,
+ })
+ const childRoute = new BaseRoute({
+ getParentRoute: () => parentRoute,
+ path: '/child',
+ loader: childLoader,
+ })
+
+ const router = createTestRouter({
+ routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]),
+ history: createMemoryHistory(),
+ })
+
+ const navigation = router.navigate({ to: '/parent' })
+ await Promise.resolve()
+ expect(parentBeforeLoad).toHaveBeenCalledTimes(1)
+
+ const preload = router.preloadRoute({ to: '/parent/child' })
+ await Promise.resolve()
+ expect(childLoader).not.toHaveBeenCalled()
+
+ parentBeforeLoadPromise.resolve({ auth: 'ok' })
+ await navigation
+ await preload
+
+ expect(childLoader).toHaveBeenCalledTimes(1)
+ expect(childLoader.mock.calls[0]?.[0].context).toMatchObject({
+ auth: 'ok',
+ })
+ })
+
+ test('executes head when loader throws notFound during preload', async () => {
+ const loader = vi.fn(({ preload }) => {
+ if (preload) {
+ throw notFound()
+ }
+ })
+ const head = vi.fn(() => ({ meta: [{ title: 'Foo' }] }))
+
+ const rootRoute = new BaseRootRoute({})
+ const fooRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/foo',
+ loader,
+ head,
+ notFoundComponent: () => null,
+ })
+
+ const router = createTestRouter({
+ routeTree: rootRoute.addChildren([fooRoute]),
+ history: createMemoryHistory(),
+ })
+
+ await router.preloadRoute({ to: '/foo' })
+
+ expect(loader).toHaveBeenCalledTimes(1)
+ expect(head).toHaveBeenCalledTimes(1)
+ })
+
test('exec if pending preload (error)', async () => {
const beforeLoad = vi.fn(async ({ preload }) => {
await sleep(100)
@@ -441,6 +654,87 @@ describe('loader skip or exec', () => {
expect(loader).toHaveBeenCalledTimes(1)
})
+ test.each([false, true])(
+ 'handles %s async returned redirects from loader',
+ async (asyncReturn) => {
+ const loader = vi.fn(() => {
+ const result = redirect({ to: '/bar' })
+ return asyncReturn ? Promise.resolve(result) : result
+ })
+ const router = setup({ loader })
+
+ await router.navigate({ to: '/foo' })
+
+ expect(router.state.location.pathname).toBe('/bar')
+ expect(loader).toHaveBeenCalledTimes(1)
+ },
+ )
+
+ test.each([false, true])(
+ 'handles %s async returned notFounds from loader',
+ async (asyncReturn) => {
+ const rootRoute = new BaseRootRoute({})
+ const fooRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/foo',
+ loader: () => {
+ const result = notFound()
+ return asyncReturn ? Promise.resolve(result) : result
+ },
+ notFoundComponent: () => null,
+ })
+
+ const routeTree = rootRoute.addChildren([fooRoute])
+ const router = createTestRouter({
+ routeTree,
+ history: createMemoryHistory(),
+ })
+
+ await router.navigate({ to: '/foo' })
+
+ const match = router.state.matches.find((m) => m.routeId === fooRoute.id)
+ expect(match?.status).toBe('notFound')
+ expect(router.state.statusCode).toBe(404)
+ },
+ )
+
+ test('settles descendant match when notFound renders an ancestor boundary', async () => {
+ const rootRoute = new BaseRootRoute({})
+ const parentRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/parent',
+ notFoundComponent: () => null,
+ })
+ const childRoute = new BaseRoute({
+ getParentRoute: () => parentRoute,
+ path: '/child',
+ loader: () => notFound({ routeId: parentRoute.id }),
+ })
+
+ const routeTree = rootRoute.addChildren([
+ parentRoute.addChildren([childRoute]),
+ ])
+ const router = createTestRouter({
+ routeTree,
+ history: createMemoryHistory(),
+ })
+
+ await router.navigate({ to: '/parent/child' })
+
+ const parentMatch = router.state.matches.find(
+ (m) => m.routeId === parentRoute.id,
+ )
+ const childMatch = router.state.matches.find(
+ (m) => m.routeId === childRoute.id,
+ )
+ expect(parentMatch?.status).toBe('notFound')
+ expect(childMatch).toMatchObject({
+ status: 'notFound',
+ isFetching: false,
+ error: expect.objectContaining({ isNotFound: true }),
+ })
+ })
+
test('exec if resolved preload (success)', async () => {
const loader = vi.fn()
const router = setup({ loader })
@@ -520,14 +814,14 @@ describe('loader skip or exec', () => {
})
await router.preloadRoute({ to: '/foo' })
expect(
- router.stores.cachedMatches.get().some((d) => d.status === 'redirected'),
+ router.stores.cachedMatches.get().some((d) => d.id === '/foo/foo'),
).toBe(false)
await sleep(10)
await router.navigate({ to: '/foo' })
expect(router.state.location.pathname).toBe('/foo')
expect(
- router.stores.cachedMatches.get().some((d) => d.status === 'redirected'),
+ router.stores.cachedMatches.get().some((d) => d.id === '/foo/foo'),
).toBe(false)
expect(loader).toHaveBeenCalledTimes(2)
})
@@ -542,19 +836,87 @@ describe('loader skip or exec', () => {
})
router.preloadRoute({ to: '/foo' })
await Promise.resolve()
- expect(
- router.stores.cachedMatches.get().some((d) => d.status === 'redirected'),
- ).toBe(false)
await router.navigate({ to: '/foo' })
expect(router.state.location.pathname).toBe('/bar')
expect(
- router.stores.cachedMatches.get().some((d) => d.status === 'redirected'),
+ router.stores.cachedMatches.get().some((d) => d.id === '/foo/foo'),
).toBe(false)
expect(loader).toHaveBeenCalledTimes(1)
})
- test('updateMatch removes redirected matches from cachedMatches', async () => {
+ test('keeps active pending match renderable when an older preload redirects', async () => {
+ vi.useFakeTimers()
+
+ try {
+ let rejectFoo!: (error: unknown) => void
+ let resolveBar!: () => void
+ const rootRoute = new BaseRootRoute({})
+ const indexRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ })
+ const fooRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/foo',
+ pendingMs: 1,
+ pendingComponent: {},
+ loader: () =>
+ new Promise((_resolve, reject) => {
+ rejectFoo = reject
+ }),
+ })
+ const barRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/bar',
+ loader: () =>
+ new Promise((resolve) => {
+ resolveBar = resolve
+ }),
+ })
+ const router = createTestRouter({
+ routeTree: rootRoute.addChildren([indexRoute, fooRoute, barRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+
+ await router.load()
+
+ const preload = router.preloadRoute({ to: '/foo' })
+ await vi.waitFor(() => expect(rejectFoo).toBeTypeOf('function'))
+
+ const navigation = router.navigate({ to: '/foo' })
+ await vi.advanceTimersByTimeAsync(1)
+ await vi.waitFor(() =>
+ expect(
+ router.state.matches.some(
+ (match) => match.id === '/foo/foo' && match.status === 'pending',
+ ),
+ ).toBe(true),
+ )
+
+ rejectFoo(redirect({ to: '/bar' }))
+ await vi.waitFor(() =>
+ expect(
+ router.stores.pendingMatches
+ .get()
+ .some((match) => match.id === '/bar/bar'),
+ ).toBe(true),
+ )
+
+ expect(
+ router.state.matches.find((match) => match.id === '/foo/foo')?.status,
+ ).toBe('pending')
+
+ resolveBar()
+ await Promise.all([preload, navigation])
+
+ expect(router.state.location.pathname).toBe('/bar')
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ test('updateMatch removes failed matches from cachedMatches', async () => {
const loader = vi.fn()
const router = setup({ loader })
@@ -565,15 +927,13 @@ describe('loader skip or exec', () => {
router.updateMatch('/foo/foo', (prev) => ({
...prev,
- status: 'redirected',
+ status: 'error',
+ error: new Error('boom'),
}))
expect(
router.stores.cachedMatches.get().some((d) => d.id === '/foo/foo'),
).toBe(false)
- expect(
- router.stores.cachedMatches.get().some((d) => d.status === 'redirected'),
- ).toBe(false)
})
test('exec if rejected preload (error)', async () => {
@@ -738,7 +1098,12 @@ describe('stale loader reload triggers', () => {
path: '/bar',
})
- const routeTree = rootRoute.addChildren([fooRoute, barRoute])
+ const bazRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/baz',
+ })
+
+ const routeTree = rootRoute.addChildren([fooRoute, barRoute, bazRoute])
return createTestRouter({
routeTree,
@@ -1152,43 +1517,170 @@ describe('stale loader reload triggers', () => {
resolveStaleReload,
)
})
-})
-test('cancelMatches after pending timeout', async () => {
- const WAIT_TIME = 5
- const onAbortMock = vi.fn()
- const rootRoute = new BaseRootRoute({})
- const fooRoute = new BaseRoute({
- getParentRoute: () => rootRoute,
- path: '/foo',
- pendingMs: WAIT_TIME * 20,
- loader: async ({ abortController }) => {
- await new Promise((resolve) => {
- const timer = setTimeout(() => {
- resolve()
- }, WAIT_TIME * 40)
- abortController.signal.addEventListener('abort', () => {
- onAbortMock()
- clearTimeout(timer)
- resolve()
- })
+ test('settles promises and drops cache entry when a background stale reload redirects', async () => {
+ let rejectStaleReload!: (error: unknown) => void
+ let loaderCalls = 0
+ const loader = vi.fn(() => {
+ loaderCalls += 1
+ if (loaderCalls === 1) {
+ return { value: 'first' }
+ }
+
+ return new Promise((_resolve, reject) => {
+ rejectStaleReload = reject
})
- },
- pendingComponent: {},
- })
- const barRoute = new BaseRoute({
- getParentRoute: () => rootRoute,
- path: '/bar',
- })
- const routeTree = rootRoute.addChildren([fooRoute, barRoute])
- const router = createTestRouter({ routeTree, history: createMemoryHistory() })
+ })
+ const router = setup({ loader, staleTime: 0 })
- await router.load()
- router.navigate({ to: '/foo' })
- await sleep(WAIT_TIME * 30)
+ await router.navigate({ to: '/foo' })
+ expect(loader).toHaveBeenCalledTimes(1)
- // At this point, pending timeout should have triggered
- const fooMatch = router.getMatch('/foo/foo')
+ await vi.advanceTimersByTimeAsync(1)
+ await router.load()
+ await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(2))
+
+ const fooMatch = getMatchById(router, '/foo/foo')!
+ const backgroundLoaderPromise = fooMatch._nonReactive.loaderPromise
+ const backgroundLoadPromise = fooMatch._nonReactive.loadPromise
+
+ expect(backgroundLoaderPromise?.status).toBe('pending')
+ expect(backgroundLoadPromise?.status).toBe('pending')
+
+ rejectStaleReload(redirect({ to: '/bar' }))
+ await backgroundLoaderPromise
+ await vi.waitFor(() => expect(router.state.location.pathname).toBe('/bar'))
+
+ expect(backgroundLoadPromise?.status).toBe('resolved')
+ expect(fooMatch._nonReactive.loaderPromise).toBeUndefined()
+ expect(fooMatch._nonReactive.loadPromise).toBeUndefined()
+ expect(
+ router.stores.cachedMatches
+ .get()
+ .some((match) => match.id === '/foo/foo'),
+ ).toBe(false)
+ })
+
+ test('settles promises and drops cache entry when a cached background stale reload redirects', async () => {
+ let rejectStaleReload!: (error: unknown) => void
+ let loaderCalls = 0
+ const loader = vi.fn(() => {
+ loaderCalls += 1
+ if (loaderCalls === 1) {
+ return { value: 'first' }
+ }
+
+ return new Promise((_resolve, reject) => {
+ rejectStaleReload = reject
+ })
+ })
+ const router = setup({ loader, staleTime: 0 })
+
+ await router.navigate({ to: '/foo' })
+ await vi.advanceTimersByTimeAsync(1)
+ await router.load()
+ await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(2))
+
+ const fooMatch = getMatchById(router, '/foo/foo')!
+ const backgroundLoaderPromise = fooMatch._nonReactive.loaderPromise
+ const backgroundLoadPromise = fooMatch._nonReactive.loadPromise
+
+ expect(backgroundLoaderPromise?.status).toBe('pending')
+ expect(backgroundLoadPromise?.status).toBe('pending')
+
+ await router.navigate({ to: '/bar' })
+ expect(router.state.location.pathname).toBe('/bar')
+ expect(
+ router.stores.cachedMatches
+ .get()
+ .some((match) => match.id === '/foo/foo'),
+ ).toBe(true)
+
+ rejectStaleReload(redirect({ to: '/baz' }))
+ await backgroundLoaderPromise
+ await vi.waitFor(() => expect(router.state.location.pathname).toBe('/baz'))
+ await vi.waitFor(() =>
+ expect(
+ router.stores.cachedMatches
+ .get()
+ .some((match) => match.id === '/foo/foo'),
+ ).toBe(false),
+ )
+
+ expect(backgroundLoadPromise?.status).toBe('resolved')
+ expect(fooMatch._nonReactive.loaderPromise).toBeUndefined()
+ expect(fooMatch._nonReactive.loadPromise).toBeUndefined()
+ })
+
+ test('settles promises and drops cache entry when a cached pending preload errors', async () => {
+ let rejectPreload!: (error: unknown) => void
+ const loader = vi.fn(() => {
+ return new Promise((_resolve, reject) => {
+ rejectPreload = reject
+ })
+ })
+ const router = setup({ loader })
+
+ const preload = router.preloadRoute({ to: '/foo' })
+ await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(1))
+
+ const fooMatch = getMatchById(router, '/foo/foo')!
+ const loaderPromise = fooMatch._nonReactive.loaderPromise
+ const loadPromise = fooMatch._nonReactive.loadPromise
+
+ expect(loaderPromise?.status).toBe('pending')
+ expect(loadPromise?.status).toBe('pending')
+
+ rejectPreload(new Error('preload failed'))
+ await preload
+
+ expect(loaderPromise?.status).toBe('resolved')
+ expect(loadPromise?.status).toBe('resolved')
+ expect(fooMatch._nonReactive.loaderPromise).toBeUndefined()
+ expect(fooMatch._nonReactive.loadPromise).toBeUndefined()
+ expect(
+ router.stores.cachedMatches
+ .get()
+ .some((match) => match.id === '/foo/foo'),
+ ).toBe(false)
+ })
+})
+
+test('cancelMatches after pending timeout', async () => {
+ const WAIT_TIME = 5
+ const onAbortMock = vi.fn()
+ const rootRoute = new BaseRootRoute({})
+ const fooRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/foo',
+ pendingMs: WAIT_TIME * 20,
+ loader: async ({ abortController }) => {
+ await new Promise((resolve) => {
+ const timer = setTimeout(() => {
+ resolve()
+ }, WAIT_TIME * 40)
+ abortController.signal.addEventListener('abort', () => {
+ onAbortMock()
+ clearTimeout(timer)
+ resolve()
+ })
+ })
+ },
+ pendingComponent: {},
+ })
+ const barRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/bar',
+ })
+ const routeTree = rootRoute.addChildren([fooRoute, barRoute])
+ const router = createTestRouter({ routeTree, history: createMemoryHistory() })
+
+ await router.load()
+ router.navigate({ to: '/foo' })
+ await sleep(WAIT_TIME * 30)
+
+ // At this point, pending timeout should have triggered
+ const fooMatch = router.getMatch('/foo/foo')
expect(fooMatch).toBeDefined()
// Navigate away, which should cancel the pending match
@@ -1203,6 +1695,272 @@ test('cancelMatches after pending timeout', async () => {
expect(cancelledFooMatch?._nonReactive.pendingTimeout).toBeUndefined()
})
+test('pending timeout clears itself so a later load pass can re-arm it', async () => {
+ vi.useFakeTimers()
+
+ try {
+ const WAIT_TIME = 5
+ let resolveLoader!: () => void
+ const loader = vi.fn(
+ () =>
+ new Promise((resolve) => {
+ resolveLoader = resolve
+ }),
+ )
+
+ const rootRoute = new BaseRootRoute({})
+ const fooRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/foo',
+ pendingMs: WAIT_TIME,
+ loader,
+ pendingComponent: {},
+ })
+ const routeTree = rootRoute.addChildren([fooRoute])
+ const router = createTestRouter({
+ routeTree,
+ history: createMemoryHistory(),
+ })
+
+ await router.load()
+ const navigation = router.navigate({ to: '/foo' })
+ await vi.advanceTimersByTimeAsync(WAIT_TIME * 2)
+
+ const firstPendingMatch = router.getMatch('/foo/foo')
+ expect(firstPendingMatch?._nonReactive.pendingTimeout).toBeUndefined()
+
+ const joinedLoad = router.load()
+ await Promise.resolve()
+
+ const rearmedMatch = router.getMatch('/foo/foo')
+ expect(rearmedMatch?._nonReactive.pendingTimeout).toBeDefined()
+
+ await vi.advanceTimersByTimeAsync(WAIT_TIME * 2)
+ expect(rearmedMatch?._nonReactive.pendingTimeout).toBeUndefined()
+
+ resolveLoader()
+ await Promise.all([navigation, joinedLoad])
+ expect(loader).toHaveBeenCalledTimes(1)
+ } finally {
+ vi.useRealTimers()
+ }
+})
+
+test('settles load promise for pending-visible match that redirects after exiting', async () => {
+ vi.useFakeTimers()
+
+ try {
+ let rejectLoader!: (error: unknown) => void
+ const rootRoute = new BaseRootRoute({})
+ const indexRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ })
+ const fromRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/from',
+ pendingMs: 1,
+ pendingComponent: {},
+ loader: () =>
+ new Promise((_resolve, reject) => {
+ rejectLoader = reject
+ }),
+ })
+ const toRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/to',
+ })
+ const router = createTestRouter({
+ routeTree: rootRoute.addChildren([indexRoute, fromRoute, toRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+
+ await router.load()
+
+ const navigation = router.navigate({ to: '/from' })
+ await vi.waitFor(() => expect(router.state.status).toBe('pending'))
+ await vi.advanceTimersByTimeAsync(1)
+ await vi.waitFor(() =>
+ expect(
+ router.state.matches.some(
+ (match) => match.id === '/from/from' && match.status === 'pending',
+ ),
+ ).toBe(true),
+ )
+
+ const fromMatch = router.state.matches.find(
+ (match) => match.id === '/from/from',
+ )!
+ const loadPromise = fromMatch._nonReactive.loadPromise
+
+ expect(loadPromise?.status).toBe('pending')
+
+ rejectLoader(redirect({ to: '/to' }))
+ await navigation
+
+ expect(router.state.location.pathname).toBe('/to')
+ expect(loadPromise?.status).toBe('resolved')
+ expect(fromMatch._nonReactive.loadPromise).toBeUndefined()
+ expect(
+ router.stores.cachedMatches
+ .get()
+ .some((match) => match.id === '/from/from'),
+ ).toBe(false)
+ } finally {
+ vi.useRealTimers()
+ }
+})
+
+test('ignores late loader resolution after pending-visible match exits', async () => {
+ vi.useFakeTimers()
+
+ try {
+ let resolveLoader!: () => void
+ const rootRoute = new BaseRootRoute({})
+ const indexRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ })
+ const fromRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/from',
+ pendingMs: 1,
+ pendingComponent: {},
+ loader: () =>
+ new Promise((resolve) => {
+ resolveLoader = resolve
+ }),
+ })
+ const toRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/to',
+ })
+ const router = createTestRouter({
+ routeTree: rootRoute.addChildren([indexRoute, fromRoute, toRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+
+ await router.load()
+
+ const fromNavigation = router.navigate({ to: '/from' })
+ await vi.waitFor(() => expect(router.state.status).toBe('pending'))
+ await vi.advanceTimersByTimeAsync(1)
+ await vi.waitFor(() =>
+ expect(
+ router.state.matches.some(
+ (match) => match.id === '/from/from' && match.status === 'pending',
+ ),
+ ).toBe(true),
+ )
+
+ const fromMatch = router.state.matches.find(
+ (match) => match.id === '/from/from',
+ )!
+ const minPendingPromise = createControlledPromise()
+ fromMatch._nonReactive.minPendingPromise = minPendingPromise
+ const loaderPromise = fromMatch._nonReactive.loaderPromise
+ const loadPromise = fromMatch._nonReactive.loadPromise
+
+ expect(minPendingPromise.status).toBe('pending')
+ expect(loaderPromise?.status).toBe('pending')
+ expect(loadPromise?.status).toBe('pending')
+
+ await router.navigate({ to: '/to' })
+
+ expect(router.state.location.pathname).toBe('/to')
+ expect(minPendingPromise.status).toBe('resolved')
+ expect(fromMatch._nonReactive.minPendingPromise).toBeUndefined()
+ expect(loaderPromise?.status).toBe('resolved')
+ expect(loadPromise?.status).toBe('resolved')
+ expect(fromMatch._nonReactive.loaderPromise).toBeUndefined()
+ expect(fromMatch._nonReactive.loadPromise).toBeUndefined()
+
+ resolveLoader()
+ await fromNavigation
+
+ expect(router.state.location.pathname).toBe('/to')
+ expect(
+ router.stores.cachedMatches
+ .get()
+ .some((match) => match.id === '/from/from'),
+ ).toBe(false)
+ } finally {
+ vi.useRealTimers()
+ }
+})
+
+test('settles promises for pending-visible match whose loader rejects AbortError after exiting', async () => {
+ vi.useFakeTimers()
+
+ try {
+ const rootRoute = new BaseRootRoute({})
+ const indexRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ })
+ const fromRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/from',
+ pendingMs: 1,
+ pendingComponent: {},
+ loader: ({ abortController }) =>
+ new Promise((_resolve, reject) => {
+ abortController.signal.addEventListener('abort', () => {
+ const abortError = new Error('aborted')
+ abortError.name = 'AbortError'
+ reject(abortError)
+ })
+ }),
+ })
+ const toRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/to',
+ })
+ const router = createTestRouter({
+ routeTree: rootRoute.addChildren([indexRoute, fromRoute, toRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+
+ await router.load()
+
+ const fromNavigation = router.navigate({ to: '/from' })
+ await vi.waitFor(() => expect(router.state.status).toBe('pending'))
+ await vi.advanceTimersByTimeAsync(1)
+ await vi.waitFor(() =>
+ expect(
+ router.state.matches.some(
+ (match) => match.id === '/from/from' && match.status === 'pending',
+ ),
+ ).toBe(true),
+ )
+
+ const fromMatch = router.state.matches.find(
+ (match) => match.id === '/from/from',
+ )!
+ const loaderPromise = fromMatch._nonReactive.loaderPromise
+ const loadPromise = fromMatch._nonReactive.loadPromise
+
+ expect(loaderPromise?.status).toBe('pending')
+ expect(loadPromise?.status).toBe('pending')
+
+ await router.navigate({ to: '/to' })
+ await fromNavigation
+
+ expect(router.state.location.pathname).toBe('/to')
+ expect(loaderPromise?.status).toBe('resolved')
+ expect(loadPromise?.status).toBe('resolved')
+ expect(fromMatch._nonReactive.loaderPromise).toBeUndefined()
+ expect(fromMatch._nonReactive.loadPromise).toBeUndefined()
+ expect(
+ router.stores.cachedMatches
+ .get()
+ .some((match) => match.id === '/from/from'),
+ ).toBe(false)
+ } finally {
+ vi.useRealTimers()
+ }
+})
+
describe('head execution', () => {
const setupBeforeLoadNotFoundHierarchy = (throwAtIndex: 1 | 2 | 3) => {
const loaderResolvers: Array<(() => void) | undefined> = []
@@ -1417,6 +2175,46 @@ describe('head execution', () => {
expect(childHead).toHaveBeenCalledTimes(1)
})
+ test('clears force pending when beforeLoad throws non-notFound error', async () => {
+ const beforeLoadError = new Error('beforeLoad-sync-error')
+ const rootRoute = new BaseRootRoute({})
+
+ const childRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/test',
+ beforeLoad: () => {
+ throw beforeLoadError
+ },
+ })
+
+ const routeTree = rootRoute.addChildren([childRoute])
+ const router = createTestRouter({
+ routeTree,
+ history: createMemoryHistory({ initialEntries: ['/test'] }),
+ })
+
+ const location = router.latestLocation
+ const matches = router.matchRoutes(location)
+ const childMatch = matches[1]!
+ childMatch._forcePending = true
+ childMatch._nonReactive.minPendingPromise = createControlledPromise()
+ router.stores.setPending(matches)
+
+ await expect(
+ loadMatches({
+ router,
+ location,
+ matches,
+ updateMatch: router.updateMatch,
+ }),
+ ).rejects.toBe(beforeLoadError)
+
+ const updatedMatch = router.getMatch(childMatch.id)
+ expect(updatedMatch?.status).toBe('error')
+ expect(updatedMatch?._forcePending).toBeUndefined()
+ expect(updatedMatch?._nonReactive.minPendingPromise).toBeUndefined()
+ })
+
test('propagates async beforeLoad non-notFound error running ancestor loaders and heads', async () => {
const beforeLoadError = new Error('beforeLoad-async-error')
const rootLoader = vi.fn(() => ({ level: 0 }))
@@ -1916,6 +2714,49 @@ describe('head execution', () => {
expect(rootMatch?.globalNotFound).toBe(false)
expect(rootMatch?.error).toBeUndefined()
})
+
+ test('keeps root globalNotFound from overlapping stale initial load', async () => {
+ const rootRoute = new BaseRootRoute({
+ notFoundComponent: () => null,
+ })
+ const indexRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ })
+ const postsRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/posts',
+ })
+
+ const router = createTestRouter({
+ routeTree: rootRoute.addChildren([indexRoute, postsRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+
+ const matchResult = router.getMatchedRoutes('/non-existent')
+ expect(matchResult.foundRoute).toBeUndefined()
+ expect(matchResult.matchedRoutes.map((route) => route.id)).toEqual([
+ rootRoute.id,
+ ])
+
+ const initialLoad = router.load()
+ const notFoundNavigation = router.navigate({
+ to: '/non-existent' as never,
+ })
+
+ await Promise.all([initialLoad, notFoundNavigation])
+
+ expect(router.state.location.pathname).toBe('/non-existent')
+ expect(router.state.statusCode).toBe(404)
+ expect(router.state.matches).toHaveLength(1)
+ expect(router.state.matches[0]).toEqual(
+ expect.objectContaining({
+ routeId: rootRoute.id,
+ status: 'success',
+ globalNotFound: true,
+ }),
+ )
+ })
})
})
@@ -2176,6 +3017,308 @@ describe('routeId in context options', () => {
})
})
+describe('beforeLoad context lifecycle', () => {
+ test('clears stale beforeLoad context when a later run returns undefined', async () => {
+ let returnContext = true
+ const seenContexts: Array> = []
+
+ const rootRoute = new BaseRootRoute({
+ beforeLoad: () => {
+ return returnContext ? { token: 'one' } : undefined
+ },
+ })
+ const childRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/child',
+ staleTime: 0,
+ loader: ({ context }) => {
+ seenContexts.push(context)
+ },
+ })
+
+ const router = createTestRouter({
+ routeTree: rootRoute.addChildren([childRoute]),
+ history: createMemoryHistory({ initialEntries: ['/child'] }),
+ })
+
+ await router.load()
+ expect(seenContexts.at(-1)).toMatchObject({ token: 'one' })
+
+ returnContext = false
+ await router.invalidate({ sync: true })
+
+ expect(seenContexts.at(-1)).not.toHaveProperty('token')
+ expect(router.state.matches[0]?.__beforeLoadContext).toBeUndefined()
+ })
+})
+
+describe('loadRouteChunk', () => {
+ test('partial notFoundComponent preload does not mark all components loaded', async () => {
+ const componentPreload = vi.fn()
+ const errorPreload = vi.fn()
+ const notFoundPreload = vi.fn()
+ const rootRoute = new BaseRootRoute({})
+ const route = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/chunked',
+ component: { preload: componentPreload } as any,
+ errorComponent: { preload: errorPreload } as any,
+ notFoundComponent: { preload: notFoundPreload } as any,
+ })
+
+ await loadRouteChunk(route, ['notFoundComponent'])
+
+ expect(notFoundPreload).toHaveBeenCalledTimes(1)
+ expect(componentPreload).not.toHaveBeenCalled()
+ expect(errorPreload).not.toHaveBeenCalled()
+ expect((route as any)._componentsLoaded).not.toBe(true)
+
+ await loadRouteChunk(route)
+
+ expect(componentPreload).toHaveBeenCalledTimes(1)
+ expect(errorPreload).toHaveBeenCalledTimes(1)
+ expect(notFoundPreload).toHaveBeenCalledTimes(2)
+ expect((route as any)._componentsLoaded).toBe(true)
+
+ await loadRouteChunk(route)
+
+ expect(componentPreload).toHaveBeenCalledTimes(1)
+ expect(errorPreload).toHaveBeenCalledTimes(1)
+ expect(notFoundPreload).toHaveBeenCalledTimes(2)
+ })
+
+ test('dedupes concurrent full component preloads', async () => {
+ let resolveComponent!: () => void
+ const componentPreload = vi.fn(
+ () =>
+ new Promise((resolve) => {
+ resolveComponent = resolve
+ }),
+ )
+ const rootRoute = new BaseRootRoute({})
+ const route = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/chunked',
+ component: { preload: componentPreload } as any,
+ })
+
+ const first = loadRouteChunk(route)
+ const second = loadRouteChunk(route)
+
+ expect(componentPreload).toHaveBeenCalledTimes(1)
+
+ resolveComponent()
+ await Promise.all([first, second])
+
+ expect((route as any)._componentsLoaded).toBe(true)
+
+ await loadRouteChunk(route)
+
+ expect(componentPreload).toHaveBeenCalledTimes(1)
+ })
+})
+
+describe('settle errors do not leak across load generations', () => {
+ test('clearCache settles promises for evicted cached matches', async () => {
+ const rootRoute = new BaseRootRoute({})
+ const cachedRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/cached',
+ loader: () => undefined,
+ })
+ const router = createTestRouter({
+ routeTree: rootRoute.addChildren([cachedRoute]),
+ history: createMemoryHistory(),
+ })
+ await router.load()
+ await router.preloadRoute({ to: '/cached' })
+
+ const match = router.stores.cachedMatches.get()[0]!
+ const beforeLoadPromise = createControlledPromise()
+ const loaderPromise = createControlledPromise()
+ const loadPromise = createControlledPromise()
+ const minPendingPromise = createControlledPromise()
+
+ match._nonReactive.beforeLoadPromise = beforeLoadPromise
+ match._nonReactive.loaderPromise = loaderPromise
+ match._nonReactive.loadPromise = loadPromise
+ match._nonReactive.minPendingPromise = minPendingPromise
+
+ router.clearCache()
+
+ expect(router.stores.cachedMatches.get()).toEqual([])
+ expect(beforeLoadPromise.status).toBe('resolved')
+ expect(loaderPromise.status).toBe('resolved')
+ expect(loadPromise.status).toBe('resolved')
+ expect(minPendingPromise.status).toBe('resolved')
+ })
+
+ test('a stale redirect resolving after a newer navigation does not navigate or update redirect state', async () => {
+ const slowBeforeLoadStarted = vi.fn()
+ const slowBeforeLoadGate = createControlledPromise()
+
+ const rootRoute = new BaseRootRoute({})
+ const indexRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ })
+ const slowRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/slow',
+ beforeLoad: async () => {
+ slowBeforeLoadStarted()
+ await slowBeforeLoadGate
+ throw redirect({ to: '/redirected' })
+ },
+ })
+ const safeRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/safe',
+ })
+ const redirectedRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/redirected',
+ })
+
+ const router = createTestRouter({
+ routeTree: rootRoute.addChildren([
+ indexRoute,
+ slowRoute,
+ safeRoute,
+ redirectedRoute,
+ ]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+ await router.load()
+
+ const staleNavigation = router.navigate({ to: '/slow' })
+ await vi.waitFor(() => expect(slowBeforeLoadStarted).toHaveBeenCalled())
+
+ await router.navigate({ to: '/safe' })
+ expect(router.state.location.pathname).toBe('/safe')
+
+ slowBeforeLoadGate.resolve()
+ await staleNavigation
+
+ expect(router.state.location.pathname).toBe('/safe')
+ expect(router.state.redirect).toBeUndefined()
+ })
+
+ test('a notFound stored by a previous preload is not replayed onto a load pass that joins a newer in-flight load', async () => {
+ let loaderCalls = 0
+ let releaseLoader!: () => void
+ const loaderGate = new Promise((resolve) => {
+ releaseLoader = resolve
+ })
+
+ const loader = vi.fn(async () => {
+ loaderCalls++
+ if (loaderCalls === 1) {
+ // the preload generation settles with notFound
+ throw notFound()
+ }
+ // the navigation generation succeeds, but slowly
+ await loaderGate
+ return { value: 'fresh' }
+ })
+
+ const rootRoute = new BaseRootRoute({})
+ const staleRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/stale',
+ loader,
+ staleTime: 0,
+ gcTime: 60_000,
+ })
+ const routeTree = rootRoute.addChildren([staleRoute])
+ const router = createTestRouter({
+ routeTree,
+ history: createMemoryHistory(),
+ })
+ await router.load()
+
+ // generation 1: the preload stores the notFound settle error on the
+ // cached match
+ await router.preloadRoute({ to: '/stale' })
+ expect(loader).toHaveBeenCalledTimes(1)
+
+ // generation 2: navigating reuses the cached match and starts the slow
+ // loader
+ const navigatePromise = router.navigate({ to: '/stale' })
+ await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(2))
+
+ // generation 3: a load pass joins the in-flight generation 2 loader.
+ // It must observe generation 2's result, not the stale notFound settle
+ // error stored by generation 1.
+ const joinPromise = router.load()
+ await sleep(5)
+
+ releaseLoader()
+ await Promise.all([navigatePromise, joinPromise])
+
+ const match = router.state.matches.find((m) => m.routeId === staleRoute.id)!
+ expect(match.status).toBe('success')
+ expect(match.loaderData).toEqual({ value: 'fresh' })
+ })
+
+ test('a redirect stored by a previous preload is not replayed onto a load pass that joins a newer in-flight load', async () => {
+ let loaderCalls = 0
+ let releaseLoader!: () => void
+ const loaderGate = new Promise((resolve) => {
+ releaseLoader = resolve
+ })
+
+ const loader = vi.fn(async () => {
+ loaderCalls++
+ if (loaderCalls === 1) {
+ throw redirect({ to: '/other' })
+ }
+ await loaderGate
+ return { value: 'fresh' }
+ })
+
+ const rootRoute = new BaseRootRoute({})
+ const staleRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/stale',
+ loader,
+ staleTime: 0,
+ gcTime: 60_000,
+ })
+ const otherRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/other',
+ })
+ const routeTree = rootRoute.addChildren([staleRoute, otherRoute])
+ const router = createTestRouter({
+ routeTree,
+ history: createMemoryHistory(),
+ })
+ await router.load()
+
+ await router.preloadRoute({ to: '/stale' })
+ expect(loader).toHaveBeenCalledTimes(1)
+ expect(
+ router.stores.cachedMatches
+ .get()
+ .some((match) => match.id === '/stale/stale'),
+ ).toBe(false)
+
+ const navigatePromise = router.navigate({ to: '/stale' })
+ await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(2))
+ const joinPromise = router.load()
+ await sleep(5)
+
+ releaseLoader()
+ await Promise.all([navigatePromise, joinPromise])
+
+ expect(router.state.location.pathname).toBe('/stale')
+ const match = router.state.matches.find((m) => m.routeId === staleRoute.id)!
+ expect(match.status).toBe('success')
+ expect(match.loaderData).toEqual({ value: 'fresh' })
+ })
+})
+
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
diff --git a/packages/router-devtools-core/src/useStyles.tsx b/packages/router-devtools-core/src/useStyles.tsx
index 5524ed0ae8..32908c524d 100644
--- a/packages/router-devtools-core/src/useStyles.tsx
+++ b/packages/router-devtools-core/src/useStyles.tsx
@@ -428,7 +428,7 @@ const stylesFactory = (shadowDOMTarget?: ShadowRoot) => {
line-height: ${tokens.font.lineHeight.sm};
`,
matchStatus: (
- status: 'pending' | 'success' | 'error' | 'notFound' | 'redirected',
+ status: 'pending' | 'success' | 'error' | 'notFound',
isFetching: false | 'beforeLoad' | 'loader',
) => {
const colorMap = {
@@ -436,7 +436,6 @@ const stylesFactory = (shadowDOMTarget?: ShadowRoot) => {
success: 'green',
error: 'red',
notFound: 'purple',
- redirected: 'gray',
} as const
const color =
diff --git a/packages/router-devtools-core/src/utils.tsx b/packages/router-devtools-core/src/utils.tsx
index c14bfd80be..962cdaf0f3 100644
--- a/packages/router-devtools-core/src/utils.tsx
+++ b/packages/router-devtools-core/src/utils.tsx
@@ -25,7 +25,6 @@ export function getStatusColor(match: AnyRouteMatch) {
success: 'green',
error: 'red',
notFound: 'purple',
- redirected: 'gray',
} as const
return match.isFetching && match.status === 'success'
diff --git a/packages/solid-router/src/Match.tsx b/packages/solid-router/src/Match.tsx
index 205e778689..453179047a 100644
--- a/packages/solid-router/src/Match.tsx
+++ b/packages/solid-router/src/Match.tsx
@@ -4,7 +4,6 @@ import {
getLocationChangeInfo,
invariant,
isNotFound,
- isRedirect,
rootRouteId,
} from '@tanstack/router-core'
import { isServer } from '@tanstack/router-core/isServer'
@@ -279,22 +278,6 @@ export const MatchInner = (): any => {
return
}
- const getLoadPromise = (
- matchId: string,
- fallbackMatch:
- | {
- _nonReactive: {
- loadPromise?: Promise
- }
- }
- | undefined,
- ) => {
- return (
- router.getMatch(matchId)?._nonReactive.loadPromise ??
- fallbackMatch?._nonReactive.loadPromise
- )
- }
-
const keyedOut = () => (
{(_key) => out()}
@@ -395,29 +378,6 @@ export const MatchInner = (): any => {
)
}}
-
- {(_) => {
- const matchId = currentMatch().id
- const routerMatch = router.getMatch(matchId)
-
- if (!isRedirect(currentMatch().error)) {
- if (process.env.NODE_ENV !== 'production') {
- throw new Error(
- 'Invariant failed: Expected a redirect error',
- )
- }
-
- invariant()
- }
-
- const [loaderResult] = Solid.createResource(async () => {
- await Promise.resolve()
- return getLoadPromise(matchId, routerMatch)
- })
-
- return <>{loaderResult()}>
- }}
-
{(_) => {
if (isServer ?? router.isServer) {
@@ -469,14 +429,7 @@ export const Outlet = () => {
: undefined
})
- const childMatchStatus = Solid.createMemo(() => {
- const id = childMatchId()
- if (!id) return undefined
- return router.stores.matchStores.get(id)?.get().status
- })
-
- const shouldShowNotFound = () =>
- childMatchStatus() !== 'redirected' && parentGlobalNotFound()
+ const shouldShowNotFound = () => parentGlobalNotFound()
const childRouteKey = Solid.createMemo(() => {
if (shouldShowNotFound()) return undefined
diff --git a/packages/solid-router/tests/redirect.test.tsx b/packages/solid-router/tests/redirect.test.tsx
index 81bd1f6bc6..b7f3ab7d76 100644
--- a/packages/solid-router/tests/redirect.test.tsx
+++ b/packages/solid-router/tests/redirect.test.tsx
@@ -105,52 +105,6 @@ describe('redirect', () => {
expect(nestedFooLoaderMock).toHaveBeenCalled()
})
- test('when root `beforeLoad` redirects while root pendingComponent is showing and the target route is lazy', async () => {
- let hasRedirected = false
- const consoleError = vi
- .spyOn(console, 'error')
- .mockImplementation(() => {})
-
- const rootRoute = createRootRoute({
- component: () => ,
- pendingMs: 0,
- pendingComponent: () => loading
,
- beforeLoad: async () => {
- await sleep(WAIT_TIME)
- if (!hasRedirected) {
- hasRedirected = true
- throw redirect({ to: '/posts' })
- }
- },
- })
-
- const indexRoute = createRoute({
- getParentRoute: () => rootRoute,
- path: '/',
- component: () => Index page
,
- })
-
- const postsRoute = createRoute({
- getParentRoute: () => rootRoute,
- path: '/posts',
- }).lazy(() => import('./lazy/normal').then((d) => d.Route('/posts')))
-
- const router = createRouter({
- routeTree: rootRoute.addChildren([indexRoute, postsRoute]),
- history,
- })
-
- render(() => )
-
- // The lazy target route adds the async boundary that exposes the stale
- // redirected-match render path this regression is guarding.
- expect(await screen.findByTestId('lazy-route-page')).toBeInTheDocument()
- expect(screen.queryByTestId('pending')).not.toBeInTheDocument()
- expect(router.state.location.href).toBe('/posts')
- expect(router.state.status).toBe('idle')
- expect(consoleError).not.toHaveBeenCalled()
- })
-
test('when `redirect` is thrown in `loader`', async () => {
const nestedLoaderMock = vi.fn()
const nestedFooLoaderMock = vi.fn()
diff --git a/packages/solid-router/tests/routeContext.test.tsx b/packages/solid-router/tests/routeContext.test.tsx
index b7e0a902a9..aa5ec20ad7 100644
--- a/packages/solid-router/tests/routeContext.test.tsx
+++ b/packages/solid-router/tests/routeContext.test.tsx
@@ -2451,6 +2451,82 @@ describe('useRouteContext in the component', () => {
expect(allContextsValid).toBe(true)
})
+ test('context value from beforeLoad is propagated when a sub-route is re-entered while its loader reloads in the background', async () => {
+ let sawUndefinedContext = false
+ const loaderTime = WAIT_TIME * 3
+
+ const rootRoute = createRootRoute({
+ component: () => ,
+ })
+ const homeRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => Home page
,
+ })
+ const reloadInFlightRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/reload-in-flight',
+ beforeLoad: () => ({ number: 42 }),
+ component: () => ,
+ })
+ const reloadInFlightIndexRoute = createRoute({
+ getParentRoute: () => reloadInFlightRoute,
+ path: '/',
+ staleTime: 0,
+ loader: async () => {
+ await sleep(loaderTime)
+ },
+ component: () => {
+ const context = reloadInFlightIndexRoute.useRouteContext()
+ const number = () => context().number
+ sawUndefinedContext ||= number() === undefined
+
+ return (
+
+ number = {String(number())}, saw undefined ={' '}
+ {String(sawUndefinedContext)}
+
+ )
+ },
+ })
+
+ const routeTree = rootRoute.addChildren([
+ homeRoute,
+ reloadInFlightRoute.addChildren([reloadInFlightIndexRoute]),
+ ])
+ const router = createRouter({ routeTree, history })
+
+ render(() => )
+
+ await router.navigate({ to: '/reload-in-flight' })
+
+ expect(
+ await screen.findByText('number = 42, saw undefined = false'),
+ ).toBeInTheDocument()
+
+ await router.navigate({ to: '/' })
+ expect(await screen.findByText('Home page')).toBeInTheDocument()
+ router.history.back()
+
+ expect(
+ await screen.findByText('number = 42, saw undefined = false'),
+ ).toBeInTheDocument()
+
+ await router.navigate({ to: '/' })
+ expect(await screen.findByText('Home page')).toBeInTheDocument()
+ router.history.back()
+
+ expect(
+ await screen.findByText('number = 42, saw undefined = false'),
+ ).toBeInTheDocument()
+
+ await sleep(loaderTime + 50)
+
+ expect(
+ await screen.findByText('number = 42, saw undefined = false'),
+ ).toBeInTheDocument()
+ })
+
test('route context (sleep in loader), present root route', async () => {
const rootRoute = createRootRoute({
loader: async () => {
diff --git a/packages/solid-router/tests/store-updates-during-navigation.test.tsx b/packages/solid-router/tests/store-updates-during-navigation.test.tsx
index 69b570122d..baf66ccafc 100644
--- a/packages/solid-router/tests/store-updates-during-navigation.test.tsx
+++ b/packages/solid-router/tests/store-updates-during-navigation.test.tsx
@@ -136,7 +136,7 @@ describe("Store doesn't update *too many* times during navigation", () => {
// This number should be as small as possible to minimize the amount of work
// that needs to be done during a navigation.
// Any change that increases this number should be investigated.
- expect(updates).toBe(9)
+ expect(updates).toBe(7)
})
test('redirection in preload', async () => {
@@ -156,6 +156,11 @@ describe("Store doesn't update *too many* times during navigation", () => {
// Any change that increases this number should be investigated.
// Note: Solid has different update counts than React due to different reactivity
expect(updates).toBe(2)
+ expect(
+ router.stores.cachedMatches
+ .get()
+ .some((match) => match.pathname === '/posts'),
+ ).toBe(false)
})
test('sync beforeLoad', async () => {
@@ -198,7 +203,7 @@ describe("Store doesn't update *too many* times during navigation", () => {
// This number should be as small as possible to minimize the amount of work
// that needs to be done during a navigation.
// Any change that increases this number should be investigated.
- expect(updates).toBe(4)
+ expect(updates).toBe(3)
})
test('hover preload, then navigate, w/ async loaders', async () => {
diff --git a/packages/vue-router/src/Match.tsx b/packages/vue-router/src/Match.tsx
index 44e9aca0cd..82c972595c 100644
--- a/packages/vue-router/src/Match.tsx
+++ b/packages/vue-router/src/Match.tsx
@@ -4,7 +4,6 @@ import {
getLocationChangeInfo,
invariant,
isNotFound,
- isRedirect,
rootRouteId,
} from '@tanstack/router-core'
import { isServer } from '@tanstack/router-core/isServer'
@@ -349,22 +348,6 @@ export const MatchInner = Vue.defineComponent({
const match = Vue.computed(() => combinedState.value?.match)
const remountKey = Vue.computed(() => combinedState.value?.remountKey)
- const getMatchPromise = (
- match: {
- id: string
- _nonReactive: {
- displayPendingPromise?: Promise
- minPendingPromise?: Promise
- loadPromise?: Promise
- }
- },
- key: 'displayPendingPromise' | 'minPendingPromise' | 'loadPromise',
- ) => {
- return (
- router.getMatch(match.id)?._nonReactive[key] ?? match._nonReactive[key]
- )
- }
-
return (): VNode | null => {
// If match doesn't exist, return null (component is being unmounted or not ready)
if (!combinedState.value || !match.value || !route.value) return null
@@ -397,17 +380,6 @@ export const MatchInner = Vue.defineComponent({
return renderRouteNotFound(router, route.value, match.value.error)
}
- if (match.value.status === 'redirected') {
- if (!isRedirect(match.value.error)) {
- if (process.env.NODE_ENV !== 'production') {
- throw new Error('Invariant failed: Expected a redirect error')
- }
-
- invariant()
- }
- throw getMatchPromise(match.value, 'loadPromise')
- }
-
if (match.value.status === 'error') {
// Check if this route or any parent has an error component
const RouteErrorComponent =
diff --git a/packages/vue-router/src/Transitioner.tsx b/packages/vue-router/src/Transitioner.tsx
index b0133d965c..37e04bc374 100644
--- a/packages/vue-router/src/Transitioner.tsx
+++ b/packages/vue-router/src/Transitioner.tsx
@@ -82,9 +82,9 @@ export function useTransitionerSetup() {
// Vue updates DOM asynchronously (next tick). The View Transitions API expects the
// update callback promise to resolve only after the DOM has been updated.
// Wrap the router-core implementation to await a Vue flush before resolving.
- const originalStartViewTransition:
- | undefined
- | ((fn: () => Promise) => void) =
+ const originalStartViewTransition: (
+ fn: () => Promise,
+ ) => Promise =
(router as any).__tsrOriginalStartViewTransition ??
router.startViewTransition
diff --git a/packages/vue-router/tests/redirect.test.tsx b/packages/vue-router/tests/redirect.test.tsx
index b7ba2e1af3..a4faa5cd78 100644
--- a/packages/vue-router/tests/redirect.test.tsx
+++ b/packages/vue-router/tests/redirect.test.tsx
@@ -95,51 +95,6 @@ describe('redirect', () => {
expect(nestedFooLoaderMock).toHaveBeenCalled()
})
- test('when root `beforeLoad` redirects while root pendingComponent is showing and the target route is lazy', async () => {
- let hasRedirected = false
- const consoleError = vi
- .spyOn(console, 'error')
- .mockImplementation(() => {})
-
- const rootRoute = createRootRoute({
- component: () => ,
- pendingMs: 0,
- pendingComponent: () => loading
,
- beforeLoad: async () => {
- await sleep(WAIT_TIME)
- if (!hasRedirected) {
- hasRedirected = true
- throw redirect({ to: '/posts' })
- }
- },
- })
-
- const indexRoute = createRoute({
- getParentRoute: () => rootRoute,
- path: '/',
- component: () => Index page
,
- })
-
- const postsRoute = createRoute({
- getParentRoute: () => rootRoute,
- path: '/posts',
- }).lazy(() => import('./lazy/normal').then((d) => d.Route('/posts')))
-
- const router = createRouter({
- routeTree: rootRoute.addChildren([indexRoute, postsRoute]),
- history: createMemoryHistory({ initialEntries: ['/'] }),
- })
-
- render()
-
- // The lazy target route adds the async boundary that exposes the stale
- // redirected-match render path this regression is guarding.
- expect(await screen.findByTestId('lazy-route-page')).toBeInTheDocument()
- expect(screen.queryByTestId('pending')).not.toBeInTheDocument()
- expect(router.state.location.href).toBe('/posts')
- expect(consoleError).not.toHaveBeenCalled()
- })
-
test('when `redirect` is thrown in `loader`', async () => {
const nestedLoaderMock = vi.fn()
const nestedFooLoaderMock = vi.fn()
diff --git a/packages/vue-router/tests/store-updates-during-navigation.test.tsx b/packages/vue-router/tests/store-updates-during-navigation.test.tsx
index 1c40bf7be7..3a51d638f4 100644
--- a/packages/vue-router/tests/store-updates-during-navigation.test.tsx
+++ b/packages/vue-router/tests/store-updates-during-navigation.test.tsx
@@ -138,7 +138,7 @@ describe("Store doesn't update *too many* times during navigation", () => {
// that needs to be done during a navigation.
// Any change that increases this number should be investigated.
// Note: Vue has different update counts than React/Solid due to different reactivity
- expect(updates).toBe(16)
+ expect(updates).toBe(15)
})
test('redirection in preload', async () => {
@@ -158,6 +158,11 @@ describe("Store doesn't update *too many* times during navigation", () => {
// Any change that increases this number should be investigated.
// Note: Vue has different update counts than React/Solid due to different reactivity
expect(updates).toBe(5)
+ expect(
+ router.stores.cachedMatches
+ .get()
+ .some((match) => match.pathname === '/posts'),
+ ).toBe(false)
})
test('sync beforeLoad', async () => {
@@ -202,7 +207,7 @@ describe("Store doesn't update *too many* times during navigation", () => {
// that needs to be done during a navigation.
// Any change that increases this number should be investigated.
// Note: Vue has different update counts than React/Solid due to different reactivity
- expect(updates).toBe(9)
+ expect(updates).toBe(8)
})
test('hover preload, then navigate, w/ async loaders', async () => {
@@ -229,7 +234,7 @@ describe("Store doesn't update *too many* times during navigation", () => {
// that needs to be done during a navigation.
// Any change that increases this number should be investigated.
// Note: Vue has different update counts than React/Solid due to different reactivity
- expect(updates).toBe(17)
+ expect(updates).toBe(13)
})
test('navigate, w/ preloaded & async loaders', async () => {
@@ -246,7 +251,7 @@ describe("Store doesn't update *too many* times during navigation", () => {
// that needs to be done during a navigation.
// Any change that increases this number should be investigated.
// Note: Vue has different update counts than React/Solid due to different reactivity
- expect(updates).toBe(10)
+ expect(updates).toBe(8)
})
test('navigate, w/ preloaded & sync loaders', async () => {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 66e022dc6c..53ce64e72a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1148,6 +1148,71 @@ importers:
specifier: ^8.0.14
version: 8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0)
+ e2e/react-router/issue-7120:
+ dependencies:
+ '@tanstack/react-router':
+ specifier: workspace:*
+ version: link:../../../packages/react-router
+ react:
+ specifier: ^19.2.3
+ version: 19.2.3
+ react-dom:
+ specifier: ^19.2.3
+ version: 19.2.3(react@19.2.3)
+ devDependencies:
+ '@playwright/test':
+ specifier: ^1.57.0
+ version: 1.58.0
+ '@tanstack/router-e2e-utils':
+ specifier: workspace:^
+ version: link:../../e2e-utils
+ '@types/react':
+ specifier: ^19.2.8
+ version: 19.2.9
+ '@types/react-dom':
+ specifier: ^19.2.3
+ version: 19.2.3(@types/react@19.2.9)
+ '@vitejs/plugin-react':
+ specifier: ^6.0.1
+ version: 6.0.1(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0))
+ vite:
+ specifier: ^8.0.14
+ version: 8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0)
+
+ e2e/react-router/issue-7457:
+ dependencies:
+ '@tanstack/react-router':
+ specifier: workspace:*
+ version: link:../../../packages/react-router
+ '@tanstack/router-plugin':
+ specifier: workspace:*
+ version: link:../../../packages/router-plugin
+ react:
+ specifier: ^19.2.3
+ version: 19.2.3
+ react-dom:
+ specifier: ^19.2.3
+ version: 19.2.3(react@19.2.3)
+ devDependencies:
+ '@playwright/test':
+ specifier: ^1.57.0
+ version: 1.58.0
+ '@tanstack/router-e2e-utils':
+ specifier: workspace:^
+ version: link:../../e2e-utils
+ '@types/react':
+ specifier: ^19.2.8
+ version: 19.2.9
+ '@types/react-dom':
+ specifier: ^19.2.3
+ version: 19.2.3(@types/react@19.2.9)
+ '@vitejs/plugin-react':
+ specifier: ^6.0.1
+ version: 6.0.1(vite@8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0))
+ vite:
+ specifier: ^8.0.14
+ version: 8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0)
+
e2e/react-router/js-only-file-based:
dependencies:
'@tailwindcss/vite':
diff --git a/skills/bundle-size-optimization/SKILL.md b/skills/bundle-size-optimization/SKILL.md
index d24538be69..3cdffc303e 100644
--- a/skills/bundle-size-optimization/SKILL.md
+++ b/skills/bundle-size-optimization/SKILL.md
@@ -113,6 +113,13 @@ Useful patterns: remove prod-only strings, remove unused exports, flatten wrappe
Rolldown removes code only when unused and side-effect-free. Property reads may trigger getters; storage/global access can observe or throw.
+### `isServer` DCE
+
+- `@tanstack/router-core/isServer` is conditionally exported: browser/client builds use `client.ts` (`isServer = false`), server builds use `server.ts` (`isServer = process.env.NODE_ENV === 'test' ? undefined : true`), and development/test builds use `development.ts` (`isServer = undefined`). The `undefined` value intentionally lets code fall back to `router.isServer` in tests and development.
+- Do not assume `const onServer = isServer ?? this.isServer` will treeshake client-only code. In production browser bundles it can become a local `const onServer = false`, while guarded branches like `onServer && redirect.headers.set(...)` or `else if (onServer)` may still remain in emitted JS.
+- Prefer inlining `isServer ?? this.isServer` at the server-only branch site instead of assigning it to a local alias. The inline form preserves test/development fallback and gives the browser build a better chance to fold the branch away.
+- When a server-only branch should disappear from client bundles, inspect emitted JS in `benchmarks/bundle-size/dist//assets/*.js` to confirm it actually disappeared.
+
| Annotation | Valid | Unsafe |
| ----------------------------------------- | --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| `/* @__PURE__ */ call()` | immediately before a call/new expression whose unused result can be dropped | declarations, property reads, setup, storage, DOM/history/listener code |
From 22e132fe5ffc73e54de09cd7dda4170d69d79c2c Mon Sep 17 00:00:00 2001
From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com>
Date: Wed, 17 Jun 2026 21:07:36 +0200
Subject: [PATCH 02/13] fix
---
packages/react-router/tests/redirect.test.tsx | 1 -
packages/router-core/src/load-matches.ts | 21 ++++++++++++-------
packages/router-core/src/router.ts | 6 +++++-
packages/solid-router/tests/redirect.test.tsx | 1 -
packages/vue-router/tests/redirect.test.tsx | 1 -
5 files changed, 19 insertions(+), 11 deletions(-)
diff --git a/packages/react-router/tests/redirect.test.tsx b/packages/react-router/tests/redirect.test.tsx
index 0b2f7079e6..47847ce1e3 100644
--- a/packages/react-router/tests/redirect.test.tsx
+++ b/packages/react-router/tests/redirect.test.tsx
@@ -11,7 +11,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import {
Link,
- Outlet,
RouterProvider,
createBrowserHistory,
createMemoryHistory,
diff --git a/packages/router-core/src/load-matches.ts b/packages/router-core/src/load-matches.ts
index c037e93d63..578fd0fac6 100644
--- a/packages/router-core/src/load-matches.ts
+++ b/packages/router-core/src/load-matches.ts
@@ -283,13 +283,19 @@ const handleSerialError = (
inner.firstBadMatchIndex ??= index
match.__beforeLoadContext = undefined
- handleRedirectOrNotFound(inner, inner.router.getMatch(matchId), err)
+ const currentMatch = inner.router.getMatch(matchId)
+ if (currentMatch) {
+ currentMatch.__beforeLoadContext = undefined
+ }
+
+ handleRedirectOrNotFound(inner, currentMatch, err)
try {
route.options.onError?.(err)
} catch (errorHandlerErr) {
err = errorHandlerErr
- handleRedirectOrNotFound(inner, inner.router.getMatch(matchId), err)
+ // The current match's pending beforeLoad context was already cleared above.
+ handleRedirectOrNotFound(inner, currentMatch, err)
}
// A match that errors during the beforeLoad phase never reaches the loader
@@ -304,9 +310,9 @@ const handleSerialError = (
abortController: new AbortController(),
})
- const currentMatch = inner.router.getMatch(matchId)
- if (currentMatch) {
- clearMatchPromises(currentMatch)
+ const updatedMatch = inner.router.getMatch(matchId)
+ if (updatedMatch) {
+ clearMatchPromises(updatedMatch)
}
if (!inner.preload) {
@@ -1161,10 +1167,11 @@ export async function loadMatches(arg: {
notFoundToThrow.routeId = boundaryMatch.routeId
- patchMatch(
+ commitMatch(
inner,
boundaryMatch.id,
- boundaryMatch.routeId === inner.router.routeTree.id
+ renderedBoundaryIndex,
+ boundaryMatch.routeId === rootRouteId
? // For root boundary, use globalNotFound so the root component's
// shell still renders and handles the not-found display,
// instead of replacing the entire root shell via status='notFound'.
diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts
index 87be4cebc0..67c0eadae5 100644
--- a/packages/router-core/src/router.ts
+++ b/packages/router-core/src/router.ts
@@ -2879,7 +2879,11 @@ export class RouterCore<
continue
}
- await this.getMatch(id)?._nonReactive.beforeLoadPromise
+ const beforeLoadPromise =
+ this.getMatch(id)?._nonReactive.beforeLoadPromise
+ if (beforeLoadPromise) {
+ await beforeLoadPromise
+ }
const settledMatch = this.getMatch(id)
if (
diff --git a/packages/solid-router/tests/redirect.test.tsx b/packages/solid-router/tests/redirect.test.tsx
index b7f3ab7d76..5d3ecc898a 100644
--- a/packages/solid-router/tests/redirect.test.tsx
+++ b/packages/solid-router/tests/redirect.test.tsx
@@ -4,7 +4,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import {
Link,
- Outlet,
RouterProvider,
createBrowserHistory,
createMemoryHistory,
diff --git a/packages/vue-router/tests/redirect.test.tsx b/packages/vue-router/tests/redirect.test.tsx
index a4faa5cd78..f198f8adf6 100644
--- a/packages/vue-router/tests/redirect.test.tsx
+++ b/packages/vue-router/tests/redirect.test.tsx
@@ -4,7 +4,6 @@ import { afterEach, describe, expect, test, vi } from 'vitest'
import {
Link,
- Outlet,
RouterProvider,
createMemoryHistory,
createRootRoute,
From abacc3158c6fe2c6ebed66d1e448541c2a0a5125 Mon Sep 17 00:00:00 2001
From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com>
Date: Wed, 17 Jun 2026 21:07:36 +0200
Subject: [PATCH 03/13] revert unrelated changes
---
packages/react-router/tests/redirect.test.tsx | 47 +++++++++++++++++++
packages/router-core/src/redirect.ts | 2 +-
packages/solid-router/tests/redirect.test.tsx | 47 +++++++++++++++++++
packages/vue-router/tests/redirect.test.tsx | 46 ++++++++++++++++++
4 files changed, 141 insertions(+), 1 deletion(-)
diff --git a/packages/react-router/tests/redirect.test.tsx b/packages/react-router/tests/redirect.test.tsx
index 47847ce1e3..cc15f0da36 100644
--- a/packages/react-router/tests/redirect.test.tsx
+++ b/packages/react-router/tests/redirect.test.tsx
@@ -11,6 +11,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import {
Link,
+ Outlet,
RouterProvider,
createBrowserHistory,
createMemoryHistory,
@@ -112,6 +113,52 @@ describe('redirect', () => {
expect(nestedFooLoaderMock).toHaveBeenCalled()
})
+ test('when root `beforeLoad` redirects while root pendingComponent is showing and the target route is lazy', async () => {
+ let hasRedirected = false
+ const consoleError = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {})
+
+ const rootRoute = createRootRoute({
+ component: () => ,
+ pendingMs: 0,
+ pendingComponent: () => loading
,
+ beforeLoad: async () => {
+ await sleep(WAIT_TIME)
+ if (!hasRedirected) {
+ hasRedirected = true
+ throw redirect({ to: '/posts' })
+ }
+ },
+ })
+
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => Index page
,
+ })
+
+ const postsRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/posts',
+ }).lazy(() => import('./lazy/normal').then((d) => d.Route('/posts')))
+
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([indexRoute, postsRoute]),
+ history,
+ })
+
+ render()
+
+ // The lazy target route adds the async boundary that exposes the stale
+ // redirected-match render path this regression is guarding.
+ expect(await screen.findByTestId('lazy-route-page')).toBeInTheDocument()
+ expect(screen.queryByTestId('pending')).not.toBeInTheDocument()
+ expect(router.state.location.href).toBe('/posts')
+ expect(router.state.status).toBe('idle')
+ expect(consoleError).not.toHaveBeenCalled()
+ })
+
test('when `redirect` is thrown in `loader`', async () => {
const nestedLoaderMock = vi.fn()
const nestedFooLoaderMock = vi.fn()
diff --git a/packages/router-core/src/redirect.ts b/packages/router-core/src/redirect.ts
index 220ebd0ed8..baaacd80d3 100644
--- a/packages/router-core/src/redirect.ts
+++ b/packages/router-core/src/redirect.ts
@@ -182,5 +182,5 @@ export function parseRedirect(obj: any) {
return redirect(obj)
}
- return
+ return undefined
}
diff --git a/packages/solid-router/tests/redirect.test.tsx b/packages/solid-router/tests/redirect.test.tsx
index 5d3ecc898a..81bd1f6bc6 100644
--- a/packages/solid-router/tests/redirect.test.tsx
+++ b/packages/solid-router/tests/redirect.test.tsx
@@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import {
Link,
+ Outlet,
RouterProvider,
createBrowserHistory,
createMemoryHistory,
@@ -104,6 +105,52 @@ describe('redirect', () => {
expect(nestedFooLoaderMock).toHaveBeenCalled()
})
+ test('when root `beforeLoad` redirects while root pendingComponent is showing and the target route is lazy', async () => {
+ let hasRedirected = false
+ const consoleError = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {})
+
+ const rootRoute = createRootRoute({
+ component: () => ,
+ pendingMs: 0,
+ pendingComponent: () => loading
,
+ beforeLoad: async () => {
+ await sleep(WAIT_TIME)
+ if (!hasRedirected) {
+ hasRedirected = true
+ throw redirect({ to: '/posts' })
+ }
+ },
+ })
+
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => Index page
,
+ })
+
+ const postsRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/posts',
+ }).lazy(() => import('./lazy/normal').then((d) => d.Route('/posts')))
+
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([indexRoute, postsRoute]),
+ history,
+ })
+
+ render(() => )
+
+ // The lazy target route adds the async boundary that exposes the stale
+ // redirected-match render path this regression is guarding.
+ expect(await screen.findByTestId('lazy-route-page')).toBeInTheDocument()
+ expect(screen.queryByTestId('pending')).not.toBeInTheDocument()
+ expect(router.state.location.href).toBe('/posts')
+ expect(router.state.status).toBe('idle')
+ expect(consoleError).not.toHaveBeenCalled()
+ })
+
test('when `redirect` is thrown in `loader`', async () => {
const nestedLoaderMock = vi.fn()
const nestedFooLoaderMock = vi.fn()
diff --git a/packages/vue-router/tests/redirect.test.tsx b/packages/vue-router/tests/redirect.test.tsx
index f198f8adf6..b7ba2e1af3 100644
--- a/packages/vue-router/tests/redirect.test.tsx
+++ b/packages/vue-router/tests/redirect.test.tsx
@@ -4,6 +4,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest'
import {
Link,
+ Outlet,
RouterProvider,
createMemoryHistory,
createRootRoute,
@@ -94,6 +95,51 @@ describe('redirect', () => {
expect(nestedFooLoaderMock).toHaveBeenCalled()
})
+ test('when root `beforeLoad` redirects while root pendingComponent is showing and the target route is lazy', async () => {
+ let hasRedirected = false
+ const consoleError = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {})
+
+ const rootRoute = createRootRoute({
+ component: () => ,
+ pendingMs: 0,
+ pendingComponent: () => loading
,
+ beforeLoad: async () => {
+ await sleep(WAIT_TIME)
+ if (!hasRedirected) {
+ hasRedirected = true
+ throw redirect({ to: '/posts' })
+ }
+ },
+ })
+
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => Index page
,
+ })
+
+ const postsRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/posts',
+ }).lazy(() => import('./lazy/normal').then((d) => d.Route('/posts')))
+
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([indexRoute, postsRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+
+ render()
+
+ // The lazy target route adds the async boundary that exposes the stale
+ // redirected-match render path this regression is guarding.
+ expect(await screen.findByTestId('lazy-route-page')).toBeInTheDocument()
+ expect(screen.queryByTestId('pending')).not.toBeInTheDocument()
+ expect(router.state.location.href).toBe('/posts')
+ expect(consoleError).not.toHaveBeenCalled()
+ })
+
test('when `redirect` is thrown in `loader`', async () => {
const nestedLoaderMock = vi.fn()
const nestedFooLoaderMock = vi.fn()
From d71b6f5772bd9185d6e84135a322bf5c20c153cb Mon Sep 17 00:00:00 2001
From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com>
Date: Wed, 17 Jun 2026 21:07:36 +0200
Subject: [PATCH 04/13] single function for match manipulation
---
packages/router-core/src/load-matches.ts | 87 +++++++++++++-----------
1 file changed, 47 insertions(+), 40 deletions(-)
diff --git a/packages/router-core/src/load-matches.ts b/packages/router-core/src/load-matches.ts
index 578fd0fac6..fb85da50c2 100644
--- a/packages/router-core/src/load-matches.ts
+++ b/packages/router-core/src/load-matches.ts
@@ -68,31 +68,23 @@ const buildMatchContext = (
return context
}
-// Commits the merged context exactly when a match's beforeLoad phase settles.
-// Loader-phase updates intentionally leave context alone; loaders cannot change
-// the inputs used by buildMatchContext.
-const commitMatch = (
- inner: InnerLoadContext,
- matchId: string,
- index: number,
- patch: Partial,
-): void => {
- inner.updateMatch(matchId, (prev) => ({
- ...prev,
- ...patch,
- context: buildMatchContext(inner, index),
- }))
-}
-
const patchMatch = (
inner: InnerLoadContext,
matchId: string,
patch: Partial,
+ contextIndex?: number,
): void => {
- inner.updateMatch(matchId, (prev) => ({
- ...prev,
- ...patch,
- }))
+ inner.updateMatch(matchId, (prev) => {
+ const next = {
+ ...prev,
+ ...patch,
+ }
+ if (contextIndex !== undefined) {
+ // Context is only recomputed when the beforeLoad/SSR-skip phase settles.
+ next.context = buildMatchContext(inner, contextIndex)
+ }
+ return next
+ })
}
const getNavigate = (inner: InnerLoadContext) => (opts: any) =>
@@ -300,15 +292,20 @@ const handleSerialError = (
// A match that errors during the beforeLoad phase never reaches the loader
// phase. Settle its promises after committing the error state.
- commitMatch(inner, matchId, index, {
- __beforeLoadContext: undefined,
- error: err,
- status: 'error',
- isFetching: false,
- _forcePending: undefined,
- updatedAt: Date.now(),
- abortController: new AbortController(),
- })
+ patchMatch(
+ inner,
+ matchId,
+ {
+ __beforeLoadContext: undefined,
+ error: err,
+ status: 'error',
+ isFetching: false,
+ _forcePending: undefined,
+ updatedAt: Date.now(),
+ abortController: new AbortController(),
+ },
+ index,
+ )
const updatedMatch = inner.router.getMatch(matchId)
if (updatedMatch) {
@@ -482,10 +479,15 @@ const executeBeforeLoad = (
inner.matches[index]!.__beforeLoadContext = undefined
inner.router.batch(() => {
pending()
- commitMatch(inner, matchId, index, {
- isFetching: false as const,
- __beforeLoadContext: undefined,
- })
+ patchMatch(
+ inner,
+ matchId,
+ {
+ isFetching: false as const,
+ __beforeLoadContext: undefined,
+ },
+ index,
+ )
})
settleBeforeLoadPromise(match)
return
@@ -512,10 +514,15 @@ const executeBeforeLoad = (
inner.router.batch(() => {
pending()
- commitMatch(inner, matchId, index, {
- isFetching: false as const,
- __beforeLoadContext: beforeLoadContext,
- })
+ patchMatch(
+ inner,
+ matchId,
+ {
+ isFetching: false as const,
+ __beforeLoadContext: beforeLoadContext,
+ },
+ index,
+ )
})
settleBeforeLoadPromise(match)
}
@@ -929,7 +936,7 @@ const loadRouteMatch = async (
// the beforeLoad phase (and with it the context commit) does not run for
// skipped matches, so commit the merged route context here
- commitMatch(inner, matchId, index, { invalid: false })
+ patchMatch(inner, matchId, { invalid: false }, index)
if (isServer ?? inner.router.isServer) {
return inner.router.getMatch(matchId)!
@@ -1167,10 +1174,9 @@ export async function loadMatches(arg: {
notFoundToThrow.routeId = boundaryMatch.routeId
- commitMatch(
+ patchMatch(
inner,
boundaryMatch.id,
- renderedBoundaryIndex,
boundaryMatch.routeId === rootRouteId
? // For root boundary, use globalNotFound so the root component's
// shell still renders and handles the not-found display,
@@ -1190,6 +1196,7 @@ export async function loadMatches(arg: {
isFetching: false,
_forcePending: undefined,
},
+ renderedBoundaryIndex,
)
headMaxIndex = renderedBoundaryIndex
From 14895fb300e2634973604646196357c6d40d77cd Mon Sep 17 00:00:00 2001
From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com>
Date: Wed, 17 Jun 2026 21:07:36 +0200
Subject: [PATCH 05/13] simplify
---
packages/router-core/src/load-matches.ts | 316 +++++++++++------------
packages/router-core/src/router.ts | 26 --
packages/router-core/tests/load.test.ts | 265 +++++++++++++++++--
3 files changed, 398 insertions(+), 209 deletions(-)
diff --git a/packages/router-core/src/load-matches.ts b/packages/router-core/src/load-matches.ts
index fb85da50c2..3717806470 100644
--- a/packages/router-core/src/load-matches.ts
+++ b/packages/router-core/src/load-matches.ts
@@ -1,5 +1,4 @@
import { isServer } from '@tanstack/router-core/isServer'
-import { invariant } from './invariant'
import { createControlledPromise, isPromise } from './utils'
import { isNotFound } from './not-found'
import { rootRouteId } from './root'
@@ -33,7 +32,6 @@ type InnerLoadContext = {
updateMatch: UpdateMatchFn
matches: Array
preload?: boolean
- preloadMatchIds?: Set
forceStaleReload?: boolean
onReady?: () => Promise
sync?: boolean
@@ -50,6 +48,68 @@ const resolvePreload = (inner: InnerLoadContext, matchId: string): boolean => {
return !!inner.preload && !inner.router.stores.matchStores.has(matchId)
}
+const isActivePreloadMatch = (
+ inner: InnerLoadContext,
+ matchId: string,
+): boolean => {
+ return !!(
+ inner.preload &&
+ (inner.router.stores.matchStores.has(matchId) ||
+ inner.router.stores.pendingMatchStores.has(matchId))
+ )
+}
+
+const joinActivePreloadMatch = async (
+ inner: InnerLoadContext,
+ index: number,
+ waitForLoader: boolean,
+): Promise => {
+ const matchId = inner.matches[index]!.id
+ let match = inner.router.getMatch(matchId) ?? inner.matches[index]!
+ const route = inner.router.looseRoutesById[match.routeId]!
+
+ if (match._nonReactive.beforeLoadPromise) {
+ await match._nonReactive.beforeLoadPromise
+ match = inner.router.getMatch(matchId) ?? inner.matches[index]!
+ } else if (
+ route.options.beforeLoad &&
+ match.status === 'pending' &&
+ match.fetchCount === 0
+ ) {
+ const loadPromise = match._nonReactive.loadPromise
+ if (loadPromise?.status === 'pending') {
+ await loadPromise
+ match = inner.router.getMatch(matchId) ?? inner.matches[index]!
+ }
+ }
+
+ inner.matches[index] = match
+ let error = match._nonReactive.error || match.error
+
+ if (!error && waitForLoader && match.status === 'pending') {
+ if (match._nonReactive.loaderPromise) {
+ await match._nonReactive.loaderPromise
+ match = inner.router.getMatch(matchId) ?? inner.matches[index]!
+ } else {
+ const loadPromise = match._nonReactive.loadPromise
+ if (loadPromise?.status === 'pending') {
+ await loadPromise
+ match = inner.router.getMatch(matchId) ?? inner.matches[index]!
+ }
+ }
+ inner.matches[index] = match
+ error = match._nonReactive.error || match.error
+ }
+
+ handleRedirectOrNotFound(inner, match, error)
+ if (match.status === 'error' || match.status === 'notFound') {
+ inner.firstBadMatchIndex ??= index
+ throw error
+ }
+
+ return match
+}
+
/**
* Builds the accumulated context from router options and all matches up to the given index.
* Merges __routeContext and __beforeLoadContext from each match.
@@ -72,18 +132,12 @@ const patchMatch = (
inner: InnerLoadContext,
matchId: string,
patch: Partial,
- contextIndex?: number,
): void => {
inner.updateMatch(matchId, (prev) => {
- const next = {
+ return {
...prev,
...patch,
}
- if (contextIndex !== undefined) {
- // Context is only recomputed when the beforeLoad/SSR-skip phase settles.
- next.context = buildMatchContext(inner, contextIndex)
- }
- return next
})
}
@@ -125,11 +179,7 @@ export const clearMatchPromises = (match: AnyRouteMatch): void => {
const getNotFoundBoundaryIndex = (
inner: InnerLoadContext,
err: NotFoundError,
-): number | undefined => {
- if (!inner.matches.length) {
- return undefined
- }
-
+): number => {
const requestedRouteId = err.routeId
let startIndex = requestedRouteId
@@ -165,16 +215,16 @@ const handleRedirect = (
// in case of a redirecting match during preload, the match does not exist
if (match) {
match._nonReactive.error = redirect
- clearPending(match)
- settleBeforeLoadPromise(match)
if (inner.preload || inner.router.stores.cachedMatchStores.has(match.id)) {
+ clearMatchPromises(match)
inner.router.clearCache({ filter: (d) => d.id === match.id })
- settleLoadPromises(match)
} else {
// A redirect is not renderable navigation state. Keep the current
// renderable status (pending or success) until the redirect target
// commits, but clear fetching state.
+ clearPending(match)
+ settleBeforeLoadPromise(match)
settleLoaderPromise(match)
patchMatch(inner, match.id, {
isFetching: false as const,
@@ -195,9 +245,7 @@ const handleNotFound = (
): void => {
if (match) {
match._nonReactive.error = notFound
- clearPending(match)
- settleBeforeLoadPromise(match)
- settleLoadPromises(match)
+ clearMatchPromises(match)
if (!notFound.routeId) {
// Stamp the throwing match's routeId so that the finalization step in
@@ -212,10 +260,6 @@ const handleNotFound = (
isFetching: false,
_forcePending: undefined,
})
-
- if (inner.preload || inner.router.stores.cachedMatchStores.has(match.id)) {
- inner.router.clearCache({ filter: (d) => d.id === match.id })
- }
}
throw notFound
@@ -228,32 +272,25 @@ const handleRedirectOrNotFound = (
): void => {
if (isRedirect(err)) {
handleRedirect(inner, match, err)
- }
-
- if (isNotFound(err)) {
+ } else if (isNotFound(err)) {
handleNotFound(inner, match, err)
}
}
-const getLoaderMatch = (
+const shouldSkipMatchLoad = (
inner: InnerLoadContext,
- matchId: string,
-): AnyRouteMatch | false | undefined => {
- const match = inner.router.getMatch(matchId)
- if (!match || inner.preloadMatchIds?.has(matchId)) {
- return
- }
-
+ match: AnyRouteMatch,
+): boolean => {
// upon hydration, we skip the loader if the match has been dehydrated on the server
if (!(isServer ?? inner.router.isServer) && match._nonReactive.dehydrated) {
- return false
+ return true
}
if ((isServer ?? inner.router.isServer) && match.ssr === false) {
- return false
+ return true
}
- return match
+ return false
}
const handleSerialError = (
@@ -292,20 +329,16 @@ const handleSerialError = (
// A match that errors during the beforeLoad phase never reaches the loader
// phase. Settle its promises after committing the error state.
- patchMatch(
- inner,
- matchId,
- {
- __beforeLoadContext: undefined,
- error: err,
- status: 'error',
- isFetching: false,
- _forcePending: undefined,
- updatedAt: Date.now(),
- abortController: new AbortController(),
- },
- index,
- )
+ patchMatch(inner, matchId, {
+ __beforeLoadContext: undefined,
+ error: err,
+ status: 'error',
+ isFetching: false,
+ _forcePending: undefined,
+ context: buildMatchContext(inner, index),
+ updatedAt: Date.now(),
+ abortController: new AbortController(),
+ })
const updatedMatch = inner.router.getMatch(matchId)
if (updatedMatch) {
@@ -479,15 +512,11 @@ const executeBeforeLoad = (
inner.matches[index]!.__beforeLoadContext = undefined
inner.router.batch(() => {
pending()
- patchMatch(
- inner,
- matchId,
- {
- isFetching: false as const,
- __beforeLoadContext: undefined,
- },
- index,
- )
+ patchMatch(inner, matchId, {
+ isFetching: false as const,
+ __beforeLoadContext: undefined,
+ context: buildMatchContext(inner, index),
+ })
})
settleBeforeLoadPromise(match)
return
@@ -514,15 +543,11 @@ const executeBeforeLoad = (
inner.router.batch(() => {
pending()
- patchMatch(
- inner,
- matchId,
- {
- isFetching: false as const,
- __beforeLoadContext: beforeLoadContext,
- },
- index,
- )
+ patchMatch(inner, matchId, {
+ isFetching: false as const,
+ __beforeLoadContext: beforeLoadContext,
+ context: buildMatchContext(inner, index),
+ })
})
settleBeforeLoadPromise(match)
}
@@ -587,13 +612,17 @@ const executeBeforeLoad = (
const handleBeforeLoad = (
inner: InnerLoadContext,
index: number,
-): void | Promise => {
+): void | Promise => {
const { id: matchId, routeId } = inner.matches[index]!
const route = inner.router.looseRoutesById[routeId]!
+ if (isActivePreloadMatch(inner, matchId)) {
+ return joinActivePreloadMatch(inner, index, false)
+ }
+
const queueExecution = () => {
- const existingMatch = getLoaderMatch(inner, matchId)
- if (!existingMatch) {
+ const existingMatch = inner.router.getMatch(matchId)
+ if (!existingMatch || shouldSkipMatchLoad(inner, existingMatch)) {
return
}
@@ -605,27 +634,16 @@ const handleBeforeLoad = (
if (pendingBeforeLoad) {
return pendingBeforeLoad.then(() => {
- const match = inner.router.getMatch(matchId)!
- if (match.preload && match.status === 'notFound') {
- handleRedirectOrNotFound(inner, match, match.error)
- }
-
- if (!getLoaderMatch(inner, matchId)) {
+ const match = inner.router.getMatch(matchId)
+ if (!match || shouldSkipMatchLoad(inner, match)) {
return
}
+
return executeBeforeLoad(inner, matchId, index, route)
})
}
-
- const match = inner.router.getMatch(matchId)!
- if (match.preload && match.status === 'notFound') {
- handleRedirectOrNotFound(inner, match, match.error)
- }
}
- if (!getLoaderMatch(inner, matchId)) {
- return
- }
return executeBeforeLoad(inner, matchId, index, route)
}
@@ -859,42 +877,40 @@ const loadRouteMatch = async (
previousRouteMatchId: string | undefined,
match: AnyRouteMatch,
): void | Promise => {
- const age = Date.now() - prevMatch.updatedAt
-
- const staleAge = preload
- ? (route.options.preloadStaleTime ??
- inner.router.options.defaultPreloadStaleTime ??
- 30_000) // 30 seconds for preloads by default
- : (route.options.staleTime ?? inner.router.options.defaultStaleTime ?? 0)
-
- const shouldReloadOption = route.options.shouldReload
-
- // Default to reloading the route all the time
- // Allow shouldReload to get the last say,
- // if provided.
- const shouldReload =
- typeof shouldReloadOption === 'function'
- ? shouldReloadOption(
- getLoaderContext(inner, matchPromises, matchId, index, route),
- )
- : shouldReloadOption
-
- const { status, invalid } = match
- const staleMatchShouldReload =
- age >= staleAge &&
- (!!inner.forceStaleReload ||
- match.cause === 'enter' ||
- (previousRouteMatchId !== undefined &&
- previousRouteMatchId !== match.id))
- const loaderShouldRunAsync =
- status === 'success' &&
- (invalid || (shouldReload ?? staleMatchShouldReload))
-
if (preload && route.options.preload === false) {
// Do nothing
return
}
+ const { status, invalid } = match
+ let loaderShouldRunAsync = false
+
+ if (status === 'success') {
+ const age = Date.now() - prevMatch.updatedAt
+ const staleAge = preload
+ ? (route.options.preloadStaleTime ??
+ inner.router.options.defaultPreloadStaleTime ??
+ 30_000) // 30 seconds for preloads by default
+ : (route.options.staleTime ??
+ inner.router.options.defaultStaleTime ??
+ 0)
+ const shouldReloadOption = route.options.shouldReload
+ const shouldReload =
+ typeof shouldReloadOption === 'function'
+ ? shouldReloadOption(
+ getLoaderContext(inner, matchPromises, matchId, index, route),
+ )
+ : shouldReloadOption
+ const staleMatchShouldReload =
+ age >= staleAge &&
+ (!!inner.forceStaleReload ||
+ match.cause === 'enter' ||
+ (previousRouteMatchId !== undefined &&
+ previousRouteMatchId !== match.id))
+
+ loaderShouldRunAsync = invalid || (shouldReload ?? staleMatchShouldReload)
+ }
+
if (loaderShouldRunAsync && !inner.sync && shouldReloadInBackground) {
// stale-while-revalidate: leave the loader running detached
loaderIsRunningAsync = true
@@ -927,16 +943,23 @@ const loadRouteMatch = async (
}
}
- const prevMatch = getLoaderMatch(inner, matchId)
+ if (isActivePreloadMatch(inner, matchId)) {
+ return await joinActivePreloadMatch(inner, index, true)
+ }
+
+ const prevMatch = inner.router.getMatch(matchId)
if (!prevMatch) {
// in case of a redirecting match during preload, the match does not exist
- if (prevMatch === undefined) {
- return inner.matches[index]!
- }
+ return inner.matches[index]!
+ }
+ if (shouldSkipMatchLoad(inner, prevMatch)) {
// the beforeLoad phase (and with it the context commit) does not run for
// skipped matches, so commit the merged route context here
- patchMatch(inner, matchId, { invalid: false }, index)
+ patchMatch(inner, matchId, {
+ invalid: false,
+ context: buildMatchContext(inner, index),
+ })
if (isServer ?? inner.router.isServer) {
return inner.router.getMatch(matchId)!
@@ -1040,7 +1063,6 @@ export async function loadMatches(arg: {
location: ParsedLocation
matches: Array
preload?: boolean
- preloadMatchIds?: Set
forceStaleReload?: boolean
onReady?: () => Promise
updateMatch: UpdateMatchFn
@@ -1053,12 +1075,7 @@ export async function loadMatches(arg: {
// the pending component was already rendered on the server and we want to keep it shown on the client until minPendingMs is reached
if (
!(isServer ?? inner.router.isServer) &&
- inner.router.stores.matchesId
- .get()
- .some(
- (matchId) =>
- inner.router.stores.matchStores.get(matchId)?.get()._forcePending,
- )
+ inner.router.stores.matches.get().some((match) => match._forcePending)
) {
triggerOnReady(inner)
}
@@ -1082,28 +1099,21 @@ export async function loadMatches(arg: {
break
}
- if (inner.serialError || inner.firstBadMatchIndex != null) {
+ if (inner.firstBadMatchIndex != null) {
break
}
}
// Execute loaders once, with max index adapted for beforeLoad notFound handling.
const baseMaxIndexExclusive = inner.firstBadMatchIndex ?? inner.matches.length
-
- const boundaryIndex =
- beforeLoadNotFound && !inner.preload
- ? getNotFoundBoundaryIndex(inner, beforeLoadNotFound)
- : undefined
-
- const maxIndexExclusive =
- beforeLoadNotFound && inner.preload
- ? 0
- : boundaryIndex !== undefined
- ? Math.min(boundaryIndex + 1, baseMaxIndexExclusive)
- : baseMaxIndexExclusive
+ const maxIndexExclusive = beforeLoadNotFound
+ ? Math.min(
+ getNotFoundBoundaryIndex(inner, beforeLoadNotFound) + 1,
+ baseMaxIndexExclusive,
+ )
+ : baseMaxIndexExclusive
let firstNotFound: NotFoundError | undefined
- let firstUnhandledRejection: unknown
for (let i = 0; i < maxIndexExclusive; i++) {
matchPromises.push(loadRouteMatch(inner, matchPromises, i))
@@ -1113,6 +1123,7 @@ export async function loadMatches(arg: {
await Promise.all(matchPromises)
} catch {
const settled = await Promise.allSettled(matchPromises)
+ let firstUnhandledRejection: unknown
for (const result of settled) {
if (result.status !== 'rejected') continue
@@ -1133,10 +1144,6 @@ export async function loadMatches(arg: {
}
}
- if (beforeLoadNotFound && inner.preload) {
- return inner.matches
- }
-
const notFoundToThrow = firstNotFound ?? beforeLoadNotFound
let headMaxIndex = inner.firstBadMatchIndex ?? inner.matches.length - 1
@@ -1152,15 +1159,6 @@ export async function loadMatches(arg: {
notFoundToThrow,
)
- if (renderedBoundaryIndex === undefined) {
- if (process.env.NODE_ENV !== 'production') {
- throw new Error(
- 'Invariant failed: Could not find match for notFound boundary',
- )
- }
-
- invariant()
- }
const boundaryMatch = inner.matches[renderedBoundaryIndex]!
const boundaryRoute = inner.router.looseRoutesById[boundaryMatch.routeId]!
@@ -1173,6 +1171,7 @@ export async function loadMatches(arg: {
}
notFoundToThrow.routeId = boundaryMatch.routeId
+ const context = buildMatchContext(inner, renderedBoundaryIndex)
patchMatch(
inner,
@@ -1187,6 +1186,7 @@ export async function loadMatches(arg: {
error: undefined,
isFetching: false,
_forcePending: undefined,
+ context,
}
: // For non-root boundaries, set status:'notFound' so MatchInner
// renders the notFoundComponent directly.
@@ -1195,8 +1195,8 @@ export async function loadMatches(arg: {
error: notFoundToThrow,
isFetching: false,
_forcePending: undefined,
+ context,
},
- renderedBoundaryIndex,
)
headMaxIndex = renderedBoundaryIndex
diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts
index 67c0eadae5..083422df2a 100644
--- a/packages/router-core/src/router.ts
+++ b/packages/router-core/src/router.ts
@@ -2873,31 +2873,6 @@ export class RouterCore<
...this.stores.pendingIds.get(),
])
- for (let i = 0; i < matches.length; i++) {
- const id = matches[i]!.id
- if (!activeMatchIds.has(id)) {
- continue
- }
-
- const beforeLoadPromise =
- this.getMatch(id)?._nonReactive.beforeLoadPromise
- if (beforeLoadPromise) {
- await beforeLoadPromise
- }
-
- const settledMatch = this.getMatch(id)
- if (
- !settledMatch ||
- settledMatch._nonReactive.error ||
- settledMatch.status === 'error' ||
- settledMatch.status === 'notFound'
- ) {
- return matches
- }
-
- matches[i] = settledMatch
- }
-
// If the matches are already loaded, we need to add them to the cached matches.
const matchesToCache = matches.filter(
(match) =>
@@ -2915,7 +2890,6 @@ export class RouterCore<
matches,
location: next,
preload: true,
- preloadMatchIds: activeMatchIds,
updateMatch: (id, updater) => {
// Don't update matches that were active when the preload started.
if (activeMatchIds.has(id)) {
diff --git a/packages/router-core/tests/load.test.ts b/packages/router-core/tests/load.test.ts
index 666b66c180..d41603d642 100644
--- a/packages/router-core/tests/load.test.ts
+++ b/packages/router-core/tests/load.test.ts
@@ -544,6 +544,193 @@ describe('beforeLoad skip or exec', () => {
})
})
+ test('preload from onBeforeLoad waits for active root beforeLoad context', async () => {
+ vi.useFakeTimers()
+
+ try {
+ const rootBeforeLoadPromise = createControlledPromise<{ auth: string }>()
+ const rootBeforeLoad = vi.fn(() => rootBeforeLoadPromise)
+ const childLoader = vi.fn(({ context }) => context)
+
+ const rootRoute = new BaseRootRoute({
+ beforeLoad: rootBeforeLoad,
+ })
+ const parentRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/parent',
+ })
+ const childRoute = new BaseRoute({
+ getParentRoute: () => parentRoute,
+ path: '/child',
+ loader: childLoader,
+ })
+
+ const router = createTestRouter({
+ routeTree: rootRoute.addChildren([
+ parentRoute.addChildren([childRoute]),
+ ]),
+ history: createMemoryHistory(),
+ })
+
+ let preload: ReturnType | undefined
+ const unsubscribe = router.subscribe('onBeforeLoad', (event) => {
+ if (!preload && event.toLocation.pathname === '/parent') {
+ preload = router.preloadRoute({ to: '/parent/child' })
+ }
+ })
+
+ try {
+ const navigation = router.navigate({ to: '/parent' })
+ await vi.advanceTimersByTimeAsync(0)
+
+ expect(rootBeforeLoad).toHaveBeenCalledTimes(1)
+ expect(childLoader).not.toHaveBeenCalled()
+
+ rootBeforeLoadPromise.resolve({ auth: 'ok' })
+ await navigation
+ await preload
+
+ expect(childLoader).toHaveBeenCalledTimes(1)
+ expect(childLoader.mock.calls[0]?.[0].context).toMatchObject({
+ auth: 'ok',
+ })
+ } finally {
+ unsubscribe()
+ }
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ test('preload descendant waits for active parent loader data', async () => {
+ vi.useFakeTimers()
+
+ try {
+ const parentLoaderPromise = createControlledPromise<{ auth: string }>()
+ const unexpectedParentPreloadPromise = createControlledPromise<{
+ auth: string
+ }>()
+ const parentLoader = vi.fn(({ preload }) => {
+ return preload ? unexpectedParentPreloadPromise : parentLoaderPromise
+ })
+ let childLoaderSettled = false
+ const childLoader = vi.fn(async ({ parentMatchPromise }) => {
+ const parentMatch = (await parentMatchPromise) as any
+ childLoaderSettled = true
+ return parentMatch.loaderData
+ })
+
+ const rootRoute = new BaseRootRoute({})
+ const parentRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/parent',
+ loader: parentLoader,
+ })
+ const childRoute = new BaseRoute({
+ getParentRoute: () => parentRoute,
+ path: '/child',
+ loader: childLoader,
+ })
+
+ const router = createTestRouter({
+ routeTree: rootRoute.addChildren([
+ parentRoute.addChildren([childRoute]),
+ ]),
+ history: createMemoryHistory(),
+ })
+
+ const navigation = router.navigate({ to: '/parent' })
+ await vi.waitFor(() => expect(parentLoader).toHaveBeenCalledTimes(1))
+
+ const preload = router.preloadRoute({ to: '/parent/child' })
+ await vi.advanceTimersByTimeAsync(5)
+ expect(parentLoader).toHaveBeenCalledTimes(1)
+ expect(childLoader).toHaveBeenCalledTimes(1)
+ expect(childLoaderSettled).toBe(false)
+
+ parentLoaderPromise.resolve({ auth: 'ok' })
+ await navigation
+ await preload
+
+ expect(parentLoader).toHaveBeenCalledTimes(1)
+ expect(childLoaderSettled).toBe(true)
+ await expect(childLoader.mock.results[0]!.value).resolves.toEqual({
+ auth: 'ok',
+ })
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ test('preload from onBeforeLoad waits for active parent loader data', async () => {
+ vi.useFakeTimers()
+
+ try {
+ const parentLoaderPromise = createControlledPromise<{ auth: string }>()
+ const unexpectedParentPreloadPromise = createControlledPromise<{
+ auth: string
+ }>()
+ const parentLoader = vi.fn(({ preload }) => {
+ return preload ? unexpectedParentPreloadPromise : parentLoaderPromise
+ })
+ let childLoaderSettled = false
+ const childLoader = vi.fn(async ({ parentMatchPromise }) => {
+ const parentMatch = (await parentMatchPromise) as any
+ childLoaderSettled = true
+ return parentMatch.loaderData
+ })
+
+ const rootRoute = new BaseRootRoute({})
+ const parentRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/parent',
+ loader: parentLoader,
+ })
+ const childRoute = new BaseRoute({
+ getParentRoute: () => parentRoute,
+ path: '/child',
+ loader: childLoader,
+ })
+
+ const router = createTestRouter({
+ routeTree: rootRoute.addChildren([
+ parentRoute.addChildren([childRoute]),
+ ]),
+ history: createMemoryHistory(),
+ })
+
+ let preload: ReturnType | undefined
+ const unsubscribe = router.subscribe('onBeforeLoad', (event) => {
+ if (!preload && event.toLocation.pathname === '/parent') {
+ preload = router.preloadRoute({ to: '/parent/child' })
+ }
+ })
+
+ try {
+ const navigation = router.navigate({ to: '/parent' })
+ await vi.advanceTimersByTimeAsync(5)
+
+ expect(parentLoader).toHaveBeenCalledTimes(1)
+ expect(childLoader).toHaveBeenCalledTimes(1)
+ expect(childLoaderSettled).toBe(false)
+
+ parentLoaderPromise.resolve({ auth: 'ok' })
+ await navigation
+ await preload
+
+ expect(parentLoader).toHaveBeenCalledTimes(1)
+ expect(childLoaderSettled).toBe(true)
+ await expect(childLoader.mock.results[0]!.value).resolves.toEqual({
+ auth: 'ok',
+ })
+ } finally {
+ unsubscribe()
+ }
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
test('executes head when loader throws notFound during preload', async () => {
const loader = vi.fn(({ preload }) => {
if (preload) {
@@ -572,6 +759,34 @@ describe('beforeLoad skip or exec', () => {
expect(head).toHaveBeenCalledTimes(1)
})
+ test('executes head when beforeLoad throws notFound during preload', async () => {
+ const beforeLoad = vi.fn(({ preload }) => {
+ if (preload) {
+ throw notFound()
+ }
+ })
+ const head = vi.fn(() => ({ meta: [{ title: 'Foo' }] }))
+
+ const rootRoute = new BaseRootRoute({})
+ const fooRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/foo',
+ beforeLoad,
+ head,
+ notFoundComponent: () => null,
+ })
+
+ const router = createTestRouter({
+ routeTree: rootRoute.addChildren([fooRoute]),
+ history: createMemoryHistory(),
+ })
+
+ await router.preloadRoute({ to: '/foo' })
+
+ expect(beforeLoad).toHaveBeenCalledTimes(1)
+ expect(head).toHaveBeenCalledTimes(1)
+ })
+
test('exec if pending preload (error)', async () => {
const beforeLoad = vi.fn(async ({ preload }) => {
await sleep(100)
@@ -631,6 +846,29 @@ describe('loader skip or exec', () => {
expect(loader).toHaveBeenCalledTimes(0)
})
+ test('does not call shouldReload on initial pending load', async () => {
+ const loader = vi.fn()
+ const shouldReload = vi.fn(() => false)
+
+ const rootRoute = new BaseRootRoute({})
+ const fooRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/foo',
+ loader,
+ shouldReload,
+ })
+
+ const router = createTestRouter({
+ routeTree: rootRoute.addChildren([fooRoute]),
+ history: createMemoryHistory({ initialEntries: ['/foo'] }),
+ })
+
+ await router.load()
+
+ expect(loader).toHaveBeenCalledTimes(1)
+ expect(shouldReload).not.toHaveBeenCalled()
+ })
+
test('exec on regular nav', async () => {
const loader = vi.fn(() => Promise.resolve({ hello: 'world' }))
const router = setup({ loader })
@@ -2268,13 +2506,13 @@ describe('head execution', () => {
describe('beforeLoad notFound parent loader outcomes', () => {
type ThrowAtIndex = 1 | 2 | 3
- type ParentFailure = 'notFound' | 'redirect' | 'error'
+ type ParentFailure = 'notFound' | 'redirect'
type ParentFailureMap = Partial>
type Scenario = {
name: string
throwAtIndex: ThrowAtIndex
parentFailures: ParentFailureMap
- expectedErrorKind: 'notFound' | 'redirect' | 'error'
+ expectedErrorKind: 'notFound' | 'redirect'
expectedErrorSource?: string
expectedErrorRouteIndex?: 0 | 1 | 2 | 3
expectedLoaderMaxIndex: number
@@ -2350,14 +2588,6 @@ describe('head execution', () => {
const routes = [rootRoute, level1Route, level2Route, level3Route] as const
- ;([0, 1, 2] as const).forEach((index) => {
- if (parentFailures[index] === 'error') {
- ;(routes[index].options as any).shouldReload = () => {
- throw new Error(`loader-${index}-error`)
- }
- }
- })
-
const throwRoute = routes[throwAtIndex]!
throwRoute.options.beforeLoad = () => {
const beforeLoadNotFound = beforeLoadNotFoundFactory
@@ -2523,15 +2753,6 @@ describe('head execution', () => {
expectedLoaderMaxIndex: 2,
expectedRenderedHeadMaxIndex: -1,
},
- {
- name: 'propagates regular loader error when mixed with loader notFound in settled loaders',
- throwAtIndex: 3 as const,
- parentFailures: { 1: 'notFound', 2: 'error' } as ParentFailureMap,
- expectedErrorKind: 'error' as const,
- expectedErrorSource: 'loader-2-error',
- expectedLoaderMaxIndex: 1,
- expectedRenderedHeadMaxIndex: -1,
- },
] satisfies Array
test.each(scenarios)('$name', async (scenario) => {
@@ -2568,12 +2789,6 @@ describe('head execution', () => {
return
}
- if (scenario.expectedErrorKind === 'error') {
- expect(error).toBeInstanceOf(Error)
- expect((error as Error).message).toBe(scenario.expectedErrorSource)
- return
- }
-
expect(error).toEqual(
expect.objectContaining({
isNotFound: true,
From 22bf4afcd8dfa21528de6360923ccc7d90bdf9b0 Mon Sep 17 00:00:00 2001
From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com>
Date: Wed, 17 Jun 2026 21:07:36 +0200
Subject: [PATCH 06/13] simplify
---
packages/router-core/src/load-matches.ts | 678 ++++++++++-------------
1 file changed, 305 insertions(+), 373 deletions(-)
diff --git a/packages/router-core/src/load-matches.ts b/packages/router-core/src/load-matches.ts
index 3717806470..4d2d22b66a 100644
--- a/packages/router-core/src/load-matches.ts
+++ b/packages/router-core/src/load-matches.ts
@@ -4,7 +4,6 @@ import { isNotFound } from './not-found'
import { rootRouteId } from './root'
import { isRedirect } from './redirect'
import type { NotFoundError } from './not-found'
-import type { AnyRedirect } from './redirect'
import type { ParsedLocation } from './location'
import type {
AnyRoute,
@@ -28,7 +27,6 @@ type InnerLoadContext = {
firstBadMatchIndex?: number
/** mutable state, scoped to a `loadMatches` call */
rendered?: boolean
- serialError?: unknown
updateMatch: UpdateMatchFn
matches: Array
preload?: boolean
@@ -65,37 +63,30 @@ const joinActivePreloadMatch = async (
waitForLoader: boolean,
): Promise => {
const matchId = inner.matches[index]!.id
- let match = inner.router.getMatch(matchId) ?? inner.matches[index]!
+ let match = inner.router.getMatch(matchId)!
const route = inner.router.looseRoutesById[match.routeId]!
- if (match._nonReactive.beforeLoadPromise) {
- await match._nonReactive.beforeLoadPromise
- match = inner.router.getMatch(matchId) ?? inner.matches[index]!
- } else if (
- route.options.beforeLoad &&
+ const beforeLoadPromise =
+ match._nonReactive.beforeLoadPromise ||
+ (route.options.beforeLoad &&
match.status === 'pending' &&
match.fetchCount === 0
- ) {
- const loadPromise = match._nonReactive.loadPromise
- if (loadPromise?.status === 'pending') {
- await loadPromise
- match = inner.router.getMatch(matchId) ?? inner.matches[index]!
- }
+ ? match._nonReactive.loadPromise
+ : undefined)
+ if (beforeLoadPromise?.status === 'pending') {
+ await beforeLoadPromise
+ match = inner.router.getMatch(matchId) ?? inner.matches[index]!
}
inner.matches[index] = match
let error = match._nonReactive.error || match.error
if (!error && waitForLoader && match.status === 'pending') {
- if (match._nonReactive.loaderPromise) {
- await match._nonReactive.loaderPromise
+ const loaderPromise =
+ match._nonReactive.loaderPromise || match._nonReactive.loadPromise
+ if (loaderPromise?.status === 'pending') {
+ await loaderPromise
match = inner.router.getMatch(matchId) ?? inner.matches[index]!
- } else {
- const loadPromise = match._nonReactive.loadPromise
- if (loadPromise?.status === 'pending') {
- await loadPromise
- match = inner.router.getMatch(matchId) ?? inner.matches[index]!
- }
}
inner.matches[index] = match
error = match._nonReactive.error || match.error
@@ -118,9 +109,10 @@ const buildMatchContext = (
inner: InnerLoadContext,
index: number,
): Record => {
- const context: Record = {
- ...(inner.router.options.context ?? {}),
- }
+ const context: Record = Object.assign(
+ {},
+ inner.router.options.context,
+ )
for (let i = 0; i <= index; i++) {
const match = inner.matches[i]!
Object.assign(context, match.__routeContext, match.__beforeLoadContext)
@@ -141,12 +133,6 @@ const patchMatch = (
})
}
-const getNavigate = (inner: InnerLoadContext) => (opts: any) =>
- inner.router.navigate({
- ...opts,
- _fromLocation: inner.location,
- })
-
const settleBeforeLoadPromise = (match: AnyRouteMatch): void => {
match._nonReactive.beforeLoadPromise?.resolve()
match._nonReactive.beforeLoadPromise = undefined
@@ -203,18 +189,17 @@ const getNotFoundBoundaryIndex = (
return requestedRouteId ? startIndex : 0
}
-const handleRedirect = (
+const handleRedirectOrNotFound = (
inner: InnerLoadContext,
- match: AnyRouteMatch | undefined,
- redirect: AnyRedirect,
+ match: AnyRouteMatch,
+ err: unknown,
): void => {
- if (redirect.redirectHandled && !redirect.options.reloadDocument) {
- throw redirect
- }
+ if (isRedirect(err)) {
+ if (err.redirectHandled && !err.options.reloadDocument) {
+ throw err
+ }
- // in case of a redirecting match during preload, the match does not exist
- if (match) {
- match._nonReactive.error = redirect
+ match._nonReactive.error = err
if (inner.preload || inner.router.stores.cachedMatchStores.has(match.id)) {
clearMatchPromises(match)
@@ -230,50 +215,32 @@ const handleRedirect = (
isFetching: false as const,
})
}
- }
- inner.rendered = true
- redirect.options._fromLocation = inner.location
- redirect.redirectHandled = true
- throw inner.router.resolveRedirect(redirect)
-}
+ inner.rendered = true
+ err.options._fromLocation = inner.location
+ err.redirectHandled = true
+ throw inner.router.resolveRedirect(err)
+ }
-const handleNotFound = (
- inner: InnerLoadContext,
- match: AnyRouteMatch | undefined,
- notFound: NotFoundError,
-): void => {
- if (match) {
- match._nonReactive.error = notFound
+ if (isNotFound(err)) {
+ match._nonReactive.error = err
clearMatchPromises(match)
- if (!notFound.routeId) {
+ if (!err.routeId) {
// Stamp the throwing match's routeId so that the finalization step in
// loadMatches knows where the notFound originated. The actual boundary
// resolution is deferred until firstBadMatchIndex is stable.
- notFound.routeId = match.routeId
+ err.routeId = match.routeId
}
patchMatch(inner, match.id, {
status: 'notFound',
- error: notFound,
+ error: err,
isFetching: false,
_forcePending: undefined,
})
- }
-
- throw notFound
-}
-const handleRedirectOrNotFound = (
- inner: InnerLoadContext,
- match: AnyRouteMatch | undefined,
- err: unknown,
-): void => {
- if (isRedirect(err)) {
- handleRedirect(inner, match, err)
- } else if (isNotFound(err)) {
- handleNotFound(inner, match, err)
+ throw err
}
}
@@ -281,16 +248,12 @@ const shouldSkipMatchLoad = (
inner: InnerLoadContext,
match: AnyRouteMatch,
): boolean => {
- // upon hydration, we skip the loader if the match has been dehydrated on the server
- if (!(isServer ?? inner.router.isServer) && match._nonReactive.dehydrated) {
- return true
- }
-
- if ((isServer ?? inner.router.isServer) && match.ssr === false) {
- return true
+ if (isServer ?? inner.router.isServer) {
+ return match.ssr === false
}
- return false
+ // upon hydration, we skip the loader if the match has been dehydrated on the server
+ return !!match._nonReactive.dehydrated
}
const handleSerialError = (
@@ -312,10 +275,8 @@ const handleSerialError = (
inner.firstBadMatchIndex ??= index
match.__beforeLoadContext = undefined
- const currentMatch = inner.router.getMatch(matchId)
- if (currentMatch) {
- currentMatch.__beforeLoadContext = undefined
- }
+ const currentMatch = inner.router.getMatch(matchId)!
+ currentMatch.__beforeLoadContext = undefined
handleRedirectOrNotFound(inner, currentMatch, err)
@@ -344,10 +305,6 @@ const handleSerialError = (
if (updatedMatch) {
clearMatchPromises(updatedMatch)
}
-
- if (!inner.preload) {
- inner.serialError ??= err
- }
}
const isBeforeLoadSsr = (
@@ -472,15 +429,9 @@ const executeBeforeLoad = (
prevLoadPromise = undefined
})
- const { paramsError, searchError } = match
-
- if (paramsError) {
- handleSerialError(inner, index, paramsError)
- return
- }
-
- if (searchError) {
- handleSerialError(inner, index, searchError)
+ const serialError = match.paramsError || match.searchError
+ if (serialError) {
+ handleSerialError(inner, index, serialError)
return
}
@@ -505,20 +456,24 @@ const executeBeforeLoad = (
}))
}
- // if there is no `beforeLoad` option, just mark as pending and resolve.
- // The undefined beforeLoad context is still committed here to clear any
- // stale context from a previous load generation of the same match.
- if (!beforeLoad) {
- inner.matches[index]!.__beforeLoadContext = undefined
+ const commitBeforeLoad = (beforeLoadContext: any) => {
+ inner.matches[index]!.__beforeLoadContext = beforeLoadContext
inner.router.batch(() => {
pending()
patchMatch(inner, matchId, {
isFetching: false as const,
- __beforeLoadContext: undefined,
+ __beforeLoadContext: beforeLoadContext,
context: buildMatchContext(inner, index),
})
})
settleBeforeLoadPromise(match)
+ }
+
+ // if there is no `beforeLoad` option, just mark as pending and resolve.
+ // The undefined beforeLoad context is still committed here to clear any
+ // stale context from a previous load generation of the same match.
+ if (!beforeLoad) {
+ commitBeforeLoad(undefined)
return
}
@@ -539,27 +494,17 @@ const executeBeforeLoad = (
return
}
- inner.matches[index]!.__beforeLoadContext = beforeLoadContext
-
- inner.router.batch(() => {
- pending()
- patchMatch(inner, matchId, {
- isFetching: false as const,
- __beforeLoadContext: beforeLoadContext,
- context: buildMatchContext(inner, index),
- })
- })
- settleBeforeLoadPromise(match)
+ commitBeforeLoad(beforeLoadContext)
}
match._nonReactive.beforeLoadPromise = beforeLoadPromise
// Build context from all parent matches, excluding current match's __beforeLoadContext
// (since we're about to execute beforeLoad for this match)
- const context = {
- ...buildMatchContext(inner, index - 1),
- ...match.__routeContext,
- }
+ const context = Object.assign(
+ buildMatchContext(inner, index - 1),
+ match.__routeContext,
+ )
const { search, params, cause } = match
const preload = resolvePreload(inner, matchId)
const beforeLoadFnContext: BeforeLoadContextOptions<
@@ -579,7 +524,11 @@ const executeBeforeLoad = (
preload,
context,
location: inner.location,
- navigate: getNavigate(inner),
+ navigate: (opts: any) =>
+ inner.router.navigate({
+ ...opts,
+ _fromLocation: inner.location,
+ }),
buildLocation: inner.router.buildLocation,
cause: preload ? 'preload' : cause,
matches: inner.matches,
@@ -629,19 +578,20 @@ const handleBeforeLoad = (
// If we are in the middle of a load, either of these will be present
// (not to be confused with `loadPromise`, which is always defined)
const pendingBeforeLoad = existingMatch._nonReactive.beforeLoadPromise
- if (pendingBeforeLoad || existingMatch._nonReactive.loaderPromise) {
+ if (pendingBeforeLoad) {
setupPendingTimeout(inner, matchId, route, existingMatch)
+ return pendingBeforeLoad.then(() => {
+ const match = inner.router.getMatch(matchId)
+ if (!match || shouldSkipMatchLoad(inner, match)) {
+ return
+ }
- if (pendingBeforeLoad) {
- return pendingBeforeLoad.then(() => {
- const match = inner.router.getMatch(matchId)
- if (!match || shouldSkipMatchLoad(inner, match)) {
- return
- }
+ return executeBeforeLoad(inner, matchId, index, route)
+ })
+ }
- return executeBeforeLoad(inner, matchId, index, route)
- })
- }
+ if (existingMatch._nonReactive.loaderPromise) {
+ setupPendingTimeout(inner, matchId, route, existingMatch)
}
return executeBeforeLoad(inner, matchId, index, route)
@@ -678,7 +628,11 @@ const getLoaderContext = (
abortController,
context,
location: inner.location,
- navigate: getNavigate(inner),
+ navigate: (opts: any) =>
+ inner.router.navigate({
+ ...opts,
+ _fromLocation: inner.location,
+ }),
cause: preload ? 'preload' : cause,
route,
...inner.router.options.additionalContext,
@@ -692,160 +646,142 @@ const runLoader = async (
index: number,
route: AnyRoute,
): Promise => {
- let getCurrentMatch: (() => AnyRouteMatch | undefined) | undefined
+ // If the Matches component rendered the pending component and needs to show
+ // it for a minimum duration, we'll wait for it to resolve before committing
+ // to the match and resolving the loadPromise.
+ const match = inner.router.getMatch(matchId)!
+ const loaderBucket = match._nonReactive
+ const loaderPromise = loaderBucket.loaderPromise
+ const isCurrentLoader = () => loaderBucket.loaderPromise === loaderPromise
+ const getCurrentMatch = () => {
+ return isCurrentLoader() ? inner.router.getMatch(matchId) : undefined
+ }
+ // Actually run the loader and handle the result
try {
- // If the Matches component rendered
- // the pending component and needs to show it for
- // a minimum duration, we''ll wait for it to resolve
- // before committing to the match and resolving
- // the loadPromise
-
- const match = inner.router.getMatch(matchId)!
- const loaderBucket = match._nonReactive
- const loaderPromise = loaderBucket.loaderPromise
- const isCurrentLoader = () => loaderBucket.loaderPromise === loaderPromise
- getCurrentMatch = () => {
- return isCurrentLoader() ? inner.router.getMatch(matchId) : undefined
+ if (!(isServer ?? inner.router.isServer) || match.ssr === true) {
+ loadRouteChunk(route)
}
- // Actually run the loader and handle the result
- try {
- if (!(isServer ?? inner.router.isServer) || match.ssr === true) {
- loadRouteChunk(route)
+ // Kick off the loader!
+ const routeLoader = route.options.loader
+ const loader =
+ typeof routeLoader === 'function' ? routeLoader : routeLoader?.handler
+ const loaderResult = loader?.(
+ getLoaderContext(inner, matchPromises, matchId, index, route),
+ )
+ const loaderResultIsPromise = isPromise(loaderResult)
+
+ if (
+ loaderResultIsPromise ||
+ route._lazyPromise ||
+ route._componentsPromise ||
+ route.options.head ||
+ route.options.scripts ||
+ route.options.headers ||
+ loaderBucket.minPendingPromise
+ ) {
+ patchMatch(inner, matchId, {
+ isFetching: 'loader',
+ })
+ }
+
+ if (loader) {
+ const loaderData = loaderResultIsPromise
+ ? await loaderResult
+ : loaderResult
+
+ if (!getCurrentMatch()) {
+ return
}
- // Kick off the loader!
- const routeLoader = route.options.loader
- const loader =
- typeof routeLoader === 'function' ? routeLoader : routeLoader?.handler
- const loaderResult = loader?.(
- getLoaderContext(inner, matchPromises, matchId, index, route),
- )
- const loaderResultIsPromise = isPromise(loaderResult)
-
- const willLoadSomething = !!(
- loaderResultIsPromise ||
- route._lazyPromise ||
- route._componentsPromise ||
- route.options.head ||
- route.options.scripts ||
- route.options.headers ||
- loaderBucket.minPendingPromise
- )
+ if (isRedirect(loaderData) || isNotFound(loaderData)) {
+ throw loaderData
+ }
- if (willLoadSomething) {
+ if (loaderData !== undefined) {
patchMatch(inner, matchId, {
- isFetching: 'loader',
+ loaderData,
})
}
+ }
- if (loader) {
- const loaderData = loaderResultIsPromise
- ? await loaderResult
- : loaderResult
-
- if (!getCurrentMatch()) {
- return
- }
-
- if (isRedirect(loaderData) || isNotFound(loaderData)) {
- throw loaderData
- }
+ // Lazy option can modify the route options,
+ // so we need to wait for it to resolve before
+ // we can use the options
+ if (route._lazyPromise) await route._lazyPromise
+ const pendingPromise = loaderBucket.minPendingPromise
+ if (pendingPromise) await pendingPromise
+
+ // Last but not least, wait for the components
+ // to be preloaded before we resolve the match
+ if (route._componentsPromise) await route._componentsPromise
+ if (!isCurrentLoader()) {
+ return
+ }
+ patchMatch(inner, matchId, {
+ error: undefined,
+ status: 'success',
+ isFetching: false as const,
+ updatedAt: Date.now(),
+ })
+ } catch (e) {
+ let error = e
- if (loaderData !== undefined) {
- patchMatch(inner, matchId, {
- loaderData,
- })
- }
- }
+ if (isRedirect(e) && e.redirectHandled) {
+ throw e
+ }
- // Lazy option can modify the route options,
- // so we need to wait for it to resolve before
- // we can use the options
- if (route._lazyPromise) await route._lazyPromise
- const pendingPromise = loaderBucket.minPendingPromise
- if (pendingPromise) await pendingPromise
-
- // Last but not least, wait for the components
- // to be preloaded before we resolve the match
- if (route._componentsPromise) await route._componentsPromise
- if (!isCurrentLoader()) {
+ if ((error as any)?.name === 'AbortError') {
+ if (match.abortController.signal.aborted) {
return
}
- patchMatch(inner, matchId, {
- error: undefined,
- status: 'success',
- isFetching: false as const,
- updatedAt: Date.now(),
- })
- } catch (e) {
- let error = e
-
- if (isRedirect(e) && e.redirectHandled) {
- throw e
- }
-
- if ((error as any)?.name === 'AbortError') {
- if (match.abortController.signal.aborted) {
- return
- }
- if (!getCurrentMatch()) {
- return
- }
- // a softly aborted pending match keeps its previous data and is
- // committed as success
- inner.updateMatch(matchId, (prev) => ({
- ...prev,
- status: prev.status === 'pending' ? 'success' : prev.status,
- isFetching: false,
- }))
+ if (!getCurrentMatch()) {
return
}
+ // a softly aborted pending match keeps its previous data and is
+ // committed as success
+ inner.updateMatch(matchId, (prev) => ({
+ ...prev,
+ status: prev.status === 'pending' ? 'success' : prev.status,
+ isFetching: false,
+ }))
+ return
+ }
+
+ const pendingPromise = loaderBucket.minPendingPromise
+ if (pendingPromise) await pendingPromise
+ let currentMatch = getCurrentMatch()
+ if (!currentMatch) {
+ return
+ }
- const pendingPromise = loaderBucket.minPendingPromise
- if (pendingPromise) await pendingPromise
- let currentMatch = getCurrentMatch()
+ if (isNotFound(e)) {
+ await (route.options.notFoundComponent as any)?.preload?.()
+ currentMatch = getCurrentMatch()
if (!currentMatch) {
return
}
+ }
- if (isNotFound(e)) {
- await (route.options.notFoundComponent as any)?.preload?.()
- currentMatch = getCurrentMatch()
- if (!currentMatch) {
- return
- }
- }
-
- handleRedirectOrNotFound(inner, currentMatch, e)
-
- try {
- route.options.onError?.(e)
- } catch (onErrorError) {
- error = onErrorError
- handleRedirectOrNotFound(inner, currentMatch, onErrorError)
- }
- await loadRouteChunk(route, ['errorComponent'])
- if (!isCurrentLoader()) {
- return
- }
+ handleRedirectOrNotFound(inner, currentMatch, e)
- patchMatch(inner, matchId, {
- error,
- status: 'error',
- isFetching: false,
- })
- }
- } catch (err) {
- if ((isRedirect(err) && err.redirectHandled) || isNotFound(err)) {
- throw err
+ try {
+ route.options.onError?.(e)
+ } catch (onErrorError) {
+ error = onErrorError
+ handleRedirectOrNotFound(inner, currentMatch, onErrorError)
}
- const match = getCurrentMatch?.()
- if (!match) {
+ await loadRouteChunk(route, ['errorComponent'])
+ if (!isCurrentLoader()) {
return
}
- handleRedirectOrNotFound(inner, match, err)
+
+ patchMatch(inner, matchId, {
+ error,
+ status: 'error',
+ isFetching: false,
+ })
}
}
@@ -856,92 +792,11 @@ const loadRouteMatch = async (
): Promise => {
const { id: matchId, routeId } = inner.matches[index]!
const route = inner.router.looseRoutesById[routeId]!
- const routeLoader = route.options.loader
- const shouldReloadInBackground =
- ((typeof routeLoader === 'function'
- ? undefined
- : routeLoader?.staleReloadMode) ??
- inner.router.options.defaultStaleReloadMode) !== 'blocking'
// becomes true when this pass leaves the loader running detached in the
// background, in which case finalization is deferred to that detached run
let loaderIsRunningAsync = false
let loaderGeneration: AnyRouteMatch['_nonReactive']['loaderPromise']
- let loaderBucket: AnyRouteMatch['_nonReactive'] | undefined
-
- /**
- * Decides how the loader runs for this pass and executes it.
- */
- const runLoaderPhase = (
- preload: boolean,
- prevMatch: AnyRouteMatch,
- previousRouteMatchId: string | undefined,
- match: AnyRouteMatch,
- ): void | Promise => {
- if (preload && route.options.preload === false) {
- // Do nothing
- return
- }
-
- const { status, invalid } = match
- let loaderShouldRunAsync = false
-
- if (status === 'success') {
- const age = Date.now() - prevMatch.updatedAt
- const staleAge = preload
- ? (route.options.preloadStaleTime ??
- inner.router.options.defaultPreloadStaleTime ??
- 30_000) // 30 seconds for preloads by default
- : (route.options.staleTime ??
- inner.router.options.defaultStaleTime ??
- 0)
- const shouldReloadOption = route.options.shouldReload
- const shouldReload =
- typeof shouldReloadOption === 'function'
- ? shouldReloadOption(
- getLoaderContext(inner, matchPromises, matchId, index, route),
- )
- : shouldReloadOption
- const staleMatchShouldReload =
- age >= staleAge &&
- (!!inner.forceStaleReload ||
- match.cause === 'enter' ||
- (previousRouteMatchId !== undefined &&
- previousRouteMatchId !== match.id))
-
- loaderShouldRunAsync = invalid || (shouldReload ?? staleMatchShouldReload)
- }
-
- if (loaderShouldRunAsync && !inner.sync && shouldReloadInBackground) {
- // stale-while-revalidate: leave the loader running detached
- loaderIsRunningAsync = true
- const backgroundGeneration = match._nonReactive.loaderPromise
- if (match.invalid !== false) {
- patchMatch(inner, matchId, { invalid: false })
- }
- ;(async () => {
- try {
- await runLoader(inner, matchPromises, matchId, index, route)
- } catch (err) {
- if (isRedirect(err)) {
- await inner.router.navigate(err.options)
- return
- }
- }
- const latestMatch = inner.router.getMatch(matchId)
- if (
- latestMatch &&
- latestMatch._nonReactive.loaderPromise === backgroundGeneration
- ) {
- settleLoadPromises(latestMatch)
- }
- })()
- return
- }
-
- if (status !== 'success' || loaderShouldRunAsync) {
- return runLoader(inner, matchPromises, matchId, index, route)
- }
- }
+ let matchToLoad: AnyRouteMatch | undefined
if (isActivePreloadMatch(inner, matchId)) {
return await joinActivePreloadMatch(inner, index, true)
@@ -965,22 +820,17 @@ const loadRouteMatch = async (
return inner.router.getMatch(matchId)!
}
} else {
- const activeIdAtIndex = inner.router.stores.matchesId.get()[index]
- const activeAtIndex =
- (activeIdAtIndex &&
- inner.router.stores.matchStores.get(activeIdAtIndex)) ||
- null
- const previousRouteMatchId =
- activeAtIndex?.routeId === routeId
- ? activeIdAtIndex
- : inner.router.stores.matches.get().find((d) => d.routeId === routeId)
- ?.id
+ const routeLoader = route.options.loader
+ const shouldReloadInBackground =
+ ((typeof routeLoader === 'function'
+ ? undefined
+ : routeLoader?.staleReloadMode) ??
+ inner.router.options.defaultStaleReloadMode) !== 'blocking'
const preload = resolvePreload(inner, matchId)
// there is a loaderPromise, so we are in the middle of a load
if (prevMatch._nonReactive.loaderPromise) {
- loaderBucket = prevMatch._nonReactive
- loaderGeneration = loaderBucket.loaderPromise
+ loaderGeneration = prevMatch._nonReactive.loaderPromise
// do not block if we already have stale data we can show
// but only if the ongoing load is not a preload since error handling is different for preloads
// and we don't want to swallow errors
@@ -1008,25 +858,97 @@ const loadRouteMatch = async (
handleRedirectOrNotFound(inner, match, error)
}
- if (match.status === 'pending') {
- await runLoaderPhase(preload, prevMatch, previousRouteMatchId, match)
- }
+ matchToLoad = match.status === 'pending' ? match : undefined
}
} else {
- const match = inner.router.getMatch(matchId)!
+ const match = prevMatch
// a new load generation starts: any settle error stored by a previous
// generation no longer applies to this one
match._nonReactive.error = undefined
match._nonReactive.loaderPromise = createControlledPromise()
- loaderBucket = match._nonReactive
- loaderGeneration = loaderBucket.loaderPromise
+ loaderGeneration = match._nonReactive.loaderPromise
if (preload !== match.preload) {
patchMatch(inner, matchId, {
preload,
})
}
- await runLoaderPhase(preload, prevMatch, previousRouteMatchId, match)
+ matchToLoad = match
+ }
+
+ if (matchToLoad && !(preload && route.options.preload === false)) {
+ const { status, invalid } = matchToLoad
+ let loaderShouldRun = status !== 'success'
+
+ if (!loaderShouldRun) {
+ const activeIdAtIndex = inner.router.stores.matchesId.get()[index]
+ const activeAtIndex =
+ (activeIdAtIndex &&
+ inner.router.stores.matchStores.get(activeIdAtIndex)) ||
+ null
+ const previousRouteMatchId =
+ activeAtIndex?.routeId === routeId
+ ? activeIdAtIndex
+ : inner.router.stores.matches
+ .get()
+ .find((d) => d.routeId === routeId)?.id
+ const age = Date.now() - prevMatch.updatedAt
+ const staleAge = preload
+ ? (route.options.preloadStaleTime ??
+ inner.router.options.defaultPreloadStaleTime ??
+ 30_000) // 30 seconds for preloads by default
+ : (route.options.staleTime ??
+ inner.router.options.defaultStaleTime ??
+ 0)
+ const shouldReloadOption = route.options.shouldReload
+ const shouldReload =
+ typeof shouldReloadOption === 'function'
+ ? shouldReloadOption(
+ getLoaderContext(inner, matchPromises, matchId, index, route),
+ )
+ : shouldReloadOption
+ const staleMatchShouldReload =
+ age >= staleAge &&
+ (!!inner.forceStaleReload ||
+ matchToLoad.cause === 'enter' ||
+ (previousRouteMatchId !== undefined &&
+ previousRouteMatchId !== matchToLoad.id))
+
+ loaderShouldRun = invalid || (shouldReload ?? staleMatchShouldReload)
+ }
+
+ if (
+ loaderShouldRun &&
+ status === 'success' &&
+ !inner.sync &&
+ shouldReloadInBackground
+ ) {
+ // stale-while-revalidate: leave the loader running detached
+ loaderIsRunningAsync = true
+ const backgroundGeneration = matchToLoad._nonReactive.loaderPromise
+ if (matchToLoad.invalid !== false) {
+ patchMatch(inner, matchId, { invalid: false })
+ }
+ ;(async () => {
+ try {
+ await runLoader(inner, matchPromises, matchId, index, route)
+ } catch (err) {
+ if (isRedirect(err)) {
+ await inner.router.navigate(err.options)
+ return
+ }
+ }
+ const latestMatch = inner.router.getMatch(matchId)
+ if (
+ latestMatch &&
+ latestMatch._nonReactive.loaderPromise === backgroundGeneration
+ ) {
+ settleLoadPromises(latestMatch)
+ }
+ })()
+ } else if (loaderShouldRun) {
+ await runLoader(inner, matchPromises, matchId, index, route)
+ }
}
}
@@ -1034,7 +956,10 @@ const loadRouteMatch = async (
if (!match) {
return inner.matches[index]!
}
- if (loaderGeneration && loaderBucket?.loaderPromise !== loaderGeneration) {
+ if (
+ loaderGeneration &&
+ match._nonReactive.loaderPromise !== loaderGeneration
+ ) {
return inner.matches[index]!
}
@@ -1088,13 +1013,10 @@ export async function loadMatches(arg: {
const beforeLoad = handleBeforeLoad(inner, i)
if (isPromise(beforeLoad)) await beforeLoad
} catch (err) {
- if (isRedirect(err)) {
- throw err
- }
if (isNotFound(err)) {
beforeLoadNotFound = err
- } else {
- if (!inner.preload) throw err
+ } else if (isRedirect(err) || !inner.preload) {
+ throw err
}
break
}
@@ -1209,7 +1131,11 @@ export async function loadMatches(arg: {
// When a serial error occurred (e.g. beforeLoad threw a regular Error),
// the erroring route's lazy chunk wasn't loaded because loaders were skipped.
// We need to load it so the code-split errorComponent is available for rendering.
- if (inner.serialError && inner.firstBadMatchIndex !== undefined) {
+ if (
+ !notFoundToThrow &&
+ !inner.preload &&
+ inner.firstBadMatchIndex !== undefined
+ ) {
const errorRoute =
inner.router.looseRoutesById[
inner.matches[inner.firstBadMatchIndex]!.routeId
@@ -1269,14 +1195,23 @@ export async function loadMatches(arg: {
throw notFoundToThrow
}
- if (inner.serialError && !inner.onReady) {
- throw inner.serialError
+ if (
+ !inner.preload &&
+ !inner.onReady &&
+ inner.firstBadMatchIndex !== undefined
+ ) {
+ const errorMatch =
+ inner.router.getMatch(inner.matches[inner.firstBadMatchIndex]!.id) ??
+ inner.matches[inner.firstBadMatchIndex]!
+ if (errorMatch.status === 'error') {
+ throw errorMatch.error
+ }
}
return inner.matches
}
-export type RouteComponentType =
+type RouteComponentType =
| 'component'
| 'errorComponent'
| 'pendingComponent'
@@ -1356,15 +1291,12 @@ function makeMaybe(
}
export function routeNeedsPreload(route: AnyRoute) {
- for (const componentType of componentTypes) {
- if ((route.options[componentType] as any)?.preload) {
- return true
- }
- }
- return false
+ return componentTypes.some(
+ (componentType) => (route.options[componentType] as any)?.preload,
+ )
}
-export const componentTypes: Array = [
+const componentTypes: Array = [
'component',
'errorComponent',
'pendingComponent',
From 7b7d52ec9dc76a62460d4636f9d8c42716d73d6b Mon Sep 17 00:00:00 2001
From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com>
Date: Wed, 17 Jun 2026 21:07:36 +0200
Subject: [PATCH 07/13] simplify
---
packages/router-core/src/load-matches.ts | 92 ++++++++++++++++--------
packages/router-core/tests/load.test.ts | 52 ++++++++++++++
2 files changed, 116 insertions(+), 28 deletions(-)
diff --git a/packages/router-core/src/load-matches.ts b/packages/router-core/src/load-matches.ts
index 4d2d22b66a..c8562b8f7f 100644
--- a/packages/router-core/src/load-matches.ts
+++ b/packages/router-core/src/load-matches.ts
@@ -43,17 +43,50 @@ const triggerOnReady = (inner: InnerLoadContext): void | Promise => {
}
const resolvePreload = (inner: InnerLoadContext, matchId: string): boolean => {
- return !!inner.preload && !inner.router.stores.matchStores.has(matchId)
+ return !!inner.preload && !isActiveMatch(inner, matchId)
+}
+
+const isActiveMatch = (
+ inner: InnerLoadContext,
+ matchId: string,
+): boolean => {
+ return (
+ inner.router.stores.matchStores.has(matchId) ||
+ inner.router.stores.pendingMatchStores.has(matchId)
+ )
}
const isActivePreloadMatch = (
inner: InnerLoadContext,
matchId: string,
): boolean => {
- return !!(
- inner.preload &&
- (inner.router.stores.matchStores.has(matchId) ||
- inner.router.stores.pendingMatchStores.has(matchId))
+ return !!inner.preload && isActiveMatch(inner, matchId)
+}
+
+const getActiveMatch = (
+ inner: InnerLoadContext,
+ matchId: string,
+): AnyRouteMatch | undefined => {
+ return (
+ inner.router.stores.pendingMatchStores.get(matchId)?.get() ??
+ inner.router.stores.matchStores.get(matchId)?.get()
+ )
+}
+
+const getLoadMatch = (
+ inner: InnerLoadContext,
+ matchId: string,
+): AnyRouteMatch | undefined => {
+ const stores = inner.router.stores
+ if (inner.preload) {
+ return (
+ stores.cachedMatchStores.get(matchId)?.get() ??
+ getActiveMatch(inner, matchId)
+ )
+ }
+ return (
+ getActiveMatch(inner, matchId) ??
+ stores.cachedMatchStores.get(matchId)?.get()
)
}
@@ -63,7 +96,7 @@ const joinActivePreloadMatch = async (
waitForLoader: boolean,
): Promise => {
const matchId = inner.matches[index]!.id
- let match = inner.router.getMatch(matchId)!
+ let match = getActiveMatch(inner, matchId)!
const route = inner.router.looseRoutesById[match.routeId]!
const beforeLoadPromise =
@@ -75,7 +108,7 @@ const joinActivePreloadMatch = async (
: undefined)
if (beforeLoadPromise?.status === 'pending') {
await beforeLoadPromise
- match = inner.router.getMatch(matchId) ?? inner.matches[index]!
+ match = getActiveMatch(inner, matchId) ?? inner.matches[index]!
}
inner.matches[index] = match
@@ -86,7 +119,7 @@ const joinActivePreloadMatch = async (
match._nonReactive.loaderPromise || match._nonReactive.loadPromise
if (loaderPromise?.status === 'pending') {
await loaderPromise
- match = inner.router.getMatch(matchId) ?? inner.matches[index]!
+ match = getActiveMatch(inner, matchId) ?? inner.matches[index]!
}
inner.matches[index] = match
error = match._nonReactive.error || match.error
@@ -275,7 +308,7 @@ const handleSerialError = (
inner.firstBadMatchIndex ??= index
match.__beforeLoadContext = undefined
- const currentMatch = inner.router.getMatch(matchId)!
+ const currentMatch = getLoadMatch(inner, matchId)!
currentMatch.__beforeLoadContext = undefined
handleRedirectOrNotFound(inner, currentMatch, err)
@@ -301,7 +334,7 @@ const handleSerialError = (
abortController: new AbortController(),
})
- const updatedMatch = inner.router.getMatch(matchId)
+ const updatedMatch = getLoadMatch(inner, matchId)
if (updatedMatch) {
clearMatchPromises(updatedMatch)
}
@@ -313,10 +346,10 @@ const isBeforeLoadSsr = (
index: number,
route: AnyRoute,
): void | Promise => {
- const existingMatch = inner.router.getMatch(matchId)!
+ const existingMatch = getLoadMatch(inner, matchId)!
const parentMatchId = inner.matches[index - 1]?.id
const parentMatch = parentMatchId
- ? inner.router.getMatch(parentMatchId)!
+ ? getLoadMatch(inner, parentMatchId)!
: undefined
// in SPA mode, only SSR the root route
@@ -420,7 +453,7 @@ const executeBeforeLoad = (
index: number,
route: AnyRoute,
): void | Promise => {
- const match = inner.router.getMatch(matchId)!
+ const match = getLoadMatch(inner, matchId)!
// explicitly capture the previous loadPromise
let prevLoadPromise = match._nonReactive.loadPromise
@@ -479,7 +512,7 @@ const executeBeforeLoad = (
const beforeLoadPromise = createControlledPromise()
const isCurrentBeforeLoad = () =>
- inner.router.getMatch(matchId)?._nonReactive.beforeLoadPromise ===
+ getLoadMatch(inner, matchId)?._nonReactive.beforeLoadPromise ===
beforeLoadPromise
// commits the result of the beforeLoad phase and settles its promise
@@ -570,7 +603,7 @@ const handleBeforeLoad = (
}
const queueExecution = () => {
- const existingMatch = inner.router.getMatch(matchId)
+ const existingMatch = getLoadMatch(inner, matchId)
if (!existingMatch || shouldSkipMatchLoad(inner, existingMatch)) {
return
}
@@ -581,7 +614,7 @@ const handleBeforeLoad = (
if (pendingBeforeLoad) {
setupPendingTimeout(inner, matchId, route, existingMatch)
return pendingBeforeLoad.then(() => {
- const match = inner.router.getMatch(matchId)
+ const match = getLoadMatch(inner, matchId)
if (!match || shouldSkipMatchLoad(inner, match)) {
return
}
@@ -614,7 +647,7 @@ const getLoaderContext = (
): LoaderFnContext => {
const parentMatchPromise = matchPromises[index - 1] as any
const { params, loaderDeps, abortController, cause } =
- inner.router.getMatch(matchId)!
+ getLoadMatch(inner, matchId)!
const context = buildMatchContext(inner, index)
@@ -649,12 +682,12 @@ const runLoader = async (
// If the Matches component rendered the pending component and needs to show
// it for a minimum duration, we'll wait for it to resolve before committing
// to the match and resolving the loadPromise.
- const match = inner.router.getMatch(matchId)!
+ const match = getLoadMatch(inner, matchId)!
const loaderBucket = match._nonReactive
const loaderPromise = loaderBucket.loaderPromise
const isCurrentLoader = () => loaderBucket.loaderPromise === loaderPromise
const getCurrentMatch = () => {
- return isCurrentLoader() ? inner.router.getMatch(matchId) : undefined
+ return isCurrentLoader() ? getLoadMatch(inner, matchId) : undefined
}
// Actually run the loader and handle the result
@@ -802,7 +835,7 @@ const loadRouteMatch = async (
return await joinActivePreloadMatch(inner, index, true)
}
- const prevMatch = inner.router.getMatch(matchId)
+ const prevMatch = getLoadMatch(inner, matchId)
if (!prevMatch) {
// in case of a redirecting match during preload, the match does not exist
return inner.matches[index]!
@@ -817,7 +850,7 @@ const loadRouteMatch = async (
})
if (isServer ?? inner.router.isServer) {
- return inner.router.getMatch(matchId)!
+ return getLoadMatch(inner, matchId)!
}
} else {
const routeLoader = route.options.loader
@@ -848,10 +881,10 @@ const loadRouteMatch = async (
invalid: false,
})
}
- return inner.router.getMatch(matchId)!
+ return getLoadMatch(inner, matchId)!
}
await loaderGeneration
- const match = inner.router.getMatch(matchId)
+ const match = getLoadMatch(inner, matchId)
if (match) {
const error = match._nonReactive.error || match.error
if (error) {
@@ -938,7 +971,7 @@ const loadRouteMatch = async (
return
}
}
- const latestMatch = inner.router.getMatch(matchId)
+ const latestMatch = getLoadMatch(inner, matchId)
if (
latestMatch &&
latestMatch._nonReactive.loaderPromise === backgroundGeneration
@@ -952,7 +985,7 @@ const loadRouteMatch = async (
}
}
- let match = inner.router.getMatch(matchId)
+ let match = getLoadMatch(inner, matchId)
if (!match) {
return inner.matches[index]!
}
@@ -973,7 +1006,7 @@ const loadRouteMatch = async (
isFetching: nextIsFetching,
invalid: false,
})
- match = inner.router.getMatch(matchId)!
+ match = getLoadMatch(inner, matchId)!
}
if (!loaderIsRunningAsync) {
@@ -1150,9 +1183,12 @@ export async function loadMatches(arg: {
const { id: matchId, routeId } = match
const route = inner.router.looseRoutesById[routeId]!
const routeOptions = route.options
+ if (isActivePreloadMatch(inner, matchId)) {
+ continue
+ }
try {
const headMatch =
- inner.router.getMatch(matchId) ?? (inner.preload && match)
+ getLoadMatch(inner, matchId) ?? (inner.preload && match)
if (
headMatch &&
(routeOptions.head || routeOptions.scripts || routeOptions.headers)
@@ -1201,7 +1237,7 @@ export async function loadMatches(arg: {
inner.firstBadMatchIndex !== undefined
) {
const errorMatch =
- inner.router.getMatch(inner.matches[inner.firstBadMatchIndex]!.id) ??
+ getLoadMatch(inner, inner.matches[inner.firstBadMatchIndex]!.id) ??
inner.matches[inner.firstBadMatchIndex]!
if (errorMatch.status === 'error') {
throw errorMatch.error
diff --git a/packages/router-core/tests/load.test.ts b/packages/router-core/tests/load.test.ts
index d41603d642..41c9f63c0c 100644
--- a/packages/router-core/tests/load.test.ts
+++ b/packages/router-core/tests/load.test.ts
@@ -869,6 +869,58 @@ describe('loader skip or exec', () => {
expect(shouldReload).not.toHaveBeenCalled()
})
+ test('active preload joins active match instead of cached duplicate with same id', async () => {
+ const loader = vi.fn(() => ({ source: 'active' }))
+ const router = setup({ loader })
+
+ await router.navigate({ to: '/foo' })
+
+ const activeMatch = router.state.matches.find((match) =>
+ match.id.endsWith('/foo'),
+ )!
+
+ router.stores.setCached([
+ ...router.stores.cachedMatches.get(),
+ {
+ ...activeMatch,
+ loaderData: { source: 'cached' },
+ preload: true,
+ },
+ ])
+
+ const matches = await router.preloadRoute({ to: '/foo' })
+ const preloadedMatch = matches?.find((match) => match.id === activeMatch.id)
+
+ expect(loader).toHaveBeenCalledTimes(1)
+ expect(preloadedMatch?.loaderData).toEqual({ source: 'active' })
+ })
+
+ test('active preload does not execute active head hooks', async () => {
+ const loader = vi.fn(() => ({ source: 'active' }))
+ const head = vi.fn(() => ({ meta: [{ title: 'Foo' }] }))
+
+ const rootRoute = new BaseRootRoute({})
+ const fooRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/foo',
+ loader,
+ head,
+ })
+
+ const router = createTestRouter({
+ routeTree: rootRoute.addChildren([fooRoute]),
+ history: createMemoryHistory(),
+ })
+
+ await router.navigate({ to: '/foo' })
+ expect(head).toHaveBeenCalledTimes(1)
+
+ await router.preloadRoute({ to: '/foo' })
+
+ expect(loader).toHaveBeenCalledTimes(1)
+ expect(head).toHaveBeenCalledTimes(1)
+ })
+
test('exec on regular nav', async () => {
const loader = vi.fn(() => Promise.resolve({ hello: 'world' }))
const router = setup({ loader })
From c928d310569bef31189f0ad3d50919ebcc50ce11 Mon Sep 17 00:00:00 2001
From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com>
Date: Wed, 17 Jun 2026 21:07:36 +0200
Subject: [PATCH 08/13] waypoint
---
packages/router-core/src/load-matches.ts | 157 +++++++------
packages/router-core/src/router.ts | 14 +-
packages/router-core/tests/load.test.ts | 271 +++++++++++++++++++++++
3 files changed, 359 insertions(+), 83 deletions(-)
diff --git a/packages/router-core/src/load-matches.ts b/packages/router-core/src/load-matches.ts
index c8562b8f7f..474ab67cfa 100644
--- a/packages/router-core/src/load-matches.ts
+++ b/packages/router-core/src/load-matches.ts
@@ -29,7 +29,8 @@ type InnerLoadContext = {
rendered?: boolean
updateMatch: UpdateMatchFn
matches: Array
- preload?: boolean
+ /** Set only for preload passes. Contains active ids this preload must join, not mutate. */
+ preload?: Set
forceStaleReload?: boolean
onReady?: () => Promise
sync?: boolean
@@ -42,27 +43,6 @@ const triggerOnReady = (inner: InnerLoadContext): void | Promise => {
}
}
-const resolvePreload = (inner: InnerLoadContext, matchId: string): boolean => {
- return !!inner.preload && !isActiveMatch(inner, matchId)
-}
-
-const isActiveMatch = (
- inner: InnerLoadContext,
- matchId: string,
-): boolean => {
- return (
- inner.router.stores.matchStores.has(matchId) ||
- inner.router.stores.pendingMatchStores.has(matchId)
- )
-}
-
-const isActivePreloadMatch = (
- inner: InnerLoadContext,
- matchId: string,
-): boolean => {
- return !!inner.preload && isActiveMatch(inner, matchId)
-}
-
const getActiveMatch = (
inner: InnerLoadContext,
matchId: string,
@@ -77,20 +57,25 @@ const getLoadMatch = (
inner: InnerLoadContext,
matchId: string,
): AnyRouteMatch | undefined => {
- const stores = inner.router.stores
- if (inner.preload) {
- return (
- stores.cachedMatchStores.get(matchId)?.get() ??
- getActiveMatch(inner, matchId)
- )
- }
+ // loadMatches commits through updateMatch, which also prefers live owners.
+ // Re-read in the same order so the pass-local matches array is not refreshed
+ // from a stale cached duplicate with the same id.
return (
- getActiveMatch(inner, matchId) ??
- stores.cachedMatchStores.get(matchId)?.get()
+ inner.router.stores.pendingMatchStores.get(matchId)?.get() ??
+ inner.router.stores.matchStores.get(matchId)?.get() ??
+ inner.router.stores.cachedMatchStores.get(matchId)?.get()
)
}
-const joinActivePreloadMatch = async (
+const isPreloadMatch = (inner: InnerLoadContext, matchId: string): boolean => {
+ return !!inner.preload && !isJoinedPreload(inner, matchId)
+}
+
+const isJoinedPreload = (inner: InnerLoadContext, matchId: string): boolean => {
+ return !!inner.preload?.has(matchId)
+}
+
+const joinPreloadedActiveMatch = async (
inner: InnerLoadContext,
index: number,
waitForLoader: boolean,
@@ -153,17 +138,19 @@ const buildMatchContext = (
return context
}
-const patchMatch = (
+const commitMatch = (
inner: InnerLoadContext,
matchId: string,
patch: Partial,
): void => {
- inner.updateMatch(matchId, (prev) => {
- return {
- ...prev,
- ...patch,
- }
- })
+ inner.updateMatch(matchId, (prev) => ({
+ ...prev,
+ ...patch,
+ }))
+ const match = getLoadMatch(inner, matchId)
+ if (match) {
+ inner.matches[match.index] = match
+ }
}
const settleBeforeLoadPromise = (match: AnyRouteMatch): void => {
@@ -231,10 +218,16 @@ const handleRedirectOrNotFound = (
if (err.redirectHandled && !err.options.reloadDocument) {
throw err
}
+ if (isJoinedPreload(inner, match.id)) {
+ throw err
+ }
match._nonReactive.error = err
- if (inner.preload || inner.router.stores.cachedMatchStores.has(match.id)) {
+ if (
+ inner.preload ||
+ inner.router.stores.cachedMatchStores.get(match.id)?.get() === match
+ ) {
clearMatchPromises(match)
inner.router.clearCache({ filter: (d) => d.id === match.id })
} else {
@@ -244,7 +237,7 @@ const handleRedirectOrNotFound = (
clearPending(match)
settleBeforeLoadPromise(match)
settleLoaderPromise(match)
- patchMatch(inner, match.id, {
+ commitMatch(inner, match.id, {
isFetching: false as const,
})
}
@@ -256,6 +249,10 @@ const handleRedirectOrNotFound = (
}
if (isNotFound(err)) {
+ if (isJoinedPreload(inner, match.id)) {
+ throw err
+ }
+
match._nonReactive.error = err
clearMatchPromises(match)
@@ -266,7 +263,7 @@ const handleRedirectOrNotFound = (
err.routeId = match.routeId
}
- patchMatch(inner, match.id, {
+ commitMatch(inner, match.id, {
status: 'notFound',
error: err,
isFetching: false,
@@ -323,7 +320,7 @@ const handleSerialError = (
// A match that errors during the beforeLoad phase never reaches the loader
// phase. Settle its promises after committing the error state.
- patchMatch(inner, matchId, {
+ commitMatch(inner, matchId, {
__beforeLoadContext: undefined,
error: err,
status: 'error',
@@ -424,7 +421,7 @@ const setupPendingTimeout = (
const shouldPending = !!(
inner.onReady &&
!(isServer ?? inner.router.isServer) &&
- !resolvePreload(inner, matchId) &&
+ !isPreloadMatch(inner, matchId) &&
(route.options.loader ||
route.options.beforeLoad ||
routeNeedsPreload(route)) &&
@@ -478,22 +475,22 @@ const executeBeforeLoad = (
return
}
isPending = true
- inner.updateMatch(matchId, (prev) => ({
- ...prev,
+ const currentMatch = getLoadMatch(inner, matchId)!
+ commitMatch(inner, matchId, {
isFetching: 'beforeLoad',
- fetchCount: prev.fetchCount + 1,
+ fetchCount: currentMatch.fetchCount + 1,
abortController,
// Note: We intentionally don't update context here.
// Context should only be updated after beforeLoad resolves to avoid
// components seeing incomplete context during async beforeLoad execution.
- }))
+ })
}
const commitBeforeLoad = (beforeLoadContext: any) => {
- inner.matches[index]!.__beforeLoadContext = beforeLoadContext
inner.router.batch(() => {
pending()
- patchMatch(inner, matchId, {
+ inner.matches[index]!.__beforeLoadContext = beforeLoadContext
+ commitMatch(inner, matchId, {
isFetching: false as const,
__beforeLoadContext: beforeLoadContext,
context: buildMatchContext(inner, index),
@@ -539,7 +536,7 @@ const executeBeforeLoad = (
match.__routeContext,
)
const { search, params, cause } = match
- const preload = resolvePreload(inner, matchId)
+ const preload = isPreloadMatch(inner, matchId)
const beforeLoadFnContext: BeforeLoadContextOptions<
any,
any,
@@ -598,8 +595,8 @@ const handleBeforeLoad = (
const { id: matchId, routeId } = inner.matches[index]!
const route = inner.router.looseRoutesById[routeId]!
- if (isActivePreloadMatch(inner, matchId)) {
- return joinActivePreloadMatch(inner, index, false)
+ if (isJoinedPreload(inner, matchId)) {
+ return joinPreloadedActiveMatch(inner, index, false)
}
const queueExecution = () => {
@@ -646,12 +643,14 @@ const getLoaderContext = (
route: AnyRoute,
): LoaderFnContext => {
const parentMatchPromise = matchPromises[index - 1] as any
- const { params, loaderDeps, abortController, cause } =
- getLoadMatch(inner, matchId)!
+ const { params, loaderDeps, abortController, cause } = getLoadMatch(
+ inner,
+ matchId,
+ )!
const context = buildMatchContext(inner, index)
- const preload = resolvePreload(inner, matchId)
+ const preload = isPreloadMatch(inner, matchId)
return {
params,
@@ -714,7 +713,7 @@ const runLoader = async (
route.options.headers ||
loaderBucket.minPendingPromise
) {
- patchMatch(inner, matchId, {
+ commitMatch(inner, matchId, {
isFetching: 'loader',
})
}
@@ -733,7 +732,7 @@ const runLoader = async (
}
if (loaderData !== undefined) {
- patchMatch(inner, matchId, {
+ commitMatch(inner, matchId, {
loaderData,
})
}
@@ -752,7 +751,7 @@ const runLoader = async (
if (!isCurrentLoader()) {
return
}
- patchMatch(inner, matchId, {
+ commitMatch(inner, matchId, {
error: undefined,
status: 'success',
isFetching: false as const,
@@ -769,16 +768,17 @@ const runLoader = async (
if (match.abortController.signal.aborted) {
return
}
- if (!getCurrentMatch()) {
+ const currentMatch = getCurrentMatch()
+ if (!currentMatch) {
return
}
// a softly aborted pending match keeps its previous data and is
// committed as success
- inner.updateMatch(matchId, (prev) => ({
- ...prev,
- status: prev.status === 'pending' ? 'success' : prev.status,
+ commitMatch(inner, matchId, {
+ status:
+ currentMatch.status === 'pending' ? 'success' : currentMatch.status,
isFetching: false,
- }))
+ })
return
}
@@ -810,7 +810,7 @@ const runLoader = async (
return
}
- patchMatch(inner, matchId, {
+ commitMatch(inner, matchId, {
error,
status: 'error',
isFetching: false,
@@ -831,8 +831,8 @@ const loadRouteMatch = async (
let loaderGeneration: AnyRouteMatch['_nonReactive']['loaderPromise']
let matchToLoad: AnyRouteMatch | undefined
- if (isActivePreloadMatch(inner, matchId)) {
- return await joinActivePreloadMatch(inner, index, true)
+ if (isJoinedPreload(inner, matchId)) {
+ return await joinPreloadedActiveMatch(inner, index, true)
}
const prevMatch = getLoadMatch(inner, matchId)
@@ -844,7 +844,7 @@ const loadRouteMatch = async (
if (shouldSkipMatchLoad(inner, prevMatch)) {
// the beforeLoad phase (and with it the context commit) does not run for
// skipped matches, so commit the merged route context here
- patchMatch(inner, matchId, {
+ commitMatch(inner, matchId, {
invalid: false,
context: buildMatchContext(inner, index),
})
@@ -859,7 +859,7 @@ const loadRouteMatch = async (
? undefined
: routeLoader?.staleReloadMode) ??
inner.router.options.defaultStaleReloadMode) !== 'blocking'
- const preload = resolvePreload(inner, matchId)
+ const preload = isPreloadMatch(inner, matchId)
// there is a loaderPromise, so we are in the middle of a load
if (prevMatch._nonReactive.loaderPromise) {
@@ -877,7 +877,7 @@ const loadRouteMatch = async (
// finalization is skipped, so clear invalid here without touching
// promises or loader state.
if (prevMatch.invalid !== false) {
- patchMatch(inner, matchId, {
+ commitMatch(inner, matchId, {
invalid: false,
})
}
@@ -901,7 +901,7 @@ const loadRouteMatch = async (
match._nonReactive.loaderPromise = createControlledPromise()
loaderGeneration = match._nonReactive.loaderPromise
if (preload !== match.preload) {
- patchMatch(inner, matchId, {
+ commitMatch(inner, matchId, {
preload,
})
}
@@ -960,7 +960,7 @@ const loadRouteMatch = async (
loaderIsRunningAsync = true
const backgroundGeneration = matchToLoad._nonReactive.loaderPromise
if (matchToLoad.invalid !== false) {
- patchMatch(inner, matchId, { invalid: false })
+ commitMatch(inner, matchId, { invalid: false })
}
;(async () => {
try {
@@ -1002,7 +1002,7 @@ const loadRouteMatch = async (
const nextIsFetching = loaderIsRunningAsync ? match.isFetching : false
if (nextIsFetching !== match.isFetching || match.invalid !== false) {
- patchMatch(inner, matchId, {
+ commitMatch(inner, matchId, {
isFetching: nextIsFetching,
invalid: false,
})
@@ -1020,7 +1020,7 @@ export async function loadMatches(arg: {
router: AnyRouter
location: ParsedLocation
matches: Array
- preload?: boolean
+ preload?: Set
forceStaleReload?: boolean
onReady?: () => Promise
updateMatch: UpdateMatchFn
@@ -1128,7 +1128,7 @@ export async function loadMatches(arg: {
notFoundToThrow.routeId = boundaryMatch.routeId
const context = buildMatchContext(inner, renderedBoundaryIndex)
- patchMatch(
+ commitMatch(
inner,
boundaryMatch.id,
boundaryMatch.routeId === rootRouteId
@@ -1183,12 +1183,11 @@ export async function loadMatches(arg: {
const { id: matchId, routeId } = match
const route = inner.router.looseRoutesById[routeId]!
const routeOptions = route.options
- if (isActivePreloadMatch(inner, matchId)) {
+ if (isJoinedPreload(inner, matchId)) {
continue
}
try {
- const headMatch =
- getLoadMatch(inner, matchId) ?? (inner.preload && match)
+ const headMatch = getLoadMatch(inner, matchId) ?? (inner.preload && match)
if (
headMatch &&
(routeOptions.head || routeOptions.scripts || routeOptions.headers)
@@ -1206,7 +1205,7 @@ export async function loadMatches(arg: {
routeOptions.scripts?.(assetContext),
routeOptions.headers?.(assetContext),
])
- patchMatch(inner, matchId, {
+ commitMatch(inner, matchId, {
meta: headFnContent?.meta,
links: headFnContent?.links,
headScripts: headFnContent?.scripts,
diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts
index 083422df2a..a43836dc5d 100644
--- a/packages/router-core/src/router.ts
+++ b/packages/router-core/src/router.ts
@@ -1437,6 +1437,13 @@ export class RouterCore<
): Array {
const throwOnError = opts?.throwOnError
const preload = opts?.preload
+ // Internal matching must prefer live owners over cached duplicates. The
+ // public getMatch is cached-first for compatibility, but using that here
+ // can seed preload children with stale cached parent context.
+ const getExistingMatch = (matchId: string) =>
+ this.stores.pendingMatchStores.get(matchId)?.get() ??
+ this.stores.matchStores.get(matchId)?.get() ??
+ this.stores.cachedMatchStores.get(matchId)?.get()
const matchedRoutesResult = this.getMatchedRoutes(next.pathname)
const { foundRoute, routeParams } = matchedRoutesResult
let { matchedRoutes } = matchedRoutesResult
@@ -1554,7 +1561,7 @@ export class RouterCore<
// explicit deps
loaderDepsHash
- const existingMatch = this.getMatch(matchId)
+ const existingMatch = getExistingMatch(matchId)
const previousMatch = previousActiveMatchesByRouteId.get(route.id)
@@ -1680,7 +1687,7 @@ export class RouterCore<
for (let index = 0; index < matches.length; index++) {
const match = matches[index]!
const route = this.looseRoutesById[match.routeId]!
- const existingMatch = this.getMatch(match.id)
+ const existingMatch = getExistingMatch(match.id)
// Update the match's params
const previousMatch = previousActiveMatchesByRouteId.get(match.routeId)
@@ -2865,7 +2872,6 @@ export class RouterCore<
let matches = this.matchRoutes(next, {
throwOnError: true,
preload: true,
- dest: opts,
})
const activeMatchIds = new Set([
@@ -2889,7 +2895,7 @@ export class RouterCore<
router: this,
matches,
location: next,
- preload: true,
+ preload: activeMatchIds,
updateMatch: (id, updater) => {
// Don't update matches that were active when the preload started.
if (activeMatchIds.has(id)) {
diff --git a/packages/router-core/tests/load.test.ts b/packages/router-core/tests/load.test.ts
index 41c9f63c0c..134f8ea482 100644
--- a/packages/router-core/tests/load.test.ts
+++ b/packages/router-core/tests/load.test.ts
@@ -921,6 +921,168 @@ describe('loader skip or exec', () => {
expect(head).toHaveBeenCalledTimes(1)
})
+ test('preloadRoute returns cache-owned matches with loaderData after load', async () => {
+ const loader = vi.fn(() => ({ source: 'preload' }))
+ const router = setup({ loader })
+
+ const matches = await router.preloadRoute({ to: '/foo' })
+ const match = matches?.find((d) => d.id === '/foo/foo')
+
+ expect(loader).toHaveBeenCalledTimes(1)
+ expect(match?.loaderData).toEqual({ source: 'preload' })
+ })
+
+ test('head assetContext.matches sees lane-updated loaderData', async () => {
+ const parentLoader = vi.fn(() => ({ parent: 'data' }))
+ const seenParentLoaderData: Array = []
+
+ const rootRoute = new BaseRootRoute({})
+ const parentRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/parent',
+ loader: parentLoader,
+ })
+ const childRoute = new BaseRoute({
+ getParentRoute: () => parentRoute,
+ path: '/child',
+ head: ({ matches }) => {
+ seenParentLoaderData.push(
+ matches.find((match) => match.routeId === parentRoute.id)?.loaderData,
+ )
+ return { meta: [{ title: 'Child' }] }
+ },
+ })
+
+ const router = createTestRouter({
+ routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]),
+ history: createMemoryHistory(),
+ })
+
+ await router.preloadRoute({ to: '/parent/child' })
+
+ expect(parentLoader).toHaveBeenCalledTimes(1)
+ expect(seenParentLoaderData).toEqual([{ parent: 'data' }])
+ })
+
+ test('same-location load prefers active match over cached duplicate with same id', async () => {
+ const loader = vi.fn(() => ({ source: 'active' }))
+ const router = setup({ loader, staleTime: Infinity })
+
+ await router.navigate({ to: '/foo' })
+
+ const activeMatch = router.state.matches.find((match) =>
+ match.id.endsWith('/foo'),
+ )!
+
+ router.stores.setCached([
+ ...router.stores.cachedMatches.get(),
+ {
+ ...activeMatch,
+ loaderData: { source: 'cached' },
+ preload: true,
+ },
+ ])
+
+ await router.load()
+
+ const loadedMatch = router.state.matches.find(
+ (match) => match.id === activeMatch.id,
+ )
+
+ expect(loader).toHaveBeenCalledTimes(1)
+ expect(loadedMatch?.loaderData).toEqual({ source: 'active' })
+ })
+
+ test('preload child context uses active parent over cached duplicate with same id', async () => {
+ const seenParentContext: Array = []
+
+ const rootRoute = new BaseRootRoute({})
+ const parentRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/parent',
+ context: () => ({ source: 'active' }),
+ })
+ const childRoute = new BaseRoute({
+ getParentRoute: () => parentRoute,
+ path: '/child',
+ context: ({ context }) => {
+ seenParentContext.push(context.source)
+ return {}
+ },
+ })
+
+ const router = createTestRouter({
+ routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]),
+ history: createMemoryHistory({ initialEntries: ['/parent'] }),
+ })
+
+ await router.load()
+
+ const activeParent = router.state.matches.find(
+ (match) => match.routeId === parentRoute.id,
+ )!
+
+ router.stores.setCached([
+ ...router.stores.cachedMatches.get(),
+ {
+ ...activeParent,
+ __routeContext: { source: 'cached' },
+ context: { source: 'cached' },
+ preload: true,
+ },
+ ])
+
+ await router.preloadRoute({ to: '/parent/child' })
+
+ expect(seenParentContext).toEqual(['active'])
+ })
+
+ test('active redirect ignores cached duplicate ownership by id', async () => {
+ const rootRoute = new BaseRootRoute({})
+ const fooRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/foo',
+ loader: () => redirect({ to: '/bar' }),
+ })
+ const barRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/bar',
+ })
+
+ const router = createTestRouter({
+ routeTree: rootRoute.addChildren([fooRoute, barRoute]),
+ history: createMemoryHistory({ initialEntries: ['/foo'] }),
+ })
+
+ const location = router.latestLocation
+ const matches = router.matchRoutes(location)
+ const fooMatch = matches.find((match) => match.routeId === fooRoute.id)!
+ const activeLoadPromise = fooMatch._nonReactive.loadPromise
+
+ router.stores.setPending(matches)
+ router.stores.setCached([
+ ...router.stores.cachedMatches.get(),
+ {
+ ...fooMatch,
+ preload: true,
+ status: 'success',
+ },
+ ])
+
+ await expect(
+ loadMatches({
+ router,
+ location,
+ matches,
+ updateMatch: router.updateMatch,
+ }),
+ ).rejects.toMatchObject({
+ options: expect.objectContaining({ to: '/bar' }),
+ })
+
+ expect(activeLoadPromise?.status).toBe('pending')
+ })
+
test('exec on regular nav', async () => {
const loader = vi.fn(() => Promise.resolve({ hello: 'world' }))
const router = setup({ loader })
@@ -1206,6 +1368,82 @@ describe('loader skip or exec', () => {
}
})
+ test('active-join preload rethrows redirect without clearing active owner loadPromise', async () => {
+ vi.useFakeTimers()
+
+ try {
+ let rejectFoo!: (error: unknown) => void
+ let resolveBar!: () => void
+ const rootRoute = new BaseRootRoute({})
+ const indexRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ })
+ const fooRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/foo',
+ pendingMs: 1,
+ pendingComponent: {},
+ loader: () =>
+ new Promise((_resolve, reject) => {
+ rejectFoo = reject
+ }),
+ })
+ const barRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/bar',
+ loader: () =>
+ new Promise((resolve) => {
+ resolveBar = resolve
+ }),
+ })
+ const router = createTestRouter({
+ routeTree: rootRoute.addChildren([indexRoute, fooRoute, barRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+
+ await router.load()
+
+ const navigation = router.navigate({ to: '/foo' })
+ await vi.waitFor(() => expect(rejectFoo).toBeTypeOf('function'))
+ await vi.advanceTimersByTimeAsync(1)
+ await vi.waitFor(() =>
+ expect(
+ router.state.matches.some(
+ (match) => match.id === '/foo/foo' && match.status === 'pending',
+ ),
+ ).toBe(true),
+ )
+
+ const activeFoo = router.state.matches.find(
+ (match) => match.id === '/foo/foo',
+ )!
+ const activeLoadPromise = activeFoo._nonReactive.loadPromise
+ expect(activeLoadPromise?.status).toBe('pending')
+
+ const preload = router.preloadRoute({ to: '/foo' })
+ await Promise.resolve()
+
+ rejectFoo(redirect({ to: '/bar' }))
+ await vi.waitFor(() =>
+ expect(
+ router.stores.pendingMatches
+ .get()
+ .some((match) => match.id === '/bar/bar'),
+ ).toBe(true),
+ )
+
+ expect(activeLoadPromise?.status).toBe('pending')
+
+ resolveBar()
+ await Promise.all([navigation, preload])
+
+ expect(router.state.location.pathname).toBe('/bar')
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
test('updateMatch removes failed matches from cachedMatches', async () => {
const loader = vi.fn()
const router = setup({ loader })
@@ -3285,6 +3523,39 @@ describe('routeId in context options', () => {
})
describe('beforeLoad context lifecycle', () => {
+ test('cached preload reload commits fresh beforeLoad context to returned match context', async () => {
+ let token = 'one'
+ const beforeLoad = vi.fn(() => ({ token }))
+
+ const rootRoute = new BaseRootRoute({})
+ const fooRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/foo',
+ beforeLoad,
+ preloadStaleTime: Infinity,
+ })
+
+ const router = createTestRouter({
+ routeTree: rootRoute.addChildren([fooRoute]),
+ history: createMemoryHistory(),
+ })
+
+ const first = await router.preloadRoute({ to: '/foo' })
+ const firstMatch = first?.find((match) => match.routeId === fooRoute.id)
+
+ expect(firstMatch?.__beforeLoadContext).toEqual({ token: 'one' })
+ expect(firstMatch?.context).toMatchObject({ token: 'one' })
+
+ token = 'two'
+
+ const second = await router.preloadRoute({ to: '/foo' })
+ const secondMatch = second?.find((match) => match.routeId === fooRoute.id)
+
+ expect(beforeLoad).toHaveBeenCalledTimes(2)
+ expect(secondMatch?.__beforeLoadContext).toEqual({ token: 'two' })
+ expect(secondMatch?.context).toMatchObject({ token: 'two' })
+ })
+
test('clears stale beforeLoad context when a later run returns undefined', async () => {
let returnContext = true
const seenContexts: Array> = []
From b9897a42d5c6228a44308efc8062209407780523 Mon Sep 17 00:00:00 2001
From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com>
Date: Wed, 17 Jun 2026 21:07:36 +0200
Subject: [PATCH 09/13] wp
---
packages/react-router/tests/Scripts.test.tsx | 76 +++
packages/router-core/src/load-matches.ts | 92 +++-
packages/router-core/src/router.ts | 6 +-
packages/router-core/tests/load.test.ts | 536 +++++++++++++++++++
4 files changed, 686 insertions(+), 24 deletions(-)
diff --git a/packages/react-router/tests/Scripts.test.tsx b/packages/react-router/tests/Scripts.test.tsx
index b7605c9402..8422b1d29f 100644
--- a/packages/react-router/tests/Scripts.test.tsx
+++ b/packages/react-router/tests/Scripts.test.tsx
@@ -508,6 +508,82 @@ describe('ssr HeadContent', () => {
).toHaveLength(1)
})
+ test('removes stale child title when parent beforeLoad throws', async () => {
+ const history = createMemoryHistory({
+ initialEntries: ['/parent/child?fail=false'],
+ })
+
+ const rootRoute = createRootRoute({
+ component: () => {
+ return (
+ <>
+ {createPortal(, document.head)}
+
+ >
+ )
+ },
+ })
+
+ const parentRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/parent',
+ validateSearch: (search: Record) => ({
+ fail: search.fail === true || search.fail === 'true',
+ }),
+ beforeLoad: ({ search }) => {
+ if (search.fail) {
+ throw new Error('Parent beforeLoad failed')
+ }
+ },
+ head: ({ match }) => ({
+ meta: [
+ {
+ title: match.error ? 'Parent error title' : 'Parent success title',
+ },
+ ],
+ }),
+ errorComponent: () => Parent error boundary
,
+ component: () => ,
+ })
+
+ const childRoute = createRoute({
+ getParentRoute: () => parentRoute,
+ path: '/child',
+ head: () => ({
+ meta: [{ title: 'Child success title' }],
+ }),
+ component: () => Child success
,
+ })
+
+ const router = createRouter({
+ history,
+ routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]),
+ })
+
+ await act(() => render())
+
+ expect(await screen.findByText('Child success')).toBeInTheDocument()
+ await waitFor(() => {
+ expect(document.title).toBe('Child success title')
+ })
+
+ await act(() =>
+ router.navigate({
+ to: '/parent/child',
+ search: { fail: true },
+ } as never),
+ )
+
+ expect(await screen.findByText('Parent error boundary')).toBeInTheDocument()
+ expect(router.state.matches.map((match) => match.routeId)).toEqual([
+ rootRoute.id,
+ parentRoute.id,
+ ])
+ await waitFor(() => {
+ expect(document.title).toBe('Parent error title')
+ })
+ })
+
test('applies assetCrossOrigin to manifest stylesheets and preloads', async () => {
const history = createTestBrowserHistory()
const stylesheetHref = '/asset-cross-origin.css'
diff --git a/packages/router-core/src/load-matches.ts b/packages/router-core/src/load-matches.ts
index 474ab67cfa..9a1fe2249f 100644
--- a/packages/router-core/src/load-matches.ts
+++ b/packages/router-core/src/load-matches.ts
@@ -32,14 +32,23 @@ type InnerLoadContext = {
/** Set only for preload passes. Contains active ids this preload must join, not mutate. */
preload?: Set
forceStaleReload?: boolean
- onReady?: () => Promise
+ onReady?: (matches: Array) => Promise
sync?: boolean
}
const triggerOnReady = (inner: InnerLoadContext): void | Promise => {
if (!inner.rendered) {
inner.rendered = true
- return inner.onReady?.()
+ return inner.onReady?.(inner.matches)
+ }
+}
+
+const trimMatches = (inner: InnerLoadContext, index: number): void => {
+ if (index < inner.matches.length - 1) {
+ for (let i = index + 1; i < inner.matches.length; i++) {
+ clearMatchPromises(inner.matches[i]!)
+ }
+ inner.matches = inner.matches.slice(0, index + 1)
}
}
@@ -57,9 +66,6 @@ const getLoadMatch = (
inner: InnerLoadContext,
matchId: string,
): AnyRouteMatch | undefined => {
- // loadMatches commits through updateMatch, which also prefers live owners.
- // Re-read in the same order so the pass-local matches array is not refreshed
- // from a stale cached duplicate with the same id.
return (
inner.router.stores.pendingMatchStores.get(matchId)?.get() ??
inner.router.stores.matchStores.get(matchId)?.get() ??
@@ -81,7 +87,18 @@ const joinPreloadedActiveMatch = async (
waitForLoader: boolean,
): Promise => {
const matchId = inner.matches[index]!.id
- let match = getActiveMatch(inner, matchId)!
+ const getMatch = (): AnyRouteMatch => {
+ const match = getActiveMatch(inner, matchId)
+ if (match) {
+ return match
+ }
+
+ inner.router.clearCache({
+ filter: (match) => inner.matches.includes(match),
+ })
+ throw inner
+ }
+ let match = getMatch()
const route = inner.router.looseRoutesById[match.routeId]!
const beforeLoadPromise =
@@ -93,7 +110,7 @@ const joinPreloadedActiveMatch = async (
: undefined)
if (beforeLoadPromise?.status === 'pending') {
await beforeLoadPromise
- match = getActiveMatch(inner, matchId) ?? inner.matches[index]!
+ match = getMatch()
}
inner.matches[index] = match
@@ -104,7 +121,7 @@ const joinPreloadedActiveMatch = async (
match._nonReactive.loaderPromise || match._nonReactive.loadPromise
if (loaderPromise?.status === 'pending') {
await loaderPromise
- match = getActiveMatch(inner, matchId) ?? inner.matches[index]!
+ match = getMatch()
}
inner.matches[index] = match
error = match._nonReactive.error || match.error
@@ -508,9 +525,12 @@ const executeBeforeLoad = (
}
const beforeLoadPromise = createControlledPromise()
- const isCurrentBeforeLoad = () =>
- getLoadMatch(inner, matchId)?._nonReactive.beforeLoadPromise ===
- beforeLoadPromise
+ const isCurrentBeforeLoad = () => {
+ return (
+ getLoadMatch(inner, matchId)?._nonReactive.beforeLoadPromise ===
+ beforeLoadPromise
+ )
+ }
// commits the result of the beforeLoad phase and settles its promise
const updateContext = (beforeLoadContext: any) => {
@@ -686,7 +706,9 @@ const runLoader = async (
const loaderPromise = loaderBucket.loaderPromise
const isCurrentLoader = () => loaderBucket.loaderPromise === loaderPromise
const getCurrentMatch = () => {
- return isCurrentLoader() ? getLoadMatch(inner, matchId) : undefined
+ return isCurrentLoader()
+ ? getLoadMatch(inner, matchId)
+ : undefined
}
// Actually run the loader and handle the result
@@ -798,6 +820,7 @@ const runLoader = async (
}
handleRedirectOrNotFound(inner, currentMatch, e)
+ inner.firstBadMatchIndex ??= index
try {
route.options.onError?.(e)
@@ -991,10 +1014,12 @@ const loadRouteMatch = async (
}
if (
loaderGeneration &&
+ match._nonReactive.loaderPromise &&
match._nonReactive.loaderPromise !== loaderGeneration
) {
return inner.matches[index]!
}
+ inner.matches[index] = match
clearTimeout(match._nonReactive.pendingTimeout)
match._nonReactive.pendingTimeout = undefined
@@ -1022,7 +1047,7 @@ export async function loadMatches(arg: {
matches: Array
preload?: Set
forceStaleReload?: boolean
- onReady?: () => Promise
+ onReady?: (matches: Array) => Promise
updateMatch: UpdateMatchFn
sync?: boolean
}): Promise> {
@@ -1046,6 +1071,9 @@ export async function loadMatches(arg: {
const beforeLoad = handleBeforeLoad(inner, i)
if (isPromise(beforeLoad)) await beforeLoad
} catch (err) {
+ if (err === inner) {
+ return inner.matches
+ }
if (isNotFound(err)) {
beforeLoadNotFound = err
} else if (isRedirect(err) || !inner.preload) {
@@ -1076,14 +1104,35 @@ export async function loadMatches(arg: {
try {
await Promise.all(matchPromises)
- } catch {
- const settled = await Promise.allSettled(matchPromises)
+ } catch (err) {
+ if (err === inner) {
+ return inner.matches
+ }
+
+ const preloadCancelled = createControlledPromise()
+ for (const matchPromise of matchPromises) {
+ matchPromise.catch((err) => {
+ if (err === inner) {
+ preloadCancelled.resolve()
+ }
+ })
+ }
+ const settled = await Promise.race([
+ Promise.allSettled(matchPromises),
+ preloadCancelled,
+ ])
+ if (!settled) {
+ return inner.matches
+ }
let firstUnhandledRejection: unknown
for (const result of settled) {
if (result.status !== 'rejected') continue
const reason = result.reason
+ if (reason === inner) {
+ return inner.matches
+ }
if (isRedirect(reason)) {
throw reason
}
@@ -1101,7 +1150,7 @@ export async function loadMatches(arg: {
const notFoundToThrow = firstNotFound ?? beforeLoadNotFound
- let headMaxIndex = inner.firstBadMatchIndex ?? inner.matches.length - 1
+ let renderMaxIndex = inner.firstBadMatchIndex ?? inner.matches.length - 1
if (notFoundToThrow) {
// Determine once which matched route will actually render the
@@ -1154,7 +1203,7 @@ export async function loadMatches(arg: {
},
)
- headMaxIndex = renderedBoundaryIndex
+ renderMaxIndex = renderedBoundaryIndex
// Ensure the rendering boundary route chunk (and its lazy components, including
// lazy notFoundComponent) is loaded before we continue to head execution/render.
@@ -1176,9 +1225,13 @@ export async function loadMatches(arg: {
await loadRouteChunk(errorRoute, ['errorComponent'])
}
+ if (!notFoundToThrow) {
+ trimMatches(inner, renderMaxIndex)
+ }
+
// serially execute heads once after loaders/notFound handling, ensuring
// all head functions get a chance even if one throws.
- for (let i = 0; i <= headMaxIndex; i++) {
+ for (let i = 0; i <= renderMaxIndex; i++) {
const match = inner.matches[i]!
const { id: matchId, routeId } = match
const route = inner.router.looseRoutesById[routeId]!
@@ -1187,7 +1240,8 @@ export async function loadMatches(arg: {
continue
}
try {
- const headMatch = getLoadMatch(inner, matchId) ?? (inner.preload && match)
+ const headMatch =
+ getLoadMatch(inner, matchId) ?? (inner.preload && match)
if (
headMatch &&
(routeOptions.head || routeOptions.scripts || routeOptions.headers)
diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts
index a43836dc5d..84b1bf100a 100644
--- a/packages/router-core/src/router.ts
+++ b/packages/router-core/src/router.ts
@@ -1437,9 +1437,6 @@ export class RouterCore<
): Array {
const throwOnError = opts?.throwOnError
const preload = opts?.preload
- // Internal matching must prefer live owners over cached duplicates. The
- // public getMatch is cached-first for compatibility, but using that here
- // can seed preload children with stale cached parent context.
const getExistingMatch = (matchId: string) =>
this.stores.pendingMatchStores.get(matchId)?.get() ??
this.stores.matchStores.get(matchId)?.get() ??
@@ -2504,7 +2501,7 @@ export class RouterCore<
matches: this.stores.pendingMatches.get(),
location: next,
updateMatch: this.updateMatch,
- onReady: () =>
+ onReady: (pendingMatches) =>
new Promise((resolve, reject) => {
// Wrap batch in framework-specific transition wrapper (e.g., Solid's startTransition)
this.startTransition(() => {
@@ -2522,7 +2519,6 @@ export class RouterCore<
// Commit the pending matches. If a previous match was
// removed, place it in the cachedMatches.
//
- const pendingMatches = this.stores.pendingMatches.get()
const currentMatches = this.stores.matches.get()
this.batch(() => {
diff --git a/packages/router-core/tests/load.test.ts b/packages/router-core/tests/load.test.ts
index 134f8ea482..a841a9fc1a 100644
--- a/packages/router-core/tests/load.test.ts
+++ b/packages/router-core/tests/load.test.ts
@@ -544,6 +544,214 @@ describe('beforeLoad skip or exec', () => {
})
})
+ test('preload does not continue loader-owned descendants when joined active beforeLoad owner exits before settling', async () => {
+ vi.useFakeTimers()
+ const consoleError = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => undefined)
+
+ try {
+ const parentBeforeLoadPromise = createControlledPromise<{ auth: string }>()
+ const parentBeforeLoad = vi.fn(() => parentBeforeLoadPromise)
+ const childBeforeLoad = vi.fn()
+ const childLoader = vi.fn(() => undefined)
+
+ const rootRoute = new BaseRootRoute({})
+ const indexRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ })
+ const parentRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/parent',
+ beforeLoad: parentBeforeLoad,
+ pendingMs: 1,
+ pendingComponent: {},
+ })
+ const childRoute = new BaseRoute({
+ getParentRoute: () => parentRoute,
+ path: '/child',
+ beforeLoad: childBeforeLoad,
+ loader: childLoader,
+ })
+ const otherRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/other',
+ })
+
+ const router = createTestRouter({
+ routeTree: rootRoute.addChildren([
+ indexRoute,
+ parentRoute.addChildren([childRoute]),
+ otherRoute,
+ ]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+
+ await router.load()
+
+ const parentNavigation = router.navigate({ to: '/parent' })
+ await vi.waitFor(() => expect(parentBeforeLoad).toHaveBeenCalledTimes(1))
+
+ await vi.advanceTimersByTimeAsync(1)
+ await vi.waitFor(() =>
+ expect(
+ router.state.matches.some(
+ (match) =>
+ match.routeId === parentRoute.id && match.status === 'pending',
+ ),
+ ).toBe(true),
+ )
+
+ const preload = router.preloadRoute({ to: '/parent/child' })
+ await Promise.resolve()
+ expect(childBeforeLoad).not.toHaveBeenCalled()
+
+ const childCachedMatch = router.stores.cachedMatches
+ .get()
+ .find((match) => match.routeId === childRoute.id)!
+ const childLoadPromise = childCachedMatch._nonReactive.loadPromise
+ expect(childLoadPromise?.status).toBe('pending')
+
+ await router.navigate({ to: '/other' })
+
+ parentBeforeLoadPromise.resolve({ auth: 'late' })
+ await Promise.all([parentNavigation, preload])
+
+ expect(router.state.location.pathname).toBe('/other')
+ expect(childBeforeLoad).not.toHaveBeenCalled()
+ expect(childLoader).not.toHaveBeenCalled()
+ expect(
+ router.stores.cachedMatches
+ .get()
+ .some((match) => match.routeId === childRoute.id),
+ ).toBe(false)
+ expect(childLoadPromise?.status).toBe('resolved')
+ } finally {
+ consoleError.mockRestore()
+ vi.useRealTimers()
+ }
+ })
+
+ test('beforeLoad error commits only the renderable match prefix', async () => {
+ const parentHead = vi.fn(({ match }) => ({
+ meta: [{ title: match.error ? 'Parent error' : 'Parent success' }],
+ }))
+ const childHead = vi.fn(() => ({
+ meta: [{ title: 'Child success' }],
+ }))
+
+ const rootRoute = new BaseRootRoute({})
+ const parentRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/parent',
+ validateSearch: (search: Record) => ({
+ fail: search.fail === true || search.fail === 'true',
+ }),
+ beforeLoad: ({ search }) => {
+ if (search.fail) {
+ throw new Error('Parent beforeLoad failed')
+ }
+ },
+ head: parentHead,
+ })
+ const childRoute = new BaseRoute({
+ getParentRoute: () => parentRoute,
+ path: '/child',
+ head: childHead,
+ })
+
+ const router = createTestRouter({
+ routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]),
+ history: createMemoryHistory({
+ initialEntries: ['/parent/child?fail=false'],
+ }),
+ })
+
+ await router.load()
+
+ expect(router.state.matches.map((match) => match.routeId)).toContain(
+ childRoute.id,
+ )
+ expect(childHead).toHaveBeenCalledTimes(1)
+
+ await router.navigate({
+ to: '/parent/child',
+ search: { fail: true },
+ } as never)
+
+ expect(router.state.matches.map((match) => match.routeId)).toEqual([
+ rootRoute.id,
+ parentRoute.id,
+ ])
+ expect(
+ router.state.matches.find((match) => match.routeId === parentRoute.id)
+ ?.status,
+ ).toBe('error')
+ expect(parentHead).toHaveBeenCalledTimes(2)
+ expect(childHead).toHaveBeenCalledTimes(1)
+ })
+
+ test('loader error commits only the renderable match prefix', async () => {
+ const parentHead = vi.fn(({ match }) => ({
+ meta: [{ title: match.error ? 'Parent error' : 'Parent success' }],
+ }))
+ const childHead = vi.fn(() => ({
+ meta: [{ title: 'Child success' }],
+ }))
+
+ const rootRoute = new BaseRootRoute({})
+ const parentRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/parent',
+ validateSearch: (search: Record) => ({
+ fail: search.fail === true || search.fail === 'true',
+ }),
+ loaderDeps: ({ search }) => ({ fail: search.fail }),
+ loader: (({ deps }) => {
+ if ((deps as { fail?: boolean }).fail) {
+ throw new Error('Parent loader failed')
+ }
+ }) as LoaderFn,
+ head: parentHead,
+ })
+ const childRoute = new BaseRoute({
+ getParentRoute: () => parentRoute,
+ path: '/child',
+ head: childHead,
+ })
+
+ const router = createTestRouter({
+ routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]),
+ history: createMemoryHistory({
+ initialEntries: ['/parent/child?fail=false'],
+ }),
+ })
+
+ await router.load()
+
+ expect(router.state.matches.map((match) => match.routeId)).toContain(
+ childRoute.id,
+ )
+ expect(childHead).toHaveBeenCalledTimes(1)
+
+ await router.navigate({
+ to: '/parent/child',
+ search: { fail: true },
+ } as never)
+
+ expect(router.state.matches.map((match) => match.routeId)).toEqual([
+ rootRoute.id,
+ parentRoute.id,
+ ])
+ expect(
+ router.state.matches.find((match) => match.routeId === parentRoute.id)
+ ?.status,
+ ).toBe('error')
+ expect(parentHead).toHaveBeenCalledTimes(2)
+ expect(childHead).toHaveBeenCalledTimes(1)
+ })
+
test('preload from onBeforeLoad waits for active root beforeLoad context', async () => {
vi.useFakeTimers()
@@ -662,6 +870,334 @@ describe('beforeLoad skip or exec', () => {
}
})
+ test('preload does not settle descendant loader when joined active loader owner exits before settling', async () => {
+ vi.useFakeTimers()
+
+ try {
+ const parentLoaderPromise = createControlledPromise<{ auth: string }>()
+ const parentLoader = vi.fn(() => parentLoaderPromise)
+ let childLoaderSettled = false
+ const childLoader = vi.fn(async ({ parentMatchPromise }) => {
+ await parentMatchPromise
+ childLoaderSettled = true
+ })
+ const childOnError = vi.fn()
+
+ const rootRoute = new BaseRootRoute({})
+ const indexRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ })
+ const parentRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/parent',
+ loader: parentLoader,
+ pendingMs: 1,
+ pendingComponent: {},
+ })
+ const childRoute = new BaseRoute({
+ getParentRoute: () => parentRoute,
+ path: '/child',
+ loader: childLoader,
+ onError: childOnError,
+ })
+ const otherRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/other',
+ })
+
+ const router = createTestRouter({
+ routeTree: rootRoute.addChildren([
+ indexRoute,
+ parentRoute.addChildren([childRoute]),
+ otherRoute,
+ ]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+
+ await router.load()
+
+ const parentNavigation = router.navigate({ to: '/parent' })
+ await vi.waitFor(() => expect(parentLoader).toHaveBeenCalledTimes(1))
+
+ const preload = router.preloadRoute({ to: '/parent/child' })
+ await vi.advanceTimersByTimeAsync(5)
+ expect(parentLoader).toHaveBeenCalledTimes(1)
+ expect(childLoader).toHaveBeenCalledTimes(1)
+ expect(childLoaderSettled).toBe(false)
+
+ const childCachedMatch = router.stores.cachedMatches
+ .get()
+ .find((match) => match.routeId === childRoute.id)!
+ const childLoadPromise = childCachedMatch._nonReactive.loadPromise
+ const childLoaderPromise = childCachedMatch._nonReactive.loaderPromise
+ expect(childLoadPromise?.status).toBe('pending')
+ expect(childLoaderPromise?.status).toBe('pending')
+
+ await router.navigate({ to: '/other' })
+
+ parentLoaderPromise.resolve({ auth: 'late' })
+ await Promise.all([parentNavigation, preload])
+
+ expect(router.state.location.pathname).toBe('/other')
+ expect(childLoaderSettled).toBe(false)
+ expect(childOnError).not.toHaveBeenCalled()
+ expect(
+ router.stores.cachedMatches
+ .get()
+ .some((match) => match.routeId === childRoute.id),
+ ).toBe(false)
+ expect(childLoadPromise?.status).toBe('resolved')
+ expect(childLoaderPromise?.status).toBe('resolved')
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ test('preload clears independently completed descendant when joined active loader owner exits', async () => {
+ vi.useFakeTimers()
+
+ try {
+ const parentLoaderPromise = createControlledPromise<{ auth: string }>()
+ const parentLoader = vi.fn(() => parentLoaderPromise)
+ const childLoader = vi.fn(() => ({ child: 'preloaded' }))
+
+ const rootRoute = new BaseRootRoute({})
+ const indexRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ })
+ const parentRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/parent',
+ loader: parentLoader,
+ pendingMs: 1,
+ pendingComponent: {},
+ })
+ const childRoute = new BaseRoute({
+ getParentRoute: () => parentRoute,
+ path: '/child',
+ loader: childLoader,
+ })
+ const otherRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/other',
+ })
+
+ const router = createTestRouter({
+ routeTree: rootRoute.addChildren([
+ indexRoute,
+ parentRoute.addChildren([childRoute]),
+ otherRoute,
+ ]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+
+ await router.load()
+
+ const parentNavigation = router.navigate({ to: '/parent' })
+ await vi.waitFor(() => expect(parentLoader).toHaveBeenCalledTimes(1))
+
+ const preload = router.preloadRoute({ to: '/parent/child' })
+ await vi.waitFor(() => expect(childLoader).toHaveBeenCalledTimes(1))
+ await vi.waitFor(() =>
+ expect(
+ router.stores.cachedMatches
+ .get()
+ .some(
+ (match) =>
+ match.routeId === childRoute.id && match.status === 'success',
+ ),
+ ).toBe(true),
+ )
+
+ await router.navigate({ to: '/other' })
+
+ parentLoaderPromise.resolve({ auth: 'late' })
+ await Promise.all([parentNavigation, preload])
+
+ expect(router.state.location.pathname).toBe('/other')
+ expect(
+ router.stores.cachedMatches
+ .get()
+ .some((match) => match.routeId === childRoute.id),
+ ).toBe(false)
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ test('preload resolves when joined active loader owner exits with a never-settling descendant loader', async () => {
+ vi.useFakeTimers()
+
+ try {
+ const parentLoaderPromise = createControlledPromise<{ auth: string }>()
+ const childLoaderPromise = createControlledPromise()
+ const parentLoader = vi.fn(() => parentLoaderPromise)
+ const childLoader = vi.fn(() => childLoaderPromise)
+
+ const rootRoute = new BaseRootRoute({})
+ const indexRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ })
+ const parentRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/parent',
+ loader: parentLoader,
+ pendingMs: 1,
+ pendingComponent: {},
+ })
+ const childRoute = new BaseRoute({
+ getParentRoute: () => parentRoute,
+ path: '/child',
+ loader: childLoader,
+ })
+ const otherRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/other',
+ })
+
+ const router = createTestRouter({
+ routeTree: rootRoute.addChildren([
+ indexRoute,
+ parentRoute.addChildren([childRoute]),
+ otherRoute,
+ ]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+
+ await router.load()
+
+ const parentNavigation = router.navigate({ to: '/parent' })
+ await vi.waitFor(() => expect(parentLoader).toHaveBeenCalledTimes(1))
+
+ const preloadSettled = vi.fn()
+ const preload = router.preloadRoute({ to: '/parent/child' })
+ preload.then(preloadSettled)
+ await vi.waitFor(() => expect(childLoader).toHaveBeenCalledTimes(1))
+
+ await router.navigate({ to: '/other' })
+
+ parentLoaderPromise.resolve({ auth: 'late' })
+ await parentNavigation
+ await Promise.resolve()
+ await Promise.resolve()
+
+ expect(preloadSettled).toHaveBeenCalledTimes(1)
+ expect(
+ router.stores.cachedMatches
+ .get()
+ .some((match) => match.routeId === childRoute.id),
+ ).toBe(false)
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ test.each([
+ {
+ name: 'without a never-settling descendant',
+ withNeverSettlingDescendant: false,
+ },
+ {
+ name: 'with a never-settling descendant',
+ withNeverSettlingDescendant: true,
+ },
+ ])(
+ 'preload cancellation wins after earlier redirect rejection $name',
+ async ({ withNeverSettlingDescendant }) => {
+ vi.useFakeTimers()
+ const consoleError = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => undefined)
+
+ try {
+ const parentLoaderPromise = createControlledPromise<{ auth: string }>()
+ const hangLoaderPromise = createControlledPromise()
+ const parentLoader = vi.fn(() => parentLoaderPromise)
+ const childLoader = vi.fn(() => {
+ throw redirect({ to: '/target' })
+ })
+ const hangLoader = vi.fn(() => hangLoaderPromise)
+ const targetLoader = vi.fn(() => undefined)
+
+ const rootRoute = new BaseRootRoute({})
+ const indexRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ })
+ const parentRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/parent',
+ loader: parentLoader,
+ pendingMs: 1,
+ pendingComponent: {},
+ })
+ const childRoute = new BaseRoute({
+ getParentRoute: () => parentRoute,
+ path: '/child',
+ loader: childLoader,
+ })
+ const hangRoute = new BaseRoute({
+ getParentRoute: () => childRoute,
+ path: '/hang',
+ loader: hangLoader,
+ })
+ const targetRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/target',
+ loader: targetLoader,
+ })
+ const otherRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/other',
+ })
+
+ const router = createTestRouter({
+ routeTree: rootRoute.addChildren([
+ indexRoute,
+ parentRoute.addChildren([childRoute.addChildren([hangRoute])]),
+ targetRoute,
+ otherRoute,
+ ]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+
+ await router.load()
+
+ const parentNavigation = router.navigate({ to: '/parent' })
+ await vi.waitFor(() => expect(parentLoader).toHaveBeenCalledTimes(1))
+
+ const preloadSettled = vi.fn()
+ const preload = router.preloadRoute({
+ to: withNeverSettlingDescendant
+ ? '/parent/child/hang'
+ : '/parent/child',
+ })
+ preload.then(preloadSettled)
+
+ await vi.waitFor(() => expect(childLoader).toHaveBeenCalledTimes(1))
+ if (withNeverSettlingDescendant) {
+ await vi.waitFor(() => expect(hangLoader).toHaveBeenCalledTimes(1))
+ }
+
+ await router.navigate({ to: '/other' })
+
+ parentLoaderPromise.resolve({ auth: 'late' })
+ await parentNavigation
+ await vi.waitFor(() => expect(preloadSettled).toHaveBeenCalledTimes(1))
+
+ expect(router.state.location.pathname).toBe('/other')
+ expect(targetLoader).not.toHaveBeenCalled()
+ expect(consoleError).not.toHaveBeenCalled()
+ } finally {
+ consoleError.mockRestore()
+ vi.useRealTimers()
+ }
+ },
+ )
+
test('preload from onBeforeLoad waits for active parent loader data', async () => {
vi.useFakeTimers()
From f7be0ae898bb0b7d43a14b7a565060312aeb9c01 Mon Sep 17 00:00:00 2001
From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com>
Date: Wed, 17 Jun 2026 21:07:36 +0200
Subject: [PATCH 10/13] wp
---
packages/router-core/src/load-matches.ts | 98 +++++++++---------------
packages/router-core/src/router.ts | 7 +-
2 files changed, 39 insertions(+), 66 deletions(-)
diff --git a/packages/router-core/src/load-matches.ts b/packages/router-core/src/load-matches.ts
index 9a1fe2249f..78e263380c 100644
--- a/packages/router-core/src/load-matches.ts
+++ b/packages/router-core/src/load-matches.ts
@@ -3,6 +3,7 @@ import { createControlledPromise, isPromise } from './utils'
import { isNotFound } from './not-found'
import { rootRouteId } from './root'
import { isRedirect } from './redirect'
+import type { ControlledPromise } from './utils'
import type { NotFoundError } from './not-found'
import type { ParsedLocation } from './location'
import type {
@@ -31,6 +32,7 @@ type InnerLoadContext = {
matches: Array
/** Set only for preload passes. Contains active ids this preload must join, not mutate. */
preload?: Set
+ cancel?: ControlledPromise
forceStaleReload?: boolean
onReady?: (matches: Array) => Promise
sync?: boolean
@@ -43,15 +45,6 @@ const triggerOnReady = (inner: InnerLoadContext): void | Promise => {
}
}
-const trimMatches = (inner: InnerLoadContext, index: number): void => {
- if (index < inner.matches.length - 1) {
- for (let i = index + 1; i < inner.matches.length; i++) {
- clearMatchPromises(inner.matches[i]!)
- }
- inner.matches = inner.matches.slice(0, index + 1)
- }
-}
-
const getActiveMatch = (
inner: InnerLoadContext,
matchId: string,
@@ -87,18 +80,7 @@ const joinPreloadedActiveMatch = async (
waitForLoader: boolean,
): Promise => {
const matchId = inner.matches[index]!.id
- const getMatch = (): AnyRouteMatch => {
- const match = getActiveMatch(inner, matchId)
- if (match) {
- return match
- }
-
- inner.router.clearCache({
- filter: (match) => inner.matches.includes(match),
- })
- throw inner
- }
- let match = getMatch()
+ let match = getActiveMatch(inner, matchId)!
const route = inner.router.looseRoutesById[match.routeId]!
const beforeLoadPromise =
@@ -110,7 +92,14 @@ const joinPreloadedActiveMatch = async (
: undefined)
if (beforeLoadPromise?.status === 'pending') {
await beforeLoadPromise
- match = getMatch()
+ match = getActiveMatch(inner, matchId)!
+ if (!match) {
+ inner.router.clearCache({
+ filter: (match) => inner.matches.includes(match),
+ })
+ inner.cancel?.resolve()
+ throw inner
+ }
}
inner.matches[index] = match
@@ -121,7 +110,14 @@ const joinPreloadedActiveMatch = async (
match._nonReactive.loaderPromise || match._nonReactive.loadPromise
if (loaderPromise?.status === 'pending') {
await loaderPromise
- match = getMatch()
+ match = getActiveMatch(inner, matchId)!
+ if (!match) {
+ inner.router.clearCache({
+ filter: (match) => inner.matches.includes(match),
+ })
+ inner.cancel?.resolve()
+ throw inner
+ }
}
inner.matches[index] = match
error = match._nonReactive.error || match.error
@@ -525,12 +521,9 @@ const executeBeforeLoad = (
}
const beforeLoadPromise = createControlledPromise()
- const isCurrentBeforeLoad = () => {
- return (
- getLoadMatch(inner, matchId)?._nonReactive.beforeLoadPromise ===
- beforeLoadPromise
- )
- }
+ const isCurrentBeforeLoad = () =>
+ getLoadMatch(inner, matchId)?._nonReactive.beforeLoadPromise ===
+ beforeLoadPromise
// commits the result of the beforeLoad phase and settles its promise
const updateContext = (beforeLoadContext: any) => {
@@ -705,11 +698,8 @@ const runLoader = async (
const loaderBucket = match._nonReactive
const loaderPromise = loaderBucket.loaderPromise
const isCurrentLoader = () => loaderBucket.loaderPromise === loaderPromise
- const getCurrentMatch = () => {
- return isCurrentLoader()
- ? getLoadMatch(inner, matchId)
- : undefined
- }
+ const getCurrentMatch = () =>
+ isCurrentLoader() ? getLoadMatch(inner, matchId) : undefined
// Actually run the loader and handle the result
try {
@@ -1019,7 +1009,6 @@ const loadRouteMatch = async (
) {
return inner.matches[index]!
}
- inner.matches[index] = match
clearTimeout(match._nonReactive.pendingTimeout)
match._nonReactive.pendingTimeout = undefined
@@ -1038,7 +1027,7 @@ const loadRouteMatch = async (
settleLoadPromises(match)
}
- return match
+ return (inner.matches[index] = match)
}
export async function loadMatches(arg: {
@@ -1052,6 +1041,9 @@ export async function loadMatches(arg: {
sync?: boolean
}): Promise> {
const inner: InnerLoadContext = arg
+ const cancel = inner.preload
+ ? (inner.cancel = createControlledPromise())
+ : undefined
const matchPromises: Array> = []
// make sure the pending component is immediately rendered when hydrating a match that is not SSRed
@@ -1071,9 +1063,6 @@ export async function loadMatches(arg: {
const beforeLoad = handleBeforeLoad(inner, i)
if (isPromise(beforeLoad)) await beforeLoad
} catch (err) {
- if (err === inner) {
- return inner.matches
- }
if (isNotFound(err)) {
beforeLoadNotFound = err
} else if (isRedirect(err) || !inner.preload) {
@@ -1105,22 +1094,9 @@ export async function loadMatches(arg: {
try {
await Promise.all(matchPromises)
} catch (err) {
- if (err === inner) {
- return inner.matches
- }
-
- const preloadCancelled = createControlledPromise()
- for (const matchPromise of matchPromises) {
- matchPromise.catch((err) => {
- if (err === inner) {
- preloadCancelled.resolve()
- }
- })
- }
- const settled = await Promise.race([
- Promise.allSettled(matchPromises),
- preloadCancelled,
- ])
+ const settled = await (cancel
+ ? Promise.race([cancel, Promise.allSettled(matchPromises)])
+ : Promise.allSettled(matchPromises))
if (!settled) {
return inner.matches
}
@@ -1130,9 +1106,6 @@ export async function loadMatches(arg: {
if (result.status !== 'rejected') continue
const reason = result.reason
- if (reason === inner) {
- return inner.matches
- }
if (isRedirect(reason)) {
throw reason
}
@@ -1200,7 +1173,7 @@ export async function loadMatches(arg: {
isFetching: false,
_forcePending: undefined,
context,
- },
+ },
)
renderMaxIndex = renderedBoundaryIndex
@@ -1225,8 +1198,8 @@ export async function loadMatches(arg: {
await loadRouteChunk(errorRoute, ['errorComponent'])
}
- if (!notFoundToThrow) {
- trimMatches(inner, renderMaxIndex)
+ while (!notFoundToThrow && inner.matches.length > renderMaxIndex + 1) {
+ clearMatchPromises(inner.matches.pop()!)
}
// serially execute heads once after loaders/notFound handling, ensuring
@@ -1234,8 +1207,7 @@ export async function loadMatches(arg: {
for (let i = 0; i <= renderMaxIndex; i++) {
const match = inner.matches[i]!
const { id: matchId, routeId } = match
- const route = inner.router.looseRoutesById[routeId]!
- const routeOptions = route.options
+ const routeOptions = inner.router.looseRoutesById[routeId]!.options
if (isJoinedPreload(inner, matchId)) {
continue
}
diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts
index 84b1bf100a..35a0ef8a5b 100644
--- a/packages/router-core/src/router.ts
+++ b/packages/router-core/src/router.ts
@@ -2520,6 +2520,7 @@ export class RouterCore<
// removed, place it in the cachedMatches.
//
const currentMatches = this.stores.matches.get()
+ const hasPendingMatches = pendingMatches.length
this.batch(() => {
this.stores.isLoading.set(false)
@@ -2529,7 +2530,7 @@ export class RouterCore<
* else must be dropped and have its stale loadPromise
* released so abandoned renders cannot stay suspended.
*/
- if (pendingMatches.length) {
+ if (hasPendingMatches) {
this.stores.setMatches(pendingMatches)
this.stores.setPending([])
const nextCachedMatches = [
@@ -2556,7 +2557,7 @@ export class RouterCore<
for (const match of currentMatches) {
if (
- pendingMatches.length &&
+ hasPendingMatches &&
!pendingMatches.some((d) => d.routeId === match.routeId)
) {
this.looseRoutesById[match.routeId]!.options.onLeave?.(
@@ -2565,7 +2566,7 @@ export class RouterCore<
}
}
- for (const match of pendingMatches.length
+ for (const match of hasPendingMatches
? pendingMatches
: currentMatches) {
const hook = currentMatches.some(
From 72ddee907b23605a538a9428bf0c6b8a05a03109 Mon Sep 17 00:00:00 2001
From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com>
Date: Wed, 17 Jun 2026 21:07:36 +0200
Subject: [PATCH 11/13] wp
---
packages/router-core/src/load-matches.ts | 206 +++++++++---------
packages/router-core/src/router.ts | 38 ++--
.../router-core/tests/granular-stores.test.ts | 2 +-
packages/router-core/tests/load.test.ts | 104 +++++++++
4 files changed, 226 insertions(+), 124 deletions(-)
diff --git a/packages/router-core/src/load-matches.ts b/packages/router-core/src/load-matches.ts
index 78e263380c..34cf6a072b 100644
--- a/packages/router-core/src/load-matches.ts
+++ b/packages/router-core/src/load-matches.ts
@@ -32,7 +32,7 @@ type InnerLoadContext = {
matches: Array
/** Set only for preload passes. Contains active ids this preload must join, not mutate. */
preload?: Set
- cancel?: ControlledPromise
+ cancel?: ControlledPromise
forceStaleReload?: boolean
onReady?: (matches: Array) => Promise
sync?: boolean
@@ -45,27 +45,6 @@ const triggerOnReady = (inner: InnerLoadContext): void | Promise => {
}
}
-const getActiveMatch = (
- inner: InnerLoadContext,
- matchId: string,
-): AnyRouteMatch | undefined => {
- return (
- inner.router.stores.pendingMatchStores.get(matchId)?.get() ??
- inner.router.stores.matchStores.get(matchId)?.get()
- )
-}
-
-const getLoadMatch = (
- inner: InnerLoadContext,
- matchId: string,
-): AnyRouteMatch | undefined => {
- return (
- inner.router.stores.pendingMatchStores.get(matchId)?.get() ??
- inner.router.stores.matchStores.get(matchId)?.get() ??
- inner.router.stores.cachedMatchStores.get(matchId)?.get()
- )
-}
-
const isPreloadMatch = (inner: InnerLoadContext, matchId: string): boolean => {
return !!inner.preload && !isJoinedPreload(inner, matchId)
}
@@ -80,7 +59,23 @@ const joinPreloadedActiveMatch = async (
waitForLoader: boolean,
): Promise => {
const matchId = inner.matches[index]!.id
- let match = getActiveMatch(inner, matchId)!
+ const cancelJoinedPreload = (): void => {
+ inner.router.clearCache({
+ filter: (match) => inner.matches.includes(match),
+ })
+ inner.cancel?.resolve(inner)
+ }
+ const throwCancelledPreload = (): never => {
+ cancelJoinedPreload()
+ throw inner
+ }
+ const cancelIfOwnerMissing = () => {
+ if (!inner.router.getMatch(matchId, 'live')) {
+ cancelJoinedPreload()
+ }
+ }
+
+ let match = inner.router.getMatch(matchId, 'live') ?? throwCancelledPreload()
const route = inner.router.looseRoutesById[match.routeId]!
const beforeLoadPromise =
@@ -92,14 +87,7 @@ const joinPreloadedActiveMatch = async (
: undefined)
if (beforeLoadPromise?.status === 'pending') {
await beforeLoadPromise
- match = getActiveMatch(inner, matchId)!
- if (!match) {
- inner.router.clearCache({
- filter: (match) => inner.matches.includes(match),
- })
- inner.cancel?.resolve()
- throw inner
- }
+ match = inner.router.getMatch(matchId, 'live') ?? throwCancelledPreload()
}
inner.matches[index] = match
@@ -110,17 +98,17 @@ const joinPreloadedActiveMatch = async (
match._nonReactive.loaderPromise || match._nonReactive.loadPromise
if (loaderPromise?.status === 'pending') {
await loaderPromise
- match = getActiveMatch(inner, matchId)!
- if (!match) {
- inner.router.clearCache({
- filter: (match) => inner.matches.includes(match),
- })
- inner.cancel?.resolve()
- throw inner
- }
+ match = inner.router.getMatch(matchId, 'live') ?? throwCancelledPreload()
}
inner.matches[index] = match
error = match._nonReactive.error || match.error
+ } else if (!waitForLoader && match.status === 'pending') {
+ const loaderPromise =
+ match._nonReactive.loaderPromise || match._nonReactive.loadPromise
+ if (loaderPromise?.status === 'pending') {
+ inner.cancel ??= createControlledPromise()
+ loaderPromise.then(cancelIfOwnerMissing, cancelIfOwnerMissing)
+ }
}
handleRedirectOrNotFound(inner, match, error)
@@ -156,11 +144,15 @@ const commitMatch = (
matchId: string,
patch: Partial,
): void => {
+ if (isJoinedPreload(inner, matchId)) {
+ return
+ }
+
inner.updateMatch(matchId, (prev) => ({
...prev,
...patch,
}))
- const match = getLoadMatch(inner, matchId)
+ const match = inner.router.getMatch(matchId)
if (match) {
inner.matches[match.index] = match
}
@@ -318,7 +310,7 @@ const handleSerialError = (
inner.firstBadMatchIndex ??= index
match.__beforeLoadContext = undefined
- const currentMatch = getLoadMatch(inner, matchId)!
+ const currentMatch = inner.router.getMatch(matchId)!
currentMatch.__beforeLoadContext = undefined
handleRedirectOrNotFound(inner, currentMatch, err)
@@ -344,7 +336,7 @@ const handleSerialError = (
abortController: new AbortController(),
})
- const updatedMatch = getLoadMatch(inner, matchId)
+ const updatedMatch = inner.router.getMatch(matchId)
if (updatedMatch) {
clearMatchPromises(updatedMatch)
}
@@ -356,10 +348,10 @@ const isBeforeLoadSsr = (
index: number,
route: AnyRoute,
): void | Promise => {
- const existingMatch = getLoadMatch(inner, matchId)!
+ const existingMatch = inner.router.getMatch(matchId)!
const parentMatchId = inner.matches[index - 1]?.id
const parentMatch = parentMatchId
- ? getLoadMatch(inner, parentMatchId)!
+ ? inner.router.getMatch(parentMatchId)!
: undefined
// in SPA mode, only SSR the root route
@@ -463,7 +455,7 @@ const executeBeforeLoad = (
index: number,
route: AnyRoute,
): void | Promise => {
- const match = getLoadMatch(inner, matchId)!
+ const match = inner.router.getMatch(matchId)!
// explicitly capture the previous loadPromise
let prevLoadPromise = match._nonReactive.loadPromise
@@ -488,7 +480,7 @@ const executeBeforeLoad = (
return
}
isPending = true
- const currentMatch = getLoadMatch(inner, matchId)!
+ const currentMatch = inner.router.getMatch(matchId)!
commitMatch(inner, matchId, {
isFetching: 'beforeLoad',
fetchCount: currentMatch.fetchCount + 1,
@@ -522,7 +514,7 @@ const executeBeforeLoad = (
const beforeLoadPromise = createControlledPromise()
const isCurrentBeforeLoad = () =>
- getLoadMatch(inner, matchId)?._nonReactive.beforeLoadPromise ===
+ inner.router.getMatch(matchId)?._nonReactive.beforeLoadPromise ===
beforeLoadPromise
// commits the result of the beforeLoad phase and settles its promise
@@ -613,7 +605,7 @@ const handleBeforeLoad = (
}
const queueExecution = () => {
- const existingMatch = getLoadMatch(inner, matchId)
+ const existingMatch = inner.router.getMatch(matchId)
if (!existingMatch || shouldSkipMatchLoad(inner, existingMatch)) {
return
}
@@ -624,7 +616,7 @@ const handleBeforeLoad = (
if (pendingBeforeLoad) {
setupPendingTimeout(inner, matchId, route, existingMatch)
return pendingBeforeLoad.then(() => {
- const match = getLoadMatch(inner, matchId)
+ const match = inner.router.getMatch(matchId)
if (!match || shouldSkipMatchLoad(inner, match)) {
return
}
@@ -656,10 +648,8 @@ const getLoaderContext = (
route: AnyRoute,
): LoaderFnContext => {
const parentMatchPromise = matchPromises[index - 1] as any
- const { params, loaderDeps, abortController, cause } = getLoadMatch(
- inner,
- matchId,
- )!
+ const { params, loaderDeps, abortController, cause } =
+ inner.router.getMatch(matchId)!
const context = buildMatchContext(inner, index)
@@ -694,12 +684,12 @@ const runLoader = async (
// If the Matches component rendered the pending component and needs to show
// it for a minimum duration, we'll wait for it to resolve before committing
// to the match and resolving the loadPromise.
- const match = getLoadMatch(inner, matchId)!
+ const match = inner.router.getMatch(matchId)!
const loaderBucket = match._nonReactive
const loaderPromise = loaderBucket.loaderPromise
const isCurrentLoader = () => loaderBucket.loaderPromise === loaderPromise
const getCurrentMatch = () =>
- isCurrentLoader() ? getLoadMatch(inner, matchId) : undefined
+ isCurrentLoader() ? inner.router.getMatch(matchId) : undefined
// Actually run the loader and handle the result
try {
@@ -848,7 +838,7 @@ const loadRouteMatch = async (
return await joinPreloadedActiveMatch(inner, index, true)
}
- const prevMatch = getLoadMatch(inner, matchId)
+ const prevMatch = inner.router.getMatch(matchId)
if (!prevMatch) {
// in case of a redirecting match during preload, the match does not exist
return inner.matches[index]!
@@ -863,7 +853,7 @@ const loadRouteMatch = async (
})
if (isServer ?? inner.router.isServer) {
- return getLoadMatch(inner, matchId)!
+ return inner.router.getMatch(matchId)!
}
} else {
const routeLoader = route.options.loader
@@ -894,10 +884,10 @@ const loadRouteMatch = async (
invalid: false,
})
}
- return getLoadMatch(inner, matchId)!
+ return inner.router.getMatch(matchId)!
}
await loaderGeneration
- const match = getLoadMatch(inner, matchId)
+ const match = inner.router.getMatch(matchId)
if (match) {
const error = match._nonReactive.error || match.error
if (error) {
@@ -984,7 +974,7 @@ const loadRouteMatch = async (
return
}
}
- const latestMatch = getLoadMatch(inner, matchId)
+ const latestMatch = inner.router.getMatch(matchId)
if (
latestMatch &&
latestMatch._nonReactive.loaderPromise === backgroundGeneration
@@ -993,12 +983,15 @@ const loadRouteMatch = async (
}
})()
} else if (loaderShouldRun) {
- await runLoader(inner, matchPromises, matchId, index, route)
+ const run = runLoader(inner, matchPromises, matchId, index, route)
+ await (preload && loaderGeneration
+ ? Promise.race([run, loaderGeneration])
+ : run)
}
}
}
- let match = getLoadMatch(inner, matchId)
+ let match = inner.router.getMatch(matchId)
if (!match) {
return inner.matches[index]!
}
@@ -1020,7 +1013,7 @@ const loadRouteMatch = async (
isFetching: nextIsFetching,
invalid: false,
})
- match = getLoadMatch(inner, matchId)!
+ match = inner.router.getMatch(matchId)!
}
if (!loaderIsRunningAsync) {
@@ -1041,9 +1034,6 @@ export async function loadMatches(arg: {
sync?: boolean
}): Promise> {
const inner: InnerLoadContext = arg
- const cancel = inner.preload
- ? (inner.cancel = createControlledPromise())
- : undefined
const matchPromises: Array> = []
// make sure the pending component is immediately rendered when hydrating a match that is not SSRed
@@ -1061,8 +1051,18 @@ export async function loadMatches(arg: {
for (let i = 0; i < inner.matches.length; i++) {
try {
const beforeLoad = handleBeforeLoad(inner, i)
- if (isPromise(beforeLoad)) await beforeLoad
+ if (isPromise(beforeLoad)) {
+ const result = await (inner.cancel
+ ? Promise.race([beforeLoad, inner.cancel])
+ : beforeLoad)
+ if (result === inner) {
+ return inner.matches
+ }
+ }
} catch (err) {
+ if (err === inner) {
+ return inner.matches
+ }
if (isNotFound(err)) {
beforeLoadNotFound = err
} else if (isRedirect(err) || !inner.preload) {
@@ -1091,15 +1091,27 @@ export async function loadMatches(arg: {
matchPromises.push(loadRouteMatch(inner, matchPromises, i))
}
- try {
- await Promise.all(matchPromises)
- } catch (err) {
- const settled = await (cancel
- ? Promise.race([cancel, Promise.allSettled(matchPromises)])
- : Promise.allSettled(matchPromises))
- if (!settled) {
+ let settled: Array> | undefined
+ if (inner.preload) {
+ settled = await Promise.allSettled(matchPromises)
+ } else {
+ try {
+ await Promise.all(matchPromises)
+ } catch {
+ settled = await Promise.allSettled(matchPromises)
+ }
+ }
+
+ if (settled) {
+ if (
+ inner.preload &&
+ settled.some(
+ (result) => result.status === 'rejected' && result.reason === inner,
+ )
+ ) {
return inner.matches
}
+
let firstUnhandledRejection: unknown
for (const result of settled) {
@@ -1122,8 +1134,7 @@ export async function loadMatches(arg: {
}
const notFoundToThrow = firstNotFound ?? beforeLoadNotFound
-
- let renderMaxIndex = inner.firstBadMatchIndex ?? inner.matches.length - 1
+ let headMatches = inner.matches
if (notFoundToThrow) {
// Determine once which matched route will actually render the
@@ -1173,39 +1184,34 @@ export async function loadMatches(arg: {
isFetching: false,
_forcePending: undefined,
context,
- },
+ },
)
- renderMaxIndex = renderedBoundaryIndex
+ headMatches = inner.matches.slice(0, renderedBoundaryIndex + 1)
// Ensure the rendering boundary route chunk (and its lazy components, including
// lazy notFoundComponent) is loaded before we continue to head execution/render.
await loadRouteChunk(boundaryRoute, ['notFoundComponent'])
- }
-
- // When a serial error occurred (e.g. beforeLoad threw a regular Error),
- // the erroring route's lazy chunk wasn't loaded because loaders were skipped.
- // We need to load it so the code-split errorComponent is available for rendering.
- if (
- !notFoundToThrow &&
- !inner.preload &&
- inner.firstBadMatchIndex !== undefined
- ) {
- const errorRoute =
- inner.router.looseRoutesById[
- inner.matches[inner.firstBadMatchIndex]!.routeId
- ]!
- await loadRouteChunk(errorRoute, ['errorComponent'])
- }
+ } else if (inner.firstBadMatchIndex !== undefined) {
+ // When a serial error occurred (e.g. beforeLoad threw a regular Error),
+ // the erroring route's lazy chunk wasn't loaded because loaders were skipped.
+ // We need to load it so the code-split errorComponent is available for rendering.
+ if (!inner.preload) {
+ const errorRoute =
+ inner.router.looseRoutesById[
+ inner.matches[inner.firstBadMatchIndex]!.routeId
+ ]!
+ await loadRouteChunk(errorRoute, ['errorComponent'])
+ }
- while (!notFoundToThrow && inner.matches.length > renderMaxIndex + 1) {
- clearMatchPromises(inner.matches.pop()!)
+ for (const match of inner.matches.splice(inner.firstBadMatchIndex + 1)) {
+ clearMatchPromises(match)
+ }
}
// serially execute heads once after loaders/notFound handling, ensuring
// all head functions get a chance even if one throws.
- for (let i = 0; i <= renderMaxIndex; i++) {
- const match = inner.matches[i]!
+ for (const match of headMatches) {
const { id: matchId, routeId } = match
const routeOptions = inner.router.looseRoutesById[routeId]!.options
if (isJoinedPreload(inner, matchId)) {
@@ -1213,7 +1219,7 @@ export async function loadMatches(arg: {
}
try {
const headMatch =
- getLoadMatch(inner, matchId) ?? (inner.preload && match)
+ inner.router.getMatch(matchId) ?? (inner.preload && match)
if (
headMatch &&
(routeOptions.head || routeOptions.scripts || routeOptions.headers)
@@ -1262,7 +1268,7 @@ export async function loadMatches(arg: {
inner.firstBadMatchIndex !== undefined
) {
const errorMatch =
- getLoadMatch(inner, inner.matches[inner.firstBadMatchIndex]!.id) ??
+ inner.router.getMatch(inner.matches[inner.firstBadMatchIndex]!.id) ??
inner.matches[inner.firstBadMatchIndex]!
if (errorMatch.status === 'error') {
throw errorMatch.error
diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts
index 35a0ef8a5b..374121af78 100644
--- a/packages/router-core/src/router.ts
+++ b/packages/router-core/src/router.ts
@@ -779,7 +779,10 @@ export interface MatchRoutesFn {
): Array
}
-export type GetMatchFn = (matchId: string) => AnyRouteMatch | undefined
+export type GetMatchFn = (
+ matchId: string,
+ mode?: 'live',
+) => AnyRouteMatch | undefined
export type UpdateMatchFn = (
id: string,
@@ -1437,10 +1440,6 @@ export class RouterCore<
): Array {
const throwOnError = opts?.throwOnError
const preload = opts?.preload
- const getExistingMatch = (matchId: string) =>
- this.stores.pendingMatchStores.get(matchId)?.get() ??
- this.stores.matchStores.get(matchId)?.get() ??
- this.stores.cachedMatchStores.get(matchId)?.get()
const matchedRoutesResult = this.getMatchedRoutes(next.pathname)
const { foundRoute, routeParams } = matchedRoutesResult
let { matchedRoutes } = matchedRoutesResult
@@ -1558,7 +1557,7 @@ export class RouterCore<
// explicit deps
loaderDepsHash
- const existingMatch = getExistingMatch(matchId)
+ const existingMatch = this.getMatch(matchId)
const previousMatch = previousActiveMatchesByRouteId.get(route.id)
@@ -1684,7 +1683,7 @@ export class RouterCore<
for (let index = 0; index < matches.length; index++) {
const match = matches[index]!
const route = this.looseRoutesById[match.routeId]!
- const existingMatch = getExistingMatch(match.id)
+ const existingMatch = this.getMatch(match.id)
// Update the match's params
const previousMatch = previousActiveMatchesByRouteId.get(match.routeId)
@@ -2520,7 +2519,6 @@ export class RouterCore<
// removed, place it in the cachedMatches.
//
const currentMatches = this.stores.matches.get()
- const hasPendingMatches = pendingMatches.length
this.batch(() => {
this.stores.isLoading.set(false)
@@ -2530,7 +2528,7 @@ export class RouterCore<
* else must be dropped and have its stale loadPromise
* released so abandoned renders cannot stay suspended.
*/
- if (hasPendingMatches) {
+ if (pendingMatches.length) {
this.stores.setMatches(pendingMatches)
this.stores.setPending([])
const nextCachedMatches = [
@@ -2557,7 +2555,7 @@ export class RouterCore<
for (const match of currentMatches) {
if (
- hasPendingMatches &&
+ pendingMatches.length &&
!pendingMatches.some((d) => d.routeId === match.routeId)
) {
this.looseRoutesById[match.routeId]!.options.onLeave?.(
@@ -2566,7 +2564,7 @@ export class RouterCore<
}
}
- for (const match of hasPendingMatches
+ for (const match of pendingMatches.length
? pendingMatches
: currentMatches) {
const hook = currentMatches.some(
@@ -2713,12 +2711,13 @@ export class RouterCore<
})
}
- getMatch: GetMatchFn = (matchId: string): AnyRouteMatch | undefined => {
- return (
- this.stores.cachedMatchStores.get(matchId)?.get() ??
+ getMatch: GetMatchFn = (matchId, mode) => {
+ const match =
this.stores.pendingMatchStores.get(matchId)?.get() ??
this.stores.matchStores.get(matchId)?.get()
- )
+ return mode === 'live'
+ ? match
+ : (match ?? this.stores.cachedMatchStores.get(matchId)?.get())
}
/**
@@ -2893,14 +2892,7 @@ export class RouterCore<
matches,
location: next,
preload: activeMatchIds,
- updateMatch: (id, updater) => {
- // Don't update matches that were active when the preload started.
- if (activeMatchIds.has(id)) {
- return
- }
-
- this.updateMatch(id, updater)
- },
+ updateMatch: this.updateMatch,
})
return matches
diff --git a/packages/router-core/tests/granular-stores.test.ts b/packages/router-core/tests/granular-stores.test.ts
index 54d2a4efab..18db398dca 100644
--- a/packages/router-core/tests/granular-stores.test.ts
+++ b/packages/router-core/tests/granular-stores.test.ts
@@ -349,6 +349,6 @@ describe('granular stores', () => {
).toBe('pending')
expect(router.stores.pendingMatches.get()[0]?.status).toBe('pending')
expect(router.stores.cachedMatches.get()[0]?.status).toBe('success')
- expect(router.getMatch(duplicatedId)?.status).toBe('success')
+ expect(router.getMatch(duplicatedId)?.status).toBe('pending')
})
})
diff --git a/packages/router-core/tests/load.test.ts b/packages/router-core/tests/load.test.ts
index a841a9fc1a..74d6541f4e 100644
--- a/packages/router-core/tests/load.test.ts
+++ b/packages/router-core/tests/load.test.ts
@@ -1095,6 +1095,73 @@ describe('beforeLoad skip or exec', () => {
}
})
+ test('preload resolves when joined active loader owner exits with a never-settling descendant beforeLoad', async () => {
+ vi.useFakeTimers()
+
+ try {
+ const parentLoaderPromise = createControlledPromise<{ auth: string }>()
+ const childBeforeLoadPromise = createControlledPromise()
+ const parentLoader = vi.fn(() => parentLoaderPromise)
+ const childBeforeLoad = vi.fn(() => childBeforeLoadPromise)
+
+ const rootRoute = new BaseRootRoute({})
+ const indexRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ })
+ const parentRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/parent',
+ loader: parentLoader,
+ pendingMs: 1,
+ pendingComponent: {},
+ })
+ const childRoute = new BaseRoute({
+ getParentRoute: () => parentRoute,
+ path: '/child',
+ beforeLoad: childBeforeLoad,
+ })
+ const otherRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/other',
+ })
+
+ const router = createTestRouter({
+ routeTree: rootRoute.addChildren([
+ indexRoute,
+ parentRoute.addChildren([childRoute]),
+ otherRoute,
+ ]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+
+ await router.load()
+
+ const parentNavigation = router.navigate({ to: '/parent' })
+ await vi.waitFor(() => expect(parentLoader).toHaveBeenCalledTimes(1))
+
+ const preloadSettled = vi.fn()
+ const preload = router.preloadRoute({ to: '/parent/child' })
+ preload.then(preloadSettled)
+ await vi.waitFor(() => expect(childBeforeLoad).toHaveBeenCalledTimes(1))
+
+ await router.navigate({ to: '/other' })
+ await vi.waitFor(() => expect(preloadSettled).toHaveBeenCalledTimes(1))
+
+ expect(
+ router.stores.cachedMatches
+ .get()
+ .some((match) => match.routeId === childRoute.id),
+ ).toBe(false)
+
+ parentLoaderPromise.resolve({ auth: 'late' })
+ childBeforeLoadPromise.resolve()
+ await Promise.all([parentNavigation, preload])
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
test.each([
{
name: 'without a never-settling descendant',
@@ -1723,6 +1790,43 @@ describe('loader skip or exec', () => {
})
})
+ test('preload notFound targeting active parent does not mutate borrowed parent boundary', async () => {
+ const rootRoute = new BaseRootRoute({})
+ const parentRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/parent',
+ notFoundComponent: () => null,
+ })
+ const childRoute = new BaseRoute({
+ getParentRoute: () => parentRoute,
+ path: '/child',
+ loader: () => notFound({ routeId: parentRoute.id }),
+ })
+
+ const routeTree = rootRoute.addChildren([
+ parentRoute.addChildren([childRoute]),
+ ])
+ const router = createTestRouter({
+ routeTree,
+ history: createMemoryHistory(),
+ })
+
+ await router.navigate({ to: '/parent' })
+
+ const activeParentBefore = router.state.matches.find(
+ (match) => match.routeId === parentRoute.id,
+ )
+ expect(activeParentBefore?.status).toBe('success')
+
+ await router.preloadRoute({ to: '/parent/child' })
+
+ const activeParentAfter = router.state.matches.find(
+ (match) => match.routeId === parentRoute.id,
+ )
+ expect(activeParentAfter?.status).toBe('success')
+ expect(activeParentAfter?.error).toBeUndefined()
+ })
+
test('exec if resolved preload (success)', async () => {
const loader = vi.fn()
const router = setup({ loader })
From 1d394e1ac1b59f5f5cfe910ee1bda717ac2fa42b Mon Sep 17 00:00:00 2001
From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com>
Date: Wed, 17 Jun 2026 21:07:36 +0200
Subject: [PATCH 12/13] wp
---
packages/react-router/src/Match.tsx | 8 +-
packages/router-core/src/load-matches.ts | 37 ++-
packages/router-core/src/router.ts | 217 ++++++++----------
packages/router-core/tests/load.test.ts | 10 +-
.../src/core/hmr/handle-route-update.ts | 5 +-
packages/solid-router/src/Match.tsx | 49 ++--
packages/vue-router/src/Match.tsx | 12 +-
.../store-updates-during-navigation.test.tsx | 14 +-
8 files changed, 161 insertions(+), 191 deletions(-)
diff --git a/packages/react-router/src/Match.tsx b/packages/react-router/src/Match.tsx
index 05744ffa0c..97b996041d 100644
--- a/packages/react-router/src/Match.tsx
+++ b/packages/react-router/src/Match.tsx
@@ -326,8 +326,7 @@ export const MatchInner = React.memo(function MatchInnerImpl({
const routeId = match.routeId as string
const route = router.routesById[routeId] as AnyRoute
const remountFn =
- (router.routesById[routeId] as AnyRoute).options.remountDeps ??
- router.options.defaultRemountDeps
+ route.options.remountDeps ?? router.options.defaultRemountDeps
const remountDeps = remountFn?.({
routeId,
loaderDeps: match.loaderDeps,
@@ -397,8 +396,7 @@ export const MatchInner = React.memo(function MatchInnerImpl({
// eslint-disable-next-line react-hooks/rules-of-hooks
const key = React.useMemo(() => {
const remountFn =
- (router.routesById[routeId] as AnyRoute).options.remountDeps ??
- router.options.defaultRemountDeps
+ route.options.remountDeps ?? router.options.defaultRemountDeps
const remountDeps = remountFn?.({
routeId,
loaderDeps: match.loaderDeps,
@@ -411,8 +409,8 @@ export const MatchInner = React.memo(function MatchInnerImpl({
match.loaderDeps,
match._strictParams,
match._strictSearch,
+ route.options.remountDeps,
router.options.defaultRemountDeps,
- router.routesById,
])
// eslint-disable-next-line react-hooks/rules-of-hooks
diff --git a/packages/router-core/src/load-matches.ts b/packages/router-core/src/load-matches.ts
index 34cf6a072b..28fc8c7fd6 100644
--- a/packages/router-core/src/load-matches.ts
+++ b/packages/router-core/src/load-matches.ts
@@ -13,7 +13,7 @@ import type {
SsrContextOptions,
} from './route'
import type { AnyRouteMatch, MakeRouteMatch } from './Matches'
-import type { AnyRouter, SSROption, UpdateMatchFn } from './router'
+import type { AnyRouter, SSROption } from './router'
/**
* An object of this shape is created when calling `loadMatches`.
@@ -28,7 +28,6 @@ type InnerLoadContext = {
firstBadMatchIndex?: number
/** mutable state, scoped to a `loadMatches` call */
rendered?: boolean
- updateMatch: UpdateMatchFn
matches: Array
/** Set only for preload passes. Contains active ids this preload must join, not mutate. */
preload?: Set
@@ -70,12 +69,13 @@ const joinPreloadedActiveMatch = async (
throw inner
}
const cancelIfOwnerMissing = () => {
- if (!inner.router.getMatch(matchId, 'live')) {
+ // Cached matches do not prove a borrowed active/pending owner still exists.
+ if (!inner.router.getMatch(matchId, false)) {
cancelJoinedPreload()
}
}
- let match = inner.router.getMatch(matchId, 'live') ?? throwCancelledPreload()
+ let match = inner.router.getMatch(matchId, false) ?? throwCancelledPreload()
const route = inner.router.looseRoutesById[match.routeId]!
const beforeLoadPromise =
@@ -87,7 +87,7 @@ const joinPreloadedActiveMatch = async (
: undefined)
if (beforeLoadPromise?.status === 'pending') {
await beforeLoadPromise
- match = inner.router.getMatch(matchId, 'live') ?? throwCancelledPreload()
+ match = inner.router.getMatch(matchId, false) ?? throwCancelledPreload()
}
inner.matches[index] = match
@@ -98,7 +98,7 @@ const joinPreloadedActiveMatch = async (
match._nonReactive.loaderPromise || match._nonReactive.loadPromise
if (loaderPromise?.status === 'pending') {
await loaderPromise
- match = inner.router.getMatch(matchId, 'live') ?? throwCancelledPreload()
+ match = inner.router.getMatch(matchId, false) ?? throwCancelledPreload()
}
inner.matches[index] = match
error = match._nonReactive.error || match.error
@@ -148,7 +148,7 @@ const commitMatch = (
return
}
- inner.updateMatch(matchId, (prev) => ({
+ inner.router.updateMatch(matchId, (prev) => ({
...prev,
...patch,
}))
@@ -1030,7 +1030,6 @@ export async function loadMatches(arg: {
preload?: Set
forceStaleReload?: boolean
onReady?: (matches: Array) => Promise
- updateMatch: UpdateMatchFn
sync?: boolean
}): Promise> {
const inner: InnerLoadContext = arg
@@ -1103,31 +1102,29 @@ export async function loadMatches(arg: {
}
if (settled) {
- if (
- inner.preload &&
- settled.some(
- (result) => result.status === 'rejected' && result.reason === inner,
- )
- ) {
- return inner.matches
- }
-
+ let firstRedirect: unknown
let firstUnhandledRejection: unknown
for (const result of settled) {
if (result.status !== 'rejected') continue
const reason = result.reason
- if (isRedirect(reason)) {
- throw reason
+ if (inner.preload && reason === inner) {
+ return inner.matches
}
- if (isNotFound(reason)) {
+ if (isRedirect(reason)) {
+ firstRedirect ??= reason
+ } else if (isNotFound(reason)) {
firstNotFound ??= reason
} else {
firstUnhandledRejection ??= reason
}
}
+ if (firstRedirect) {
+ throw firstRedirect
+ }
+
if (firstUnhandledRejection !== undefined) {
throw firstUnhandledRejection
}
diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts
index 374121af78..f20a5737bc 100644
--- a/packages/router-core/src/router.ts
+++ b/packages/router-core/src/router.ts
@@ -779,9 +779,13 @@ export interface MatchRoutesFn {
): Array
}
+/**
+ * Internal match lookup. By default this includes cached matches; pass
+ * `false` when checking whether a match still has an active/pending owner.
+ */
export type GetMatchFn = (
matchId: string,
- mode?: 'live',
+ includeCached?: boolean,
) => AnyRouteMatch | undefined
export type UpdateMatchFn = (
@@ -1430,10 +1434,6 @@ export class RouterCore<
return this.matchRoutesInternal(pathnameOrNext, locationSearchOrOpts)
}
- private getParentContext(parentMatch?: AnyRouteMatch) {
- return parentMatch?.context ?? this.options.context ?? undefined
- }
-
private matchRoutesInternal(
next: ParsedLocation,
opts?: MatchRoutesOpts,
@@ -1467,14 +1467,9 @@ export class RouterCore<
: undefined
const matches = new Array(matchedRoutes.length)
- // Snapshot of active match state keyed by routeId, used to stabilise
- // params/search across navigations.
- const previousActiveMatchesByRouteId = new Map()
- for (const store of this.stores.matchStores.values()) {
- if (store.routeId) {
- previousActiveMatchesByRouteId.set(store.routeId, store.get())
- }
- }
+ const previousActiveMatches = this.stores.matches.get()
+ const getPreviousMatch = (routeId: string) =>
+ previousActiveMatches.find((match) => match.routeId === routeId)
for (let index = 0; index < matchedRoutes.length; index++) {
const route = matchedRoutes[index]!
@@ -1559,7 +1554,7 @@ export class RouterCore<
const existingMatch = this.getMatch(matchId)
- const previousMatch = previousActiveMatchesByRouteId.get(route.id)
+ const previousMatch = getPreviousMatch(route.id)
const strictParams = existingMatch?._strictParams ?? usedParams
@@ -1669,7 +1664,7 @@ export class RouterCore<
// update the searchError if there is one
match.searchError = searchError
- const parentContext = this.getParentContext(parentMatch)
+ const parentContext = parentMatch?.context ?? this.options.context
match.context = {
...parentContext,
@@ -1682,18 +1677,18 @@ export class RouterCore<
for (let index = 0; index < matches.length; index++) {
const match = matches[index]!
- const route = this.looseRoutesById[match.routeId]!
+ const route = matchedRoutes[index]!
const existingMatch = this.getMatch(match.id)
// Update the match's params
- const previousMatch = previousActiveMatchesByRouteId.get(match.routeId)
+ const previousMatch = getPreviousMatch(match.routeId)
match.params = previousMatch
? nullReplaceEqualDeep(previousMatch.params, routeParams)
: routeParams
if (!existingMatch) {
const parentMatch = matches[index - 1]
- const parentContext = this.getParentContext(parentMatch)
+ const parentContext = parentMatch?.context ?? this.options.context
// Update the match's context
@@ -2499,87 +2494,75 @@ export class RouterCore<
forceStaleReload: previousLocation.href === next.href,
matches: this.stores.pendingMatches.get(),
location: next,
- updateMatch: this.updateMatch,
onReady: (pendingMatches) =>
new Promise((resolve, reject) => {
// Wrap batch in framework-specific transition wrapper (e.g., Solid's startTransition)
this.startTransition(() => {
- if (this.latestLoadPromise !== loadPromise) {
- resolve()
- return
- }
-
- try {
- this.startViewTransition(async () => {
- if (this.latestLoadPromise !== loadPromise) {
- return
- }
-
- // Commit the pending matches. If a previous match was
- // removed, place it in the cachedMatches.
- //
- const currentMatches = this.stores.matches.get()
-
- this.batch(() => {
- this.stores.isLoading.set(false)
- this.stores.loadedAt.set(Date.now())
- /**
- * Only successful exiting matches are reusable. Everything
- * else must be dropped and have its stale loadPromise
- * released so abandoned renders cannot stay suspended.
- */
- if (pendingMatches.length) {
- this.stores.setMatches(pendingMatches)
- this.stores.setPending([])
- const nextCachedMatches = [
- ...this.stores.cachedMatches.get(),
- ]
- for (const match of currentMatches) {
- // Exiting uses match.id (routeId + params + loaderDeps),
- // so changing loader deps correctly caches the old entry.
- if (!pendingMatches.some((d) => d.id === match.id)) {
- if (
- match.status === 'success' &&
- !isRedirect(match._nonReactive.error)
- ) {
- nextCachedMatches.push(match)
- } else {
- clearMatchPromises(match)
- }
+ this.startViewTransition(async () => {
+ if (this.latestLoadPromise !== loadPromise) {
+ return
+ }
+
+ // Commit the pending matches. If a previous match was
+ // removed, place it in the cachedMatches.
+ //
+ const currentMatches = this.stores.matches.get()
+
+ this.batch(() => {
+ this.stores.isLoading.set(false)
+ this.stores.loadedAt.set(Date.now())
+ /**
+ * Only successful exiting matches are reusable. Everything
+ * else must be dropped and have its stale loadPromise
+ * released so abandoned renders cannot stay suspended.
+ */
+ if (pendingMatches.length) {
+ this.stores.setMatches(pendingMatches)
+ this.stores.setPending([])
+ const nextCachedMatches = [
+ ...this.stores.cachedMatches.get(),
+ ]
+ for (const match of currentMatches) {
+ // Exiting uses match.id (routeId + params + loaderDeps),
+ // so changing loader deps correctly caches the old entry.
+ if (!pendingMatches.some((d) => d.id === match.id)) {
+ if (
+ match.status === 'success' &&
+ !isRedirect(match._nonReactive.error)
+ ) {
+ nextCachedMatches.push(match)
+ } else {
+ clearMatchPromises(match)
}
}
- this.stores.setCached(nextCachedMatches)
- this.clearExpiredCache()
- }
- })
-
- for (const match of currentMatches) {
- if (
- pendingMatches.length &&
- !pendingMatches.some((d) => d.routeId === match.routeId)
- ) {
- this.looseRoutesById[match.routeId]!.options.onLeave?.(
- match,
- )
}
+ this.stores.setCached(nextCachedMatches)
+ this.clearExpiredCache()
}
-
- for (const match of pendingMatches.length
- ? pendingMatches
- : currentMatches) {
- const hook = currentMatches.some(
- (d) => d.routeId === match.routeId,
- )
- ? 'onStay'
- : 'onEnter'
- this.looseRoutesById[match.routeId]!.options[hook]?.(
+ })
+
+ for (const match of currentMatches) {
+ if (
+ pendingMatches.length &&
+ !pendingMatches.some((d) => d.routeId === match.routeId)
+ ) {
+ this.looseRoutesById[match.routeId]!.options.onLeave?.(
match,
)
}
- }).then(resolve, reject)
- } catch (err) {
- reject(err)
- }
+ }
+
+ for (const match of pendingMatches.length
+ ? pendingMatches
+ : currentMatches) {
+ const hook = currentMatches.some(
+ (d) => d.routeId === match.routeId,
+ )
+ ? 'onStay'
+ : 'onEnter'
+ this.looseRoutesById[match.routeId]!.options[hook]?.(match)
+ }
+ }).then(resolve, reject)
})
}),
})
@@ -2681,43 +2664,34 @@ export class RouterCore<
}
updateMatch: UpdateMatchFn = (id, updater) => {
- this.startTransition(() => {
- const pendingMatch = this.stores.pendingMatchStores.get(id)
- if (pendingMatch) {
- pendingMatch.set(updater)
- return
- }
-
- const activeMatch = this.stores.matchStores.get(id)
- if (activeMatch) {
- activeMatch.set(updater)
- return
- }
+ const matchStore =
+ this.stores.pendingMatchStores.get(id) ?? this.stores.matchStores.get(id)
+ if (matchStore) {
+ matchStore.set(updater)
+ return
+ }
- const cachedMatch = this.stores.cachedMatchStores.get(id)
- if (cachedMatch) {
- const match = cachedMatch.get()
- const next = updater(match)
- if (next.status !== 'success' && next.status !== 'pending') {
- clearMatchPromises(match)
- this.stores.cachedMatchStores.delete(id)
- this.stores.cachedIds.set((prev) =>
- prev.filter((matchId) => matchId !== id),
- )
- } else {
- cachedMatch.set(next)
- }
+ const cachedMatch = this.stores.cachedMatchStores.get(id)
+ if (cachedMatch) {
+ const match = cachedMatch.get()
+ const next = updater(match)
+ if (next.status !== 'success' && next.status !== 'pending') {
+ this.clearCache({ filter: (d) => d.id === id })
+ } else {
+ cachedMatch.set(next)
}
- })
+ }
}
- getMatch: GetMatchFn = (matchId, mode) => {
- const match =
- this.stores.pendingMatchStores.get(matchId)?.get() ??
- this.stores.matchStores.get(matchId)?.get()
- return mode === 'live'
- ? match
- : (match ?? this.stores.cachedMatchStores.get(matchId)?.get())
+ getMatch: GetMatchFn = (matchId, includeCached) => {
+ const matchStore =
+ this.stores.pendingMatchStores.get(matchId) ??
+ this.stores.matchStores.get(matchId)
+ return (
+ includeCached === false
+ ? matchStore
+ : (matchStore ?? this.stores.cachedMatchStores.get(matchId))
+ )?.get()
}
/**
@@ -2892,7 +2866,6 @@ export class RouterCore<
matches,
location: next,
preload: activeMatchIds,
- updateMatch: this.updateMatch,
})
return matches
diff --git a/packages/router-core/tests/load.test.ts b/packages/router-core/tests/load.test.ts
index 74d6541f4e..482f0037e8 100644
--- a/packages/router-core/tests/load.test.ts
+++ b/packages/router-core/tests/load.test.ts
@@ -551,7 +551,9 @@ describe('beforeLoad skip or exec', () => {
.mockImplementation(() => undefined)
try {
- const parentBeforeLoadPromise = createControlledPromise<{ auth: string }>()
+ const parentBeforeLoadPromise = createControlledPromise<{
+ auth: string
+ }>()
const parentBeforeLoad = vi.fn(() => parentBeforeLoadPromise)
const childBeforeLoad = vi.fn()
const childLoader = vi.fn(() => undefined)
@@ -1677,7 +1679,6 @@ describe('loader skip or exec', () => {
router,
location,
matches,
- updateMatch: router.updateMatch,
}),
).rejects.toMatchObject({
options: expect.objectContaining({ to: '/bar' }),
@@ -3331,7 +3332,6 @@ describe('head execution', () => {
router,
location,
matches,
- updateMatch: router.updateMatch,
}),
).rejects.toBe(beforeLoadError)
@@ -3373,7 +3373,6 @@ describe('head execution', () => {
router,
location,
matches,
- updateMatch: router.updateMatch,
}),
).rejects.toBe(beforeLoadError)
@@ -3422,7 +3421,6 @@ describe('head execution', () => {
router,
location,
matches,
- updateMatch: router.updateMatch,
}),
).rejects.toBe(beforeLoadError)
@@ -3561,7 +3559,6 @@ describe('head execution', () => {
router,
location,
matches,
- updateMatch: router.updateMatch,
})
return { error: undefined, matches }
} catch (error) {
@@ -3846,7 +3843,6 @@ describe('head execution', () => {
router,
location,
matches,
- updateMatch: router.updateMatch,
}),
).resolves.toBe(matches)
diff --git a/packages/router-plugin/src/core/hmr/handle-route-update.ts b/packages/router-plugin/src/core/hmr/handle-route-update.ts
index c0325b06e9..8731b19ac5 100644
--- a/packages/router-plugin/src/core/hmr/handle-route-update.ts
+++ b/packages/router-plugin/src/core/hmr/handle-route-update.ts
@@ -124,9 +124,8 @@ function handleRouteUpdate(
// match from the store (via ...existingMatch spread) and the stale
// loaderData / __beforeLoadContext survives the reload cycle.
//
- // We must update the store directly (not via router.updateMatch) because
- // updateMatch wraps in startTransition which may defer the state update,
- // and we need the clear to be visible before invalidate reads the store.
+ // We update the store directly so the clear is visible before invalidate
+ // reads the store and rematches the route.
if (removedKeys.has('loader') || removedKeys.has('beforeLoad')) {
const matchIds = [
activeMatch?.id,
diff --git a/packages/solid-router/src/Match.tsx b/packages/solid-router/src/Match.tsx
index 453179047a..872d3c117c 100644
--- a/packages/solid-router/src/Match.tsx
+++ b/packages/solid-router/src/Match.tsx
@@ -255,6 +255,7 @@ export const MatchInner = (): any => {
error: currentMatch.error,
_forcePending: currentMatch._forcePending ?? false,
_displayPending: currentMatch._displayPending ?? false,
+ _nonReactive: currentMatch._nonReactive,
},
}
})
@@ -269,6 +270,19 @@ export const MatchInner = (): any => {
const componentKey = () =>
currentMatchState().key ?? currentMatchState().match.id
+ const PendingComponent = () =>
+ route().options.pendingComponent ?? router.options.defaultPendingComponent
+
+ const pendingReplacement = () => {
+ const pendingMatch = router.stores.pendingMatches
+ .get()
+ .find((pending) => pending.routeId === currentMatchState().routeId)
+ return (
+ pendingMatch?.status === 'pending' &&
+ pendingMatch.id !== currentMatch().id
+ )
+ }
+
const out = () => {
const Comp =
route().options.component ?? router.options.defaultComponent
@@ -289,9 +303,7 @@ export const MatchInner = (): any => {
{(_) => {
const [displayPendingResult] = Solid.createResource(
- () =>
- router.getMatch(currentMatch().id)?._nonReactive
- .displayPendingPromise,
+ () => currentMatch()._nonReactive.displayPendingPromise,
)
return <>{displayPendingResult()}>
@@ -300,37 +312,39 @@ export const MatchInner = (): any => {
{(_) => {
const [minPendingResult] = Solid.createResource(
- () =>
- router.getMatch(currentMatch().id)?._nonReactive
- .minPendingPromise,
+ () => currentMatch()._nonReactive.minPendingPromise,
)
return <>{minPendingResult()}>
}}
+
+ {(_) => {
+ const FallbackComponent = PendingComponent()
+ return FallbackComponent ? (
+
+ ) : null
+ }}
+
{(_) => {
const pendingMinMs =
route().options.pendingMinMs ??
router.options.defaultPendingMinMs
+ const matchBucket = currentMatch()._nonReactive
if (pendingMinMs) {
- const routerMatch = router.getMatch(currentMatch().id)
- if (
- routerMatch &&
- !routerMatch._nonReactive.minPendingPromise
- ) {
+ if (!matchBucket.minPendingPromise) {
// Create a promise that will resolve after the minPendingMs
if (!(isServer ?? router.isServer)) {
const minPendingPromise = createControlledPromise()
- routerMatch._nonReactive.minPendingPromise =
- minPendingPromise
+ matchBucket.minPendingPromise = minPendingPromise
setTimeout(() => {
minPendingPromise.resolve()
// We've handled the minPendingPromise, so we can delete it
- routerMatch._nonReactive.minPendingPromise = undefined
+ matchBucket.minPendingPromise = undefined
}, pendingMinMs)
}
}
@@ -338,13 +352,10 @@ export const MatchInner = (): any => {
const [loaderResult] = Solid.createResource(async () => {
await Promise.resolve()
- return router.getMatch(currentMatch().id)?._nonReactive
- .loadPromise
+ return matchBucket.loadPromise
})
- const FallbackComponent =
- route().options.pendingComponent ??
- router.options.defaultPendingComponent
+ const FallbackComponent = PendingComponent()
return (
<>
diff --git a/packages/vue-router/src/Match.tsx b/packages/vue-router/src/Match.tsx
index 82c972595c..b9259c5db0 100644
--- a/packages/vue-router/src/Match.tsx
+++ b/packages/vue-router/src/Match.tsx
@@ -409,22 +409,18 @@ export const MatchInner = Vue.defineComponent({
const pendingMinMs =
route.value.options.pendingMinMs ?? router.options.defaultPendingMinMs
- const routerMatch = router.getMatch(match.value.id)
- if (
- pendingMinMs &&
- routerMatch &&
- !routerMatch._nonReactive.minPendingPromise
- ) {
+ const matchBucket = Vue.toRaw(match.value._nonReactive)
+ if (pendingMinMs && !matchBucket.minPendingPromise) {
// Create a promise that will resolve after the minPendingMs
if (!(isServer ?? router.isServer)) {
const minPendingPromise = createControlledPromise()
- routerMatch._nonReactive.minPendingPromise = minPendingPromise
+ matchBucket.minPendingPromise = minPendingPromise
setTimeout(() => {
minPendingPromise.resolve()
// We've handled the minPendingPromise, so we can delete it
- routerMatch._nonReactive.minPendingPromise = undefined
+ matchBucket.minPendingPromise = undefined
}, pendingMinMs)
}
}
diff --git a/packages/vue-router/tests/store-updates-during-navigation.test.tsx b/packages/vue-router/tests/store-updates-during-navigation.test.tsx
index 3a51d638f4..b6e12e0216 100644
--- a/packages/vue-router/tests/store-updates-during-navigation.test.tsx
+++ b/packages/vue-router/tests/store-updates-during-navigation.test.tsx
@@ -138,7 +138,7 @@ describe("Store doesn't update *too many* times during navigation", () => {
// that needs to be done during a navigation.
// Any change that increases this number should be investigated.
// Note: Vue has different update counts than React/Solid due to different reactivity
- expect(updates).toBe(15)
+ expect(updates).toBe(11)
})
test('redirection in preload', async () => {
@@ -157,7 +157,7 @@ describe("Store doesn't update *too many* times during navigation", () => {
// that needs to be done during a navigation.
// Any change that increases this number should be investigated.
// Note: Vue has different update counts than React/Solid due to different reactivity
- expect(updates).toBe(5)
+ expect(updates).toBe(3)
expect(
router.stores.cachedMatches
.get()
@@ -179,7 +179,7 @@ describe("Store doesn't update *too many* times during navigation", () => {
// that needs to be done during a navigation.
// Any change that increases this number should be investigated.
// Note: Vue has different update counts than React/Solid due to different reactivity
- expect(updates).toBe(12)
+ expect(updates).toBe(9)
})
test('nothing', async () => {
@@ -207,7 +207,7 @@ describe("Store doesn't update *too many* times during navigation", () => {
// that needs to be done during a navigation.
// Any change that increases this number should be investigated.
// Note: Vue has different update counts than React/Solid due to different reactivity
- expect(updates).toBe(8)
+ expect(updates).toBe(6)
})
test('hover preload, then navigate, w/ async loaders', async () => {
@@ -234,7 +234,7 @@ describe("Store doesn't update *too many* times during navigation", () => {
// that needs to be done during a navigation.
// Any change that increases this number should be investigated.
// Note: Vue has different update counts than React/Solid due to different reactivity
- expect(updates).toBe(13)
+ expect(updates).toBe(7)
})
test('navigate, w/ preloaded & async loaders', async () => {
@@ -251,7 +251,7 @@ describe("Store doesn't update *too many* times during navigation", () => {
// that needs to be done during a navigation.
// Any change that increases this number should be investigated.
// Note: Vue has different update counts than React/Solid due to different reactivity
- expect(updates).toBe(8)
+ expect(updates).toBe(6)
})
test('navigate, w/ preloaded & sync loaders', async () => {
@@ -304,6 +304,6 @@ describe("Store doesn't update *too many* times during navigation", () => {
// that needs to be done during a navigation.
// Any change that increases this number should be investigated.
// Note: Vue has different update counts than React/Solid due to different reactivity
- expect(updates).toBe(2)
+ expect(updates).toBe(0)
})
})
From 0d36b6cfabf55f255af68268217cc3046913d25f Mon Sep 17 00:00:00 2001
From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com>
Date: Wed, 17 Jun 2026 19:09:01 +0000
Subject: [PATCH 13/13] ci: apply automated fixes
---
packages/solid-router/src/Match.tsx | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/packages/solid-router/src/Match.tsx b/packages/solid-router/src/Match.tsx
index 872d3c117c..577a5b2eee 100644
--- a/packages/solid-router/src/Match.tsx
+++ b/packages/solid-router/src/Match.tsx
@@ -271,7 +271,8 @@ export const MatchInner = (): any => {
currentMatchState().key ?? currentMatchState().match.id
const PendingComponent = () =>
- route().options.pendingComponent ?? router.options.defaultPendingComponent
+ route().options.pendingComponent ??
+ router.options.defaultPendingComponent
const pendingReplacement = () => {
const pendingMatch = router.stores.pendingMatches