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 ( +
+
    + {posts.map((post) => { + return ( +
  • + +
    {post.title.substring(0, 20)}
    + +
  • + ) + })} +
+
+ ) +} 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