diff --git a/.changeset/violet-poets-wait.md b/.changeset/violet-poets-wait.md new file mode 100644 index 0000000000..9cb332593a --- /dev/null +++ b/.changeset/violet-poets-wait.md @@ -0,0 +1,17 @@ +--- +'@tanstack/router-core': patch +'@tanstack/react-router': patch +'@tanstack/solid-router': patch +'@tanstack/vue-router': patch +'@tanstack/start-server-core': patch +--- + +Fix match loading consistency across client navigation, preloading, background reloads, server rendering, and hydration. + +- `beforeLoad` context is now propagated to descendant loaders and components during background reloads, preloaded navigation, route re-entry while background work is still in flight, and error or notFound states. +- Redirects no longer use a renderable `RouteMatch.status`; `RouteMatch.status` is now `'pending' | 'success' | 'error' | 'notFound'`. Abandoned, redirected, or failed matches are dropped from cache and their readiness promises are settled so stale suspense work cannot keep rendering suspended. +- Route assets from `head` and `scripts` are projected from the committed match lane after loader data is current, including preloaded navigations, background reloads, and SSR hydration. +- Pending UI and `pendingMinMs` now remain owned by match loading, so long-running `beforeLoad` or loader work does not prematurely clear pending fallbacks. +- SSR hydration now handles dehydrated and data-only matches in a dedicated hydration pass, restoring route context and assets without rerunning client loaders. +- Server loading now has a dedicated path for status codes, redirects, notFound and error responses. +- React, Solid, and Vue match rendering now consume the simplified match readiness model, including aborted loader errors, SSR/data-only pending fallbacks, and stale render snapshots. 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/docs/router/api/router/RouterStateType.md b/docs/router/api/router/RouterStateType.md index a77f4e775f..f49ccb1a7c 100644 --- a/docs/router/api/router/RouterStateType.md +++ b/docs/router/api/router/RouterStateType.md @@ -9,7 +9,6 @@ The `RouterState` type represents shape of the internal state of the router. The type RouterState = { status: 'pending' | 'idle' isLoading: boolean - isTransitioning: boolean matches: Array location: ParsedLocation resolvedLocation: ParsedLocation @@ -30,11 +29,6 @@ The `RouterState` type contains all of the properties that are available on the - Type: `boolean` - `true` if the router is currently loading a route or waiting for a route to finish loading. -### `isTransitioning` property - -- Type: `boolean` -- `true` if the router is currently transitioning to a new route. - ### `matches` property - Type: [`Array`](./RouteMatchType.md) diff --git a/docs/router/how-to/debug-router-issues.md b/docs/router/how-to/debug-router-issues.md index b4d20ec72f..068cbfed1a 100644 --- a/docs/router/how-to/debug-router-issues.md +++ b/docs/router/how-to/debug-router-issues.md @@ -326,7 +326,6 @@ function DataLoadingDebug() { console.log('Route status:', { isLoading: location.isLoading, - isTransitioning: location.isTransitioning, }) return null diff --git a/e2e/react-router/basic-file-based/tests/app.spec.ts b/e2e/react-router/basic-file-based/tests/app.spec.ts index ae87d350ce..62ae2cfb73 100644 --- a/e2e/react-router/basic-file-based/tests/app.spec.ts +++ b/e2e/react-router/basic-file-based/tests/app.spec.ts @@ -246,7 +246,7 @@ async function getRenderCount(page: Page) { return renderCount } async function structuralSharingTest(page: Page, enabled: boolean) { - page.goto(`/structural-sharing/${enabled}/?foo=f1&bar=b1`) + await page.goto(`/structural-sharing/${enabled}/?foo=f1&bar=b1`) await expect(page.getByTestId('enabled')).toHaveText(JSON.stringify(enabled)) async function checkSearch({ foo, bar }: { foo: string; bar: string }) { @@ -268,9 +268,9 @@ async function structuralSharingTest(page: Page, enabled: boolean) { test('structural sharing disabled', async ({ page }) => { await structuralSharingTest(page, false) - expect(await getRenderCount(page)).toBe(2) + expect(await getRenderCount(page)).toBe(3) await page.getByTestId('link').click() - expect(await getRenderCount(page)).toBeGreaterThan(2) + expect(await getRenderCount(page)).toBeGreaterThan(3) }) test('structural sharing enabled', async ({ page }) => { 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/react-start/issue-6221/.gitignore b/e2e/react-start/issue-6221/.gitignore new file mode 100644 index 0000000000..528451b60c --- /dev/null +++ b/e2e/react-start/issue-6221/.gitignore @@ -0,0 +1,3 @@ +dist +port*.txt +test-results diff --git a/e2e/react-start/issue-6221/package.json b/e2e/react-start/issue-6221/package.json new file mode 100644 index 0000000000..c3330ecb52 --- /dev/null +++ b/e2e/react-start/issue-6221/package.json @@ -0,0 +1,31 @@ +{ + "name": "tanstack-react-start-e2e-issue-6221", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "dev": "vite dev --port 3000", + "dev:e2e": "vite dev", + "build": "vite build && tsc --noEmit", + "preview": "vite preview", + "start": "pnpm dlx srvx --prod -s ../client dist/server/server.js", + "test:e2e": "rm -rf port*.txt; playwright test --project=chromium" + }, + "dependencies": { + "@tanstack/react-router": "workspace:^", + "@tanstack/react-start": "workspace:^", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@playwright/test": "^1.50.1", + "@tanstack/router-e2e-utils": "workspace:^", + "@types/node": "^22.10.2", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "@vitejs/plugin-react": "^6.0.1", + "srvx": "^0.11.9", + "typescript": "^6.0.2", + "vite": "^8.0.14" + } +} diff --git a/e2e/react-start/issue-6221/playwright.config.ts b/e2e/react-start/issue-6221/playwright.config.ts new file mode 100644 index 0000000000..2c2952c186 --- /dev/null +++ b/e2e/react-start/issue-6221/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_SERVER_PORT=${PORT} pnpm build && NODE_ENV=production PORT=${PORT} VITE_SERVER_PORT=${PORT} pnpm start`, + url: baseURL, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}) diff --git a/e2e/react-start/issue-6221/src/routeTree.gen.ts b/e2e/react-start/issue-6221/src/routeTree.gen.ts new file mode 100644 index 0000000000..010acc5542 --- /dev/null +++ b/e2e/react-start/issue-6221/src/routeTree.gen.ts @@ -0,0 +1,122 @@ +/* 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 LoginRouteImport } from './routes/login' +import { Route as DashboardRouteImport } from './routes/dashboard' +import { Route as IndexRouteImport } from './routes/index' +import { Route as ArticleIdRouteImport } from './routes/article.$id' + +const LoginRoute = LoginRouteImport.update({ + id: '/login', + path: '/login', + getParentRoute: () => rootRouteImport, +} as any) +const DashboardRoute = DashboardRouteImport.update({ + id: '/dashboard', + path: '/dashboard', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) +const ArticleIdRoute = ArticleIdRouteImport.update({ + id: '/article/$id', + path: '/article/$id', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/dashboard': typeof DashboardRoute + '/login': typeof LoginRoute + '/article/$id': typeof ArticleIdRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/dashboard': typeof DashboardRoute + '/login': typeof LoginRoute + '/article/$id': typeof ArticleIdRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/dashboard': typeof DashboardRoute + '/login': typeof LoginRoute + '/article/$id': typeof ArticleIdRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' | '/dashboard' | '/login' | '/article/$id' + fileRoutesByTo: FileRoutesByTo + to: '/' | '/dashboard' | '/login' | '/article/$id' + id: '__root__' | '/' | '/dashboard' | '/login' | '/article/$id' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + DashboardRoute: typeof DashboardRoute + LoginRoute: typeof LoginRoute + ArticleIdRoute: typeof ArticleIdRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/login': { + id: '/login' + path: '/login' + fullPath: '/login' + preLoaderRoute: typeof LoginRouteImport + parentRoute: typeof rootRouteImport + } + '/dashboard': { + id: '/dashboard' + path: '/dashboard' + fullPath: '/dashboard' + preLoaderRoute: typeof DashboardRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/article/$id': { + id: '/article/$id' + path: '/article/$id' + fullPath: '/article/$id' + preLoaderRoute: typeof ArticleIdRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + DashboardRoute: DashboardRoute, + LoginRoute: LoginRoute, + ArticleIdRoute: ArticleIdRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/react-start' +declare module '@tanstack/react-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/e2e/react-start/issue-6221/src/router.tsx b/e2e/react-start/issue-6221/src/router.tsx new file mode 100644 index 0000000000..191f74ee02 --- /dev/null +++ b/e2e/react-start/issue-6221/src/router.tsx @@ -0,0 +1,8 @@ +import { createRouter } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' + +export function getRouter() { + return createRouter({ + routeTree, + }) +} diff --git a/e2e/react-start/issue-6221/src/routes/__root.tsx b/e2e/react-start/issue-6221/src/routes/__root.tsx new file mode 100644 index 0000000000..8b60be2cb9 --- /dev/null +++ b/e2e/react-start/issue-6221/src/routes/__root.tsx @@ -0,0 +1,38 @@ +/// +import * as React from 'react' +import { + HeadContent, + Link, + Outlet, + Scripts, + createRootRoute, +} from '@tanstack/react-router' + +export const Route = createRootRoute({ + head: () => ({ + meta: [ + { charSet: 'utf-8' }, + { name: 'viewport', content: 'width=device-width, initial-scale=1' }, + { title: 'Issue 6221' }, + ], + }), + shellComponent: RootDocument, + component: () => , +}) + +function RootDocument({ children }: { children: React.ReactNode }) { + return ( + + + + + + + {children} + + + + ) +} diff --git a/e2e/react-start/issue-6221/src/routes/article.$id.tsx b/e2e/react-start/issue-6221/src/routes/article.$id.tsx new file mode 100644 index 0000000000..3c1b8469e6 --- /dev/null +++ b/e2e/react-start/issue-6221/src/routes/article.$id.tsx @@ -0,0 +1,50 @@ +import { Link, createFileRoute } from '@tanstack/react-router' + +type ArticleData = { + title: string + body: string +} + +const loadArticle = async (id: string): Promise => { + await new Promise((resolve) => setTimeout(resolve, 100)) + + if (window.localStorage.getItem('issue-6221-auth') !== 'true') { + return null + } + + return { + title: `Article ${id} Title`, + body: `Article ${id} body`, + } +} + +export const Route = createFileRoute('/article/$id')({ + ssr: false, + loader: ({ params }) => loadArticle(params.id), + head: ({ loaderData }) => ({ + meta: [{ title: loaderData?.title ?? 'Article Not Found' }], + }), + component: Article, +}) + +function Article() { + const article = Route.useLoaderData() + + if (!article) { + return ( +
+

Article not found

+ + Go to login + +
+ ) + } + + return ( +
+

{article.title}

+

{article.body}

+
+ ) +} diff --git a/e2e/react-start/issue-6221/src/routes/dashboard.tsx b/e2e/react-start/issue-6221/src/routes/dashboard.tsx new file mode 100644 index 0000000000..dc03c1f4e1 --- /dev/null +++ b/e2e/react-start/issue-6221/src/routes/dashboard.tsx @@ -0,0 +1,16 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/dashboard')({ + head: () => ({ + meta: [{ title: 'Dashboard' }], + }), + component: Dashboard, +}) + +function Dashboard() { + return ( +
+

Dashboard

+
+ ) +} diff --git a/e2e/react-start/issue-6221/src/routes/index.tsx b/e2e/react-start/issue-6221/src/routes/index.tsx new file mode 100644 index 0000000000..a8efa4c572 --- /dev/null +++ b/e2e/react-start/issue-6221/src/routes/index.tsx @@ -0,0 +1,16 @@ +import { Link, createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/')({ + component: Home, +}) + +function Home() { + return ( +
+

Issue 6221

+ + Article 123 + +
+ ) +} diff --git a/e2e/react-start/issue-6221/src/routes/login.tsx b/e2e/react-start/issue-6221/src/routes/login.tsx new file mode 100644 index 0000000000..7d07073ea4 --- /dev/null +++ b/e2e/react-start/issue-6221/src/routes/login.tsx @@ -0,0 +1,29 @@ +import { createFileRoute, useNavigate } from '@tanstack/react-router' + +export const Route = createFileRoute('/login')({ + ssr: false, + head: () => ({ + meta: [{ title: 'Login' }], + }), + component: Login, +}) + +function Login() { + const navigate = useNavigate() + + return ( +
+

Login

+ +
+ ) +} diff --git a/e2e/react-start/issue-6221/tests/head.spec.ts b/e2e/react-start/issue-6221/tests/head.spec.ts new file mode 100644 index 0000000000..679f059c01 --- /dev/null +++ b/e2e/react-start/issue-6221/tests/head.spec.ts @@ -0,0 +1,29 @@ +import { expect } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' + +test('updates head after a stale cached loader refreshes in the background', async ({ + page, +}) => { + await page.goto('/') + await page.evaluate(() => window.localStorage.clear()) + + await page.goto('/article/123') + await expect(page.getByTestId('article-not-found')).toBeVisible() + await expect(page).toHaveTitle('Article Not Found') + + await page.getByTestId('login-link').click() + await expect(page.getByTestId('login-page')).toBeVisible() + await expect(page).toHaveTitle('Login') + + await page.getByTestId('login-button').click() + await expect(page.getByTestId('dashboard')).toBeVisible() + await expect(page).toHaveTitle('Dashboard') + + await page.goBack() + await expect(page.getByTestId('article-content')).toBeVisible() + await expect(page.getByTestId('article-title')).toHaveText( + 'Article 123 Title', + ) + + await expect(page).toHaveTitle('Article 123 Title') +}) diff --git a/e2e/react-start/issue-6221/tsconfig.json b/e2e/react-start/issue-6221/tsconfig.json new file mode 100644 index 0000000000..cef9369516 --- /dev/null +++ b/e2e/react-start/issue-6221/tsconfig.json @@ -0,0 +1,21 @@ +{ + "include": ["**/*.ts", "**/*.tsx"], + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "isolatedModules": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "target": "ES2022", + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "paths": { + "~/*": ["./src/*"] + }, + "noEmit": true + } +} diff --git a/e2e/react-start/issue-6221/vite.config.ts b/e2e/react-start/issue-6221/vite.config.ts new file mode 100644 index 0000000000..c32652e9c8 --- /dev/null +++ b/e2e/react-start/issue-6221/vite.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vite' +import { tanstackStart } from '@tanstack/react-start/plugin/vite' +import viteReact from '@vitejs/plugin-react' + +export default defineConfig({ + resolve: { tsconfigPaths: true }, + server: { + port: 3000, + }, + plugins: [tanstackStart(), viteReact()], +}) diff --git a/e2e/react-start/selective-ssr/src/routeTree.gen.ts b/e2e/react-start/selective-ssr/src/routeTree.gen.ts index 188cd5c91a..39a0a971b9 100644 --- a/e2e/react-start/selective-ssr/src/routeTree.gen.ts +++ b/e2e/react-start/selective-ssr/src/routeTree.gen.ts @@ -9,10 +9,16 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' +import { Route as SsrFalsePendingMinRouteImport } from './routes/ssr-false-pending-min' import { Route as PostsRouteImport } from './routes/posts' import { Route as IndexRouteImport } from './routes/index' import { Route as PostsPostIdRouteImport } from './routes/posts.$postId' +const SsrFalsePendingMinRoute = SsrFalsePendingMinRouteImport.update({ + id: '/ssr-false-pending-min', + path: '/ssr-false-pending-min', + getParentRoute: () => rootRouteImport, +} as any) const PostsRoute = PostsRouteImport.update({ id: '/posts', path: '/posts', @@ -32,34 +38,45 @@ const PostsPostIdRoute = PostsPostIdRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/posts': typeof PostsRouteWithChildren + '/ssr-false-pending-min': typeof SsrFalsePendingMinRoute '/posts/$postId': typeof PostsPostIdRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/posts': typeof PostsRouteWithChildren + '/ssr-false-pending-min': typeof SsrFalsePendingMinRoute '/posts/$postId': typeof PostsPostIdRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/posts': typeof PostsRouteWithChildren + '/ssr-false-pending-min': typeof SsrFalsePendingMinRoute '/posts/$postId': typeof PostsPostIdRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/posts' | '/posts/$postId' + fullPaths: '/' | '/posts' | '/ssr-false-pending-min' | '/posts/$postId' fileRoutesByTo: FileRoutesByTo - to: '/' | '/posts' | '/posts/$postId' - id: '__root__' | '/' | '/posts' | '/posts/$postId' + to: '/' | '/posts' | '/ssr-false-pending-min' | '/posts/$postId' + id: '__root__' | '/' | '/posts' | '/ssr-false-pending-min' | '/posts/$postId' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute PostsRoute: typeof PostsRouteWithChildren + SsrFalsePendingMinRoute: typeof SsrFalsePendingMinRoute } declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/ssr-false-pending-min': { + id: '/ssr-false-pending-min' + path: '/ssr-false-pending-min' + fullPath: '/ssr-false-pending-min' + preLoaderRoute: typeof SsrFalsePendingMinRouteImport + parentRoute: typeof rootRouteImport + } '/posts': { id: '/posts' path: '/posts' @@ -97,6 +114,7 @@ const PostsRouteWithChildren = PostsRoute._addFileChildren(PostsRouteChildren) const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, PostsRoute: PostsRouteWithChildren, + SsrFalsePendingMinRoute: SsrFalsePendingMinRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/e2e/react-start/selective-ssr/src/router.tsx b/e2e/react-start/selective-ssr/src/router.tsx index 82a730704a..24eb56ec3a 100644 --- a/e2e/react-start/selective-ssr/src/router.tsx +++ b/e2e/react-start/selective-ssr/src/router.tsx @@ -9,3 +9,9 @@ export function getRouter() { return router } + +declare module '@tanstack/react-router' { + interface Register { + router: ReturnType + } +} diff --git a/e2e/react-start/selective-ssr/src/routes/ssr-false-pending-min.tsx b/e2e/react-start/selective-ssr/src/routes/ssr-false-pending-min.tsx new file mode 100644 index 0000000000..64b8362a86 --- /dev/null +++ b/e2e/react-start/selective-ssr/src/routes/ssr-false-pending-min.tsx @@ -0,0 +1,49 @@ +import * as React from 'react' +import { createFileRoute } from '@tanstack/react-router' + +const pendingMinMs = 1500 + +function recordEvent(type: string) { + if (typeof window === 'undefined') { + return + } + + const global = window as any + global.__events ??= [] + global.__events.push({ type, t: performance.now() }) +} + +function Pending() { + React.useEffect(() => { + recordEvent('pending-mounted') + + return () => { + recordEvent('pending-unmounted') + } + }, []) + + return
Loading SSR false route...
+} + +function Target() { + React.useEffect(() => { + recordEvent('target-mounted') + }, []) + + return
SSR false route loaded
+} + +export const Route = createFileRoute('/ssr-false-pending-min')({ + ssr: false, + pendingMs: 0, + pendingMinMs, + pendingComponent: Pending, + loader: async () => { + recordEvent('loader-start') + await new Promise((resolve) => setTimeout(resolve, 50)) + recordEvent('loader-done') + + return { ok: true } + }, + component: Target, +}) diff --git a/e2e/react-start/selective-ssr/tests/app.spec.ts b/e2e/react-start/selective-ssr/tests/app.spec.ts index aea216d506..b14c7e028a 100644 --- a/e2e/react-start/selective-ssr/tests/app.spec.ts +++ b/e2e/react-start/selective-ssr/tests/app.spec.ts @@ -1,8 +1,73 @@ import { expect } from '@playwright/test' import { test } from '@tanstack/router-e2e-utils' +import type { Page } from '@playwright/test' const testCount = 7 +type BrowserEvent = { type: string; t: number } + +function findEvent(events: Array, type: string) { + const event = events.find((entry) => entry.type === type) + expect(event, `Expected browser event "${type}"`).toBeTruthy() + return event! +} + +function findLastEvent(events: Array, type: string) { + let event: BrowserEvent | undefined + for (let index = events.length - 1; index >= 0; index--) { + if (events[index]!.type === type) { + event = events[index] + break + } + } + expect(event, `Expected browser event "${type}"`).toBeTruthy() + return event! +} + +async function expectSsrFalsePendingMin(page: Page) { + const errors: Array = [] + + page.on('pageerror', (err) => { + errors.push(err.message) + }) + page.on('console', (msg) => { + if (msg.type() === 'error') { + errors.push(msg.text()) + } + }) + + await page.goto('/ssr-false-pending-min') + + const pending = page.getByTestId('ssr-false-pending') + const visiblePending = page.locator( + '[data-testid="ssr-false-pending"]:visible', + ) + const target = page.getByTestId('ssr-false-target') + + await expect(visiblePending).toHaveCount(1) + + await page.waitForTimeout(500) + + await expect(visiblePending).toHaveCount(1) + await expect(target).toBeHidden() + + await expect(target).toBeVisible() + await expect(pending).toHaveCount(0) + + const events = await page.evaluate>( + () => (window as any).__events ?? [], + ) + const pendingMounted = findEvent(events, 'pending-mounted') + const pendingUnmounted = findLastEvent(events, 'pending-unmounted') + const targetMounted = findEvent(events, 'target-mounted') + const loaderDone = findEvent(events, 'loader-done') + + expect(loaderDone.t - pendingMounted.t).toBeLessThan(500) + expect(pendingUnmounted.t - pendingMounted.t).toBeGreaterThanOrEqual(1400) + expect(targetMounted.t - pendingMounted.t).toBeGreaterThanOrEqual(1400) + expect(errors).toEqual([]) +} + test.describe('selective ssr', () => { test('testcount matches', async ({ page }) => { await page.goto('/') @@ -38,4 +103,10 @@ test.describe('selective ssr', () => { await expect(page.getByTestId('router-status')).toContainText('idle') }) } + + test('direct ssr false hydration keeps pending visible for pendingMinMs', async ({ + page, + }) => { + await expectSsrFalsePendingMin(page) + }) }) diff --git a/e2e/solid-start/basic-auth/package.json b/e2e/solid-start/basic-auth/package.json index dc99d114fe..12ac82d514 100644 --- a/e2e/solid-start/basic-auth/package.json +++ b/e2e/solid-start/basic-auth/package.json @@ -10,7 +10,7 @@ "preview": "vite preview", "start": "pnpx srvx --prod -s ../client dist/server/server.js", "prisma-generate": "prisma generate", - "test:e2e": "rm -rf port*.txt; mkdir -p .tmp; rm -f .tmp/dev.db .tmp/dev.db-journal .tmp/dev.db-wal .tmp/dev.db-shm; export DATABASE_URL=file:./.tmp/dev.db; pnpm run prisma-generate && pnpm exec prisma migrate deploy && playwright test --project=chromium" + "test:e2e": "rm -rf port*.txt; mkdir -p .tmp; rm -f .tmp/dev.db .tmp/dev.db-journal .tmp/dev.db-wal .tmp/dev.db-shm; touch .tmp/dev.db; export DATABASE_URL=file:./.tmp/dev.db; pnpm run prisma-generate && pnpm exec prisma migrate deploy && playwright test --project=chromium" }, "dependencies": { "@libsql/client": "^0.15.15", diff --git a/e2e/solid-start/selective-ssr/src/routeTree.gen.ts b/e2e/solid-start/selective-ssr/src/routeTree.gen.ts index df2b865e51..8fb128de38 100644 --- a/e2e/solid-start/selective-ssr/src/routeTree.gen.ts +++ b/e2e/solid-start/selective-ssr/src/routeTree.gen.ts @@ -9,11 +9,17 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' +import { Route as SsrFalsePendingMinRouteImport } from './routes/ssr-false-pending-min' import { Route as PostsRouteImport } from './routes/posts' import { Route as DataOnlyPendingComponentRouteImport } from './routes/data-only-pending-component' import { Route as IndexRouteImport } from './routes/index' import { Route as PostsPostIdRouteImport } from './routes/posts.$postId' +const SsrFalsePendingMinRoute = SsrFalsePendingMinRouteImport.update({ + id: '/ssr-false-pending-min', + path: '/ssr-false-pending-min', + getParentRoute: () => rootRouteImport, +} as any) const PostsRoute = PostsRouteImport.update({ id: '/posts', path: '/posts', @@ -40,12 +46,14 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/data-only-pending-component': typeof DataOnlyPendingComponentRoute '/posts': typeof PostsRouteWithChildren + '/ssr-false-pending-min': typeof SsrFalsePendingMinRoute '/posts/$postId': typeof PostsPostIdRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/data-only-pending-component': typeof DataOnlyPendingComponentRoute '/posts': typeof PostsRouteWithChildren + '/ssr-false-pending-min': typeof SsrFalsePendingMinRoute '/posts/$postId': typeof PostsPostIdRoute } export interface FileRoutesById { @@ -53,18 +61,30 @@ export interface FileRoutesById { '/': typeof IndexRoute '/data-only-pending-component': typeof DataOnlyPendingComponentRoute '/posts': typeof PostsRouteWithChildren + '/ssr-false-pending-min': typeof SsrFalsePendingMinRoute '/posts/$postId': typeof PostsPostIdRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/data-only-pending-component' | '/posts' | '/posts/$postId' + fullPaths: + | '/' + | '/data-only-pending-component' + | '/posts' + | '/ssr-false-pending-min' + | '/posts/$postId' fileRoutesByTo: FileRoutesByTo - to: '/' | '/data-only-pending-component' | '/posts' | '/posts/$postId' + to: + | '/' + | '/data-only-pending-component' + | '/posts' + | '/ssr-false-pending-min' + | '/posts/$postId' id: | '__root__' | '/' | '/data-only-pending-component' | '/posts' + | '/ssr-false-pending-min' | '/posts/$postId' fileRoutesById: FileRoutesById } @@ -72,10 +92,18 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute DataOnlyPendingComponentRoute: typeof DataOnlyPendingComponentRoute PostsRoute: typeof PostsRouteWithChildren + SsrFalsePendingMinRoute: typeof SsrFalsePendingMinRoute } declare module '@tanstack/solid-router' { interface FileRoutesByPath { + '/ssr-false-pending-min': { + id: '/ssr-false-pending-min' + path: '/ssr-false-pending-min' + fullPath: '/ssr-false-pending-min' + preLoaderRoute: typeof SsrFalsePendingMinRouteImport + parentRoute: typeof rootRouteImport + } '/posts': { id: '/posts' path: '/posts' @@ -121,6 +149,7 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, DataOnlyPendingComponentRoute: DataOnlyPendingComponentRoute, PostsRoute: PostsRouteWithChildren, + SsrFalsePendingMinRoute: SsrFalsePendingMinRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/e2e/solid-start/selective-ssr/src/routes/ssr-false-pending-min.tsx b/e2e/solid-start/selective-ssr/src/routes/ssr-false-pending-min.tsx new file mode 100644 index 0000000000..82425f9575 --- /dev/null +++ b/e2e/solid-start/selective-ssr/src/routes/ssr-false-pending-min.tsx @@ -0,0 +1,48 @@ +import { onCleanup, onMount } from 'solid-js' +import { createFileRoute } from '@tanstack/solid-router' + +const pendingMinMs = 1500 + +function recordEvent(type: string) { + if (typeof window === 'undefined') { + return + } + + const global = window as any + global.__events ??= [] + global.__events.push({ type, t: performance.now() }) +} + +function Pending() { + onMount(() => { + recordEvent('pending-mounted') + }) + onCleanup(() => { + recordEvent('pending-unmounted') + }) + + return
Loading SSR false route...
+} + +function Target() { + onMount(() => { + recordEvent('target-mounted') + }) + + return
SSR false route loaded
+} + +export const Route = createFileRoute('/ssr-false-pending-min')({ + ssr: false, + pendingMs: 0, + pendingMinMs, + pendingComponent: Pending, + loader: async () => { + recordEvent('loader-start') + await new Promise((resolve) => setTimeout(resolve, 50)) + recordEvent('loader-done') + + return { ok: true } + }, + component: Target, +}) diff --git a/e2e/solid-start/selective-ssr/tests/app.spec.ts b/e2e/solid-start/selective-ssr/tests/app.spec.ts index aea216d506..b14c7e028a 100644 --- a/e2e/solid-start/selective-ssr/tests/app.spec.ts +++ b/e2e/solid-start/selective-ssr/tests/app.spec.ts @@ -1,8 +1,73 @@ import { expect } from '@playwright/test' import { test } from '@tanstack/router-e2e-utils' +import type { Page } from '@playwright/test' const testCount = 7 +type BrowserEvent = { type: string; t: number } + +function findEvent(events: Array, type: string) { + const event = events.find((entry) => entry.type === type) + expect(event, `Expected browser event "${type}"`).toBeTruthy() + return event! +} + +function findLastEvent(events: Array, type: string) { + let event: BrowserEvent | undefined + for (let index = events.length - 1; index >= 0; index--) { + if (events[index]!.type === type) { + event = events[index] + break + } + } + expect(event, `Expected browser event "${type}"`).toBeTruthy() + return event! +} + +async function expectSsrFalsePendingMin(page: Page) { + const errors: Array = [] + + page.on('pageerror', (err) => { + errors.push(err.message) + }) + page.on('console', (msg) => { + if (msg.type() === 'error') { + errors.push(msg.text()) + } + }) + + await page.goto('/ssr-false-pending-min') + + const pending = page.getByTestId('ssr-false-pending') + const visiblePending = page.locator( + '[data-testid="ssr-false-pending"]:visible', + ) + const target = page.getByTestId('ssr-false-target') + + await expect(visiblePending).toHaveCount(1) + + await page.waitForTimeout(500) + + await expect(visiblePending).toHaveCount(1) + await expect(target).toBeHidden() + + await expect(target).toBeVisible() + await expect(pending).toHaveCount(0) + + const events = await page.evaluate>( + () => (window as any).__events ?? [], + ) + const pendingMounted = findEvent(events, 'pending-mounted') + const pendingUnmounted = findLastEvent(events, 'pending-unmounted') + const targetMounted = findEvent(events, 'target-mounted') + const loaderDone = findEvent(events, 'loader-done') + + expect(loaderDone.t - pendingMounted.t).toBeLessThan(500) + expect(pendingUnmounted.t - pendingMounted.t).toBeGreaterThanOrEqual(1400) + expect(targetMounted.t - pendingMounted.t).toBeGreaterThanOrEqual(1400) + expect(errors).toEqual([]) +} + test.describe('selective ssr', () => { test('testcount matches', async ({ page }) => { await page.goto('/') @@ -38,4 +103,10 @@ test.describe('selective ssr', () => { await expect(page.getByTestId('router-status')).toContainText('idle') }) } + + test('direct ssr false hydration keeps pending visible for pendingMinMs', async ({ + page, + }) => { + await expectSsrFalsePendingMin(page) + }) }) 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/e2e/vue-start/basic-auth/package.json b/e2e/vue-start/basic-auth/package.json index 3a572536e9..cb3b0cd166 100644 --- a/e2e/vue-start/basic-auth/package.json +++ b/e2e/vue-start/basic-auth/package.json @@ -10,7 +10,7 @@ "preview": "vite preview", "start": "pnpx srvx --prod -s ../client dist/server/server.js", "prisma-generate": "prisma generate", - "test:e2e": "rm -rf port*.txt; mkdir -p .tmp; rm -f .tmp/dev.db .tmp/dev.db-journal .tmp/dev.db-wal .tmp/dev.db-shm; export DATABASE_URL=file:./.tmp/dev.db; pnpm run prisma-generate && pnpm exec prisma migrate deploy && playwright test --project=chromium" + "test:e2e": "rm -rf port*.txt; mkdir -p .tmp; rm -f .tmp/dev.db .tmp/dev.db-journal .tmp/dev.db-wal .tmp/dev.db-shm; touch .tmp/dev.db; export DATABASE_URL=file:./.tmp/dev.db; pnpm run prisma-generate && pnpm exec prisma migrate deploy && playwright test --project=chromium" }, "dependencies": { "@libsql/client": "^0.15.15", diff --git a/e2e/vue-start/selective-ssr/src/routeTree.gen.ts b/e2e/vue-start/selective-ssr/src/routeTree.gen.ts index 474705b7e7..217a30eb9f 100644 --- a/e2e/vue-start/selective-ssr/src/routeTree.gen.ts +++ b/e2e/vue-start/selective-ssr/src/routeTree.gen.ts @@ -9,10 +9,16 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' +import { Route as SsrFalsePendingMinRouteImport } from './routes/ssr-false-pending-min' import { Route as PostsRouteImport } from './routes/posts' import { Route as IndexRouteImport } from './routes/index' import { Route as PostsPostIdRouteImport } from './routes/posts.$postId' +const SsrFalsePendingMinRoute = SsrFalsePendingMinRouteImport.update({ + id: '/ssr-false-pending-min', + path: '/ssr-false-pending-min', + getParentRoute: () => rootRouteImport, +} as any) const PostsRoute = PostsRouteImport.update({ id: '/posts', path: '/posts', @@ -32,34 +38,45 @@ const PostsPostIdRoute = PostsPostIdRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/posts': typeof PostsRouteWithChildren + '/ssr-false-pending-min': typeof SsrFalsePendingMinRoute '/posts/$postId': typeof PostsPostIdRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/posts': typeof PostsRouteWithChildren + '/ssr-false-pending-min': typeof SsrFalsePendingMinRoute '/posts/$postId': typeof PostsPostIdRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/posts': typeof PostsRouteWithChildren + '/ssr-false-pending-min': typeof SsrFalsePendingMinRoute '/posts/$postId': typeof PostsPostIdRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/posts' | '/posts/$postId' + fullPaths: '/' | '/posts' | '/ssr-false-pending-min' | '/posts/$postId' fileRoutesByTo: FileRoutesByTo - to: '/' | '/posts' | '/posts/$postId' - id: '__root__' | '/' | '/posts' | '/posts/$postId' + to: '/' | '/posts' | '/ssr-false-pending-min' | '/posts/$postId' + id: '__root__' | '/' | '/posts' | '/ssr-false-pending-min' | '/posts/$postId' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute PostsRoute: typeof PostsRouteWithChildren + SsrFalsePendingMinRoute: typeof SsrFalsePendingMinRoute } declare module '@tanstack/vue-router' { interface FileRoutesByPath { + '/ssr-false-pending-min': { + id: '/ssr-false-pending-min' + path: '/ssr-false-pending-min' + fullPath: '/ssr-false-pending-min' + preLoaderRoute: typeof SsrFalsePendingMinRouteImport + parentRoute: typeof rootRouteImport + } '/posts': { id: '/posts' path: '/posts' @@ -97,6 +114,7 @@ const PostsRouteWithChildren = PostsRoute._addFileChildren(PostsRouteChildren) const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, PostsRoute: PostsRouteWithChildren, + SsrFalsePendingMinRoute: SsrFalsePendingMinRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/e2e/vue-start/selective-ssr/src/routes/ssr-false-pending-min.tsx b/e2e/vue-start/selective-ssr/src/routes/ssr-false-pending-min.tsx new file mode 100644 index 0000000000..17bbd702ab --- /dev/null +++ b/e2e/vue-start/selective-ssr/src/routes/ssr-false-pending-min.tsx @@ -0,0 +1,56 @@ +import { defineComponent, onMounted, onUnmounted } from 'vue' +import { createFileRoute } from '@tanstack/vue-router' + +const pendingMinMs = 1500 + +function recordEvent(type: string) { + if (typeof window === 'undefined') { + return + } + + const global = window as any + global.__events ??= [] + global.__events.push({ type, t: performance.now() }) +} + +const Pending = defineComponent({ + setup() { + onMounted(() => { + recordEvent('pending-mounted') + }) + onUnmounted(() => { + recordEvent('pending-unmounted') + }) + + return () => ( +
Loading SSR false route...
+ ) + }, +}) + +const Target = defineComponent({ + setup() { + onMounted(() => { + recordEvent('target-mounted') + }) + + return () => ( +
SSR false route loaded
+ ) + }, +}) + +export const Route = createFileRoute('/ssr-false-pending-min')({ + ssr: false, + pendingMs: 0, + pendingMinMs, + pendingComponent: Pending, + loader: async () => { + recordEvent('loader-start') + await new Promise((resolve) => setTimeout(resolve, 50)) + recordEvent('loader-done') + + return { ok: true } + }, + component: Target, +}) diff --git a/e2e/vue-start/selective-ssr/tests/app.spec.ts b/e2e/vue-start/selective-ssr/tests/app.spec.ts index aea216d506..b14c7e028a 100644 --- a/e2e/vue-start/selective-ssr/tests/app.spec.ts +++ b/e2e/vue-start/selective-ssr/tests/app.spec.ts @@ -1,8 +1,73 @@ import { expect } from '@playwright/test' import { test } from '@tanstack/router-e2e-utils' +import type { Page } from '@playwright/test' const testCount = 7 +type BrowserEvent = { type: string; t: number } + +function findEvent(events: Array, type: string) { + const event = events.find((entry) => entry.type === type) + expect(event, `Expected browser event "${type}"`).toBeTruthy() + return event! +} + +function findLastEvent(events: Array, type: string) { + let event: BrowserEvent | undefined + for (let index = events.length - 1; index >= 0; index--) { + if (events[index]!.type === type) { + event = events[index] + break + } + } + expect(event, `Expected browser event "${type}"`).toBeTruthy() + return event! +} + +async function expectSsrFalsePendingMin(page: Page) { + const errors: Array = [] + + page.on('pageerror', (err) => { + errors.push(err.message) + }) + page.on('console', (msg) => { + if (msg.type() === 'error') { + errors.push(msg.text()) + } + }) + + await page.goto('/ssr-false-pending-min') + + const pending = page.getByTestId('ssr-false-pending') + const visiblePending = page.locator( + '[data-testid="ssr-false-pending"]:visible', + ) + const target = page.getByTestId('ssr-false-target') + + await expect(visiblePending).toHaveCount(1) + + await page.waitForTimeout(500) + + await expect(visiblePending).toHaveCount(1) + await expect(target).toBeHidden() + + await expect(target).toBeVisible() + await expect(pending).toHaveCount(0) + + const events = await page.evaluate>( + () => (window as any).__events ?? [], + ) + const pendingMounted = findEvent(events, 'pending-mounted') + const pendingUnmounted = findLastEvent(events, 'pending-unmounted') + const targetMounted = findEvent(events, 'target-mounted') + const loaderDone = findEvent(events, 'loader-done') + + expect(loaderDone.t - pendingMounted.t).toBeLessThan(500) + expect(pendingUnmounted.t - pendingMounted.t).toBeGreaterThanOrEqual(1400) + expect(targetMounted.t - pendingMounted.t).toBeGreaterThanOrEqual(1400) + expect(errors).toEqual([]) +} + test.describe('selective ssr', () => { test('testcount matches', async ({ page }) => { await page.goto('/') @@ -38,4 +103,10 @@ test.describe('selective ssr', () => { await expect(page.getByTestId('router-status')).toContainText('idle') }) } + + test('direct ssr false hydration keeps pending visible for pendingMinMs', async ({ + page, + }) => { + await expectSsrFalsePendingMin(page) + }) }) diff --git a/packages/react-router/src/Match.tsx b/packages/react-router/src/Match.tsx index 5de5546aeb..c6f0e7a2e4 100644 --- a/packages/react-router/src/Match.tsx +++ b/packages/react-router/src/Match.tsx @@ -3,11 +3,9 @@ import * as React from 'react' import { useStore } from '@tanstack/react-store' import { - createControlledPromise, getLocationChangeInfo, invariant, isNotFound, - isRedirect, rootRouteId, } from '@tanstack/router-core' import { isServer } from '@tanstack/router-core/isServer' @@ -33,13 +31,41 @@ type OutletMatchSelection = [ ] const matchViewFieldsEqual = (a: AnyRouteMatch, b: AnyRouteMatch) => - a.routeId === b.routeId && a._displayPending === b._displayPending + a.routeId === b.routeId && + a.ssr === b.ssr && + a._displayPending === b._displayPending const outletMatchSelectionEqual = ( a: OutletMatchSelection, b: OutletMatchSelection, ) => a[0] === b[0] && a[1] === b[1] +const getLoadPromise = ( + router: ReturnType, + match: AnyRouteMatch, +) => { + const localPromise = match._.loadPromise + const promise = + localPromise?.status === 'pending' + ? localPromise + : // React may render a stale match snapshot after its + // match-local promise was settled/removed but before + // the newer lane commits. Suspend that stale render on + // the current router load so it can be replaced safely. + router.latestLoadPromise + if (!promise) { + if (process.env.NODE_ENV !== 'production') { + throw new Error( + `Invariant failed: pending match "${match.id}" has no loadPromise`, + ) + } + + invariant() + } + + return promise +} + export const Match = React.memo(function MatchImpl({ matchId, }: { @@ -59,30 +85,18 @@ export const Match = React.memo(function MatchImpl({ invariant() } - const routeId = match.routeId as string - const parentRouteId = (router.routesById[routeId] as AnyRoute).parentRoute - ?.id - return ( ) } - // Subscribe directly to the match store from the pool. - // The matchId prop is stable for this component's lifetime (set by Outlet), - // and reconcileMatchPool reuses stores for the same matchId. - const matchStore = router.stores.matchStores.get(matchId) + if (!matchStore) { if (process.env.NODE_ENV !== 'production') { throw new Error( @@ -92,53 +106,36 @@ export const Match = React.memo(function MatchImpl({ invariant() } + // eslint-disable-next-line react-hooks/rules-of-hooks const resetKey = useStore(router.stores.loadedAt, (loadedAt) => loadedAt) // eslint-disable-next-line react-hooks/rules-of-hooks const match = useStore(matchStore, (value) => value, matchViewFieldsEqual) - // eslint-disable-next-line react-hooks/rules-of-hooks - const matchState = React.useMemo(() => { - const routeId = match.routeId as string - const parentRouteId = (router.routesById[routeId] as AnyRoute).parentRoute - ?.id - - return { - routeId, - ssr: match.ssr, - _displayPending: match._displayPending, - parentRouteId: parentRouteId as string | undefined, - } satisfies MatchViewState - }, [match._displayPending, match.routeId, match.ssr, router.routesById]) return ( ) }) -type MatchViewState = { - routeId: string - ssr: boolean | 'data-only' | undefined - _displayPending: boolean | undefined - parentRouteId: string | undefined -} - function MatchView({ router, matchId, resetKey, - matchState, + match, }: { router: ReturnType matchId: string resetKey: number - matchState: MatchViewState + match: AnyRouteMatch }) { - const route: AnyRoute = router.routesById[matchState.routeId] + const routeId = match.routeId as string + const route: AnyRoute = router.routesById[routeId] + const parentRouteId = route.parentRoute?.id const PendingComponent = route.options.pendingComponent ?? router.options.defaultPendingComponent @@ -156,8 +153,7 @@ function MatchView({ router.options.notFoundRoute?.options.component) : route.options.notFoundComponent - const resolvedNoSsr = - matchState.ssr === false || matchState.ssr === 'data-only' + const resolvedNoSsr = match.ssr === false || match.ssr === 'data-only' const ResolvedSuspenseBoundary = // If we're on the root route, allow forcefully wrapping in suspense (!route.isRoot || route.options.wrapInSuspense || resolvedNoSsr) && @@ -188,7 +184,7 @@ function MatchView({ onCatch={(error, errorInfo) => { // Forward not found errors (we don't want to show the error component for these) if (isNotFound(error)) { - error.routeId ??= matchState.routeId as any + error.routeId ??= routeId as any throw error } if (process.env.NODE_ENV !== 'production') { @@ -199,21 +195,24 @@ function MatchView({ > { - error.routeId ??= matchState.routeId as any + error.routeId ??= routeId as any // If the current not found handler doesn't exist or it has a // route ID which doesn't match the current route, rethrow the error if ( !routeNotFoundComponent || - (error.routeId && error.routeId !== matchState.routeId) || + (error.routeId && error.routeId !== routeId) || (!error.routeId && !route.isRoot) - ) + ) { throw error + } return React.createElement(routeNotFoundComponent, error as any) }} > - {resolvedNoSsr || matchState._displayPending ? ( + {match._displayPending ? ( + pendingElement + ) : resolvedNoSsr ? ( @@ -224,10 +223,10 @@ function MatchView({ - {matchState.parentRouteId === rootRouteId ? ( + {parentRouteId === rootRouteId ? ( <> - {router.options.scrollRestoration && (isServer ?? router.isServer) ? ( + {(isServer ?? router.isServer) && router.options.scrollRestoration ? ( ) : null} @@ -294,22 +293,6 @@ export const MatchInner = React.memo(function MatchInnerImpl({ }): any { const router = useRouter() - 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] - ) - } - if (isServer ?? router.isServer) { const match = router.stores.matchStores.get(matchId)?.get() if (!match) { @@ -324,9 +307,9 @@ export const MatchInner = React.memo(function MatchInnerImpl({ const routeId = match.routeId as string const route = router.routesById[routeId] as AnyRoute + const routeOptions = route.options const remountFn = - (router.routesById[routeId] as AnyRoute).options.remountDeps ?? - router.options.defaultRemountDeps + routeOptions.remountDeps ?? router.options.defaultRemountDeps const remountDeps = remountFn?.({ routeId, loaderDeps: match.loaderDeps, @@ -334,19 +317,11 @@ export const MatchInner = React.memo(function MatchInnerImpl({ search: match._strictSearch, }) const key = remountDeps ? JSON.stringify(remountDeps) : undefined - const Comp = route.options.component ?? router.options.defaultComponent + const Comp = routeOptions.component ?? router.options.defaultComponent const out = Comp ? : - if (match._displayPending) { - throw getMatchPromise(match, 'displayPendingPromise') - } - - if (match._forcePending) { - throw getMatchPromise(match, 'minPendingPromise') - } - if (match.status === 'pending') { - throw getMatchPromise(match, 'loadPromise') + throw getLoadPromise(router, match) } if (match.status === 'notFound') { @@ -360,21 +335,9 @@ 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 ?? - router.options.defaultErrorComponent) || + (routeOptions.errorComponent ?? router.options.defaultErrorComponent) || ErrorComponent return ( value) const routeId = match.routeId as string const route = router.routesById[routeId] as AnyRoute + const routeOptions = route.options // 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 + routeOptions.remountDeps ?? router.options.defaultRemountDeps const remountDeps = remountFn?.({ routeId, loaderDeps: match.loaderDeps, @@ -421,50 +385,36 @@ export const MatchInner = React.memo(function MatchInnerImpl({ match.loaderDeps, match._strictParams, match._strictSearch, + routeOptions.remountDeps, router.options.defaultRemountDeps, - router.routesById, ]) + const Comp = routeOptions.component ?? router.options.defaultComponent + // eslint-disable-next-line react-hooks/rules-of-hooks const out = React.useMemo(() => { - const Comp = route.options.component ?? router.options.defaultComponent if (Comp) { return } return - }, [key, route.options.component, router.options.defaultComponent]) - - if (match._displayPending) { - throw getMatchPromise(match, 'displayPendingPromise') - } - - if (match._forcePending) { - throw getMatchPromise(match, 'minPendingPromise') - } + }, [key, Comp]) - // see also hydrate() in packages/router-core/src/ssr/ssr-client.ts if (match.status === 'pending') { - // We're pending, and if we have a minPendingMs, we need to wait for it - const pendingMinMs = - route.options.pendingMinMs ?? router.options.defaultPendingMinMs - if (pendingMinMs) { - const routerMatch = router.getMatch(match.id) - if (routerMatch && !routerMatch._nonReactive.minPendingPromise) { - // Create a promise that will resolve after the minPendingMs - if (!(isServer ?? router.isServer)) { - const minPendingPromise = createControlledPromise() - - routerMatch._nonReactive.minPendingPromise = minPendingPromise - - setTimeout(() => { - minPendingPromise.resolve() - // We've handled the minPendingPromise, so we can delete it - routerMatch._nonReactive.minPendingPromise = undefined - }, pendingMinMs) - } - } + const PendingComponent = + routeOptions.pendingComponent ?? router.options.defaultPendingComponent + const pendingMinMs = PendingComponent + ? (routeOptions.pendingMinMs ?? router.options.defaultPendingMinMs) + : undefined + const localPromise = match._.loadPromise + if ( + !(isServer ?? router.isServer) && + localPromise?.status === 'pending' && + pendingMinMs + ) { + localPromise.pendingUntil ??= Date.now() + pendingMinMs } - throw getMatchPromise(match, 'loadPromise') + + throw getLoadPromise(router, match) } if (match.status === 'notFound') { @@ -478,32 +428,10 @@ 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 - // of a suspense boundary. This is the only way to get - // renderToPipeableStream to not hang indefinitely. - // We'll serialize the error and rethrow it on the client. if (isServer ?? router.isServer) { const RouteErrorComponent = - (route.options.errorComponent ?? - router.options.defaultErrorComponent) || + (routeOptions.errorComponent ?? router.options.defaultErrorComponent) || ErrorComponent return ( { - const index = ids.findIndex((id) => id === matchId) + const index = ids.indexOf(matchId!) return ids[index + 1] }) } const route = routeId ? router.routesById[routeId] : undefined - const pendingElement = router.options.defaultPendingComponent ? ( - + const DefaultPendingComponent = router.options.defaultPendingComponent + const pendingElement = DefaultPendingComponent ? ( + ) : null if (parentGlobalNotFound) { diff --git a/packages/react-router/src/ssr/renderRouterToStream.tsx b/packages/react-router/src/ssr/renderRouterToStream.tsx index 46275a5421..1117836c2c 100644 --- a/packages/react-router/src/ssr/renderRouterToStream.tsx +++ b/packages/react-router/src/ssr/renderRouterToStream.tsx @@ -76,7 +76,7 @@ export const renderRouterToStream = async ({ return createSsrStreamResponse( router, new Response(responseStream as any, { - status: router.stores.statusCode.get(), + status: router.statusCode, headers: responseHeaders, }), ) @@ -199,7 +199,7 @@ export const renderRouterToStream = async ({ return createSsrStreamResponse( router, new Response(responseStream as any, { - status: router.stores.statusCode.get(), + status: router.statusCode, headers: responseHeaders, }), ) diff --git a/packages/react-router/src/ssr/renderRouterToString.tsx b/packages/react-router/src/ssr/renderRouterToString.tsx index e9fe4ec779..ebb2c71790 100644 --- a/packages/react-router/src/ssr/renderRouterToString.tsx +++ b/packages/react-router/src/ssr/renderRouterToString.tsx @@ -21,7 +21,7 @@ export const renderRouterToString = async ({ } return new Response(`${html}`, { - status: router.stores.statusCode.get(), + status: router.statusCode, headers: responseHeaders, }) } catch (error) { diff --git a/packages/react-router/tests/Matches.test.tsx b/packages/react-router/tests/Matches.test.tsx index dd3ca3dfa6..8ab0c21f07 100644 --- a/packages/react-router/tests/Matches.test.tsx +++ b/packages/react-router/tests/Matches.test.tsx @@ -1,9 +1,12 @@ -import { afterEach, describe, expect, test } from 'vitest' +import { afterEach, describe, expect, test, vi } from 'vitest' +import * as React from 'react' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { createMemoryHistory } from '@tanstack/history' +import { createControlledPromise } from '@tanstack/router-core' import { Link, Outlet, + RouterContextProvider, RouterProvider, createRootRoute, createRoute, @@ -12,6 +15,7 @@ import { useMatchRoute, useMatches, } from '../src' +import { Match, MatchInner } from '../src/Match' const rootRoute = createRootRoute() @@ -145,6 +149,479 @@ test('should show pendingComponent of root route', async () => { expect(await rendered.findByTestId('root-content')).toBeInTheDocument() }) +test('pending fallback remains visible through pendingMinMs', async () => { + vi.useFakeTimers() + + try { + let navigation!: Promise + const root = createRootRoute({ + component: () => , + }) + const index = createRoute({ + getParentRoute: () => root, + path: '/', + component: () =>
Index
, + }) + const slow = createRoute({ + getParentRoute: () => root, + path: '/slow', + pendingMs: 0, + pendingMinMs: 100, + pendingComponent: () =>
Slow pending
, + loader: async () => { + await new Promise((resolve) => setTimeout(resolve, 10)) + }, + component: () =>
Slow content
, + }) + const router = createRouter({ + routeTree: root.addChildren([index, slow]), + history: createMemoryHistory({ initialEntries: ['/'] }), + defaultPendingMs: 0, + }) + + await router.load() + render() + expect(screen.getByText('Index')).toBeInTheDocument() + + await act(async () => { + navigation = router.navigate({ to: '/slow' }) + await vi.advanceTimersByTimeAsync(0) + }) + expect(screen.getByText('Slow pending')).toBeInTheDocument() + + await act(async () => { + await vi.advanceTimersByTimeAsync(99) + }) + expect(screen.getByText('Slow pending')).toBeInTheDocument() + expect(screen.queryByText('Slow content')).not.toBeInTheDocument() + + await act(async () => { + await vi.advanceTimersByTimeAsync(1) + await navigation + }) + expect(screen.getByText('Slow content')).toBeInTheDocument() + } finally { + cleanup() + vi.useRealTimers() + } +}) + +test('pending fallback remains visible while beforeLoad outlives pending timers', async () => { + vi.useFakeTimers() + + try { + let navigation!: Promise + const beforeLoadGate = createControlledPromise() + const root = createRootRoute({ + component: () => , + }) + const index = createRoute({ + getParentRoute: () => root, + path: '/', + component: () =>
Index
, + }) + const slow = createRoute({ + getParentRoute: () => root, + path: '/slow', + pendingMs: 10, + pendingMinMs: 50, + pendingComponent: () =>
Slow pending
, + beforeLoad: () => beforeLoadGate, + component: () =>
Slow content
, + }) + const router = createRouter({ + routeTree: root.addChildren([index, slow]), + history: createMemoryHistory({ initialEntries: ['/'] }), + defaultPendingMs: 0, + }) + + await router.load() + render() + expect(screen.getByText('Index')).toBeInTheDocument() + + await act(async () => { + navigation = router.navigate({ to: '/slow' }) + await vi.advanceTimersByTimeAsync(9) + }) + expect(screen.queryByText('Slow pending')).not.toBeInTheDocument() + expect(screen.queryByText('Slow content')).not.toBeInTheDocument() + + await act(async () => { + await vi.advanceTimersByTimeAsync(1) + }) + expect(screen.getByText('Slow pending')).toBeInTheDocument() + expect(screen.queryByText('Slow content')).not.toBeInTheDocument() + + await act(async () => { + await vi.advanceTimersByTimeAsync(50) + }) + expect(screen.getByText('Slow pending')).toBeInTheDocument() + expect(screen.queryByText('Slow content')).not.toBeInTheDocument() + + await act(async () => { + beforeLoadGate.resolve() + await navigation + }) + expect(screen.queryByText('Slow pending')).not.toBeInTheDocument() + expect(screen.getByText('Slow content')).toBeInTheDocument() + } finally { + cleanup() + vi.useRealTimers() + } +}) + +test('hidden child pending fallback does not delay parent pending completion', async () => { + vi.useFakeTimers() + + try { + let resolveParent!: () => void + let resolveChild!: () => void + let navigation!: Promise + const ChildPending = vi.fn(() =>
Child pending
) + + const root = createRootRoute({ + component: () => , + }) + const index = createRoute({ + getParentRoute: () => root, + path: '/', + component: () =>
Index
, + }) + const parent = createRoute({ + getParentRoute: () => root, + path: '/parent', + pendingMs: 0, + pendingMinMs: 50, + pendingComponent: () =>
Parent pending
, + loader: () => + new Promise((resolve) => { + resolveParent = resolve + }), + component: () => , + }) + const child = createRoute({ + getParentRoute: () => parent, + path: '/child', + pendingMs: 0, + pendingMinMs: 5000, + pendingComponent: ChildPending, + loader: () => + new Promise((resolve) => { + resolveChild = resolve + }), + component: () =>
Child content
, + }) + + const router = createRouter({ + routeTree: root.addChildren([index, parent.addChildren([child])]), + history: createMemoryHistory({ initialEntries: ['/'] }), + defaultPendingMs: 0, + }) + + render() + + await act(async () => { + navigation = router.navigate({ to: '/parent/child' }) + await vi.advanceTimersByTimeAsync(0) + }) + + expect(screen.getByText('Parent pending')).toBeInTheDocument() + expect(screen.queryByText('Child pending')).not.toBeInTheDocument() + expect(ChildPending).not.toHaveBeenCalled() + + resolveParent() + resolveChild() + + await act(async () => { + await vi.advanceTimersByTimeAsync(49) + }) + expect(screen.queryByText('Child content')).not.toBeInTheDocument() + + await act(async () => { + await vi.advanceTimersByTimeAsync(1) + await navigation + }) + + expect(screen.getByText('Child content')).toBeInTheDocument() + expect(ChildPending).not.toHaveBeenCalled() + } finally { + cleanup() + vi.useRealTimers() + } +}) + +test('pending match without fallback does not arm pendingMinMs', async () => { + vi.useFakeTimers() + + try { + const loaderGate = createControlledPromise() + const root = createRootRoute({ + component: () => , + }) + const slow = createRoute({ + getParentRoute: () => root, + path: '/slow', + wrapInSuspense: true, + pendingMinMs: 100, + loader: async () => { + await loaderGate + }, + component: () =>
Slow content
, + }) + const router = createRouter({ + routeTree: root.addChildren([slow]), + history: createMemoryHistory({ initialEntries: ['/slow'] }), + defaultPendingMs: 0, + }) + router.stores.setMatches(router.matchRoutes(router.latestLocation)) + + render() + + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) + + const slowMatch = router.state.matches.find( + (match) => match.routeId === slow.id, + )! + const loadPromise = slowMatch._.loadPromise + + expect(slowMatch.status).toBe('pending') + expect(loadPromise?.pendingUntil).toBeUndefined() + + loaderGate.resolve() + + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + await router.latestLoadPromise + }) + + expect(screen.getByText('Slow content')).toBeInTheDocument() + } finally { + cleanup() + vi.useRealTimers() + } +}) + +test('stale pending render ignores a resolved local promise and waits for latestLoadPromise', async () => { + const latestLoadPromise = createControlledPromise() + const root = createRootRoute() + const slow = createRoute({ + getParentRoute: () => root, + path: '/slow', + loader: () => latestLoadPromise, + component: () =>
Slow content
, + }) + const router = createRouter({ + routeTree: root.addChildren([slow]), + history: createMemoryHistory({ initialEntries: ['/slow'] }), + }) + const matches = router.matchRoutes(router.latestLocation) + router.stores.setMatches(matches) + const slowMatch = matches.find((match) => match.routeId === slow.id)! + const localPromise = slowMatch._.loadPromise! + localPromise.resolve() + router.latestLoadPromise = latestLoadPromise + + render( + + Fallback}> + + + , + ) + + expect(screen.getByText('Fallback')).toBeInTheDocument() + expect(screen.queryByText('Slow content')).not.toBeInTheDocument() + + await act(async () => { + router.updateMatch(slowMatch.id, (prev) => ({ + ...prev, + status: 'success', + _: { + ...prev._, + loadPromise: undefined, + }, + })) + router.latestLoadPromise = undefined + latestLoadPromise.resolve() + await latestLoadPromise + }) + + expect(screen.getByText('Slow content')).toBeInTheDocument() +}) + +test('Match view does not re-render for non-view match updates', () => { + const shellRender = vi.fn() + const root = createRootRoute({ + shellComponent: function ShellComponent({ + children, + }: { + children: React.ReactNode + }) { + shellRender() + return <>{children} + }, + component: () =>
Root content
, + }) + const router = createRouter({ + routeTree: root, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + const matches = router.matchRoutes(router.latestLocation) + router.stores.setMatches(matches) + const rootMatch = matches[0]! + + const rendered = render( + + + , + ) + + expect(rendered.container).toHaveTextContent('Root content') + expect(shellRender).toHaveBeenCalledTimes(1) + + act(() => { + router.updateMatch(rootMatch.id, (prev) => ({ + ...prev, + loaderData: { updated: true }, + })) + }) + + expect(shellRender).toHaveBeenCalledTimes(1) +}) + +test('MatchInner does not re-render route component for non-rendering match updates', () => { + const routeRender = vi.fn() + const root = createRootRoute({ + component: function RootComponent() { + routeRender() + return
Root content
+ }, + }) + const router = createRouter({ + routeTree: root, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + const matches = router.matchRoutes(router.latestLocation) + router.stores.setMatches(matches) + const rootMatch = matches[0]! + + const rendered = render( + + + , + ) + + expect(rendered.container).toHaveTextContent('Root content') + expect(routeRender).toHaveBeenCalledTimes(1) + + act(() => { + router.updateMatch(rootMatch.id, (prev) => ({ + ...prev, + loaderData: { updated: true }, + })) + }) + + expect(routeRender).toHaveBeenCalledTimes(1) + rendered.unmount() +}) + +test('Match view re-renders when same-id ssr field changes', () => { + const shellRender = vi.fn() + const root = createRootRoute({ + shellComponent: function ShellComponent({ + children, + }: { + children: React.ReactNode + }) { + shellRender() + return <>{children} + }, + component: () =>
Root content
, + }) + const router = createRouter({ + routeTree: root, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + const matches = router.matchRoutes(router.latestLocation) + matches[0]!.ssr = true + router.stores.setMatches(matches) + const rootMatch = matches[0]! + + const rendered = render( + + + , + ) + + expect(rendered.container).toHaveTextContent('Root content') + expect(shellRender).toHaveBeenCalledTimes(1) + ;([false, 'data-only', true] as const).forEach((ssr, index) => { + act(() => { + router.updateMatch(rootMatch.id, (prev) => ({ + ...prev, + ssr, + })) + }) + + expect(shellRender).toHaveBeenCalledTimes(index + 2) + }) + rendered.unmount() +}) + +test('fast load before pendingMs does not arm pendingUntil', async () => { + vi.useFakeTimers() + + try { + let navigation!: Promise + const root = createRootRoute({ + component: () => , + }) + const index = createRoute({ + getParentRoute: () => root, + path: '/', + component: () =>
Index
, + }) + const fast = createRoute({ + getParentRoute: () => root, + path: '/fast', + pendingMs: 100, + pendingMinMs: 10_000, + pendingComponent: () =>
Fast pending
, + loader: async () => { + await new Promise((resolve) => setTimeout(resolve, 10)) + }, + component: () =>
Fast content
, + }) + const router = createRouter({ + routeTree: root.addChildren([index, fast]), + history: createMemoryHistory({ initialEntries: ['/'] }), + defaultPendingMs: 0, + }) + + await router.load() + render() + expect(screen.getByText('Index')).toBeInTheDocument() + + await act(async () => { + navigation = router.navigate({ to: '/fast' }) + await vi.advanceTimersByTimeAsync(10) + await navigation + }) + + expect(screen.queryByText('Fast pending')).not.toBeInTheDocument() + expect(screen.getByText('Fast content')).toBeInTheDocument() + const fastMatch = router.state.matches.find( + (match) => match.routeId === fast.id, + )! + expect(fastMatch._.loadPromise?.pendingUntil).toBeUndefined() + } finally { + cleanup() + vi.useRealTimers() + } +}) + describe('matching on different param types', () => { const testCases = [ { 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/react-router/tests/errorComponent.test.tsx b/packages/react-router/tests/errorComponent.test.tsx index 0779c2e8c4..48df51be63 100644 --- a/packages/react-router/tests/errorComponent.test.tsx +++ b/packages/react-router/tests/errorComponent.test.tsx @@ -338,7 +338,7 @@ test('SSR errorComponent receives primitive errors thrown from beforeLoad', asyn await router.load() - expect(router.state.statusCode).toBe(500) + expect(router.statusCode).toBe(500) const html = ReactDOMServer.renderToString() expect(html).toContain('Error:') expect(html).toContain('primitive error thrown') diff --git a/packages/react-router/tests/loaders.test.tsx b/packages/react-router/tests/loaders.test.tsx index d9b5968d72..c9a800a735 100644 --- a/packages/react-router/tests/loaders.test.tsx +++ b/packages/react-router/tests/loaders.test.tsx @@ -647,7 +647,7 @@ test('reproducer #4546', async () => { } }) -test('clears pendingTimeout when match resolves', async () => { +test('does not show pending fallback when match resolves before pendingMs', async () => { const defaultPendingComponentOnMountMock = vi.fn() const nestedPendingComponentOnMountMock = vi.fn() const fooPendingComponentOnMountMock = vi.fn() @@ -751,12 +751,39 @@ test('throw abortError from loader upon initial load with basepath', async () => render() - const indexElement = await screen.findByText('Index route content') - expect(indexElement).toBeInTheDocument() - expect(screen.queryByTestId('index-error')).not.toBeInTheDocument() + const errorElement = await screen.findByTestId('index-error') + expect(errorElement).toBeInTheDocument() + expect(screen.queryByText('Index route content')).not.toBeInTheDocument() expect(window.location.pathname.startsWith('/app')).toBe(true) }) +test('aborted loader does not render the route component with undefined loaderData', async () => { + const rootRoute = createRootRoute({}) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + loader: async (): Promise<{ value: string }> => { + return Promise.reject(new DOMException('Aborted', 'AbortError')) + }, + component: () => { + const data = indexRoute.useLoaderData() + return
value: {data.value}
+ }, + errorComponent: () => ( +
indexErrorComponent
+ ), + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree, history }) + + render() + + expect(await screen.findByTestId('index-error')).toBeInTheDocument() + expect(screen.queryByTestId('index-content')).not.toBeInTheDocument() +}) + test('cancelMatches after pending timeout', async () => { function getPendingComponent(onMount: () => void) { const PendingComponent = () => { 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..06a716e316 100644 --- a/packages/react-router/tests/redirect.test.tsx +++ b/packages/react-router/tests/redirect.test.tsx @@ -152,7 +152,11 @@ describe('redirect', () => { // 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( + await screen.findByTestId('lazy-route-page', undefined, { + timeout: 5_000, + }), + ).toBeInTheDocument() expect(screen.queryByTestId('pending')).not.toBeInTheDocument() expect(router.state.location.href).toBe('/posts') expect(router.state.status).toBe('idle') @@ -345,9 +349,9 @@ describe('redirect', () => { await router.load() - expect(router.state.redirect).toBeDefined() - expect(router.state.redirect).toBeInstanceOf(Response) - const redirectResponse = router.state.redirect! + expect(router.redirect).toBeDefined() + expect(router.redirect).toBeInstanceOf(Response) + const redirectResponse = router.redirect! expect(redirectResponse.options).toEqual({ _fromLocation: expect.objectContaining({ @@ -395,7 +399,7 @@ describe('redirect', () => { await router.load() - const currentRedirect = router.state.redirect + const currentRedirect = router.redirect expect(currentRedirect).toBeDefined() expect(currentRedirect).toBeInstanceOf(Response) diff --git a/packages/react-router/tests/renderRouterToStream.test.tsx b/packages/react-router/tests/renderRouterToStream.test.tsx index 74e180a552..eb4dc7aeed 100644 --- a/packages/react-router/tests/renderRouterToStream.test.tsx +++ b/packages/react-router/tests/renderRouterToStream.test.tsx @@ -24,9 +24,9 @@ async function buildRouter() { const rootRoute = createRootRoute({ component: () => null }) const router = createRouter({ history: createMemoryHistory({ initialEntries: ['/'] }), + isServer: true, routeTree: rootRoute, }) - router.isServer = true attachRouterServerSsrUtils({ router, manifest: undefined }) await router.load() await router.serverSsr!.dehydrate() diff --git a/packages/react-router/tests/route.test-d.tsx b/packages/react-router/tests/route.test-d.tsx index 41b003e5d1..6466c2c2df 100644 --- a/packages/react-router/tests/route.test-d.tsx +++ b/packages/react-router/tests/route.test-d.tsx @@ -8,7 +8,6 @@ import { } from '../src' import type { BuildLocationFn, - ControlledPromise, NavigateFn, NavigateOptions, SearchSchemaInput, @@ -1328,8 +1327,6 @@ test('when creating a child route with context, search, params, loader, loaderDe search: TExpectedSearch context: TExpectedContext loaderDeps: { detailPage: number; invoicePage: number } - beforeLoadPromise?: ControlledPromise - loaderPromise?: ControlledPromise componentsPromise?: Promise> loaderData?: TExpectedLoaderData } 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/router.test.tsx b/packages/react-router/tests/router.test.tsx index cf3b1c11fe..ebd445e89c 100644 --- a/packages/react-router/tests/router.test.tsx +++ b/packages/react-router/tests/router.test.tsx @@ -2205,46 +2205,9 @@ describe('does not strip search params if search validation fails', () => { }) }) -describe('statusCode', () => { - it('should reset statusCode to 200 when navigating from 404 to valid route', async () => { - const history = createMemoryHistory({ initialEntries: ['/'] }) - - const rootRoute = createRootRoute() - - const indexRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/', - component: () =>
Home
, - }) - - const validRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/valid', - component: () =>
Valid Route
, - }) - - const routeTree = rootRoute.addChildren([indexRoute, validRoute]) - const router = createRouter({ routeTree, history }) - - render() - - expect(router.state.statusCode).toBe(200) - - await act(() => router.navigate({ to: '/' })) - expect(router.state.statusCode).toBe(200) - - await act(() => router.navigate({ to: '/non-existing' })) - expect(router.state.statusCode).toBe(404) - - await act(() => router.navigate({ to: '/valid' })) - expect(router.state.statusCode).toBe(200) - - await act(() => router.navigate({ to: '/another-non-existing' })) - expect(router.state.statusCode).toBe(404) - }) - +describe('route error rendering', () => { describe.each([true, false])( - 'status code is set when loader/beforeLoad throws (isAsync=%s)', + 'loader/beforeLoad errors render the matching boundary (isAsync=%s)', async (isAsync) => { const throwingFun = isAsync ? (toThrow: () => void) => async () => { @@ -2259,7 +2222,7 @@ describe('statusCode', () => { const throwError = throwingFun(() => { throw new Error('test-error') }) - it('should set statusCode to 404 when a route loader throws a notFound()', async () => { + it('renders notFound when a route loader throws a notFound()', async () => { const history = createMemoryHistory({ initialEntries: ['/'] }) const rootRoute = createRootRoute() @@ -2287,17 +2250,14 @@ describe('statusCode', () => { render() - expect(router.state.statusCode).toBe(200) - await act(() => router.navigate({ to: '/loader-throws-not-found' })) - expect(router.state.statusCode).toBe(404) expect( await screen.findByTestId('not-found-component'), ).toBeInTheDocument() expect(screen.queryByTestId('route-component')).not.toBeInTheDocument() }) - it('should set statusCode to 404 when a route beforeLoad throws a notFound()', async () => { + it('renders notFound when a route beforeLoad throws a notFound()', async () => { const history = createMemoryHistory({ initialEntries: ['/'] }) const rootRoute = createRootRoute({ @@ -2332,17 +2292,14 @@ describe('statusCode', () => { render() - expect(router.state.statusCode).toBe(200) - await act(() => router.navigate({ to: '/beforeload-throws-not-found' })) - expect(router.state.statusCode).toBe(404) expect( await screen.findByTestId('not-found-component'), ).toBeInTheDocument() expect(screen.queryByTestId('route-component')).not.toBeInTheDocument() }) - it('should set statusCode to 500 when a route loader throws an Error', async () => { + it('renders error when a route loader throws an Error', async () => { const history = createMemoryHistory({ initialEntries: ['/'] }) const rootRoute = createRootRoute() @@ -2368,15 +2325,12 @@ describe('statusCode', () => { render() - expect(router.state.statusCode).toBe(200) - await act(() => router.navigate({ to: '/loader-throws-error' })) - expect(router.state.statusCode).toBe(500) expect(await screen.findByTestId('error-component')).toBeInTheDocument() expect(screen.queryByTestId('route-component')).not.toBeInTheDocument() }) - it('should set statusCode to 500 when a route beforeLoad throws an Error', async () => { + it('renders error when a route beforeLoad throws an Error', async () => { const history = createMemoryHistory({ initialEntries: ['/'] }) const rootRoute = createRootRoute() @@ -2405,10 +2359,7 @@ describe('statusCode', () => { render() - expect(router.state.statusCode).toBe(200) - await act(() => router.navigate({ to: '/beforeload-throws-error' })) - expect(router.state.statusCode).toBe(500) expect(await screen.findByTestId('error-component')).toBeInTheDocument() expect(screen.queryByTestId('route-component')).not.toBeInTheDocument() }) @@ -2484,8 +2435,6 @@ describe('notFound in beforeLoad with pendingComponent', () => { await waitFor(() => { expect(router.state.status).toBe('idle') }) - - expect(router.state.statusCode).toBe(404) }) it('should transition router.state.status to idle when child beforeLoad throws notFound and parent has NO pendingComponent', async () => { @@ -2529,8 +2478,6 @@ describe('notFound in beforeLoad with pendingComponent', () => { await waitFor(() => { expect(router.state.status).toBe('idle') }) - - expect(router.state.statusCode).toBe(404) }) it('should transition router.state.status to idle when nested child beforeLoad throws notFound WITHOUT pendingComponent', async () => { @@ -2588,8 +2535,6 @@ describe('notFound in beforeLoad with pendingComponent', () => { await waitFor(() => { expect(router.state.status).toBe('idle') }) - - expect(router.state.statusCode).toBe(404) }) it('should transition router.state.status to idle when child async beforeLoad throws notFound and parent has pendingComponent with pendingMs: 0', async () => { @@ -2660,8 +2605,6 @@ describe('notFound in beforeLoad with pendingComponent', () => { await waitFor(() => { expect(router.state.status).toBe('idle') }) - - expect(router.state.statusCode).toBe(404) }) }) @@ -3612,7 +3555,6 @@ describe('basepath', () => { }) expect(router.state.location.pathname).toBe('/') - expect(router.state.statusCode).toBe(200) }, ) diff --git a/packages/react-router/tests/store-updates-during-navigation.test.tsx b/packages/react-router/tests/store-updates-during-navigation.test.tsx index 0c4c1b4146..5713e3bcd3 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(4) }) test('redirection in preload', async () => { @@ -146,6 +146,8 @@ describe("Store doesn't update *too many* times during navigation", () => { }, }) + await waitFor(() => expect(router.stores.status.get()).toBe('idle')) + const before = select.mock.calls.length await router.preloadRoute({ to: '/posts' }) const after = select.mock.calls.length @@ -154,7 +156,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(1) + expect(updates).toBe(0) }) test('sync beforeLoad', async () => { @@ -170,7 +172,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(5) + expect(updates).toBe(4) }) test('nothing', async () => { @@ -196,7 +198,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/react-router/tests/useParams.test.tsx b/packages/react-router/tests/useParams.test.tsx index 19cf096ea0..7a63d62a02 100644 --- a/packages/react-router/tests/useParams.test.tsx +++ b/packages/react-router/tests/useParams.test.tsx @@ -1,5 +1,6 @@ +import { useEffect } from 'react' import { expect, test, vi } from 'vitest' -import { act, fireEvent, render, screen } from '@testing-library/react' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { Link, Outlet, @@ -10,6 +11,63 @@ import { useParams, } from '../src' +test('keeps a params reference stable across search updates by default', async () => { + const effectSpy = vi.fn() + const paramSelections: Array<{ postId?: string }> = [] + + const rootRoute = createRootRoute({ + component: () => , + }) + + const postRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'posts/$postId', + component: PostComponent, + }) + + function PostComponent() { + const params = useParams({ strict: false }) + + useEffect(() => { + effectSpy(params) + paramSelections.push(params) + }, [params]) + + return
{params.postId}
+ } + + window.history.replaceState({}, '', '/posts/1?foo=one') + + const router = createRouter({ + routeTree: rootRoute.addChildren([postRoute]), + }) + + render() + + await act(() => router.load()) + + expect(await screen.findByTestId('post-id')).toHaveTextContent('1') + await waitFor(() => expect(effectSpy).toHaveBeenCalledTimes(1)) + + const initialParamsSelection = paramSelections[0] + + await act(() => + router.navigate({ + to: postRoute.fullPath, + params: { postId: '1' }, + search: { foo: 'two' }, + }), + ) + + await waitFor(() => { + expect(router.state.location.search).toEqual({ foo: 'two' }) + expect(effectSpy).toHaveBeenCalledTimes(1) + }) + + expect(paramSelections).toHaveLength(1) + expect(paramSelections[0]).toBe(initialParamsSelection) +}) + test('useParams must return parsed result if applicable.', async () => { const posts = [ { diff --git a/packages/router-core/INTERNALS.md b/packages/router-core/INTERNALS.md new file mode 100644 index 0000000000..d6559c3aa7 --- /dev/null +++ b/packages/router-core/INTERNALS.md @@ -0,0 +1,1075 @@ +# Router Core Match Loading Internals + +This document explains how router-core match loading works. It is written for +maintainers changing `src/router.ts`, `src/router-load.*.ts`, +`src/load-matches*.ts`, `src/route-assets.*.ts`, or the framework pending UI +integration. + +Match loading is intentionally split between server and client builds. The +server path is request-local and mostly linear. The client path has to handle +navigation cancellation, pending UI, preloads, background stale reloads, route +asset projection, and hydration handoff. + +## File Map + +- `src/router.ts` + - Dispatches `router.load()` and `router.preloadRoute()` to server or client + implementations. + - Builds route match arrays in `matchRoutesInternal`. + - Owns `cancelMatches()`, `getMatch()`, `updateMatch()`, cache APIs, and public + router state. `updateMatch()` patches only live active/pending match stores; + cache validity is owned by `setCached()` / `clearCache()` / cache + reconciliation, not by `updateMatch()`. +- `src/router-load.client.ts` + - Top-level client navigation load orchestration. + - Publishes pending matches, runs `loadClientMatches`, projects assets, and + performs the final active-lane commit. +- `src/router-load.server.ts` + - Top-level server load orchestration. + - Matches the request location, runs `loadServerMatches`, sets status code and + redirect state. +- `src/load-matches.ts` + - Shared types and small helpers for lane context, notFound boundary + selection, failure normalization, context merging, and promise settlement. +- `src/load-matches.client.ts` + - Client beforeLoad, loader, preload joining, pending UI, background reload, + route failure, and lane trimming logic. +- `src/load-matches.server.ts` + - Server beforeLoad, SSR eligibility, loader, route failure, and server asset + projection logic. +- `src/router-preload.client.ts` + - Client route preload entry point. Owns preload cancellation, route-control + recursion, asset projection, and owned-match cache insertion. +- `src/route-assets.client.ts` + - Client `head()` and `scripts()` projection for a finished lane. +- `src/route-assets.server.ts` + - Server `head()`, `scripts()`, and `headers()` projection. +- `src/stores.ts` + - Active, pending, and cached match pools. The compatibility `__store` + exposes only public client state (`status`, `isLoading`, `matches`, + `location`, and `resolvedLocation`). +- `src/ssr/ssr-client.ts` + - One-shot hydration reconstruction before falling back to normal + `router.load()` when needed. + +## Terminology + +### Match + +A match is an `AnyRouteMatch` for one route in the matched branch. Important +runtime fields: + +- `id`: stable identity built from route id, interpolated pathname, and loader + deps hash. +- `routeId`: route definition id. +- `index`: position in the matched branch. +- `status`: `pending`, `success`, `error`, or `notFound`. +- `isFetching`: `false`, `beforeLoad`, or `loader`. +- `context`: merged parent context, route context, and beforeLoad context. +- `abortController`: per-generation abort controller. Client work uses it as an + ownership/currentness token; server `beforeLoad` and loader contexts also + receive one. +- `_.loadPromise`: private readiness promise used by Suspense, + pending UI, preloads, and cancellation cleanup. +- `_.dehydrated`: client hydration marker. +- `_.error`: private redirect marker used by preload borrowing. + notFound and regular error outcomes live on public match state. +- `_displayPending`: hydration-only flag that asks frameworks to render pending + UI for a hydration display match. The display match can be the SPA fallback or + the first `ssr: false`/`data-only` match, so it is not always dehydrated. + +### Lane + +A lane is an ordered array of matches that represents one possible renderable +branch for one location. + +The same route branch can exist in several lanes at once: + +- **Active lane**: `router.stores.matches`. This is the current rendered router + state. +- **Pending lane**: `router.stores.pendingMatches`. This is the lane currently + being navigated to and exposed to readers as pending work. +- **Render-ready pending lane**: a private lane copy that was published into + `router.stores.matches` by `onReady()` so pending UI can render before the + final commit. +- **Cached lane entries**: `router.stores.cachedMatches`. These are inactive + finalized success snapshots from navigation/preload cache ownership. Public + invalidation may mark them `invalid: true`, but cached entries should remain + `status: 'success'` until they are reused, expired, or explicitly cleared. +- **Private load lane**: `InnerLoadContext.matches`. This is a mutable array + owned by one load pass. It is not necessarily published. +- **Preload lane**: a private load pass created with `preload: true`. Owned + entries have `match.preload === true`; borrowed active/pending entries keep + their original flag. If a borrowed owner disappears or owns a route-control / + error outcome, the preload returns without projecting assets or caching owned + speculative descendants. +- **Background lane**: a private copy of the active lane used for client + non-blocking reloads. Same-href loads can sometimes skip the foreground final + commit and become background-only. + +The store layer stores lanes as ids plus per-match stores: + +```mermaid +flowchart LR + subgraph "Router stores" + AM["matchStores"] + AI["matchesId"] + PM["pendingMatchStores"] + PI["pendingIds"] + CM["cachedMatchStores"] + CI["cachedIds"] + end + + A["active lane = readPoolMatches(matchStores, matchesId)"] + P["pending lane = readPoolMatches(pendingMatchStores, pendingIds)"] + C["cached entries = readPoolMatches(cachedMatchStores, cachedIds)"] + + AM --> A + AI --> A + PM --> P + PI --> P + CM --> C + CI --> C +``` + +`router.updateMatch(id, updater)` intentionally targets only active or pending +match stores. It does not mutate `cachedMatchStores`, and it is not responsible +for evicting invalid cache entries. Cache publication and eviction go through +`stores.setCached()` and `router.clearCache()` so the cache invariant stays +centralized. + +### Commit + +There are several kinds of commit. Keep them separate: + +- **Private match commit**: `commitMatch(inner, index, patch)` replaces one + entry in a private lane. This does not publish router state by itself. +- **Pending publication**: `onReady(matches)` publishes a render-ready pending + lane into active matches so frameworks can render pending UI. It does not run + route lifecycle hooks, cache reconciliation, `loadedAt`, or the final view + transition. It also clears the pending match pool for that load pass. +- **Final client commit**: `commitFinalMatches()` publishes the final active + lane, reconciles cache, clears pending state, updates `loadedAt`, and runs + `onLeave`, `onStay`, and `onEnter`. +- **Background commit**: `startBackgroundLoad()` publishes a completed client + non-blocking reload lane with `stores.setMatches(matches)`. It is atomic for + data and assets, but it does not run navigation lifecycle hooks. +- **Server commit**: `loadServerRouter()` writes the server lane into + `stores.matches` and updates server status/redirect properties. + +### Current + +"Current" means "this async work still owns the state it wants to mutate". + +Client match work is current when all of these hold: + +- `inner.matches[index]` still exists. +- `inner.matches[index].abortController` is the controller captured by this + async pass. +- The controller signal has not been aborted. +- If `router.pendingBuiltLocation` exists, its public browser href still equals + the load context public href. + +The helper `requireCurrentMatch(inner, index, controller)` checks those +conditions. It throws `inner` as a private sentinel when ownership is lost. +`loadClientMatches()` recognizes that sentinel and rethrows it so route-error +reduction does not run. The entry point that owns the pass then treats it as +cancellation: foreground navigation cleanup resolves the public load, while +preloads return without projecting assets or caching speculative matches. + +Top-level client navigation is current when +`router.latestLoadPromise === loadPromise`. + +Background work is current when: + +- `router._backgroundLoad === token`. +- The token controller is not aborted. +- The active store location still has the token href. +- There is no `pendingBuiltLocation`. + +## Server vs Client Split + +`router.ts` chooses the implementation at module initialization: + +```mermaid +flowchart TD + L["router.load(opts)"] --> D{"isServer export"} + D -->|"true"| S["loadServerRouter(router, opts)"] + D -->|"false"| C["loadClientRouter(router, opts)"] + D -->|"undefined in tests/dev"| R{"router.isServer"} + R -->|"true"| S + R -->|"false"| C + + P["router.preloadRoute(opts)"] --> PD{"server?"} + PD -->|"yes"| PN["return undefined"] + PD -->|"no"| PC["preloadClientRoute(router, opts)"] +``` + +The split is not just an optimization. The two runtimes have different +contracts. + +Server match loading: + +- Is intended for request-local router instances and assumes no client + interleaving. +- Does not inspect `router.load(opts)`: `sync`, `action`, request signals, + preloads, background work, and client currentness are client-side semantics in + this implementation. +- Does not publish pending UI. +- Does not run client preloads. +- Does not run background stale reloads. +- Does not need foreground current-load checks. +- Does not use stale-time reload decisions. +- Owns status code and redirect response metadata. +- Keeps status code and redirect response metadata as router instance + properties, not reactive client stores. +- Projects server assets, including headers. + +Client match loading: + +- Can have overlapping navigations, preloads, background reloads, and hydration + fallback loads. +- Can publish pending UI before final navigation commit when a pending match's + pending timer fires. +- Uses `AbortController`, `latestLoadPromise`, and background tokens to reject + stale async work. +- Projects foreground/background assets with currentness guards, and projects + successful preload assets only for owned preload entries. +- Caches successful owned preloads and eligible old active matches. + +## Match Creation + +Both server and client call `router.matchRoutes(location)`. The same function +creates the initial lane. + +`matchRoutesInternal()`: + +1. Finds matched routes. +2. Handles unmatched/global notFound routing. +3. Validates search params; for new matches, extracts and validates strict path + params. +4. Computes loader deps and the match id. +5. Reuses an existing match by id when possible. +6. Creates a fresh `AbortController`. +7. Sets `cause` to `preload`, `stay`, or `enter`. +8. Runs route `context()` for new matches. +9. Merges route context and existing beforeLoad context. + +New client matches start as `pending` when the route has work that can block +rendering: + +- `loader` +- `beforeLoad` +- `lazyFn` +- a component preload (`component`, `errorComponent`, `pendingComponent`, + `notFoundComponent`) + +Server-created matches do not use client pending status for this decision. + +Existing matches are shallow-copied into the new lane with fresh +params/search/preload/cause/controller. The private `_` bucket preserves an +existing `_.loadPromise` first, because hydration and pending owners can keep +readiness ownership across rematching. If there is no load promise, the copy +preserves only the dehydrated marker. That copy is a new generation. The old +match may still exist in an active, pending, or cached pool. + +## Client Top-Level Navigation Load + +`loadClientRouter()` owns the public navigation workflow. + +```mermaid +sequenceDiagram + participant App + participant Router + participant Stores + participant Loader as loadClientMatches + participant Assets as projectClientRouteAssets + participant Commit as commitFinalMatches + participant Bg as startBackgroundLoad + + App->>Router: router.load(opts) + Router->>Router: create loadPromise + Router->>Router: latestLoadPromise = loadPromise + Router->>Router: abort active background load if any + Router->>Router: cancelMatches() + Router->>Router: updateLatestLocation() + Router->>Router: matchRoutes(latestLocation) + Router->>Stores: status=pending, isLoading=true, location=next + Router->>Stores: setPending(pendingMatches), evict overlapping cache + Router->>Loader: loadClientMatches(loadContext) + Loader-->>Router: loaded private lane + alt foreground commit required + Router->>Assets: project assets for loaded lane + Assets-->>Router: assets written into private lane + Router->>Router: startViewTransition(...) + Router->>Router: startTransition(...) + Router->>Commit: publish final lane + Commit->>Stores: setMatches, clear pending, set cached, loadedAt + Commit->>Router: run onLeave/onStay/onEnter + else pure same-href background only + Router->>Stores: isLoading=false, clear pending + end + opt stale matches selected for background reload + Router->>Bg: startBackgroundLoad(next, loadedMatches, indices) + end + Router->>Router: resolve commitLocationPromise and loadPromise + opt background was started + Router->>Router: yield one microtask for sync background commit + end +``` + +Core sets `status` to `pending` at the start. Normal final commits and +background-only exits clear `isLoading`/pending; foreground redirects clear +pending and start a replacement navigation, whose load later clears +`isLoading`. Framework transitioners are responsible for later marking the +router status idle and updating `resolvedLocation`. + +### Background-only foreground pass + +When a same-href load finds only non-blocking stale reloads, the foreground pass +does not final-commit. `loadClientRouter()` first filters the selected +background indices against the finalized loaded lane. Only matches that still +exist, are `success`, and are not `globalNotFound` can stay in that list. This +is the `backgroundOnly` case: + +- The target href equals the resolved/current href. +- The filtered background list is non-empty. +- No route work set `requiresCommit`. +- No pending UI was published. + +The foreground pass clears pending/isLoading and then starts the background +reload. If a background load was started, `loadClientRouter()` yields one +microtask after resolving its public load promise so synchronous background work +can publish its final state before callers observe a transient fetching marker. + +## Client Match Loading + +`loadClientMatches(inner)` owns the private client lane. + +It has two large phases: + +1. Run `beforeLoad` serially from root to leaf. +2. Run loader/chunk work in parallel for the renderable prefix. + +The serial phase matters because child contexts depend on parent contexts. +The loader phase can be parallel because loader contexts receive a +`parentMatchPromise`. + +```mermaid +flowchart TD + Start["loadClientMatches(inner)"] --> BL["serial beforeLoad pass"] + BL --> BF{"beforeLoad failure?"} + BF -->|"redirect"| Prefix0["loader prefix length = 0"] + BF -->|"notFound"| NFPrefix["prefix = min(boundary + 1, failing index)"] + BF -->|"regular error"| ErrPrefix["prefix ends before failing route"] + BF -->|"none"| FullPrefix["prefix = full lane or first bad index"] + + Prefix0 --> Loaders["start loader/chunk promises for prefix"] + NFPrefix --> Loaders + ErrPrefix --> Loaders + FullPrefix --> Loaders + + Loaders --> FinalizeBL["append beforeLoad failure finalizer if any"] + FinalizeBL --> Reduce["reduce all promises together"] + Reduce --> Stale{"lost ownership sentinel?"} + Stale -->|"yes"| ThrowStale["throw sentinel to caller"] + Stale -->|"no"| Redirect{"redirect?"} + Redirect -->|"yes"| ThrowRedirect["throw redirect"] + Redirect -->|"no"| Fatal{"unhandled fatal rejection?"} + Fatal -->|"yes"| ThrowFatal["throw fatal rejection"] + Fatal -->|"no"| NotFound{"notFound?"} + NotFound -->|"yes"| CommitBoundary["commit notFound boundary and pop descendants"] + NotFound -->|"no"| ErrorIndex{"badIndex?"} + ErrorIndex -->|"yes"| PopAfterError["pop descendants after error index"] + ErrorIndex -->|"no"| Done["return private lane"] + CommitBoundary --> Done + PopAfterError --> Done +``` + +### beforeLoad on the client + +`handleClientBeforeLoad()`: + +- Creates a load promise when the match has `beforeLoad`, `loader`, or a serial + validation error. +- Arms the pending timer after serial validation succeeds, when the route can + show pending UI and the match is still pending. +- Commits `isFetching: 'beforeLoad'` for async beforeLoad, serial validation + errors, and thrown synchronous beforeLoad failures. +- Calls `beforeLoad` with parent context, params, search, location, navigate, + `abortController`, and current lane matches. +- Commits `__beforeLoadContext` and merged `context` only after `beforeLoad` + resolves. +- Converts thrown/returned redirects and notFounds into a serial failure record. + +This is why a child loader never sees a half-finished parent beforeLoad context. +When the serial phase records a beforeLoad failure, retained ancestor prefix +loaders run with `inner.background` temporarily disabled. Those reloads belong +to the foreground failure lane and must not be deferred into a background batch +that may be discarded by the boundary trim. + +### loader and chunk work on the client + +`loadClientRouteMatch()` handles one match: + +1. Hydrated matches finish immediately after reconstructing context. +2. Preload-disabled matches finish without running work. +3. Successful matches are checked for invalidation, staleness, or + `shouldReload`. +4. Non-blocking reloads are selected for background work. +5. Otherwise route chunks and loader run. +6. Loader data is committed into the private lane. If a loader exists, its + result is written even when the value is `undefined`; a reload that returns + `undefined` clears stale `loaderData` instead of preserving the previous + value. +7. `shouldReload` and loader failures are normalized and finalized. Serial + `beforeLoad` failures are finalized by `loadClientMatches()` using the same + finalizer. Route chunk failures are committed directly. +8. `pendingMinMs` is honored if pending UI was rendered. +9. Success clears error/fetching, sets `updatedAt`, and settles load promise. + +`parentMatchPromise` points at `matchPromises[index - 1]`, so loaders can wait +for parent work without forcing the whole route branch to become serial. + +Lost ownership is not a route outcome. If route work catches `err === inner`, it +rethrows the sentinel instead of reducing it as a route error. The path that +owns the abandoned work is responsible for promise settlement or cleanup before +the pass is dropped. + +## Pending UI and pendingMinMs + +Pending UI has two timers: + +- `pendingMs`: when the route is allowed to publish pending UI. +- `pendingMinMs`: once pending UI is rendered, how long it must remain visible. + +Core owns `pendingMs`. Framework match components own `pendingMinMs` by writing +`loadPromise.pendingUntil` only when a pending fallback actually renders for a +still-pending local load promise. Core later observes `pendingUntil` before +committing success/error/notFound/redirect. + +```mermaid +sequenceDiagram + participant Load as loadClientMatches + participant Timer as pendingMs timer + participant Router as loadClientRouter + participant FW as framework Match + participant Work as beforeLoad/loader/chunk + + Load->>Timer: set pendingTimeout on loadPromise + Load->>Work: continue route work + Timer-->>Router: onReady(private lane copy) + Router->>Router: stores.setMatches(render-ready lane) + Router->>Router: stores.setPending([]) + FW->>FW: render pendingComponent + FW->>Load: loadPromise.pendingUntil = now + pendingMinMs + Work-->>Load: resolves or fails + Load->>Load: wait until pendingUntil if needed + Load-->>Router: final private lane or thrown outcome + Router->>Router: final commit or redirect +``` + +Important invariants: + +- Pending publication is not final commit. +- Pending publication does not run lifecycle hooks. +- Pending publication does not reconcile cache. +- Pending publication does not consume the final view transition. +- After pending UI, a renderable same-href outcome still needs a final commit. + Redirect control flow waits any pending minimum and then navigates instead. + +Tests that anchor this behavior include: + +- `pending publication runs no lifecycle or cache reconciliation before beforeLoad error final commit` +- `pending publication does not consume the final view transition` +- `visible pending remains until beforeLoad settles after pendingMs plus pendingMinMs` +- `pendingMin wait does not throw after its match is canceled` + +## Failure Finalization and Lane Shortening + +Route failures can change the size of the lane. + +### Regular error + +A regular route error commits on the failing match and marks `inner.badIndex`. +Descendants after that match are removed after the promise reduction. Core does +not select an ancestor error boundary for route loading errors; framework error +boundaries handle render-time propagation later. + +### notFound + +A notFound chooses a notFound boundary with `getNotFoundBoundaryIndex()`. That +boundary may be the throwing route, an ancestor, or root. The final lane is +trimmed to include only matches up to the selected boundary. Non-root boundaries +become `status: 'notFound'`; root/global notFound uses `globalNotFound: true` +on a success root match. + +### redirect + +A redirect is terminal control flow. In foreground loads, it is resolved by +`router.resolveRedirect` and thrown to `loadClientRouter()`, which calls +`router.navigate()` with `replace: true` and `ignoreBlocker: true`. Background +redirects are handled inside `startBackgroundLoad()`. + +### Component/chunk failure while handling another failure + +If loading an `errorComponent` or `notFoundComponent` fails, the component/chunk +failure replaces the original route failure and is committed directly as an +error. It must not recursively try to load another boundary component. + +```mermaid +flowchart TD + F["loader/beforeLoad throws"] --> N["normalizeRouteFailure"] + Chunk["route/component chunk throws"] --> Direct + N --> R{"redirect?"} + R -->|"yes"| RT["settle source match and throw resolved redirect"] + R -->|"no"| NF{"notFound?"} + NF -->|"yes"| B["choose notFound boundary"] + B --> LNF["load notFoundComponent chunk"] + LNF --> NFCommit["commit temporary notFound and throw notFound"] + NF -->|"no"| LE["load errorComponent chunk"] + LE --> ErrCommit["commit error and mark badIndex"] + LNF --> CFail["component/chunk failure"] + LE --> CFail + CFail --> Direct["commit chunk/component failure as error"] +``` + +The lane can also change size before loaders start. A serial beforeLoad failure +limits the loader prefix so descendants that cannot render will not run. + +## How Lanes Change Size + +A lane is an array, and the array length is allowed to change at specific +boundaries. + +| Moment | How size changes | Why it is allowed | +| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| `matchRoutes()` | The lane length follows the matched URL branch. Unmatched paths can add a notFound route or mark a global notFound boundary. | This is initial route matching, before any async work owns the lane. | +| New navigation | The new pending lane can be shorter, longer, or share a prefix with the active lane. | A different URL can match a different branch. | +| beforeLoad serial failure | The loader prefix is shortened before loader promises are started. For notFound, the prefix is capped at the smaller of `selectedBoundaryIndex + 1` and the throwing route index: ancestor/root boundary loaders may run, but the throwing route and its descendants do not run during the prefix. | Descendants below a redirect/error/notFound outcome cannot render correctly. | +| Loader or chunk error | Descendants after `inner.badIndex` are popped. | The failing match owns the route-loading error state below that point. | +| notFound | Descendants after the selected notFound boundary are popped. | The chosen boundary is the renderable leaf for this outcome. | +| Background error/notFound | The copied background lane is trimmed before atomic publication. | Active state must not see partial stale data or stale head output. | +| Final commit | Replaced active matches are moved into cache if eligible. | The new active lane owns rendering; old successful loader matches may be reused later. | +| Preload finish | Borrowed matches are not cached; owned successful preload matches may be cached. | A preload must not take ownership of active or pending foreground matches. | + +Lane shortening must settle load promises for removed matches. Otherwise +Suspense/preload waiters can remain pending after the match is no longer +reachable. + +## Preloads and Match Borrowing + +`router.preloadRoute()` is client-only. It builds a preload lane with +`matchRoutes(next, { preload: true, throwOnError: true })`, then calls +`loadClientMatches()` with a list of active and pending match ids that may be +borrowed. + +Preloading has two kinds of matches: + +- **Borrowed matches**: ids already present in the active or pending lanes. + The preload observes these read-only and does not cache them as its own work. +- **Owned preload matches**: `match.preload` entries that are not active or + pending when the successful preload is cached. + +```mermaid +sequenceDiagram + participant P as preloadClientRoute + participant R as Router + participant L as loadClientMatches + participant J as joinPreloadedActiveMatch + participant F as foreground owner + participant C as cache + + P->>R: matchRoutes(next, preload=true) + P->>R: collect active + pending ids + P->>L: loadClientMatches(preload ids) + L->>J: matched id is borrowable + J->>R: getMatch(id, includeCached=false) + alt borrowed loadPromise pending + J->>F: await borrowed loadPromise + end + alt borrowed match still in pending store + J->>F: await router.latestLoadPromise + end + J->>R: re-read owner match + alt owner missing/aborted + J-->>L: throw inner sentinel + else owner renderable + J-->>L: install borrowed match read-only in preload lane + opt borrowed owner has route-control/error ownership + J-->>L: throw inner sentinel + end + end + alt lane lost ownership + L-->>P: throw loadContext sentinel + P-->>P: return without assets/cache + else lane completed + L-->>P: loaded preload lane + end + alt every match success + P->>P: project and await preload assets + P->>C: cache owned preload matches only + end + P->>P: settle preload-owned load promises +``` + +Why wait for both `loadPromise` and sometimes `latestLoadPromise`? + +- `loadPromise` says the borrowed match's local work has reached a terminal + render state. +- `latestLoadPromise` says the foreground owner has actually committed or + disappeared from the pending store. + +Without the foreground wait, a preload can observe a transient pending-store +owner and start descendant work before the navigation that owns the parent has +finished. Tests anchor this with: + +- `joined preload waits for borrowed parent success while a sibling is still loading` +- `joined preload waits for borrowed terminal error to commit` +- `joined preload waits for borrowed notFound to commit` +- `preload canceled by borrowed owner disappearance projects no assets and caches nothing` + +Preload route-control behavior: + +- A private preload redirect recursively preloads the redirect target. It does + not navigate the active app, and it stops if `reloadDocument` is required. +- A preload notFound or regular error is not fatal to the app. +- Borrowed active redirect/notFound/error stops descendant preload work because + the active foreground owner already owns that outcome. +- Borrowed matches must re-read as present, un-aborted, and `status: 'success'` + after any waits. Otherwise the preload throws its private sentinel, projects + no assets, and caches nothing for speculative descendants. + +Cache invariant: + +- Pending preload work is private and does not enter `cachedMatches`. +- Failed, redirected, notFound, loaderless transient, and pending speculative + results should not be cached through public flows. +- `invalidate({ forcePending: true })` keeps cached entries as invalid success + snapshots instead of turning cached entries into pending matches. +- `clearCache()` is the explicit cleanup path for cached entries and only owns + cache membership. Public cache entries are written after match readiness has + settled, so cache removal does not settle synthetic private promises. + +## Background Stale Reloads + +Background reloads happen only on the client for invalid, stale, or +`shouldReload` successful loader matches whose stale reload mode is not +`blocking`. + +The foreground pass selects background indices inside `loadClientRouteMatch()`. +If a same-href load can background every necessary reload without any required +foreground commit or pending publication, `loadClientRouter()` skips final +foreground commit and starts `startBackgroundLoad()`. Other loads can still +start background work after their foreground commit. + +Background work is token-owned: + +- Starting a new background load aborts the old token. +- Starting a foreground load aborts any active background token and clears + fetching markers. +- A background token is current only while the active location href still + matches and no foreground pending location exists. +- Once a worker observes that the token is stale, cancellation is sticky: the + token is aborted and later workers, asset projection, and publication all stop. +- Background startup rewrites fetching markers across the whole active base lane: + selected indices become `isFetching: 'loader'`, and non-selected entries are + reset to `false`. + +```mermaid +sequenceDiagram + participant R as loadClientRouter + participant S as Stores + participant B as startBackgroundLoad + participant L1 as loader A + participant L2 as loader B + participant A as projectClientRouteAssets + + R->>R: load selects non-blocking stale/invalid/shouldReload indices + opt same-href background-only + R->>S: clear pending/isLoading without final commit + end + R->>B: start token-owned background lane + B->>S: selected active matches fetching, non-selected false + par selected loader A + B->>L1: run with copied lane + and selected loader B + B->>L2: run with copied lane + end + opt any worker observes stale token + B->>B: abort token and stop publication + end + B->>B: wait for all selected work + B->>B: reduce route-order outcomes + alt redirect + B->>R: navigate(replace=true, ignoreBlocker=true) + else error or notFound + B->>B: load selected failure component and trim lane + B->>A: project assets for trimmed lane + B->>S: setMatches(background lane) + else success + B->>A: project assets after all fresh loader data exists + B->>S: setMatches(background lane) + end + B->>S: if token still registered and not committed, clear fetching markers +``` + +Outcome priority for background batches: + +1. Redirect wins over notFound and regular errors. +2. If no redirect wins, the shallowest regular error wins. +3. If there is no regular error, the first selected notFound wins. + +Background asset projection waits until all selected loader work has settled. +That keeps data and head coherent. A parent-only background reload can still +republish child head because the child head may derive from parent loader data. +Selected background loaders own their `loaderData` field just like foreground +loaders: an `undefined` result is written into the copied background lane and +asset projection sees that cleared value before the atomic `setMatches()`. + +Tests that anchor this behavior include: + +- `same-url background beforeLoad context remains private until the atomic background commit` +- `pure same-url background revalidation does not perform a foreground commit` +- `multiple async background loaders flush heads after parent then child resolve` +- `background child redirect waits for parent error and then wins when ... settles first` +- `background parent error waits for child notFound and then wins when ... settles first` +- `background shallow regular error waits for deep regular error and then wins when ... settles first` +- `newer background batch supersedes older batch for the same lane` +- `foreground navigation clears an active background fetching marker before commit` +- `background batch that observed a pending navigation cannot later commit` +- `successful background commit performs one active match publication` + +## Route Asset Projection + +Route asset projection means executing route `head()`, `scripts()`, and on the +server `headers()`, then writing those results into the match lane. + +Projection always uses the lane it is handed. This matters because `head()` can +read `matches`, `match`, params, and loaderData. + +Client projection: + +- Runs after foreground match loading and before final commit. +- Runs after successful preloads, but only for `match.preload` entries. +- Runs after background loader work completes and before the background atomic + `setMatches()`. +- Foreground and background projection pass `isCurrent()` checks so stale lanes + do not mutate or publish assets. +- Preload projection filters to `match.preload` entries and relies on + owned-match cache checks instead of an `isCurrent()` callback. +- Handles `head()` and `scripts()`, not headers. +- If ownership is lost after an async `head()` or `scripts()` was started, the + projector observes the abandoned promise with `Promise.allSettled()` before + returning. This prevents unhandled rejections without committing stale assets + or continuing to later route asset hooks. +- If `scripts()` throws synchronously after an async `head()` started, client + projection observes that `head()` promise for the same reason, even when the + lane is still current. + +Server projection: + +- After reduction, redirects throw before asset projection. For non-redirect + outcomes, server projection runs over the trimmed/current lane before + throwing notFound or route errors. Unhandled fatal rejections project the + current lane before being rethrown; they may not have a committed error match. +- Handles `head()`, `scripts()`, and `headers()`. +- Does not need currentness checks. +- If `scripts()` or `headers()` throws synchronously after an earlier async asset + hook started, server projection observes the abandoned earlier promises. Async + server projection uses `Promise.allSettled()` so one rejected asset hook does + not leave another hook unobserved. + +Hydration projection: + +- Is not the same function as normal client projection. +- `ssr-client.ts` waits for route chunks first because lazy chunks can install + route context, head, and scripts. +- It reconstructs route context from dehydrated data where available, then + executes head and scripts for each match in the initial hydration lane. +- A `notFound()` thrown by hydration head/scripts records a notFound-shaped + match error and continues. Other hydration asset/context errors reject, + settle matches, and clear display pending. + +## Currentness and Cancellation + +Client currentness exists because async work can resolve after it no longer +owns the lane. + +```mermaid +stateDiagram-v2 + [*] --> Current + Current --> Current: await returns and owner still matches + Current --> Stale: controller identity changed + Current --> Stale: controller aborted + Current --> Stale: match removed from private lane + Current --> Stale: pendingBuiltLocation publicHref changed + Current --> Stale: background token replaced + Current --> Stale: latestLoadPromise replaced + Stale --> [*]: throw private sentinel or return without publishing + Current --> Mutated: private match commit + Current --> Published: pending publication / final commit / background setMatches +``` + +`cancelMatches()` aborts and settles pending and active matches. It skips active +matches that are also in the pending pool so the same match is not canceled +twice. Settling clears `loadPromise`, clears pending timeout, and resolves the +promise if it is still pending. + +After foreground/background work detects ownership loss, it must not: + +- run route error handling, +- commit or publish asset results, or continue to later asset hooks after + currentness is lost, +- publish route results into active stores. + +Cleanup mutations are different: stale/canceled background work is allowed, and +sometimes required, to clear active `isFetching` markers. + +Separately, preload completion must not cache borrowed active/pending matches. + +Cancellation and lane-shortening paths must settle removed or canceled load +promises. + +Tests that anchor this include: + +- `cancelMatches settles pending match load promises immediately` +- `cancelMatches after pending timeout` +- `pending timeout stays scoped to the current load pass` +- `pendingMin wait does not throw after its match is canceled` +- `background reload resolving after route exit does not execute head` +- `background redirect is ignored while a newer navigation is pending` + +## Final Client Commit + +`commitFinalMatches(router, baseMatches, nextMatches)` is the only normal +navigation final commit. + +It does three things: + +1. Cache eligible old active matches that were replaced. +2. Publish the next active lane and clear pending state. +3. Run lifecycle diffs. + +```mermaid +flowchart TD + Start["commitFinalMatches(base, next)"] --> CacheOld["push replaced base matches into cached candidates"] + CacheOld --> GC["filter cache by loader, gcTime/preloadGcTime, and success status"] + GC --> Stores["batch: isLoading=false, setMatches(next), clear pending, set loadedAt, set cached"] + Stores --> Diff["diff base vs next by index"] + Diff --> Leave["onLeave for old routeId not present at same index"] + Diff --> StayEnter["onStay if same routeId else onEnter"] +``` + +The final commit is intentionally separate from pending publication. Pending UI +is presentation; final commit is router state ownership. + +## Server Match Loading + +The server path is simpler because there is no client interleaving. + +```mermaid +sequenceDiagram + participant Req as request handler + participant R as loadServerRouter + participant M as matchRoutes + participant L as loadServerMatches + participant A as projectServerRouteAssets + participant S as stores/status + + Req->>R: router.load() + R->>R: choose latest/pendingBuilt location + R->>R: rebuild canonical location + alt canonical publicHref differs + R-->>Req: redirect + else canonical + R->>M: matchRoutes(next) + R->>S: statusCode=200, location=next + R->>L: loadServerMatches(matches) + L->>L: serial SSR decision + beforeLoad + L->>L: parallel loaders/chunks for renderable prefix + L->>L: reduce redirect/notFound/error + opt non-redirect outcome + L->>A: project head/scripts/headers + end + L-->>R: loaded lane or throws redirect/notFound/route error + R->>S: loadedAt, setMatches + end + alt non-redirect error/notFound after matching + R->>S: commit mutated matched lane, then set statusCode + else redirect + R->>S: set redirect/statusCode + end +``` + +Server beforeLoad differs from client beforeLoad: + +- It also computes SSR eligibility (`true`, `false`, or `data-only`). +- `ssr: false` skips server beforeLoad, loader, and route-chunk work for that + match. +- `data-only` still runs server beforeLoad and loader work, but skips route + chunk loading. +- Shell mode only SSR-loads the root. +- Parent `ssr: false` forces descendants to `ssr: false`. +- Preload is always false in server loader context. + +Server route failure handling: + +- Redirects update response redirect metadata and status code. +- For non-redirect errors and notFounds, `loadServerMatches()` mutates and + trims the matched lane before throwing; `loadServerRouter()` commits that lane + from its catch path and then derives the final status code. +- notFound commits the selected boundary. Non-root boundaries use + `status: 'notFound'`; root/global notFound uses `globalNotFound: true`, and + server status later becomes 404. +- Committed regular route errors use `status: 'error'` and later set status + code 500. +- Lane trimming mirrors client behavior, but without pending/currentness + machinery. + +## Hydration Handoff + +Hydration is a one-shot client path in `src/ssr/ssr-client.ts`. It keeps SSR +hydration reconstruction out of normal client match loading. The normal client +loader only retains a minimal dehydrated fast path for the fallback case where +hydration still needs to call `router.load()`. + +Hydration flow: + +1. Read `window.$_TSR.router`. +2. Install serialization adapters and SSR manifest. +3. Run custom `router.options.hydrate()` before matching routes. +4. Match the current client location. +5. Copy dehydrated status, data, errors, `ssr`, `updatedAt`, beforeLoad + context, and `globalNotFound` when present into matching client matches. + Mark unmatched client matches as `ssr: false`. +6. Mark matches as dehydrated unless `ssr === false`. +7. Start route chunk loading while copying those match payloads. +8. Publish initial matches. +9. Wait for route chunks before reconstructing route context and assets. Lazy + chunks can install route context, head, scripts, and `ssr` options. +10. Restore `route.options.ssr = match.ssr` during the reconstruction pass, then + rerun route context and execute head/scripts for the hydration lane. +11. If there are no `ssr: false` matches and this is not SPA mode, clear + dehydrated markers, settle load promises, set `resolvedLocation`, and stop. +12. Otherwise call normal `router.load()` to fill SPA or `ssr: false` holes. + +```mermaid +flowchart TD + H["hydrate(router)"] --> Data["read TSR bootstrap data"] + Data --> Hook["run user hydrate hook"] + Hook --> Match["match current location"] + Match --> Copy["copy dehydrated match payloads"] + Copy --> ChunksStarted["start route chunks"] + ChunksStarted --> Display{"display pending needed?"} + Display -->|"yes"| Pending["mark _displayPending and settle display match"] + Display -->|"no"| SetInitial["setMatches(matches)"] + Pending --> SetInitial + SetInitial --> Chunks["await all route chunks"] + Chunks --> Rebuild["restore ssr, rebuild route context and head/scripts"] + Rebuild --> Full{"no ssr:false and not SPA mode?"} + Full -->|"yes"| Done["settle, clear dehydrated, set resolvedLocation"] + Full -->|"no"| Load["Promise.resolve().then(router.load) for SPA/ssr:false matches"] +``` + +Hydration display pending uses `_displayPending`, not the normal client pending +timer. It applies to the SPA fallback match, or to the first non-fully-rendered +match (`ssr: false` or `data-only`) when pending UI/minimum timing applies. It +is cleared only after route chunks/context/head/scripts work, any required +`pendingMinMs`, and any follow-up SPA/`ssr:false` client load has left that +display match's pending state. If the marker is still present on a pending match +while the router is loading, hydration waits for `router.latestLoadPromise` and +tries again. After the follow-up load, hydration also repairs a still-pending +router status by setting `status: 'idle'` and `resolvedLocation` to the current +location; this covers synchronous loads that finish before framework +transitioners can observe them. Hydration failure cleanup settles matches and +clears the marker early. + +## Route Outcome Ordering + +When several route tasks settle, reduction order matters. + +Foreground client loader reduction: + +- Redirect throws immediately and wins. +- notFound is remembered if no redirect wins. +- The first unhandled non-route-control rejection is thrown. +- If no fatal rejection wins, notFound commits its boundary or root/global state + and trims descendants. +- If an error match was committed, descendants after `badIndex` are removed. + +Background reduction: + +- Waits for all selected background loaders. +- Redirect wins. +- If no redirect wins, the shallowest regular error wins. +- If there is no regular error, the first selected notFound wins. + +Server reduction: + +- Waits for all renderable-prefix promises. +- Redirect wins. +- Redirects throw before server asset projection. +- If no fatal rejection wins, notFound can commit its selected boundary. +- Committed route errors trim to `badIndex`, project assets, and then throw the + committed `errorMatch.error`. +- Unhandled fatal rejections throw after asset projection, but may not have a + committed error match. + +## Why Lanes Are Private First + +Most match loading work mutates a private lane before publication. This gives +the router a place to build a coherent result while async work interleaves. + +Private lanes let us: + +- Run beforeLoad serially and loaders in parallel. +- Keep background data private until all selected loaders and assets are + coherent. +- Publish pending UI without running final lifecycle hooks. +- Trim descendants after error/notFound without temporarily exposing invalid + active state. +- Let preloads borrow active matches without owning or caching them. +- Abandon stale async work by throwing a private sentinel. + +The rule of thumb: + +> Async route work may mutate only the private lane it owns. It may publish only +> after proving it is still current. + +## Common Change Checklist + +When changing match loading, check these invariants: + +- Does every async foreground/background continuation prove currentness before + mutating or publishing? +- If a lane can be shortened, are removed matches' load promises settled? +- Does pending publication remain separate from final commit? +- Does route asset projection see the final coherent lane for that path? +- Does any client reload path that runs a loader write `loaderData`, including + `undefined`, so a reload can clear previous data? +- Does cache mutation happen through cache APIs instead of `updateMatch()`? +- Can preload projection or caching accidentally treat a borrowed active/pending + match as owned? +- Can background work publish after a foreground navigation starts? +- Does a redirect bypass source head execution? +- Does notFound choose the same boundary on client and server? +- Are server-only status code and headers kept out of client-only paths? +- Do component/chunk failures replace the original route failure without + recursively trying to load another boundary component? +- Are `pendingMs` and `pendingMinMs` both honored when pending UI renders? + +Useful test anchors: + +- `packages/router-core/tests/load.test.ts` + - preload borrowing and owner disappearance tests + - pending publication and pendingMinMs tests + - background stale reload and superseding-token tests + - loader-returning-undefined foreground/background client tests + - client/server notFound boundary trimming tests + - redirect ownership tests + - route asset projection tests +- `packages/router-core/tests/hydrate.test.ts` + - dehydrated match reconstruction tests + - route-chunk-before-head hydration tests + - display pending cleanup tests diff --git a/packages/router-core/src/Matches.ts b/packages/router-core/src/Matches.ts index 852d186b67..eac88656a2 100644 --- a/packages/router-core/src/Matches.ts +++ b/packages/router-core/src/Matches.ts @@ -11,6 +11,11 @@ import type { import type { AnyRouter, RegisteredRouter, SSROption } from './router' import type { Constrain, ControlledPromise } from './utils' +type MatchLoadPromise = ControlledPromise & { + pendingTimeout?: ReturnType + pendingUntil?: number +} + export type AnyMatchAndValue = { match: any; value: any } export type FindValueByIndex< @@ -131,35 +136,28 @@ 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 searchError: unknown updatedAt: number - _nonReactive: { - /** @internal */ - beforeLoadPromise?: ControlledPromise - /** @internal */ - loaderPromise?: ControlledPromise - /** @internal */ - pendingTimeout?: ReturnType - loadPromise?: ControlledPromise - displayPendingPromise?: Promise - minPendingPromise?: ControlledPromise + /** Internal non-reactive match lifecycle bucket. */ + _: { + loadPromise?: MatchLoadPromise + /** Internal: true while this match is using SSR-hydrated data. */ dehydrated?: boolean - /** @internal */ + /** Internal loader error used by match loading. */ error?: unknown } loaderData?: TLoaderData - /** @internal */ + /** Internal route context scratch value. */ __routeContext?: Record - /** @internal */ + /** Internal beforeLoad context scratch value. */ __beforeLoadContext?: Record context: TAllContext search: TFullSearchSchema _strictSearch: TFullSearchSchema - fetchCount: number abortController: AbortController cause: 'preload' | 'enter' | 'stay' loaderDeps: TLoaderDeps @@ -170,7 +168,6 @@ export interface RouteMatch< staticData: StaticDataRouteOption /** This attribute is not reactive */ ssr?: SSROption - _forcePending?: boolean _displayPending?: boolean } diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts index fd673ca410..471a17f97d 100644 --- a/packages/router-core/src/index.ts +++ b/packages/router-core/src/index.ts @@ -236,15 +236,14 @@ export type { } from './stores' export { defaultSerializeError, - getLocationChangeInfo, RouterCore, lazyFn, SearchParamError, PathParamError, - getInitialRouterState, getMatchedRoutes, trailingSlashOptions, } from './router' +export { getLocationChangeInfo } from './location-change' export type { ViewTransitionOptions, diff --git a/packages/router-core/src/load-matches.client.ts b/packages/router-core/src/load-matches.client.ts new file mode 100644 index 0000000000..02645044cd --- /dev/null +++ b/packages/router-core/src/load-matches.client.ts @@ -0,0 +1,1066 @@ +import { createControlledPromise, isPromise } from './utils' +import { isNotFound } from './not-found' +import { isRedirect } from './redirect' +import { loadRouteChunk } from './route-chunks' +import { projectClientRouteAssets } from './route-assets.client' +import { + getMatchContext, + getNotFoundBoundaryIndex, + getNotFoundBoundaryPatch, + markError, + normalizeRouteFailure, + settleMatchLoad, +} from './load-matches' +import type { NotFoundError } from './not-found' +import type { + AnyRoute, + BeforeLoadContextOptions, + LoaderFnContext, +} from './route' +import type { AnyRouteMatch, MakeRouteMatch } from './Matches' +import type { AnyRouter } from './router' +import type { InnerLoadContext, SerialFailure } from './load-matches' + +const isRouteControl = (error: unknown) => + isRedirect(error) || isNotFound(error) + +const getLoader = (loaderOption: AnyRoute['options']['loader']) => + typeof loaderOption === 'function' ? loaderOption : loaderOption?.handler + +// Route work may only commit while it still owns the lane entry it is about to +// mutate. Each client load pass stamps its matches with an AbortController: +// starting a newer load replaces the controller, cancellation aborts its signal, +// and a different pending public location means this pass no longer represents +// the browser target. Compare publicHref, not href, because href may be the +// router-internal path while publicHref includes basepath/rewrite output. Any +// of those cases makes continuing unsafe because stale beforeLoad/loader/chunk +// work could write into the current lane. +// +// Throwing `inner` is intentional. `loadClientMatches` catches that exact object +// as the private "lost ownership" sentinel, so abandoned work stops without +// becoming a user-visible route error or triggering route error handling. +const requireCurrentMatch = ( + inner: InnerLoadContext, + index: number, + abortController: AbortController, +): AnyRouteMatch => { + const match = inner.matches[index] + const pendingLocation = inner.router.pendingBuiltLocation + if ( + !match || + match.abortController !== abortController || + abortController.signal.aborted || + (pendingLocation && + pendingLocation.publicHref !== inner.location.publicHref) + ) { + throw inner + } + + return match +} + +const commitMatch = ( + inner: InnerLoadContext, + index: number, + patch: Partial, +): AnyRouteMatch => { + return (inner.matches[index] = { + ...inner.matches[index]!, + ...patch, + }) +} + +function getNavigate(inner: InnerLoadContext) { + return (opts: any) => + inner.router.navigate({ + ...opts, + _fromLocation: inner.location, + }) +} + +const joinPreloadedActiveMatch = async ( + inner: InnerLoadContext, + index: number, +): Promise => { + const matchId = inner.matches[index]!.id + + // A preload can reuse an active/pending match by ID, but it does not own that + // match. If the foreground owner is already gone or aborted, this speculative + // preload pass has nothing safe to borrow. + let match = inner.router.getMatch(matchId, false) + if (!match) { + throw inner + } + if (match.abortController.signal.aborted) { + throw inner + } + + // Wait for the borrowed match's own beforeLoad/loader/component work to reach + // a terminal render state before copying it into the private preload lane. + if (match._.loadPromise?.status === 'pending') { + await match._.loadPromise + } + + // If the borrowed match still lives in the pending store, its local readiness + // may have settled before the foreground load committed the lane. Wait for the + // foreground load so the preload observes the committed owner, not a transient + // pending-store snapshot. + if (inner.router.stores.pendingMatchStores.has(matchId)) { + await inner.router.latestLoadPromise + } + + // Re-read after the waits because the owner may have committed, redirected, + // disappeared, or been aborted while this preload was observing it. + match = inner.router.getMatch(matchId, false) + if (!match || match.abortController.signal.aborted) { + throw inner + } + + // From here the preload lane uses the owner match read-only. It must not clone + // or cache a borrowed active match as if the preload generated it itself. + inner.matches[index] = match + const error = match._.error ?? match.error + + if (isRouteControl(error)) { + // Borrowed active matches keep ownership of their route outcomes. Preloads + // only observe the active lane; they must not chase a redirect/notFound that + // the foreground navigation is already handling. + throw inner + } + + if (match.status !== 'success') { + throw inner + } + + return match +} + +function waitPendingMin(match: AnyRouteMatch): Promise | undefined { + const remaining = (match._.loadPromise?.pendingUntil ?? 0) - Date.now() + if (remaining > 0) { + return new Promise((resolve) => setTimeout(resolve, remaining)) + } + return undefined +} + +const finalizeRouteFailure = async ( + inner: InnerLoadContext, + index: number, + error: unknown, + abortController: AbortController, + componentFailure?: boolean, +): Promise => { + let errorIndex = index + + let currentMatch = requireCurrentMatch(inner, index, abortController) + + if (!componentFailure) { + try { + if (isNotFound(error)) { + errorIndex = getNotFoundBoundaryIndex(inner, error) + const boundaryMatch = inner.matches[errorIndex]! + + if (inner.preload?.includes(boundaryMatch.id)) { + // The selected boundary is owned by an active/pending route match. + // A speculative preload must not load its boundary component or mutate it. + throw inner + } + await loadRouteChunk( + inner.router.routesById[boundaryMatch.routeId], + 'notFoundComponent', + ) + } else if (!isRedirect(error)) { + await loadRouteChunk( + inner.router.routesById[currentMatch.routeId], + 'errorComponent', + ) + } + } catch (chunkError) { + if (chunkError === inner) { + // Preserve the stale-pass sentinel from the borrowed-boundary branch; + // it is not a component preload failure. + throw inner + } + // This error already came from component/chunk loading, so commit it + // directly instead of trying to load another boundary component. + error = chunkError + componentFailure = true + } + + currentMatch = requireCurrentMatch(inner, index, abortController) + } + + const pendingWait = waitPendingMin(currentMatch) + if (pendingWait) { + await pendingWait + currentMatch = requireCurrentMatch(inner, index, abortController) + } + + if (!componentFailure) { + if (isRedirect(error)) { + currentMatch._.error = error + settleMatchLoad(currentMatch) + + error.options._fromLocation = inner.location + // Redirects are terminal control flow for this pass, not renderable match + // state. Throw the resolved redirect so navigation can take over. + throw inner.router.resolveRedirect(error) + } + + if (isNotFound(error)) { + inner.requiresCommit = true + + commitMatch(inner, index, { + status: 'notFound', + error, + isFetching: false, + }) + + // notFound is also terminal for this phase. The later allSettled reduction + // chooses the render boundary and trims the final lane. + throw error + } + } + + const matchToCommit = inner.matches[errorIndex] + if (!matchToCommit) { + // The lane was shortened by a newer outcome before this finalizer could + // commit. Cancel this stale finalizer. + throw inner + } + + inner.requiresCommit = true + markError(inner, errorIndex) + + commitMatch(inner, errorIndex, { + error, + status: 'error' as const, + isFetching: false as const, + context: getMatchContext( + inner, + errorIndex, + matchToCommit.__beforeLoadContext, + ), + updatedAt: Date.now(), + }) + finishMatchLoad(inner, errorIndex) +} + +const recordBeforeLoadFailure = ( + inner: InnerLoadContext, + index: number, + error: unknown, +): SerialFailure => { + const abortController = inner.matches[index]!.abortController + requireCurrentMatch(inner, index, abortController) + error = normalizeRouteFailure(inner, index, error) + requireCurrentMatch(inner, index, abortController).__beforeLoadContext = + undefined + + return [index, error] +} + +const setupPendingTimeout = ( + inner: InnerLoadContext, + routeOptions: AnyRoute['options'], + index: number, + match: AnyRouteMatch, +): void => { + const onReady = inner.onReady + if ( + match.status === 'pending' && + onReady && + !inner.pendingPublished && + (routeOptions.pendingComponent ?? + (inner.router.options as any).defaultPendingComponent) + ) { + const pendingMs = + routeOptions.pendingMs ?? inner.router.options.defaultPendingMs + const promise = match._.loadPromise + if ( + (pendingMs as number) < Infinity && + promise && + !promise.pendingTimeout + ) { + promise.pendingTimeout = setTimeout(() => { + const current = inner.matches[index] + if ( + !inner.pendingPublished && + current?._.loadPromise === promise && + current.status === 'pending' + ) { + // Publish the current render-ready lane so pending UI can render while + // beforeLoad/loader work continues. + inner.pendingPublished = true + inner.rendered ||= onReady(inner.matches.slice()) ?? true + } + }, pendingMs) + } + } +} + +const handleClientBeforeLoad = ( + inner: InnerLoadContext, + index: number, +): void | SerialFailure | Promise => { + const existingMatch = inner.matches[index]! + + const { preload, routeId } = existingMatch + const routeOptions = inner.router.routesById[routeId]!.options + const abortController = existingMatch.abortController + const pending = () => { + commitMatch(inner, index, { + isFetching: 'beforeLoad', + // 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 serialError = + existingMatch.paramsError !== undefined + ? existingMatch.paramsError + : existingMatch.searchError + const beforeLoad = routeOptions.beforeLoad + if (beforeLoad || routeOptions.loader || serialError !== undefined) { + existingMatch._.loadPromise ||= createControlledPromise() + } + + if (serialError !== undefined) { + pending() + return recordBeforeLoadFailure(inner, index, serialError) + } + + setupPendingTimeout(inner, routeOptions, index, existingMatch) + + const commitBeforeLoad = (beforeLoadContext: any) => { + commitMatch(inner, index, { + isFetching: false as const, + __beforeLoadContext: beforeLoadContext, + context: getMatchContext(inner, index, beforeLoadContext), + }) + } + + // Routes without beforeLoad still need to pick up parent beforeLoad context + // before descendants run their loaders. + if (!beforeLoad) { + commitBeforeLoad(undefined) + return + } + + // commits the result of the beforeLoad phase and settles its promise + const updateContext = (beforeLoadContext: any): void | SerialFailure => { + requireCurrentMatch(inner, index, abortController) + + if (isRouteControl(beforeLoadContext)) { + return recordBeforeLoadFailure(inner, index, beforeLoadContext) + } + + commitBeforeLoad(beforeLoadContext) + } + + // Exclude the current beforeLoad context while invoking beforeLoad for this generation. + const context = getMatchContext(inner, index, undefined) + const { search, params, cause } = existingMatch + const beforeLoadFnContext: BeforeLoadContextOptions< + any, + any, + any, + any, + any, + any, + any, + any, + any + > = { + search, + abortController, + params, + preload, + context, + location: inner.location, + navigate: getNavigate(inner), + buildLocation: inner.router.buildLocation, + cause, + matches: inner.matches, + routeId, + ...inner.router.options.additionalContext, + } + + let beforeLoadContext + try { + requireCurrentMatch(inner, index, abortController) + beforeLoadContext = beforeLoad(beforeLoadFnContext) + if (isPromise(beforeLoadContext)) { + requireCurrentMatch(inner, index, abortController) + pending() + return beforeLoadContext.then(updateContext, (err) => { + requireCurrentMatch(inner, index, abortController) + return recordBeforeLoadFailure(inner, index, err) + }) + } + } catch (err) { + if (err === inner) { + throw err + } + + pending() + return recordBeforeLoadFailure(inner, index, err) + } + + return updateContext(beforeLoadContext) +} + +const getLoaderContext = ( + inner: InnerLoadContext, + matchPromises: Array>, + index: number, + route: AnyRoute, +): LoaderFnContext => { + const { params, loaderDeps, abortController, cause, context, preload } = + inner.matches[index]! + + return { + params, + deps: loaderDeps, + preload, + parentMatchPromise: matchPromises[index - 1] as any, + abortController, + context, + location: inner.location, + navigate: getNavigate(inner), + cause, + route, + ...inner.router.options.additionalContext, + } +} + +const finishMatchLoad = ( + inner: InnerLoadContext, + index: number, +): AnyRouteMatch => { + const match = inner.matches[index]! + + match._.dehydrated = undefined + settleMatchLoad(match) + + return match.isFetching || match.invalid + ? commitMatch(inner, index, { + isFetching: false, + invalid: false, + }) + : match +} + +const loadClientRouteMatch = async ( + inner: InnerLoadContext, + matchPromises: Array>, + index: number, +): Promise => { + const { router, matches } = inner + const initialMatch = matches[index]! + const { routeId } = initialMatch + const route = router.routesById[routeId]! + const routeOptions = route.options + const loaderOption = routeOptions.loader + const loader = getLoader(loaderOption) + + let match = initialMatch + const { preload } = match + + if (match._.dehydrated) { + commitMatch(inner, index, { + invalid: false, + context: getMatchContext(inner, index, match.__beforeLoadContext), + }) + + // Hydrated client matches are already loaded. Finish by clearing stale + // fetching/invalid flags and resolving their load promises. + return finishMatchLoad(inner, index) + } + + if (preload && routeOptions.preload === false) { + // Route-level preload opt-out still needs promise cleanup for the lane entry. + return finishMatchLoad(inner, index) + } + + const passController = match.abortController + + if (match.status === 'success') { + const activeMatch = router.stores.matches.get()[index] + const staleAge = preload + ? (routeOptions.preloadStaleTime ?? + router.options.defaultPreloadStaleTime ?? + 30_000) // 30 seconds for preloads by default + : (routeOptions.staleTime ?? router.options.defaultStaleTime ?? 0) + const shouldReloadOption = routeOptions.shouldReload + let shouldReload = shouldReloadOption + if (typeof shouldReloadOption === 'function') { + try { + requireCurrentMatch(inner, index, passController) + shouldReload = shouldReloadOption( + getLoaderContext(inner, matchPromises, index, route), + ) + } catch (err) { + requireCurrentMatch(inner, index, passController) + // shouldReload runs inside the route outcome lifecycle. If it throws, + // commit that failure like a loader error and return the finalized lane. + await finalizeRouteFailure( + inner, + index, + normalizeRouteFailure(inner, index, err), + passController, + ) + return matches[index]! + } + } + match = requireCurrentMatch(inner, index, passController) + + if ( + !match.invalid && + !( + shouldReload ?? + (Date.now() - match.updatedAt >= staleAge && + (inner.forceStaleReload || + match.cause !== 'stay' || + (activeMatch && + activeMatch.routeId === routeId && + activeMatch.id !== match.id))) + ) + ) { + // Fresh enough, not invalid, and shouldReload did not request work: settle + // the match without creating a loader generation. + return finishMatchLoad(inner, index) + } + + if ( + loader && + inner.background && + (loaderOption?.staleReloadMode ?? + router.options.defaultStaleReloadMode) !== 'blocking' + ) { + inner.background.push(index) + return finishMatchLoad(inner, index) + } + } + + if (loader || match.status === 'pending' || match.invalid) { + inner.requiresCommit = true + } + + await (async (): Promise => { + const routeChunkPromise = loadRouteChunk(route) + void routeChunkPromise?.catch(() => {}) + + // Actually run the loader and handle the result + try { + // Kick off the loader! + const loaderResult = loader?.( + getLoaderContext(inner, matchPromises, index, route), + ) + const loaderResultIsPromise = isPromise(loaderResult) + if (loaderResultIsPromise || routeChunkPromise) { + commitMatch(inner, index, { + isFetching: 'loader', + }) + } + + if (loader) { + const loaderData = loaderResultIsPromise + ? await loaderResult + : loaderResult + + requireCurrentMatch(inner, index, passController) + + if (isRouteControl(loaderData)) { + throw loaderData + } + + commitMatch(inner, index, { + loaderData, + }) + } + } catch (error) { + if (error === inner) { + // `inner` is cancellation. Re-throw it instead of wrapping it as a + // route error. + throw error + } + + if ( + (error as any)?.name === 'AbortError' && + requireCurrentMatch(inner, index, passController).status !== 'pending' + ) { + // The route already has a renderable result, so an AbortError here is + // a stale-while-revalidate abort. Clear fetching/invalid state and + // settle readiness without turning the abort into a route error. + finishMatchLoad(inner, index) + return + } else if (preload && isRedirect(error)) { + settleMatchLoad(match) + throw error + } + + // Pending AbortErrors have no previous loaderData to keep, so they share + // the normal route-error finalization path with other loader failures. + requireCurrentMatch(inner, index, passController) + return finalizeRouteFailure( + inner, + index, + normalizeRouteFailure(inner, index, error), + passController, + ) + } + + try { + if (routeChunkPromise) { + await routeChunkPromise + } + } catch (chunkError) { + // A route/component chunk failure replaces the loader result and is committed + // as the route error for this loader generation. + return finalizeRouteFailure( + inner, + index, + chunkError, + passController, + true, + ) + } + + const pendingWait = waitPendingMin( + requireCurrentMatch(inner, index, passController), + ) + if (pendingWait) { + await pendingWait + requireCurrentMatch(inner, index, passController) + } + + commitMatch(inner, index, { + error: undefined, + status: 'success', + isFetching: false, + updatedAt: Date.now(), + }) + finishMatchLoad(inner, index) + })().catch((error) => { + if (error === inner) { + settleMatchLoad(match) + } + throw error + }) + + requireCurrentMatch(inner, index, passController) + + return matches[index]! +} + +export const clearBackgroundFetching = (router: AnyRouter): void => { + router.batch(() => { + for (const matchId of router.stores.matchesId.get()) { + router.updateMatch(matchId, (match) => + match.isFetching + ? { + ...match, + isFetching: false, + } + : match, + ) + } + }) +} + +export function startBackgroundLoad( + router: AnyRouter, + location: InnerLoadContext['location'], + base: Array, + indices: Array, +): void { + router._backgroundLoad?.controller.abort() + const token = (router._backgroundLoad = { + href: location.href, + controller: new AbortController(), + }) + + const matches = base.slice() + let committed = false + const cancelBatch = () => { + token.controller.abort() + } + const isCurrent = () => + router._backgroundLoad === token && + !token.controller.signal.aborted && + router.stores.location.get().href === token.href && + !router.pendingBuiltLocation + const isCurrentOrCancel = () => { + if (isCurrent()) { + return true + } + cancelBatch() + return false + } + const requireCurrent = () => { + if (!isCurrent()) { + throw token + } + } + + router.batch(() => { + for (let i = 0; i < base.length; i++) { + const match = base[i]! + const isFetching = indices.includes(i) ? 'loader' : false + router.updateMatch(match.id, (previous) => + previous.isFetching === isFetching + ? previous + : { + ...previous, + isFetching, + }, + ) + } + }) + + const inner: InnerLoadContext = { + router, + location, + matches, + } + + void (async (): Promise => { + const matchPromises = matches.map((match) => Promise.resolve(match)) + // Sparse entries preserve `throw undefined`: a missing index means no + // failure, while an own index with value `undefined` is still a failure. + const failures: Array = [] + + for (const index of indices) { + let match = (matches[index] = { + ...base[index]!, + abortController: token.controller, + }) + + matchPromises[index] = (async (): Promise => { + const route = router.routesById[match.routeId]! + const loader = getLoader(route.options.loader)! + + try { + requireCurrent() + let loaderData = loader( + getLoaderContext(inner, matchPromises, index, route), + ) + requireCurrent() + if (isPromise(loaderData)) { + loaderData = await loaderData + requireCurrent() + } + + if (isRouteControl(loaderData)) { + throw loaderData + } + + match = { + ...match, + loaderData, + } + } catch (error) { + if (error === token) { + throw error + } + + requireCurrent() + if ((error as any)?.name === 'AbortError') { + return match + } + + // eslint-disable-next-line no-ex-assign + error = normalizeRouteFailure(inner, index, error) + requireCurrent() + throw error + } + + requireCurrent() + return (matches[index] = { + ...match, + isFetching: false, + updatedAt: Date.now(), + }) + })().catch((error) => { + if (error === token) { + cancelBatch() + } else { + failures[index] = error + } + return matches[index]! + }) + } + + await Promise.all(matchPromises) + + if (!isCurrentOrCancel()) { + return + } + + let redirectError: unknown + let notFoundError: NotFoundError | undefined + let failure: [number, unknown] | undefined + + for (let index = 0; index < failures.length; index++) { + if (!(index in failures)) { + continue + } + const error = failures[index] + + if (isRedirect(error)) { + redirectError ||= error + } else if (isNotFound(error)) { + notFoundError ||= error + } else { + failure ||= [index, error] + } + } + + if (redirectError) { + if (isCurrent()) { + void router.navigate({ + ...(redirectError as any).options, + replace: true, + ignoreBlocker: true, + }) + } + return + } + + if (failure) { + // eslint-disable-next-line prefer-const + let [index, error] = failure + try { + requireCurrent() + await loadRouteChunk( + router.routesById[matches[index]!.routeId], + 'errorComponent', + ) + requireCurrent() + } catch (componentError) { + if (componentError === token) { + cancelBatch() + return + } + if (!isCurrentOrCancel()) { + return + } + error = componentError + } + + matches[index] = { + ...matches[index]!, + error, + status: 'error', + isFetching: false, + updatedAt: Date.now(), + } + matches.length = index + 1 + } else if (notFoundError) { + const index = getNotFoundBoundaryIndex(inner, notFoundError) + const match = matches[index]! + try { + requireCurrent() + await loadRouteChunk( + router.routesById[match.routeId], + 'notFoundComponent', + ) + requireCurrent() + matches[index] = { + ...match, + ...getNotFoundBoundaryPatch(inner, index, notFoundError), + invalid: false, + } + } catch (componentError) { + if (componentError === token) { + cancelBatch() + return + } + if (!isCurrentOrCancel()) { + return + } + matches[index] = { + ...matches[index]!, + error: componentError, + status: 'error', + isFetching: false, + updatedAt: Date.now(), + } + } + matches.length = index + 1 + } + + const assets = projectClientRouteAssets( + router, + matches, + undefined, + isCurrentOrCancel, + ) + if (isPromise(assets)) { + await assets + } + + if (isCurrentOrCancel()) { + router.stores.setMatches(matches) + committed = true + } + })().finally(() => { + if (router._backgroundLoad === token) { + router._backgroundLoad = undefined + if (!committed) { + clearBackgroundFetching(router) + } + } + }) +} + +export async function loadClientMatches( + inner: InnerLoadContext, +): Promise> { + const matchPromises: Array> = [] + + let firstNotFound: NotFoundError | undefined + let failure: SerialFailure | undefined + + for ( + let i = 0; + i < inner.matches.length && !failure && inner.badIndex === undefined; + i++ + ) { + try { + if (inner.preload?.includes(inner.matches[i]!.id)) { + await (matchPromises[i] = joinPreloadedActiveMatch(inner, i)) + continue + } + + if (inner.matches[i]!._.dehydrated) { + matchPromises[i] = loadClientRouteMatch(inner, matchPromises, i) + continue + } + + const beforeLoadResult = handleClientBeforeLoad(inner, i) + if (isPromise(beforeLoadResult)) { + failure = (await beforeLoadResult) as SerialFailure | undefined + } else { + failure = beforeLoadResult as SerialFailure | undefined + } + } catch (err) { + if (err === inner) { + // This pass lost ownership before serial loading reached a route outcome. + throw err + } + if (isNotFound(err)) { + firstNotFound ||= err + } else if (isRedirect(err) || !inner.preload) { + throw err + } + break + } + } + + let maxIndexExclusive = inner.badIndex ?? inner.matches.length + if (failure) { + const [index, error] = failure + maxIndexExclusive = Math.min( + maxIndexExclusive, + isRedirect(error) + ? 0 + : isNotFound(error) + ? Math.min(getNotFoundBoundaryIndex(inner, error) + 1, index) + : index, + ) + } + + const background = inner.background + if (failure) { + // A serial failure is part of the foreground render lane. Retained + // ancestors must honor shouldReload/staleTime in that lane instead of + // being deferred into background work that may be discarded by the boundary. + inner.background = undefined + } + for (let i = 0; i < maxIndexExclusive; i++) { + matchPromises[i] ||= loadClientRouteMatch(inner, matchPromises, i) + } + inner.background = background + + if (failure) { + const [index, error] = failure + matchPromises.push( + finalizeRouteFailure( + inner, + index, + error, + inner.matches[index]!.abortController, + ) as any, + ) + } + + let fatalError: unknown + let hasFatalError = false + for (const result of await Promise.allSettled(matchPromises)) { + if (result.status === 'fulfilled') { + continue + } + + const reason = + process.env.NODE_ENV !== 'production' && result.reason === undefined + ? new Error('Route load failed with undefined') + : result.reason + if (reason === inner) { + // `inner` means "this pass lost ownership". Stop reducing outcomes and + // leave the current lane untouched for the newer owner. + throw reason + } + if (isRedirect(reason)) { + // Redirects outrank notFound and regular errors; let navigation handle it. + throw reason + } else if (isNotFound(reason)) { + firstNotFound ||= reason + } else if (!hasFatalError) { + fatalError = reason + hasFatalError = true + } + } + + if (hasFatalError) { + // Non-route-control failures are fatal to this load call. + throw fatalError + } + + const errorIndex = inner.badIndex + + if (firstNotFound) { + // Determine once which matched route will actually render the + // notFoundComponent, then pass this precomputed index through the remaining + // finalization steps. + // This can differ from the throwing route when routeId targets an ancestor + // boundary (or when bubbling resolves to a parent/root boundary). + const index = getNotFoundBoundaryIndex(inner, firstNotFound) + if (inner.preload?.includes(inner.matches[index]!.id)) { + return inner.matches + } + + const patch = getNotFoundBoundaryPatch(inner, index, firstNotFound) + commitMatch(inner, index, patch) + finishMatchLoad(inner, index) + while (inner.matches.length > index + 1) { + settleMatchLoad(inner.matches.pop()!) + } + } else if (errorIndex !== undefined) { + while (inner.matches.length > errorIndex + 1) { + settleMatchLoad(inner.matches.pop()!) + } + } + + if (inner.rendered && inner.rendered !== true) { + await inner.rendered + } + + if (firstNotFound) { + throw firstNotFound + } + + return inner.matches +} diff --git a/packages/router-core/src/load-matches.server.ts b/packages/router-core/src/load-matches.server.ts new file mode 100644 index 0000000000..b207157169 --- /dev/null +++ b/packages/router-core/src/load-matches.server.ts @@ -0,0 +1,553 @@ +import { isPromise } from './utils' +import { isNotFound } from './not-found' +import { rootRouteId } from './root' +import { isRedirect } from './redirect' +import { loadRouteChunk } from './route-chunks' +import { projectServerRouteAssets } from './route-assets.server' +import { + getMatchContext, + getNotFoundBoundaryIndex, + markError, + normalizeRouteFailure, +} from './load-matches' +import type { NotFoundError } from './not-found' +import type { + AnyRoute, + BeforeLoadContextOptions, + LoaderFnContext, + SsrContextOptions, +} from './route' +import type { AnyRouteMatch, MakeRouteMatch } from './Matches' +import type { SSROption } from './router' +import type { InnerLoadContext, LoadMatchesArg } from './load-matches' + +const commitMatch = ( + inner: InnerLoadContext, + index: number, + patch: Partial, +): AnyRouteMatch => { + return (inner.matches[index] = { + ...inner.matches[index]!, + ...patch, + }) +} + +const handleServerRedirectOrNotFound = ( + inner: InnerLoadContext, + index: number, + match: AnyRouteMatch, + err: unknown, +): void => { + if (isRedirect(err)) { + if (err.redirectHandled && !err.options.reloadDocument) { + throw err + } + + match._.error = err + + err.options._fromLocation = inner.location + err.redirectHandled = true + throw inner.router.resolveRedirect(err) + } + + if (isNotFound(err)) { + commitMatch(inner, index, { + status: 'notFound', + error: err, + }) + throw err + } +} + +const recordServerBeforeLoadFailure = ( + inner: InnerLoadContext, + index: number, + error: unknown, +): void => { + const match = inner.matches[index]! + error = normalizeRouteFailure(inner, index, error) + match.__beforeLoadContext = undefined + inner.serialFailure = [index, error] +} + +const finalizeServerRouteFailure = async ( + inner: InnerLoadContext, + index: number, + error: unknown, + componentError?: boolean, +): Promise => { + const routesById = inner.router.routesById + let errorIndex = index + + if (!componentError) { + try { + if (isNotFound(error)) { + errorIndex = getNotFoundBoundaryIndex(inner, error) + await loadRouteChunk( + routesById[inner.matches[errorIndex]!.routeId], + 'notFoundComponent', + ) + } else if (!isRedirect(error)) { + await loadRouteChunk( + routesById[inner.matches[index]!.routeId], + 'errorComponent', + ) + } + } catch (chunkError) { + error = chunkError + componentError = true + } + } + + if (!componentError) { + handleServerRedirectOrNotFound(inner, index, inner.matches[index]!, error) + } + + const matchToCommit = inner.matches[errorIndex] + if (!matchToCommit) { + return + } + + markError(inner, errorIndex) + commitMatch(inner, errorIndex, { + error, + status: 'error' as const, + context: getMatchContext( + inner, + errorIndex, + matchToCommit.__beforeLoadContext, + ), + updatedAt: Date.now(), + }) +} + +const runServerBeforeLoad = ( + inner: InnerLoadContext, + index: number, + route: AnyRoute, +): void | Promise => { + const match = inner.matches[index]! + const { abortController } = match + + const serialError = + match.paramsError !== undefined ? match.paramsError : match.searchError + if (serialError !== undefined) { + return recordServerBeforeLoadFailure(inner, index, serialError) + } + + const beforeLoad = route.options.beforeLoad + const commitBeforeLoad = (beforeLoadContext: any): void => { + commitMatch(inner, index, { + __beforeLoadContext: beforeLoadContext, + context: getMatchContext(inner, index, beforeLoadContext), + }) + } + + if (!beforeLoad) { + commitBeforeLoad(undefined) + return + } + + const updateContext = (beforeLoadContext: any): void => { + if (isRedirect(beforeLoadContext) || isNotFound(beforeLoadContext)) { + return recordServerBeforeLoadFailure(inner, index, beforeLoadContext) + } + + commitBeforeLoad(beforeLoadContext) + } + + const context = getMatchContext(inner, index, undefined) + const { search, params, cause } = match + const beforeLoadFnContext = { + search, + abortController, + params, + context, + location: inner.location, + navigate: (opts: any) => + inner.router.navigate({ + ...opts, + _fromLocation: inner.location, + }), + buildLocation: inner.router.buildLocation, + cause, + matches: inner.matches, + routeId: route.id, + ...inner.router.options.additionalContext, + } as BeforeLoadContextOptions + + try { + const beforeLoadContext = beforeLoad(beforeLoadFnContext) + if (isPromise(beforeLoadContext)) { + return beforeLoadContext.then(updateContext, (err) => + recordServerBeforeLoadFailure(inner, index, err), + ) + } + return updateContext(beforeLoadContext) + } catch (err) { + return recordServerBeforeLoadFailure(inner, index, err) + } +} + +const handleServerBeforeLoad = ( + inner: InnerLoadContext, + index: number, +): false | void | Promise => { + const { routeId } = inner.matches[index]! + const route = inner.router.routesById[routeId]! + const existingMatch = inner.matches[index]! + const parentMatch = inner.matches[index - 1] + + const queueServerBeforeLoad = (): + | false + | void + | Promise => { + if (existingMatch.ssr === false) { + return false + } + + return runServerBeforeLoad(inner, index, route) + } + + if (inner.router.isShell()) { + existingMatch.ssr = route.id === rootRouteId + return queueServerBeforeLoad() + } + + if (parentMatch?.ssr === false) { + existingMatch.ssr = false + return false + } + + const parentOverride = (tempSsr: SSROption) => { + if (tempSsr === true && parentMatch?.ssr === 'data-only') { + return 'data-only' + } + return tempSsr + } + + const defaultSsr = inner.router.options.defaultSsr ?? true + + if (route.options.ssr === undefined) { + existingMatch.ssr = parentOverride(defaultSsr) + return queueServerBeforeLoad() + } + + if (typeof route.options.ssr !== 'function') { + existingMatch.ssr = parentOverride(route.options.ssr) + return queueServerBeforeLoad() + } + + const { search, params } = existingMatch + const ssrFnContext: SsrContextOptions = { + search: makeMaybe(search, existingMatch.searchError), + params: makeMaybe(params, existingMatch.paramsError), + location: inner.location, + matches: inner.matches.map((match) => ({ + index: match.index, + pathname: match.pathname, + fullPath: match.fullPath, + staticData: match.staticData, + id: match.id, + routeId: match.routeId, + search: makeMaybe(match.search, match.searchError), + params: makeMaybe(match.params, match.paramsError), + ssr: match.ssr, + })), + } + + const tempSsr = route.options.ssr(ssrFnContext) + if (isPromise(tempSsr)) { + return tempSsr.then((ssr) => { + existingMatch.ssr = parentOverride(ssr ?? defaultSsr) + return queueServerBeforeLoad() as any + }) as Promise + } + + existingMatch.ssr = parentOverride(tempSsr ?? defaultSsr) + return queueServerBeforeLoad() +} + +const getServerLoaderContext = ( + inner: InnerLoadContext, + matchPromises: Array>, + index: number, + route: AnyRoute, +): LoaderFnContext => { + const { params, loaderDeps, abortController, cause, context } = + inner.matches[index]! + + return { + params, + deps: loaderDeps, + preload: false, + parentMatchPromise: matchPromises[index - 1] as any, + abortController, + context, + location: inner.location, + navigate: (opts: any) => + inner.router.navigate({ + ...opts, + _fromLocation: inner.location, + }), + cause, + route, + ...inner.router.options.additionalContext, + } +} + +const finishServerSkippedMatch = ( + inner: InnerLoadContext, + index: number, +): AnyRouteMatch => { + commitMatch(inner, index, { + context: getMatchContext( + inner, + index, + inner.matches[index]!.__beforeLoadContext, + ), + }) + return inner.matches[index]! +} + +const commitServerNotFoundBoundary = ( + inner: InnerLoadContext, + err: NotFoundError, +): number => { + const index = getNotFoundBoundaryIndex(inner, err) + const match = inner.matches[index]! + const routeId = match.routeId + + err.routeId = routeId + commitMatch( + inner, + index, + routeId === rootRouteId + ? { + status: 'success', + error: undefined, + globalNotFound: true, + context: getMatchContext(inner, index, match.__beforeLoadContext), + } + : { + status: 'notFound', + error: err, + context: getMatchContext(inner, index, match.__beforeLoadContext), + }, + ) + return index +} + +const loadServerRouteMatch = async ( + inner: InnerLoadContext, + matchPromises: Array>, + index: number, +): Promise => { + const initialMatch = inner.matches[index]! + const { routeId } = initialMatch + const route = inner.router.routesById[routeId]! + const loaderOption = route.options.loader + const loader = + typeof loaderOption === 'function' ? loaderOption : loaderOption?.handler + + const routeChunkPromise = + initialMatch.ssr === true ? loadRouteChunk(route) : undefined + void routeChunkPromise?.catch(() => {}) + + try { + const loaderResult = loader?.( + getServerLoaderContext(inner, matchPromises, index, route), + ) + const loaderResultIsPromise = isPromise(loaderResult) + + if (loader) { + const loaderData = loaderResultIsPromise + ? await loaderResult + : loaderResult + + if (isRedirect(loaderData) || isNotFound(loaderData)) { + throw loaderData + } + + commitMatch(inner, index, { + loaderData, + }) + } + } catch (loaderError) { + if (isRedirect(loaderError) && loaderError.redirectHandled) { + throw loaderError + } + + await finalizeServerRouteFailure( + inner, + index, + normalizeRouteFailure(inner, index, loaderError), + ) + return inner.matches[index]! + } + + try { + if (routeChunkPromise) { + await routeChunkPromise + } + } catch (chunkError) { + await finalizeServerRouteFailure(inner, index, chunkError, true) + return inner.matches[index]! + } + + commitMatch(inner, index, { + error: undefined, + status: 'success', + updatedAt: Date.now(), + }) + + return inner.matches[index]! +} + +export const loadServerMatches = async ( + arg: LoadMatchesArg, +): Promise> => { + const inner: InnerLoadContext = { + router: arg.router, + location: arg.location, + matches: arg.matches, + preload: arg.preload, + forceStaleReload: arg.forceReload, + background: arg.background, + onReady: arg.onReady, + } + const matchPromises: Array> = [] + + let firstNotFound: NotFoundError | undefined + + for (let i = 0; i < inner.matches.length && !inner.serialFailure; i++) { + try { + let beforeLoadResult: + | false + | void + | AnyRouteMatch + | Promise = handleServerBeforeLoad( + inner, + i, + ) + if (isPromise(beforeLoadResult)) { + beforeLoadResult = await beforeLoadResult + } + if (beforeLoadResult === false) { + matchPromises[i] = Promise.resolve(finishServerSkippedMatch(inner, i)) + } + } catch (err) { + if (isNotFound(err)) { + firstNotFound ||= err + } else { + throw err + } + break + } + } + + const failure = inner.serialFailure + let maxIndexExclusive = inner.matches.length + if (failure) { + const [index, error] = failure + maxIndexExclusive = isRedirect(error) + ? 0 + : isNotFound(error) + ? Math.min(getNotFoundBoundaryIndex(inner, error) + 1, index) + : index + } + + for (let i = 0; i < maxIndexExclusive; i++) { + matchPromises[i] ||= loadServerRouteMatch(inner, matchPromises, i) + } + + if (failure) { + const [index, error] = failure + matchPromises.push(finalizeServerRouteFailure(inner, index, error) as any) + } + + let firstRedirect: unknown + let fatalError: unknown + let hasFatalError = false + + const results = await Promise.all( + matchPromises.map((promise) => + promise.then( + () => ({ ok: true as const }), + (reason) => ({ + ok: false as const, + error: + process.env.NODE_ENV !== 'production' && reason === undefined + ? new Error('Route load failed with undefined') + : reason, + }), + ), + ), + ) + + for (const result of results) { + if (result.ok) { + continue + } + const reason = result.error + if (isRedirect(reason)) { + firstRedirect ||= reason + } else if (isNotFound(reason)) { + firstNotFound ||= reason + } else if (!hasFatalError) { + fatalError = reason + hasFatalError = true + } + } + + if (firstRedirect) { + throw firstRedirect + } + + const notFoundToThrow = firstNotFound + const errorIndex = inner.badIndex + + if (!hasFatalError && notFoundToThrow) { + const index = commitServerNotFoundBoundary(inner, notFoundToThrow) + while (inner.matches.length > index + 1) { + inner.matches.pop() + } + } else if (!hasFatalError) { + while (errorIndex != null && inner.matches.length > errorIndex + 1) { + inner.matches.pop() + } + } + + const assets = projectServerRouteAssets(inner.router, inner.matches) + + if (isPromise(assets)) { + await assets + } + + if (hasFatalError) { + throw fatalError + } + + if (notFoundToThrow) { + throw notFoundToThrow + } + + if (errorIndex != null) { + const errorMatch = inner.matches[errorIndex]! + if (errorMatch.status === 'error') { + throw errorMatch.error + } + } + + return inner.matches +} + +function makeMaybe( + value: TValue, + error: TError, +): { status: 'success'; value: TValue } | { status: 'error'; error: TError } { + if (error !== undefined) { + return { status: 'error' as const, error } + } + return { status: 'success' as const, value } +} diff --git a/packages/router-core/src/load-matches.ts b/packages/router-core/src/load-matches.ts index f901a0c97d..f5e77df2b9 100644 --- a/packages/router-core/src/load-matches.ts +++ b/packages/router-core/src/load-matches.ts @@ -1,1278 +1,165 @@ -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' import { isRedirect } from './redirect' +import { rootRouteId } from './root' import type { NotFoundError } from './not-found' import type { ParsedLocation } from './location' -import type { - AnyRoute, - BeforeLoadContextOptions, - LoaderFnContext, - SsrContextOptions, -} from './route' -import type { AnyRouteMatch, MakeRouteMatch } from './Matches' -import type { AnyRouter, SSROption, UpdateMatchFn } from './router' +import type { AnyRouteMatch } from './Matches' +import type { AnyRouter } from './router' -/** - * An object of this shape is created when calling `loadMatches`. - * It contains everything we need for all other functions in this file - * to work. (It's basically the function's argument, plus a few mutable states) - */ -type InnerLoadContext = { - /** the calling router instance */ +export type InnerLoadContext = { + /** Router for this private load lane. */ router: AnyRouter + /** Target location for this private load lane. */ location: ParsedLocation - /** mutable state, scoped to a `loadMatches` call */ - firstBadMatchIndex?: number - /** mutable state, scoped to a `loadMatches` call */ - rendered?: boolean - serialError?: unknown - updateMatch: UpdateMatchFn + /** Framework render promise returned by pending publication. */ + rendered?: true | Promise + /** Pending lane was published for presentation; final commit still owns effects. */ + pendingPublished?: true + /** Private match lane being loaded. */ matches: Array - preload?: boolean + /** + * Set only for preload passes. Contains active/pending match IDs that + * this pass borrows read-only instead of owning in the cache. + */ + preload?: Array + /** Earliest route index with a committed route error. */ + badIndex?: number + /** Same-href client load should revalidate stale matches. */ forceStaleReload?: boolean - onReady?: () => Promise - sync?: boolean + /** Callback that publishes pending UI when the lane becomes renderable. */ + onReady?: (matches: Array) => void | Promise + /** Server beforeLoad failure captured during the serial phase. */ + serialFailure?: SerialFailure + /** Client background reload indices selected during foreground matching. */ + background?: Array + /** Foreground load must commit even if same-href background reloads exist. */ + requiresCommit?: boolean } -const triggerOnReady = (inner: InnerLoadContext): void | Promise => { - if (!inner.rendered) { - inner.rendered = true - return inner.onReady?.() - } +export type SerialFailure = [index: number, error: unknown] + +export type LoadMatchesArg = { + router: AnyRouter + location: ParsedLocation + matches: Array + preload?: Array + forceReload?: boolean + background?: Array + onReady?: (matches: Array) => void | Promise } -const hasForcePendingActiveMatch = (router: AnyRouter): boolean => { - return router.stores.matchesId.get().some((matchId) => { - return router.stores.matchStores.get(matchId)?.get()._forcePending - }) +export type BackgroundLoad = { + href: string + controller: AbortController } -const resolvePreload = (inner: InnerLoadContext, matchId: string): boolean => { - return !!(inner.preload && !inner.router.stores.matchStores.has(matchId)) +export const markError = (inner: InnerLoadContext, index: number) => { + inner.badIndex = Math.min(inner.badIndex ?? index, index) } -/** - * Builds the accumulated context from router options and all matches up to (and optionally including) the given index. - * Merges __routeContext and __beforeLoadContext from each match. - */ -const buildMatchContext = ( - inner: InnerLoadContext, +export const getMatchContext = ( + inner: Pick, index: number, - includeCurrentMatch: boolean = true, + beforeLoadContext: unknown, ): 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) + const parentContext = + inner.matches[index - 1]?.context ?? inner.router.options.context ?? {} + const routeContext = inner.matches[index]!.__routeContext + return routeContext || beforeLoadContext + ? { + ...parentContext, + ...routeContext, + ...(beforeLoadContext as Record), + } + : parentContext +} + +export const settleMatchLoad = (match: AnyRouteMatch): void => { + const promise = match._.loadPromise + match._.loadPromise = undefined + clearTimeout(promise?.pendingTimeout) + + if (promise?.status === 'pending') { + promise.resolve() } - return context } -const getNotFoundBoundaryIndex = ( +export const getNotFoundBoundaryIndex = ( inner: InnerLoadContext, err: NotFoundError, -): number | undefined => { - if (!inner.matches.length) { - return undefined - } - +): number => { 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) + : inner.matches.length - 1 - if (startIndex < 0) { - startIndex = rootIndex + if (startIndex === -1) { + startIndex = 0 } for (let i = startIndex; i >= 0; i--) { - const match = inner.matches[i]! - const route = inner.router.looseRoutesById[match.routeId]! - if (route.options.notFoundComponent) { + if ( + inner.router.routesById[inner.matches[i]!.routeId]!.options + .notFoundComponent + ) { return i } } // If no boundary component is found, preserve explicit routeId targeting behavior, // otherwise default to root for untargeted notFounds. - return requestedRouteId ? startIndex : rootIndex -} - -const handleRedirectAndNotFound = ( - inner: InnerLoadContext, - match: AnyRouteMatch | undefined, - err: unknown, -): void => { - if (!isRedirect(err) && !isNotFound(err)) return - - if (isRedirect(err) && err.redirectHandled && !err.options.reloadDocument) { - throw err - } - - // 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 = err - - 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, - })) - - if (isNotFound(err) && !err.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 - } - - match._nonReactive.loadPromise?.resolve() - } - - if (isRedirect(err)) { - inner.rendered = true - err.options._fromLocation = inner.location - err.redirectHandled = true - err = inner.router.resolveRedirect(err) - } - - throw err -} - -const shouldSkipLoader = ( - inner: InnerLoadContext, - matchId: string, -): boolean => { - const match = inner.router.getMatch(matchId) - if (!match) { - return true - } - // 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 - } - - 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 requestedRouteId ? startIndex : 0 } -const handleSerialError = ( +export const normalizeRouteFailure = ( inner: InnerLoadContext, index: number, - err: any, -): void => { - const { id: matchId, routeId } = inner.matches[index]! - const route = inner.router.looseRoutesById[routeId]! - - // Much like suspense, we use a promise here to know if - // we've been outdated by a new loadMatches call and - // should abort the current async operation - if (err instanceof Promise) { - throw err + error: unknown, +): unknown => { + if (process.env.NODE_ENV !== 'production' && error === undefined) { + error = new Error('Route load failed with undefined') } - inner.firstBadMatchIndex ??= index - handleRedirectAndNotFound(inner, inner.router.getMatch(matchId), err) - - try { - route.options.onError?.(err) - } catch (errorHandlerErr) { - err = errorHandlerErr - handleRedirectAndNotFound(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(), - } - }) - - if (!inner.preload && !isRedirect(err) && !isNotFound(err)) { - inner.serialError ??= err - } -} - -const isBeforeLoadSsr = ( - inner: InnerLoadContext, - matchId: string, - index: number, - route: AnyRoute, -): void | Promise => { - const existingMatch = inner.router.getMatch(matchId)! - const parentMatchId = inner.matches[index - 1]?.id - const parentMatch = parentMatchId - ? inner.router.getMatch(parentMatchId)! - : undefined - - // in SPA mode, only SSR the root route - if (inner.router.isShell()) { - existingMatch.ssr = route.id === rootRouteId - return - } - - if (parentMatch?.ssr === false) { - existingMatch.ssr = false - return - } - - const parentOverride = (tempSsr: SSROption) => { - if (tempSsr === true && parentMatch?.ssr === 'data-only') { - return 'data-only' - } - return tempSsr - } - - const defaultSsr = inner.router.options.defaultSsr ?? true - - if (route.options.ssr === undefined) { - existingMatch.ssr = parentOverride(defaultSsr) - return - } - - if (typeof route.options.ssr !== 'function') { - existingMatch.ssr = parentOverride(route.options.ssr) - return - } - const { search, params } = existingMatch - - const ssrFnContext: SsrContextOptions = { - search: makeMaybe(search, existingMatch.searchError), - params: makeMaybe(params, existingMatch.paramsError), - location: inner.location, - matches: inner.matches.map((match) => ({ - index: match.index, - pathname: match.pathname, - fullPath: match.fullPath, - staticData: match.staticData, - id: match.id, - routeId: match.routeId, - search: makeMaybe(match.search, match.searchError), - params: makeMaybe(match.params, match.paramsError), - ssr: match.ssr, - })), - } - - const tempSsr = route.options.ssr(ssrFnContext) - if (isPromise(tempSsr)) { - return tempSsr.then((ssr) => { - existingMatch.ssr = parentOverride(ssr ?? defaultSsr) - }) - } - - existingMatch.ssr = parentOverride(tempSsr ?? defaultSsr) - return -} - -const setupPendingTimeout = ( - inner: InnerLoadContext, - matchId: string, - route: AnyRoute, - match: AnyRouteMatch, -): void => { - if (match._nonReactive.pendingTimeout !== undefined) return - - const pendingMs = - route.options.pendingMs ?? inner.router.options.defaultPendingMs - const shouldPending = !!( - inner.onReady && - !(isServer ?? inner.router.isServer) && - !resolvePreload(inner, matchId) && - (route.options.loader || - route.options.beforeLoad || - routeNeedsPreload(route)) && - typeof pendingMs === 'number' && - pendingMs !== Infinity && - (route.options.pendingComponent ?? - (inner.router.options as any)?.defaultPendingComponent) - ) - - if (shouldPending) { - const pendingTimeout = setTimeout(() => { - // Update the match and prematurely resolve the loadMatches promise so that - // the pending component can start rendering - triggerOnReady(inner) - }, pendingMs) - match._nonReactive.pendingTimeout = pendingTimeout - } -} - -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, - index: number, - route: AnyRoute, -): void | Promise => { - const match = inner.router.getMatch(matchId)! - - // explicitly capture the previous loadPromise - let prevLoadPromise = match._nonReactive.loadPromise - match._nonReactive.loadPromise = createControlledPromise(() => { - prevLoadPromise?.resolve() - prevLoadPromise = undefined - }) - - const { paramsError, searchError } = match - - if (paramsError) { - handleSerialError(inner, index, paramsError) - } - - if (searchError) { - handleSerialError(inner, index, searchError) - } - - setupPendingTimeout(inner, matchId, route, match) - - const abortController = new AbortController() - - let isPending = false - const pending = () => { - if (isPending) return - isPending = true - inner.updateMatch(matchId, (prev) => ({ - ...prev, - isFetching: 'beforeLoad', - fetchCount: prev.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 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 - // Context will be updated later in loadRouteMatch after loader completes - if (!route.options.beforeLoad) { - inner.router.batch(() => { - pending() - resolve() - }) - return - } - - match._nonReactive.beforeLoadPromise = createControlledPromise() - - // 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), - ...match.__routeContext, - } - const { search, params, cause } = match - const preload = resolvePreload(inner, matchId) - const beforeLoadFnContext: BeforeLoadContextOptions< - any, - any, - any, - any, - any, - any, - any, - any, - any - > = { - search, - abortController, - params, - preload, - context, - location: inner.location, - navigate: (opts: any) => - inner.router.navigate({ - ...opts, - _fromLocation: inner.location, - }), - buildLocation: inner.router.buildLocation, - cause: preload ? 'preload' : cause, - matches: inner.matches, - routeId: route.id, - ...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) - if (isPromise(beforeLoadContext)) { - pending() - return beforeLoadContext - .catch((err) => { - handleSerialError(inner, index, err) - }) - .then(updateContext) - } - } catch (err) { - pending() - handleSerialError(inner, index, err) - } - - updateContext(beforeLoadContext) - return -} - -const handleBeforeLoad = ( - inner: InnerLoadContext, - index: number, -): void | Promise => { - 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 match = inner.matches[index]! + if (!isRedirect(error) && !isNotFound(error)) { + try { + inner.router.routesById[match.routeId]!.options.onError?.(error) + } catch (onErrorError) { + error = onErrorError } - return queueExecution() } - const execute = () => executeBeforeLoad(inner, matchId, index, route) - - const queueExecution = () => { - if (shouldSkipLoader(inner, matchId)) return - const result = preBeforeLoadSetup(inner, matchId, route) - return isPromise(result) ? result.then(execute) : execute() + if (isNotFound(error)) { + error.routeId ||= match.routeId } - return serverSsr() -} - -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, - } - - 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, - } - }) -} - -const getLoaderContext = ( - inner: InnerLoadContext, - matchPromises: Array>, - matchId: string, - index: number, - route: AnyRoute, -): LoaderFnContext => { - const parentMatchPromise = matchPromises[index - 1] as any - const { params, loaderDeps, abortController, cause } = - inner.router.getMatch(matchId)! - - const context = buildMatchContext(inner, index) - - const preload = resolvePreload(inner, matchId) - - return { - params, - deps: loaderDeps, - preload: !!preload, - parentMatchPromise, - abortController, - context, - location: inner.location, - navigate: (opts) => - inner.router.navigate({ - ...opts, - _fromLocation: inner.location, - }), - cause: preload ? 'preload' : cause, - route, - ...inner.router.options.additionalContext, - } + return error } -const runLoader = async ( +export const getNotFoundBoundaryPatch = ( inner: InnerLoadContext, - matchPromises: Array>, - matchId: string, index: number, - route: AnyRoute, -): Promise => { - 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)! - - // 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 = !!loader && isPromise(loaderResult) - - const willLoadSomething = !!( - loaderResultIsPromise || - route._lazyPromise || - route._componentsPromise || - route.options.head || - route.options.scripts || - route.options.headers || - match._nonReactive.minPendingPromise - ) - - if (willLoadSomething) { - inner.updateMatch(matchId, (prev) => ({ - ...prev, - isFetching: 'loader', - })) - } - - if (loader) { - const loaderData = loaderResultIsPromise - ? await loaderResult - : loaderResult - - handleRedirectAndNotFound( - inner, - inner.router.getMatch(matchId), - loaderData, - ) - if (loaderData !== undefined) { - inner.updateMatch(matchId, (prev) => ({ - ...prev, - 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 = match._nonReactive.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, - error: undefined, - context: buildMatchContext(inner, index), - status: 'success', - isFetching: false, - updatedAt: Date.now(), - })) - } catch (e) { - let error = e - - if ((error as any)?.name === 'AbortError') { - if (match.abortController.signal.aborted) { - match._nonReactive.loaderPromise?.resolve() - match._nonReactive.loaderPromise = undefined - return + err: NotFoundError, +): Partial => { + const match = inner.matches[index]! + + err.routeId = match.routeId + const patch: Partial = + match.routeId === rootRouteId + ? { + status: 'success', + error: undefined, + isFetching: false, + globalNotFound: true, } - inner.updateMatch(matchId, (prev) => ({ - ...prev, - status: prev.status === 'pending' ? 'success' : prev.status, + : { + status: 'notFound', + error: err, isFetching: false, - context: buildMatchContext(inner, index), - })) - return - } - - const pendingPromise = match._nonReactive.minPendingPromise - if (pendingPromise) await pendingPromise - - if (isNotFound(e)) { - await (route.options.notFoundComponent as any)?.preload?.() - } - - handleRedirectAndNotFound(inner, inner.router.getMatch(matchId), e) - - try { - route.options.onError?.(e) - } catch (onErrorError) { - error = onErrorError - handleRedirectAndNotFound( - inner, - inner.router.getMatch(matchId), - onErrorError, - ) - } - if (!isRedirect(error) && !isNotFound(error)) { - await loadRouteChunk(route, ['errorComponent']) - } - - inner.updateMatch(matchId, (prev) => ({ - ...prev, - 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 - } - handleRedirectAndNotFound(inner, match, err) - } -} - -const loadRouteMatch = async ( - inner: InnerLoadContext, - matchPromises: Array>, - index: number, -): Promise => { - async function handleLoader( - preload: boolean, - prevMatch: AnyRouteMatch, - previousRouteMatchId: string | undefined, - match: AnyRouteMatch, - route: AnyRoute, - ) { - 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 - - // If the route is successful and still fresh, just resolve - const { status, invalid } = match - const staleMatchShouldReload = - age >= staleAge && - (!!inner.forceStaleReload || - match.cause === 'enter' || - (previousRouteMatchId !== undefined && - previousRouteMatchId !== match.id)) - loaderShouldRunAsync = - status === 'success' && - (invalid || (shouldReload ?? staleMatchShouldReload)) - if (preload && route.options.preload === false) { - // Do nothing - } else if ( - loaderShouldRunAsync && - !inner.sync && - shouldReloadInBackground - ) { - loaderIsRunningAsync = true - ;(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) - } } - })() - } else if (status !== 'success' || loaderShouldRunAsync) { - await runLoader(inner, matchPromises, matchId, index, route) - } else { - syncMatchContext(inner, matchId, index) - } - } - - 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 (shouldSkipLoader(inner, matchId)) { - const match = inner.router.getMatch(matchId) - if (!match) { - return inner.matches[index]! - } - - syncMatchContext(inner, matchId, index) - - 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 && - 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 preload = resolvePreload(inner, matchId) - - // there is a loaderPromise, so we are in the middle of a load - if (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 - if ( - prevMatch.status === 'success' && - !inner.sync && - !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) - } - - if (match.status === 'pending') { - await handleLoader( - preload, - prevMatch, - previousRouteMatchId, - match, - route, - ) - } - } else { - const nextPreload = - preload && !inner.router.stores.matchStores.has(matchId) - const match = inner.router.getMatch(matchId)! - match._nonReactive.loaderPromise = createControlledPromise() - if (nextPreload !== match.preload) { - inner.updateMatch(matchId, (prev) => ({ - ...prev, - preload: nextPreload, - })) - } - - await handleLoader(preload, prevMatch, previousRouteMatchId, match, route) - } - } - const match = inner.router.getMatch(matchId)! - if (!loaderIsRunningAsync) { - match._nonReactive.loaderPromise?.resolve() - match._nonReactive.loadPromise?.resolve() - match._nonReactive.loadPromise = undefined - } - - 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, - isFetching: nextIsFetching, - invalid: false, - })) - return inner.router.getMatch(matchId)! - } else { - return match - } -} - -export async function loadMatches(arg: { - router: AnyRouter - location: ParsedLocation - matches: Array - preload?: boolean - forceStaleReload?: boolean - onReady?: () => Promise - updateMatch: UpdateMatchFn - sync?: boolean -}): Promise> { - const inner: InnerLoadContext = arg - const matchPromises: Array> = [] - - // make sure the pending component is immediately rendered when hydrating a match that is not SSRed - // 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) - ) { - triggerOnReady(inner) - } - - let beforeLoadNotFound: NotFoundError | undefined - - // Execute all beforeLoads one by one - for (let i = 0; i < inner.matches.length; i++) { - try { - 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 - } - break - } - - if (inner.serialError || 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 - - let firstNotFound: NotFoundError | undefined - let firstUnhandledRejection: unknown - - for (let i = 0; i < maxIndexExclusive; i++) { - matchPromises.push(loadRouteMatch(inner, matchPromises, i)) - } - - try { - await Promise.all(matchPromises) - } catch { - const settled = await Promise.allSettled(matchPromises) - - for (const result of settled) { - if (result.status !== 'rejected') continue - - const reason = result.reason - if (isRedirect(reason)) { - throw reason - } - if (isNotFound(reason)) { - firstNotFound ??= reason - } else { - firstUnhandledRejection ??= reason - } - } - - if (firstUnhandledRejection !== undefined) { - throw firstUnhandledRejection - } - } - const notFoundToThrow = - firstNotFound ?? - (beforeLoadNotFound && !inner.preload ? beforeLoadNotFound : undefined) + patch.context = getMatchContext(inner, index, match.__beforeLoadContext) - let headMaxIndex = - inner.firstBadMatchIndex !== undefined - ? inner.firstBadMatchIndex - : inner.matches.length - 1 - - if (!notFoundToThrow && beforeLoadNotFound && inner.preload) { - return inner.matches - } - - if (notFoundToThrow) { - // Determine once which matched route will actually render the - // notFoundComponent, then pass this precomputed index through the remaining - // finalization steps. - // This can differ from the throwing route when routeId targets an ancestor - // boundary (or when bubbling resolves to a parent/root boundary). - const renderedBoundaryIndex = getNotFoundBoundaryIndex( - inner, - 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]! - const defaultNotFoundComponent = (inner.router.options as any) - ?.defaultNotFoundComponent - - // Ensure a notFoundComponent exists on the boundary route - if (!boundaryRoute.options.notFoundComponent && defaultNotFoundComponent) { - boundaryRoute.options.notFoundComponent = defaultNotFoundComponent - } - - notFoundToThrow.routeId = boundaryMatch.routeId - - const boundaryIsRoot = boundaryMatch.routeId === inner.router.routeTree.id - - inner.updateMatch(boundaryMatch.id, (prev) => ({ - ...prev, - ...(boundaryIsRoot - ? // 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 } - : // For non-root boundaries, set status:'notFound' so MatchInner - // renders the notFoundComponent directly. - { status: 'notFound' as const, error: notFoundToThrow }), - isFetching: false, - })) - - 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), - // 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) { - const errorRoute = - inner.router.looseRoutesById[ - inner.matches[inner.firstBadMatchIndex]!.routeId - ]! - await loadRouteChunk(errorRoute, ['errorComponent']) - } - - // 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++) { - const match = inner.matches[i]! - const { id: matchId, routeId } = match - const route = inner.router.looseRoutesById[routeId]! - try { - const headResult = executeHead(inner, matchId, route) - if (headResult) { - const head = await headResult - inner.updateMatch(matchId, (prev) => ({ - ...prev, - ...head, - })) - } - } catch (err) { - console.error(`Error executing head for route ${routeId}:`, err) - } - } - - const readyPromise = triggerOnReady(inner) - if (isPromise(readyPromise)) { - await readyPromise - } - - if (notFoundToThrow) { - throw notFoundToThrow - } - - if (inner.serialError && !inner.preload && !inner.onReady) { - throw inner.serialError - } - - return inner.matches -} - -export type RouteComponentType = - | 'component' - | 'errorComponent' - | 'pendingComponent' - | 'notFoundComponent' - -function preloadRouteComponents( - route: AnyRoute, - componentTypesToLoad: Array, -): Promise | undefined { - const preloads = componentTypesToLoad - .map((type) => (route.options[type] as any)?.preload?.()) - .filter(Boolean) - - if (preloads.length === 0) return undefined - - return Promise.all(preloads) as any as Promise -} - -export function loadRouteChunk( - route: AnyRoute, - componentTypesToLoad: Array = componentTypes, -) { - if (!route._lazyLoaded && route._lazyPromise === undefined) { - if (route.lazyFn) { - route._lazyPromise = route.lazyFn().then((lazyRoute) => { - // explicitly don't copy over the lazy route's id - const { id: _id, ...options } = lazyRoute.options - Object.assign(route.options, options) - route._lazyLoaded = true - route._lazyPromise = undefined // gc promise, we won't need it anymore - }) - } else { - route._lazyLoaded = true - } - } - - 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) - - return route._lazyPromise - ? route._lazyPromise.then(runAfterLazy) - : runAfterLazy() + return patch } - -function makeMaybe( - value: TValue, - error: TError, -): { status: 'success'; value: TValue } | { status: 'error'; error: TError } { - if (error) { - return { status: 'error' as const, error } - } - return { status: 'success' as const, value } -} - -export function routeNeedsPreload(route: AnyRoute) { - for (const componentType of componentTypes) { - if ((route.options[componentType] as any)?.preload) { - return true - } - } - return false -} - -export const componentTypes: Array = [ - 'component', - 'errorComponent', - 'pendingComponent', - 'notFoundComponent', -] as const diff --git a/packages/router-core/src/location-change.ts b/packages/router-core/src/location-change.ts new file mode 100644 index 0000000000..57c272113e --- /dev/null +++ b/packages/router-core/src/location-change.ts @@ -0,0 +1,19 @@ +import type { HistoryAction } from '@tanstack/history' +import type { ParsedLocation } from './location' + +export function getLocationChangeInfo( + location: ParsedLocation, + resolvedLocation?: ParsedLocation, +) { + const fromLocation = resolvedLocation + const toLocation = location + const pathChanged = fromLocation?.pathname !== toLocation.pathname + const hrefChanged = fromLocation?.href !== toLocation.href + const hashChanged = fromLocation?.hash !== toLocation.hash + return { fromLocation, toLocation, pathChanged, hrefChanged, hashChanged } +} + +export const locationHistoryActions = new WeakMap< + ParsedLocation, + HistoryAction +>() diff --git a/packages/router-core/src/redirect.ts b/packages/router-core/src/redirect.ts index b10fac6d7a..312343b7a1 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' @@ -121,7 +122,9 @@ export function redirect< >( opts: RedirectOptions, ): Redirect { - opts.statusCode = opts.statusCode || opts.code || 307 + if (isServer ?? typeof document === 'undefined') { + opts.statusCode = opts.statusCode || opts.code || 307 + } if ( !opts._builtLocation && @@ -134,14 +137,14 @@ 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, + ...((isServer ?? typeof document === 'undefined') + ? { status: opts.statusCode } + : undefined), + headers: + (isServer ?? typeof document === 'undefined') && opts.href + ? getRedirectHeaders(opts) + : opts.headers, }) ;(response as Redirect).options = @@ -154,6 +157,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 { diff --git a/packages/router-core/src/route-assets.client.ts b/packages/router-core/src/route-assets.client.ts new file mode 100644 index 0000000000..6b52f70f20 --- /dev/null +++ b/packages/router-core/src/route-assets.client.ts @@ -0,0 +1,122 @@ +import { isPromise } from './utils' +import type { AnyRouteMatch } from './Matches' +import type { AnyRouter } from './router' + +function commitClientAssets( + matches: Array, + index: number, + head: any, + scripts: any, +): void { + matches[index] = { + ...matches[index]!, + meta: head?.meta, + links: head?.links, + headScripts: head?.scripts, + scripts, + styles: head?.styles, + } +} + +export function projectClientRouteAssets( + router: AnyRouter, + matches: Array, + preload?: boolean, + isCurrent?: () => boolean, + startIndex = 0, +): void | Promise { + for (let i = startIndex; i < matches.length; i++) { + if (isCurrent && !isCurrent()) { + return + } + + const match = matches[i]! + if (preload && !match.preload) { + continue + } + + const routeOptions = router.routesById[match.routeId]!.options + if (!(routeOptions.head || routeOptions.scripts)) { + continue + } + + try { + const assetContext = { + ssr: router.options.ssr, + matches, + match, + params: match.params, + loaderData: match.loaderData, + } + + const head = routeOptions.head?.(assetContext) + if (isCurrent && !isCurrent()) { + // This pass lost ownership before the normal Promise.all path could + // observe `head`; allSettled owns any later rejection. + void Promise.allSettled([head]) + return + } + + let scripts: any + try { + scripts = routeOptions.scripts?.(assetContext) + } catch (error) { + // `head` may be async and abandoned because `scripts` threw + // synchronously. Own its rejection so it cannot become unhandled. + void Promise.allSettled([head]) + throw error + } + if (isCurrent && !isCurrent()) { + // This pass lost ownership before the normal Promise.all path could + // observe the asset promises; allSettled owns any later rejection. + void Promise.allSettled([head, scripts]) + return + } + + if (isPromise(head) || isPromise(scripts)) { + return Promise.all([head, scripts]).then( + ([headResult, scriptResult]) => { + if (!isCurrent || isCurrent()) { + commitClientAssets(matches, i, headResult, scriptResult) + return projectClientRouteAssets( + router, + matches, + preload, + isCurrent, + i + 1, + ) + } + }, + (error) => { + if (!isCurrent || isCurrent()) { + if (process.env.NODE_ENV !== 'production') { + console.error( + `Error executing head for route ${match.routeId}:`, + error, + ) + } + + return projectClientRouteAssets( + router, + matches, + preload, + isCurrent, + i + 1, + ) + } + }, + ) + } + + commitClientAssets(matches, i, head, scripts) + } catch (error) { + if (isCurrent && !isCurrent()) { + return + } + + if (process.env.NODE_ENV !== 'production') { + console.error(`Error executing head for route ${match.routeId}:`, error) + } + } + } +} diff --git a/packages/router-core/src/route-assets.server.ts b/packages/router-core/src/route-assets.server.ts new file mode 100644 index 0000000000..2592e9640b --- /dev/null +++ b/packages/router-core/src/route-assets.server.ts @@ -0,0 +1,183 @@ +import { isPromise } from './utils' +import type { AnyRouteMatch } from './Matches' +import type { AnyRouter } from './router' + +const withServerAssets = ( + match: AnyRouteMatch, + head: any, + scripts: any, + headers: any, +): AnyRouteMatch => ({ + ...match, + meta: head?.meta, + links: head?.links, + headScripts: head?.scripts, + scripts, + styles: head?.styles, + headers, +}) + +export const projectServerRouteAssets = ( + router: AnyRouter, + matches: Array, + startIndex = 0, +): void | Promise => { + for (let i = startIndex; i < matches.length; i++) { + const match = matches[i]! + const routeOptions = router.routesById[match.routeId]!.options + if (!(routeOptions.head || routeOptions.scripts || routeOptions.headers)) { + continue + } + + try { + const assetContext = { + ssr: router.options.ssr, + matches, + match, + params: match.params, + loaderData: match.loaderData, + } + + const head = routeOptions.head?.(assetContext) + let scripts: any + try { + scripts = routeOptions.scripts?.(assetContext) + } catch (error) { + // `head` may be async and abandoned because `scripts` threw + // synchronously. Own its rejection so it cannot become unhandled. + void Promise.allSettled([head]) + throw error + } + let headers: any + try { + headers = routeOptions.headers?.(assetContext) + } catch (error) { + // `head`/`scripts` may be async and abandoned because `headers` threw + // synchronously. Own rejections so they cannot become unhandled. + void Promise.allSettled([head, scripts]) + throw error + } + + if (isPromise(head) || isPromise(scripts) || isPromise(headers)) { + return continueServerRouteAssets( + router, + matches, + i, + match, + head, + scripts, + headers, + ) + } + + matches[i] = withServerAssets(match, head, scripts, headers) + } catch (error) { + if (process.env.NODE_ENV !== 'production') { + console.error(`Error executing head for route ${match.routeId}:`, error) + } + } + } +} + +const continueServerRouteAssets = async ( + router: AnyRouter, + matches: Array, + startIndex: number, + firstMatch: AnyRouteMatch, + firstHead: any, + firstScripts: any, + firstHeaders: any, +): Promise => { + let i = startIndex + let match = firstMatch + let head = firstHead + let scripts = firstScripts + let headers = firstHeaders + + while (true) { + const results = await Promise.allSettled([head, scripts, headers]) + + const headResult = results[0] + const scriptResult = results[1] + const headerResult = results[2] + + if ( + headResult.status === 'fulfilled' && + scriptResult.status === 'fulfilled' && + headerResult.status === 'fulfilled' + ) { + matches[i] = withServerAssets( + match, + headResult.value, + scriptResult.value, + headerResult.value, + ) + } else if (process.env.NODE_ENV !== 'production') { + const failed = + headResult.status === 'rejected' + ? headResult + : scriptResult.status === 'rejected' + ? scriptResult + : headerResult + console.error( + `Error executing head for route ${match.routeId}:`, + (failed as PromiseRejectedResult).reason, + ) + } + + for (i++; i < matches.length; i++) { + match = matches[i]! + const routeOptions = router.routesById[match.routeId]!.options + if ( + !(routeOptions.head || routeOptions.scripts || routeOptions.headers) + ) { + continue + } + + try { + const assetContext = { + ssr: router.options.ssr, + matches, + match, + params: match.params, + loaderData: match.loaderData, + } + + head = routeOptions.head?.(assetContext) + try { + scripts = routeOptions.scripts?.(assetContext) + } catch (error) { + // `head` may be async and abandoned because `scripts` threw + // synchronously. Own its rejection so it cannot become unhandled. + void Promise.allSettled([head]) + throw error + } + try { + headers = routeOptions.headers?.(assetContext) + } catch (error) { + // `head`/`scripts` may be async and abandoned because `headers` threw + // synchronously. Own rejections so they cannot become unhandled. + void Promise.allSettled([head, scripts]) + throw error + } + + if (isPromise(head) || isPromise(scripts) || isPromise(headers)) { + break + } + + matches[i] = withServerAssets(match, head, scripts, headers) + } catch (error) { + if (process.env.NODE_ENV !== 'production') { + console.error( + `Error executing head for route ${match.routeId}:`, + error, + ) + } + } + } + + if (i >= matches.length) { + return + } + } +} diff --git a/packages/router-core/src/route-chunks.ts b/packages/router-core/src/route-chunks.ts new file mode 100644 index 0000000000..5b06543ee2 --- /dev/null +++ b/packages/router-core/src/route-chunks.ts @@ -0,0 +1,97 @@ +import type { AnyRoute } from './route' + +type RouteComponentType = + | 'component' + | 'errorComponent' + | 'pendingComponent' + | 'notFoundComponent' + +function preloadComponent(component: any): Promise | undefined { + try { + return component?.preload?.() + } catch (error) { + return Promise.reject(error) + } +} + +function preloadRouteComponents( + route: AnyRoute, +): Promise> | undefined { + let preloads: Array> | undefined + for (const componentType of componentTypes) { + const preload = preloadComponent(route.options[componentType]) + if (preload) { + ;(preloads ||= []).push(preload) + } + } + + return preloads && Promise.all(preloads) +} + +export function loadRouteChunk( + route: AnyRoute, + componentType?: RouteComponentType, +) { + if (!route._lazyLoaded && !route._lazyPromise) { + if (route.lazyFn) { + try { + route._lazyPromise = route.lazyFn().then((lazyRoute) => { + // explicitly don't copy over the lazy route's id + const { id: _id, ...options } = lazyRoute.options + Object.assign(route.options, options) + route._lazyLoaded = true + route._lazyPromise = undefined // gc promise, we won't need it anymore + }) + } catch (error) { + route._lazyPromise = Promise.reject(error) + } + } else { + route._lazyLoaded = true + } + } + + const runAfterLazy = () => { + if (route._componentsLoaded) { + return + } + if (componentType) { + return ( + route._componentsPromise || + preloadComponent(route.options[componentType]) + ) + } + if (!route._componentsPromise) { + const componentsPromise = preloadRouteComponents(route) + + 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) + : runAfterLazy() +} + +export function routeNeedsPreload(route: AnyRoute) { + for (const componentType of componentTypes) { + if ((route.options[componentType] as any)?.preload) { + return true + } + } + return false +} + +const componentTypes = [ + 'component', + 'errorComponent', + 'pendingComponent', + 'notFoundComponent', +] as const satisfies ReadonlyArray diff --git a/packages/router-core/src/router-load.client.ts b/packages/router-core/src/router-load.client.ts new file mode 100644 index 0000000000..6ec881acdf --- /dev/null +++ b/packages/router-core/src/router-load.client.ts @@ -0,0 +1,258 @@ +import { createControlledPromise, isPromise } from './utils' +import { isRedirect } from './redirect' +import { + getLocationChangeInfo, + locationHistoryActions, +} from './location-change' +import { settleMatchLoad } from './load-matches' +import { + clearBackgroundFetching, + loadClientMatches, + startBackgroundLoad, +} from './load-matches.client' +import { projectClientRouteAssets } from './route-assets.client' +import type { AnyRouteMatch } from './Matches' +import type { AnyRouter, LoadFn } from './router' +import type { InnerLoadContext } from './load-matches' + +const commitFinalMatches = ( + router: AnyRouter, + baseMatches: Array, + nextMatches: Array, +): void => { + const { stores } = router + router.batch(() => { + const now = Date.now() + const cached = stores.cachedMatches.get().slice() + + for (let i = 0; i < baseMatches.length; i++) { + const match = baseMatches[i]! + if (nextMatches[i]?.id !== match.id) { + cached.push({ + ...match, + isFetching: false, + }) + } + } + + let write = 0 + for (const match of cached) { + const routeOptions = router.routesById[match.routeId]!.options + const gcTime = + (match.preload + ? (routeOptions.preloadGcTime ?? router.options.defaultPreloadGcTime) + : (routeOptions.gcTime ?? router.options.defaultGcTime)) ?? 300_000 + + if ( + routeOptions.loader && + match.status === 'success' && + now - match.updatedAt < gcTime + ) { + cached[write++] = match + } else { + settleMatchLoad(match) + } + } + + cached.length = write + stores.isLoading.set(false) + stores.setMatches(nextMatches) + if (stores.pendingIds.get().length) { + stores.setPending([]) + } + stores.loadedAt.set(now) + stores.setCached(cached) + }) + + const matchCount = Math.max(baseMatches.length, nextMatches.length) + for (let i = 0; i < matchCount; i++) { + const current = baseMatches[i] + const nextMatch = nextMatches[i] + + if (current && current.routeId !== nextMatch?.routeId) { + router.routesById[current.routeId]!.options.onLeave?.(current) + } + + if (nextMatch) { + router.routesById[nextMatch.routeId]!.options[ + current?.routeId === nextMatch.routeId ? 'onStay' : 'onEnter' + ]?.(nextMatch) + } + } +} + +export const loadClientRouter = async ( + router: AnyRouter, + opts?: Parameters[0], +): Promise => { + const historyAction = opts?.action?.type + const loadPromise = createControlledPromise() + const { stores } = router + + router.latestLoadPromise = loadPromise + + const isCurrentLoad = () => router.latestLoadPromise === loadPromise + let startedBackgroundLoad = false + + try { + const backgroundLoad = router._backgroundLoad + if (backgroundLoad) { + // A foreground navigation supersedes stale same-href background work. + // Abort its private token and clear active fetching markers now, + // because the background finalizer no longer owns the token. + backgroundLoad.controller.abort() + router._backgroundLoad = undefined + clearBackgroundFetching(router) + } + router.cancelMatches() + router.updateLatestLocation() + + const next = router.latestLocation + const pendingMatches = router.matchRoutes(next) + const sameHref = + (stores.resolvedLocation.get() ?? stores.location.get()).href === + next.href + + router.batch(() => { + stores.status.set('pending') + stores.isLoading.set(true) + stores.location.set(next) + stores.setPending(pendingMatches) + stores.setCached( + stores.cachedMatches + .get() + .filter((match) => pendingMatches[match.index]?.id !== match.id), + ) + }) + + const baseMatches = stores.matches.get() + if (historyAction) { + locationHistoryActions.set(next, historyAction) + } else { + locationHistoryActions.delete(next) + } + + if (router.subscribers.size) { + const locationChangeInfo = getLocationChangeInfo( + next, + stores.resolvedLocation.get(), + ) + + router.emit({ + type: 'onBeforeNavigate', + ...locationChangeInfo, + }) + + router.emit({ + type: 'onBeforeLoad', + ...locationChangeInfo, + }) + } + + let loadedMatches: Array = pendingMatches + const background = opts?.sync ? undefined : ([] as Array) + const commitReady = (matches: Array) => { + if (!isCurrentLoad()) { + return + } + + router.batch(() => { + stores.setMatches(matches) + if (stores.pendingIds.get().length) { + stores.setPending([]) + } + }) + } + const loadContext: InnerLoadContext = { + router, + forceStaleReload: sameHref, + matches: pendingMatches, + location: next, + background, + onReady: commitReady, + } + try { + loadedMatches = (await loadClientMatches( + loadContext, + )) as Array + } catch (err) { + if (err === loadContext) { + // This foreground lane was superseded before reaching a route outcome. + // Let the outer load cleanup settle it as cancellation, not route error. + throw err + } + if (isRedirect(err)) { + throw err + } + loadContext.requiresCommit = true + } + + const backgroundIndices = background?.filter((index) => { + const match = loadedMatches[index] + return match && match.status === 'success' && !match.globalNotFound + }) + const backgroundLength = backgroundIndices?.length + const backgroundOnly = + sameHref && + backgroundLength && + !loadContext.requiresCommit && + !loadContext.pendingPublished + + if (isCurrentLoad()) { + if (backgroundOnly) { + router.batch(() => { + stores.isLoading.set(false) + if (stores.pendingIds.get().length) { + stores.setPending([]) + } + }) + } else { + const assets = projectClientRouteAssets( + router, + loadedMatches, + undefined, + isCurrentLoad, + ) + if (isPromise(assets)) { + await assets + } + if (isCurrentLoad()) { + await router.startViewTransition(async () => { + if (isCurrentLoad()) { + router.startTransition(() => { + commitFinalMatches(router, baseMatches, loadedMatches) + }) + } + }) + } + } + } + if (isCurrentLoad() && backgroundLength) { + startedBackgroundLoad = true + startBackgroundLoad(router, next, loadedMatches, backgroundIndices) + } + } catch (err) { + if (isCurrentLoad() && isRedirect(err)) { + stores.setPending([]) + router.navigate({ + ...err.options, + replace: true, + ignoreBlocker: true, + }) + } + } + + if (isCurrentLoad()) { + const commitLocationPromise = router.commitLocationPromise + router.latestLoadPromise = router.commitLocationPromise = undefined + commitLocationPromise?.resolve() + } + + loadPromise.resolve() + if (startedBackgroundLoad) { + // Background stale reloads run after the foreground lane is done. If that + // background work is sync, its commit is queued in the next microtask; yield + // once so the caller does not observe the temporary fetching marker. + await Promise.resolve() + } +} diff --git a/packages/router-core/src/router-load.server.ts b/packages/router-core/src/router-load.server.ts new file mode 100644 index 0000000000..b768e3d958 --- /dev/null +++ b/packages/router-core/src/router-load.server.ts @@ -0,0 +1,96 @@ +import { isNotFound } from './not-found' +import { isRedirect, redirect } from './redirect' +import { loadServerMatches } from './load-matches.server' +import type { AnyRouteMatch } from './Matches' +import type { AnyRouter, LoadFn } from './router' + +export const loadServerRouter = async ( + router: AnyRouter, + opts?: Parameters[0], +): Promise => { + let matchedMatches: Array | undefined + try { + const next = router.pendingBuiltLocation ?? router.latestLocation + router.latestLocation = next + + const nextLocation = router.buildLocation({ + to: next.pathname, + search: true, + params: true, + hash: true, + state: true, + _includeValidateSearch: true, + }) + + if (next.publicHref !== nextLocation.publicHref) { + const href = nextLocation.publicHref || '/' + throw nextLocation.external + ? redirect({ href }) + : redirect({ href, _builtLocation: nextLocation }) + } + + matchedMatches = router.matchRoutes(next) + + router.statusCode = 200 + router.stores.location.set(next) + + const loadedMatches = (await loadServerMatches({ + router, + matches: matchedMatches, + location: next, + })) as Array + + router.stores.loadedAt.set(Date.now()) + router.stores.setMatches(loadedMatches) + } catch (err) { + let resolvedRedirect = isRedirect(err) + ? router.resolveRedirect(err) + : undefined + if (resolvedRedirect) { + const options = resolvedRedirect.options as any + const statusCode = + options.statusCode ?? + options.code ?? + (resolvedRedirect.status === 200 ? 307 : resolvedRedirect.status) + options.statusCode = statusCode + + if (resolvedRedirect.status !== statusCode) { + const redirectResponse = new Response(null, { + status: statusCode, + headers: resolvedRedirect.headers, + }) as typeof resolvedRedirect + redirectResponse.options = resolvedRedirect.options + redirectResponse.redirectHandled = resolvedRedirect.redirectHandled + resolvedRedirect = redirectResponse + } + } else if (matchedMatches?.length) { + router.stores.loadedAt.set(Date.now()) + router.stores.setMatches(matchedMatches) + } + + router.statusCode = resolvedRedirect + ? (resolvedRedirect.options as any).statusCode + : isNotFound(err) + ? 404 + : router.stores.matches.get().some((d) => d.status === 'error') + ? 500 + : 200 + router.redirect = resolvedRedirect + } finally { + const commitLocationPromise = router.commitLocationPromise + router.latestLoadPromise = undefined + router.commitLocationPromise = undefined + commitLocationPromise?.resolve() + } + + const newStatusCode = router.stores.matches + .get() + .some((d) => d.status === 'notFound' || d.globalNotFound) + ? 404 + : router.stores.matches.get().some((d) => d.status === 'error') + ? 500 + : undefined + if (newStatusCode) { + router.statusCode = newStatusCode + } +} diff --git a/packages/router-core/src/router-preload.client.ts b/packages/router-core/src/router-preload.client.ts new file mode 100644 index 0000000000..1845e94729 --- /dev/null +++ b/packages/router-core/src/router-preload.client.ts @@ -0,0 +1,88 @@ +import { isNotFound } from './not-found' +import { isRedirect } from './redirect' +import { loadClientMatches } from './load-matches.client' +import { projectClientRouteAssets } from './route-assets.client' +import { settleMatchLoad } from './load-matches' +import { isPromise } from './utils' +import type { AnyRouteMatch } from './Matches' +import type { AnyRouter } from './router' +import type { InnerLoadContext } from './load-matches' + +export const preloadClientRoute = async ( + router: AnyRouter, + opts: any, +): Promise | undefined> => { + const next = opts._builtLocation ?? router.buildLocation(opts) + + let matches = router.matchRoutes(next, { + throwOnError: true, + preload: true, + }) + + const loadContext: InnerLoadContext = { + router, + matches, + location: next, + preload: router.stores.matchesId + .get() + .concat(router.stores.pendingIds.get()), + } + + try { + matches = await loadClientMatches(loadContext) + + if (matches.every((match) => match.status === 'success')) { + const assets = projectClientRouteAssets(router, matches, true) + if (isPromise(assets)) { + await assets + } + + let ownedMatches: Array | undefined + for (const match of matches) { + if (match.preload && !router.getMatch(match.id, false)) { + ;(ownedMatches ||= []).push(match) + } + } + if (ownedMatches) { + router.stores.setCached([ + ...router.stores.cachedMatches + .get() + .filter( + (cachedMatch) => + !ownedMatches.some((match) => match.id === cachedMatch.id), + ), + ...ownedMatches, + ]) + } + } + + return matches + } catch (err) { + if (err === loadContext) { + // The preload lane lost ownership while borrowing active/pending matches. + // Do not project assets or cache speculative descendants for this pass. + return + } + if (isRedirect(err)) { + if (err.options.reloadDocument) { + return + } + + return preloadClientRoute(router, { + ...err.options, + _fromLocation: next, + }) + } + if (process.env.NODE_ENV !== 'production' && !isNotFound(err)) { + // Preload errors are not fatal, but we should still log them + console.error(err) + } + return + } finally { + for (const match of matches) { + if (match.preload) { + settleMatchLoad(match) + } + } + } +} diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 2197dab737..24cc25ce3e 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -35,8 +35,13 @@ import { isNotFound } from './not-found' 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 { isRedirect } from './redirect' +import { getLocationChangeInfo } from './location-change' +import { settleMatchLoad } from './load-matches' +import { preloadClientRoute } from './router-preload.client' +import { loadClientRouter } from './router-load.client' +import { loadServerRouter } from './router-load.server' +import { loadRouteChunk, routeNeedsPreload } from './route-chunks' import { composeRewrites, executeRewriteInput, @@ -51,6 +56,7 @@ import type { } from './new-process-route-tree' import type { SearchParser, SearchSerializer } from './searchParams' import type { AnyRedirect, ResolvedRedirect } from './redirect' +import type { BackgroundLoad } from './load-matches' import type { HistoryAction, HistoryLocation, @@ -81,6 +87,7 @@ import type { SearchMiddleware, SearchMiddlewareMeta, } from './route' + import type { FullSearchSchema, RouteById, @@ -106,7 +113,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, @@ -544,14 +550,10 @@ export interface RouterState< in out TRouteMatch = MakeRouteMatchUnion, > { status: 'pending' | 'idle' - loadedAt: number isLoading: boolean - isTransitioning: boolean matches: Array location: ParsedLocation> resolvedLocation?: ParsedLocation> - statusCode: number - redirect?: AnyRedirect } export interface BuildNextOptions { @@ -775,7 +777,14 @@ export interface MatchRoutesFn { ): Array } -export type GetMatchFn = (matchId: string) => AnyRouteMatch | undefined +/** + * 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, + includeCached?: boolean, +) => AnyRouteMatch | undefined export type UpdateMatchFn = ( id: string, @@ -842,6 +851,24 @@ export type AnyRouterWithContext = RouterCore< export type AnyRouter = RouterCore +const loadRouter = + isServer === true + ? loadServerRouter + : isServer === false + ? loadClientRouter + : (router: AnyRouter, opts?: Parameters[0]) => + router.isServer + ? loadServerRouter(router, opts) + : loadClientRouter(router, opts) + +const preloadRouterRoute = + isServer === true + ? async () => undefined + : isServer === false + ? preloadClientRoute + : (router: AnyRouter, opts: any) => + router.isServer ? undefined : preloadClientRoute(router, opts) + export interface ViewTransitionOptions { types: | Array @@ -888,27 +915,6 @@ export const trailingSlashOptions = { export type TrailingSlashOption = (typeof trailingSlashOptions)[keyof typeof trailingSlashOptions] -/** - * Compute whether path, href or hash changed between previous and current - * resolved locations. - */ -export function getLocationChangeInfo( - location: ParsedLocation, - resolvedLocation?: ParsedLocation, -) { - const fromLocation = resolvedLocation - const toLocation = location - const pathChanged = fromLocation?.pathname !== toLocation.pathname - const hrefChanged = fromLocation?.href !== toLocation.href - const hashChanged = fromLocation?.hash !== toLocation.hash - return { fromLocation, toLocation, pathChanged, hrefChanged, hashChanged } -} - -export const locationHistoryActions = new WeakMap< - ParsedLocation, - HistoryAction ->() - type LightweightRouteMatchResult = { matchedRoutes: ReadonlyArray fullPath: string @@ -982,10 +988,9 @@ export class RouterCore< restoration?: boolean reset?: boolean } = { next: true } - shouldViewTransition?: boolean | ViewTransitionOptions = undefined - isViewTransitionTypesSupported?: boolean = undefined + shouldViewTransition?: boolean | ViewTransitionOptions + isViewTransitionTypesSupported?: boolean subscribers = new Set>() - viewTransitionPromise?: ControlledPromise // Must build in constructor stores!: RouterStores @@ -1007,6 +1012,8 @@ export class RouterCore< origin?: string latestLocation!: ParsedLocation> pendingBuiltLocation?: ParsedLocation> + declare redirect: AnyRedirect | undefined + declare statusCode: number basepath!: string routeTree!: TRouteTree routesById!: RoutesById @@ -1054,6 +1061,14 @@ export class RouterCore< if (typeof document !== 'undefined') { self.__TSR_ROUTER__ = this } + + if (isServer ?? this.isServer) { + this.statusCode = 200 + // Server instances read these from per-request options. Client instances + // keep the prototype fallbacks below, which always return false. + this.isShell = () => !!this.options.isShell + this.isPrerendering = () => !!this.options.isPrerendering + } } // This is a default implementation that can optionally be overridden @@ -1061,12 +1076,13 @@ export class RouterCore< // router can be used in a non-react environment if necessary startTransition: StartTransitionFn = (fn) => fn() + // Client fallbacks. Server routers shadow these methods in the constructor. isShell() { - return !!this.options.isShell + return false } isPrerendering() { - return !!this.options.isPrerendering + return false } update: UpdateFn< @@ -1169,7 +1185,14 @@ export class RouterCore< const config = this.getStoreConfig(this) this.batch = config.batch this.stores = createRouterStores( - getInitialRouterState(this.latestLocation), + { + loadedAt: 0, + isLoading: false, + status: 'idle', + resolvedLocation: undefined, + location: this.latestLocation, + matches: [], + }, config, ) @@ -1178,7 +1201,6 @@ export class RouterCore< } } - let needsLocationUpdate = false const nextBasepath = this.options.basepath ?? '/' const nextRewriteOption = this.options.rewrite const basepathChanged = basepathWasUnset || prevBasepath !== nextBasepath @@ -1201,31 +1223,15 @@ 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 ( - typeof window !== 'undefined' && - 'CSS' in window && - typeof window.CSS?.supports === 'function' - ) { - this.isViewTransitionTypesSupported = window.CSS.supports( - 'selector(:active-view-transition-type(a))', - ) + if (this.stores) { + this.stores.location.set(this.latestLocation) + } } } @@ -1250,6 +1256,7 @@ export class RouterCore< }) }, ) + if (this.options.routeMasks) { processRouteMasks(this.options.routeMasks, result.processedTree) } @@ -1408,10 +1415,6 @@ export class RouterCore< return branch } - get looseRoutesById() { - return this.routesById as Record - } - matchRoutes: MatchRoutesFn = ( pathnameOrNext: string | ParsedLocation, locationSearchOrOpts?: AnySchema | MatchRoutesOpts, @@ -1430,24 +1433,16 @@ export class RouterCore< return this.matchRoutesInternal(pathnameOrNext, locationSearchOrOpts) } - 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 - } - private matchRoutesInternal( next: ParsedLocation, opts?: MatchRoutesOpts, ): Array { + const throwOnError = opts?.throwOnError + const preload = opts?.preload === true const matchedRoutesResult = this.getMatchedRoutes(next.pathname) const { foundRoute, routeParams } = matchedRoutesResult let { matchedRoutes } = matchedRoutesResult - let isGlobalNotFound = false + let globalNotFoundRouteId: string | undefined // Check to see if the route needs a 404 entry if ( @@ -1462,26 +1457,26 @@ export class RouterCore< matchedRoutes = [...matchedRoutes, this.options.notFoundRoute] } else { // If there is no routes found during path matching - isGlobalNotFound = true + globalNotFoundRouteId = rootRouteId + if (this.options.notFoundMode !== 'root') { + for (let i = matchedRoutes.length - 1; i >= 0; i--) { + const route = matchedRoutes[i]! + if (route.children) { + globalNotFoundRouteId = route.id + break + } + } + } } } - const globalNotFoundRouteId = isGlobalNotFound - ? findGlobalNotFoundRouteId(this.options.notFoundMode, matchedRoutes) - : 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 matches: Array = [] + const previousActiveMatches = this.stores.matches.get() + const rootContext = this.options.context ?? {} for (let index = 0; index < matchedRoutes.length; index++) { const route = matchedRoutes[index]! + const routeOptions = route.options // Take each matched route and resolve + validate its search params // This has to happen serially because each route's search params // can depend on the parent route's search params @@ -1494,14 +1489,15 @@ export class RouterCore< let preMatchSearch: Record let strictMatchSearch: Record let searchError: any + { // Validate the search params and stabilize them const parentSearch = parentMatch?.search ?? next.search - const parentStrictSearch = parentMatch?._strictSearch ?? undefined + const parentStrictSearch = parentMatch?._strictSearch try { const strictSearch = - validateSearch(route.options.validateSearch, { ...parentSearch }) ?? + validateSearch(routeOptions.validateSearch, { ...parentSearch }) ?? undefined preMatchSearch = { @@ -1509,16 +1505,18 @@ export class RouterCore< ...strictSearch, } strictMatchSearch = { ...parentStrictSearch, ...strictSearch } - searchError = undefined } catch (err: any) { let searchParamError = err if (!(err instanceof SearchParamError)) { - searchParamError = new SearchParamError(err.message, { - cause: err, - }) + searchParamError = new SearchParamError( + err?.message ?? String(err), + { + cause: err, + }, + ) } - if (opts?.throwOnError) { + if (throwOnError) { throw searchParamError } @@ -1534,7 +1532,7 @@ export class RouterCore< // potential key function which is used to uniquely identify the route match in state const loaderDeps = - route.options.loaderDeps?.({ + routeOptions.loaderDeps?.({ search: preMatchSearch, }) ?? '' @@ -1544,7 +1542,7 @@ export class RouterCore< path: route.fullPath, params: routeParams, decoder: this.pathParamsDecoder, - server: this.isServer, + ...(isServer === undefined ? { server: this.isServer } : undefined), }) // Waste not, want not. If we already have a match for this route, @@ -1562,12 +1560,14 @@ export class RouterCore< loaderDepsHash const existingMatch = this.getMatch(matchId) - - const previousMatch = previousActiveMatchesByRouteId.get(route.id) + const previousMatch = + previousActiveMatches[index]?.routeId === route.id + ? previousActiveMatches[index] + : undefined const strictParams = existingMatch?._strictParams ?? usedParams - let paramsError: unknown = undefined + let paramsError: unknown if (!existingMatch) { try { @@ -1576,12 +1576,12 @@ export class RouterCore< if (isNotFound(err) || isRedirect(err)) { paramsError = err } else { - paramsError = new PathParamError(err.message, { + paramsError = new PathParamError(err?.message ?? String(err), { cause: err, }) } - if (opts?.throwOnError) { + if (throwOnError) { throw paramsError } } @@ -1589,133 +1589,117 @@ export class RouterCore< Object.assign(routeParams, strictParams) - const cause = previousMatch ? 'stay' : 'enter' + const cause = preload ? 'preload' : previousMatch ? 'stay' : 'enter' + const parentContext = parentMatch?.context ?? rootContext + const search = previousMatch + ? nullReplaceEqualDeep(previousMatch.search, preMatchSearch) + : existingMatch + ? nullReplaceEqualDeep(existingMatch.search, preMatchSearch) + : preMatchSearch + const abortController = new AbortController() let match: AnyRouteMatch if (existingMatch) { + const loadPromise = existingMatch._.loadPromise match = { ...existingMatch, cause, - params: previousMatch?.params ?? routeParams, + params: routeParams, _strictParams: strictParams, - search: previousMatch - ? nullReplaceEqualDeep(previousMatch.search, preMatchSearch) - : nullReplaceEqualDeep(existingMatch.search, preMatchSearch), + abortController, + _: loadPromise + ? { + loadPromise, + } + : existingMatch._.dehydrated + ? { + dehydrated: true, + } + : {}, + search, _strictSearch: strictMatchSearch, + searchError, + preload, } } else { - const status = - route.options.loader || - route.options.beforeLoad || - route.lazyFn || - routeNeedsPreload(route) - ? 'pending' - : 'success' + const pending = + !(isServer ?? this.isServer) && + (routeOptions.loader || + routeOptions.beforeLoad || + route.lazyFn || + routeNeedsPreload(route)) match = { id: matchId, - ssr: (isServer ?? this.isServer) ? undefined : route.options.ssr, + ssr: routeOptions.ssr, index, routeId: route.id, - params: previousMatch?.params ?? routeParams, + params: routeParams, _strictParams: strictParams, pathname: interpolatedPath, updatedAt: Date.now(), - search: previousMatch - ? nullReplaceEqualDeep(previousMatch.search, preMatchSearch) - : preMatchSearch, + search, _strictSearch: strictMatchSearch, - searchError: undefined, - status, + searchError, + status: pending ? 'pending' : 'success', isFetching: false, - error: undefined, paramsError, - __routeContext: undefined, - _nonReactive: { - loadPromise: createControlledPromise(), - }, - __beforeLoadContext: undefined, - context: {}, - abortController: new AbortController(), - fetchCount: 0, + _: pending + ? { + loadPromise: createControlledPromise(), + } + : {}, + abortController, cause, - loaderDeps: previousMatch - ? replaceEqualDeep(previousMatch.loaderDeps, loaderDeps) - : loaderDeps, + loaderDeps, invalid: false, - preload: false, - links: undefined, - scripts: undefined, - headScripts: undefined, - meta: undefined, - staticData: route.options.staticData || {}, + preload, + staticData: routeOptions.staticData || {}, fullPath: route.fullPath, - } + } as AnyRouteMatch } - 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 } - // update the searchError if there is one - match.searchError = searchError - - const parentContext = this.getParentContext(parentMatch) - - match.context = { - ...parentContext, - ...match.__routeContext, - ...match.__beforeLoadContext, - } - matches[index] = match - } - - for (let index = 0; index < matches.length; index++) { - const match = matches[index]! - const route = this.looseRoutesById[match.routeId]! - const existingMatch = this.getMatch(match.id) - // Update the match's params - const previousMatch = previousActiveMatchesByRouteId.get(match.routeId) - match.params = previousMatch - ? nullReplaceEqualDeep(previousMatch.params, routeParams) - : routeParams - - if (!existingMatch) { - const parentMatch = matches[index - 1] - const parentContext = this.getParentContext(parentMatch) - - // Update the match's context - - if (route.options.context) { - const contextFnContext: RouteContextOptions = - { - deps: match.loaderDeps, - params: match.params, - context: parentContext ?? {}, - location: next, - navigate: (opts: any) => - this.navigate({ ...opts, _fromLocation: next }), - buildLocation: this.buildLocation, - cause: match.cause, - abortController: match.abortController, - preload: !!match.preload, - matches, - routeId: route.id, - } - // Get the route context - match.__routeContext = - route.options.context(contextFnContext) ?? undefined - } + if (!existingMatch && routeOptions.context) { + match.__routeContext = routeOptions.context({ + deps: match.loaderDeps, + params: match.params, + context: parentContext, + location: next, + navigate: (opts: any) => + this.navigate({ ...opts, _fromLocation: next }), + buildLocation: this.buildLocation, + cause: match.cause, + abortController: match.abortController, + preload, + matches, + routeId: route.id, + } as RouteContextOptions) + } + if (match.__routeContext || match.__beforeLoadContext) { match.context = { ...parentContext, ...match.__routeContext, ...match.__beforeLoadContext, } + } else { + match.context = parentContext + } + } + + for (let i = 0; i < matches.length; i++) { + const previousMatch = previousActiveMatches[i] + const match = matches[i]! + if (previousMatch && previousMatch.routeId === match.routeId) { + match.params = nullReplaceEqualDeep(previousMatch.params, routeParams) } } @@ -1808,35 +1792,26 @@ export class RouterCore< return result } - cancelMatch = (id: string) => { - const match = this.getMatch(id) - - if (!match) return - - match.abortController.abort() - clearTimeout(match._nonReactive.pendingTimeout) - match._nonReactive.pendingTimeout = undefined - } + cancelMatches() { + const cancelMatch = (matchId: string) => { + const match = this.getMatch(matchId) + if (match) { + match.abortController.abort() + settleMatchLoad(match) + } + } - cancelMatches = () => { - this.stores.pendingIds.get().forEach((matchId) => { - this.cancelMatch(matchId) - }) + for (const matchId of this.stores.pendingIds.get()) { + cancelMatch(matchId) + } - this.stores.matchesId.get().forEach((matchId) => { + for (const matchId of this.stores.matchesId.get()) { if (this.stores.pendingMatchStores.has(matchId)) { - return - } - - const match = this.stores.matchStores.get(matchId)?.get() - if (!match) { - return + continue } - if (match.status === 'pending' || match.isFetching === 'loader') { - this.cancelMatch(matchId) - } - }) + cancelMatch(matchId) + } } /** @@ -1898,10 +1873,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 +1946,9 @@ export class RouterCore< path: nextTo, params: nextParams, decoder: this.pathParamsDecoder, - server: this.isServer, + ...(isServer === undefined + ? { server: this.isServer } + : undefined), }).interpolatedPath, ).path @@ -2227,11 +2204,7 @@ export class RouterCore< }, } - if ( - nextHistory.unmaskOnReload ?? - this.options.unmaskOnReload ?? - false - ) { + if (nextHistory.unmaskOnReload ?? this.options.unmaskOnReload) { nextHistory.state.__tempKey = this.tempLocationKey } } @@ -2341,7 +2314,7 @@ export class RouterCore< if (href) { try { - new URL(`${href}`) + new URL(href) hrefIsUrl = true } catch {} } @@ -2412,346 +2385,72 @@ export class RouterCore< }) } - latestLoadPromise: undefined | Promise - - beforeLoad = () => { - // Cancel any pending matches - this.cancelMatches() - this.updateLatestLocation() - - if (isServer ?? this.isServer) { - // for SPAs on the initial load, this is handled by the Transitioner - const nextLocation = this.buildLocation({ - to: this.latestLocation.pathname, - search: true, - params: true, - hash: true, - state: true, - _includeValidateSearch: true, - }) - - // Check if location changed - origin check is unnecessary since buildLocation - // always uses this.origin when constructing URLs - if (this.latestLocation.publicHref !== nextLocation.publicHref) { - const href = this.getParsedLocationHref(nextLocation) - if (nextLocation.external) { - throw redirect({ href }) - } else { - throw redirect({ href, _builtLocation: nextLocation }) - } - } - } - - // Match the routes - const pendingMatches = this.matchRoutes(this.latestLocation) - - const nextCachedMatches = this.stores.cachedMatches - .get() - .filter((d) => !pendingMatches.some((e) => e.id === d.id)) - - // Ingest the new matches - this.batch(() => { - this.stores.status.set('pending') - this.stores.statusCode.set(200) - this.stores.isLoading.set(true) - this.stores.location.set(this.latestLocation) - this.stores.setPending(pendingMatches) - // If a cached match moved to pending matches, remove it from cached matches - this.stores.setCached(nextCachedMatches) - }) - } - - load: LoadFn = async (opts): Promise => { - const historyAction = opts?.action?.type - let redirect: AnyRedirect | undefined - let notFound: NotFoundError | undefined - let loadPromise: Promise - 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.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, - // eslint-disable-next-line @typescript-eslint/require-await - onReady: async () => { - // 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(() => { - 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), - ) - : 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) { - this.looseRoutesById[match.routeId]!.options[hook]?.( - match, - ) - } - } - }) - }) - }, - }) - } catch (err) { - if (isRedirect(err)) { - redirect = err - if (!(isServer ?? this.isServer)) { - this.navigate({ - ...redirect.options, - replace: true, - ignoreBlocker: true, - }) - } - } else if (isNotFound(err)) { - notFound = err - } - - const nextStatusCode = redirect - ? redirect.status - : notFound - ? 404 - : this.stores.matches.get().some((d) => d.status === 'error') - ? 500 - : 200 - - this.batch(() => { - this.stores.statusCode.set(nextStatusCode) - this.stores.redirect.set(redirect) - }) - } - - if (this.latestLoadPromise === loadPromise) { - this.commitLocationPromise?.resolve() - this.latestLoadPromise = undefined - this.commitLocationPromise = undefined - } - - resolve() - }) - }) - - this.latestLoadPromise = loadPromise - - await loadPromise + declare latestLoadPromise: undefined | ControlledPromise + declare _backgroundLoad: BackgroundLoad | undefined - while ( - (this.latestLoadPromise as any) && - 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) { - this.stores.statusCode.set(newStatusCode) - } + load: LoadFn = async (opts) => { + await loadRouter(this, opts) } - 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 + return fn() + } + if (typeof shouldViewTransition === 'object') { if ( - typeof shouldViewTransition === 'object' && - this.isViewTransitionTypesSupported + !(this.isViewTransitionTypesSupported ??= + typeof CSS !== 'undefined' && + CSS.supports?.('selector(:active-view-transition-type(a))')) ) { - 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 - } + 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) => { - 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 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), - ) - } - } else { - cachedMatch.set(next) - } - } - }) + const matchStore = + this.stores.pendingMatchStores.get(id) ?? this.stores.matchStores.get(id) + matchStore?.set(updater) } - getMatch: GetMatchFn = (matchId: string): AnyRouteMatch | undefined => { + getMatch: GetMatchFn = (matchId, includeCached) => { + const matchStore = + this.stores.pendingMatchStores.get(matchId) ?? + this.stores.matchStores.get(matchId) return ( - this.stores.cachedMatchStores.get(matchId)?.get() ?? - this.stores.pendingMatchStores.get(matchId)?.get() ?? - this.stores.matchStores.get(matchId)?.get() - ) + includeCached === false + ? matchStore + : (matchStore ?? this.stores.cachedMatchStores.get(matchId)) + )?.get() } /** @@ -2771,7 +2470,7 @@ export class RouterCore< TDehydrated > > = (opts) => { - const invalidate = (d: MakeRouteMatch) => { + const invalidateLive = (d: MakeRouteMatch) => { if (opts?.filter?.(d as MakeRouteMatchUnion) ?? true) { return { ...d, @@ -2785,39 +2484,50 @@ export class RouterCore< } return d } + const invalidateCached = (d: MakeRouteMatch) => { + if (opts?.filter?.(d as MakeRouteMatchUnion) ?? true) { + return { + ...d, + invalid: true, + } + } + return d + } this.batch(() => { - this.stores.setMatches(this.stores.matches.get().map(invalidate)) - this.stores.setCached(this.stores.cachedMatches.get().map(invalidate)) - this.stores.setPending(this.stores.pendingMatches.get().map(invalidate)) + this.stores.setMatches(this.stores.matches.get().map(invalidateLive)) + this.stores.setCached( + this.stores.cachedMatches.get().map(invalidateCached), + ) + this.stores.setPending( + this.stores.pendingMatches.get().map(invalidateLive), + ) }) this.shouldViewTransition = false return this.load({ sync: opts?.sync }) } - getParsedLocationHref = (location: ParsedLocation) => { - // For redirects and external use, we need publicHref (with rewrite output applied) - // href is the internal path after rewrite input, publicHref is user-facing - return location.publicHref || '/' - } - resolveRedirect = (redirect: AnyRedirect): AnyRedirect => { - const locationHeader = redirect.headers.get('Location') - - if (!redirect.options.href || redirect.options._builtLocation) { - const location = - redirect.options._builtLocation ?? this.buildLocation(redirect.options) - const href = this.getParsedLocationHref(location) - redirect.options.href = href - redirect.headers.set('Location', href) - } else if (locationHeader) { + const options = redirect.options + + if (!options.href || options._builtLocation) { + const location = options._builtLocation ?? this.buildLocation(options) + const href = location.publicHref || '/' + options.href = href + if (isServer === true || (isServer === undefined && this.isServer)) { + redirect.headers.set('Location', href) + } + } else if (isServer === true || (isServer === undefined && 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 +2535,22 @@ export class RouterCore< } if ( - redirect.options.href && - !redirect.options._builtLocation && + !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 === true || (isServer === undefined && this.isServer)) && + !redirect.headers.get('Location') + ) { + redirect.headers.set('Location', options.href) } return redirect @@ -2846,42 +2558,16 @@ 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 = [] - clearExpiredCache = () => { - const now = Date.now() - // This is where all of the garbage collection magic happens - const filter = (d: MakeRouteMatch) => { - const route = this.looseRoutesById[d.routeId]! - - if (!route.options.loader) { - return true + for (const match of cachedMatches) { + if (filter && !filter(match as MakeRouteMatchUnion)) { + nextCachedMatches.push(match) } - - // If the route was preloaded, use the preloadGcTime - // otherwise, use the gcTime - const gcTime = - (d.preload - ? (route.options.preloadGcTime ?? this.options.defaultPreloadGcTime) - : (route.options.gcTime ?? this.options.defaultGcTime)) ?? - 5 * 60 * 1000 - - const isError = d.status === 'error' - if (isError) return true - - const gcEligible = now - d.updatedAt >= gcTime - return gcEligible } - this.clearCache({ filter }) + + this.stores.setCached(nextCachedMatches) } loadRouteChunk = loadRouteChunk @@ -2892,67 +2578,7 @@ export class RouterCore< TDefaultStructuralSharingOption, TRouterHistory > = async (opts) => { - const next = opts._builtLocation ?? this.buildLocation(opts as any) - - let matches = this.matchRoutes(next, { - throwOnError: true, - preload: true, - dest: opts, - }) - - const activeMatchIds = new Set([ - ...this.stores.matchesId.get(), - ...this.stores.pendingIds.get(), - ]) - - const loadedMatchIds = new Set([ - ...activeMatchIds, - ...this.stores.cachedIds.get(), - ]) - - // If the matches are already loaded, we need to add them to the cached matches. - const matchesToCache = matches.filter( - (match) => !loadedMatchIds.has(match.id), - ) - if (matchesToCache.length) { - const cachedMatches = this.stores.cachedMatches.get() - this.stores.setCached([...cachedMatches, ...matchesToCache]) - } - - try { - matches = await loadMatches({ - router: this, - matches, - location: next, - preload: true, - updateMatch: (id, updater) => { - // Don't update the match if it's currently loaded - if (activeMatchIds.has(id)) { - matches = matches.map((d) => (d.id === id ? updater(d) : d)) - } else { - this.updateMatch(id, updater) - } - }, - }) - - return matches - } catch (err) { - if (isRedirect(err)) { - if (err.options.reloadDocument) { - return undefined - } - - return await this.preloadRoute({ - ...err.options, - _fromLocation: next, - }) - } - if (!isNotFound(err)) { - // Preload errors are not fatal, but we should still log them - console.error(err) - } - return undefined - } + return preloadRouterRoute(this, opts) } matchRoute: MatchRouteFn< @@ -3016,12 +2642,6 @@ export class RouterCore< serverSsr?: ServerSsr serverSsrLifecycle?: RouterSsrLifecycle - - hasNotFoundMatch = () => { - return this.stores.matches - .get() - .some((d) => d.status === 'notFound' || d.globalNotFound) - } } /** Error thrown when search parameter validation fails. */ @@ -3052,22 +2672,6 @@ export function lazyFn< } } -/** Create an initial RouterState from a parsed location. */ -export function getInitialRouterState( - location: ParsedLocation, -): RouterState { - return { - loadedAt: 0, - isLoading: false, - isTransitioning: false, - status: 'idle', - resolvedLocation: undefined, - location, - matches: [], - statusCode: 200, - } -} - function validateSearch(validateSearch: AnyValidator, input: unknown): unknown { if (validateSearch == null) return {} @@ -3249,21 +2853,6 @@ function buildMiddlewareChain(destRoutes: ReadonlyArray) { } } -function findGlobalNotFoundRouteId( - notFoundMode: 'root' | 'fuzzy' | undefined, - routes: ReadonlyArray, -) { - if (notFoundMode !== 'root') { - for (let i = routes.length - 1; i >= 0; i--) { - const route = routes[i]! - if (route.children) { - return route.id - } - } - } - return rootRouteId -} - function extractStrictParams( route: AnyRoute, accumulatedParams: Record, diff --git a/packages/router-core/src/scroll-restoration.ts b/packages/router-core/src/scroll-restoration.ts index 6287a6fce0..ab71bc07bc 100644 --- a/packages/router-core/src/scroll-restoration.ts +++ b/packages/router-core/src/scroll-restoration.ts @@ -1,5 +1,5 @@ import { isServer } from '@tanstack/router-core/isServer' -import { locationHistoryActions } from './router' +import { locationHistoryActions } from './location-change' import type { AnyRouter } from './router' import type { ParsedLocation } from './location' diff --git a/packages/router-core/src/ssr/createRequestHandler.ts b/packages/router-core/src/ssr/createRequestHandler.ts index 32377b2bea..091269aa46 100644 --- a/packages/router-core/src/ssr/createRequestHandler.ts +++ b/packages/router-core/src/ssr/createRequestHandler.ts @@ -84,7 +84,7 @@ function getRequestHeaders(opts: { router: AnyRouter }): Headers { } // Handle Redirects - const redirect = opts.router.stores.redirect.get() + const redirect = opts.router.redirect if (redirect) { matchHeaders.push(redirect.headers) } diff --git a/packages/router-core/src/ssr/ssr-client.ts b/packages/router-core/src/ssr/ssr-client.ts index 2aa5358ac0..299e31172b 100644 --- a/packages/router-core/src/ssr/ssr-client.ts +++ b/packages/router-core/src/ssr/ssr-client.ts @@ -1,9 +1,9 @@ import { invariant } from '../invariant' +import { getMatchContext, settleMatchLoad } from '../load-matches' import { isNotFound } from '../not-found' -import { createControlledPromise } from '../utils' import { hydrateSsrMatchId } from './ssr-match-id' import type { GLOBAL_SEROVAL, GLOBAL_TSR } from './constants' -import type { DehydratedMatch, TsrSsrGlobal } from './types' +import type { TsrSsrGlobal } from './types' import type { AnyRouteMatch } from '../Matches' import type { AnyRouter } from '../router' import type { RouteContextOptions } from '../route' @@ -16,29 +16,9 @@ declare global { } } -function hydrateMatch( - match: AnyRouteMatch, - deyhydratedMatch: DehydratedMatch, -): void { - match.id = deyhydratedMatch.i - match.__beforeLoadContext = deyhydratedMatch.b - match.loaderData = deyhydratedMatch.l - match.status = deyhydratedMatch.s - match.ssr = deyhydratedMatch.ssr - match.updatedAt = deyhydratedMatch.u - match.error = deyhydratedMatch.e - // Only hydrate global-not-found when a defined value is present in the - // dehydrated payload. If omitted, preserve the value computed from the - // current client location (important for SPA fallback HTML served at unknown - // URLs, where dehydrated matches may come from `/` but client matching marks - // root as globalNotFound). - if (deyhydratedMatch.g !== undefined) { - match.globalNotFound = deyhydratedMatch.g - } -} - export async function hydrate(router: AnyRouter): Promise { - if (!window.$_TSR) { + const tsr = window.$_TSR + if (!tsr) { if (process.env.NODE_ENV !== 'production') { throw new Error( 'Invariant failed: Expected to find bootstrap data on window.$_TSR, but we did not. Please file an issue!', @@ -54,15 +34,18 @@ export async function hydrate(router: AnyRouter): Promise { if (serializationAdapters?.length) { const fromSerializableMap = new Map() - serializationAdapters.forEach((adapter) => { + for (const adapter of serializationAdapters) { fromSerializableMap.set(adapter.key, adapter.fromSerializable) - }) - window.$_TSR.t = fromSerializableMap - window.$_TSR.buffer.forEach((script) => script()) + } + tsr.t = fromSerializableMap + for (const script of tsr.buffer) { + script() + } } - window.$_TSR.initialized = true + tsr.initialized = true - if (!window.$_TSR.router) { + const dehydratedRouter = tsr.router + if (!dehydratedRouter) { if (process.env.NODE_ENV !== 'production') { throw new Error( 'Invariant failed: Expected to find a dehydrated data on window.$_TSR.router, but we did not. Please file an issue!', @@ -72,17 +55,10 @@ export async function hydrate(router: AnyRouter): Promise { invariant() } - const dehydratedRouter = window.$_TSR.router - dehydratedRouter.matches.forEach((dehydratedMatch) => { - dehydratedMatch.i = hydrateSsrMatchId(dehydratedMatch.i) - }) - if (dehydratedRouter.lastMatchId) { - dehydratedRouter.lastMatchId = hydrateSsrMatchId( - dehydratedRouter.lastMatchId, - ) - } - const { manifest, dehydratedData, lastMatchId } = dehydratedRouter - + const lastMatchId = dehydratedRouter.lastMatchId + ? hydrateSsrMatchId(dehydratedRouter.lastMatchId) + : undefined + const { manifest, dehydratedData } = dehydratedRouter router.ssr = { manifest, } @@ -96,99 +72,177 @@ export async function hydrate(router: AnyRouter): Promise { // Allow the user to handle custom hydration data before matching routes. // This lets hydration install router config that affects matching, e.g. rewrites. + // Do not alias `router.options` across this hook: it may call `router.update`, + // and the rest of hydration must observe the updated options. await router.options.hydrate?.(dehydratedData) // Hydrate the router state - const matches = router.matchRoutes(router.stores.location.get()) + const location = router.stores.location.get() + const matches = router.matchRoutes(location) + const routesById = router.routesById - // kick off loading the route chunks + // Right after hydration and before the first render, we need to rehydrate each match + // First step is to rehydrate loaderData and beforeLoad context. + let firstNonSsrMatch: AnyRouteMatch | undefined = undefined + let hasSsrFalseMatches = false const routeChunkPromise = Promise.all( - matches.map((match) => - router.loadRouteChunk(router.looseRoutesById[match.routeId]!), - ), - ) + matches.map((match) => { + const route = routesById[match.routeId]! - function setMatchForcePending(match: AnyRouteMatch) { - // usually the minPendingPromise is created in the Match component if a pending match is rendered - // however, this might be too late if the match synchronously resolves - const route = router.looseRoutesById[match.routeId]! - const pendingMinMs = - route.options.pendingMinMs ?? router.options.defaultPendingMinMs - if (pendingMinMs) { - const minPendingPromise = createControlledPromise() - match._nonReactive.minPendingPromise = minPendingPromise - match._forcePending = true - - setTimeout(() => { - minPendingPromise.resolve() - // We've handled the minPendingPromise, so we can delete it - router.updateMatch(match.id, (prev) => { - prev._nonReactive.minPendingPromise = undefined - return { - ...prev, - _forcePending: undefined, - } - }) - }, pendingMinMs) - } - } + const dehydratedMatch = dehydratedRouter.matches.find( + (dehydrated) => hydrateSsrMatchId(dehydrated.i) === match.id, + ) + if (!dehydratedMatch) { + match.ssr = false + } else { + match.__beforeLoadContext = dehydratedMatch.b + match.loaderData = dehydratedMatch.l + match.status = dehydratedMatch.s + match.ssr = dehydratedMatch.ssr + match.updatedAt = dehydratedMatch.u + match.error = dehydratedMatch.e + + // Only hydrate global-not-found when a defined value is present in the + // dehydrated payload. If omitted, preserve the value computed from the + // current client location. + if (dehydratedMatch.g !== undefined) { + match.globalNotFound = dehydratedMatch.g + } + } - function setRouteSsr(match: AnyRouteMatch) { - const route = router.looseRoutesById[match.routeId] - if (route) { route.options.ssr = match.ssr + + match._.dehydrated = match.ssr !== false + + if (match.ssr === false) { + match.status = 'pending' + match.error = undefined + hasSsrFalseMatches = true + if (!firstNonSsrMatch) { + firstNonSsrMatch = match + } + } else if (!firstNonSsrMatch && match.ssr === 'data-only') { + firstNonSsrMatch = match + } + + return router.loadRouteChunk(route) + }), + ) + const loadContext = { router, matches } + + const isSpaMode = matches[matches.length - 1]!.id !== lastMatchId + + let displayMatch = isSpaMode ? matches[1] : firstNonSsrMatch + let displayUntil = 0 + + if (isSpaMode && !displayMatch) { + if (process.env.NODE_ENV !== 'production') { + throw new Error( + 'Invariant failed: Expected to find a match below the root match in SPA mode.', + ) } + + invariant() } - // Right after hydration and before the first render, we need to rehydrate each match - // First step is to reyhdrate loaderData and __beforeLoadContext - let firstNonSsrMatchIndex: number | undefined = undefined - matches.forEach((match) => { - const dehydratedMatch = dehydratedRouter.matches.find( - (d) => d.i === match.id, - ) - if (!dehydratedMatch) { - match._nonReactive.dehydrated = false - match.ssr = false - setRouteSsr(match) - return - } - hydrateMatch(match, dehydratedMatch) - setRouteSsr(match) + if (displayMatch) { + const route = routesById[displayMatch.routeId]! + const pendingComponent = + route.options.pendingComponent ?? + (router.options as any).defaultPendingComponent + + const displayMinMs = pendingComponent + ? (route.options.pendingMinMs ?? router.options.defaultPendingMinMs) + : undefined - match._nonReactive.dehydrated = match.ssr !== false + if (isSpaMode || hasSsrFalseMatches || displayMinMs) { + displayMatch._displayPending = true + settleMatchLoad(displayMatch) - if (match.ssr === 'data-only' || match.ssr === false) { - if (firstNonSsrMatchIndex === undefined) { - firstNonSsrMatchIndex = match.index - setMatchForcePending(match) + if (displayMinMs) { + displayUntil = Date.now() + displayMinMs } + } else { + displayMatch = undefined } - }) + } router.stores.setMatches(matches) + const clearDisplayPending = () => { + let isStillLoadingDisplayMatch = false + if (displayMatch) { + router.updateMatch(displayMatch.id, (match) => { + if ( + !match._displayPending || + (match.status === 'pending' && router.stores.isLoading.get()) + ) { + // A SPA/ssr:false hydration pass can start a follow-up client load + // for this match. Keep the server-visible fallback until that load + // leaves the match's pending state. + isStillLoadingDisplayMatch = !!match._displayPending + return match + } + + settleMatchLoad(match) + return { + ...match, + _displayPending: undefined, + } + }) + } + return isStillLoadingDisplayMatch + } + + const finishDisplayPending = (): void | Promise => { + const remaining = displayUntil - Date.now() + if (remaining > 0) { + return new Promise((resolve) => + setTimeout(resolve, remaining), + ).then(finishDisplayPending) + } + + if (clearDisplayPending()) { + return router.latestLoadPromise?.then(finishDisplayPending) + } + return undefined + } + + const clearAndFailHydration = (err: unknown): never => { + matches.forEach(settleMatchLoad) + clearDisplayPending() + throw err + } + + try { + // Lazy route chunks can install context/head/scripts options. Display + // pending is already published above, so wait here before reconstructing + // hydration state from those route options. + await routeChunkPromise + } catch (err) { + return clearAndFailHydration(err) + } + // now that all necessary data is hydrated: // 1) fully reconstruct the route context // 2) execute `head()` and `scripts()` for each match - const activeMatches = router.stores.matches.get() - const location = router.stores.location.get() - await Promise.all( - activeMatches.map(async (match) => { - try { - const route = router.looseRoutesById[match.routeId]! - - const parentMatch = activeMatches[match.index - 1] - const parentContext = parentMatch?.context ?? router.options.context - - // `context()` was already executed by `matchRoutes`, however route context was not yet fully reconstructed - // so run it again and merge route context - if (route.options.context) { - const contextFnContext: RouteContextOptions = - { + try { + await Promise.all( + matches.map(async (match) => { + try { + const route = routesById[match.routeId]! + route.options.ssr = match.ssr + + // `context()` was already executed by `matchRoutes`, however route context was not yet fully reconstructed + // so run it again and merge route context + if (route.options.context) { + match.__routeContext = route.options.context({ deps: match.loaderDeps, params: match.params, - context: parentContext ?? {}, + context: + matches[match.index - 1]?.context ?? + router.options.context ?? + {}, location, navigate: (opts: any) => router.navigate({ @@ -201,94 +255,83 @@ export async function hydrate(router: AnyRouter): Promise { preload: false, matches, routeId: route.id, - } - match.__routeContext = - route.options.context(contextFnContext) ?? undefined - } - - match.context = { - ...parentContext, - ...match.__routeContext, - ...match.__beforeLoadContext, - } + } as RouteContextOptions) + } - const assetContext = { - ssr: router.options.ssr, - matches: activeMatches, - match, - params: match.params, - loaderData: match.loaderData, - } - const headFnContent = await route.options.head?.(assetContext) - - const scripts = await route.options.scripts?.(assetContext) - - match.meta = headFnContent?.meta - match.links = headFnContent?.links - match.headScripts = headFnContent?.scripts - match.styles = headFnContent?.styles - match.scripts = scripts - } catch (err) { - if (isNotFound(err)) { - match.error = { isNotFound: true } - console.error( - `NotFound error during hydration for routeId: ${match.routeId}`, - err, - ) - } else { - match.error = err as any - console.error( - `Error during hydration for route ${match.routeId}:`, - err, + match.context = getMatchContext( + loadContext, + match.index, + match.__beforeLoadContext, ) - throw err + + const assetContext = { + ssr: router.options.ssr, + matches, + match, + params: match.params, + loaderData: match.loaderData, + } + const headFnContent = await route.options.head?.(assetContext) + + const scripts = await route.options.scripts?.(assetContext) + + match.meta = headFnContent?.meta + match.links = headFnContent?.links + match.headScripts = headFnContent?.scripts + match.styles = headFnContent?.styles + match.scripts = scripts + } catch (err) { + if (isNotFound(err)) { + match.error = { isNotFound: true } + if (process.env.NODE_ENV !== 'production') { + console.error( + `NotFound error during hydration for routeId: ${match.routeId}`, + err, + ) + } + } else { + match.error = err as any + if (process.env.NODE_ENV !== 'production') { + console.error( + `Error during hydration for route ${match.routeId}:`, + err, + ) + } + throw err + } } - } - }), - ) + }), + ) + } catch (err) { + return clearAndFailHydration(err) + } - const isSpaMode = matches[matches.length - 1]!.id !== lastMatchId - const hasSsrFalseMatches = matches.some((m) => m.ssr === false) // all matches have data from the server and we are not in SPA mode so we don't need to kick of router.load() if (!hasSsrFalseMatches && !isSpaMode) { matches.forEach((match) => { // remove the dehydrated flag since we won't run router.load() which would remove it - match._nonReactive.dehydrated = undefined + match._.dehydrated = undefined }) // Mark the current location as resolved so that later load cycles // (e.g. preloads, invalidations) don't mistakenly detect a href change // (resolvedLocation defaults to undefined and router.load() is skipped // in the normal SSR hydration path). - router.stores.resolvedLocation.set(router.stores.location.get()) - return routeChunkPromise + router.stores.resolvedLocation.set(location) + + matches.forEach(settleMatchLoad) + + void finishDisplayPending() + return } - // schedule router.load() to run after the next tick so we can store the promise in the match before loading starts - const loadPromise = Promise.resolve() + void Promise.resolve() .then(() => router.load()) .catch((err) => { - console.error('Error during router hydration:', err) - }) - - // in SPA mode we need to keep the first match below the root route pending until router.load() is finished - // this will prevent that other pending components are rendered but hydration is not blocked - if (isSpaMode) { - const match = matches[1] - if (!match) { if (process.env.NODE_ENV !== 'production') { - throw new Error( - 'Invariant failed: Expected to find a match below the root match in SPA mode.', - ) + console.error('Error during router hydration:', err) } - - invariant() - } - setMatchForcePending(match) - - match._displayPending = true - match._nonReactive.displayPendingPromise = loadPromise - - loadPromise.then(() => { + }) + .then(() => { router.batch(() => { // ensure router is not in status 'pending' anymore // this usually happens in Transitioner but if loading synchronously resolves, @@ -297,14 +340,8 @@ export async function hydrate(router: AnyRouter): Promise { router.stores.status.set('idle') router.stores.resolvedLocation.set(router.stores.location.get()) } - // hide the pending component once the load is finished - router.updateMatch(match.id, (prev) => ({ - ...prev, - _displayPending: undefined, - displayPendingPromise: undefined, - })) }) + + return finishDisplayPending() }) - } - return routeChunkPromise } diff --git a/packages/router-core/src/stores.ts b/packages/router-core/src/stores.ts index c2ab8311c3..910d681e97 100644 --- a/packages/router-core/src/stores.ts +++ b/packages/router-core/src/stores.ts @@ -1,11 +1,9 @@ import { createLRUCache } from './lru-cache' import { arraysEqual, functionalUpdate } from './utils' - import type { AnyRoute } from './route' import type { RouterState } from './router' import type { FullSearchSchema } from './routeInfo' import type { ParsedLocation } from './location' -import type { AnyRedirect } from './redirect' import type { AnyRouteMatch } from './Matches' export interface RouterReadableStore { @@ -73,13 +71,10 @@ export interface RouterStores { status: RouterWritableStore['status']> loadedAt: RouterWritableStore isLoading: RouterWritableStore - isTransitioning: RouterWritableStore location: RouterWritableStore>> resolvedLocation: RouterWritableStore< ParsedLocation> | undefined > - statusCode: RouterWritableStore - redirect: RouterWritableStore matchesId: RouterWritableStore> pendingIds: RouterWritableStore> /** @internal */ @@ -116,7 +111,7 @@ export interface RouterStores { } export function createRouterStores( - initialState: RouterState, + initialState: RouterState & { loadedAt: number }, config: StoreConfig, ): RouterStores { const { createMutableStore, createReadonlyStore, batch, init } = config @@ -130,11 +125,8 @@ export function createRouterStores( const status = createMutableStore(initialState.status) const loadedAt = createMutableStore(initialState.loadedAt) const isLoading = createMutableStore(initialState.isLoading) - const isTransitioning = createMutableStore(initialState.isTransitioning) const location = createMutableStore(initialState.location) const resolvedLocation = createMutableStore(initialState.resolvedLocation) - const statusCode = createMutableStore(initialState.statusCode) - const redirect = createMutableStore(initialState.redirect) const matchesId = createMutableStore>([]) const pendingIds = createMutableStore>([]) const cachedIds = createMutableStore>([]) @@ -165,14 +157,10 @@ export function createRouterStores( // compatibility "big" state store const __store = createReadonlyStore(() => ({ status: status.get(), - loadedAt: loadedAt.get(), isLoading: isLoading.get(), - isTransitioning: isTransitioning.get(), matches: matches.get(), location: location.get(), resolvedLocation: resolvedLocation.get(), - statusCode: statusCode.get(), - redirect: redirect.get(), })) // Per-routeId computed store cache. @@ -217,11 +205,8 @@ export function createRouterStores( status, loadedAt, isLoading, - isTransitioning, location, resolvedLocation, - statusCode, - redirect, matchesId, pendingIds, cachedIds, @@ -249,11 +234,11 @@ export function createRouterStores( setMatches, setPending, setCached, - } + } as RouterStores // initialize the active matches setMatches(initialState.matches as Array) - init?.(store) + init?.(store as unknown as RouterStores) // setters to update non-reactive utilities in sync with the reactive stores function setMatches(nextMatches: Array) { 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/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/hydrate.test.ts b/packages/router-core/tests/hydrate.test.ts index efac230e49..ea39945ce6 100644 --- a/packages/router-core/tests/hydrate.test.ts +++ b/packages/router-core/tests/hydrate.test.ts @@ -1,6 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' -import { BaseRootRoute, BaseRoute, notFound } from '../src' +import { + BaseRootRoute, + BaseRoute, + createControlledPromise, + notFound, +} from '../src' import { hydrate } from '../src/ssr/client' import { createTestRouter } from './routerTestUtils' import { dehydrateSsrMatchId } from '../src/ssr/ssr-match-id' @@ -91,7 +96,7 @@ describe('hydrate', () => { mockRouter.options.serializationAdapters = [mockSerializer] - const mockMatches = [{ id: '/', routeId: '/', index: 0, _nonReactive: {} }] + const mockMatches = [{ id: '/', routeId: '/', index: 0, _: {} }] mockRouter.matchRoutes = vi.fn().mockReturnValue(mockMatches) mockRouter.state.matches = mockMatches @@ -176,14 +181,14 @@ describe('hydrate', () => { routeId: '/', index: 0, ssr: undefined, - _nonReactive: {}, + _: {}, }, { id: '/other', routeId: '/other', index: 1, ssr: undefined, - _nonReactive: {}, + _: { loadPromise: createControlledPromise() }, }, ] @@ -224,6 +229,796 @@ describe('hydrate', () => { expect(ssr).toBe(true) }) + it('clears loadPromise references after fully SSR hydration', async () => { + const matches = mockRouter.matchRoutes( + mockRouter.stores.location.get(), + ) as Array + mockRouter.matchRoutes = vi.fn().mockReturnValue(matches) + + mockWindow.$_TSR = { + router: { + manifest: testManifest, + dehydratedData: {}, + lastMatchId: matches[matches.length - 1]!.id, + matches: matches.map((match) => ({ + i: match.id, + s: 'success' as const, + ssr: true, + u: Date.now(), + })), + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + initialized: false, + } + + await hydrate(mockRouter) + + expect( + mockRouter.state.matches.every( + (match: AnyRouteMatch) => match._.loadPromise === undefined, + ), + ).toBe(true) + }) + + it('fully SSR hydration runs no client loader and executes head and scripts once', async () => { + const matches = mockRouter.matchRoutes( + mockRouter.stores.location.get(), + ) as Array + const targetMatch = matches[matches.length - 1]! + const targetRoute = mockRouter.routesById[targetMatch.routeId] + const loader = vi.fn(() => 'client-loader') + const scripts = vi.fn(() => [{ children: 'hydrated-script' }]) + const loadSpy = vi.spyOn(mockRouter, 'load') + + targetRoute.options.loader = loader + targetRoute.options.scripts = scripts + mockRouter.matchRoutes = vi.fn().mockReturnValue(matches) + + mockWindow.$_TSR = { + router: { + manifest: testManifest, + dehydratedData: {}, + lastMatchId: matches[matches.length - 1]!.id, + matches: matches.map((match) => ({ + i: match.id, + l: match.id === targetMatch.id ? 'server-loader' : undefined, + s: 'success' as const, + ssr: true, + u: Date.now(), + })), + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + initialized: false, + } + + await hydrate(mockRouter) + + expect(loadSpy).not.toHaveBeenCalled() + expect(loader).not.toHaveBeenCalled() + expect(mockHead).toHaveBeenCalledTimes(1) + expect(scripts).toHaveBeenCalledTimes(1) + expect(targetMatch.loaderData).toBe('server-loader') + expect(targetMatch.scripts).toEqual([{ children: 'hydrated-script' }]) + }) + + it('selective SSR hydration client-loads ssr false shell matches', async () => { + const rootBeforeLoad = vi.fn(() => ({ root: 'client' })) + const childBeforeLoad = vi.fn(() => ({ child: 'client' })) + const rootLoader = vi.fn(({ context }) => ({ + root: context.root, + })) + const childLoader = vi.fn(({ context }) => ({ + child: context.child, + })) + const history = createMemoryHistory({ initialEntries: ['/child'] }) + const rootRoute = new BaseRootRoute({ + beforeLoad: rootBeforeLoad, + loader: rootLoader, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/child', + }).lazy(() => + Promise.resolve({ + options: { + ssr: () => { + throw new Error('ssr should not run on the client') + }, + beforeLoad: childBeforeLoad, + loader: childLoader, + }, + } as any), + ) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([childRoute]), + history, + isServer: false, + }) + const matches = router.matchRoutes(router.stores.location.get()) + + mockWindow.$_TSR = { + router: { + manifest: testManifest, + dehydratedData: {}, + lastMatchId: matches[matches.length - 1]!.id, + matches: matches.map((match) => ({ + i: match.id, + s: 'pending' as const, + ssr: false, + u: Date.now(), + })), + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + initialized: false, + } + + await hydrate(router) + await Promise.resolve() + + expect(rootBeforeLoad).toHaveBeenCalledTimes(1) + expect(childBeforeLoad).toHaveBeenCalledTimes(1) + expect(rootLoader).toHaveBeenCalledTimes(1) + expect(childLoader).toHaveBeenCalledTimes(1) + expect(childRoute.options.ssr).toBe(false) + await vi.waitFor(() => { + const loadedMatches = router.state.matches + const rootMatch = loadedMatches.find( + (match) => match.routeId === rootRoute.id, + ) + const childMatch = loadedMatches.find( + (match) => match.routeId === childRoute.id, + ) + + expect(rootMatch?.loaderData).toEqual({ root: 'client' }) + expect(childMatch?.loaderData).toEqual({ child: 'client' }) + }) + }) + + it('keeps SPA display pending when the initial client load does not commit final matches', async () => { + vi.useFakeTimers() + try { + const history = createMemoryHistory({ initialEntries: ['/other'] }) + const rootRoute = new BaseRootRoute({}) + const otherRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/other', + loader: () => 'other', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([otherRoute]), + history, + isServer: false, + }) + const matches = router.matchRoutes(router.stores.location.get()) + const displayMatch = matches[1]! + + router.load = vi.fn(async () => { + router.cancelMatches() + router.stores.isLoading.set(true) + }) + + mockWindow.$_TSR = { + router: { + manifest: testManifest, + dehydratedData: {}, + lastMatchId: matches[0]!.id, + matches: [ + { + i: matches[0]!.id, + s: 'success' as const, + ssr: true, + u: Date.now(), + }, + ], + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + initialized: false, + } + + await hydrate(router) + await vi.runOnlyPendingTimersAsync() + + expect(router.load).toHaveBeenCalledTimes(1) + + const currentDisplayMatch = router.getMatch(displayMatch.id) + if (!currentDisplayMatch) { + throw new Error('expected display match to remain mounted') + } + + expect(currentDisplayMatch.status).toBe('pending') + expect(currentDisplayMatch._displayPending).toBe(true) + expect(currentDisplayMatch._.loadPromise).toBeUndefined() + } finally { + vi.useRealTimers() + } + }) + + it('clears SPA display pending after a follow-up client load commits final matches', async () => { + vi.useFakeTimers() + try { + const followUpLoad = createControlledPromise() + const history = createMemoryHistory({ initialEntries: ['/other'] }) + const rootRoute = new BaseRootRoute({}) + const otherRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/other', + loader: () => 'other', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([otherRoute]), + history, + isServer: false, + }) + const matches = router.matchRoutes(router.stores.location.get()) + const displayMatch = matches[1]! + + router.load = vi.fn(async () => { + router.cancelMatches() + router.stores.isLoading.set(true) + router.latestLoadPromise = followUpLoad + }) + + mockWindow.$_TSR = { + router: { + manifest: testManifest, + dehydratedData: {}, + lastMatchId: matches[0]!.id, + matches: [ + { + i: matches[0]!.id, + s: 'success' as const, + ssr: true, + u: Date.now(), + }, + ], + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + initialized: false, + } + + await hydrate(router) + await vi.runOnlyPendingTimersAsync() + + const waitingDisplayMatch = router.getMatch(displayMatch.id) + if (!waitingDisplayMatch) { + throw new Error('expected display match to wait for follow-up load') + } + + expect(waitingDisplayMatch._displayPending).toBe(true) + + router.updateMatch(displayMatch.id, (match) => ({ + ...match, + status: 'success' as const, + })) + router.stores.isLoading.set(false) + followUpLoad.resolve() + await Promise.resolve() + + const currentDisplayMatch = router.getMatch(displayMatch.id) + if (!currentDisplayMatch) { + throw new Error('expected display match to remain mounted') + } + + expect(currentDisplayMatch._displayPending).toBeUndefined() + expect(currentDisplayMatch._.loadPromise).toBeUndefined() + } finally { + vi.useRealTimers() + } + }) + + it('clears data-only display pending after route chunks and pendingMinMs', async () => { + vi.useFakeTimers() + try { + mockRouter.options.defaultPendingComponent = () => null + mockRouter.options.defaultPendingMinMs = 500 + const matches = mockRouter.matchRoutes( + mockRouter.stores.location.get(), + ) as Array + const dataOnlyMatch = matches[matches.length - 1]! + mockRouter.matchRoutes = vi.fn().mockReturnValue(matches) + + mockWindow.$_TSR = { + router: { + manifest: testManifest, + dehydratedData: {}, + lastMatchId: dataOnlyMatch.id, + matches: matches.map((match) => ({ + i: match.id, + s: 'success' as const, + ssr: match === dataOnlyMatch ? ('data-only' as const) : true, + u: Date.now(), + })), + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + initialized: false, + } + + await hydrate(mockRouter) + + const displayMatch = mockRouter.getMatch( + dataOnlyMatch.id, + ) as AnyRouteMatch + expect(displayMatch._displayPending).toBe(true) + expect(displayMatch._.loadPromise).toBeUndefined() + + await vi.advanceTimersByTimeAsync(500) + + const updatedMatch = mockRouter.getMatch( + dataOnlyMatch.id, + ) as AnyRouteMatch + expect(updatedMatch._displayPending).toBeUndefined() + expect(updatedMatch._.loadPromise).toBeUndefined() + } finally { + vi.useRealTimers() + } + }) + + it('uses elapsed SSR-visible time for fast data-only hydration pending minimum', async () => { + vi.useFakeTimers() + try { + const headGate = createControlledPromise() + mockHead.mockReturnValue(headGate) + mockRouter.options.defaultPendingComponent = () => null + mockRouter.options.defaultPendingMinMs = 100 + + const matches = mockRouter.matchRoutes( + mockRouter.stores.location.get(), + ) as Array + const dataOnlyMatch = matches[matches.length - 1]! + mockRouter.matchRoutes = vi.fn().mockReturnValue(matches) + + mockWindow.$_TSR = { + router: { + manifest: testManifest, + dehydratedData: {}, + lastMatchId: dataOnlyMatch.id, + matches: matches.map((match) => ({ + i: match.id, + s: 'success' as const, + ssr: match === dataOnlyMatch ? ('data-only' as const) : true, + u: Date.now(), + })), + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + initialized: false, + } + + const hydration = hydrate(mockRouter) + await Promise.resolve() + + const displayMatch = mockRouter.getMatch( + dataOnlyMatch.id, + ) as AnyRouteMatch + + expect(displayMatch._displayPending).toBe(true) + + await vi.advanceTimersByTimeAsync(20) + headGate.resolve() + await hydration + + await vi.advanceTimersByTimeAsync(79) + expect(mockRouter.getMatch(dataOnlyMatch.id)._displayPending).toBe(true) + expect( + mockRouter.getMatch(dataOnlyMatch.id)._.loadPromise, + ).toBeUndefined() + + await vi.advanceTimersByTimeAsync(1) + + const updatedMatch = mockRouter.getMatch( + dataOnlyMatch.id, + ) as AnyRouteMatch + expect(updatedMatch._displayPending).toBeUndefined() + expect(updatedMatch._.loadPromise).toBeUndefined() + } finally { + vi.useRealTimers() + } + }) + + it('does not add a fresh minimum after slow data-only hydration work', async () => { + vi.useFakeTimers() + try { + const headGate = createControlledPromise() + mockHead.mockReturnValue(headGate) + mockRouter.options.defaultPendingComponent = () => null + mockRouter.options.defaultPendingMinMs = 100 + + const matches = mockRouter.matchRoutes( + mockRouter.stores.location.get(), + ) as Array + const dataOnlyMatch = matches[matches.length - 1]! + mockRouter.matchRoutes = vi.fn().mockReturnValue(matches) + + mockWindow.$_TSR = { + router: { + manifest: testManifest, + dehydratedData: {}, + lastMatchId: dataOnlyMatch.id, + matches: matches.map((match) => ({ + i: match.id, + s: 'success' as const, + ssr: match === dataOnlyMatch ? ('data-only' as const) : true, + u: Date.now(), + })), + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + initialized: false, + } + + const hydration = hydrate(mockRouter) + await Promise.resolve() + + const displayMatch = mockRouter.getMatch( + dataOnlyMatch.id, + ) as AnyRouteMatch + + expect(displayMatch._displayPending).toBe(true) + + await vi.advanceTimersByTimeAsync(150) + headGate.resolve() + await hydration + + const updatedMatch = mockRouter.getMatch( + dataOnlyMatch.id, + ) as AnyRouteMatch + expect(updatedMatch._displayPending).toBeUndefined() + expect(updatedMatch._.loadPromise).toBeUndefined() + } finally { + vi.useRealTimers() + } + }) + + it('cleans up data-only display pending when head hydration throws', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const headError = new Error('head failed') + mockHead.mockImplementation(() => { + throw headError + }) + mockRouter.options.defaultPendingComponent = () => null + mockRouter.options.defaultPendingMinMs = 500 + const updateMatchSpy = vi.spyOn(mockRouter, 'updateMatch') + + const matches = mockRouter.matchRoutes( + mockRouter.stores.location.get(), + ) as Array + const dataOnlyMatch = matches[matches.length - 1]! + mockRouter.matchRoutes = vi.fn().mockReturnValue(matches) + + mockWindow.$_TSR = { + router: { + manifest: testManifest, + dehydratedData: {}, + lastMatchId: dataOnlyMatch.id, + matches: matches.map((match) => ({ + i: match.id, + s: 'success' as const, + ssr: match === dataOnlyMatch ? ('data-only' as const) : true, + u: Date.now(), + })), + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + initialized: false, + } + + await expect(hydrate(mockRouter)).rejects.toThrow(headError) + + const updatedMatch = mockRouter.getMatch(dataOnlyMatch.id) as AnyRouteMatch + expect(updatedMatch._displayPending).toBeUndefined() + expect(updatedMatch._.loadPromise).toBeUndefined() + expect(updateMatchSpy).toHaveBeenCalledWith( + dataOnlyMatch.id, + expect.any(Function), + ) + + consoleSpy.mockRestore() + }) + + it('runs hydration head work after route chunks resolve', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const headError = new Error('head failed') + const headGate = createControlledPromise() + const routeChunkGate = createControlledPromise() + mockHead.mockReturnValue(headGate) + vi.spyOn(mockRouter, 'loadRouteChunk').mockReturnValue(routeChunkGate) + + const matches = mockRouter.matchRoutes( + mockRouter.stores.location.get(), + ) as Array + mockRouter.matchRoutes = vi.fn().mockReturnValue(matches) + + mockWindow.$_TSR = { + router: { + manifest: testManifest, + dehydratedData: {}, + lastMatchId: matches[matches.length - 1]!.id, + matches: matches.map((match) => ({ + i: match.id, + s: 'success' as const, + ssr: true, + u: Date.now(), + })), + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + initialized: false, + } + + try { + const hydration = hydrate(mockRouter) + await Promise.resolve() + + expect(mockHead).not.toHaveBeenCalled() + + routeChunkGate.resolve() + await vi.waitFor(() => expect(mockHead).toHaveBeenCalled()) + + headGate.reject(headError) + await expect(hydration).rejects.toThrow(headError) + } finally { + consoleSpy.mockRestore() + } + }) + + it('lazy route head rejection after chunk resolution cleans up display pending', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const headError = new Error('head failed') + const routeChunkGate = createControlledPromise() + const head = vi.fn(() => { + throw headError + }) + const scripts = vi.fn(() => [{ children: 'hydrated-script' }]) + mockRouter.options.defaultPendingComponent = () => null + mockRouter.options.defaultPendingMinMs = 500 + + const matches = mockRouter.matchRoutes( + mockRouter.stores.location.get(), + ) as Array + const dataOnlyMatch = matches[matches.length - 1]! + const dataOnlyRoute = mockRouter.routesById[dataOnlyMatch.routeId] + dataOnlyRoute.options.head = undefined + dataOnlyRoute.options.scripts = undefined + vi.spyOn(mockRouter, 'loadRouteChunk').mockReturnValue( + routeChunkGate.then(() => { + dataOnlyRoute.options.head = head + dataOnlyRoute.options.scripts = scripts + }), + ) + mockRouter.matchRoutes = vi.fn().mockReturnValue(matches) + + mockWindow.$_TSR = { + router: { + manifest: testManifest, + dehydratedData: {}, + lastMatchId: dataOnlyMatch.id, + matches: matches.map((match) => ({ + i: match.id, + s: 'success' as const, + ssr: match === dataOnlyMatch ? ('data-only' as const) : true, + u: Date.now(), + })), + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + initialized: false, + } + + try { + const hydration = hydrate(mockRouter) + await Promise.resolve() + + const displayMatch = mockRouter.getMatch( + dataOnlyMatch.id, + ) as AnyRouteMatch + expect(displayMatch._displayPending).toBe(true) + + routeChunkGate.resolve() + await expect(hydration).rejects.toThrow(headError) + + const updatedMatch = mockRouter.getMatch( + dataOnlyMatch.id, + ) as AnyRouteMatch + expect(head).toHaveBeenCalledTimes(1) + expect(scripts).not.toHaveBeenCalled() + expect(updatedMatch._displayPending).toBeUndefined() + expect(updatedMatch._.loadPromise).toBeUndefined() + expect(consoleSpy).not.toHaveBeenCalledWith( + 'Error during router hydration:', + expect.anything(), + ) + } finally { + consoleSpy.mockRestore() + } + }) + + it('route-chunk rejection rejects before hydration head work starts', async () => { + const chunkError = new Error('chunk failed') + const unhandledRejection = vi.fn() + let rejectedChunk = false + mockRouter.options.defaultPendingComponent = () => null + mockRouter.options.defaultPendingMinMs = 500 + vi.spyOn(mockRouter, 'loadRouteChunk').mockImplementation(() => { + if (rejectedChunk) { + return Promise.resolve() + } + rejectedChunk = true + return Promise.reject(chunkError) + }) + + const matches = mockRouter.matchRoutes( + mockRouter.stores.location.get(), + ) as Array + const dataOnlyMatch = matches[matches.length - 1]! + mockRouter.matchRoutes = vi.fn().mockReturnValue(matches) + + mockWindow.$_TSR = { + router: { + manifest: testManifest, + dehydratedData: {}, + lastMatchId: dataOnlyMatch.id, + matches: matches.map((match) => ({ + i: match.id, + s: 'success' as const, + ssr: match === dataOnlyMatch ? ('data-only' as const) : true, + u: Date.now(), + })), + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + initialized: false, + } + + process.on('unhandledRejection', unhandledRejection) + + try { + const hydrationError = hydrate(mockRouter).catch((err) => err) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(unhandledRejection).not.toHaveBeenCalled() + expect(mockHead).not.toHaveBeenCalled() + + await expect(hydrationError).resolves.toBe(chunkError) + + const updatedMatch = mockRouter.getMatch( + dataOnlyMatch.id, + ) as AnyRouteMatch + expect(updatedMatch._displayPending).toBeUndefined() + expect(updatedMatch._.loadPromise).toBeUndefined() + } finally { + process.off('unhandledRejection', unhandledRejection) + } + }) + + it('settles hydration load promises when route chunk loading rejects', async () => { + const chunkError = new Error('chunk failed') + const routeChunkGate = createControlledPromise() + mockRouter.options.defaultPendingComponent = () => null + mockRouter.options.defaultPendingMinMs = 500 + vi.spyOn(mockRouter, 'loadRouteChunk').mockReturnValue(routeChunkGate) + const updateMatchSpy = vi.spyOn(mockRouter, 'updateMatch') + const matches = mockRouter.matchRoutes( + mockRouter.stores.location.get(), + ) as Array + const dataOnlyMatch = matches[matches.length - 1]! + mockRouter.matchRoutes = vi.fn().mockReturnValue(matches) + + mockWindow.$_TSR = { + router: { + manifest: testManifest, + dehydratedData: {}, + lastMatchId: dataOnlyMatch.id, + matches: matches.map((match) => ({ + i: match.id, + s: 'success' as const, + ssr: match === dataOnlyMatch ? ('data-only' as const) : true, + u: Date.now(), + })), + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + initialized: false, + } + + const hydration = hydrate(mockRouter) + setTimeout(() => routeChunkGate.reject(chunkError), 0) + + await expect(hydration).rejects.toThrow(chunkError) + expect( + matches.every( + (match: AnyRouteMatch) => match._.loadPromise === undefined, + ), + ).toBe(true) + expect( + mockRouter.getMatch(dataOnlyMatch.id)._displayPending, + ).toBeUndefined() + expect(updateMatchSpy).toHaveBeenCalledWith( + dataOnlyMatch.id, + expect.any(Function), + ) + }) + + it('fully SSR route-chunk rejection does not mark location resolved', async () => { + const chunkError = new Error('chunk failed') + const routeChunkGate = createControlledPromise() + vi.spyOn(mockRouter, 'loadRouteChunk').mockReturnValue(routeChunkGate) + const matches = mockRouter.matchRoutes( + mockRouter.stores.location.get(), + ) as Array + mockRouter.matchRoutes = vi.fn().mockReturnValue(matches) + + mockWindow.$_TSR = { + router: { + manifest: testManifest, + dehydratedData: {}, + lastMatchId: matches[matches.length - 1]!.id, + matches: matches.map((match) => ({ + i: match.id, + s: 'success' as const, + ssr: true, + u: Date.now(), + })), + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + initialized: false, + } + + const hydration = hydrate(mockRouter) + routeChunkGate.reject(chunkError) + + await expect(hydration).rejects.toThrow(chunkError) + expect(mockRouter.stores.resolvedLocation.get()).toBeUndefined() + }) + it('should hydrate globalNotFound when dehydrated flag is present', async () => { const mockMatches = [ { @@ -231,7 +1026,7 @@ describe('hydrate', () => { routeId: '/', index: 0, ssr: undefined, - _nonReactive: {}, + _: {}, }, ] @@ -275,7 +1070,7 @@ describe('hydrate', () => { routeId: '/', index: 0, ssr: undefined, - _nonReactive: {}, + _: {}, }, ] @@ -318,7 +1113,7 @@ describe('hydrate', () => { routeId: '/', index: 0, ssr: undefined, - _nonReactive: {}, + _: {}, globalNotFound: true, }, ] @@ -364,7 +1159,7 @@ describe('hydrate', () => { routeId: '/', index: 0, ssr: undefined, - _nonReactive: {}, + _: {}, }, ] @@ -467,15 +1262,70 @@ describe('hydrate', () => { ).toEqual(['__root__', '/internal']) }) + it('uses router options updated by custom hydration after the hook', async () => { + vi.useFakeTimers() + try { + mockRouter.options.hydrate = () => { + mockRouter.update({ + defaultPendingComponent: () => null, + defaultPendingMinMs: 500, + }) + } + + const matches = mockRouter.matchRoutes( + mockRouter.stores.location.get(), + ) as Array + const dataOnlyMatch = matches[matches.length - 1]! + mockRouter.matchRoutes = vi.fn().mockReturnValue(matches) + + mockWindow.$_TSR = { + router: { + manifest: testManifest, + dehydratedData: {}, + lastMatchId: dataOnlyMatch.id, + matches: matches.map((match) => ({ + i: match.id, + s: 'success' as const, + ssr: match === dataOnlyMatch ? ('data-only' as const) : true, + u: Date.now(), + })), + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + initialized: false, + } + + await hydrate(mockRouter) + + const displayMatch = mockRouter.getMatch( + dataOnlyMatch.id, + ) as AnyRouteMatch + + expect(displayMatch._displayPending).toBe(true) + expect(displayMatch._.loadPromise).toBeUndefined() + + await vi.advanceTimersByTimeAsync(500) + + const updatedMatch = mockRouter.getMatch( + dataOnlyMatch.id, + ) as AnyRouteMatch + expect(updatedMatch._displayPending).toBeUndefined() + expect(updatedMatch._.loadPromise).toBeUndefined() + } finally { + vi.useRealTimers() + } + }) + it('should handle errors during route context hydration', async () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) mockHead.mockImplementation(() => { throw notFound() }) - const mockMatches = [ - { id: '/', routeId: '/', index: 0, ssr: true, _nonReactive: {} }, - ] + const mockMatches = [{ id: '/', routeId: '/', index: 0, ssr: true, _: {} }] mockRouter.matchRoutes = vi.fn().mockReturnValue(mockMatches) mockRouter.state.matches = mockMatches @@ -517,4 +1367,265 @@ describe('hydrate', () => { consoleSpy.mockRestore() }) + + it('SPA display fallback outlives its own match loadPromise', async () => { + const childGate = createControlledPromise() + const history = createMemoryHistory({ initialEntries: ['/parent/child'] }) + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: () => 'parent', + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: async () => { + await childGate + return 'child' + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history, + isServer: false, + defaultPendingMs: 0, + defaultPendingMinMs: 0, + }) + ;(router.options as any).defaultPendingComponent = () => null + const matches = router.matchRoutes(router.stores.location.get()) + + 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(router) + await Promise.resolve() + + await vi.waitFor(() => { + const parent = router.state.matches.find( + (match) => match.routeId === parentRoute.id, + ) as AnyRouteMatch + expect(parent.status).toBe('success') + expect(parent._displayPending).toBe(true) + expect(parent._.loadPromise).toBeUndefined() + }) + + expect(router.stores.status.get()).toBe('pending') + expect(router.stores.resolvedLocation.get()).toBeUndefined() + + childGate.resolve() + + await vi.waitFor(() => expect(router.stores.status.get()).toBe('idle')) + + const currentParent = router.state.matches.find( + (match) => match.routeId === parentRoute.id, + ) as AnyRouteMatch + expect(currentParent._displayPending).toBeUndefined() + expect(currentParent._.loadPromise).toBeUndefined() + expect(router.stores.resolvedLocation.get()).toEqual( + router.stores.location.get(), + ) + }) + + it('keeps SPA hydration fallback through pendingMinMs after load completes', async () => { + vi.useFakeTimers() + try { + let resolveRouterLoad!: () => void + const routerLoadPromise = new Promise((resolve) => { + resolveRouterLoad = resolve + }) + vi.spyOn(mockRouter, 'load').mockReturnValue(routerLoadPromise) + mockRouter.options.defaultPendingComponent = () => null + mockRouter.options.defaultPendingMinMs = 500 + + 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 + + expect(match._displayPending).toBe(true) + + resolveRouterLoad() + await Promise.resolve() + await vi.advanceTimersByTimeAsync(499) + + expect(mockRouter.getMatch(match.id)._displayPending).toBe(true) + + await vi.advanceTimersByTimeAsync(1) + + const updatedMatch = mockRouter.getMatch(match.id) as AnyRouteMatch + expect(updatedMatch._displayPending).toBeUndefined() + } finally { + vi.useRealTimers() + } + }) + + it('cleans up SPA display pending when router.load rejects', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const loadError = new Error('router load failed') + vi.spyOn(mockRouter, 'load').mockRejectedValue(loadError) + mockRouter.options.defaultPendingComponent = () => null + mockRouter.options.defaultPendingMinMs = 0 + + 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) + + const match = mockRouter.stores.matches.get()[1] as AnyRouteMatch + + expect(match._displayPending).toBe(true) + + await vi.waitFor(() => { + const updatedMatch = mockRouter.getMatch(match.id) as AnyRouteMatch + expect(updatedMatch._displayPending).toBeUndefined() + }) + expect(consoleSpy).toHaveBeenCalledWith( + 'Error during router hydration:', + loadError, + ) + + consoleSpy.mockRestore() + }) + + it('SPA route-chunk failure rejects before starting router.load', async () => { + const chunkError = new Error('chunk failed') + const routeChunkGate = createControlledPromise() + const childLoader = vi.fn() + const history = createMemoryHistory({ initialEntries: ['/parent/child'] }) + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: () => 'parent', + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: childLoader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history, + isServer: false, + defaultPendingMs: 0, + defaultPendingMinMs: 0, + }) + ;(router.options as any).defaultPendingComponent = () => null + const matches = router.matchRoutes(router.stores.location.get()) + const childMatch = matches.find( + (match) => match.routeId === childRoute.id, + ) as AnyRouteMatch + router.matchRoutes = vi.fn().mockReturnValue(matches) + + let routeChunkCalls = 0 + vi.spyOn(router, 'loadRouteChunk').mockImplementation(() => { + routeChunkCalls++ + return routeChunkCalls <= matches.length ? routeChunkGate : undefined + }) + const load = router.load.bind(router) + vi.spyOn(router, 'load').mockImplementation((opts) => { + return load(opts) + }) + + mockWindow.$_TSR = { + router: { + manifest: testManifest, + dehydratedData: {}, + lastMatchId: '/not-the-current-leaf', + matches: [ + { + i: matches[0]!.id, + s: 'success' as const, + ssr: true, + u: Date.now(), + }, + ], + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + initialized: false, + } + + const hydration = hydrate(router) + await Promise.resolve() + + routeChunkGate.reject(chunkError) + + await expect(hydration).rejects.toThrow(chunkError) + + const currentChild = router.getMatch(childMatch.id) as AnyRouteMatch + expect(currentChild).toBe(childMatch) + expect(router.load).not.toHaveBeenCalled() + expect(childLoader).not.toHaveBeenCalled() + }) }) diff --git a/packages/router-core/tests/load.test.ts b/packages/router-core/tests/load.test.ts index 1ea6fca30e..779f477a03 100644 --- a/packages/router-core/tests/load.test.ts +++ b/packages/router-core/tests/load.test.ts @@ -3,26 +3,166 @@ import { createMemoryHistory } from '@tanstack/history' import { BaseRootRoute, BaseRoute, + PathParamError, + SearchParamError, + createControlledPromise, notFound, redirect, rootRouteId, } from '../src' import { createTestRouter } from './routerTestUtils' -import { loadMatches } from '../src/load-matches' +import { + loadClientMatches, + startBackgroundLoad, +} from '../src/load-matches.client' +import { projectClientRouteAssets } from '../src/route-assets.client' +import { projectServerRouteAssets } from '../src/route-assets.server' +import { loadServerMatches } from '../src/load-matches.server' +import { settleMatchLoad } from '../src/load-matches' +import { loadRouteChunk } from '../src/route-chunks' +import { isRedirect } from '../src/redirect' +import type { LoadMatchesArg } from '../src/load-matches' import type { + AnyRouteMatch, AnyRouter, + ControlledPromise, LoaderStaleReloadMode, RootRouteOptions, RouterCore, + LocationRewrite, } from '../src' type AnyRouteOptions = RootRouteOptions type BeforeLoad = NonNullable type Loader = NonNullable type LoaderEntry = Exclude +type LoaderFn = Exclude + +const settleClientLane = (matches: Array) => { + for (const match of matches) { + settleMatchLoad(match) + } +} + +const loadClientMatchesArg = (arg: LoadMatchesArg) => { + const loadContext = { + router: arg.router, + location: arg.location, + matches: arg.matches, + preload: arg.preload, + forceStaleReload: arg.forceReload, + background: arg.background, + onReady: arg.onReady, + } + + return loadClientMatches(loadContext).catch((error) => { + if (error === loadContext) { + return arg.matches + } + throw error + }) +} + +const loadMatches = async (arg: LoadMatchesArg) => { + if (arg.router.isServer) { + return loadServerMatches(arg) + } + + try { + const matches = await loadClientMatchesArg(arg) + settleClientLane(matches) + return matches + } catch (error) { + if (!isRedirect(error)) { + settleClientLane(arg.matches) + } + throw error + } +} + +const projectAssets = async ({ + router, + matches, + preload, + isCurrent = () => true, +}: { + router: AnyRouter + matches: Array + preload?: boolean + isCurrent?: () => boolean +}) => { + const assets = projectClientRouteAssets(router, matches, preload, isCurrent) + if (assets) { + await assets + } +} + +const gateFinalCommit = (router: AnyRouter) => { + const gate = createControlledPromise() + const started = vi.fn() + const original = router.startViewTransition + + router.startViewTransition = ((fn: any) => { + started() + return gate.then(() => fn()) + }) as AnyRouter['startViewTransition'] + + return { + gate, + started, + restore: () => { + router.startViewTransition = original + }, + } +} + +function expectNoCachedActiveOverlap(router: AnyRouter) { + const ownedIds = new Set([ + ...router.state.matches.map((match) => match.id), + ...router.stores.pendingMatches.get().map((match) => match.id), + ]) + const overlappingCachedIds = router.stores.cachedMatches + .get() + .flatMap((match) => (ownedIds.has(match.id) ? [match.id] : [])) + + expect(overlappingCachedIds).toEqual([]) +} + +const getLaneMatch = (matches: Array, id: string) => + matches.find((match) => match.id === id) + +describe('router server flags', () => { + test('uses false fallbacks on client routers', () => { + const rootRoute = new BaseRootRoute({}) + const router = createTestRouter({ + routeTree: rootRoute, + history: createMemoryHistory({ initialEntries: ['/'] }), + isServer: false, + isShell: true, + isPrerendering: true, + }) + + expect(router.isShell()).toBe(false) + expect(router.isPrerendering()).toBe(false) + }) + + test('reads shell and prerendering options on server routers', () => { + const rootRoute = new BaseRootRoute({}) + const router = createTestRouter({ + routeTree: rootRoute, + history: createMemoryHistory({ initialEntries: ['/'] }), + isServer: true, + isShell: true, + isPrerendering: true, + }) + + expect(router.isShell()).toBe(true) + expect(router.isPrerendering()).toBe(true) + }) +}) 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 +177,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 +194,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) => { @@ -72,327 +264,480 @@ describe('redirect resolution', () => { await router.load() - expect(router.state.redirect).toEqual( + expect(router.redirect).toEqual( expect.objectContaining({ options: expect.objectContaining({ href: '/undefined' }), }), ) - expect(router.state.redirect?.headers.get('Location')).toBe('/undefined') + expect(router.redirect?.headers.get('Location')).toBe('/undefined') }, ) }) -describe('notFound detection', () => { - test('does not treat arbitrary proxy property access as notFound', async () => { +describe('server router.load status codes', () => { + test('keeps successful server loads at status 200', async () => { const rootRoute = new BaseRootRoute({}) - const fooRoute = new BaseRoute({ + const indexRoute = new BaseRoute({ getParentRoute: () => rootRoute, - path: '/foo', - loader: () => - new Proxy( - {}, - { - get(_target, prop) { - if (prop === 'isNotFound') return 'truthy-but-not-true' - return undefined - }, - has() { - return true - }, - }, - ), + path: '/', + loader: () => 'home', }) - - const routeTree = rootRoute.addChildren([fooRoute]) - const router = createTestRouter({ - routeTree, - history: createMemoryHistory({ initialEntries: ['/foo'] }), + routeTree: rootRoute.addChildren([indexRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), isServer: true, }) await router.load() - expect(router.state.matches.at(-1)?.status).toBe('success') - expect(router.state.matches.at(-1)?.error).toBeUndefined() + expect(router.statusCode).toBe(200) + expect(router.redirect).toBeUndefined() + expect(router.state.matches.at(-1)?.loaderData).toBe('home') }) -}) -describe('beforeLoad skip or exec', () => { - const setup = ({ beforeLoad }: { beforeLoad?: BeforeLoad }) => { + test('uses redirect status and instance redirect for server redirects', async () => { const rootRoute = new BaseRootRoute({}) - - const fooRoute = new BaseRoute({ + const targetRoute = new BaseRoute({ getParentRoute: () => rootRoute, - path: '/foo', - beforeLoad, + path: '/target', }) - - const barRoute = new BaseRoute({ + const fromRoute = new BaseRoute({ getParentRoute: () => rootRoute, - path: '/bar', + path: '/from', + beforeLoad: () => { + throw redirect({ to: '/target', statusCode: 307 }) + }, }) - - const routeTree = rootRoute.addChildren([fooRoute, barRoute]) - const router = createTestRouter({ - routeTree, - history: createMemoryHistory(), + routeTree: rootRoute.addChildren([fromRoute, targetRoute]), + history: createMemoryHistory({ initialEntries: ['/from'] }), + isServer: true, }) - return router - } - - test('baseline', async () => { - const beforeLoad = vi.fn() - const router = setup({ beforeLoad }) await router.load() - expect(beforeLoad).toHaveBeenCalledTimes(0) - }) - test('exec on regular nav', async () => { - const beforeLoad = vi.fn(() => Promise.resolve({ hello: 'world' })) - const router = setup({ beforeLoad }) - const navigation = router.navigate({ to: '/foo' }) - expect(beforeLoad).toHaveBeenCalledTimes(1) - expect(router.stores.pendingMatches.get()).toEqual( - expect.arrayContaining([expect.objectContaining({ id: '/foo/foo' })]), - ) - await navigation - expect(router.state.location.pathname).toBe('/foo') - expect(router.state.matches).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: '/foo/foo', - context: { - hello: 'world', - }, - }), - ]), - ) - expect(beforeLoad).toHaveBeenCalledTimes(1) + expect(router.statusCode).toBe(307) + expect(router.redirect?.status).toBe(307) + expect(router.redirect?.options.href).toBe('/target') + expect(router.redirect?.headers.get('Location')).toBe('/target') }) - test('preserves primitive errors thrown from beforeLoad', async () => { - const beforeLoad = vi.fn(() => { - throw 'primitive error' + test.each([ + [ + 'loader returns notFound', + '/loader-return-not-found', + () => ({ + loader: () => notFound(), + }), + ], + [ + 'loader throws notFound', + '/loader-throw-not-found', + () => ({ + loader: () => { + throw notFound() + }, + }), + ], + [ + 'beforeLoad returns notFound', + '/before-return-not-found', + () => ({ + beforeLoad: () => notFound(), + }), + ], + [ + 'beforeLoad throws notFound', + '/before-throw-not-found', + () => ({ + beforeLoad: () => { + throw notFound() + }, + }), + ], + ] as const)('sets 404 when a server %s', async (_name, path, getOptions) => { + const rootRoute = new BaseRootRoute({}) + const missingRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path, + ...getOptions(), + notFoundComponent: () => null, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([missingRoute]), + history: createMemoryHistory({ initialEntries: [path] }), + isServer: true, }) - const router = setup({ beforeLoad }) - await router.navigate({ to: '/foo' }) + await router.load() - expect(router.state.statusCode).toBe(500) - expect(router.state.matches).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: '/foo/foo', - status: 'error', - error: 'primitive error', - }), - ]), + const match = router.state.matches.find( + (item) => item.routeId === missingRoute.id, ) + expect(router.statusCode).toBe(404) + expect(router.redirect).toBeUndefined() + expect(match?.status).toBe('notFound') + expect(match?.error).toEqual(expect.objectContaining({ isNotFound: true })) }) - test('does not mutate object errors thrown from beforeLoad', async () => { - const thrown = { type: 'domain-error' } - const beforeLoad = vi.fn(() => { - throw thrown + test('sets 404 for unmatched server routes with a root notFound boundary', async () => { + const rootRoute = new BaseRootRoute({ + notFoundComponent: () => null, + }) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history: createMemoryHistory({ initialEntries: ['/missing'] }), + isServer: true, }) - const router = setup({ beforeLoad }) - await router.navigate({ to: '/foo' }) + await router.load() - expect(router.state.statusCode).toBe(500) - expect(router.state.matches.find((d) => d.id === '/foo/foo')?.error).toBe( - thrown, + const rootMatch = router.state.matches.find( + (item) => item.routeId === rootRoute.id, ) - expect(thrown).toEqual({ type: 'domain-error' }) + expect(router.statusCode).toBe(404) + expect(router.redirect).toBeUndefined() + expect(rootMatch?.globalNotFound).toBe(true) }) - test('exec if resolved preload (success)', async () => { - const beforeLoad = vi.fn() - const router = setup({ beforeLoad }) - await router.preloadRoute({ to: '/foo' }) - expect(router.stores.cachedMatches.get()).toEqual( - expect.arrayContaining([expect.objectContaining({ id: '/foo/foo' })]), - ) - await sleep(10) - await router.navigate({ to: '/foo' }) + test('sets 500 when a server loader throws', async () => { + const loaderError = new Error('loader failed') + const rootRoute = new BaseRootRoute({}) + const errorRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/error', + loader: () => { + throw loaderError + }, + errorComponent: () => null, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([errorRoute]), + history: createMemoryHistory({ initialEntries: ['/error'] }), + isServer: true, + }) - expect(beforeLoad).toHaveBeenCalledTimes(2) - }) + await router.load() - test('exec if pending preload (success)', async () => { - const beforeLoad = vi.fn(() => sleep(100)) - const router = setup({ beforeLoad }) - router.preloadRoute({ to: '/foo' }) - await Promise.resolve() - expect(router.stores.cachedMatches.get()).toEqual( - expect.arrayContaining([expect.objectContaining({ id: '/foo/foo' })]), + const match = router.state.matches.find( + (item) => item.routeId === errorRoute.id, ) - await router.navigate({ to: '/foo' }) - - expect(beforeLoad).toHaveBeenCalledTimes(2) + expect(router.statusCode).toBe(500) + expect(router.redirect).toBeUndefined() + expect(match?.status).toBe('error') + expect(match?.error).toBe(loaderError) }) - test('exec if rejected preload (notFound)', async () => { - const beforeLoad = vi.fn(async ({ preload }) => { - if (preload) throw notFound() - await Promise.resolve() + test('sets 500 when a server beforeLoad throws', async () => { + const beforeLoadError = new Error('beforeLoad failed') + const rootRoute = new BaseRootRoute({}) + const errorRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/before-error', + beforeLoad: () => { + throw beforeLoadError + }, + errorComponent: () => null, }) - const router = setup({ - beforeLoad, + const router = createTestRouter({ + routeTree: rootRoute.addChildren([errorRoute]), + history: createMemoryHistory({ initialEntries: ['/before-error'] }), + isServer: true, }) - await router.preloadRoute({ to: '/foo' }) - await sleep(10) - await router.navigate({ to: '/foo' }) - expect(beforeLoad).toHaveBeenCalledTimes(2) + await router.load() + + const match = router.state.matches.find( + (item) => item.routeId === errorRoute.id, + ) + expect(router.statusCode).toBe(500) + expect(router.redirect).toBeUndefined() + expect(match?.status).toBe('error') + expect(match?.error).toBe(beforeLoadError) }) +}) - test('exec if pending preload (notFound)', async () => { - const beforeLoad = vi.fn(async ({ preload }) => { - await sleep(100) - if (preload) throw notFound() - }) - const router = setup({ - beforeLoad, +describe('direct client/server match loading', () => { + const setupDirectLoad = ({ + isServer, + routeTree, + path, + }: { + isServer: boolean + routeTree: any + path: string + }) => { + const router = createTestRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: [path] }), + isServer, }) - router.preloadRoute({ to: '/foo' }) - await Promise.resolve() - await router.navigate({ to: '/foo' }) + const location = router.latestLocation + const matches = router.matchRoutes(location) + router.stores.setPending(matches) + return { router, location, matches } + } - expect(beforeLoad).toHaveBeenCalledTimes(2) - }) + test.each([ + ['client', false, loadClientMatchesArg], + ['server', true, loadServerMatches], + ] as const)( + 'loads beforeLoad context into loaders on the %s path', + async (label, isServer, loadImpl) => { + const token = `${label}-token` + const parentBeforeLoad = vi.fn(() => ({ token })) + const childLoader = vi.fn(({ context, preload }) => ({ + token: (context as { token: string }).token, + preload, + })) + 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({ initialEntries: ['/parent/child'] }), + isServer, + }) + const location = router.latestLocation + const matches = router.matchRoutes(location) + router.stores.setPending(matches) - test('exec if rejected preload (redirect)', async () => { - const beforeLoad = vi.fn(async ({ preload }) => { - if (preload) throw redirect({ to: '/bar' }) - await Promise.resolve() - }) - const router = setup({ - beforeLoad, - }) - await router.preloadRoute({ to: '/foo' }) - expect( - router.stores.cachedMatches.get().some((d) => d.status === 'redirected'), - ).toBe(false) - await sleep(10) - await router.navigate({ to: '/foo' }) + const loadedMatches = await loadImpl({ router, location, matches }) - expect(router.state.location.pathname).toBe('/foo') - expect( - router.stores.cachedMatches.get().some((d) => d.status === 'redirected'), - ).toBe(false) - expect(beforeLoad).toHaveBeenCalledTimes(2) - }) + const parentMatch = loadedMatches.find( + (item) => item.routeId === parentRoute.id, + ) + const childMatch = loadedMatches.find( + (item) => item.routeId === childRoute.id, + ) + expect(parentBeforeLoad).toHaveBeenCalledTimes(1) + expect(childLoader).toHaveBeenCalledTimes(1) + expect(parentMatch?.context).toMatchObject({ token }) + expect(childMatch?.status).toBe('success') + expect(childMatch?.loaderData).toEqual({ token, preload: false }) + }, + ) - test('exec if pending preload (redirect)', async () => { - const beforeLoad = vi.fn(async ({ preload }) => { - await sleep(100) - if (preload) throw redirect({ to: '/bar' }) + test('server beforeLoad and loader reuse the route-matched abortController', async () => { + let beforeLoadController: AbortController | undefined + let loaderController: AbortController | undefined + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + beforeLoad: ({ abortController }) => { + beforeLoadController = abortController + }, + loader: ({ abortController }) => { + loaderController = abortController + }, }) - const router = setup({ - beforeLoad, + const { router, location, matches } = setupDirectLoad({ + isServer: true, + routeTree: rootRoute.addChildren([fooRoute]), + path: '/foo', }) - router.preloadRoute({ to: '/foo' }) - await Promise.resolve() - expect( - router.stores.cachedMatches.get().some((d) => d.status === 'redirected'), - ).toBe(false) - await router.navigate({ to: '/foo' }) + const matchedController = matches[1]!.abortController - expect(router.state.location.pathname).toBe('/foo') - expect( - router.stores.cachedMatches.get().some((d) => d.status === 'redirected'), - ).toBe(false) - expect(beforeLoad).toHaveBeenCalledTimes(2) + await loadServerMatches({ router, location, matches }) + + expect(beforeLoadController).toBe(matchedController) + expect(loaderController).toBe(matchedController) + expect(matches[1]!.abortController).toBe(matchedController) }) - test('exec if rejected preload (error)', async () => { - const beforeLoad = vi.fn(async ({ preload }) => { - if (preload) throw new Error('error') - await Promise.resolve() - }) - const router = setup({ - beforeLoad, - }) - await router.preloadRoute({ to: '/foo' }) - await sleep(10) - await router.navigate({ to: '/foo' }) + test.each([ + ['client', false, loadClientMatchesArg], + ['server', true, loadServerMatches], + ] as const)( + 'commits an ancestor notFound boundary and trims descendants on the %s path', + async (_label, isServer, loadImpl) => { + const rootRoute = new BaseRootRoute({ + notFoundComponent: () => null, + }) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + notFoundComponent: () => null, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + beforeLoad: () => { + throw notFound({ routeId: parentRoute.id }) + }, + }) + const { router, location, matches } = setupDirectLoad({ + isServer, + routeTree: rootRoute.addChildren([ + parentRoute.addChildren([childRoute]), + ]), + path: '/parent/child', + }) + const childMatch = matches[2]! - expect(beforeLoad).toHaveBeenCalledTimes(2) - }) + await expect(loadImpl({ router, location, matches })).rejects.toEqual( + expect.objectContaining({ isNotFound: true }), + ) - test('skip child beforeLoad when parent beforeLoad throws during preload', async () => { - const parentBeforeLoad = vi.fn(async ({ preload }) => { - if (preload) throw new Error('parent error') - }) - const childBeforeLoad = vi.fn() - const parentHead = vi.fn(() => ({ meta: [{ title: 'Parent' }] })) - const childHead = vi.fn(() => ({ meta: [{ title: 'Child' }] })) + expect(matches.map((match) => match.routeId)).toEqual([ + rootRoute.id, + parentRoute.id, + ]) + expect(matches[1]).toEqual( + expect.objectContaining({ + routeId: parentRoute.id, + status: 'notFound', + error: expect.objectContaining({ isNotFound: true }), + }), + ) + expect(childMatch._.loadPromise?.status).not.toBe('pending') + }, + ) + + test.each([ + ['client', false, loadClientMatchesArg], + ['server', true, loadServerMatches], + ] as const)( + 'throws redirects from beforeLoad on the %s path without running descendant loaders', + async (_label, isServer, loadImpl) => { + const childLoader = vi.fn() + const rootRoute = new BaseRootRoute({}) + const protectedRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/protected', + beforeLoad: () => { + throw redirect({ to: '/login', statusCode: 302 }) + }, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => protectedRoute, + path: '/child', + loader: childLoader, + }) + const loginRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/login', + }) + const { router, location, matches } = setupDirectLoad({ + isServer, + routeTree: rootRoute.addChildren([ + protectedRoute.addChildren([childRoute]), + loginRoute, + ]), + path: '/protected/child', + }) + + await expect(loadImpl({ router, location, matches })).rejects.toEqual( + expect.objectContaining({ + options: expect.objectContaining({ href: '/login' }), + }), + ) + expect(childLoader).not.toHaveBeenCalled() + }, + ) + + test.each([ + ['client', false, loadClientMatchesArg], + ['server', true, loadServerMatches], + ] as const)( + 'commits loader errors to the matching route on the %s path', + async (_label, isServer, loadImpl) => { + const thrown = new Error('direct loader failed') + const rootRoute = new BaseRootRoute({}) + const errorRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/error', + loader: () => { + throw thrown + }, + errorComponent: () => null, + }) + const { router, location, matches } = setupDirectLoad({ + isServer, + routeTree: rootRoute.addChildren([errorRoute]), + path: '/error', + }) + + if (isServer) { + await expect(loadImpl({ router, location, matches })).rejects.toBe( + thrown, + ) + } else { + await expect(loadImpl({ router, location, matches })).resolves.toBe( + matches, + ) + } + + expect(matches.at(-1)).toEqual( + expect.objectContaining({ + routeId: errorRoute.id, + status: 'error', + error: thrown, + }), + ) + }, + ) +}) +describe('notFound detection', () => { + test('does not treat arbitrary proxy property access as notFound', async () => { const rootRoute = new BaseRootRoute({}) - const parentRoute = new BaseRoute({ + const fooRoute = new BaseRoute({ getParentRoute: () => rootRoute, - path: '/parent', - beforeLoad: parentBeforeLoad, - head: parentHead, - }) - const childRoute = new BaseRoute({ - getParentRoute: () => parentRoute, - path: '/child', - beforeLoad: childBeforeLoad, - head: childHead, + path: '/foo', + loader: () => + new Proxy( + {}, + { + get(_target, prop) { + if (prop === 'isNotFound') return 'truthy-but-not-true' + return undefined + }, + has() { + return true + }, + }, + ), }) + const routeTree = rootRoute.addChildren([fooRoute]) + const router = createTestRouter({ - routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), - history: createMemoryHistory(), + routeTree, + history: createMemoryHistory({ initialEntries: ['/foo'] }), + isServer: true, }) - await router.preloadRoute({ to: '/parent/child' }) - - expect(parentBeforeLoad).toHaveBeenCalledTimes(1) - expect(childBeforeLoad).not.toHaveBeenCalled() - expect(parentHead).toHaveBeenCalledTimes(1) - expect(childHead).not.toHaveBeenCalled() - }) - - test('exec if pending preload (error)', async () => { - const beforeLoad = vi.fn(async ({ preload }) => { - await sleep(100) - if (preload) throw new Error('error') - }) - const router = setup({ - beforeLoad, - }) - router.preloadRoute({ to: '/foo' }) - await Promise.resolve() - await router.navigate({ to: '/foo' }) + await router.load() - expect(beforeLoad).toHaveBeenCalledTimes(2) + expect(router.state.matches.at(-1)?.status).toBe('success') + expect(router.state.matches.at(-1)?.error).toBeUndefined() }) }) -describe('loader skip or exec', () => { - const setup = ({ - loader, - staleTime, - defaultStaleReloadMode, - }: { - loader?: Loader - staleTime?: number - defaultStaleReloadMode?: LoaderStaleReloadMode - }) => { +describe('beforeLoad skip or exec', () => { + const setup = ({ beforeLoad }: { beforeLoad?: BeforeLoad }) => { const rootRoute = new BaseRootRoute({}) const fooRoute = new BaseRoute({ getParentRoute: () => rootRoute, path: '/foo', - loader, - staleTime, - gcTime: staleTime, + beforeLoad, }) const barRoute = new BaseRoute({ @@ -404,7 +749,6 @@ describe('loader skip or exec', () => { const router = createTestRouter({ routeTree, - defaultStaleReloadMode, history: createMemoryHistory(), }) @@ -412,17 +756,17 @@ describe('loader skip or exec', () => { } test('baseline', async () => { - const loader = vi.fn() - const router = setup({ loader }) + const beforeLoad = vi.fn() + const router = setup({ beforeLoad }) await router.load() - expect(loader).toHaveBeenCalledTimes(0) + expect(beforeLoad).toHaveBeenCalledTimes(0) }) test('exec on regular nav', async () => { - const loader = vi.fn(() => Promise.resolve({ hello: 'world' })) - const router = setup({ loader }) + const beforeLoad = vi.fn(() => Promise.resolve({ hello: 'world' })) + const router = setup({ beforeLoad }) const navigation = router.navigate({ to: '/foo' }) - expect(loader).toHaveBeenCalledTimes(1) + expect(beforeLoad).toHaveBeenCalledTimes(1) expect(router.stores.pendingMatches.get()).toEqual( expect.arrayContaining([expect.objectContaining({ id: '/foo/foo' })]), ) @@ -432,1184 +776,7674 @@ describe('loader skip or exec', () => { expect.arrayContaining([ expect.objectContaining({ id: '/foo/foo', - loaderData: { + context: { hello: 'world', }, }), ]), ) - expect(loader).toHaveBeenCalledTimes(1) + expect(beforeLoad).toHaveBeenCalledTimes(1) }) - test('exec if resolved preload (success)', async () => { - const loader = vi.fn() - const router = setup({ loader }) - await router.preloadRoute({ to: '/foo' }) - expect(router.stores.cachedMatches.get()).toEqual( - expect.arrayContaining([expect.objectContaining({ id: '/foo/foo' })]), - ) - await sleep(10) - await router.navigate({ to: '/foo' }) - - expect(loader).toHaveBeenCalledTimes(2) - }) + test('sync beforeLoad starts descendant loaders in the same turn', async () => { + const parentBeforeLoad = vi.fn(() => ({ token: 'sync' })) + const childLoader = vi.fn(({ context }) => context) - test('skip if resolved preload (success) within staleTime duration', async () => { - const loader = vi.fn() - const router = setup({ loader, staleTime: 1000 }) - await router.preloadRoute({ to: '/foo' }) - expect(router.stores.cachedMatches.get()).toEqual( + 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({ initialEntries: ['/parent/child'] }), + }) + const location = router.latestLocation + const matches = router.matchRoutes(location) + router.stores.setPending(matches) + + const loadPromise = loadMatches({ router, location, matches }) + + expect(parentBeforeLoad).toHaveBeenCalledTimes(1) + expect(childLoader).toHaveBeenCalledTimes(1) + expect(childLoader).toHaveReturnedWith( + expect.objectContaining({ + token: 'sync', + }), + ) + + await loadPromise + }) + + test('async beforeLoad delays descendant loaders until it settles', async () => { + const beforeLoadGate = createControlledPromise<{ token: string }>() + const parentBeforeLoad = vi.fn(() => beforeLoadGate) + 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({ initialEntries: ['/parent/child'] }), + }) + const location = router.latestLocation + const matches = router.matchRoutes(location) + router.stores.setPending(matches) + + const loadPromise = loadMatches({ router, location, matches }) + + expect(parentBeforeLoad).toHaveBeenCalledTimes(1) + expect(childLoader).not.toHaveBeenCalled() + + beforeLoadGate.resolve({ token: 'async' }) + await Promise.resolve() + await Promise.resolve() + + expect(childLoader).toHaveBeenCalledTimes(1) + expect(childLoader).toHaveReturnedWith( + expect.objectContaining({ + token: 'async', + }), + ) + + await loadPromise + }) + + test('preserves primitive errors thrown from beforeLoad', async () => { + const beforeLoad = vi.fn(() => { + throw 'primitive error' + }) + const router = setup({ beforeLoad }) + + await router.navigate({ to: '/foo' }) + + expect(router.state.matches).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: '/foo/foo', + status: 'error', + error: 'primitive error', + }), + ]), + ) + }) + + test('does not mutate object errors thrown from beforeLoad', async () => { + const thrown = { type: 'domain-error' } + const beforeLoad = vi.fn(() => { + throw thrown + }) + const router = setup({ beforeLoad }) + + await router.navigate({ to: '/foo' }) + + expect(router.state.matches.find((d) => d.id === '/foo/foo')?.error).toBe( + thrown, + ) + 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('client beforeLoad redirect stays current during basepath navigation', async () => { + const beforeLoad = vi.fn(() => { + throw redirect({ to: '/about' }) + }) + const rootRoute = new BaseRootRoute({}) + const redirectRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/redirect', + beforeLoad, + }) + const aboutRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/about', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([redirectRoute, aboutRoute]), + history: createMemoryHistory({ initialEntries: ['/app/'] }), + basepath: '/app', + isServer: false, + }) + + await router.load() + await router.navigate({ to: '/redirect' }) + + await vi.waitFor(() => + expect(router.state.location.publicHref).toBe('/app/about'), + ) + expect(beforeLoad).toHaveBeenCalledTimes(1) + }) + + test('client beforeLoad redirect stays current during rewritten navigation', async () => { + const rewrite = { + input: ({ url }) => { + if (url.pathname.startsWith('/en/')) { + url.pathname = url.pathname.slice(3) + } + return url + }, + output: ({ url }) => { + url.pathname = `/en${url.pathname}` + return url + }, + } satisfies LocationRewrite + const beforeLoad = vi.fn(() => { + throw redirect({ to: '/about' }) + }) + const rootRoute = new BaseRootRoute({}) + const redirectRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/redirect', + beforeLoad, + }) + const aboutRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/about', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([redirectRoute, aboutRoute]), + history: createMemoryHistory({ initialEntries: ['/en/'] }), + rewrite, + isServer: false, + }) + + await router.load() + await router.navigate({ to: '/redirect' }) + + await vi.waitFor(() => + expect(router.state.location.publicHref).toBe('/en/about'), + ) + 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(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 }) + await router.preloadRoute({ to: '/foo' }) + expect(router.stores.cachedMatches.get()).toEqual( expect.arrayContaining([expect.objectContaining({ id: '/foo/foo' })]), ) await sleep(10) await router.navigate({ to: '/foo' }) - expect(loader).toHaveBeenCalledTimes(1) + expect(beforeLoad).toHaveBeenCalledTimes(2) }) - test('skip if pending preload (success)', async () => { - const loader = vi.fn(() => sleep(100)) - const router = setup({ loader }) + test('exec if pending preload (success)', async () => { + const beforeLoad = vi.fn(() => sleep(100)) + const router = setup({ beforeLoad }) router.preloadRoute({ to: '/foo' }) await Promise.resolve() - expect(router.stores.cachedMatches.get()).toEqual( - expect.arrayContaining([expect.objectContaining({ id: '/foo/foo' })]), - ) + expect(router.stores.cachedMatches.get()).toEqual([]) await router.navigate({ to: '/foo' }) - expect(loader).toHaveBeenCalledTimes(1) + expect(beforeLoad).toHaveBeenCalledTimes(2) }) test('exec if rejected preload (notFound)', async () => { - const loader: Loader = vi.fn(async ({ preload }) => { + const beforeLoad = vi.fn(async ({ preload }) => { if (preload) throw notFound() await Promise.resolve() }) const router = setup({ - loader, + beforeLoad, }) await router.preloadRoute({ to: '/foo' }) await sleep(10) await router.navigate({ to: '/foo' }) - expect(loader).toHaveBeenCalledTimes(2) + expect(beforeLoad).toHaveBeenCalledTimes(2) }) - test('skip if pending preload (notFound)', async () => { - const loader: Loader = vi.fn(async ({ preload }) => { + test('exec if pending preload (notFound)', async () => { + const beforeLoad = vi.fn(async ({ preload }) => { await sleep(100) if (preload) throw notFound() }) const router = setup({ - loader, + beforeLoad, }) router.preloadRoute({ to: '/foo' }) await Promise.resolve() await router.navigate({ to: '/foo' }) - expect(loader).toHaveBeenCalledTimes(1) + expect(beforeLoad).toHaveBeenCalledTimes(2) }) test('exec if rejected preload (redirect)', async () => { - const loader: Loader = vi.fn(async ({ preload }) => { + const beforeLoad = vi.fn(async ({ preload }) => { if (preload) throw redirect({ to: '/bar' }) await Promise.resolve() }) const router = setup({ - loader, + beforeLoad, }) 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) + expect(beforeLoad).toHaveBeenCalledTimes(2) }) - test('skip if pending preload (redirect)', async () => { - const loader: Loader = vi.fn(async ({ preload }) => { + test('exec if pending preload (redirect)', async () => { + const beforeLoad = vi.fn(async ({ preload }) => { await sleep(100) if (preload) throw redirect({ to: '/bar' }) }) const router = setup({ - loader, + beforeLoad, }) 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'), - ).toBe(false) - expect(loader).toHaveBeenCalledTimes(1) - }) - - test('updateMatch removes redirected matches from cachedMatches', async () => { - const loader = vi.fn() - const router = setup({ loader }) - - await router.preloadRoute({ to: '/foo' }) - expect(router.stores.cachedMatches.get()).toEqual( - expect.arrayContaining([expect.objectContaining({ id: '/foo/foo' })]), - ) - - router.updateMatch('/foo/foo', (prev) => ({ - ...prev, - status: 'redirected', - })) - + expect(router.state.location.pathname).toBe('/foo') 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) + expect(beforeLoad).toHaveBeenCalledTimes(2) }) test('exec if rejected preload (error)', async () => { - const loader: Loader = vi.fn(async ({ preload }) => { + const beforeLoad = vi.fn(async ({ preload }) => { if (preload) throw new Error('error') await Promise.resolve() }) const router = setup({ - loader, + beforeLoad, }) await router.preloadRoute({ to: '/foo' }) await sleep(10) await router.navigate({ to: '/foo' }) - expect(loader).toHaveBeenCalledTimes(2) + expect(beforeLoad).toHaveBeenCalledTimes(2) }) - test('skip if pending preload (error)', async () => { - const loader: Loader = vi.fn(async ({ preload }) => { - await sleep(100) - if (preload) throw new Error('error') - }) - const router = setup({ - loader, + test('skip child beforeLoad when parent beforeLoad throws during preload', async () => { + const parentBeforeLoad = vi.fn(async ({ preload }) => { + if (preload) throw new Error('parent error') }) - router.preloadRoute({ to: '/foo' }) - await Promise.resolve() - await router.navigate({ to: '/foo' }) - - expect(loader).toHaveBeenCalledTimes(1) - }) -}) + const childBeforeLoad = vi.fn() + const parentHead = vi.fn(() => ({ meta: [{ title: 'Parent' }] })) + const childHead = vi.fn(() => ({ meta: [{ title: 'Child' }] })) -test('exec on stay (beforeLoad & loader)', async () => { - let rootBeforeLoadResolved = false - const rootBeforeLoad = vi.fn(async () => { - await sleep(10) - rootBeforeLoadResolved = true - }) - const rootLoader = vi.fn(() => sleep(10)) - const rootRoute = new BaseRootRoute({ - beforeLoad: rootBeforeLoad, - loader: rootLoader, - }) + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + beforeLoad: parentBeforeLoad, + head: parentHead, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + beforeLoad: childBeforeLoad, + head: childHead, + }) - let layoutBeforeLoadResolved = false - const layoutBeforeLoad = vi.fn(async () => { - await sleep(10) - layoutBeforeLoadResolved = true - }) - const layoutLoader = vi.fn(() => sleep(10)) - const layoutRoute = new BaseRoute({ - getParentRoute: () => rootRoute, - beforeLoad: layoutBeforeLoad, - loader: layoutLoader, - id: '/_layout', - }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory(), + }) - const fooRoute = new BaseRoute({ - getParentRoute: () => layoutRoute, - path: '/foo', - }) - const barRoute = new BaseRoute({ - getParentRoute: () => layoutRoute, - path: '/bar', + await router.preloadRoute({ to: '/parent/child' }) + + expect(parentBeforeLoad).toHaveBeenCalledTimes(1) + expect(childBeforeLoad).not.toHaveBeenCalled() + expect(parentHead).not.toHaveBeenCalled() + expect(childHead).not.toHaveBeenCalled() }) - const routeTree = rootRoute.addChildren([ - layoutRoute.addChildren([fooRoute, barRoute]), - ]) + 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 router = createTestRouter({ - routeTree, - history: createMemoryHistory(), - defaultStaleTime: 1000, - defaultGcTime: 1000, - }) + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + beforeLoad: parentBeforeLoad, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: childLoader, + }) - await router.navigate({ to: '/foo' }) - expect(router.state.location.pathname).toBe('/foo') + const router = createTestRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory(), + }) - rootBeforeLoadResolved = false - layoutBeforeLoadResolved = false - vi.clearAllMocks() + const navigation = router.navigate({ to: '/parent' }) + await Promise.resolve() + expect(parentBeforeLoad).toHaveBeenCalledTimes(1) - /* - * When navigating between sibling routes, - * do the parent routes get re-executed? - */ + const preload = router.preloadRoute({ to: '/parent/child' }) + await Promise.resolve() + expect(childLoader).not.toHaveBeenCalled() - await router.navigate({ to: '/bar' }) - expect(router.state.location.pathname).toBe('/bar') + parentBeforeLoadPromise.resolve({ auth: 'ok' }) + await navigation + await preload - // beforeLoads always re-execute - expect(rootBeforeLoad).toHaveBeenCalledTimes(1) - expect(layoutBeforeLoad).toHaveBeenCalledTimes(1) + expect(childLoader).toHaveBeenCalledTimes(1) + expect(childLoader.mock.calls[0]?.[0].context).toMatchObject({ + auth: 'ok', + }) + }) - // beforeLoads are called in order - expect(rootBeforeLoad.mock.invocationCallOrder[0]).toBeLessThan( - layoutBeforeLoad.mock.invocationCallOrder[0]!, - ) + test('joined preload waits for borrowed parent success while a sibling is still loading', async () => { + const siblingLoaderPromise = createControlledPromise() + const parentLoader = vi.fn(() => ({ auth: 'ok' })) + const siblingLoader = vi.fn(() => siblingLoaderPromise) + const childLoader = vi.fn(() => ({ child: 'preloaded' })) - // loaders are skipped because of staleTime - expect(rootLoader).toHaveBeenCalledTimes(0) - expect(layoutLoader).toHaveBeenCalledTimes(0) + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: parentLoader, + }) + const siblingRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/sibling', + loader: siblingLoader, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: childLoader, + }) - // beforeLoad calls were correctly awaited - expect(rootBeforeLoadResolved).toBe(true) - expect(layoutBeforeLoadResolved).toBe(true) -}) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + parentRoute.addChildren([siblingRoute, childRoute]), + ]), + history: createMemoryHistory(), + }) -describe('stale loader reload triggers', () => { - beforeEach(() => { - vi.useFakeTimers() - vi.setSystemTime(0) - }) + await router.load() - afterEach(() => { - vi.useRealTimers() - }) + const navigation = router.navigate({ to: '/parent/sibling' }) + await vi.waitFor(() => expect(siblingLoader).toHaveBeenCalledTimes(1)) - const getMatchById = ( - router: RouterCore, - id: string, - ) => - router.state.matches.find((match) => match.id === id) ?? - router.stores.pendingMatches.get().find((match) => match.id === id) ?? - router.stores.cachedMatches.get().find((match) => match.id === id) + const preloadSettled = vi.fn() + const preload = router.preloadRoute({ to: '/parent/child' }) + preload.then(preloadSettled) + await Promise.resolve() - const hasActiveMatch = ( - router: RouterCore, - id: string, - ) => router.state.matches.some((match) => match.id === id) + expect(childLoader).not.toHaveBeenCalled() + expect(preloadSettled).not.toHaveBeenCalled() - const hasPendingMatch = ( - router: RouterCore, - id: string, - ) => - router.stores.pendingMatches.get().some((match) => match.id === id) ?? false + siblingLoaderPromise.resolve() + await Promise.all([navigation, preload]) - const setup = ({ - loader, - staleTime, - defaultStaleReloadMode, - }: { - loader?: Loader - staleTime?: number - defaultStaleReloadMode?: LoaderStaleReloadMode - }) => { - const rootRoute = new BaseRootRoute({}) + expect(childLoader).toHaveBeenCalledTimes(1) + expect(preloadSettled).toHaveBeenCalledTimes(1) + }) - const fooRoute = new BaseRoute({ - getParentRoute: () => rootRoute, - path: '/foo', - loader, - staleTime, - gcTime: 60_000, + test('joined preload waits for borrowed terminal error to commit', async () => { + const parentError = new Error('parent failed') + const parentLoader = vi.fn(() => { + throw parentError }) + const childLoader = vi.fn(() => ({ child: 'preloaded' })) - const barRoute = new BaseRoute({ + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ getParentRoute: () => rootRoute, - path: '/bar', + path: '/parent', + loader: parentLoader, + errorComponent: () => null, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: childLoader, }) - const routeTree = rootRoute.addChildren([fooRoute, barRoute]) - - return createTestRouter({ - routeTree, - defaultStaleReloadMode, + const router = createTestRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), history: createMemoryHistory(), }) - } + const finalCommit = gateFinalCommit(router) - const createControlledStaleReload = () => { - let resolveStaleReload: (() => void) | undefined - let callCount = 0 + try { + const navigation = router.navigate({ to: '/parent' }) + await vi.waitFor(() => expect(finalCommit.started).toHaveBeenCalled()) - const loader = vi.fn(() => { - callCount += 1 - if (callCount === 1) { - return { value: 'first' } - } + const preloadSettled = vi.fn() + const preload = router.preloadRoute({ to: '/parent/child' }) + preload.then(preloadSettled) + await Promise.resolve() - return new Promise<{ value: string }>((resolve) => { - resolveStaleReload = () => resolve({ value: 'second' }) - }) - }) + expect(preloadSettled).not.toHaveBeenCalled() + expect(childLoader).not.toHaveBeenCalled() - return { - loader, - resolveStaleReload: () => resolveStaleReload?.(), + finalCommit.gate.resolve() + const [, preloadResult] = await Promise.all([navigation, preload]) + + expect(preloadSettled).toHaveBeenCalledTimes(1) + expect(preloadResult).toBeUndefined() + expect(childLoader).not.toHaveBeenCalled() + } finally { + finalCommit.restore() } - } + }) - const expectBlockingStaleReloadBehavior = async ( - router: RouterCore, - loader: ReturnType, - resolveStaleReload: () => void, - ) => { - await router.navigate({ to: '/foo' }) - expect(loader).toHaveBeenCalledTimes(1) - expect(getMatchById(router, '/foo/foo')?.loaderData).toEqual({ - value: 'first', - }) + test('preload canceled by borrowed redirect projects no assets and caches nothing', async () => { + vi.useFakeTimers() - await vi.advanceTimersByTimeAsync(1) - await router.navigate({ to: '/bar' }) - await vi.advanceTimersByTimeAsync(1) + try { + let rejectParent!: (error: unknown) => void + const parentLoader = vi.fn( + () => + new Promise((_resolve, reject) => { + rejectParent = reject + }), + ) + const childLoader = vi.fn(() => ({ child: 'preloaded' })) + const childHead = vi.fn(() => ({})) + const targetLoader = vi.fn(() => ({ target: true })) - const revisit = router.navigate({ to: '/foo' }) - await Promise.resolve() + 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, + head: childHead, + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + loader: targetLoader, + }) - expect(loader).toHaveBeenCalledTimes(2) - expect(hasActiveMatch(router, '/bar/bar')).toBe(true) - expect(hasActiveMatch(router, '/foo/foo')).toBe(false) - expect(hasPendingMatch(router, '/foo/foo')).toBe(true) - expect(getMatchById(router, '/foo/foo')?.loaderData).toEqual({ - value: 'first', - }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([childRoute]), + targetRoute, + ]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) - resolveStaleReload() - await revisit + await router.load() - expect(loader).toHaveBeenCalledTimes(2) - expect(hasActiveMatch(router, '/foo/foo')).toBe(true) - expect(hasPendingMatch(router, '/foo/foo')).toBe(false) - expect(router.state.location.pathname).toBe('/foo') - expect(getMatchById(router, '/foo/foo')?.loaderData).toEqual({ - value: 'second', - }) - } + const navigation = router.navigate({ to: '/parent' }) + await vi.waitFor(() => expect(parentLoader).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 expectBackgroundStaleReloadBehavior = async ( - router: RouterCore, - loader: ReturnType, - resolveStaleReload: () => void, - ) => { - await router.navigate({ to: '/foo' }) - expect(loader).toHaveBeenCalledTimes(1) - - await vi.advanceTimersByTimeAsync(1) - await router.navigate({ to: '/bar' }) - await vi.advanceTimersByTimeAsync(1) - - const revisit = router.navigate({ to: '/foo' }) - - expect(loader).toHaveBeenCalledTimes(2) - - await revisit - const backgroundReloadPromise = getMatchById(router, '/foo/foo') - ?._nonReactive.loaderPromise - - expect(backgroundReloadPromise).toBeDefined() - expect(hasActiveMatch(router, '/foo/foo')).toBe(true) - expect(hasPendingMatch(router, '/foo/foo')).toBe(false) - expect(router.state.location.pathname).toBe('/foo') - expect(getMatchById(router, '/foo/foo')?.loaderData).toEqual({ - value: 'first', - }) + const preload = router.preloadRoute({ to: '/parent/child' }) + await Promise.resolve() - resolveStaleReload() - await backgroundReloadPromise + expect(childLoader).not.toHaveBeenCalled() + expect(childHead).not.toHaveBeenCalled() + + rejectParent(redirect({ to: '/target' })) + const [, preloadResult] = await Promise.all([navigation, preload]) + + expect(router.state.location.pathname).toBe('/target') + expect(preloadResult).toBeUndefined() + expect(childLoader).not.toHaveBeenCalled() + expect(childHead).not.toHaveBeenCalled() + expect(targetLoader).toHaveBeenCalledTimes(1) + expect( + router.stores.cachedMatches + .get() + .some((match) => match.routeId === childRoute.id), + ).toBe(false) + } finally { + vi.useRealTimers() + } + }) - expect(getMatchById(router, '/foo/foo')?.loaderData).toEqual({ - value: 'second', - }) - } + test('joined preload waits for borrowed notFound to commit', async () => { + const parentLoader = vi.fn(() => notFound()) + const childLoader = vi.fn(() => ({ child: 'preloaded' })) - test('skips stale loader when only unrelated search params change', async () => { const rootRoute = new BaseRootRoute({}) - const loader = vi.fn(() => ({ ok: true })) - - const fooRoute = new BaseRoute({ + const parentRoute = new BaseRoute({ getParentRoute: () => rootRoute, - path: '/foo', - loader, - staleTime: 0, - gcTime: 0, - loaderDeps: ({ search }: { search: Record }) => ({ - page: search['page'], - }), + path: '/parent', + loader: parentLoader, + notFoundComponent: () => null, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: childLoader, }) - const routeTree = rootRoute.addChildren([fooRoute]) const router = createTestRouter({ - routeTree, + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), history: createMemoryHistory(), }) + const finalCommit = gateFinalCommit(router) - await router.navigate({ to: '/foo', search: { page: '1', filter: 'a' } }) - expect(loader).toHaveBeenCalledTimes(1) + try { + const navigation = router.navigate({ to: '/parent' }) + await vi.waitFor(() => expect(finalCommit.started).toHaveBeenCalled()) - await router.navigate({ to: '/foo', search: { page: '1', filter: 'b' } }) + const preloadSettled = vi.fn() + const preload = router.preloadRoute({ to: '/parent/child' }) + preload.then(preloadSettled) + await Promise.resolve() - expect(loader).toHaveBeenCalledTimes(1) + expect(preloadSettled).not.toHaveBeenCalled() + expect(childLoader).not.toHaveBeenCalled() + + finalCommit.gate.resolve() + const [, preloadResult] = await Promise.all([navigation, preload]) + + expect(preloadSettled).toHaveBeenCalledTimes(1) + expect(preloadResult).toBeUndefined() + expect(childLoader).not.toHaveBeenCalled() + } finally { + finalCommit.restore() + } }) - test('reloads stale loader when loader deps change', async () => { - const rootRoute = new BaseRootRoute({}) - const loader = vi.fn(() => ({ ok: true })) + test('preload canceled by borrowed notFound does not load the borrowed boundary component', async () => { + const notFoundPreload = vi.fn() + const parentLoader = vi.fn(() => notFound()) + const childLoader = vi.fn(() => ({ child: 'preloaded' })) - const fooRoute = new BaseRoute({ + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ getParentRoute: () => rootRoute, - path: '/foo', - loader, - staleTime: 0, - gcTime: 0, - loaderDeps: ({ search }: { search: Record }) => ({ - page: search['page'], - }), + path: '/parent', + loader: parentLoader, + notFoundComponent: { preload: notFoundPreload } as any, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: childLoader, }) - const routeTree = rootRoute.addChildren([fooRoute]) const router = createTestRouter({ - routeTree, + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), history: createMemoryHistory(), }) + const finalCommit = gateFinalCommit(router) - await router.navigate({ to: '/foo', search: { page: '1' } }) - expect(loader).toHaveBeenCalledTimes(1) + try { + const navigation = router.navigate({ to: '/parent' }) + await vi.waitFor(() => expect(finalCommit.started).toHaveBeenCalled()) + expect(notFoundPreload).toHaveBeenCalledTimes(1) - await router.navigate({ to: '/foo', search: { page: '2' } }) + const preload = router.preloadRoute({ to: '/parent/child' }) + await Promise.resolve() - expect(loader).toHaveBeenCalledTimes(2) + expect(childLoader).not.toHaveBeenCalled() + expect(notFoundPreload).toHaveBeenCalledTimes(1) + + finalCommit.gate.resolve() + const [, preloadResult] = await Promise.all([navigation, preload]) + + expect(preloadResult).toBeUndefined() + expect(childLoader).not.toHaveBeenCalled() + expect(notFoundPreload).toHaveBeenCalledTimes(1) + expect( + router.stores.cachedMatches + .get() + .some((match) => match.routeId === childRoute.id), + ).toBe(false) + } finally { + finalCommit.restore() + } }) - test('reloads a stale preloaded loader when switching to a different match id of the same route', async () => { - const rootRoute = new BaseRootRoute({}) - const rootLoader = vi.fn(() => ({ ok: true })) - const childLoader = vi.fn(() => ({ ok: true })) + test('preload canceled by borrowed owner disappearance projects no assets and caches nothing', async () => { + const parentLoader = vi.fn(() => ({ auth: 'ok' })) + const childLoader = vi.fn(() => ({ child: 'preloaded' })) + const childHead = vi.fn(() => ({})) - const rootChildRoute = new BaseRoute({ + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ getParentRoute: () => rootRoute, - path: '/posts', - loader: rootLoader, - staleTime: 0, - gcTime: 0, - loaderDeps: ({ search }: { search: Record }) => ({ - page: search['page'], - }), + path: '/', }) - - const leafRoute = new BaseRoute({ - getParentRoute: () => rootChildRoute, - path: '/$postId', + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: parentLoader, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', loader: childLoader, - staleTime: 0, - gcTime: 0, + head: childHead, + }) + const otherRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/other', }) - const routeTree = rootRoute.addChildren([ - rootChildRoute.addChildren([leafRoute]), - ]) const router = createTestRouter({ - routeTree, - history: createMemoryHistory(), + routeTree: rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([childRoute]), + otherRoute, + ]), + history: createMemoryHistory({ initialEntries: ['/'] }), }) - await router.navigate({ - to: '/posts/$postId', - params: { postId: '1' }, - search: { page: '1' }, - }) + await router.load() - expect(rootLoader).toHaveBeenCalledTimes(1) - expect(childLoader).toHaveBeenCalledTimes(1) + const finalCommit = gateFinalCommit(router) + try { + const parentNavigation = router.navigate({ to: '/parent' }) + await vi.waitFor(() => expect(finalCommit.started).toHaveBeenCalled()) - await router.preloadRoute({ - to: '/posts/$postId', - params: { postId: '2' }, - search: { page: '2' }, - }) + const preload = router.preloadRoute({ to: '/parent/child' }) + await Promise.resolve() + expect(childLoader).not.toHaveBeenCalled() + + const otherNavigation = router.navigate({ to: '/other' }) + finalCommit.gate.resolve() + const [, , preloadResult] = await Promise.all([ + parentNavigation, + otherNavigation, + preload, + ]) - expect(rootLoader).toHaveBeenCalledTimes(2) - expect(childLoader).toHaveBeenCalledTimes(2) + expect(router.state.location.pathname).toBe('/other') + expect(preloadResult).toBeUndefined() + expect(childLoader).not.toHaveBeenCalled() + expect(childHead).not.toHaveBeenCalled() + expect( + router.stores.cachedMatches + .get() + .some((match) => match.routeId === childRoute.id), + ).toBe(false) + } finally { + finalCommit.restore() + } + }) - await vi.advanceTimersByTimeAsync(1) + 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) - await router.navigate({ - to: '/posts/$postId', - params: { postId: '2' }, - search: { page: '2' }, - }) + 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', + }) - expect(rootLoader).toHaveBeenCalledTimes(3) - expect(childLoader).toHaveBeenCalledTimes(3) + 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() + + expect( + router.stores.cachedMatches + .get() + .some((match) => match.routeId === childRoute.id), + ).toBe(false) + + 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) + } finally { + consoleError.mockRestore() + vi.useRealTimers() + } }) - test('skips stale ancestor loader when only a child path param changes', async () => { - const rootRoute = new BaseRootRoute({}) - const parentLoader = vi.fn(() => ({ ok: true })) - const childLoader = vi.fn(() => ({ ok: true })) + 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 orgRoute = new BaseRoute({ + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ getParentRoute: () => rootRoute, - path: '/orgs/$orgId', - loader: parentLoader, - staleTime: 0, - gcTime: 0, + 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 userRoute = new BaseRoute({ - getParentRoute: () => orgRoute, - path: '/users/$userId', - loader: childLoader, - staleTime: 0, - gcTime: 0, + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + head: childHead, }) - const routeTree = rootRoute.addChildren([orgRoute.addChildren([userRoute])]) const router = createTestRouter({ - routeTree, - history: createMemoryHistory(), + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory({ + initialEntries: ['/parent/child?fail=false'], + }), }) - await router.navigate({ - to: '/orgs/$orgId/users/$userId', - params: { orgId: 'acme', userId: 'u1' }, - }) - expect(parentLoader).toHaveBeenCalledTimes(1) - expect(childLoader).toHaveBeenCalledTimes(1) + await router.load() + + expect(router.state.matches.map((match) => match.routeId)).toContain( + childRoute.id, + ) + expect(childHead).toHaveBeenCalledTimes(1) await router.navigate({ - to: '/orgs/$orgId/users/$userId', - params: { orgId: 'acme', userId: 'u2' }, - }) + to: '/parent/child', + search: { fail: true }, + } as never) - expect(parentLoader).toHaveBeenCalledTimes(1) - expect(childLoader).toHaveBeenCalledTimes(2) + 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('reloads stale ancestor loader when its own path param changes', async () => { - const rootRoute = new BaseRootRoute({}) - const parentLoader = vi.fn(() => ({ ok: true })) - const childLoader = vi.fn(() => ({ ok: true })) + 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 orgRoute = new BaseRoute({ + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ getParentRoute: () => rootRoute, - path: '/orgs/$orgId', - loader: parentLoader, - staleTime: 0, - gcTime: 0, + 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 userRoute = new BaseRoute({ - getParentRoute: () => orgRoute, - path: '/users/$userId', - loader: childLoader, - staleTime: 0, - gcTime: 0, - }) - - const routeTree = rootRoute.addChildren([orgRoute.addChildren([userRoute])]) const router = createTestRouter({ - routeTree, - history: createMemoryHistory(), + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory({ + initialEntries: ['/parent/child?fail=false'], + }), }) - await router.navigate({ - to: '/orgs/$orgId/users/$userId', - params: { orgId: 'acme', userId: 'u1' }, - }) - expect(parentLoader).toHaveBeenCalledTimes(1) - expect(childLoader).toHaveBeenCalledTimes(1) + await router.load() + + expect(router.state.matches.map((match) => match.routeId)).toContain( + childRoute.id, + ) + expect(childHead).toHaveBeenCalledTimes(1) await router.navigate({ - to: '/orgs/$orgId/users/$userId', - params: { orgId: 'beta', userId: 'u2' }, - }) + to: '/parent/child', + search: { fail: true }, + } as never) - expect(parentLoader).toHaveBeenCalledTimes(2) - expect(childLoader).toHaveBeenCalledTimes(2) + 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('revalidates stale loaders on explicit same-location router.load()', async () => { - const rootRoute = new BaseRootRoute({}) - const loader = vi.fn(() => ({ ok: true })) + test('loader errors trim to the shallowest failed match when child fails first', async () => { + const parentGate = createControlledPromise() + const parentHead = vi.fn(({ match }) => ({ + meta: [{ title: match.error ? 'Parent error' : 'Parent success' }], + })) + const childHead = vi.fn(() => ({ + meta: [{ title: 'Child error' }], + })) + const parentLoader = vi.fn(async () => { + await parentGate + throw new Error('Parent loader failed') + }) + const childLoader = vi.fn(() => { + throw new Error('Child loader failed') + }) - const fooRoute = new BaseRoute({ + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ getParentRoute: () => rootRoute, - path: '/foo', - loader, - staleTime: 0, - gcTime: 0, - loaderDeps: ({ search }: { search: Record }) => ({ - page: search['page'], - }), + path: '/parent', + loader: parentLoader, + head: parentHead, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: childLoader, + head: childHead, }) - const routeTree = rootRoute.addChildren([fooRoute]) const router = createTestRouter({ - routeTree, + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), history: createMemoryHistory(), }) - await router.navigate({ to: '/foo', search: { page: '1', filter: 'a' } }) - expect(loader).toHaveBeenCalledTimes(1) + const navigation = router.navigate({ to: '/parent/child' }) + await vi.waitFor(() => expect(childLoader).toHaveBeenCalledTimes(1)) - await vi.advanceTimersByTimeAsync(1) - await router.load() - await Promise.resolve() + parentGate.resolve() + await navigation - expect(loader).toHaveBeenCalledTimes(2) + 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(1) + expect(childHead).not.toHaveBeenCalled() }) - test('supports object-form loader handler', async () => { - const handler = vi.fn(() => ({ ok: true })) - const router = setup({ - loader: { - handler, - } satisfies LoaderEntry, + test('latest load trims when it joins an existing parent loader that later errors', async () => { + const parentGate = createControlledPromise() + const parentLoader = vi.fn(async () => { + await parentGate + throw new Error('Parent loader failed') }) + const childHead = vi.fn(() => ({ + meta: [{ title: 'Child should not render' }], + })) - await router.navigate({ to: '/foo' }) + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: parentLoader, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + head: childHead, + }) - expect(handler).toHaveBeenCalledTimes(1) - expect(router.state.matches).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: '/foo/foo', - loaderData: { ok: true }, - }), - ]), - ) - }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory(), + }) - test('reloads stale loaders in the background by default', async () => { - const { loader, resolveStaleReload } = createControlledStaleReload() - const router = setup({ loader, staleTime: 0 }) + const staleNavigation = router.navigate({ to: '/parent/child' }) + await vi.waitFor(() => expect(parentLoader).toHaveBeenCalledTimes(1)) - await expectBackgroundStaleReloadBehavior( - router, - loader, - resolveStaleReload, - ) + const latestLoad = router.load() + parentGate.resolve() + await Promise.allSettled([staleNavigation, latestLoad]) + + expect(parentLoader).toHaveBeenCalledTimes(2) + 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(childHead).not.toHaveBeenCalled() }) + ;[ + { name: 'false', value: false }, + { name: '0', value: 0 }, + { name: 'empty string', value: '' }, + { name: 'null', value: null }, + { name: 'undefined', value: undefined }, + ].forEach(({ name, value }) => { + test(`latest load trims when it joins a parent loader that rejects with ${name}`, async () => { + const parentGate = createControlledPromise() + const parentLoader = vi.fn(async () => { + await parentGate + throw value + }) + const childHead = vi.fn(() => ({ + meta: [{ title: 'Child should not render' }], + })) - test('blocks stale reloads when loader staleReloadMode is blocking', async () => { - const { loader, resolveStaleReload } = createControlledStaleReload() - const router = setup({ - staleTime: 0, - loader: { - handler: loader, - staleReloadMode: 'blocking', - } satisfies LoaderEntry, - }) + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: parentLoader, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + head: childHead, + }) - await expectBlockingStaleReloadBehavior(router, loader, resolveStaleReload) - }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + parentRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory(), + }) - test('blocks stale reloads when defaultStaleReloadMode is blocking', async () => { - const { loader, resolveStaleReload } = createControlledStaleReload() - const router = setup({ - loader, - staleTime: 0, - defaultStaleReloadMode: 'blocking', - }) + const staleNavigation = router.navigate({ to: '/parent/child' }) + await vi.waitFor(() => expect(parentLoader).toHaveBeenCalledTimes(1)) - await expectBlockingStaleReloadBehavior(router, loader, resolveStaleReload) - }) + const latestLoad = router.load() + parentGate.resolve() + await Promise.allSettled([staleNavigation, latestLoad]) - test('loader staleReloadMode overrides defaultStaleReloadMode', async () => { - const { loader, resolveStaleReload } = createControlledStaleReload() - const router = setup({ - staleTime: 0, - defaultStaleReloadMode: 'blocking', - loader: { - handler: loader, - staleReloadMode: 'background', - } satisfies LoaderEntry, + const parentMatch = router.state.matches.find( + (match) => match.routeId === parentRoute.id, + ) + const expectedError = + value === undefined + ? expect.objectContaining({ + message: 'Route load failed with undefined', + }) + : value + + expect(parentLoader).toHaveBeenCalledTimes(2) + expect(router.state.matches.map((match) => match.routeId)).toEqual([ + rootRoute.id, + parentRoute.id, + ]) + expect(parentMatch?.status).toBe('error') + expect(parentMatch?.error).toEqual(expectedError) + expect(childHead).not.toHaveBeenCalled() }) - - await expectBackgroundStaleReloadBehavior( - router, - loader, - 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('pending publication runs no lifecycle or cache reconciliation before beforeLoad error final commit', async () => { + vi.useFakeTimers() + + try { + const beforeLoadGate = createControlledPromise() + const parentOnEnter = vi.fn() + const parentOnStay = vi.fn() + const childOnEnter = vi.fn() + const childOnLeave = vi.fn() + const parentBeforeLoad = vi.fn(async () => { + await beforeLoadGate + throw new Error('Parent beforeLoad failed') }) - }, - pendingComponent: {}, - }) - const barRoute = new BaseRoute({ - getParentRoute: () => rootRoute, - path: '/bar', + + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + beforeLoad: parentBeforeLoad, + pendingMs: 1, + pendingComponent: {}, + onEnter: parentOnEnter, + onStay: parentOnStay, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + onEnter: childOnEnter, + onLeave: childOnLeave, + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + parentRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory(), + }) + const setPending = vi.spyOn(router.stores, 'setPending') + const setCached = vi.spyOn(router.stores, 'setCached') + const setLoadedAt = vi.spyOn(router.stores.loadedAt, 'set') + const setIsLoading = vi.spyOn(router.stores.isLoading, 'set') + + const navigation = router.navigate({ to: '/parent/child' }) + await vi.waitFor(() => expect(parentBeforeLoad).toHaveBeenCalledTimes(1)) + + const setCachedBeforePending = setCached.mock.calls.length + const setLoadedAtBeforePending = setLoadedAt.mock.calls.length + const setIsLoadingBeforePending = setIsLoading.mock.calls.length + + await vi.advanceTimersByTimeAsync(1) + await vi.waitFor(() => + expect(router.state.matches.map((match) => match.routeId)).toEqual([ + rootRoute.id, + parentRoute.id, + childRoute.id, + ]), + ) + const setPendingAfterPending = setPending.mock.calls.length + const setCachedAfterPending = setCached.mock.calls.length + const setLoadedAtAfterPending = setLoadedAt.mock.calls.length + const setIsLoadingAfterPending = setIsLoading.mock.calls.length + + expect(parentOnEnter).not.toHaveBeenCalled() + expect(parentOnStay).not.toHaveBeenCalled() + expect(childOnEnter).not.toHaveBeenCalled() + expect(childOnLeave).not.toHaveBeenCalled() + expect(setCached).toHaveBeenCalledTimes(setCachedBeforePending) + expect(setLoadedAt).toHaveBeenCalledTimes(setLoadedAtBeforePending) + expect(setIsLoading).toHaveBeenCalledTimes(setIsLoadingBeforePending) + + beforeLoadGate.resolve() + await navigation + + 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(parentOnEnter).toHaveBeenCalledTimes(1) + expect(parentOnStay).not.toHaveBeenCalled() + expect(childOnEnter).not.toHaveBeenCalled() + expect(childOnLeave).not.toHaveBeenCalled() + expect(setPending).toHaveBeenCalledTimes(setPendingAfterPending) + expect(setCached).toHaveBeenCalledTimes(setCachedAfterPending + 1) + expect(setLoadedAt).toHaveBeenCalledTimes(setLoadedAtAfterPending + 1) + expect(setIsLoading).toHaveBeenCalledTimes(setIsLoadingAfterPending + 1) + } finally { + vi.restoreAllMocks() + vi.useRealTimers() + } }) - const routeTree = rootRoute.addChildren([fooRoute, barRoute]) - const router = createTestRouter({ routeTree, history: createMemoryHistory() }) - await router.load() - router.navigate({ to: '/foo' }) - await sleep(WAIT_TIME * 30) + test('loader error commits final renderable prefix after pending UI renders', async () => { + vi.useFakeTimers() - // At this point, pending timeout should have triggered - const fooMatch = router.getMatch('/foo/foo') - expect(fooMatch).toBeDefined() + try { + const loaderGate = createControlledPromise() + const parentLoader = vi.fn(async () => { + await loaderGate + throw new Error('Parent loader failed') + }) - // Navigate away, which should cancel the pending match - await router.navigate({ to: '/bar' }) - await router.latestLoadPromise + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: parentLoader, + pendingMs: 1, + pendingComponent: {}, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + }) - expect(router.state.location.pathname).toBe('/bar') + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + parentRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory(), + }) - // Verify that abort was called and pending timeout was cleared - expect(onAbortMock).toHaveBeenCalled() - const cancelledFooMatch = router.getMatch('/foo/foo') - expect(cancelledFooMatch?._nonReactive.pendingTimeout).toBeUndefined() -}) + const navigation = router.navigate({ to: '/parent/child' }) + await vi.waitFor(() => expect(parentLoader).toHaveBeenCalledTimes(1)) -describe('head execution', () => { - const setupBeforeLoadNotFoundHierarchy = (throwAtIndex: 1 | 2 | 3) => { - const loaderResolvers: Array<(() => void) | undefined> = [] + await vi.advanceTimersByTimeAsync(1) + await vi.waitFor(() => + expect(router.state.matches.map((match) => match.routeId)).toEqual([ + rootRoute.id, + parentRoute.id, + childRoute.id, + ]), + ) - const makeLoader = (index: number) => - vi.fn(async () => { - await new Promise((resolve) => { - loaderResolvers[index] = resolve - }) - return { level: index } - }) + loaderGate.resolve() + await navigation - const makeHead = (label: string) => - vi.fn(() => ({ meta: [{ title: label }] })) + 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') + } finally { + vi.useRealTimers() + } + }) - const rootRoute = new BaseRootRoute({ - loader: makeLoader(0), - head: makeHead('Root'), - }) + test('loader success after pending UI renders performs final reconciliation once', async () => { + vi.useFakeTimers() - const level1Route = new BaseRoute({ - getParentRoute: () => rootRoute, - path: '/level-1', - loader: makeLoader(1), - head: makeHead('Level 1'), - beforeLoad: - throwAtIndex === 1 - ? () => { - throw notFound() - } - : undefined, - }) + try { + const loaderGate = createControlledPromise() + const parentLoader = vi.fn(async () => { + await loaderGate + }) - const level2Route = new BaseRoute({ - getParentRoute: () => level1Route, - path: '/level-2', - loader: makeLoader(2), - head: makeHead('Level 2'), - beforeLoad: - throwAtIndex === 2 - ? () => { - throw notFound() - } - : undefined, - }) + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: parentLoader, + pendingMs: 1, + pendingComponent: {}, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + }) - const level3Route = new BaseRoute({ - getParentRoute: () => level2Route, - path: '/level-3', - loader: makeLoader(3), - head: makeHead('Level 3'), - beforeLoad: - throwAtIndex === 3 - ? () => { - throw notFound() - } - : undefined, - }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + parentRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory(), + }) + const setMatches = vi.spyOn(router.stores, 'setMatches') + const setCached = vi.spyOn(router.stores, 'setCached') + + const navigation = router.navigate({ to: '/parent/child' }) + await vi.waitFor(() => expect(parentLoader).toHaveBeenCalledTimes(1)) + + await vi.advanceTimersByTimeAsync(1) + await vi.waitFor(() => + expect(router.state.matches.map((match) => match.routeId)).toEqual([ + rootRoute.id, + parentRoute.id, + childRoute.id, + ]), + ) + const setMatchesAfterPending = setMatches.mock.calls.length + const setCachedAfterPending = setCached.mock.calls.length - const routeTree = rootRoute.addChildren([ - level1Route.addChildren([level2Route.addChildren([level3Route])]), - ]) + loaderGate.resolve() + await navigation - const router = createTestRouter({ - routeTree, - history: createMemoryHistory({ - initialEntries: ['/level-1/level-2/level-3'], - }), - }) + expect(router.state.matches.map((match) => match.routeId)).toEqual([ + rootRoute.id, + parentRoute.id, + childRoute.id, + ]) + expect(setMatches).toHaveBeenCalledTimes(setMatchesAfterPending + 1) + expect(setCached).toHaveBeenCalledTimes(setCachedAfterPending + 1) + } finally { + vi.restoreAllMocks() + vi.useRealTimers() + } + }) - const routes = [rootRoute, level1Route, level2Route, level3Route] as const - const loaders = routes.map( - (route) => route.options.loader as ReturnType, - ) - const heads = routes.map( - (route) => route.options.head as ReturnType, - ) + test('pending publication does not consume the final view transition', async () => { + vi.useFakeTimers() - return { - router, - routes, - loaders, - heads, - loaderResolvers, - throwAtIndex, - } - } + try { + const loaderGate = createControlledPromise<{ value: string }>() + const childLoader = vi.fn(() => loaderGate) - const assertBeforeLoadNotFoundHierarchy = async (throwAtIndex: 1 | 2 | 3) => { - const { router, routes, loaders, heads, loaderResolvers } = - setupBeforeLoadNotFoundHierarchy(throwAtIndex) + const rootRoute = new BaseRootRoute({}) + const childRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/child', + loader: childLoader, + pendingMs: 1, + pendingComponent: {}, + }) - let loadResolved = false - const loadPromise = router.load().then(() => { - loadResolved = true - }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([childRoute]), + history: createMemoryHistory(), + }) - await Promise.resolve() - await Promise.resolve() + const startViewTransition = vi.spyOn(router, 'startViewTransition') - for (let i = 0; i < routes.length; i++) { - const loader = loaders[i]! - const expectedCalls = i < throwAtIndex ? 1 : 0 - expect(loader).toHaveBeenCalledTimes(expectedCalls) - } + const navigation = router.navigate({ + to: '/child', + viewTransition: true, + }) + await vi.waitFor(() => expect(childLoader).toHaveBeenCalledTimes(1)) - expect(loadResolved).toBe(false) + await vi.advanceTimersByTimeAsync(1) + await vi.waitFor(() => { + const childMatch = router.state.matches.find( + (match) => match.routeId === childRoute.id, + ) + expect(childMatch?.status).toBe('pending') + }) + expect(startViewTransition).not.toHaveBeenCalled() + expect(router.shouldViewTransition).toBe(true) - for (let i = 0; i < throwAtIndex; i++) { - expect(loaderResolvers[i]).toBeDefined() - loaderResolvers[i]!() + loaderGate.resolve({ value: 'fresh' }) + await navigation + + const childMatch = router.state.matches.find( + (match) => match.routeId === childRoute.id, + ) + expect(childMatch?.status).toBe('success') + expect(childMatch?.loaderData).toEqual({ value: 'fresh' }) + expect(startViewTransition).toHaveBeenCalledTimes(1) + } finally { + vi.restoreAllMocks() + vi.useRealTimers() } + }) - await loadPromise + test("stale onReady does not consume a newer navigation's view transition", async () => { + vi.useFakeTimers() - for (let i = 0; i < heads.length; i++) { - const head = heads[i]! - const expectedCalls = i <= throwAtIndex ? 1 : 0 - expect(head).toHaveBeenCalledTimes(expectedCalls) - } + try { + const slowGate = createControlledPromise() + const fastGate = createControlledPromise() + const slowLoader = vi.fn(() => slowGate) + const fastLoader = vi.fn(() => fastGate) - for (let i = 0; i < throwAtIndex; i++) { - const route = routes[i]! - const match = router.state.matches.find((m) => m.routeId === route.id) - expect(match?.loaderData).toEqual({ level: i }) - } + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const slowRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/slow', + loader: slowLoader, + pendingMs: 0, + pendingComponent: {}, + }) + const fastRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/fast', + loader: fastLoader, + }) - const thrownRoute = routes[throwAtIndex]! - const thrownMatch = router.state.matches.find( - (m) => m.routeId === thrownRoute.id, - ) - expect(thrownMatch?.status).toBe('notFound') - } + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, slowRoute, fastRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + await router.load() - ;([1, 2, 3] as const).forEach((throwAtIndex) => { - test(`beforeLoad notFound at hierarchy level ${throwAtIndex} waits for parent loader data and executes heads`, async () => { - await assertBeforeLoadNotFoundHierarchy(throwAtIndex) - }) - }) + const startViewTransition = vi.spyOn(router, 'startViewTransition') - test('executes head once when loader throws notFound', async () => { - const head = vi.fn(() => ({ meta: [{ title: 'Test' }] })) - const rootRoute = new BaseRootRoute({}) - const testRoute = new BaseRoute({ - getParentRoute: () => rootRoute, - path: '/test', - loader: () => { - throw notFound() - }, - head, - }) - const routeTree = rootRoute.addChildren([testRoute]) - const router = createTestRouter({ - routeTree, - history: createMemoryHistory({ initialEntries: ['/test'] }), - }) + const staleNavigation = router.navigate({ to: '/slow' }) + await vi.waitFor(() => expect(slowLoader).toHaveBeenCalledTimes(1)) - await router.load() + await vi.advanceTimersByTimeAsync(0) + await vi.waitFor(() => + expect(router.state.location.pathname).toBe('/slow'), + ) + expect(startViewTransition).not.toHaveBeenCalled() - expect(head).toHaveBeenCalledTimes(1) - const match = router.state.matches.find((m) => m.routeId === testRoute.id) - expect(match?.status).toBe('notFound') + const latestNavigation = router.navigate({ + to: '/fast', + viewTransition: true, + }) + await vi.waitFor(() => expect(fastLoader).toHaveBeenCalledTimes(1)) + + slowGate.resolve() + fastGate.resolve() + await Promise.all([staleNavigation, latestNavigation]) + + expect(startViewTransition).toHaveBeenCalledTimes(1) + } finally { + vi.restoreAllMocks() + vi.useRealTimers() + } }) - test('propagates sync beforeLoad non-notFound error running ancestor loaders and heads', async () => { - const beforeLoadError = new Error('beforeLoad-sync-error') - const rootLoader = vi.fn(() => ({ level: 0 })) - const rootHead = vi.fn(() => ({ meta: [{ title: 'Root' }] })) + test('redirect resolving while the render-ready commit is pending does not lose or duplicate that commit', async () => { + vi.useFakeTimers() - const rootRoute = new BaseRootRoute({ - loader: rootLoader, - head: rootHead, - }) + try { + const slowGate = createControlledPromise() + const slowLoader = vi.fn(() => slowGate) - const childLoader = vi.fn(() => ({ level: 1 })) - const childHead = vi.fn(() => ({ meta: [{ title: 'Child' }] })) + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const slowRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/slow', + loader: slowLoader, + pendingMs: 0, + pendingComponent: {}, + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + }) - const childRoute = new BaseRoute({ - getParentRoute: () => rootRoute, - path: '/test', - beforeLoad: () => { - throw beforeLoadError - }, - loader: childLoader, - head: childHead, - }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, slowRoute, targetRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + await router.load() - const routeTree = rootRoute.addChildren([childRoute]) - const router = createTestRouter({ - routeTree, - history: createMemoryHistory({ initialEntries: ['/test'] }), - }) + const startViewTransition = vi.spyOn(router, 'startViewTransition') - const location = router.latestLocation - const matches = router.matchRoutes(location) - router.stores.setPending(matches) + const navigation = router.navigate({ to: '/slow' }) + await vi.waitFor(() => expect(slowLoader).toHaveBeenCalledTimes(1)) - await expect( - loadMatches({ - router, - location, - matches, - updateMatch: router.updateMatch, - }), - ).rejects.toBe(beforeLoadError) + await vi.advanceTimersByTimeAsync(0) + await vi.waitFor(() => + expect(router.state.location.pathname).toBe('/slow'), + ) + expect(startViewTransition).not.toHaveBeenCalled() - expect(rootLoader).toHaveBeenCalledTimes(1) - expect(childLoader).toHaveBeenCalledTimes(0) - // Head functions still run for ancestors up to the erroring match so that - // SSR produces valid content (e.g. charset, viewport, stylesheets). - expect(rootHead).toHaveBeenCalledTimes(1) - expect(childHead).toHaveBeenCalledTimes(1) + slowGate.resolve(redirect({ to: '/target' })) + await vi.waitFor(() => + expect(router.state.location.pathname).toBe('/target'), + ) + + await navigation + + expect(router.state.location.pathname).toBe('/target') + expect(router.state.matches.map((match) => match.routeId)).toEqual([ + rootRoute.id, + targetRoute.id, + ]) + } finally { + vi.restoreAllMocks() + vi.useRealTimers() + } }) - 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 })) - const rootHead = vi.fn(() => ({ meta: [{ title: 'Root' }] })) + test("stale final trim does not disable a newer navigation's view transition", async () => { + vi.useFakeTimers() - const rootRoute = new BaseRootRoute({ - loader: rootLoader, - head: rootHead, - }) + try { + const parentGate = createControlledPromise() + const fastGate = createControlledPromise() + const parentBeforeLoad = vi.fn(async () => { + await parentGate + throw new Error('Parent beforeLoad failed') + }) + const fastLoader = vi.fn(() => fastGate) - const childLoader = vi.fn(() => ({ level: 1 })) - const childHead = vi.fn(() => ({ meta: [{ title: 'Child' }] })) + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + beforeLoad: parentBeforeLoad, + pendingMs: 0, + pendingComponent: {}, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + }) + const fastRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/fast', + loader: fastLoader, + }) - const childRoute = new BaseRoute({ - getParentRoute: () => rootRoute, - path: '/test', - beforeLoad: async () => { - await Promise.resolve() - throw beforeLoadError - }, - loader: childLoader, - head: childHead, - }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([childRoute]), + fastRoute, + ]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + await router.load() - const routeTree = rootRoute.addChildren([childRoute]) - const router = createTestRouter({ - routeTree, - history: createMemoryHistory({ initialEntries: ['/test'] }), - }) + const startViewTransition = vi.spyOn(router, 'startViewTransition') - const location = router.latestLocation - const matches = router.matchRoutes(location) - router.stores.setPending(matches) + const staleNavigation = router.navigate({ to: '/parent/child' }) + await vi.waitFor(() => expect(parentBeforeLoad).toHaveBeenCalledTimes(1)) - await expect( - loadMatches({ - router, - location, - matches, - updateMatch: router.updateMatch, - }), - ).rejects.toBe(beforeLoadError) + await vi.advanceTimersByTimeAsync(0) + await vi.waitFor(() => + expect(router.state.location.pathname).toBe('/parent/child'), + ) + expect(startViewTransition).not.toHaveBeenCalled() - expect(rootLoader).toHaveBeenCalledTimes(1) - expect(childLoader).toHaveBeenCalledTimes(0) - // Head functions still run for ancestors up to the erroring match so that - // SSR produces valid content (e.g. charset, viewport, stylesheets). - expect(rootHead).toHaveBeenCalledTimes(1) - expect(childHead).toHaveBeenCalledTimes(1) + const latestNavigation = router.navigate({ + to: '/fast', + viewTransition: true, + }) + await vi.waitFor(() => expect(fastLoader).toHaveBeenCalledTimes(1)) + + parentGate.resolve() + await Promise.resolve() + await Promise.resolve() + + expect(startViewTransition).not.toHaveBeenCalled() + expect(router.shouldViewTransition).toBe(true) + + fastGate.resolve() + await Promise.all([staleNavigation, latestNavigation]) + + expect(startViewTransition).toHaveBeenCalledTimes(1) + } finally { + vi.restoreAllMocks() + vi.useRealTimers() + } }) - describe('beforeLoad notFound parent loader outcomes', () => { - type ThrowAtIndex = 1 | 2 | 3 - type ParentFailure = 'notFound' | 'redirect' | 'error' - type ParentFailureMap = Partial> - type Scenario = { - name: string - throwAtIndex: ThrowAtIndex - parentFailures: ParentFailureMap - expectedErrorKind: 'notFound' | 'redirect' | 'error' - expectedErrorSource?: string - expectedErrorRouteIndex?: 0 | 1 | 2 | 3 - expectedLoaderMaxIndex: number - expectedRenderedHeadMaxIndex: number - withDefaultNotFoundComponent?: boolean - beforeLoadNotFoundFactory?: ( - routes: readonly [any, any, any, any], - ) => ReturnType - expectRootNotFoundComponentAssigned?: boolean + test('final parent loader-error trim does not cache a dropped successful child', async () => { + vi.useFakeTimers() + + try { + const parentGate = createControlledPromise() + const parentLoader = vi.fn(async () => { + await parentGate + throw new Error('Parent loader failed') + }) + const childLoader = vi.fn(() => ({ value: 'child' })) + + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: parentLoader, + pendingMs: 1, + pendingComponent: {}, + }) + 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/child' }) + await vi.waitFor(() => expect(parentLoader).toHaveBeenCalledTimes(1)) + + await vi.advanceTimersByTimeAsync(1) + await vi.waitFor(() => { + const childMatch = router.state.matches.find( + (match) => match.routeId === childRoute.id, + ) + expect(childMatch?.status).toBe('success') + }) + + parentGate.resolve() + await navigation + + expect(router.state.matches.map((match) => match.routeId)).toEqual([ + rootRoute.id, + parentRoute.id, + ]) + expect( + router.stores.cachedMatches + .get() + .some((match) => match.routeId === childRoute.id), + ).toBe(false) + } finally { + vi.useRealTimers() } + }) - const setupScenario = ({ - throwAtIndex, - parentFailures, - beforeLoadNotFoundFactory, - withDefaultNotFoundComponent, - }: { - throwAtIndex: ThrowAtIndex - parentFailures: ParentFailureMap - beforeLoadNotFoundFactory?: Scenario['beforeLoadNotFoundFactory'] - withDefaultNotFoundComponent?: boolean - }) => { - const makeHead = (label: string) => - vi.fn(() => ({ meta: [{ title: label }] })) + test('preload from onBeforeLoad waits for active root beforeLoad context', async () => { + vi.useFakeTimers() - const makeLoader = (index: number) => - vi.fn(() => { - const failure = parentFailures[index as 0 | 1 | 2] - if (failure === 'notFound') { - throw notFound({ data: { source: `loader-${index}` } }) - } - if (failure === 'redirect') { - throw redirect({ to: '/redirect-target' }) - } - return { level: index } - }) + try { + const rootBeforeLoadPromise = createControlledPromise<{ auth: string }>() + const rootBeforeLoad = vi.fn(() => rootBeforeLoadPromise) + const childLoader = vi.fn(({ context }) => context) const rootRoute = new BaseRootRoute({ - loader: makeLoader(0), - head: makeHead('Root'), + beforeLoad: rootBeforeLoad, + }) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: childLoader, }) - const level1Route = new BaseRoute({ + 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: '/level-1', - loader: makeLoader(1), - head: makeHead('Level 1'), + path: '/parent', + loader: parentLoader, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: childLoader, }) - const level2Route = new BaseRoute({ - getParentRoute: () => level1Route, - path: '/level-2', - loader: makeLoader(2), - head: makeHead('Level 2'), + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + parentRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory(), }) - const level3Route = new BaseRoute({ - getParentRoute: () => level2Route, - path: '/level-3', - loader: makeLoader(3), - head: makeHead('Level 3'), + 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).not.toHaveBeenCalled() + 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() + } + }) - const redirectTargetRoute = new BaseRoute({ + test('preload does not start 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: '/redirect-target', + 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 routeTree = rootRoute.addChildren([ - level1Route.addChildren([level2Route.addChildren([level3Route])]), - redirectTargetRoute, - ]) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([childRoute]), + otherRoute, + ]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) - const routes = [rootRoute, level1Route, level2Route, level3Route] as const + await router.load() - ;([0, 1, 2] as const).forEach((index) => { - if (parentFailures[index] === 'error') { - ;(routes[index].options as any).shouldReload = () => { - throw new Error(`loader-${index}-error`) - } - } + 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).not.toHaveBeenCalled() + expect(childLoaderSettled).toBe(false) + + 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) + } finally { + vi.useRealTimers() + } + }) + + test('preload leaves no descendant cache 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 throwRoute = routes[throwAtIndex]! - throwRoute.options.beforeLoad = () => { - const beforeLoadNotFound = beforeLoadNotFoundFactory - ? beforeLoadNotFoundFactory(routes) - : notFound({ data: { source: `beforeLoad-${throwAtIndex}` } }) - throw beforeLoadNotFound - } + 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 Promise.resolve() + expect(childLoader).not.toHaveBeenCalled() + + 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, - history: createMemoryHistory({ - initialEntries: ['/level-1/level-2/level-3'], - }), - ...(withDefaultNotFoundComponent - ? { defaultNotFoundComponent: () => null } - : {}), + routeTree: rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([childRoute]), + otherRoute, + ]), + history: createMemoryHistory({ initialEntries: ['/'] }), }) - const loaders = routes.map( - (route) => route.options.loader as ReturnType, - ) - const heads = routes.map( - (route) => route.options.head as ReturnType, - ) + await router.load() - return { - router, - routes, - loaders, - heads, - } + 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 Promise.resolve() + expect(childLoader).not.toHaveBeenCalled() + + await router.navigate({ to: '/other' }) + + parentLoaderPromise.resolve({ auth: 'late' }) + await parentNavigation + await preload + + expect(preloadSettled).toHaveBeenCalledTimes(1) + expect(childLoader).not.toHaveBeenCalled() + expect( + router.stores.cachedMatches + .get() + .some((match) => match.routeId === childRoute.id), + ).toBe(false) + } finally { + vi.useRealTimers() } + }) - const runLoadMatchesAndCapture = async (router: AnyRouter) => { - const location = router.latestLocation - const matches = router.matchRoutes(location) - router.stores.setPending(matches) + 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 Promise.resolve() + expect(childBeforeLoad).not.toHaveBeenCalled() + + 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', + withNeverSettlingDescendant: false, + }, + { + name: 'with a never-settling descendant', + withNeverSettlingDescendant: true, + }, + ])( + 'preload cancellation skips descendant redirect after owner exit $name', + async ({ withNeverSettlingDescendant }) => { + vi.useFakeTimers() + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) try { - await loadMatches({ - router, - location, - matches, - updateMatch: router.updateMatch, + const parentLoaderPromise = createControlledPromise<{ auth: string }>() + const hangLoaderPromise = createControlledPromise() + const parentLoader = vi.fn(() => parentLoaderPromise) + const childLoader = vi.fn(() => { + throw redirect({ to: '/target' }) }) - return { error: undefined, matches } - } catch (error) { - return { error, matches } - } - } + 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 Promise.resolve() + expect(childLoader).not.toHaveBeenCalled() + expect(hangLoader).not.toHaveBeenCalled() + + 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(childLoader).not.toHaveBeenCalled() + expect(hangLoader).not.toHaveBeenCalled() + 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() + + 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).not.toHaveBeenCalled() + 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('does not execute detached 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).not.toHaveBeenCalled() + }) + + test('does not execute detached 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).not.toHaveBeenCalled() + }) + + test('exec if pending preload (error)', async () => { + const beforeLoad = vi.fn(async ({ preload }) => { + await sleep(100) + if (preload) throw new Error('error') + }) + const router = setup({ + beforeLoad, + }) + router.preloadRoute({ to: '/foo' }) + await Promise.resolve() + await router.navigate({ to: '/foo' }) + + expect(beforeLoad).toHaveBeenCalledTimes(2) + }) +}) + +describe('loader skip or exec', () => { + const setup = ({ + loader, + staleTime, + defaultStaleReloadMode, + }: { + loader?: Loader + staleTime?: number + defaultStaleReloadMode?: LoaderStaleReloadMode + }) => { + const rootRoute = new BaseRootRoute({}) + + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loader, + staleTime, + gcTime: staleTime, + }) + + const barRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/bar', + }) + + const routeTree = rootRoute.addChildren([fooRoute, barRoute]) + + const router = createTestRouter({ + routeTree, + defaultStaleReloadMode, + history: createMemoryHistory(), + }) + + return router + } + + test('baseline', async () => { + const loader = vi.fn() + const router = setup({ loader }) + await router.load() + 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('client forceStaleReload reloads stale same-url matches', async () => { + const loader = vi.fn(() => ({ loaded: true })) + const router = setup({ loader, staleTime: 0 }) + + await router.navigate({ to: '/foo' }) + await router.load({ sync: true }) + + expect(loader).toHaveBeenCalledTimes(2) + }) + + test('client blocking reload clears loaderData when loader returns undefined', async () => { + let loaderCalls = 0 + const loader = vi.fn(() => { + loaderCalls += 1 + return loaderCalls === 1 ? 'old' : undefined + }) + const head = vi.fn(({ loaderData }) => ({ + meta: [{ title: String(loaderData) }], + })) + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loader, + head, + staleTime: 0, + gcTime: 60_000, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([fooRoute]), + history: createMemoryHistory(), + defaultStaleReloadMode: 'blocking', + }) + + const getFooMatch = () => + router.state.matches.find((match) => match.routeId === fooRoute.id) + + await router.navigate({ to: '/foo' }) + expect(getFooMatch()?.loaderData).toBe('old') + expect(getFooMatch()?.meta).toEqual([{ title: 'old' }]) + + await router.load({ sync: true }) + + const match = getFooMatch() + expect(loader).toHaveBeenCalledTimes(2) + expect(match?.loaderData).toBeUndefined() + expect(match?.status).toBe('success') + expect(match?.meta).toEqual([{ title: 'undefined' }]) + }) + + test('server load ignores staleTime and shouldReload', async () => { + const loader = vi.fn(() => ({ loaded: true })) + const shouldReload = vi.fn(() => false) + + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loader, + staleTime: Infinity, + shouldReload, + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([fooRoute]), + history: createMemoryHistory({ initialEntries: ['/foo'] }), + isServer: true, + }) + + await router.load() + await router.load({ sync: true }) + + expect(loader).toHaveBeenCalledTimes(2) + expect(shouldReload).not.toHaveBeenCalled() + }) + + test('preloading an active route joins the active match', async () => { + const loader = vi.fn(() => ({ source: 'active' })) + const router = setup({ loader }) + + await router.navigate({ to: '/foo' }) + expectNoCachedActiveOverlap(router) + + const activeMatch = router.state.matches.find((match) => + match.id.endsWith('/foo'), + )! + + 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' }) + expectNoCachedActiveOverlap(router) + }) + + 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('preload ownership may be non-contiguous when ancestor loaderDeps change', async () => { + const parentLoader = vi.fn(() => ({ source: 'parent' })) + const childLoader = vi.fn(() => ({ source: 'child' })) + const childHead = vi.fn(() => ({ meta: [{ title: 'Child' }] })) + + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: parentLoader, + loaderDeps: ({ search }: { search: Record }) => ({ + parentVersion: search['parentVersion'], + }), + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: childLoader, + loaderDeps: () => ({ childVersion: 'stable' }), + head: childHead, + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory(), + }) + + await router.navigate({ + to: '/parent/child', + search: { parentVersion: '1' }, + }) + expectNoCachedActiveOverlap(router) + + const activeParent = router.state.matches.find( + (match) => match.routeId === parentRoute.id, + )! + const activeChild = router.state.matches.find( + (match) => match.routeId === childRoute.id, + )! + + const preloadedMatches = await router.preloadRoute({ + to: '/parent/child', + search: { parentVersion: '2' }, + }) + + const preloadedParent = preloadedMatches?.find( + (match) => match.routeId === parentRoute.id, + )! + const preloadedChild = preloadedMatches?.find( + (match) => match.routeId === childRoute.id, + )! + + expect(preloadedParent.id).not.toBe(activeParent.id) + expect(preloadedChild.id).toBe(activeChild.id) + expect(parentLoader).toHaveBeenCalledTimes(2) + expect(childLoader).toHaveBeenCalledTimes(1) + expect(childHead).toHaveBeenCalledTimes(1) + expect( + router.stores.cachedMatches + .get() + .some((match) => match.id === activeChild.id), + ).toBe(false) + expectNoCachedActiveOverlap(router) + }) + + 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('navigation to a preloaded route commits head assets', async () => { + const loader = vi.fn(() => ({ title: 'Preloaded title' })) + const head = vi.fn(({ loaderData }) => ({ + meta: [{ title: loaderData.title }], + })) + + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loader, + head, + staleTime: 60_000, + preloadStaleTime: 60_000, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([fooRoute]), + history: createMemoryHistory(), + }) + + await router.preloadRoute({ to: '/foo' }) + await router.navigate({ to: '/foo' }) + + const match = router.state.matches.find((item) => item.id === '/foo/foo') + expect(loader).toHaveBeenCalledTimes(1) + expect(head).toHaveBeenCalled() + expect(match?.meta).toEqual([{ title: 'Preloaded title' }]) + }) + + 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('active redirect settles source loadPromise without cached ownership', 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._.loadPromise + + router.stores.setPending(matches) + expectNoCachedActiveOverlap(router) + + await expect( + loadMatches({ + router, + location, + matches, + }), + ).rejects.toMatchObject({ + options: expect.objectContaining({ to: '/bar' }), + }) + + expect(activeLoadPromise?.status).toBe('resolved') + expect(fooMatch._.loadPromise).toBeUndefined() + expect( + router.stores.cachedMatches + .get() + .some((match) => match.id === fooMatch.id), + ).toBe(false) + expectNoCachedActiveOverlap(router) + }) + + test('exec on regular nav', async () => { + const loader = vi.fn(() => Promise.resolve({ hello: 'world' })) + const router = setup({ loader }) + const navigation = router.navigate({ to: '/foo' }) + expect(loader).toHaveBeenCalledTimes(1) + expect(router.stores.pendingMatches.get()).toEqual( + expect.arrayContaining([expect.objectContaining({ id: '/foo/foo' })]), + ) + await navigation + expect(router.state.location.pathname).toBe('/foo') + expect(router.state.matches).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: '/foo/foo', + loaderData: { + hello: 'world', + }, + }), + ]), + ) + 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') + }, + ) + + 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).toBeUndefined() + }) + + test('exec if resolved preload (success)', async () => { + const loader = vi.fn() + const router = setup({ loader }) + await router.preloadRoute({ to: '/foo' }) + expect(router.stores.cachedMatches.get()).toEqual( + expect.arrayContaining([expect.objectContaining({ id: '/foo/foo' })]), + ) + await sleep(10) + await router.navigate({ to: '/foo' }) + + expect(loader).toHaveBeenCalledTimes(2) + }) + + test('skip if resolved preload (success) within staleTime duration', async () => { + const loader = vi.fn() + const router = setup({ loader, staleTime: 1000 }) + await router.preloadRoute({ to: '/foo' }) + expect(router.stores.cachedMatches.get()).toEqual( + expect.arrayContaining([expect.objectContaining({ id: '/foo/foo' })]), + ) + await sleep(10) + await router.navigate({ to: '/foo' }) + + expect(loader).toHaveBeenCalledTimes(1) + }) + + test('exec if pending preload (success)', async () => { + const loader = vi.fn(() => sleep(100)) + const router = setup({ loader }) + router.preloadRoute({ to: '/foo' }) + await Promise.resolve() + expect(router.stores.cachedMatches.get()).toEqual([]) + await router.navigate({ to: '/foo' }) + + expect(loader).toHaveBeenCalledTimes(2) + }) + + test('exec if rejected preload (notFound)', async () => { + const loader: Loader = vi.fn(async ({ preload }) => { + if (preload) throw notFound() + await Promise.resolve() + }) + const router = setup({ + loader, + }) + await router.preloadRoute({ to: '/foo' }) + await sleep(10) + await router.navigate({ to: '/foo' }) + + expect(loader).toHaveBeenCalledTimes(2) + }) + + test('exec if pending preload (notFound)', async () => { + const loader: Loader = vi.fn(async ({ preload }) => { + await sleep(100) + if (preload) throw notFound() + }) + const router = setup({ + loader, + }) + router.preloadRoute({ to: '/foo' }) + await Promise.resolve() + await router.navigate({ to: '/foo' }) + + expect(loader).toHaveBeenCalledTimes(2) + }) + + test('exec if rejected preload (redirect)', async () => { + const loader: Loader = vi.fn(async ({ preload }) => { + if (preload) throw redirect({ to: '/bar' }) + await Promise.resolve() + }) + const router = setup({ + loader, + }) + await router.preloadRoute({ to: '/foo' }) + expect( + 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.id === '/foo/foo'), + ).toBe(false) + expect(loader).toHaveBeenCalledTimes(2) + }) + + test('exec if pending preload (redirect)', async () => { + const loader: Loader = vi.fn(async ({ preload }) => { + await sleep(100) + if (preload) throw redirect({ to: '/bar' }) + }) + const router = setup({ + loader, + }) + router.preloadRoute({ to: '/foo' }) + await Promise.resolve() + await router.navigate({ to: '/foo' }) + + expect(router.state.location.pathname).toBe('/foo') + expect( + router.stores.cachedMatches.get().some((d) => d.id === '/foo/foo'), + ).toBe(false) + expect(loader).toHaveBeenCalledTimes(2) + }) + + test('private pending preload redirect does not affect active pending navigation', async () => { + vi.useFakeTimers() + + try { + const rejectFoo: Array<(error: unknown) => void> = [] + const resolveFoo: Array<() => 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) => { + resolveFoo.push(resolve) + rejectFoo.push(reject) + }), + }) + const barRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/bar', + }) + 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).toHaveLength(1)) + + const navigation = router.navigate({ to: '/foo' }) + await vi.waitFor(() => expect(rejectFoo).toHaveLength(2)) + await vi.advanceTimersByTimeAsync(1) + await vi.waitFor(() => + expect( + router.state.matches.some( + (match) => match.id === '/foo/foo' && match.status === 'pending', + ), + ).toBe(true), + ) + + rejectFoo[0]!(redirect({ to: '/bar' })) + await preload + + expect( + router.state.matches.find((match) => match.id === '/foo/foo')?.status, + ).toBe('pending') + expect(router.state.location.pathname).toBe('/foo') + + resolveFoo[1]!() + await navigation + + expect(router.state.location.pathname).toBe('/foo') + } finally { + vi.useRealTimers() + } + }) + + test('active-join preload observes active redirect after settling 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._.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('resolved') + expect(activeFoo._.loadPromise).toBeUndefined() + + resolveBar() + await Promise.all([navigation, preload]) + + expect(router.state.location.pathname).toBe('/bar') + } finally { + vi.useRealTimers() + } + }) + + test.each([ + { name: 'false', value: false }, + { name: '0', value: 0 }, + { name: 'empty string', value: '' }, + { name: 'null', value: null }, + { name: 'undefined', value: undefined }, + ])( + 'borrowed active error with $name stops preload descendants', + async ({ value }) => { + const originalNodeEnv = process.env.NODE_ENV + process.env.NODE_ENV = 'production' + try { + const childBeforeLoad = vi.fn() + const childLoader = vi.fn(() => 'child') + const childHead = vi.fn(() => ({})) + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: () => { + throw value + }, + errorComponent: () => null, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + beforeLoad: childBeforeLoad, + loader: childLoader, + head: childHead, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + parentRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory(), + }) + + await router.navigate({ to: '/parent' }) + const activeParent = router.state.matches.find( + (match) => match.routeId === parentRoute.id, + ) + expect(activeParent?.status).toBe('error') + expect(activeParent?.error).toBe(value) + + await router.preloadRoute({ to: '/parent/child' }) + + expect(childBeforeLoad).not.toHaveBeenCalled() + expect(childLoader).not.toHaveBeenCalled() + expect(childHead).not.toHaveBeenCalled() + expect( + router.stores.cachedMatches + .get() + .some((match) => match.routeId === childRoute.id), + ).toBe(false) + } finally { + process.env.NODE_ENV = originalNodeEnv + } + }, + ) + + test('exec if rejected preload (error)', async () => { + const loader: Loader = vi.fn(async ({ preload }) => { + if (preload) throw new Error('error') + await Promise.resolve() + }) + const router = setup({ + loader, + }) + await router.preloadRoute({ to: '/foo' }) + await sleep(10) + await router.navigate({ to: '/foo' }) + + expect(loader).toHaveBeenCalledTimes(2) + }) + + test('exec if pending preload (error)', async () => { + const loader: Loader = vi.fn(async ({ preload }) => { + await sleep(100) + if (preload) throw new Error('error') + }) + const router = setup({ + loader, + }) + router.preloadRoute({ to: '/foo' }) + await Promise.resolve() + await router.navigate({ to: '/foo' }) + + expect(loader).toHaveBeenCalledTimes(2) + }) +}) + +test('exec on stay (beforeLoad & loader)', async () => { + let rootBeforeLoadResolved = false + const rootBeforeLoad = vi.fn(async () => { + await sleep(10) + rootBeforeLoadResolved = true + }) + const rootLoader = vi.fn(() => sleep(10)) + const rootRoute = new BaseRootRoute({ + beforeLoad: rootBeforeLoad, + loader: rootLoader, + }) + + let layoutBeforeLoadResolved = false + const layoutBeforeLoad = vi.fn(async () => { + await sleep(10) + layoutBeforeLoadResolved = true + }) + const layoutLoader = vi.fn(() => sleep(10)) + const layoutRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + beforeLoad: layoutBeforeLoad, + loader: layoutLoader, + id: '/_layout', + }) + + const fooRoute = new BaseRoute({ + getParentRoute: () => layoutRoute, + path: '/foo', + }) + const barRoute = new BaseRoute({ + getParentRoute: () => layoutRoute, + path: '/bar', + }) + + const routeTree = rootRoute.addChildren([ + layoutRoute.addChildren([fooRoute, barRoute]), + ]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory(), + defaultStaleTime: 1000, + defaultGcTime: 1000, + }) + + await router.navigate({ to: '/foo' }) + expect(router.state.location.pathname).toBe('/foo') + + rootBeforeLoadResolved = false + layoutBeforeLoadResolved = false + vi.clearAllMocks() + + /* + * When navigating between sibling routes, + * do the parent routes get re-executed? + */ + + await router.navigate({ to: '/bar' }) + expect(router.state.location.pathname).toBe('/bar') + + // beforeLoads always re-execute + expect(rootBeforeLoad).toHaveBeenCalledTimes(1) + expect(layoutBeforeLoad).toHaveBeenCalledTimes(1) + + // beforeLoads are called in order + expect(rootBeforeLoad.mock.invocationCallOrder[0]).toBeLessThan( + layoutBeforeLoad.mock.invocationCallOrder[0]!, + ) + + // loaders are skipped because of staleTime + expect(rootLoader).toHaveBeenCalledTimes(0) + expect(layoutLoader).toHaveBeenCalledTimes(0) + + // beforeLoad calls were correctly awaited + expect(rootBeforeLoadResolved).toBe(true) + expect(layoutBeforeLoadResolved).toBe(true) +}) + +describe('stale loader reload triggers', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(0) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + const getMatchById = ( + router: RouterCore, + id: string, + ) => + router.state.matches.find((match) => match.id === id) ?? + router.stores.pendingMatches.get().find((match) => match.id === id) ?? + router.stores.cachedMatches.get().find((match) => match.id === id) + + const hasActiveMatch = ( + router: RouterCore, + id: string, + ) => router.state.matches.some((match) => match.id === id) + + const hasPendingMatch = ( + router: RouterCore, + id: string, + ) => + router.stores.pendingMatches.get().some((match) => match.id === id) ?? false + + const getTitle = (match?: AnyRouteMatch) => + (match?.meta as Array<{ title?: string }> | undefined)?.find( + (item) => item.title, + )?.title + + const setup = ({ + loader, + staleTime, + defaultStaleReloadMode, + }: { + loader?: Loader + staleTime?: number + defaultStaleReloadMode?: LoaderStaleReloadMode + }) => { + const rootRoute = new BaseRootRoute({}) + + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loader, + staleTime, + gcTime: 60_000, + }) + + const barRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/bar', + }) + + const bazRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/baz', + }) + + const routeTree = rootRoute.addChildren([fooRoute, barRoute, bazRoute]) + + return createTestRouter({ + routeTree, + defaultStaleReloadMode, + history: createMemoryHistory(), + }) + } + + const createControlledStaleReload = () => { + let resolveStaleReload: (() => void) | undefined + let callCount = 0 + + const loader = vi.fn(() => { + callCount += 1 + if (callCount === 1) { + return { value: 'first' } + } + + return new Promise<{ value: string }>((resolve) => { + resolveStaleReload = () => resolve({ value: 'second' }) + }) + }) + + return { + loader, + resolveStaleReload: () => resolveStaleReload?.(), + } + } + + const expectBlockingStaleReloadBehavior = async ( + router: RouterCore, + loader: ReturnType, + resolveStaleReload: () => void, + ) => { + await router.navigate({ to: '/foo' }) + expect(loader).toHaveBeenCalledTimes(1) + expect(getMatchById(router, '/foo/foo')?.loaderData).toEqual({ + value: 'first', + }) + + await vi.advanceTimersByTimeAsync(1) + await router.navigate({ to: '/bar' }) + await vi.advanceTimersByTimeAsync(1) + expectNoCachedActiveOverlap(router) + + const revisit = router.navigate({ to: '/foo' }) + await Promise.resolve() + + expect(loader).toHaveBeenCalledTimes(2) + expect(hasActiveMatch(router, '/bar/bar')).toBe(true) + expect(hasActiveMatch(router, '/foo/foo')).toBe(false) + expect(hasPendingMatch(router, '/foo/foo')).toBe(true) + expect(getMatchById(router, '/foo/foo')?.loaderData).toEqual({ + value: 'first', + }) + expectNoCachedActiveOverlap(router) + + resolveStaleReload() + await revisit + + expect(loader).toHaveBeenCalledTimes(2) + expect(hasActiveMatch(router, '/foo/foo')).toBe(true) + expect(hasPendingMatch(router, '/foo/foo')).toBe(false) + expect(router.state.location.pathname).toBe('/foo') + expect(getMatchById(router, '/foo/foo')?.loaderData).toEqual({ + value: 'second', + }) + expectNoCachedActiveOverlap(router) + } + + const expectBackgroundStaleReloadBehavior = async ( + router: RouterCore, + loader: ReturnType, + resolveStaleReload: () => void, + ) => { + await router.navigate({ to: '/foo' }) + expect(loader).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(1) + await router.navigate({ to: '/bar' }) + await vi.advanceTimersByTimeAsync(1) + expectNoCachedActiveOverlap(router) + + const revisit = router.navigate({ to: '/foo' }) + + await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(2)) + + await revisit + + expect(hasActiveMatch(router, '/foo/foo')).toBe(true) + expect(hasPendingMatch(router, '/foo/foo')).toBe(false) + expect(router.state.location.pathname).toBe('/foo') + expect(getMatchById(router, '/foo/foo')?.loaderData).toEqual({ + value: 'first', + }) + expect(getMatchById(router, '/foo/foo')?.isFetching).toBe('loader') + expectNoCachedActiveOverlap(router) + + resolveStaleReload() + + await vi.waitFor(() => + expect(getMatchById(router, '/foo/foo')?.loaderData).toEqual({ + value: 'second', + }), + ) + expect(getMatchById(router, '/foo/foo')?.isFetching).toBe(false) + expectNoCachedActiveOverlap(router) + } + + test('skips stale loader when only unrelated search params change', async () => { + const rootRoute = new BaseRootRoute({}) + const loader = vi.fn(() => ({ ok: true })) + + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loader, + staleTime: 0, + gcTime: 0, + loaderDeps: ({ search }: { search: Record }) => ({ + page: search['page'], + }), + }) + + const routeTree = rootRoute.addChildren([fooRoute]) + const router = createTestRouter({ + routeTree, + history: createMemoryHistory(), + }) + + await router.navigate({ to: '/foo', search: { page: '1', filter: 'a' } }) + expect(loader).toHaveBeenCalledTimes(1) + + await router.navigate({ to: '/foo', search: { page: '1', filter: 'b' } }) + + expect(loader).toHaveBeenCalledTimes(1) + }) + + test('reloads stale loader when loader deps change', async () => { + const rootRoute = new BaseRootRoute({}) + const loader = vi.fn(() => ({ ok: true })) + + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loader, + staleTime: 0, + gcTime: 0, + loaderDeps: ({ search }: { search: Record }) => ({ + page: search['page'], + }), + }) + + const routeTree = rootRoute.addChildren([fooRoute]) + const router = createTestRouter({ + routeTree, + history: createMemoryHistory(), + }) + + await router.navigate({ to: '/foo', search: { page: '1' } }) + expect(loader).toHaveBeenCalledTimes(1) + + await router.navigate({ to: '/foo', search: { page: '2' } }) + + expect(loader).toHaveBeenCalledTimes(2) + }) + + test('reloads a stale preloaded loader when switching to a different match id of the same route', async () => { + const rootRoute = new BaseRootRoute({}) + const rootLoader = vi.fn(() => ({ ok: true })) + const childLoader = vi.fn(() => ({ ok: true })) + + const rootChildRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + loader: rootLoader, + staleTime: 0, + gcTime: 0, + loaderDeps: ({ search }: { search: Record }) => ({ + page: search['page'], + }), + }) + + const leafRoute = new BaseRoute({ + getParentRoute: () => rootChildRoute, + path: '/$postId', + loader: childLoader, + staleTime: 0, + gcTime: 0, + }) + + const routeTree = rootRoute.addChildren([ + rootChildRoute.addChildren([leafRoute]), + ]) + const router = createTestRouter({ + routeTree, + history: createMemoryHistory(), + }) + + await router.navigate({ + to: '/posts/$postId', + params: { postId: '1' }, + search: { page: '1' }, + }) + + expect(rootLoader).toHaveBeenCalledTimes(1) + expect(childLoader).toHaveBeenCalledTimes(1) + + await router.preloadRoute({ + to: '/posts/$postId', + params: { postId: '2' }, + search: { page: '2' }, + }) + + expect(rootLoader).toHaveBeenCalledTimes(2) + expect(childLoader).toHaveBeenCalledTimes(2) + + await vi.advanceTimersByTimeAsync(1) + + await router.navigate({ + to: '/posts/$postId', + params: { postId: '2' }, + search: { page: '2' }, + }) + + expect(rootLoader).toHaveBeenCalledTimes(3) + expect(childLoader).toHaveBeenCalledTimes(3) + }) + + test('skips stale ancestor loader when only a child path param changes', async () => { + const rootRoute = new BaseRootRoute({}) + const parentLoader = vi.fn(() => ({ ok: true })) + const childLoader = vi.fn(() => ({ ok: true })) + + const orgRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/orgs/$orgId', + loader: parentLoader, + staleTime: 0, + gcTime: 0, + }) + + const userRoute = new BaseRoute({ + getParentRoute: () => orgRoute, + path: '/users/$userId', + loader: childLoader, + staleTime: 0, + gcTime: 0, + }) + + const routeTree = rootRoute.addChildren([orgRoute.addChildren([userRoute])]) + const router = createTestRouter({ + routeTree, + history: createMemoryHistory(), + }) + + await router.navigate({ + to: '/orgs/$orgId/users/$userId', + params: { orgId: 'acme', userId: 'u1' }, + }) + expect(parentLoader).toHaveBeenCalledTimes(1) + expect(childLoader).toHaveBeenCalledTimes(1) + + await router.navigate({ + to: '/orgs/$orgId/users/$userId', + params: { orgId: 'acme', userId: 'u2' }, + }) + + expect(parentLoader).toHaveBeenCalledTimes(1) + expect(childLoader).toHaveBeenCalledTimes(2) + }) + + test('reloads stale ancestor loader when its own path param changes', async () => { + const rootRoute = new BaseRootRoute({}) + const parentLoader = vi.fn(() => ({ ok: true })) + const childLoader = vi.fn(() => ({ ok: true })) + + const orgRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/orgs/$orgId', + loader: parentLoader, + staleTime: 0, + gcTime: 0, + }) + + const userRoute = new BaseRoute({ + getParentRoute: () => orgRoute, + path: '/users/$userId', + loader: childLoader, + staleTime: 0, + gcTime: 0, + }) + + const routeTree = rootRoute.addChildren([orgRoute.addChildren([userRoute])]) + const router = createTestRouter({ + routeTree, + history: createMemoryHistory(), + }) + + await router.navigate({ + to: '/orgs/$orgId/users/$userId', + params: { orgId: 'acme', userId: 'u1' }, + }) + expect(parentLoader).toHaveBeenCalledTimes(1) + expect(childLoader).toHaveBeenCalledTimes(1) + + await router.navigate({ + to: '/orgs/$orgId/users/$userId', + params: { orgId: 'beta', userId: 'u2' }, + }) + + expect(parentLoader).toHaveBeenCalledTimes(2) + expect(childLoader).toHaveBeenCalledTimes(2) + }) + + test('revalidates stale loaders on explicit same-location router.load()', async () => { + const rootRoute = new BaseRootRoute({}) + const loader = vi.fn(() => ({ ok: true })) + + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loader, + staleTime: 0, + gcTime: 0, + loaderDeps: ({ search }: { search: Record }) => ({ + page: search['page'], + }), + }) + + const routeTree = rootRoute.addChildren([fooRoute]) + const router = createTestRouter({ + routeTree, + history: createMemoryHistory(), + }) + + await router.navigate({ to: '/foo', search: { page: '1', filter: 'a' } }) + expect(loader).toHaveBeenCalledTimes(1) + expectNoCachedActiveOverlap(router) + + await vi.advanceTimersByTimeAsync(1) + await router.load() + await Promise.resolve() + + expect(loader).toHaveBeenCalledTimes(2) + expectNoCachedActiveOverlap(router) + }) + + test('supports object-form loader handler', async () => { + const handler = vi.fn(() => ({ ok: true })) + const router = setup({ + loader: { + handler, + } satisfies LoaderEntry, + }) + + await router.navigate({ to: '/foo' }) + + expect(handler).toHaveBeenCalledTimes(1) + expect(router.state.matches).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: '/foo/foo', + loaderData: { ok: true }, + }), + ]), + ) + }) + + test('reloads stale loaders in the background by default', async () => { + const { loader, resolveStaleReload } = createControlledStaleReload() + const router = setup({ loader, staleTime: 0 }) + + await expectBackgroundStaleReloadBehavior( + router, + loader, + resolveStaleReload, + ) + }) + + test('async background stale reload runs head once with fresh loaderData', async () => { + let resolveStaleReload!: (data: { title: string }) => void + let loaderCalls = 0 + const loader = vi.fn(() => { + loaderCalls += 1 + if (loaderCalls === 1) { + return null + } + + return new Promise<{ title: string }>((resolve) => { + resolveStaleReload = resolve + }) + }) + const seenLoaderData: Array = [] + const head = vi.fn(({ loaderData }) => { + seenLoaderData.push(loaderData) + return { + meta: [ + { + title: loaderData?.title ?? 'Article Not Found', + }, + ], + } + }) + + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loader, + head, + staleTime: 0, + gcTime: 60_000, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([fooRoute]), + history: createMemoryHistory(), + }) + + await router.navigate({ to: '/foo' }) + expect(head).toHaveBeenCalledTimes(1) + expect(seenLoaderData).toEqual([null]) + + head.mockClear() + seenLoaderData.length = 0 + + await vi.advanceTimersByTimeAsync(1) + await router.load() + await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(2)) + + expect(getMatchById(router, '/foo/foo')?.isFetching).toBe('loader') + expect(head).not.toHaveBeenCalled() + + resolveStaleReload({ title: 'Article 123 Title' }) + await vi.waitFor(() => expect(head).toHaveBeenCalledTimes(1)) + + expect(head).toHaveBeenCalledTimes(1) + expect(seenLoaderData).toEqual([{ title: 'Article 123 Title' }]) + }) + + test('client background reload clears loaderData when loader returns undefined', async () => { + let resolveStaleReload!: (data: undefined) => void + let loaderCalls = 0 + const loader = vi.fn(() => { + loaderCalls += 1 + if (loaderCalls === 1) { + return 'old' + } + + return new Promise((resolve) => { + resolveStaleReload = resolve + }) + }) + const head = vi.fn(({ loaderData }) => ({ + meta: [{ title: String(loaderData) }], + })) + + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loader, + head, + staleTime: 0, + gcTime: 60_000, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([fooRoute]), + history: createMemoryHistory(), + }) + + await router.navigate({ to: '/foo' }) + const matchId = router.state.matches.find( + (match) => match.routeId === fooRoute.id, + )!.id + expect(getMatchById(router, matchId)?.loaderData).toBe('old') + expect(getMatchById(router, matchId)?.meta).toEqual([{ title: 'old' }]) + + const matchStore = router.stores.matchStores.get(matchId)! as any + const seen: Array<{ loaderData: unknown; title?: string }> = [] + const subscription = matchStore.subscribe(() => { + const match = getMatchById(router, matchId) + seen.push({ + loaderData: match?.loaderData, + title: getTitle(match), + }) + }) + + await vi.advanceTimersByTimeAsync(1) + await router.load() + await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(2)) + + expect(getMatchById(router, matchId)?.isFetching).toBe('loader') + + resolveStaleReload(undefined) + await vi.waitFor(() => + expect(getMatchById(router, matchId)?.isFetching).toBe(false), + ) + subscription?.unsubscribe?.() + + const match = getMatchById(router, matchId) + expect(match?.loaderData).toBeUndefined() + expect(match?.status).toBe('success') + expect(match?.meta).toEqual([{ title: 'undefined' }]) + expect(seen).not.toContainEqual({ + loaderData: 'old', + title: 'undefined', + }) + expect(seen).not.toContainEqual({ + loaderData: undefined, + title: 'old', + }) + }) + + test('production background loader throw undefined commits an error match', async () => { + const originalNodeEnv = process.env.NODE_ENV + process.env.NODE_ENV = 'production' + try { + let loaderCalls = 0 + const loader = vi.fn(() => { + loaderCalls += 1 + if (loaderCalls === 1) { + return { title: 'initial' } + } + + throw undefined + }) + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loader, + staleTime: 0, + gcTime: 60_000, + errorComponent: () => null, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([fooRoute]), + history: createMemoryHistory(), + }) + + await router.navigate({ to: '/foo' }) + await vi.advanceTimersByTimeAsync(1) + await router.load() + + await vi.waitFor(() => + expect(getMatchById(router, '/foo/foo')?.status).toBe('error'), + ) + expect(getMatchById(router, '/foo/foo')?.error).toBeUndefined() + expect(loader).toHaveBeenCalledTimes(2) + } finally { + process.env.NODE_ENV = originalNodeEnv + } + }) + + test('same-url background beforeLoad context remains private until the atomic background commit', async () => { + const initialData = { version: 'old' } + const freshData = { version: 'new' } + let contextVersion = 'old' + let resolveParent!: (data: typeof freshData) => void + let parentLoaderCalls = 0 + const parentLoader = vi.fn(() => { + parentLoaderCalls += 1 + if (parentLoaderCalls === 1) { + return initialData + } + + return new Promise((resolve) => { + resolveParent = resolve + }) + }) + + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + beforeLoad: () => ({ version: contextVersion }), + loader: parentLoader, + staleTime: 0, + gcTime: 60_000, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + head: ({ matches }) => { + const parent = matches.find( + (match: any) => match.routeId === parentRoute.id, + )! + return { + meta: [ + { + title: `${parent.context.version} / ${(parent.loaderData as any).version}`, + }, + ], + } + }, + staleTime: Infinity, + gcTime: 60_000, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory(), + }) + + await router.navigate({ to: '/parent/child' }) + const parentMatchId = router.state.matches.find( + (match) => match.routeId === parentRoute.id, + )!.id + const childMatchId = router.state.matches.find( + (match) => match.routeId === childRoute.id, + )!.id + + expect(getMatchById(router, parentMatchId)?.context).toMatchObject({ + version: 'old', + }) + expect(getMatchById(router, parentMatchId)?.loaderData).toEqual(initialData) + expect(getTitle(getMatchById(router, childMatchId))).toBe('old / old') + + const parentStore = router.stores.matchStores.get(parentMatchId)! as any + const childStore = router.stores.matchStores.get(childMatchId)! as any + const seen: Array<{ + contextVersion?: string + loaderVersion?: string + title?: string + }> = [] + const capture = () => { + const parent = getMatchById(router, parentMatchId) + const child = getMatchById(router, childMatchId) + seen.push({ + contextVersion: (parent?.context as any)?.version, + loaderVersion: (parent?.loaderData as any)?.version, + title: getTitle(child), + }) + } + const parentSubscription = parentStore.subscribe(capture) + const childSubscription = childStore.subscribe(capture) + + contextVersion = 'new' + await vi.advanceTimersByTimeAsync(1) + await router.load() + await vi.waitFor(() => expect(parentLoader).toHaveBeenCalledTimes(2)) + + expect(getMatchById(router, parentMatchId)?.context).toMatchObject({ + version: 'old', + }) + expect(getMatchById(router, parentMatchId)?.loaderData).toEqual(initialData) + expect(getTitle(getMatchById(router, childMatchId))).toBe('old / old') + expect(getMatchById(router, parentMatchId)?.isFetching).toBe('loader') + + resolveParent(freshData) + await vi.waitFor(() => + expect(getMatchById(router, parentMatchId)?.loaderData).toEqual( + freshData, + ), + ) + + expect(getMatchById(router, parentMatchId)?.context).toMatchObject({ + version: 'new', + }) + expect(getTitle(getMatchById(router, childMatchId))).toBe('new / new') + expect( + seen.some( + (snapshot) => + snapshot.contextVersion === 'new' && snapshot.title === 'old / old', + ), + ).toBe(false) + expect( + seen.some( + (snapshot) => + snapshot.loaderVersion === 'new' && snapshot.title === 'old / old', + ), + ).toBe(false) + + parentSubscription?.unsubscribe?.() + childSubscription?.unsubscribe?.() + }) + + test('pure same-url background revalidation does not perform a foreground commit', async () => { + let resolveStaleReload!: (data: { value: string }) => void + let loaderCalls = 0 + const loader = vi.fn(() => { + loaderCalls += 1 + if (loaderCalls === 1) { + return { value: 'first' } + } + + return new Promise<{ value: string }>((resolve) => { + resolveStaleReload = resolve + }) + }) + const onEnter = vi.fn() + const onLeave = vi.fn() + const onStay = vi.fn() + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loader, + staleTime: 0, + gcTime: 60_000, + onEnter, + onLeave, + onStay, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([fooRoute]), + history: createMemoryHistory(), + }) + + await router.navigate({ to: '/foo' }) + const loadedAt = router.stores.loadedAt.get() + const cachedIds = router.stores.cachedMatches.get().map((match) => match.id) + onEnter.mockClear() + onLeave.mockClear() + onStay.mockClear() + + await vi.advanceTimersByTimeAsync(1) + await router.load() + await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(2)) + + expect(router.stores.loadedAt.get()).toBe(loadedAt) + expect(onEnter).not.toHaveBeenCalled() + expect(onLeave).not.toHaveBeenCalled() + expect(onStay).not.toHaveBeenCalled() + expect(router.stores.cachedMatches.get().map((match) => match.id)).toEqual( + cachedIds, + ) + expect(getMatchById(router, '/foo/foo')?.isFetching).toBe('loader') + + resolveStaleReload({ value: 'second' }) + await vi.waitFor(() => + expect(getMatchById(router, '/foo/foo')?.loaderData).toEqual({ + value: 'second', + }), + ) + + expect(router.stores.loadedAt.get()).toBe(loadedAt) + expect(onEnter).not.toHaveBeenCalled() + expect(onLeave).not.toHaveBeenCalled() + expect(onStay).not.toHaveBeenCalled() + expect(router.stores.cachedMatches.get().map((match) => match.id)).toEqual( + cachedIds, + ) + }) + + test('blocking child reload and background parent reload commit coherent asset revisions', async () => { + const initialParentData = { title: 'old-parent' } + const freshParentData = { title: 'new-parent' } + const initialChildData = { title: 'old-child' } + const freshChildData = { title: 'new-child' } + let resolveParent!: (data: typeof freshParentData) => void + let parentLoaderCalls = 0 + let childLoaderCalls = 0 + const parentLoader = vi.fn(() => { + parentLoaderCalls += 1 + if (parentLoaderCalls === 1) { + return initialParentData + } + + return new Promise((resolve) => { + resolveParent = resolve + }) + }) + const childLoader = vi.fn(() => { + childLoaderCalls += 1 + return childLoaderCalls === 1 ? initialChildData : freshChildData + }) + + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: parentLoader, + staleTime: 0, + gcTime: 60_000, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: { + handler: childLoader, + staleReloadMode: 'blocking', + } satisfies LoaderEntry, + head: ({ loaderData, matches }) => { + const parent = matches.find( + (match: any) => match.routeId === parentRoute.id, + )! + return { + meta: [ + { + title: `${(parent.loaderData as any).title} / ${(loaderData as any).title}`, + }, + ], + } + }, + staleTime: 0, + gcTime: 60_000, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory(), + }) + + await router.navigate({ to: '/parent/child' }) + const parentMatchId = router.state.matches.find( + (match) => match.routeId === parentRoute.id, + )!.id + const childMatchId = router.state.matches.find( + (match) => match.routeId === childRoute.id, + )!.id + const childStore = router.stores.matchStores.get(childMatchId)! as any + const seen: Array<{ loaderTitle?: string; metaTitle?: string }> = [] + const subscription = childStore.subscribe(() => { + const child = getMatchById(router, childMatchId) + seen.push({ + loaderTitle: (child?.loaderData as any)?.title, + metaTitle: getTitle(child), + }) + }) + + await vi.advanceTimersByTimeAsync(1) + await router.load() + await vi.waitFor(() => expect(parentLoader).toHaveBeenCalledTimes(2)) + expect(childLoader).toHaveBeenCalledTimes(2) + + expect(getMatchById(router, parentMatchId)?.loaderData).toEqual( + initialParentData, + ) + expect(getMatchById(router, childMatchId)?.loaderData).toEqual( + freshChildData, + ) + expect(getTitle(getMatchById(router, childMatchId))).toBe( + 'old-parent / new-child', + ) + expect(getMatchById(router, parentMatchId)?.isFetching).toBe('loader') + + resolveParent(freshParentData) + await vi.waitFor(() => + expect(getMatchById(router, parentMatchId)?.loaderData).toEqual( + freshParentData, + ), + ) + + expect(getMatchById(router, childMatchId)?.loaderData).toEqual( + freshChildData, + ) + expect(getTitle(getMatchById(router, childMatchId))).toBe( + 'new-parent / new-child', + ) + expect( + seen.some( + (snapshot) => + snapshot.loaderTitle === 'new-child' && + !['old-parent / new-child', 'new-parent / new-child'].includes( + snapshot.metaTitle ?? '', + ), + ), + ).toBe(false) + + subscription?.unsubscribe?.() + }) + + test('same-url load with one background route and one pending route still performs a final foreground commit', async () => { + const initialParentData = { title: 'old-parent' } + const freshParentData = { title: 'new-parent' } + const childResolvers: Array<(data: string) => void> = [] + let resolveParent!: (data: typeof freshParentData) => void + let parentLoaderCalls = 0 + const parentLoader = vi.fn(() => { + parentLoaderCalls += 1 + if (parentLoaderCalls === 1) { + return initialParentData + } + + return new Promise((resolve) => { + resolveParent = resolve + }) + }) + const childLoader = vi.fn(() => { + if (childResolvers.length === 0) { + childResolvers.push(() => {}) + return 'initial child' + } + + return new Promise((resolve) => { + childResolvers.push(resolve) + }) + }) + const childHead = vi.fn(({ loaderData }) => ({ + meta: [{ title: loaderData }], + })) + + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: parentLoader, + staleTime: 0, + gcTime: 60_000, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + pendingMs: 0, + pendingComponent: () => null, + loader: { + handler: childLoader, + staleReloadMode: 'blocking', + } satisfies LoaderEntry, + head: childHead, + staleTime: 0, + gcTime: 60_000, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + }) + + await router.load() + const parentMatchId = router.state.matches.find( + (match) => match.routeId === parentRoute.id, + )!.id + const childMatchId = router.state.matches.find( + (match) => match.routeId === childRoute.id, + )!.id + router.updateMatch(parentMatchId, (match) => ({ + ...match, + invalid: true, + })) + router.updateMatch(childMatchId, (match) => ({ + ...match, + status: 'pending', + isFetching: 'loader', + })) + + const secondLoad = router.invalidate() + await vi.waitFor(() => expect(childLoader).toHaveBeenCalledTimes(2)) + + expect(router.state.isLoading).toBe(true) + + childResolvers[1]!('fresh child') + await secondLoad + + const childMatch = router.state.matches.find( + (match) => match.routeId === childRoute.id, + ) + expect(childMatch?.status).toBe('success') + expect(childMatch?.loaderData).toBe('fresh child') + expect(getTitle(childMatch)).toBe('fresh child') + expect(router.state.isLoading).toBe(false) + + await vi.waitFor(() => expect(parentLoader).toHaveBeenCalledTimes(2)) + resolveParent(freshParentData) + await vi.waitFor(() => + expect( + router.state.matches.find((match) => match.routeId === parentRoute.id) + ?.loaderData, + ).toEqual(freshParentData), + ) + }) + + test('foreground final commit reprojects assets when only beforeLoad context changed', async () => { + let titleVersion = 'old' + const head = vi.fn(({ match }) => ({ + meta: [{ title: match.context.titleVersion }], + })) + const rootRoute = new BaseRootRoute({}) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + beforeLoad: () => ({ titleVersion }), + head, + staleTime: Infinity, + gcTime: 60_000, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([targetRoute]), + history: createMemoryHistory({ initialEntries: ['/target'] }), + }) + + await router.load() + const targetMatchId = router.state.matches.find( + (match) => match.routeId === targetRoute.id, + )!.id + const initialMatch = getMatchById(router, targetMatchId)! + expect(getTitle(initialMatch)).toBe('old') + head.mockClear() + + titleVersion = 'new' + await router.load({ sync: true }) + + const updatedMatch = getMatchById(router, targetMatchId)! + expect(updatedMatch.id).toBe(initialMatch.id) + expect(updatedMatch.loaderData).toBe(initialMatch.loaderData) + expect(updatedMatch.status).toBe(initialMatch.status) + expect(updatedMatch.error).toBe(initialMatch.error) + expect(getTitle(updatedMatch)).toBe('new') + expect(head).toHaveBeenCalledTimes(1) + }) + + test('stale cache-owned preload remains blocking', async () => { + let resolveStalePreload!: (data: { title: string }) => void + let loaderCalls = 0 + const loader = vi.fn(() => { + loaderCalls += 1 + if (loaderCalls === 1) { + return { title: 'old preload' } + } + + return new Promise<{ title: string }>((resolve) => { + resolveStalePreload = resolve + }) + }) + const seenLoaderData: Array = [] + const head = vi.fn(({ loaderData }) => { + seenLoaderData.push(loaderData) + return { meta: [{ title: loaderData.title }] } + }) + + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loader, + head, + staleTime: 0, + preloadStaleTime: 0, + gcTime: 60_000, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([fooRoute]), + history: createMemoryHistory(), + }) + + await router.preloadRoute({ to: '/foo' }) + expect(loader).toHaveBeenCalledTimes(1) + expect(head).toHaveBeenCalledTimes(1) + expect(seenLoaderData).toEqual([{ title: 'old preload' }]) + + head.mockClear() + seenLoaderData.length = 0 + + await vi.advanceTimersByTimeAsync(1) + let preloadSettled = false + const preload = router.preloadRoute({ to: '/foo' }).then((matches) => { + preloadSettled = true + return matches + }) + + await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(2)) + expect(preloadSettled).toBe(false) + expect(head).not.toHaveBeenCalled() + + resolveStalePreload({ title: 'fresh preload' }) + const matches = await preload + const match = matches?.find((item) => item.id === '/foo/foo') + + expect(preloadSettled).toBe(true) + expect(match?.loaderData).toEqual({ title: 'fresh preload' }) + expect(head).toHaveBeenCalledTimes(1) + expect(seenLoaderData).toEqual([{ title: 'fresh preload' }]) + expect(getMatchById(router, '/foo/foo')?._.loadPromise).toBeUndefined() + }) + + test('sync background stale reload executes head once after loader work', async () => { + let loaderCalls = 0 + const loader = vi.fn(() => { + loaderCalls += 1 + return { + title: loaderCalls === 1 ? 'First title' : 'Second title', + } + }) + const seenLoaderData: Array = [] + const head = vi.fn(({ loaderData }) => { + seenLoaderData.push(loaderData) + return { + meta: [{ title: loaderData.title }], + } + }) + + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loader, + head, + staleTime: 0, + gcTime: 60_000, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([fooRoute]), + history: createMemoryHistory(), + }) + + await router.navigate({ to: '/foo' }) + expect(head).toHaveBeenCalledTimes(1) + + head.mockClear() + seenLoaderData.length = 0 + + await vi.advanceTimersByTimeAsync(1) + await router.load() + + expect(loader).toHaveBeenCalledTimes(2) + await vi.waitFor(() => expect(head).toHaveBeenCalledTimes(1)) + expect(head).toHaveBeenCalledTimes(1) + expect(seenLoaderData).toEqual([{ title: 'Second title' }]) + + await Promise.resolve() + + expect(head).toHaveBeenCalledTimes(1) + }) + + test('same-url load while async background reload is pending lets latest batch win', async () => { + const resolvers: Array<(data: { title: string }) => void> = [] + let loaderCalls = 0 + const loader = vi.fn(() => { + loaderCalls += 1 + if (loaderCalls === 1) { + return { title: 'First title' } + } + + return new Promise<{ title: string }>((resolve) => { + resolvers.push(resolve) + }) + }) + const seenLoaderData: Array = [] + const head = vi.fn(({ loaderData }) => { + seenLoaderData.push(loaderData) + return { + meta: [{ title: loaderData.title }], + } + }) + + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loader, + head, + staleTime: 0, + gcTime: 60_000, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([fooRoute]), + history: createMemoryHistory(), + }) + + await router.navigate({ to: '/foo' }) + head.mockClear() + seenLoaderData.length = 0 + + await vi.advanceTimersByTimeAsync(1) + await router.load() + await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(2)) + + const firstBackground = router._backgroundLoad + expect(firstBackground).toBeDefined() + expect(getMatchById(router, '/foo/foo')?.isFetching).toBe('loader') + expect(head).not.toHaveBeenCalled() + + await router.load() + await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(3)) + expect(router._backgroundLoad).toBeDefined() + expect(router._backgroundLoad).not.toBe(firstBackground) + expect(firstBackground?.controller.signal.aborted).toBe(true) + expect(head).not.toHaveBeenCalled() + + resolvers[0]!({ title: 'Stale title' }) + await Promise.resolve() + expect(head).not.toHaveBeenCalled() + + resolvers[1]!({ title: 'Second title' }) + await vi.waitFor(() => expect(head).toHaveBeenCalledTimes(1)) + + expect(seenLoaderData).toEqual([{ title: 'Second title' }]) + }) + + test('background batch that observed a pending navigation cannot later commit', async () => { + const initialParentData = { title: 'initial parent' } + const freshParentData = { title: 'fresh parent' } + const initialChildData = { title: 'initial child' } + const freshChildData = { title: 'fresh child' } + let resolveChild!: (data: typeof freshChildData) => void + let parentLoaderCalls = 0 + let childLoaderCalls = 0 + let router: RouterCore + let pendingNavigation: Promise | undefined + const parentHead = vi.fn(() => ({})) + const childHead = vi.fn(() => ({})) + + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: vi.fn(() => { + parentLoaderCalls += 1 + if (parentLoaderCalls === 1) { + return initialParentData + } + + pendingNavigation = router.navigate({ to: '/bar' }) + return freshParentData + }), + head: parentHead, + staleTime: 0, + gcTime: 60_000, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: vi.fn(() => { + childLoaderCalls += 1 + if (childLoaderCalls === 1) { + return initialChildData + } + + return new Promise((resolve) => { + resolveChild = resolve + }) + }), + head: childHead, + staleTime: 0, + gcTime: 60_000, + }) + const barRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/bar', + }) + + const history = createMemoryHistory() + + router = createTestRouter({ + routeTree: rootRoute.addChildren([ + parentRoute.addChildren([childRoute]), + barRoute, + ]), + history, + }) + + await router.navigate({ to: '/parent/child' }) + parentHead.mockClear() + childHead.mockClear() + // Pause auto-loading of the nested navigation so the background batch + // observes the same public pending-location marker a user navigation sets. + const unsubscribeHistory = history.subscribe(() => {}) + + const baseMatches = router.state.matches + const parentIndex = baseMatches.findIndex( + (match) => match.routeId === parentRoute.id, + ) + const childIndex = baseMatches.findIndex( + (match) => match.routeId === childRoute.id, + ) + + startBackgroundLoad(router, router.state.location, baseMatches, [ + childIndex, + parentIndex, + ]) + + await vi.waitFor(() => expect(parentLoaderCalls).toBe(2)) + const oldToken = router._backgroundLoad + + await vi.waitFor(() => + expect(oldToken?.controller.signal.aborted).toBe(true), + ) + + resolveChild(freshChildData) + await Promise.resolve() + await Promise.resolve() + + await vi.waitFor(() => expect(router._backgroundLoad).not.toBe(oldToken)) + expect(getMatchById(router, baseMatches[parentIndex]!.id)?.loaderData).toBe( + initialParentData, + ) + expect(getMatchById(router, baseMatches[childIndex]!.id)?.loaderData).toBe( + initialChildData, + ) + expect(getMatchById(router, baseMatches[parentIndex]!.id)?.isFetching).toBe( + false, + ) + expect(getMatchById(router, baseMatches[childIndex]!.id)?.isFetching).toBe( + false, + ) + expect(parentHead).not.toHaveBeenCalled() + expect(childHead).not.toHaveBeenCalled() + + unsubscribeHistory() + router.commitLocationPromise?.resolve() + await pendingNavigation + }) + + test.each(['error', 'notFound'] as const)( + 'background index below a foreground %s boundary is discarded', + async (outcome) => { + const parentError = new Error('parent failed') + let parentLoaderCalls = 0 + const parentLoader = vi.fn(() => { + parentLoaderCalls += 1 + if (parentLoaderCalls === 1) { + return { title: 'initial parent' } + } + + throw outcome === 'error' ? parentError : notFound() + }) + const childLoader = vi.fn(() => ({ title: 'initial child' })) + + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: { + handler: parentLoader, + staleReloadMode: 'blocking', + } satisfies LoaderEntry, + errorComponent: () => null, + notFoundComponent: () => null, + staleTime: 0, + gcTime: 60_000, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: childLoader, + staleTime: 0, + gcTime: 60_000, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + parentRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + }) + + await router.load() + await vi.advanceTimersByTimeAsync(1) + await router.load() + await Promise.resolve() + await Promise.resolve() + + expect(parentLoader).toHaveBeenCalledTimes(2) + expect(childLoader).toHaveBeenCalledTimes(1) + expect(router._backgroundLoad).toBeUndefined() + expect(router.state.matches.map((match) => match.routeId)).toEqual([ + rootRoute.id, + parentRoute.id, + ]) + expect(router.state.matches[1]?.status).toBe(outcome) + if (outcome === 'error') { + expect(router.state.matches[1]?.error).toBe(parentError) + } else { + expect(router.state.matches[1]?.error).toEqual( + expect.objectContaining({ isNotFound: true }), + ) + } + }, + ) + + test.each(['errorComponent', 'notFoundComponent'] as const)( + 'stale background %s preload rejection cancels the batch', + async (componentType) => { + const componentError = new Error('component failed') + const componentGate = createControlledPromise() + const componentPreload = vi.fn(() => componentGate) + let loaderCalls = 0 + const loader = vi.fn(() => { + loaderCalls += 1 + if (loaderCalls === 1) { + return { title: 'initial' } + } + + if (componentType === 'errorComponent') { + throw new Error('loader failed') + } + + throw notFound() + }) + + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loader, + staleTime: 0, + gcTime: 60_000, + }) + const barRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/bar', + }) + const history = createMemoryHistory() + const router = createTestRouter({ + routeTree: rootRoute.addChildren([fooRoute, barRoute]), + history, + }) + + await router.navigate({ to: '/foo' }) + fooRoute.options[componentType] = { preload: componentPreload } as any + ;(fooRoute as any)._componentsLoaded = false + const baseMatches = router.state.matches + const fooIndex = baseMatches.findIndex( + (match) => match.routeId === fooRoute.id, + ) + expect(fooIndex).not.toBe(-1) + startBackgroundLoad(router, router.state.location, baseMatches, [ + fooIndex, + ]) + await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(2)) + await vi.waitFor(() => expect(componentPreload).toHaveBeenCalledTimes(1)) + + const oldToken = router._backgroundLoad + expect(oldToken).toBeDefined() + const unsubscribeHistory = history.subscribe(() => {}) + + componentGate.reject(componentError) + const pendingNavigation = router.navigate({ to: '/bar' }) + + await vi.waitFor(() => + expect(oldToken?.controller.signal.aborted).toBe(true), + ) + + unsubscribeHistory() + router.commitLocationPromise?.resolve() + await pendingNavigation + }, + ) + + test('successful background commit performs one active match publication', async () => { + const initialData = { title: 'initial' } + const freshData = { title: 'fresh' } + let resolveFresh!: (data: typeof freshData) => void + let loaderCalls = 0 + const loader = vi.fn(() => { + loaderCalls += 1 + if (loaderCalls === 1) { + return initialData + } + + return new Promise((resolve) => { + resolveFresh = resolve + }) + }) + + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loader, + staleTime: 0, + gcTime: 60_000, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([fooRoute]), + history: createMemoryHistory(), + }) + + await router.navigate({ to: '/foo' }) + const matchId = router.state.matches.find( + (match) => match.routeId === fooRoute.id, + )!.id + const matchStore = router.stores.matchStores.get(matchId)! as any + const updates: Array<{ + isFetching: AnyRouteMatch['isFetching'] + title: string + }> = [] + const subscription = matchStore.subscribe(() => { + const match = getMatchById(router, matchId)! + updates.push({ + isFetching: match.isFetching, + title: (match.loaderData as typeof initialData).title, + }) + }) + + await vi.advanceTimersByTimeAsync(1) + await router.load() + await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(2)) + resolveFresh(freshData) + await vi.waitFor(() => + expect(getMatchById(router, matchId)?.loaderData).toBe(freshData), + ) + subscription?.unsubscribe?.() + + expect(updates).toEqual([ + { isFetching: 'loader', title: 'initial' }, + { isFetching: false, title: 'fresh' }, + ]) + }) + + test.each([ + ['parent', 'child'], + ['child', 'parent'], + ] as const)( + 'multiple async background loaders flush heads after %s then %s resolve', + async (first, second) => { + const freshParentData = { title: 'fresh parent' } + const freshChildData = { title: 'fresh child' } + let resolveParent!: (data: typeof freshParentData) => void + let resolveChild!: (data: typeof freshChildData) => void + let parentLoaderCalls = 0 + let childLoaderCalls = 0 + const parentLoader = vi.fn(() => { + parentLoaderCalls += 1 + if (parentLoaderCalls === 1) { + return { title: 'initial parent' } + } + return new Promise((resolve) => { + resolveParent = resolve + }) + }) + const childLoader = vi.fn(() => { + childLoaderCalls += 1 + if (childLoaderCalls === 1) { + return { title: 'initial child' } + } + return new Promise((resolve) => { + resolveChild = resolve + }) + }) + const parentSeenData: Array = [] + const childSeenData: Array = [] + const parentHead = vi.fn(({ loaderData }) => { + parentSeenData.push(loaderData) + return { meta: [{ title: loaderData.title }] } + }) + const childHead = vi.fn(({ loaderData }) => { + childSeenData.push(loaderData) + return { meta: [{ title: loaderData.title }] } + }) + + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: parentLoader, + head: parentHead, + staleTime: 0, + gcTime: 60_000, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: childLoader, + head: childHead, + staleTime: 0, + gcTime: 60_000, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + parentRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory(), + }) + + await router.navigate({ to: '/parent/child' }) + const parentMatchId = router.state.matches.find( + (match) => match.routeId === parentRoute.id, + )!.id + const childMatchId = router.state.matches.find( + (match) => match.routeId === childRoute.id, + )!.id + parentHead.mockClear() + childHead.mockClear() + parentSeenData.length = 0 + childSeenData.length = 0 + + await vi.advanceTimersByTimeAsync(1) + await router.load() + await vi.waitFor(() => expect(parentLoader).toHaveBeenCalledTimes(2)) + await vi.waitFor(() => expect(childLoader).toHaveBeenCalledTimes(2)) + + expect(getMatchById(router, parentMatchId)?.isFetching).toBe('loader') + expect(getMatchById(router, childMatchId)?.isFetching).toBe('loader') + expect(parentHead).not.toHaveBeenCalled() + expect(childHead).not.toHaveBeenCalled() + + if (first === 'parent') { + resolveParent(freshParentData) + } else { + resolveChild(freshChildData) + } + + await Promise.resolve() + expect(parentHead).not.toHaveBeenCalled() + expect(childHead).not.toHaveBeenCalled() + + if (second === 'parent') { + resolveParent(freshParentData) + } else { + resolveChild(freshChildData) + } + + await vi.waitFor(() => expect(parentHead).toHaveBeenCalledTimes(1)) + expect(childHead).toHaveBeenCalledTimes(1) + expect(parentSeenData).toEqual([freshParentData]) + expect(childSeenData).toEqual([freshChildData]) + }, + ) + + test.each([ + ['parent', 'child'], + ['child', 'parent'], + ] as const)( + 'background child redirect waits for parent error and then wins when %s settles first', + async (first, _second) => { + const parentError = new Error('parent failed') + let rejectParent!: (error: unknown) => void + let rejectChild!: (error: unknown) => void + let parentLoaderCalls = 0 + let childLoaderCalls = 0 + const parentLoader = vi.fn(() => { + parentLoaderCalls += 1 + if (parentLoaderCalls === 1) { + return { title: 'initial parent' } + } + return new Promise((_resolve, reject) => { + rejectParent = reject + }) + }) + const childLoader = vi.fn(() => { + childLoaderCalls += 1 + if (childLoaderCalls === 1) { + return { title: 'initial child' } + } + return new Promise((_resolve, reject) => { + rejectChild = reject + }) + }) + + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: parentLoader, + staleTime: 0, + gcTime: 60_000, + errorComponent: () => null, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: childLoader, + staleTime: 0, + gcTime: 60_000, + }) + const barRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/bar', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + parentRoute.addChildren([childRoute]), + barRoute, + ]), + history: createMemoryHistory(), + }) + + await router.navigate({ to: '/parent/child' }) + await vi.advanceTimersByTimeAsync(1) + await router.load() + await vi.waitFor(() => expect(parentLoader).toHaveBeenCalledTimes(2)) + await vi.waitFor(() => expect(childLoader).toHaveBeenCalledTimes(2)) + + if (first === 'parent') { + rejectParent(parentError) + } else { + rejectChild(redirect({ to: '/bar' })) + } + await Promise.resolve() + expect(router.state.location.pathname).toBe('/parent/child') + + if (first === 'parent') { + rejectChild(redirect({ to: '/bar' })) + } else { + rejectParent(parentError) + } + + await vi.waitFor(() => + expect(router.state.location.pathname).toBe('/bar'), + ) + }, + ) + + test.each([ + ['parent', 'child'], + ['child', 'parent'], + ] as const)( + 'background parent error waits for child notFound and then wins when %s settles first', + async (first, _second) => { + const parentError = new Error('parent failed') + let rejectParent!: (error: unknown) => void + let rejectChild!: (error: unknown) => void + let parentLoaderCalls = 0 + let childLoaderCalls = 0 + const parentLoader = vi.fn(() => { + parentLoaderCalls += 1 + if (parentLoaderCalls === 1) { + return { title: 'initial parent' } + } + return new Promise((_resolve, reject) => { + rejectParent = reject + }) + }) + const childLoader = vi.fn(() => { + childLoaderCalls += 1 + if (childLoaderCalls === 1) { + return { title: 'initial child' } + } + return new Promise((_resolve, reject) => { + rejectChild = reject + }) + }) + + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: parentLoader, + staleTime: 0, + gcTime: 60_000, + errorComponent: () => null, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: childLoader, + staleTime: 0, + gcTime: 60_000, + notFoundComponent: () => null, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + parentRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory(), + }) + + await router.navigate({ to: '/parent/child' }) + const parentMatchId = router.state.matches.find( + (match) => match.routeId === parentRoute.id, + )!.id + await vi.advanceTimersByTimeAsync(1) + await router.load() + await vi.waitFor(() => expect(parentLoader).toHaveBeenCalledTimes(2)) + await vi.waitFor(() => expect(childLoader).toHaveBeenCalledTimes(2)) + + if (first === 'parent') { + rejectParent(parentError) + } else { + rejectChild(notFound()) + } + await Promise.resolve() + expect(getMatchById(router, parentMatchId)?.status).toBe('success') + + if (first === 'parent') { + rejectChild(notFound()) + } else { + rejectParent(parentError) + } + + await vi.waitFor(() => + expect(getMatchById(router, parentMatchId)?.status).toBe('error'), + ) + expect(getMatchById(router, parentMatchId)?.error).toBe(parentError) + }, + ) + + test.each([ + ['parent', 'child'], + ['child', 'parent'], + ] as const)( + 'background shallow regular error waits for deep regular error and then wins when %s settles first', + async (first, _second) => { + const parentError = new Error('parent failed') + const childError = new Error('child failed') + let rejectParent!: (error: unknown) => void + let rejectChild!: (error: unknown) => void + let parentLoaderCalls = 0 + let childLoaderCalls = 0 + const parentLoader = vi.fn(() => { + parentLoaderCalls += 1 + if (parentLoaderCalls === 1) { + return { title: 'initial parent' } + } + return new Promise((_resolve, reject) => { + rejectParent = reject + }) + }) + const childLoader = vi.fn(() => { + childLoaderCalls += 1 + if (childLoaderCalls === 1) { + return { title: 'initial child' } + } + return new Promise((_resolve, reject) => { + rejectChild = reject + }) + }) + + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: parentLoader, + staleTime: 0, + gcTime: 60_000, + errorComponent: () => null, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: childLoader, + staleTime: 0, + gcTime: 60_000, + errorComponent: () => null, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + parentRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory(), + }) + + await router.navigate({ to: '/parent/child' }) + const parentMatchId = router.state.matches.find( + (match) => match.routeId === parentRoute.id, + )!.id + const childMatchId = router.state.matches.find( + (match) => match.routeId === childRoute.id, + )!.id + await vi.advanceTimersByTimeAsync(1) + await router.load() + await vi.waitFor(() => expect(parentLoader).toHaveBeenCalledTimes(2)) + await vi.waitFor(() => expect(childLoader).toHaveBeenCalledTimes(2)) + + if (first === 'parent') { + rejectParent(parentError) + } else { + rejectChild(childError) + } + await Promise.resolve() + expect(getMatchById(router, parentMatchId)?.status).toBe('success') + expect(getMatchById(router, childMatchId)?.status).toBe('success') + + if (first === 'parent') { + rejectChild(childError) + } else { + rejectParent(parentError) + } + + await vi.waitFor(() => + expect(getMatchById(router, parentMatchId)?.status).toBe('error'), + ) + expect(getMatchById(router, parentMatchId)?.error).toBe(parentError) + expect(hasActiveMatch(router, childMatchId)).toBe(false) + }, + ) + + test('newer background batch supersedes older batch for the same lane', async () => { + const initialParentData = { title: 'initial parent' } + const initialChildData = { title: 'initial child' } + const freshParentData = { title: 'fresh parent' } + const freshChildData = { title: 'fresh child' } + let reloadParent = false + let reloadChild = false + let resolveParent!: (data: typeof freshParentData) => void + let resolveChild!: (data: typeof freshChildData) => void + let parentLoaderCalls = 0 + let childLoaderCalls = 0 + const parentLoader = vi.fn(() => { + parentLoaderCalls += 1 + if (parentLoaderCalls === 1) { + return initialParentData + } + return new Promise((resolve) => { + resolveParent = resolve + }) + }) + const childLoader = vi.fn(() => { + childLoaderCalls += 1 + if (childLoaderCalls === 1) { + return initialChildData + } + return new Promise((resolve) => { + resolveChild = resolve + }) + }) + const childSeenData: Array<{ + loaderData: unknown + parentLoaderData: unknown + }> = [] + const parentHead = vi.fn(({ loaderData }) => ({ + meta: [{ title: loaderData.title }], + })) + const childHead = vi.fn(({ loaderData, matches }) => { + childSeenData.push({ + loaderData, + parentLoaderData: matches.find( + (match: any) => match.routeId === parentRoute.id, + )?.loaderData, + }) + return { meta: [{ title: loaderData.title }] } + }) + + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: parentLoader, + head: parentHead, + staleTime: Infinity, + gcTime: 60_000, + shouldReload: () => reloadParent, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: childLoader, + head: childHead, + staleTime: Infinity, + gcTime: 60_000, + shouldReload: () => reloadChild, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory(), + }) + + await router.navigate({ to: '/parent/child' }) + const parentMatchId = router.state.matches.find( + (match) => match.routeId === parentRoute.id, + )!.id + const childMatchId = router.state.matches.find( + (match) => match.routeId === childRoute.id, + )!.id + parentHead.mockClear() + childHead.mockClear() + childSeenData.length = 0 + + reloadParent = true + reloadChild = false + await router.load() + await vi.waitFor(() => expect(parentLoader).toHaveBeenCalledTimes(2)) + const firstBackground = router._backgroundLoad + expect(getMatchById(router, parentMatchId)?.isFetching).toBe('loader') + + reloadParent = false + reloadChild = true + await router.load() + await vi.waitFor(() => expect(childLoader).toHaveBeenCalledTimes(2)) + expect(router._backgroundLoad).toBeDefined() + expect(router._backgroundLoad).not.toBe(firstBackground) + expect(firstBackground?.controller.signal.aborted).toBe(true) + expect(getMatchById(router, parentMatchId)?.isFetching).toBe(false) + expect(getMatchById(router, childMatchId)?.isFetching).toBe('loader') + expect(parentHead).not.toHaveBeenCalled() + expect(childHead).not.toHaveBeenCalled() + + resolveParent(freshParentData) + await Promise.resolve() + expect(getMatchById(router, parentMatchId)?.loaderData).toEqual( + initialParentData, + ) + expect(parentHead).not.toHaveBeenCalled() + expect(childHead).not.toHaveBeenCalled() + + resolveChild(freshChildData) + await vi.waitFor(() => expect(childHead).toHaveBeenCalledTimes(1)) + + expect(getMatchById(router, parentMatchId)?.loaderData).toEqual( + initialParentData, + ) + expect(childSeenData).toEqual([ + { + loaderData: freshChildData, + parentLoaderData: initialParentData, + }, + ]) + expect(router.state.matches.every((match) => !match.isFetching)).toBe(true) + }) + + test('latest same-url pass owns the deferred head boundary', async () => { + const freshParentData = { title: 'fresh parent' } + let reloadParent = false + let childNotFound = false + let parentLoaderCalls = 0 + let resolveParent!: (data: typeof freshParentData) => void + const parentLoader = vi.fn(() => { + parentLoaderCalls += 1 + if (parentLoaderCalls === 1) { + return { title: 'initial parent' } + } + return new Promise((resolve) => { + resolveParent = resolve + }) + }) + const childLoader = vi.fn(() => ({ title: 'initial child' })) + const parentHead = vi.fn(({ loaderData }) => ({ + meta: [{ title: loaderData.title }], + })) + const childHead = vi.fn(({ loaderData }) => ({ + meta: [{ title: loaderData.title }], + })) + + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: parentLoader, + head: parentHead, + staleTime: Infinity, + gcTime: 60_000, + shouldReload: () => reloadParent, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + beforeLoad: () => { + if (childNotFound) { + throw notFound({ routeId: parentRoute.id }) + } + }, + loader: childLoader, + head: childHead, + staleTime: Infinity, + gcTime: 60_000, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory(), + }) + + await router.navigate({ to: '/parent/child' }) + const parentMatchId = router.state.matches.find( + (match) => match.routeId === parentRoute.id, + )!.id + parentHead.mockClear() + childHead.mockClear() + + reloadParent = true + await router.load() + await vi.waitFor(() => expect(parentLoader).toHaveBeenCalledTimes(2)) + expect(getMatchById(router, parentMatchId)?.isFetching).toBe('loader') + + reloadParent = false + childNotFound = true + await router.load() + expect(parentHead).toHaveBeenCalledTimes(1) + expect(childHead).not.toHaveBeenCalled() + + resolveParent(freshParentData) + await Promise.resolve() + + expect(parentHead).toHaveBeenCalledTimes(1) + expect(childHead).not.toHaveBeenCalled() + expect( + router.state.matches.some( + (match) => + match.status === 'notFound' && + (match.error as { routeId?: string } | undefined)?.routeId === + parentRoute.id, + ), + ).toBe(true) + }) + + test.each([ + ['error', 'success'], + ['success', 'error'], + ] as const)( + 'successful background completion does not flush heads after another background match failed: %s then %s', + async (first, second) => { + void second + const parentError = new Error('parent failed') + const freshChildData = { title: 'fresh child' } + let rejectParent!: (error: unknown) => void + let resolveChild!: (data: typeof freshChildData) => void + let parentLoaderCalls = 0 + let childLoaderCalls = 0 + const parentLoader = vi.fn(() => { + parentLoaderCalls += 1 + if (parentLoaderCalls === 1) { + return { title: 'initial parent' } + } + return new Promise((_resolve, reject) => { + rejectParent = reject + }) + }) + const childLoader = vi.fn(() => { + childLoaderCalls += 1 + if (childLoaderCalls === 1) { + return { title: 'initial child' } + } + return new Promise((resolve) => { + resolveChild = resolve + }) + }) + const parentHead = vi.fn(({ loaderData }) => ({ + meta: [{ title: loaderData.title }], + })) + const childHead = vi.fn(({ loaderData }) => ({ + meta: [{ title: loaderData.title }], + })) + + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: parentLoader, + head: parentHead, + staleTime: 0, + gcTime: 60_000, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: childLoader, + head: childHead, + staleTime: 0, + gcTime: 60_000, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + parentRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory(), + }) + + await router.navigate({ to: '/parent/child' }) + const parentMatchId = router.state.matches.find( + (match) => match.routeId === parentRoute.id, + )!.id + const childMatchId = router.state.matches.find( + (match) => match.routeId === childRoute.id, + )!.id + parentHead.mockClear() + childHead.mockClear() + + await vi.advanceTimersByTimeAsync(1) + await router.load() + await vi.waitFor(() => expect(parentLoader).toHaveBeenCalledTimes(2)) + await vi.waitFor(() => expect(childLoader).toHaveBeenCalledTimes(2)) + + expect(getMatchById(router, parentMatchId)?.isFetching).toBe('loader') + expect(getMatchById(router, childMatchId)?.isFetching).toBe('loader') + + if (first === 'error') { + rejectParent(parentError) + resolveChild(freshChildData) + } else { + resolveChild(freshChildData) + rejectParent(parentError) + } + + await vi.waitFor(() => + expect(getMatchById(router, parentMatchId)?.status).toBe('error'), + ) + expect(parentHead).toHaveBeenCalledTimes(1) + expect(childHead).not.toHaveBeenCalled() + }, + ) + + test('synchronous background loader error waits for the error component before executing boundary head', async () => { + const loaderError = new Error('sync background failed') + const errorGate = createControlledPromise() + let loaderCalls = 0 + const loader = vi.fn(() => { + loaderCalls += 1 + if (loaderCalls === 1) { + return { title: 'initial' } + } + throw loaderError + }) + const head = vi.fn(({ loaderData, match }) => ({ + meta: [{ title: match.error ? 'error' : loaderData.title }], + })) + const errorPreload = vi.fn(() => errorGate) + + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loader, + head, + staleTime: 0, + gcTime: 60_000, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([fooRoute]), + history: createMemoryHistory(), + }) + + await router.navigate({ to: '/foo' }) + fooRoute.options.errorComponent = { preload: errorPreload } as any + ;(fooRoute as any)._componentsLoaded = false + head.mockClear() + + await vi.advanceTimersByTimeAsync(1) + await router.load() + + expect(loader).toHaveBeenCalledTimes(2) + await vi.waitFor(() => expect(errorPreload).toHaveBeenCalledTimes(1)) + expect(getMatchById(router, '/foo/foo')?.isFetching).toBe('loader') + expect(head).not.toHaveBeenCalled() + + errorGate.resolve() + + await vi.waitFor(() => + expect(getMatchById(router, '/foo/foo')?.status).toBe('error'), + ) + expect(head).toHaveBeenCalledTimes(1) + expect(getMatchById(router, '/foo/foo')?.meta).toEqual([{ title: 'error' }]) + }) + + test('synchronous background loader redirect does not execute source head', async () => { + let loaderCalls = 0 + const loader = vi.fn(() => { + loaderCalls += 1 + if (loaderCalls === 1) { + return { title: 'initial' } + } + throw redirect({ to: '/bar' }) + }) + const head = vi.fn(({ loaderData }) => ({ + meta: [{ title: loaderData.title }], + })) + + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loader, + head, + staleTime: 0, + gcTime: 60_000, + }) + const barRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/bar', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([fooRoute, barRoute]), + history: createMemoryHistory(), + }) + + await router.navigate({ to: '/foo' }) + head.mockClear() + + await vi.advanceTimersByTimeAsync(1) + await router.load() + + expect(loader).toHaveBeenCalledTimes(2) + expect(head).not.toHaveBeenCalled() + await vi.waitFor(() => expect(router.state.location.pathname).toBe('/bar')) + expect(head).not.toHaveBeenCalled() + }) + + test('synchronous background loader notFound executes the selected boundary head', async () => { + let loaderCalls = 0 + const loader = vi.fn(() => { + loaderCalls += 1 + if (loaderCalls === 1) { + return { title: 'initial' } + } + throw notFound() + }) + const head = vi.fn(({ loaderData, match }) => ({ + meta: [{ title: match.error ? 'not-found' : loaderData.title }], + })) + + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loader, + head, + staleTime: 0, + gcTime: 60_000, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([fooRoute]), + history: createMemoryHistory(), + }) + + await router.navigate({ to: '/foo' }) + head.mockClear() + + await vi.advanceTimersByTimeAsync(1) + await router.load() + + expect(loader).toHaveBeenCalledTimes(2) + expect(head).not.toHaveBeenCalled() + + await vi.waitFor(() => + expect(getMatchById(router, '/foo/foo')?.status).toBe('notFound'), + ) + expect(head).toHaveBeenCalledTimes(1) + expect(getMatchById(router, '/foo/foo')?.meta).toEqual([ + { title: 'not-found' }, + ]) + }) + + test('sync stale loader commits fresh data without reloading route chunk', async () => { + let loaderCalls = 0 + const loader = vi.fn(() => { + loaderCalls += 1 + return { title: loaderCalls === 1 ? 'initial' : 'fresh' } + }) + const lazyHead = vi.fn(({ loaderData }) => ({ + meta: [{ title: loaderData.title }], + })) + const lazyGate = createControlledPromise() + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loader, + staleTime: 0, + gcTime: 60_000, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([fooRoute]), + history: createMemoryHistory(), + }) + + await router.navigate({ to: '/foo' }) + const lazyFn = vi.fn(() => lazyGate) + ;(fooRoute as any)._lazyLoaded = false + ;(fooRoute as any).lazyFn = lazyFn + + await vi.advanceTimersByTimeAsync(1) + await router.load() + + expect(loader).toHaveBeenCalledTimes(2) + expect(lazyFn).not.toHaveBeenCalled() + expect(lazyHead).not.toHaveBeenCalled() + expect(getMatchById(router, '/foo/foo')?.isFetching).toBe(false) + expect(getMatchById(router, '/foo/foo')?.loaderData).toEqual({ + title: 'fresh', + }) + + lazyGate.resolve({ options: { head: lazyHead } }) + await Promise.resolve() + expect(lazyFn).not.toHaveBeenCalled() + expect(lazyHead).not.toHaveBeenCalled() + expect(getMatchById(router, '/foo/foo')?.loaderData).toEqual({ + title: 'fresh', + }) + }) + + test('background reload resolving after route exit does not execute head', async () => { + let resolveStaleReload!: (data: { title: string }) => void + let loaderCalls = 0 + const loader = vi.fn(() => { + loaderCalls += 1 + if (loaderCalls === 1) { + return { title: 'initial' } + } + return new Promise<{ title: string }>((resolve) => { + resolveStaleReload = resolve + }) + }) + const head = vi.fn(() => ({ meta: [{ title: 'foo' }] })) + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loader, + head, + staleTime: 0, + gcTime: 60_000, + }) + const barRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/bar', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([fooRoute, barRoute]), + history: createMemoryHistory(), + }) + + await router.navigate({ to: '/foo' }) + head.mockClear() + + await vi.advanceTimersByTimeAsync(1) + await router.load() + await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(2)) + expect(getMatchById(router, '/foo/foo')?.isFetching).toBe('loader') + + await router.navigate({ to: '/bar' }) + resolveStaleReload({ title: 'fresh' }) + await Promise.resolve() + + expect(router.state.location.pathname).toBe('/bar') + expect(head).not.toHaveBeenCalled() + }) + + test('foreground navigation clears an active background fetching marker before commit', async () => { + let resolveStaleReload!: (data: { title: string }) => void + let loaderCalls = 0 + const loader = vi.fn(() => { + loaderCalls += 1 + if (loaderCalls === 1) { + return { title: 'initial' } + } + return new Promise<{ title: string }>((resolve) => { + resolveStaleReload = resolve + }) + }) + const barBeforeLoadGate = createControlledPromise() + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loader, + staleTime: 0, + gcTime: 60_000, + }) + const barRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/bar', + beforeLoad: () => barBeforeLoadGate, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([fooRoute, barRoute]), + history: createMemoryHistory(), + }) + + await router.navigate({ to: '/foo' }) + await vi.advanceTimersByTimeAsync(1) + await router.load() + await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(2)) + expect(getMatchById(router, '/foo/foo')?.isFetching).toBe('loader') + + const barNavigation = router.navigate({ to: '/bar' }) + await vi.waitFor(() => + expect(router.stores.pendingIds.get()).toContain('/bar/bar'), + ) + + expect(getMatchById(router, '/foo/foo')?.isFetching).toBe(false) + + resolveStaleReload({ title: 'fresh' }) + barBeforeLoadGate.resolve() + await barNavigation + expect(router.state.location.pathname).toBe('/bar') + }) + + test('background reload redirect does not execute deferred source head', async () => { + let rejectStaleReload!: (error: unknown) => void + let loaderCalls = 0 + const loader = vi.fn(() => { + loaderCalls += 1 + if (loaderCalls === 1) { + return { title: 'initial' } + } + return new Promise((_resolve, reject) => { + rejectStaleReload = reject + }) + }) + const head = vi.fn(() => ({ meta: [{ title: 'foo' }] })) + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loader, + head, + staleTime: 0, + gcTime: 60_000, + }) + const barRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/bar', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([fooRoute, barRoute]), + history: createMemoryHistory(), + }) + + await router.navigate({ to: '/foo' }) + head.mockClear() + await vi.advanceTimersByTimeAsync(1) + await router.load() + await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(2)) + expect(getMatchById(router, '/foo/foo')?.isFetching).toBe('loader') + + rejectStaleReload(redirect({ to: '/bar' })) + await vi.waitFor(() => expect(router.state.location.pathname).toBe('/bar')) + + expect(head).not.toHaveBeenCalled() + }) + + test('background reload error does not execute success head with stale data', async () => { + let rejectStaleReload!: (error: unknown) => void + let loaderCalls = 0 + const loader = vi.fn(() => { + loaderCalls += 1 + if (loaderCalls === 1) { + return { title: 'initial' } + } + return new Promise((_resolve, reject) => { + rejectStaleReload = reject + }) + }) + const head = vi.fn(({ match }) => ({ + meta: [{ title: match.error ? 'error' : 'foo' }], + })) + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loader, + head, + staleTime: 0, + gcTime: 60_000, + errorComponent: () => null, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([fooRoute]), + history: createMemoryHistory(), + }) + + await router.navigate({ to: '/foo' }) + head.mockClear() + await vi.advanceTimersByTimeAsync(1) + await router.load() + await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(2)) + expect(getMatchById(router, '/foo/foo')?.isFetching).toBe('loader') + + rejectStaleReload(new Error('background failed')) + await vi.waitFor(() => + expect(getMatchById(router, '/foo/foo')?.status).toBe('error'), + ) + + expect(head).toHaveBeenCalledTimes(1) + expect(getMatchById(router, '/foo/foo')?.meta).toEqual([{ title: 'error' }]) + }) + + test('background-atomicity keeps active data and head coherent until atomic commit', async () => { + let resolveStaleReload!: (value: { value: string }) => void + let resolveHead!: () => void + let loaderCalls = 0 + const loader = vi.fn(() => { + loaderCalls += 1 + if (loaderCalls === 1) { + return { value: 'old' } + } + + return new Promise<{ value: string }>((resolve) => { + resolveStaleReload = resolve + }) + }) + const head = vi.fn(({ loaderData }: { loaderData?: any }) => { + if (loaderData?.value === 'new') { + return new Promise<{ meta: Array<{ title: string }> }>((resolve) => { + resolveHead = () => resolve({ meta: [{ title: 'new' }] }) + }) + } + + return { meta: [{ title: 'old' }] } + }) + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loader, + head, + staleTime: 0, + gcTime: 60_000, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([fooRoute]), + history: createMemoryHistory(), + }) + + await router.navigate({ to: '/foo' }) + expect(getMatchById(router, '/foo/foo')?.loaderData).toEqual({ + value: 'old', + }) + const initialMeta = getMatchById(router, '/foo/foo')?.meta + head.mockClear() + + await vi.advanceTimersByTimeAsync(1) + await router.load() + await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(2)) + + expect(getMatchById(router, '/foo/foo')?.isFetching).toBe('loader') + + resolveStaleReload({ value: 'new' }) + await vi.waitFor(() => expect(resolveHead).toBeTypeOf('function')) + + const activeWhileHeadPending = getMatchById(router, '/foo/foo')! + expect(activeWhileHeadPending.loaderData).toEqual({ value: 'old' }) + expect(activeWhileHeadPending.meta).toBe(initialMeta) + expect(activeWhileHeadPending.isFetching).toBe('loader') + + resolveHead() + await vi.waitFor(() => + expect(getMatchById(router, '/foo/foo')?.loaderData).toEqual({ + value: 'new', + }), + ) + expect(getMatchById(router, '/foo/foo')?.meta).toEqual([{ title: 'new' }]) + expect(getMatchById(router, '/foo/foo')?.isFetching).toBe(false) + }) + + test('parent-only background reload republishes child head derived from parent loaderData', async () => { + const freshParentData = { title: 'fresh parent' } + let reloadParent = false + let resolveParent!: (data: typeof freshParentData) => void + let parentLoaderCalls = 0 + const parentLoader = vi.fn(() => { + parentLoaderCalls += 1 + if (parentLoaderCalls === 1) { + return { title: 'initial parent' } + } + return new Promise((resolve) => { + resolveParent = resolve + }) + }) + const childHead = vi.fn(({ matches }) => { + const parent = matches.find( + (match: any) => match.routeId === parentRoute.id, + ) + return { meta: [{ title: parent?.loaderData.title }] } + }) + + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: parentLoader, + staleTime: Infinity, + gcTime: 60_000, + shouldReload: () => reloadParent, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + head: childHead, + staleTime: Infinity, + gcTime: 60_000, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory(), + }) + + await router.navigate({ to: '/parent/child' }) + const childMatchId = router.state.matches.find( + (match) => match.routeId === childRoute.id, + )!.id + const childStore = router.stores.matchStores.get(childMatchId)! as any + const seen: Array = [] + const subscription = childStore.subscribe(() => { + seen.push(childStore.get().meta) + }) + childHead.mockClear() + + reloadParent = true + await router.load() + await vi.waitFor(() => expect(parentLoader).toHaveBeenCalledTimes(2)) + expect(childHead).not.toHaveBeenCalled() + + resolveParent(freshParentData) + await vi.waitFor(() => + expect(getMatchById(router, childMatchId)?.meta).toEqual([ + { title: freshParentData.title }, + ]), + ) + + expect(childHead).toHaveBeenCalledTimes(1) + expect(seen).toContainEqual([{ title: freshParentData.title }]) + subscription?.unsubscribe?.() + }) + + test('blocks stale reloads when loader staleReloadMode is blocking', async () => { + const { loader, resolveStaleReload } = createControlledStaleReload() + const router = setup({ + staleTime: 0, + loader: { + handler: loader, + staleReloadMode: 'blocking', + } satisfies LoaderEntry, + }) + + await expectBlockingStaleReloadBehavior(router, loader, resolveStaleReload) + }) + + test('blocks stale reloads when defaultStaleReloadMode is blocking', async () => { + const { loader, resolveStaleReload } = createControlledStaleReload() + const router = setup({ + loader, + staleTime: 0, + defaultStaleReloadMode: 'blocking', + }) + + await expectBlockingStaleReloadBehavior(router, loader, resolveStaleReload) + }) + + test('loader staleReloadMode overrides defaultStaleReloadMode', async () => { + const { loader, resolveStaleReload } = createControlledStaleReload() + const router = setup({ + staleTime: 0, + defaultStaleReloadMode: 'blocking', + loader: { + handler: loader, + staleReloadMode: 'background', + } satisfies LoaderEntry, + }) + + await expectBackgroundStaleReloadBehavior( + router, + loader, + resolveStaleReload, + ) + }) + + test('active background reload redirect navigates while its location is still current', 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' }) + expect(loader).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(1) + await router.load() + await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(2)) + + expect(getMatchById(router, '/foo/foo')?.isFetching).toBe('loader') + + rejectStaleReload(redirect({ to: '/bar' })) + await vi.waitFor(() => expect(router.state.location.pathname).toBe('/bar')) + + expect( + router.stores.cachedMatches + .get() + .some((match) => match.id === '/foo/foo'), + ).toBe(true) + }) + + test('current background redirect bypasses navigation blockers and does not leave the source fetching', 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 blockerFn = vi.fn(() => true) + const router = setup({ loader, staleTime: 0 }) + let unblock: (() => void) | undefined + + try { + await router.navigate({ to: '/foo' }) + expect(loader).toHaveBeenCalledTimes(1) + unblock = router.history.block({ + blockerFn, + enableBeforeUnload: false, + }) + + await vi.advanceTimersByTimeAsync(1) + await router.load() + await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(2)) + + expect(getMatchById(router, '/foo/foo')?.isFetching).toBe('loader') + + rejectStaleReload(redirect({ to: '/bar' })) + await vi.waitFor(() => + expect(router.state.location.pathname).toBe('/bar'), + ) + + expect(blockerFn).not.toHaveBeenCalled() + const sourceMatch = + router.state.matches.find((match) => match.id === '/foo/foo') ?? + router.stores.cachedMatches + .get() + .find((match) => match.id === '/foo/foo') + expect(sourceMatch?.isFetching).not.toBe('loader') + } finally { + unblock?.() + } + }) + + test('exited background reload redirect is ignored without evicting the source entry', 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)) + + expect(getMatchById(router, '/foo/foo')?.isFetching).toBe('loader') + + await router.navigate({ to: '/bar' }) + expect(router.state.location.pathname).toBe('/bar') + const sourceLoadPromise = getMatchById(router, '/foo/foo')?._.loadPromise + expect(sourceLoadPromise?.status).not.toBe('pending') + expect( + router.stores.cachedMatches + .get() + .some((match) => match.id === '/foo/foo'), + ).toBe(true) + expect( + router.stores.cachedMatches.get().find((match) => match.id === '/foo/foo') + ?._.loadPromise, + ).toBeUndefined() + expectNoCachedActiveOverlap(router) + + rejectStaleReload(redirect({ to: '/baz' })) + + expect(router.state.location.pathname).toBe('/bar') + await vi.waitFor(() => + expect( + router.stores.cachedMatches + .get() + .some((match) => match.id === '/foo/foo'), + ).toBe(true), + ) + expectNoCachedActiveOverlap(router) + }) + + test('background redirect is ignored while a newer navigation is pending, even though the old source match is still active', async () => { + let rejectStaleReload!: (error: unknown) => void + let loaderCalls = 0 + const fooLoader = vi.fn(() => { + loaderCalls += 1 + if (loaderCalls === 1) { + return { value: 'first' } + } + + return new Promise((_resolve, reject) => { + rejectStaleReload = reject + }) + }) + const barBeforeLoadGate = createControlledPromise() + const barBeforeLoad = vi.fn(() => barBeforeLoadGate) + const bazBeforeLoad = vi.fn() + + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loader: fooLoader, + staleTime: 0, + gcTime: 60_000, + }) + const barRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/bar', + beforeLoad: barBeforeLoad, + }) + const bazRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/baz', + beforeLoad: bazBeforeLoad, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([fooRoute, barRoute, bazRoute]), + history: createMemoryHistory(), + }) + + await router.navigate({ to: '/foo' }) + await vi.advanceTimersByTimeAsync(1) + await router.load() + await vi.waitFor(() => expect(fooLoader).toHaveBeenCalledTimes(2)) + + expect(getMatchById(router, '/foo/foo')?.isFetching).toBe('loader') + + const barNavigation = router.navigate({ to: '/bar' }) + await vi.waitFor(() => expect(barBeforeLoad).toHaveBeenCalledTimes(1)) + expect(hasActiveMatch(router, '/foo/foo')).toBe(true) + expect(hasPendingMatch(router, '/bar/bar')).toBe(true) + + rejectStaleReload(redirect({ to: '/baz' })) + await Promise.resolve() + + expect(bazBeforeLoad).not.toHaveBeenCalled() + + barBeforeLoadGate.resolve() + await barNavigation + + expect(router.state.location.pathname).toBe('/bar') + expect(bazBeforeLoad).not.toHaveBeenCalled() + expectNoCachedActiveOverlap(router) + }) + + test('soft background AbortError preserves data and updatedAt and clears fetching', async () => { + let rejectStaleReload!: (error: unknown) => void + let loaderCalls = 0 + const loader = vi.fn(() => { + loaderCalls += 1 + if (loaderCalls === 1) { + return { value: 'old' } + } + + return new Promise((_resolve, reject) => { + rejectStaleReload = reject + }) + }) + const head = vi.fn(({ loaderData }) => ({ + meta: [{ title: loaderData.value }], + })) + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loader, + head, + staleTime: 0, + gcTime: 60_000, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([fooRoute]), + history: createMemoryHistory(), + }) + + await router.navigate({ to: '/foo' }) + const initialMatch = getMatchById(router, '/foo/foo')! + const initialUpdatedAt = initialMatch.updatedAt + expect(initialMatch.loaderData).toEqual({ value: 'old' }) + expect(getTitle(initialMatch)).toBe('old') + head.mockClear() + + await vi.advanceTimersByTimeAsync(1) + await router.load() + await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(2)) + + expect(getMatchById(router, '/foo/foo')?.isFetching).toBe('loader') + + const abortError = new Error('soft aborted') + abortError.name = 'AbortError' + rejectStaleReload(abortError) + + await vi.waitFor(() => + expect(getMatchById(router, '/foo/foo')?.isFetching).toBe(false), + ) + + const currentMatch = getMatchById(router, '/foo/foo')! + expect(currentMatch.loaderData).toEqual({ value: 'old' }) + expect(currentMatch.updatedAt).toBe(initialUpdatedAt) + expect(getTitle(currentMatch)).toBe('old') + expect(head).toHaveBeenCalledTimes(1) + }) + + test('pending preload error leaves no cache entry', 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)) + + expect(getMatchById(router, '/foo/foo')).toBeUndefined() + expect(router.stores.cachedMatches.get()).toEqual([]) + + rejectPreload(new Error('preload failed')) + await preload + + 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 + await router.navigate({ to: '/bar' }) + await router.latestLoadPromise + + expect(router.state.location.pathname).toBe('/bar') + + // Verify that abort was called and no stale pending route remains active. + expect(onAbortMock).toHaveBeenCalled() + const cancelledFooMatch = router.getMatch('/foo/foo') + expect(cancelledFooMatch?.status).not.toBe('pending') +}) + +test('pending timeout stays scoped to the current load pass', 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?.status).toBe('pending') + + const joinedLoad = router.load() + await Promise.resolve() + + const rearmedMatch = router.getMatch('/foo/foo') + expect(rearmedMatch?.status).toBe('pending') + + await vi.advanceTimersByTimeAsync(WAIT_TIME * 2) + expect(rearmedMatch?.status).toBe('pending') + + resolveLoader() + await Promise.all([navigation, joinedLoad]) + expect(loader).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } +}) + +test('pendingMin wait does not throw after its match is canceled', async () => { + vi.useFakeTimers() + + try { + let resolveLoader!: () => void + const rootRoute = new BaseRootRoute({}) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + pendingMs: 0, + pendingMinMs: 100, + pendingComponent: () => null, + loader: () => + new Promise((resolve) => { + resolveLoader = resolve + }), + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([targetRoute]), + history: createMemoryHistory({ initialEntries: ['/target'] }), + }) + const location = router.latestLocation + const matches = router.matchRoutes(location) + router.stores.setPending(matches) + const targetMatch = matches.find( + (match) => match.routeId === targetRoute.id, + )! + + const loadPromise = loadMatches({ + router, + location, + matches, + onReady: (readyMatches) => { + const readyMatch = readyMatches.find( + (match) => match.routeId === targetRoute.id, + )! + const promise = readyMatch._.loadPromise + if (promise) { + promise.pendingUntil ??= Date.now() + 100 + } + }, + }) + + await vi.advanceTimersByTimeAsync(0) + expect(router.getMatch(targetMatch.id)?.status).toBe('pending') + + resolveLoader() + await Promise.resolve() + router.cancelMatches() + + await vi.advanceTimersByTimeAsync(100) + await expect(loadPromise).resolves.toBe(matches) + } finally { + vi.useRealTimers() + } +}) + +test('cancelMatches settles pending match load promises immediately', async () => { + let resolveLoader!: () => void + const rootRoute = new BaseRootRoute({}) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + loader: () => + new Promise((resolve) => { + resolveLoader = resolve + }), + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([targetRoute]), + history: createMemoryHistory({ initialEntries: ['/target'] }), + }) + const location = router.latestLocation + const matches = router.matchRoutes(location) + router.stores.setPending(matches) + const targetMatch = matches.find((match) => match.routeId === targetRoute.id)! + + const loadPromise = loadMatches({ router, location, matches }) + await vi.waitFor(() => + expect(router.getMatch(targetMatch.id)?._.loadPromise).toBeDefined(), + ) + + const matchLoadPromise = router.getMatch(targetMatch.id)?._.loadPromise + expect(matchLoadPromise?.status).toBe('pending') + + router.cancelMatches() + + expect(matchLoadPromise?.status).toBe('resolved') + + resolveLoader() + await expect(loadPromise).resolves.toBe(matches) +}) + +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._.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._.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 loadPromise = fromMatch._.loadPromise + loadPromise!.pendingUntil = Date.now() + 100 + + expect(loadPromise?.status).toBe('pending') + + await router.navigate({ to: '/to' }) + + expect(router.state.location.pathname).toBe('/to') + expect(loadPromise?.status).toBe('resolved') + expect(fromMatch._.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 loadPromise = fromMatch._.loadPromise + + expect(loadPromise?.status).toBe('pending') + + await router.navigate({ to: '/to' }) + await fromNavigation + + expect(router.state.location.pathname).toBe('/to') + expect(loadPromise?.status).toBe('resolved') + expect(fromMatch._.loadPromise).toBeUndefined() + expect( + router.stores.cachedMatches + .get() + .some((match) => match.id === '/from/from'), + ).toBe(false) + } finally { + vi.useRealTimers() + } +}) + +test('loader AbortError respects pendingMinMs before committing error', async () => { + vi.useFakeTimers() + + try { + const abortError = new Error('soft aborted') + abortError.name = 'AbortError' + const rootRoute = new BaseRootRoute({}) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + pendingMs: 0, + pendingMinMs: 100, + pendingComponent: () => null, + loader: () => + new Promise((_resolve, reject) => { + setTimeout(() => { + reject(abortError) + }, 10) + }), + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([targetRoute]), + history: createMemoryHistory({ initialEntries: ['/target'] }), + }) + const location = router.latestLocation + const matches = router.matchRoutes(location) + router.stores.setPending(matches) + const targetMatch = matches.find( + (match) => match.routeId === targetRoute.id, + )! + const initialUpdatedAt = targetMatch.updatedAt + + const loadPromise = loadMatches({ + router, + location, + matches, + onReady: async (readyMatches) => { + const readyMatch = readyMatches.find( + (match) => match.routeId === targetRoute.id, + )! + readyMatch._.loadPromise!.pendingUntil = Date.now() + 100 + }, + }) + + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(10) + await Promise.resolve() + + expect(router.getMatch(targetMatch.id)?.status).toBe('pending') + + await vi.advanceTimersByTimeAsync(89) + expect(router.getMatch(targetMatch.id)?.status).toBe('pending') + + await vi.advanceTimersByTimeAsync(1) + await loadPromise + + const updatedMatch = getLaneMatch(matches, targetMatch.id) + expect(updatedMatch?.status).toBe('error') + expect(updatedMatch?.error).toBe(abortError) + expect(updatedMatch?.updatedAt).toBeGreaterThan(initialUpdatedAt) + expect(updatedMatch?._.loadPromise).toBeUndefined() + } finally { + vi.useRealTimers() + } +}) + +test('loader AbortError waits for route component preload before committing error', async () => { + const abortError = new Error('soft aborted') + abortError.name = 'AbortError' + const componentGate = createControlledPromise() + const rootRoute = new BaseRootRoute({}) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + loader: () => { + throw abortError + }, + component: { preload: () => componentGate } as any, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([targetRoute]), + history: createMemoryHistory({ initialEntries: ['/target'] }), + }) + const location = router.latestLocation + const matches = router.matchRoutes(location) + router.stores.setPending(matches) + const targetMatch = matches.find((match) => match.routeId === targetRoute.id)! + + let loadSettled = false + const loadPromise = loadMatches({ router, location, matches }).finally(() => { + loadSettled = true + }) + + await vi.waitFor(() => + expect(router.getMatch(targetMatch.id)?._.loadPromise?.status).toBe( + 'pending', + ), + ) + expect(router.getMatch(targetMatch.id)?.status).toBe('pending') + expect(loadSettled).toBe(false) + + componentGate.resolve() + await loadPromise + + const updatedMatch = getLaneMatch(matches, targetMatch.id) + expect(updatedMatch?.status).toBe('error') + expect(updatedMatch?.error).toBe(abortError) + expect(updatedMatch?._.loadPromise).toBeUndefined() +}) + +test('loader AbortError followed by component preload failure commits error', async () => { + const chunkError = new Error('chunk failed') + const componentGate = createControlledPromise() + const rootRoute = new BaseRootRoute({}) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + loader: () => { + const abortError = new Error('soft aborted') + abortError.name = 'AbortError' + throw abortError + }, + component: { preload: () => componentGate } as any, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([targetRoute]), + history: createMemoryHistory({ initialEntries: ['/target'] }), + }) + const location = router.latestLocation + const matches = router.matchRoutes(location) + router.stores.setPending(matches) + const targetMatch = matches.find((match) => match.routeId === targetRoute.id)! + + const loadPromise = loadMatches({ router, location, matches }).then( + () => undefined, + (err) => err, + ) + + await vi.waitFor(() => + expect(router.getMatch(targetMatch.id)?._.loadPromise?.status).toBe( + 'pending', + ), + ) + + componentGate.reject(chunkError) + + await expect(loadPromise).resolves.toBeUndefined() + const updatedMatch = getLaneMatch(matches, targetMatch.id) + expect(updatedMatch?.status).toBe('error') + expect(updatedMatch?.error).toBe(chunkError) + expect(updatedMatch?._.loadPromise).toBeUndefined() +}) + +test('lazy route AbortError is not treated as a soft loader abort', async () => { + const abortError = new Error('lazy aborted') + abortError.name = 'AbortError' + const lazyGate = createControlledPromise() + const rootRoute = new BaseRootRoute({}) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + loader: () => 'loaded', + }).lazy(() => lazyGate) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([targetRoute]), + history: createMemoryHistory({ initialEntries: ['/target'] }), + }) + const location = router.latestLocation + const matches = router.matchRoutes(location) + router.stores.setPending(matches) + const targetMatch = matches.find((match) => match.routeId === targetRoute.id)! + + const loadPromise = loadMatches({ router, location, matches }).then( + () => undefined, + (err) => err, + ) + await vi.waitFor(() => + expect(router.getMatch(targetMatch.id)?._.loadPromise).toBeDefined(), + ) + + lazyGate.reject(abortError) + + await expect(loadPromise).resolves.toBeUndefined() + const updatedMatch = getLaneMatch(matches, targetMatch.id) + expect(updatedMatch?.status).toBe('error') + expect(updatedMatch?.error).toBe(abortError) + expect(updatedMatch?._.loadPromise).toBeUndefined() +}) + +describe('head execution', () => { + test('client asset projection never mutates the input match object', async () => { + const oldMeta = [{ title: 'old' }] + const rootRoute = new BaseRootRoute({}) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + head: () => ({ meta: [{ title: 'new' }] }), + scripts: () => [{ children: 'target-script' }], + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([targetRoute]), + history: createMemoryHistory({ initialEntries: ['/target'] }), + }) + const matches = router.matchRoutes(router.stores.location.get()) + const original = matches.find((match) => match.routeId === targetRoute.id)! + original.meta = oldMeta + Object.freeze(original) + + await projectAssets({ router, matches }) + + const updated = matches[original.index]! + expect(updated).not.toBe(original) + expect(original.meta).toBe(oldMeta) + expect(updated.meta).toEqual([{ title: 'new' }]) + expect(updated.scripts).toEqual([{ children: 'target-script' }]) + }) + + test('client asset projection handles async head rejection after scripts throws', async () => { + const headGate = createControlledPromise<{ + meta: Array<{ title: string }> + }>() + const unhandledRejection = vi.fn() + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + process.on('unhandledRejection', unhandledRejection) + + try { + const scriptsError = new Error('scripts failed') + const rootRoute = new BaseRootRoute({}) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + head: () => headGate, + scripts: () => { + throw scriptsError + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([targetRoute]), + history: createMemoryHistory({ initialEntries: ['/target'] }), + }) + const matches = router.matchRoutes(router.stores.location.get()) + + await projectAssets({ router, matches }) + headGate.reject(new Error('head failed late')) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(unhandledRejection).not.toHaveBeenCalled() + } finally { + process.off('unhandledRejection', unhandledRejection) + consoleError.mockRestore() + } + }) + + test('server asset projection handles async head and scripts rejection after headers throws', async () => { + const headGate = createControlledPromise<{ + meta: Array<{ title: string }> + }>() + const scriptsGate = createControlledPromise>() + const unhandledRejection = vi.fn() + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + process.on('unhandledRejection', unhandledRejection) + + try { + const headersError = new Error('headers failed') + const rootRoute = new BaseRootRoute({}) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + head: () => headGate, + scripts: () => scriptsGate, + headers: () => { + throw headersError + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([targetRoute]), + history: createMemoryHistory({ initialEntries: ['/target'] }), + isServer: true, + }) + const matches = router.matchRoutes(router.stores.location.get()) + const assets = projectServerRouteAssets(router, matches) + if (assets) { + await assets + } + + headGate.reject(new Error('head failed late')) + scriptsGate.reject(new Error('scripts failed late')) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(unhandledRejection).not.toHaveBeenCalled() + } finally { + process.off('unhandledRejection', unhandledRejection) + consoleError.mockRestore() + } + }) + + const setupBeforeLoadNotFoundHierarchy = (throwAtIndex: 1 | 2 | 3) => { + const loaderResolvers: Array<(() => void) | undefined> = [] + + const makeLoader = (index: number) => + vi.fn(async () => { + await new Promise((resolve) => { + loaderResolvers[index] = resolve + }) + return { level: index } + }) + + const makeHead = (label: string) => + vi.fn(() => ({ meta: [{ title: label }] })) + + const rootRoute = new BaseRootRoute({ + loader: makeLoader(0), + head: makeHead('Root'), + }) + + const level1Route = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/level-1', + loader: makeLoader(1), + head: makeHead('Level 1'), + beforeLoad: + throwAtIndex === 1 + ? () => { + throw notFound() + } + : undefined, + }) + + const level2Route = new BaseRoute({ + getParentRoute: () => level1Route, + path: '/level-2', + loader: makeLoader(2), + head: makeHead('Level 2'), + beforeLoad: + throwAtIndex === 2 + ? () => { + throw notFound() + } + : undefined, + }) + + const level3Route = new BaseRoute({ + getParentRoute: () => level2Route, + path: '/level-3', + loader: makeLoader(3), + head: makeHead('Level 3'), + beforeLoad: + throwAtIndex === 3 + ? () => { + throw notFound() + } + : undefined, + }) + + const routeTree = rootRoute.addChildren([ + level1Route.addChildren([level2Route.addChildren([level3Route])]), + ]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory({ + initialEntries: ['/level-1/level-2/level-3'], + }), + }) + + const routes = [rootRoute, level1Route, level2Route, level3Route] as const + const loaders = routes.map( + (route) => route.options.loader as ReturnType, + ) + const heads = routes.map( + (route) => route.options.head as ReturnType, + ) + + return { + router, + routes, + loaders, + heads, + loaderResolvers, + throwAtIndex, + } + } + + test('head-triggered navigation prevents scripts and descendant assets from running', async () => { + let router!: AnyRouter + const staleScripts = vi.fn(() => []) + const staleChildHead = vi.fn(() => ({})) + const barBeforeLoad = vi.fn() + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + head: () => { + void router.navigate({ to: '/bar' }) + return {} + }, + scripts: staleScripts, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => fooRoute, + path: '/child', + head: staleChildHead, + }) + const barRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/bar', + beforeLoad: barBeforeLoad, + }) + + router = createTestRouter({ + routeTree: rootRoute.addChildren([ + fooRoute.addChildren([childRoute]), + barRoute, + ]), + history: createMemoryHistory(), + }) + + await router.navigate({ to: '/foo/child' }).catch(() => undefined) + await vi.waitFor(() => expect(router.state.location.pathname).toBe('/bar')) + + expect(staleScripts).not.toHaveBeenCalled() + expect(staleChildHead).not.toHaveBeenCalled() + expect(barBeforeLoad).toHaveBeenCalledTimes(1) + }) + + test('same-href head reentry cannot run stale scripts or descendant heads', async () => { + let router!: AnyRouter + let reenter = false + let didNavigate = false + let headPass = 0 + let navigation: Promise | undefined + const scripts = vi.fn(() => []) + const childHead = vi.fn(() => ({})) + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + head: () => { + headPass += 1 + if (reenter && !didNavigate) { + didNavigate = true + navigation = Promise.resolve(router.navigate({ to: '/foo/child' })) + return { meta: [{ title: 'stale' }] } + } + return { meta: [{ title: 'fresh' }] } + }, + scripts, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => fooRoute, + path: '/child', + head: childHead, + }) + + router = createTestRouter({ + routeTree: rootRoute.addChildren([fooRoute.addChildren([childRoute])]), + history: createMemoryHistory({ initialEntries: ['/foo/child'] }), + }) + + await router.load() + scripts.mockClear() + childHead.mockClear() + headPass = 0 + reenter = true + + await router.load() + await navigation + + expect(headPass).toBe(2) + expect(scripts).toHaveBeenCalledTimes(1) + expect(childHead).toHaveBeenCalledTimes(1) + expect( + router.state.matches.find((match) => match.routeId === fooRoute.id)?.meta, + ).toEqual([{ title: 'fresh' }]) + }) + + const assertBeforeLoadNotFoundHierarchy = async (throwAtIndex: 1 | 2 | 3) => { + const { router, routes, loaders, heads, loaderResolvers } = + setupBeforeLoadNotFoundHierarchy(throwAtIndex) + + let loadResolved = false + const loadPromise = router.load().then(() => { + loadResolved = true + }) + + await Promise.resolve() + await Promise.resolve() + + for (let i = 0; i < routes.length; i++) { + const loader = loaders[i]! + const expectedCalls = i < throwAtIndex ? 1 : 0 + expect(loader).toHaveBeenCalledTimes(expectedCalls) + } + + expect(loadResolved).toBe(false) + + for (let i = 0; i < throwAtIndex; i++) { + expect(loaderResolvers[i]).toBeDefined() + loaderResolvers[i]!() + } + + await loadPromise + + for (let i = 0; i < heads.length; i++) { + const head = heads[i]! + const expectedCalls = i <= throwAtIndex ? 1 : 0 + expect(head).toHaveBeenCalledTimes(expectedCalls) + } + + for (let i = 0; i < throwAtIndex; i++) { + const route = routes[i]! + const match = router.state.matches.find((m) => m.routeId === route.id) + expect(match?.loaderData).toEqual({ level: i }) + } + + const thrownRoute = routes[throwAtIndex]! + const thrownMatch = router.state.matches.find( + (m) => m.routeId === thrownRoute.id, + ) + expect(thrownMatch?.status).toBe('notFound') + } + + ;([1, 2, 3] as const).forEach((throwAtIndex) => { + test(`beforeLoad notFound at hierarchy level ${throwAtIndex} waits for parent loader data and executes heads`, async () => { + await assertBeforeLoadNotFoundHierarchy(throwAtIndex) + }) + }) + + test('synchronous head hooks execute serially in the same turn', async () => { + let yielded = false + const parentHead = vi.fn(() => { + queueMicrotask(() => { + yielded = true + }) + return {} + }) + const childHead = vi.fn(() => { + expect(yielded).toBe(false) + return {} + }) + + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + 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'] }), + }) + + await router.load() + + expect(parentHead).toHaveBeenCalledTimes(1) + expect(childHead).toHaveBeenCalledTimes(1) + + await Promise.resolve() + expect(yielded).toBe(true) + }) + + test('synchronous parent scripts do not insert a microtask before child head', async () => { + let yielded = false + const parentScripts = vi.fn(() => { + queueMicrotask(() => { + yielded = true + }) + return [] + }) + const childHead = vi.fn(() => { + expect(yielded).toBe(false) + return {} + }) + + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + scripts: parentScripts, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + head: childHead, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + }) + + await router.load() + + expect(parentScripts).toHaveBeenCalledTimes(1) + expect(childHead).toHaveBeenCalledTimes(1) + + await Promise.resolve() + expect(yielded).toBe(true) + }) + + test('synchronous head that starts a newer navigation and throws stops the old pass', async () => { + const oldHeadError = new Error('old head') + const oldChildHead = vi.fn(() => ({})) + const oldOnReady = vi.fn() + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + let current = true + + try { + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + head: () => { + current = false + void router.navigate({ to: '/bar' }) + throw oldHeadError + }, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => fooRoute, + path: '/child', + head: oldChildHead, + }) + const barRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/bar', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + fooRoute.addChildren([childRoute]), + barRoute, + ]), + history: createMemoryHistory(), + }) + + const location = router.buildLocation({ to: '/foo/child' }) + const matches = router.matchRoutes(location) + router.stores.setPending(matches) + + await loadMatches({ + router, + location, + matches, + onReady: async () => { + oldOnReady() + }, + }) + await projectAssets({ + router, + matches, + isCurrent: () => current, + }) + await vi.waitFor(() => + expect(router.state.location.pathname).toBe('/bar'), + ) + + expect(consoleError).not.toHaveBeenCalled() + expect(oldChildHead).not.toHaveBeenCalled() + expect(oldOnReady).not.toHaveBeenCalled() + } finally { + consoleError.mockRestore() + } + }) + + test('executes head once when loader throws notFound', async () => { + const head = vi.fn(() => ({ meta: [{ title: 'Test' }] })) + const rootRoute = new BaseRootRoute({}) + const testRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/test', + loader: () => { + throw notFound() + }, + head, + }) + const routeTree = rootRoute.addChildren([testRoute]) + const router = createTestRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: ['/test'] }), + }) + + await router.load() + + expect(head).toHaveBeenCalledTimes(1) + const match = router.state.matches.find((m) => m.routeId === testRoute.id) + expect(match?.status).toBe('notFound') + }) + + test('propagates sync beforeLoad non-notFound error running ancestor loaders and heads', async () => { + const beforeLoadError = new Error('beforeLoad-sync-error') + const rootLoader = vi.fn(() => ({ level: 0 })) + const rootHead = vi.fn(() => ({ meta: [{ title: 'Root' }] })) + + const rootRoute = new BaseRootRoute({ + loader: rootLoader, + head: rootHead, + }) + + const childLoader = vi.fn(() => ({ level: 1 })) + const childHead = vi.fn(() => ({ meta: [{ title: 'Child' }] })) + + const childRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/test', + beforeLoad: () => { + throw beforeLoadError + }, + loader: childLoader, + head: childHead, + }) + + const routeTree = rootRoute.addChildren([childRoute]) + const router = createTestRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: ['/test'] }), + }) + + const location = router.latestLocation + const matches = router.matchRoutes(location) + router.stores.setPending(matches) + + await loadMatches({ + router, + location, + matches, + }) + await projectAssets({ router, matches }) + + expect(rootLoader).toHaveBeenCalledTimes(1) + expect(childLoader).toHaveBeenCalledTimes(0) + // Head functions still run for ancestors up to the erroring match so that + // SSR produces valid content (e.g. charset, viewport, stylesheets). + expect(rootHead).toHaveBeenCalledTimes(1) + expect(childHead).toHaveBeenCalledTimes(1) + }) + + test('settles loadPromise 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]! + const loadPromise = childMatch._.loadPromise + router.stores.setPending(matches) + + await loadMatches({ + router, + location, + matches, + }) + + const updatedMatch = getLaneMatch(matches, childMatch.id) + expect(updatedMatch?.status).toBe('error') + expect(loadPromise?.status).toBe('resolved') + expect(updatedMatch?._.loadPromise).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 })) + const rootHead = vi.fn(() => ({ meta: [{ title: 'Root' }] })) + + const rootRoute = new BaseRootRoute({ + loader: rootLoader, + head: rootHead, + }) + + const childLoader = vi.fn(() => ({ level: 1 })) + const childHead = vi.fn(() => ({ meta: [{ title: 'Child' }] })) + + const childRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/test', + beforeLoad: async () => { + await Promise.resolve() + throw beforeLoadError + }, + loader: childLoader, + head: childHead, + }) + + const routeTree = rootRoute.addChildren([childRoute]) + const router = createTestRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: ['/test'] }), + }) + + const location = router.latestLocation + const matches = router.matchRoutes(location) + router.stores.setPending(matches) + + await loadMatches({ + router, + location, + matches, + }) + await projectAssets({ router, matches }) + + expect(rootLoader).toHaveBeenCalledTimes(1) + expect(childLoader).toHaveBeenCalledTimes(0) + // Head functions still run for ancestors up to the erroring match so that + // SSR produces valid content (e.g. charset, viewport, stylesheets). + expect(rootHead).toHaveBeenCalledTimes(1) + expect(childHead).toHaveBeenCalledTimes(1) + }) + + describe('beforeLoad notFound parent loader outcomes', () => { + type ThrowAtIndex = 1 | 2 | 3 + type ParentFailure = 'notFound' | 'redirect' + type ParentFailureMap = Partial> + type Scenario = { + name: string + throwAtIndex: ThrowAtIndex + parentFailures: ParentFailureMap + expectedErrorKind: 'notFound' | 'redirect' + expectedErrorSource?: string + expectedErrorRouteIndex?: 0 | 1 | 2 | 3 + expectedLoaderMaxIndex: number + expectedRenderedHeadMaxIndex: number + withDefaultNotFoundComponent?: boolean + beforeLoadNotFoundFactory?: ( + routes: readonly [any, any, any, any], + ) => ReturnType + } + + const setupScenario = ({ + throwAtIndex, + parentFailures, + beforeLoadNotFoundFactory, + withDefaultNotFoundComponent, + }: { + throwAtIndex: ThrowAtIndex + parentFailures: ParentFailureMap + beforeLoadNotFoundFactory?: Scenario['beforeLoadNotFoundFactory'] + withDefaultNotFoundComponent?: boolean + }) => { + const makeHead = (label: string) => + vi.fn(() => ({ meta: [{ title: label }] })) + + const makeLoader = (index: number) => + vi.fn(() => { + const failure = parentFailures[index as 0 | 1 | 2] + if (failure === 'notFound') { + throw notFound({ data: { source: `loader-${index}` } }) + } + if (failure === 'redirect') { + throw redirect({ to: '/redirect-target' }) + } + return { level: index } + }) + + const rootRoute = new BaseRootRoute({ + loader: makeLoader(0), + head: makeHead('Root'), + }) + + const level1Route = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/level-1', + loader: makeLoader(1), + head: makeHead('Level 1'), + }) + + const level2Route = new BaseRoute({ + getParentRoute: () => level1Route, + path: '/level-2', + loader: makeLoader(2), + head: makeHead('Level 2'), + }) + + const level3Route = new BaseRoute({ + getParentRoute: () => level2Route, + path: '/level-3', + loader: makeLoader(3), + head: makeHead('Level 3'), + }) + + const redirectTargetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/redirect-target', + }) + + const routeTree = rootRoute.addChildren([ + level1Route.addChildren([level2Route.addChildren([level3Route])]), + redirectTargetRoute, + ]) + + const routes = [rootRoute, level1Route, level2Route, level3Route] as const + + const throwRoute = routes[throwAtIndex]! + throwRoute.options.beforeLoad = () => { + const beforeLoadNotFound = beforeLoadNotFoundFactory + ? beforeLoadNotFoundFactory(routes) + : notFound({ data: { source: `beforeLoad-${throwAtIndex}` } }) + throw beforeLoadNotFound + } + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory({ + initialEntries: ['/level-1/level-2/level-3'], + }), + ...(withDefaultNotFoundComponent + ? { defaultNotFoundComponent: () => null } + : {}), + }) + + const loaders = routes.map( + (route) => route.options.loader as ReturnType, + ) + const heads = routes.map( + (route) => route.options.head as ReturnType, + ) + + return { + router, + routes, + loaders, + heads, + } + } + + const runLoadMatchesAndCapture = async ( + router: AnyRouter, + _headCount?: number, + ) => { + const location = router.latestLocation + const matches = router.matchRoutes(location) + router.stores.setPending(matches) + + try { + await loadMatches({ + router, + location, + matches, + }) + } catch (error) { + if (!isRedirect(error)) { + await projectAssets({ router, matches }) + } + return { error, matches } + } + await projectAssets({ router, matches }) + return { error: undefined, matches } + } const scenarios = [ { @@ -1621,558 +8455,3858 @@ describe('head execution', () => { expectedLoaderMaxIndex: 2, expectedRenderedHeadMaxIndex: 3, }, - { - name: 'uses parent loader notFound when parent loader throws notFound', - throwAtIndex: 3 as const, - parentFailures: { 1: 'notFound' } as ParentFailureMap, - expectedErrorKind: 'notFound' as const, - expectedErrorSource: 'loader-1', - expectedLoaderMaxIndex: 2, - expectedRenderedHeadMaxIndex: 1, + { + name: 'uses parent loader notFound when parent loader throws notFound', + throwAtIndex: 3 as const, + parentFailures: { 1: 'notFound' } as ParentFailureMap, + expectedErrorKind: 'notFound' as const, + expectedErrorSource: 'loader-1', + expectedLoaderMaxIndex: 2, + expectedRenderedHeadMaxIndex: 1, + }, + { + name: 'uses first parent loader notFound when multiple parent loaders throw notFound', + throwAtIndex: 3 as const, + parentFailures: { 1: 'notFound', 2: 'notFound' } as ParentFailureMap, + expectedErrorKind: 'notFound' as const, + expectedErrorSource: 'loader-1', + expectedLoaderMaxIndex: 2, + expectedRenderedHeadMaxIndex: 1, + }, + { + name: 'uses parent loader notFound when root loader throws notFound', + throwAtIndex: 2 as const, + parentFailures: { 0: 'notFound' } as ParentFailureMap, + expectedErrorKind: 'notFound' as const, + expectedErrorSource: 'loader-0', + expectedLoaderMaxIndex: 1, + expectedRenderedHeadMaxIndex: 0, + }, + { + name: 'uses explicit routeId from beforeLoad notFound to target ancestor boundary', + throwAtIndex: 3 as const, + parentFailures: {} as ParentFailureMap, + expectedErrorKind: 'notFound' as const, + expectedErrorSource: 'beforeLoad-explicit-level1', + expectedErrorRouteIndex: 1, + expectedLoaderMaxIndex: 1, + expectedRenderedHeadMaxIndex: 1, + beforeLoadNotFoundFactory: (routes) => + notFound({ + routeId: routes[1].id as never, + data: { source: 'beforeLoad-explicit-level1' }, + }), + }, + { + name: 'falls back to root boundary when beforeLoad notFound uses unknown routeId', + throwAtIndex: 3 as const, + parentFailures: {} as ParentFailureMap, + expectedErrorKind: 'notFound' as const, + expectedErrorSource: 'beforeLoad-invalid-route', + expectedLoaderMaxIndex: 0, + expectedRenderedHeadMaxIndex: 0, + beforeLoadNotFoundFactory: () => + notFound({ + routeId: '/does-not-exist' as never, + data: { source: 'beforeLoad-invalid-route' }, + }), + }, + { + name: 'falls back to root boundary when beforeLoad notFound uses non-exact routeId', + throwAtIndex: 3 as const, + parentFailures: {} as ParentFailureMap, + expectedErrorKind: 'notFound' as const, + expectedErrorSource: 'beforeLoad-non-exact-route', + expectedLoaderMaxIndex: 0, + expectedRenderedHeadMaxIndex: 0, + beforeLoadNotFoundFactory: (routes) => + notFound({ + routeId: `${routes[1].id}/` as never, + data: { source: 'beforeLoad-non-exact-route' }, + }), + }, + { + name: 'uses defaultNotFoundComponent without mutating root route when unknown routeId falls back to root', + throwAtIndex: 3 as const, + parentFailures: {} as ParentFailureMap, + expectedErrorKind: 'notFound' as const, + expectedErrorSource: 'beforeLoad-invalid-route-default', + expectedLoaderMaxIndex: 0, + expectedRenderedHeadMaxIndex: 0, + withDefaultNotFoundComponent: true, + beforeLoadNotFoundFactory: () => + notFound({ + routeId: '/does-not-exist' as never, + data: { source: 'beforeLoad-invalid-route-default' }, + }), + }, + { + name: 'prioritizes redirect when parent loader throws redirect', + throwAtIndex: 3 as const, + parentFailures: { 0: 'redirect' } as ParentFailureMap, + expectedErrorKind: 'redirect' as const, + expectedErrorSource: undefined, + expectedLoaderMaxIndex: 2, + expectedRenderedHeadMaxIndex: -1, + }, + { + name: 'prioritizes redirect over root-loader notFound when both appear in settled loaders', + throwAtIndex: 3 as const, + parentFailures: { 0: 'notFound', 1: 'redirect' } as ParentFailureMap, + expectedErrorKind: 'redirect' as const, + expectedErrorSource: undefined, + expectedLoaderMaxIndex: 2, + expectedRenderedHeadMaxIndex: -1, + }, + ] satisfies Array + + test.each(scenarios)('$name', async (scenario) => { + const { router, routes, loaders, heads } = setupScenario({ + throwAtIndex: scenario.throwAtIndex, + parentFailures: scenario.parentFailures, + beforeLoadNotFoundFactory: scenario.beforeLoadNotFoundFactory, + withDefaultNotFoundComponent: scenario.withDefaultNotFoundComponent, + }) + + const { error, matches } = await runLoadMatchesAndCapture( + router, + scenario.expectedRenderedHeadMaxIndex + 1, + ) + + for (let i = 0; i < routes.length; i++) { + const loader = loaders[i]! + const expectedCalls = i <= scenario.expectedLoaderMaxIndex ? 1 : 0 + expect(loader).toHaveBeenCalledTimes(expectedCalls) + } + + for (let i = 0; i < heads.length; i++) { + const head = heads[i]! + const expectedCalls = i <= scenario.expectedRenderedHeadMaxIndex ? 1 : 0 + expect(head).toHaveBeenCalledTimes(expectedCalls) + } + + if (scenario.expectedErrorKind === 'redirect') { + expect(error).toEqual( + expect.objectContaining({ + options: expect.objectContaining({ + to: '/redirect-target', + }), + }), + ) + return + } + + expect(error).toEqual( + expect.objectContaining({ + isNotFound: true, + data: { source: scenario.expectedErrorSource }, + }), + ) + + if (scenario.expectedErrorRouteIndex !== undefined) { + expect((error as { routeId?: string }).routeId).toBe( + routes[scenario.expectedErrorRouteIndex]!.id, + ) + } + + if (scenario.withDefaultNotFoundComponent) { + expect(routes[0].options.notFoundComponent).toBeUndefined() + expect(matches[0]?.globalNotFound).toBe(true) + } + }) + + test('sets globalNotFound on root match when beforeLoad notFound targets root boundary', async () => { + const { router, routes } = setupScenario({ + throwAtIndex: 3, + parentFailures: {}, + beforeLoadNotFoundFactory: (innerRoutes) => + notFound({ + routeId: innerRoutes[0].id as never, + data: { source: 'beforeLoad-root-explicit' }, + }), + }) + + const { error, matches } = await runLoadMatchesAndCapture(router) + + expect(error).toEqual( + expect.objectContaining({ + isNotFound: true, + data: { source: 'beforeLoad-root-explicit' }, + }), + ) + + const rootMatch = matches.find((m) => m.routeId === routes[0].id) + + expect(rootMatch?.globalNotFound).toBe(true) + expect(rootMatch?.status).toBe('success') + expect(rootMatch?.error).toBeUndefined() + }) + + test('clears stale root globalNotFound when root loader is skipped', async () => { + const rootLoader = vi.fn(() => ({ level: 0 })) + const rootRoute = new BaseRootRoute({ + loader: rootLoader, + staleTime: Infinity, + shouldReload: () => false, + }) + + const childLoader = vi.fn(() => ({ level: 1 })) + const childRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/test', + loader: childLoader, + staleTime: Infinity, + shouldReload: () => false, + }) + + const routeTree = rootRoute.addChildren([childRoute]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: ['/missing'] }), + }) + + await router.load() + const missingRootMatch = router.state.matches.find( + (m) => m.routeId === rootRoute.id, + ) + expect(missingRootMatch?.globalNotFound).toBe(true) + expect(rootLoader).toHaveBeenCalledTimes(1) + + await router.navigate({ to: '/test' }) + expect(rootLoader).toHaveBeenCalledTimes(1) + expect(childLoader).toHaveBeenCalledTimes(1) + + const rootMatch = router.state.matches.find( + (m) => m.routeId === rootRoute.id, + ) + + 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.matches).toHaveLength(1) + expect(router.state.matches[0]).toEqual( + expect.objectContaining({ + routeId: rootRoute.id, + status: 'success', + globalNotFound: true, + }), + ) + }) + }) +}) + +describe('params.parse notFound', () => { + test('throws notFound on invalid params', async () => { + const rootRoute = new BaseRootRoute({}) + const testRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/test/$id', + params: { + parse: ({ id }: { id: string }) => { + const parsed = parseInt(id, 10) + if (Number.isNaN(parsed)) { + throw notFound() + } + return { id: parsed } + }, + }, + }) + const routeTree = rootRoute.addChildren([testRoute]) + const router = createTestRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: ['/test/invalid'] }), + }) + + await router.load() + + const match = router.stores.matches + .get() + .find((m) => m.routeId === testRoute.id) + + expect(match?.status).toBe('notFound') + }) + + test('succeeds on valid params', async () => { + const rootRoute = new BaseRootRoute({}) + const testRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/test/$id', + params: { + parse: ({ id }: { id: string }) => { + const parsed = parseInt(id, 10) + if (Number.isNaN(parsed)) { + throw notFound() + } + return { id: parsed } + }, + }, + }) + const routeTree = rootRoute.addChildren([testRoute]) + const router = createTestRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: ['/test/123'] }), + }) + + await router.load() + + const match = router.state.matches.find((m) => m.routeId === testRoute.id) + expect(match?.status).toBe('success') + }) +}) + +describe('routeId in context options', () => { + test('beforeLoad and context receive correct routeId for root route', async () => { + const beforeLoad = vi.fn() + const context = vi.fn() + const rootRoute = new BaseRootRoute({ + beforeLoad, + context, + }) + + const routeTree = rootRoute.addChildren([]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory(), + }) + + await router.load() + + expect(beforeLoad).toHaveBeenCalledTimes(1) + expect(beforeLoad).toHaveBeenCalledWith( + expect.objectContaining({ + routeId: rootRouteId, + }), + ) + + expect(context).toHaveBeenCalledTimes(1) + expect(context).toHaveBeenCalledWith( + expect.objectContaining({ + routeId: rootRouteId, + }), + ) + }) + + test('beforeLoad and context receive correct routeId for child route', async () => { + const beforeLoad = vi.fn() + const context = vi.fn() + const rootRoute = new BaseRootRoute({}) + + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + beforeLoad, + context, + }) + + const routeTree = rootRoute.addChildren([fooRoute]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory(), + }) + + await router.navigate({ to: '/foo' }) + + expect(beforeLoad).toHaveBeenCalledTimes(1) + expect(beforeLoad).toHaveBeenCalledWith( + expect.objectContaining({ + routeId: '/foo', + }), + ) + + expect(context).toHaveBeenCalledTimes(1) + expect(context).toHaveBeenCalledWith( + expect.objectContaining({ + routeId: '/foo', + }), + ) + }) + + test('beforeLoad and context receive correct routeId for nested route', async () => { + const parentBeforeLoad = vi.fn() + const parentContext = vi.fn() + const childBeforeLoad = vi.fn() + const childContext = vi.fn() + const rootRoute = new BaseRootRoute({}) + + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + beforeLoad: parentBeforeLoad, + context: parentContext, + }) + + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + beforeLoad: childBeforeLoad, + context: childContext, + }) + + const routeTree = rootRoute.addChildren([ + parentRoute.addChildren([childRoute]), + ]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory(), + }) + + await router.navigate({ to: '/parent/child' }) + + expect(parentBeforeLoad).toHaveBeenCalledWith( + expect.objectContaining({ + routeId: '/parent', + }), + ) + expect(parentContext).toHaveBeenCalledWith( + expect.objectContaining({ + routeId: '/parent', + }), + ) + expect(childBeforeLoad).toHaveBeenCalledWith( + expect.objectContaining({ + routeId: '/parent/child', + }), + ) + expect(childContext).toHaveBeenCalledWith( + expect.objectContaining({ + routeId: '/parent/child', + }), + ) + }) + + test('beforeLoad and context receive correct routeId for route with dynamic params', async () => { + const beforeLoad = vi.fn() + const context = vi.fn() + const rootRoute = new BaseRootRoute({}) + + const postRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/posts/$postId', + beforeLoad, + context, + }) + + const routeTree = rootRoute.addChildren([postRoute]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory(), + }) + + await router.navigate({ to: '/posts/$postId', params: { postId: '123' } }) + + expect(beforeLoad).toHaveBeenCalledWith( + expect.objectContaining({ + routeId: '/posts/$postId', + }), + ) + expect(context).toHaveBeenCalledWith( + expect.objectContaining({ + routeId: '/posts/$postId', + }), + ) + }) + + test('beforeLoad and context receive correct routeId for layout route', async () => { + const beforeLoad = vi.fn() + const context = vi.fn() + const rootRoute = new BaseRootRoute({}) + + const layoutRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + id: '/_layout', + beforeLoad, + context, + }) + + const indexRoute = new BaseRoute({ + getParentRoute: () => layoutRoute, + path: '/', + }) + + const routeTree = rootRoute.addChildren([ + layoutRoute.addChildren([indexRoute]), + ]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory(), + }) + + await router.load() + + expect(beforeLoad).toHaveBeenCalledWith( + expect.objectContaining({ + routeId: '/_layout', + }), + ) + expect(context).toHaveBeenCalledWith( + expect.objectContaining({ + routeId: '/_layout', + }), + ) + }) + + test('context receives preload classification from the matching operation', async () => { + const contextCalls: Array<{ preload: boolean; deps: unknown }> = [] + const rootRoute = new BaseRootRoute({}) + + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loaderDeps: ({ search }: { search: Record }) => ({ + q: search['q'], + }), + loader: () => undefined, + context: ({ preload, deps }) => { + contextCalls.push({ preload, deps }) + }, + preloadStaleTime: Infinity, + staleTime: Infinity, + gcTime: Infinity, + }) + + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, fooRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + await router.preloadRoute({ + to: '/foo', + search: { q: 'preload' }, + } as never) + await router.navigate({ + to: '/foo', + search: { q: 'navigate' }, + } as never) + await router.navigate({ + to: '/foo', + search: { q: 'preload' }, + } as never) + + expect(contextCalls).toEqual([ + { preload: true, deps: { q: 'preload' } }, + { preload: false, deps: { q: 'navigate' } }, + ]) + }) + + test('preload-created matches carry preload cause', async () => { + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loader: () => undefined, + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([fooRoute]), + history: createMemoryHistory(), + }) + + const matches = await router.preloadRoute({ to: '/foo' }) + const fooMatch = matches?.find((match) => match.routeId === fooRoute.id) + + expect(fooMatch?.cause).toBe('preload') + }) +}) + +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> = [] + + 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('match loadPromise lifecycle', () => { + const expectNotPendingWithoutLoadPromise = (match: any) => { + expect( + match?.status === 'pending' && match._.loadPromise === undefined, + ).toBe(false) + } + + const expectBeforeLoadPendingMinimum = async ( + outcome: 'error' | 'notFound' | 'redirect', + ) => { + vi.useFakeTimers() + try { + const beforeLoadError = new Error('beforeLoad failed') + const rootRoute = new BaseRootRoute({}) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + pendingMs: 0, + pendingMinMs: 100, + pendingComponent: () => null, + beforeLoad: async () => { + await new Promise((resolve) => { + setTimeout(resolve, 10) + }) + + if (outcome === 'error') { + throw beforeLoadError + } + + if (outcome === 'notFound') { + throw notFound() + } + + throw redirect({ to: '/redirected' }) + }, + errorComponent: () => null, + notFoundComponent: () => null, + }) + const redirectedRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/redirected', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([targetRoute, redirectedRoute]), + history: createMemoryHistory({ initialEntries: ['/target'] }), + }) + const location = router.latestLocation + const matches = router.matchRoutes(location) + router.stores.setPending(matches) + const targetMatch = matches.find( + (match) => match.routeId === targetRoute.id, + )! + + const loadPromise = loadMatches({ + router, + location, + matches, + onReady: async (readyMatches) => { + const readyMatch = readyMatches.find( + (match) => match.routeId === targetRoute.id, + )! + const promise = readyMatch._.loadPromise + if (promise) { + promise.pendingUntil ??= Date.now() + 100 + } + }, + }).then( + () => undefined, + (err) => err, + ) + + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(10) + await Promise.resolve() + + expect(router.getMatch(targetMatch.id)?.status).toBe('pending') + + await vi.advanceTimersByTimeAsync(89) + expect(router.getMatch(targetMatch.id)?.status).toBe('pending') + + await vi.advanceTimersByTimeAsync(1) + const result = await loadPromise + + const updatedMatch = getLaneMatch(matches, targetMatch.id) + if (outcome === 'error') { + expect(result).toBeUndefined() + expect(updatedMatch?.status).toBe('error') + expect(updatedMatch?.error).toBe(beforeLoadError) + } else if (outcome === 'notFound') { + expect(result).toMatchObject({ isNotFound: true }) + expect(updatedMatch?.status).toBe('notFound') + } else { + expect(result).toMatchObject({ + options: expect.objectContaining({ to: '/redirected' }), + }) + } + } finally { + vi.useRealTimers() + } + } + + test('visible pending + beforeLoad Error honors pendingMinMs', async () => { + await expectBeforeLoadPendingMinimum('error') + }) + + test('visible pending + beforeLoad notFound honors pendingMinMs', async () => { + await expectBeforeLoadPendingMinimum('notFound') + }) + + test('visible pending + beforeLoad redirect honors pendingMinMs', async () => { + await expectBeforeLoadPendingMinimum('redirect') + }) + + test('visible pending remains until beforeLoad settles after pendingMs plus pendingMinMs', async () => { + vi.useFakeTimers() + try { + const beforeLoadGate = createControlledPromise<{ auth: string }>() + const rootRoute = new BaseRootRoute({}) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + pendingMs: 10, + pendingMinMs: 50, + pendingComponent: () => null, + beforeLoad: () => beforeLoadGate, + loader: () => 'loaded', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([targetRoute]), + history: createMemoryHistory({ initialEntries: ['/target'] }), + }) + const location = router.latestLocation + const matches = router.matchRoutes(location) + router.stores.setPending(matches) + const targetMatch = matches.find( + (match) => match.routeId === targetRoute.id, + )! + let renderedMatch: AnyRouteMatch | undefined + + const loadPromise = loadMatches({ + router, + location, + matches, + onReady: (readyMatches) => { + renderedMatch = readyMatches.find( + (match) => match.routeId === targetRoute.id, + ) + const promise = renderedMatch?._.loadPromise + if (promise) { + promise.pendingUntil ??= Date.now() + 50 + } + }, + }) + + await vi.advanceTimersByTimeAsync(9) + expect(renderedMatch).toBeUndefined() + + await vi.advanceTimersByTimeAsync(1) + expect(renderedMatch?.status).toBe('pending') + const loadGeneration = renderedMatch?._.loadPromise + expect(loadGeneration?.status).toBe('pending') + + await vi.advanceTimersByTimeAsync(50) + expect(loadGeneration?.status).toBe('pending') + expect(router.getMatch(targetMatch.id)?.status).toBe('pending') + + beforeLoadGate.resolve({ auth: 'ok' }) + await loadPromise + + const updatedMatch = getLaneMatch(matches, targetMatch.id) + expect(updatedMatch?.status).toBe('success') + expect(updatedMatch?.context).toMatchObject({ auth: 'ok' }) + expect(updatedMatch?.loaderData).toBe('loaded') + expect(loadGeneration?.status).toBe('resolved') + } finally { + vi.useRealTimers() + } + }) + + test('resolved ancestor pending timer does not publish a slower child early', async () => { + vi.useFakeTimers() + try { + const childLoaderGate = createControlledPromise() + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + pendingMs: 10, + pendingComponent: () => null, + loader: async () => { + await new Promise((resolve) => { + setTimeout(resolve, 1) + }) + return 'parent' + }, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + pendingMs: 100, + pendingComponent: () => null, + loader: () => childLoaderGate, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + parentRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + }) + const location = router.latestLocation + const matches = router.matchRoutes(location) + router.stores.setPending(matches) + const readySnapshots: Array> = [] + + const loadPromise = loadMatches({ + router, + location, + matches, + onReady: (readyMatches) => { + readySnapshots.push( + readyMatches.map((match) => `${match.routeId}:${match.status}`), + ) + }, + }) + + await vi.advanceTimersByTimeAsync(1) + await vi.advanceTimersByTimeAsync(9) + expect(readySnapshots).toEqual([]) + + childLoaderGate.resolve('child') + await loadPromise + + expect(readySnapshots).toEqual([]) + const childMatch = matches.find( + (match) => match.routeId === childRoute.id, + ) + expect(childMatch?.status).toBe('success') + expect(childMatch?.loaderData).toBe('child') + } finally { + vi.useRealTimers() + } + }) + + test('publishing pending UI keeps isLoading true until the final lane commits', async () => { + vi.useFakeTimers() + + try { + const loaderGate = createControlledPromise() + const rootRoute = new BaseRootRoute({}) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + pendingMs: 0, + pendingComponent: () => null, + loader: () => loaderGate, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([targetRoute]), + history: createMemoryHistory({ initialEntries: ['/target'] }), + }) + + const loadPromise = router.load() + await vi.waitFor(() => + expect( + router.state.matches.some( + (match) => + match.routeId === targetRoute.id && match.status === 'pending', + ), + ).toBe(true), + ) + + expect(router.state.isLoading).toBe(true) + + loaderGate.resolve('loaded') + await loadPromise + + const targetMatch = router.state.matches.find( + (match) => match.routeId === targetRoute.id, + )! + expect(targetMatch.status).toBe('success') + expect(targetMatch.loaderData).toBe('loaded') + expect(router.state.isLoading).toBe(false) + } finally { + vi.useRealTimers() + } + }) + + test('stale beforeLoad failure cannot overwrite a newer same-id generation', async () => { + const staleError = new Error('stale beforeLoad failed') + const errorComponentGate = createControlledPromise() + const staleHead = vi.fn() + const freshHead = vi.fn() + const staleScripts = vi.fn() + const freshScripts = vi.fn() + const staleOnReady = vi.fn() + let errorPreloadRun = 0 + const errorPreload = vi.fn(() => { + errorPreloadRun++ + return errorPreloadRun === 1 ? errorComponentGate : undefined + }) + let beforeLoadRun = 0 + const getAssetPass = ({ matches }: any) => + matches.some((match: any) => match.search?.pass === 'stale') + ? 'stale' + : 'fresh' + + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + validateSearch: (search: Record) => ({ + pass: search.pass, + }), + beforeLoad: () => { + beforeLoadRun++ + if (beforeLoadRun === 1) { + throw staleError + } + }, + loader: () => 'fresh', + head: (ctx) => { + if (getAssetPass(ctx) === 'stale') { + staleHead() + } else { + freshHead() + } + return {} + }, + scripts: (ctx) => { + if (getAssetPass(ctx) === 'stale') { + staleScripts() + } else { + freshScripts() + } + return [] + }, + errorComponent: { preload: errorPreload } as any, + }) + const barRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/bar', + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([fooRoute, barRoute]), + history: createMemoryHistory(), + }) + + const fooLocation = router.buildLocation({ + to: '/foo', + search: { pass: 'stale' }, + } as never) + const firstMatches = router.matchRoutes(fooLocation) + router.stores.setPending(firstMatches) + const staleLoad = loadMatches({ + router, + location: fooLocation, + matches: firstMatches, + onReady: async (readyMatches) => { + staleOnReady(readyMatches) + }, + }) + + await vi.waitFor(() => expect(errorPreload).toHaveBeenCalledTimes(1)) + + const barLocation = router.buildLocation({ to: '/bar' }) + router.cancelMatches() + router.stores.setPending(router.matchRoutes(barLocation)) + + const freshLocation = router.buildLocation({ + to: '/foo', + search: { pass: 'fresh' }, + } as never) + const secondMatches = router.matchRoutes(freshLocation) + router.stores.setPending(secondMatches) + await loadMatches({ + router, + location: freshLocation, + matches: secondMatches, + }) + await projectAssets({ router, matches: secondMatches }) + + const freshMatchBeforeRelease = getLaneMatch( + secondMatches, + secondMatches[1]!.id, + ) + expect(freshMatchBeforeRelease?.routeId).toBe(fooRoute.id) + expect(freshMatchBeforeRelease?.status).toBe('success') + expect(freshMatchBeforeRelease?.error).toBeUndefined() + + errorComponentGate.resolve() + await expect(staleLoad).resolves.toBe(firstMatches) + + const freshMatchAfterRelease = getLaneMatch( + secondMatches, + secondMatches[1]!.id, + ) + expect(freshMatchAfterRelease?.routeId).toBe(fooRoute.id) + expect(freshMatchAfterRelease?.status).toBe('success') + expect(freshMatchAfterRelease?.error).toBeUndefined() + expect(freshMatchAfterRelease?.loaderData).toBe('fresh') + expect(beforeLoadRun).toBe(2) + expect(staleHead).not.toHaveBeenCalled() + expect(staleScripts).not.toHaveBeenCalled() + expect(staleOnReady).not.toHaveBeenCalled() + expect(freshHead).toHaveBeenCalledTimes(1) + expect(freshScripts).toHaveBeenCalledTimes(1) + }) + + test('stale pass joining an existing loader generation cannot continue after a newer same-id pass', async () => { + const loaderGate = createControlledPromise() + const loader = vi.fn(() => loaderGate) + const initialHead = vi.fn() + const staleHead = vi.fn() + const freshHead = vi.fn() + const staleScripts = vi.fn() + const freshScripts = vi.fn() + const staleOnReady = vi.fn() + const getAssetPass = ({ matches }: any) => + matches.find((match: any) => match.routeId === '/foo')?.search?.pass + + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + validateSearch: (search: Record) => ({ + pass: search.pass, + }), + loader, + head: (ctx) => { + const pass = getAssetPass(ctx) + if (pass === 'stale') { + staleHead() + } else if (pass === 'fresh') { + freshHead() + } else { + initialHead() + } + return {} + }, + scripts: (ctx) => { + if (getAssetPass(ctx) === 'stale') { + staleScripts() + } else if (getAssetPass(ctx) === 'fresh') { + freshScripts() + } + return [] + }, + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([fooRoute]), + history: createMemoryHistory(), + }) + + const initialLocation = router.buildLocation({ + to: '/foo', + search: { pass: 'initial' }, + } as never) + const initialMatches = router.matchRoutes(initialLocation) + router.stores.setPending(initialMatches) + const initialLoad = loadMatches({ + router, + location: initialLocation, + matches: initialMatches, + }) + + await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(1)) + const fooId = initialMatches[1]!.id + const initialController = router.getMatch(fooId)!.abortController + const initialGeneration = router.getMatch(fooId)!._.loadPromise + expect(initialGeneration?.status).toBe('pending') + + const staleLocation = router.buildLocation({ + to: '/foo', + search: { pass: 'stale' }, + } as never) + const staleMatches = router.matchRoutes(staleLocation) + router.stores.setPending(staleMatches) + const staleLoad = loadMatches({ + router, + location: staleLocation, + matches: staleMatches, + onReady: async (readyMatches) => { + staleOnReady(readyMatches) + }, + }) + const staleController = router.getMatch(fooId)!.abortController + expect(staleController).not.toBe(initialController) + + router.cancelMatches() + const freshLocation = router.buildLocation({ + to: '/foo', + search: { pass: 'fresh' }, + } as never) + const freshMatches = router.matchRoutes(freshLocation) + router.stores.setPending(freshMatches) + const freshLoad = (async () => { + await loadMatches({ + router, + location: freshLocation, + matches: freshMatches, + }) + await projectAssets({ router, matches: freshMatches }) + })() + const freshController = router.getMatch(fooId)!.abortController + expect(freshController).not.toBe(staleController) + + loaderGate.resolve('loaded') + await Promise.all([initialLoad, staleLoad, freshLoad]) + + const finalMatch = getLaneMatch(freshMatches, fooId) + expect(staleHead).not.toHaveBeenCalled() + expect(staleScripts).not.toHaveBeenCalled() + expect(staleOnReady).not.toHaveBeenCalled() + expect(freshHead).toHaveBeenCalledTimes(1) + expect(freshScripts).toHaveBeenCalledTimes(1) + expect(loader).toHaveBeenCalledTimes(3) + expect(finalMatch?.status).toBe('success') + expect(finalMatch?.abortController).toBe(freshController) + expect(finalMatch?._.loadPromise).toBeUndefined() + }) + + test('late rejection from an abandoned loader does not invoke onError or error-component preload', async () => { + const loaderError = new Error('abandoned loader failed') + const loaderGate = createControlledPromise() + const loader = vi.fn(() => loaderGate) + const onError = vi.fn() + const errorComponentPreload = vi.fn() + const fooHead = vi.fn(() => ({})) + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loader, + onError, + head: fooHead, + errorComponent: { preload: errorComponentPreload } as any, + }) + 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) + router.stores.setPending(matches) + + const staleLoad = loadMatches({ router, location, matches }) + await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(1)) + + const fooMatch = router.getMatch(matches[1]!.id)! + const oldLoadPromise = fooMatch._.loadPromise + errorComponentPreload.mockClear() + + await router.navigate({ to: '/bar' }) + + loaderGate.reject(loaderError) + await expect(staleLoad).resolves.toBe(matches) + + expect(onError).not.toHaveBeenCalled() + expect(errorComponentPreload).not.toHaveBeenCalled() + expect(fooHead).not.toHaveBeenCalled() + expect(router.state.location.pathname).toBe('/bar') + expect( + router.state.matches.some((match) => match.routeId === fooRoute.id), + ).toBe(false) + expect(oldLoadPromise?.status).not.toBe('pending') + }) + + test('shouldReload-triggered navigation cancels the old pass before head and onReady', async () => { + const oldHead = vi.fn(() => ({})) + const oldScripts = vi.fn(() => []) + const oldOnReady = vi.fn() + let shouldNavigate = false + let navigation: Promise | undefined + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loader: () => 'foo', + shouldReload: ({ navigate }) => { + if (shouldNavigate) { + navigation = Promise.resolve(navigate({ to: '/bar' })) + return false + } + return true + }, + head: oldHead, + scripts: oldScripts, + }) + const barRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/bar', + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([fooRoute, barRoute]), + history: createMemoryHistory(), + }) + + await router.navigate({ to: '/foo' }) + oldHead.mockClear() + oldScripts.mockClear() + shouldNavigate = true + + const location = router.buildLocation({ to: '/foo' }) + const matches = router.matchRoutes(location) + router.stores.setPending(matches) + + await loadMatches({ + router, + location, + matches, + onReady: async (readyMatches) => { + oldOnReady(readyMatches) + }, + }) + await navigation + + expect(router.state.location.pathname).toBe('/bar') + expect(oldHead).not.toHaveBeenCalled() + expect(oldScripts).not.toHaveBeenCalled() + expect(oldOnReady).not.toHaveBeenCalled() + }) + + test('shouldReload navigation that returns true cancels the old pass before loader, head, and onReady', async () => { + const staleLoader = vi.fn(() => 'stale') + const staleHead = vi.fn(() => ({})) + const staleScripts = vi.fn(() => []) + const staleOnReady = vi.fn() + let shouldNavigate = false + let navigation: Promise | undefined + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loader: staleLoader, + shouldReload: ({ navigate }) => { + if (shouldNavigate) { + navigation = Promise.resolve(navigate({ to: '/bar' })) + return true + } + return true + }, + head: staleHead, + scripts: staleScripts, + }) + const barRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/bar', + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([fooRoute, barRoute]), + history: createMemoryHistory(), + }) + + await router.navigate({ to: '/foo' }) + staleLoader.mockClear() + staleHead.mockClear() + staleScripts.mockClear() + shouldNavigate = true + + const location = router.buildLocation({ to: '/foo' }) + const matches = router.matchRoutes(location) + router.stores.setPending(matches) + + await loadMatches({ + router, + location, + matches, + onReady: async (readyMatches) => { + staleOnReady(readyMatches) + }, + }) + await navigation + + expect(router.state.location.pathname).toBe('/bar') + expect(staleLoader).not.toHaveBeenCalled() + expect(staleHead).not.toHaveBeenCalled() + expect(staleScripts).not.toHaveBeenCalled() + expect(staleOnReady).not.toHaveBeenCalled() + }) + + test('same-href shouldReload reentry cannot start old loader work', async () => { + const loader = vi.fn(() => 'loaded') + let reenter = false + let didNavigate = false + let navigation: Promise | undefined + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loader, + shouldReload: ({ navigate }) => { + if (reenter && !didNavigate) { + didNavigate = true + navigation = Promise.resolve(navigate({ to: '/foo' })) + return true + } + return true + }, + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([fooRoute]), + history: createMemoryHistory({ initialEntries: ['/foo'] }), + }) + + await router.load() + loader.mockClear() + reenter = true + + await router.load() + await navigation + + expect(loader).toHaveBeenCalledTimes(1) + expect(router.state.location.pathname).toBe('/foo') + }) + + test('aborted generation does not invoke shouldReload', async () => { + const shouldReload = vi.fn(() => true) + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loader: () => 'foo', + staleTime: 0, + shouldReload, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([fooRoute]), + history: createMemoryHistory({ initialEntries: ['/foo'] }), + }) + + await router.load() + shouldReload.mockClear() + + const location = router.buildLocation({ to: '/foo' }) + const matches = router.matchRoutes(location) + const fooMatch = matches.find((match) => match.routeId === fooRoute.id)! + router.stores.setPending(matches) + fooMatch.abortController.abort() + + await expect( + loadMatches({ + router, + location, + matches, + }), + ).resolves.toBe(matches) + + expect(shouldReload).not.toHaveBeenCalled() + }) + + test('shouldReload that starts a newer navigation and then throws does not invoke stale onError', async () => { + const staleError = new Error('stale shouldReload reentry failed') + const staleOnError = vi.fn() + const staleErrorPreload = vi.fn() + let shouldNavigate = false + let navigation: Promise | undefined + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loader: () => 'foo', + shouldReload: ({ navigate }) => { + if (shouldNavigate) { + navigation = Promise.resolve(navigate({ to: '/bar' })) + throw staleError + } + return true + }, + onError: staleOnError, + errorComponent: { preload: staleErrorPreload } as any, + }) + const barRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/bar', + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([fooRoute, barRoute]), + history: createMemoryHistory(), + }) + + await router.navigate({ to: '/foo' }) + staleErrorPreload.mockClear() + shouldNavigate = true + + const location = router.buildLocation({ to: '/foo' }) + const matches = router.matchRoutes(location) + router.stores.setPending(matches) + + await loadMatches({ + router, + location, + matches, + }) + await navigation + + expect(staleOnError).not.toHaveBeenCalled() + expect(staleErrorPreload).not.toHaveBeenCalled() + expect(router.state.location.pathname).toBe('/bar') + }) + + test('same-href beforeLoad reentry does not invoke stale onError', async () => { + const staleError = new Error('stale beforeLoad reentry failed') + const staleOnError = vi.fn() + const staleErrorPreload = vi.fn() + let reenter = false + let didNavigate = false + let navigation: Promise | undefined + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + beforeLoad: ({ navigate }) => { + if (reenter && !didNavigate) { + didNavigate = true + navigation = Promise.resolve(navigate({ to: '/foo' })) + throw staleError + } + return { didNavigate } + }, + onError: staleOnError, + errorComponent: { preload: staleErrorPreload } as any, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([fooRoute]), + history: createMemoryHistory({ initialEntries: ['/foo'] }), + }) + + await router.load() + staleErrorPreload.mockClear() + reenter = true + + await router.load() + await navigation + + expect(staleOnError).not.toHaveBeenCalled() + expect(staleErrorPreload).not.toHaveBeenCalled() + expect(router.state.location.pathname).toBe('/foo') + expect( + router.state.matches.some((match) => match.error === staleError), + ).toBe(false) + }) + + test('async head from a stale same-ID pass cannot overwrite a newer pass', async () => { + const staleHeadGate = createControlledPromise<{ + meta: Array<{ title: string }> + }>() + const staleHead = vi.fn(() => staleHeadGate) + const freshHead = vi.fn(() => ({ meta: [{ title: 'fresh' }] })) + const staleChildHead = vi.fn(() => ({})) + const freshChildHead = vi.fn(() => ({})) + const staleOnReady = vi.fn() + const freshOnReady = vi.fn() + const getAssetPass = ({ matches }: any) => + matches.find((match: any) => match.routeId === '/foo')?.search?.pass + + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + validateSearch: (search: Record) => ({ + pass: search.pass, + }), + loader: () => 'loaded', + head: (ctx) => { + if (getAssetPass(ctx) === 'stale') { + staleHead() + return staleHeadGate + } + return freshHead() + }, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => fooRoute, + path: '/child', + head: (ctx) => { + if (getAssetPass(ctx) === 'stale') { + staleChildHead() + } else { + freshChildHead() + } + return {} + }, + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([fooRoute.addChildren([childRoute])]), + history: createMemoryHistory(), + }) + + const staleLocation = router.buildLocation({ + to: '/foo/child', + search: { pass: 'stale' }, + } as never) + const staleMatches = router.matchRoutes(staleLocation) + router.stores.setPending(staleMatches) + const fooId = staleMatches.find( + (match) => match.routeId === fooRoute.id, + )!.id + const staleLoad = (async () => { + await loadMatches({ + router, + location: staleLocation, + matches: staleMatches, + onReady: async (readyMatches) => { + staleOnReady(readyMatches) + }, + }) + await projectAssets({ + router, + matches: staleMatches, + isCurrent: () => router.getMatch(fooId)?.search.pass === 'stale', + }) + return staleMatches + })() + + await vi.waitFor(() => expect(staleHead).toHaveBeenCalledTimes(1)) + + router.cancelMatches() + const freshLocation = router.buildLocation({ + to: '/foo/child', + search: { pass: 'fresh' }, + } as never) + const freshMatches = router.matchRoutes(freshLocation) + router.stores.setPending(freshMatches) + await loadMatches({ + router, + location: freshLocation, + matches: freshMatches, + onReady: async (readyMatches) => { + freshOnReady(readyMatches) + }, + }) + await projectAssets({ router, matches: freshMatches }) + router.stores.setPending(freshMatches) + + expect(router.getMatch(fooId)?.meta).toEqual([ + expect.objectContaining({ title: 'fresh' }), + ]) + + staleHeadGate.resolve({ meta: [{ title: 'stale' }] }) + await expect(staleLoad).resolves.toBe(staleMatches) + + expect(router.getMatch(fooId)?.meta).toEqual([ + expect.objectContaining({ title: 'fresh' }), + ]) + expect(staleChildHead).not.toHaveBeenCalled() + expect(staleOnReady).not.toHaveBeenCalled() + expect(freshHead).toHaveBeenCalledTimes(1) + expect(freshChildHead).toHaveBeenCalledTimes(1) + expect(freshOnReady).not.toHaveBeenCalled() + }) + + test('rejected async head from a stale same-ID pass is ignored', async () => { + const staleHeadGate = createControlledPromise<{ + meta: Array<{ title: string }> + }>() + const staleHead = vi.fn(() => staleHeadGate) + const freshHead = vi.fn(() => ({ meta: [{ title: 'fresh' }] })) + const staleChildHead = vi.fn(() => ({})) + const freshChildHead = vi.fn(() => ({})) + const staleOnReady = vi.fn() + const freshOnReady = vi.fn() + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const getAssetPass = ({ matches }: any) => + matches.find((match: any) => match.routeId === '/foo')?.search?.pass + + try { + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + validateSearch: (search: Record) => ({ + pass: search.pass, + }), + loader: () => 'loaded', + head: (ctx) => { + if (getAssetPass(ctx) === 'stale') { + staleHead() + return staleHeadGate + } + return freshHead() + }, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => fooRoute, + path: '/child', + head: (ctx) => { + if (getAssetPass(ctx) === 'stale') { + staleChildHead() + } else { + freshChildHead() + } + return {} + }, + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([fooRoute.addChildren([childRoute])]), + history: createMemoryHistory(), + }) + + const staleLocation = router.buildLocation({ + to: '/foo/child', + search: { pass: 'stale' }, + } as never) + const staleMatches = router.matchRoutes(staleLocation) + router.stores.setPending(staleMatches) + const fooId = staleMatches.find( + (match) => match.routeId === fooRoute.id, + )!.id + const staleLoad = (async () => { + await loadMatches({ + router, + location: staleLocation, + matches: staleMatches, + onReady: async (readyMatches) => { + staleOnReady(readyMatches) + }, + }) + await projectAssets({ + router, + matches: staleMatches, + isCurrent: () => router.getMatch(fooId)?.search.pass === 'stale', + }) + return staleMatches + })() + + await vi.waitFor(() => expect(staleHead).toHaveBeenCalledTimes(1)) + + router.cancelMatches() + const freshLocation = router.buildLocation({ + to: '/foo/child', + search: { pass: 'fresh' }, + } as never) + const freshMatches = router.matchRoutes(freshLocation) + router.stores.setPending(freshMatches) + await loadMatches({ + router, + location: freshLocation, + matches: freshMatches, + onReady: async (readyMatches) => { + freshOnReady(readyMatches) + }, + }) + await projectAssets({ router, matches: freshMatches }) + router.stores.setPending(freshMatches) + + staleHeadGate.reject(new Error('stale head failed')) + await expect(staleLoad).resolves.toBe(staleMatches) + + expect(consoleError).not.toHaveBeenCalled() + expect(router.getMatch(fooId)?.meta).toEqual([ + expect.objectContaining({ title: 'fresh' }), + ]) + expect(staleChildHead).not.toHaveBeenCalled() + expect(staleOnReady).not.toHaveBeenCalled() + expect(freshHead).toHaveBeenCalledTimes(1) + expect(freshChildHead).toHaveBeenCalledTimes(1) + expect(freshOnReady).not.toHaveBeenCalled() + } finally { + consoleError.mockRestore() + } + }) + + test('pending preload-owned match is cached only after async head work', async () => { + const headGate = createControlledPromise<{ + meta: Array<{ title: string }> + }>() + const parentHead = vi.fn(() => headGate) + const childHead = vi.fn(() => ({})) + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: () => 'parent', + head: parentHead, + gcTime: 60_000, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + head: childHead, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory(), + }) + + const preload = router.preloadRoute({ to: '/parent/child' }) + await vi.waitFor(() => expect(parentHead).toHaveBeenCalledTimes(1)) + expect(router.stores.cachedMatches.get()).toEqual([]) + + headGate.resolve({ meta: [{ title: 'detached' }] }) + await preload + + const parentMatch = router.stores.cachedMatches + .get() + .find((match) => match.routeId === parentRoute.id)! + expect(parentMatch.meta).toEqual([{ title: 'detached' }]) + expect(parentMatch.status).toBe('success') + expect(parentMatch._.loadPromise).toBeUndefined() + expect(parentMatch.error).toBeUndefined() + expect(childHead).toHaveBeenCalledTimes(1) + expectNoCachedActiveOverlap(router) + }) + + test('beforeLoad onError-triggered navigation cancels the old failure', async () => { + const failure = new Error('beforeLoad failed') + let router!: AnyRouter + const onError = vi.fn(() => { + void router.navigate({ to: '/bar' }) + }) + const fooHead = vi.fn(() => ({})) + const barBeforeLoad = vi.fn() + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + beforeLoad: () => { + throw failure + }, + onError, + head: fooHead, + errorComponent: () => null, + }) + const barRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/bar', + beforeLoad: barBeforeLoad, + }) + + router = createTestRouter({ + routeTree: rootRoute.addChildren([fooRoute, barRoute]), + history: createMemoryHistory(), + }) + + await router.navigate({ to: '/foo' }).catch(() => undefined) + await vi.waitFor(() => expect(router.state.location.pathname).toBe('/bar')) + + expect(onError).toHaveBeenCalledTimes(1) + expect(barBeforeLoad).toHaveBeenCalledTimes(1) + expect(fooHead).not.toHaveBeenCalled() + expect(router.state.matches.some((match) => match.error === failure)).toBe( + false, + ) + }) + + test('synchronous beforeLoad navigation cancels the old pass even when beforeLoad returns normally', async () => { + let router!: AnyRouter + const childLoader = vi.fn(() => 'stale child') + const oldHead = vi.fn(() => ({})) + const barBeforeLoad = vi.fn() + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + beforeLoad: () => { + void router.navigate({ to: '/bar' }) + return { staleContext: true } + }, + head: oldHead, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => fooRoute, + path: '/child', + loader: childLoader, + }) + const barRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/bar', + beforeLoad: barBeforeLoad, + }) + + router = createTestRouter({ + routeTree: rootRoute.addChildren([ + fooRoute.addChildren([childRoute]), + barRoute, + ]), + history: createMemoryHistory(), + }) + + await router.navigate({ to: '/foo/child' }).catch(() => undefined) + await vi.waitFor(() => expect(router.state.location.pathname).toBe('/bar')) + + expect(childLoader).not.toHaveBeenCalled() + expect(oldHead).not.toHaveBeenCalled() + expect(barBeforeLoad).toHaveBeenCalledTimes(1) + }) + + test('synchronous beforeLoad navigation followed by redirect does not let the stale redirect win', async () => { + let router!: AnyRouter + const barBeforeLoad = vi.fn() + const bazBeforeLoad = vi.fn() + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + beforeLoad: () => { + void router.navigate({ to: '/bar' }) + return redirect({ to: '/baz' }) + }, + }) + const barRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/bar', + beforeLoad: barBeforeLoad, + }) + const bazRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/baz', + beforeLoad: bazBeforeLoad, + }) + + router = createTestRouter({ + routeTree: rootRoute.addChildren([fooRoute, barRoute, bazRoute]), + history: createMemoryHistory(), + }) + + await router.navigate({ to: '/foo' }).catch(() => undefined) + await vi.waitFor(() => expect(router.state.location.pathname).toBe('/bar')) + + expect(barBeforeLoad).toHaveBeenCalledTimes(1) + expect(bazBeforeLoad).not.toHaveBeenCalled() + }) + + test('loader onError-triggered navigation cancels stale error finalization', async () => { + const failure = new Error('loader failed') + let router!: AnyRouter + const onError = vi.fn(() => { + void router.navigate({ to: '/bar' }) + }) + const fooHead = vi.fn(() => ({})) + const barBeforeLoad = vi.fn() + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loader: () => { + throw failure + }, + onError, + head: fooHead, + errorComponent: () => null, + }) + const barRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/bar', + beforeLoad: barBeforeLoad, + }) + + router = createTestRouter({ + routeTree: rootRoute.addChildren([fooRoute, barRoute]), + history: createMemoryHistory(), + }) + + await router.navigate({ to: '/foo' }).catch(() => undefined) + await vi.waitFor(() => expect(router.state.location.pathname).toBe('/bar')) + + expect(onError).toHaveBeenCalledTimes(1) + expect(barBeforeLoad).toHaveBeenCalledTimes(1) + expect(fooHead).not.toHaveBeenCalled() + expect(router.state.matches.some((match) => match.error === failure)).toBe( + false, + ) + }) + + test('loader that ignores abort cannot commit while a newer navigation is pending', async () => { + const fooLoaderGate = createControlledPromise<{ value: string }>() + const barBeforeLoadGate = createControlledPromise() + const fooLoader = vi.fn(() => fooLoaderGate) + const fooHead = vi.fn(() => ({})) + const fooScripts = vi.fn(() => []) + const barBeforeLoad = vi.fn(() => barBeforeLoadGate) + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loader: fooLoader, + head: fooHead, + scripts: fooScripts, + }) + const barRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/bar', + beforeLoad: barBeforeLoad, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([fooRoute, barRoute]), + history: createMemoryHistory(), + }) + + const fooNavigation = router.navigate({ to: '/foo' }).catch(() => undefined) + await vi.waitFor(() => expect(fooLoader).toHaveBeenCalledTimes(1)) + const fooMatch = router.getMatch('/foo/foo')! + expect(fooMatch._.loadPromise?.status).toBe('pending') + + const barNavigation = router.navigate({ to: '/bar' }) + await vi.waitFor(() => expect(barBeforeLoad).toHaveBeenCalledTimes(1)) + expect(fooMatch.abortController.signal.aborted).toBe(true) + + fooLoaderGate.resolve({ value: 'stale' }) + await Promise.resolve() + + expect(router.getMatch('/foo/foo')?.loaderData).toBeUndefined() + expect(fooHead).not.toHaveBeenCalled() + expect(fooScripts).not.toHaveBeenCalled() + + barBeforeLoadGate.resolve() + await barNavigation + await fooNavigation + + expect(router.state.location.pathname).toBe('/bar') + }) + + test('aborted async beforeLoad does not invoke onError, heads, or descendants', async () => { + const beforeLoadGate = createControlledPromise() + const onError = vi.fn() + const parentHead = vi.fn(() => ({})) + const parentScripts = vi.fn(() => []) + const childLoader = vi.fn(() => 'child') + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + beforeLoad: async () => { + await beforeLoadGate + throw new Error('aborted beforeLoad failed') + }, + onError, + head: parentHead, + scripts: parentScripts, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: childLoader, + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + }) + const location = router.latestLocation + const matches = router.matchRoutes(location) + router.stores.setPending(matches) + + const loadPromise = loadMatches({ router, location, matches }) + await vi.waitFor(() => + expect(getLaneMatch(matches, matches[1]!.id)?.isFetching).toBe( + 'beforeLoad', + ), + ) + + router.cancelMatches() + beforeLoadGate.resolve() + + await expect(loadPromise).resolves.toBe(matches) + expect(onError).not.toHaveBeenCalled() + expect(parentHead).not.toHaveBeenCalled() + expect(parentScripts).not.toHaveBeenCalled() + expect(childLoader).not.toHaveBeenCalled() + }) + + test('applies pendingMinMs when pending renders during component preload', async () => { + vi.useFakeTimers() + try { + const componentGate = createControlledPromise() + const rootRoute = new BaseRootRoute({}) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + pendingMs: 0, + pendingMinMs: 100, + pendingComponent: () => null, + loader: () => 'loaded', + component: { preload: () => componentGate } as any, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([targetRoute as any]), + history: createMemoryHistory({ initialEntries: ['/target'] }), + }) + const location = router.latestLocation + const matches = router.matchRoutes(location) + router.stores.setPending(matches) + const targetMatch = matches.find( + (match) => match.routeId === targetRoute.id, + )! + + const loadPromise = loadMatches({ + router, + location, + matches, + onReady: async (readyMatches) => { + const readyMatch = readyMatches.find( + (match) => match.routeId === targetRoute.id, + )! + const promise = readyMatch._.loadPromise + if (promise) { + promise.pendingUntil ??= Date.now() + 100 + } + }, + }) + + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(10) + componentGate.resolve() + await Promise.resolve() + + expect(router.getMatch(targetMatch.id)?.status).toBe('pending') + + await vi.advanceTimersByTimeAsync(89) + expect(router.getMatch(targetMatch.id)?.status).toBe('pending') + + await vi.advanceTimersByTimeAsync(1) + await loadPromise + + const updatedMatch = getLaneMatch(matches, targetMatch.id) + expect(updatedMatch?.status).toBe('success') + expect(updatedMatch?._.loadPromise).toBeUndefined() + } finally { + vi.useRealTimers() + } + }) + + test('applies pendingMinMs when pending renders during error component preload', async () => { + vi.useFakeTimers() + try { + const errorGate = createControlledPromise() + const rootRoute = new BaseRootRoute({}) + const errorRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/error', + pendingMs: 0, + pendingMinMs: 100, + pendingComponent: () => null, + loader: () => { + throw new Error('loader failed') + }, + errorComponent: { preload: () => errorGate } as any, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([errorRoute]), + history: createMemoryHistory({ initialEntries: ['/error'] }), + }) + const location = router.latestLocation + const matches = router.matchRoutes(location) + router.stores.setPending(matches) + const errorMatch = matches.find( + (match) => match.routeId === errorRoute.id, + )! + + const loadPromise = loadMatches({ + router, + location, + matches, + onReady: async (readyMatches) => { + const readyMatch = readyMatches.find( + (match) => match.routeId === errorRoute.id, + )! + const promise = readyMatch._.loadPromise + if (promise) { + promise.pendingUntil ??= Date.now() + 100 + } + }, + }) + + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(10) + errorGate.resolve() + await Promise.resolve() + + expect(router.getMatch(errorMatch.id)?.status).toBe('pending') + + await vi.advanceTimersByTimeAsync(89) + expect(router.getMatch(errorMatch.id)?.status).toBe('pending') + + await vi.advanceTimersByTimeAsync(1) + await loadPromise + + const updatedMatch = getLaneMatch(matches, errorMatch.id) + expect(updatedMatch?.status).toBe('error') + expect(updatedMatch?._.loadPromise).toBeUndefined() + } finally { + vi.useRealTimers() + } + }) + + test('applies pendingMinMs when pending renders during notFound component preload', async () => { + vi.useFakeTimers() + try { + const notFoundGate = createControlledPromise() + const rootRoute = new BaseRootRoute({}) + const missingRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/missing', + pendingMs: 0, + pendingMinMs: 100, + pendingComponent: () => null, + loader: () => notFound(), + notFoundComponent: { preload: () => notFoundGate } as any, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([missingRoute]), + history: createMemoryHistory({ initialEntries: ['/missing'] }), + }) + const location = router.latestLocation + const matches = router.matchRoutes(location) + router.stores.setPending(matches) + const missingMatch = matches.find( + (match) => match.routeId === missingRoute.id, + )! + + const loadPromise = loadMatches({ + router, + location, + matches, + onReady: async (readyMatches) => { + const readyMatch = readyMatches.find( + (match) => match.routeId === missingRoute.id, + )! + const promise = readyMatch._.loadPromise + if (promise) { + promise.pendingUntil ??= Date.now() + 100 + } + }, + }).then( + () => undefined, + (err) => err, + ) + + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(10) + notFoundGate.resolve() + await Promise.resolve() + + expect(router.getMatch(missingMatch.id)?.status).toBe('pending') + + await vi.advanceTimersByTimeAsync(89) + expect(router.getMatch(missingMatch.id)?.status).toBe('pending') + + await vi.advanceTimersByTimeAsync(1) + await expect(loadPromise).resolves.toMatchObject({ isNotFound: true }) + + const updatedMatch = getLaneMatch(matches, missingMatch.id) + expect(updatedMatch?.status).toBe('notFound') + expect(updatedMatch?._.loadPromise).toBeUndefined() + } finally { + vi.useRealTimers() + } + }) + + test('lazy route function throwing synchronously commits error and clears promises', async () => { + const lazyError = new Error('lazy failed') + const rootRoute = new BaseRootRoute({}) + const lazyRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/lazy', + }).lazy(() => { + throw lazyError + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([lazyRoute]), + history: createMemoryHistory({ initialEntries: ['/lazy'] }), + }) + const location = router.latestLocation + const matches = router.matchRoutes(location) + router.stores.setPending(matches) + const lazyMatch = matches.find((match) => match.routeId === lazyRoute.id)! + + await expect(loadMatches({ router, location, matches })).resolves.toBe( + matches, + ) + + const updatedMatch = getLaneMatch(matches, lazyMatch.id) + expect(updatedMatch?.status).toBe('error') + expect(updatedMatch?.error).toBe(lazyError) + expect(updatedMatch?._.loadPromise).toBeUndefined() + expectNotPendingWithoutLoadPromise(updatedMatch) + }) + + test('component preload throwing synchronously commits error and clears promises', async () => { + const preloadError = new Error('component failed') + const rootRoute = new BaseRootRoute({}) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + loader: () => 'loaded', + component: { + preload: () => { + throw preloadError + }, + } as any, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([targetRoute]), + history: createMemoryHistory({ initialEntries: ['/target'] }), + }) + const location = router.latestLocation + const matches = router.matchRoutes(location) + router.stores.setPending(matches) + const targetMatch = matches.find( + (match) => match.routeId === targetRoute.id, + )! + + await expect(loadMatches({ router, location, matches })).resolves.toBe( + matches, + ) + + const updatedMatch = getLaneMatch(matches, targetMatch.id) + expect(updatedMatch?.status).toBe('error') + expect(updatedMatch?.error).toBe(preloadError) + expect(updatedMatch?._.loadPromise).toBeUndefined() + expectNotPendingWithoutLoadPromise(updatedMatch) + }) + + test('sync errorComponent preload error replaces loader error and settles the match', async () => { + const preloadError = new Error('error boundary failed') + const rootRoute = new BaseRootRoute({}) + const errorRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/error', + loader: () => { + throw new Error('loader failed') + }, + errorComponent: { + preload: () => { + throw preloadError + }, + } as any, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([errorRoute]), + history: createMemoryHistory({ initialEntries: ['/error'] }), + }) + const location = router.latestLocation + const matches = router.matchRoutes(location) + router.stores.setPending(matches) + const errorMatch = matches.find((match) => match.routeId === errorRoute.id)! + + await expect(loadMatches({ router, location, matches })).resolves.toBe( + matches, + ) + + const updatedMatch = getLaneMatch(matches, errorMatch.id) + expect(updatedMatch?.status).toBe('error') + expect(updatedMatch?.error).toBe(preloadError) + expect(updatedMatch?._.loadPromise).toBeUndefined() + expectNotPendingWithoutLoadPromise(updatedMatch) + }) + + test('notFoundComponent preload throwing synchronously cannot leave status pending', async () => { + const preloadError = new Error('notFound boundary failed') + const rootRoute = new BaseRootRoute({}) + const missingRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/missing', + loader: () => notFound(), + notFoundComponent: { + preload: () => { + throw preloadError + }, + } as any, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([missingRoute]), + history: createMemoryHistory({ initialEntries: ['/missing'] }), + }) + const location = router.latestLocation + const matches = router.matchRoutes(location) + router.stores.setPending(matches) + const missingMatch = matches.find( + (match) => match.routeId === missingRoute.id, + )! + + await expect(loadMatches({ router, location, matches })).resolves.toBe( + matches, + ) + + const updatedMatch = getLaneMatch(matches, missingMatch.id) + expect(updatedMatch?.status).toBe('error') + expect(updatedMatch?.error).toBe(preloadError) + expect(updatedMatch?._.loadPromise).toBeUndefined() + expectNotPendingWithoutLoadPromise(updatedMatch) + }) + + test('ancestor notFoundComponent preload failure commits error at the ancestor boundary', async () => { + const boundaryError = new Error('ancestor notFound boundary failed') + const notFoundGate = createControlledPromise() + const notFoundPreload = vi.fn(() => notFoundGate) + const childHead = vi.fn(() => ({ meta: [{ title: 'Child' }] })) + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + ssr: 'data-only', + notFoundComponent: { preload: notFoundPreload } as any, + errorComponent: () => null, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: () => notFound({ routeId: parentRoute.id }), + head: childHead, + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + isServer: true, + }) + + const location = router.latestLocation + const matches = router.matchRoutes(location) + router.stores.setPending(matches) + const load = loadMatches({ router, location, matches }).then( + () => undefined, + (err) => err, + ) + await vi.waitFor(() => expect(notFoundPreload).toHaveBeenCalledTimes(1)) + + notFoundGate.reject(boundaryError) + await expect(load).resolves.toBe(boundaryError) + + expect(matches.map((match) => match.routeId)).toEqual([ + rootRoute.id, + parentRoute.id, + ]) + + const parentMatch = matches.at(-1)! + expect(parentMatch.status).toBe('error') + expect(parentMatch.error).toBe(boundaryError) + expect(childHead).not.toHaveBeenCalled() + expect(notFoundPreload).toHaveBeenCalledTimes(1) + expect(parentMatch._.loadPromise).toBeUndefined() + }) + + test('onError throwing notFound calls notFoundComponent preload once', async () => { + const notFoundPreload = vi.fn() + const rootRoute = new BaseRootRoute({}) + const errorRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/error', + loader: () => { + throw new Error('loader failed') }, - { - name: 'uses first parent loader notFound when multiple parent loaders throw notFound', - throwAtIndex: 3 as const, - parentFailures: { 1: 'notFound', 2: 'notFound' } as ParentFailureMap, - expectedErrorKind: 'notFound' as const, - expectedErrorSource: 'loader-1', - expectedLoaderMaxIndex: 2, - expectedRenderedHeadMaxIndex: 1, + onError: () => { + throw notFound() }, - { - name: 'uses parent loader notFound when root loader throws notFound', - throwAtIndex: 2 as const, - parentFailures: { 0: 'notFound' } as ParentFailureMap, - expectedErrorKind: 'notFound' as const, - expectedErrorSource: 'loader-0', - expectedLoaderMaxIndex: 1, - expectedRenderedHeadMaxIndex: 0, + notFoundComponent: { preload: notFoundPreload } as any, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([errorRoute]), + history: createMemoryHistory({ initialEntries: ['/error'] }), + }) + const location = router.latestLocation + const matches = router.matchRoutes(location) + router.stores.setPending(matches) + const errorMatch = matches.find((match) => match.routeId === errorRoute.id)! + + await expect( + loadMatches({ router, location, matches }), + ).rejects.toMatchObject({ isNotFound: true }) + + const updatedMatch = getLaneMatch(matches, errorMatch.id) + expect(updatedMatch?.status).toBe('notFound') + expect(updatedMatch?._.loadPromise).toBeUndefined() + expectNotPendingWithoutLoadPromise(updatedMatch) + expect(notFoundPreload).toHaveBeenCalledTimes(1) + }) + + test('onError throwing notFound targets the failing route boundary before descendants', async () => { + const parentNotFoundPreload = vi.fn() + const childNotFoundPreload = vi.fn() + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: () => { + throw new Error('parent failed') }, - { - name: 'uses explicit routeId from beforeLoad notFound to target ancestor boundary', - throwAtIndex: 3 as const, - parentFailures: {} as ParentFailureMap, - expectedErrorKind: 'notFound' as const, - expectedErrorSource: 'beforeLoad-explicit-level1', - expectedErrorRouteIndex: 1, - expectedLoaderMaxIndex: 1, - expectedRenderedHeadMaxIndex: 1, - beforeLoadNotFoundFactory: (routes) => - notFound({ - routeId: routes[1].id as never, - data: { source: 'beforeLoad-explicit-level1' }, - }), + onError: () => { + throw notFound() + }, + notFoundComponent: { preload: parentNotFoundPreload } as any, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: 'child', + notFoundComponent: { preload: childNotFoundPreload } as any, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + }) + const location = router.latestLocation + const matches = router.matchRoutes(location) + router.stores.setPending(matches) + const parentMatch = matches.find( + (match) => match.routeId === parentRoute.id, + )! + + await expect( + loadMatches({ router, location, matches }), + ).rejects.toMatchObject({ isNotFound: true, routeId: parentRoute.id }) + + expect(matches.map((match) => match.routeId)).toEqual([ + rootRoute.id, + parentRoute.id, + ]) + expect(getLaneMatch(matches, parentMatch.id)?.status).toBe('notFound') + expect(parentNotFoundPreload).toHaveBeenCalledTimes(1) + }) + + test('loader errorComponent preload runs once', async () => { + const loaderError = new Error('loader failed') + const errorPreload = vi.fn() + const rootRoute = new BaseRootRoute({}) + const errorRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/error', + loader: () => { + throw loaderError + }, + errorComponent: { preload: errorPreload } as any, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([errorRoute]), + history: createMemoryHistory({ initialEntries: ['/error'] }), + }) + const location = router.latestLocation + const matches = router.matchRoutes(location) + router.stores.setPending(matches) + + await expect(loadMatches({ router, location, matches })).resolves.toBe( + matches, + ) + expect(errorPreload).toHaveBeenCalledTimes(1) + }) + + test('loader notFoundComponent preload runs once', async () => { + const notFoundPreload = vi.fn() + const rootRoute = new BaseRootRoute({}) + const missingRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/missing', + loader: () => notFound(), + notFoundComponent: { preload: notFoundPreload } as any, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([missingRoute]), + history: createMemoryHistory({ initialEntries: ['/missing'] }), + }) + const location = router.latestLocation + const matches = router.matchRoutes(location) + router.stores.setPending(matches) + + await expect( + loadMatches({ router, location, matches }), + ).rejects.toMatchObject({ isNotFound: true }) + expect(notFoundPreload).toHaveBeenCalledTimes(1) + }) + + test('beforeLoad errorComponent preload runs once', async () => { + const beforeLoadError = new Error('beforeLoad failed') + const errorPreload = vi.fn() + const rootRoute = new BaseRootRoute({}) + const errorRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/error', + beforeLoad: () => { + throw beforeLoadError + }, + errorComponent: { preload: errorPreload } as any, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([errorRoute]), + history: createMemoryHistory({ initialEntries: ['/error'] }), + }) + const location = router.latestLocation + const matches = router.matchRoutes(location) + router.stores.setPending(matches) + + await expect(loadMatches({ router, location, matches })).resolves.toBe( + matches, + ) + expect(errorPreload).toHaveBeenCalledTimes(1) + }) + + test('beforeLoad notFoundComponent preload runs once', async () => { + const notFoundPreload = vi.fn() + const rootRoute = new BaseRootRoute({}) + const missingRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/missing', + beforeLoad: () => notFound(), + notFoundComponent: { preload: notFoundPreload } as any, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([missingRoute]), + history: createMemoryHistory({ initialEntries: ['/missing'] }), + }) + const location = router.latestLocation + const matches = router.matchRoutes(location) + router.stores.setPending(matches) + + await expect( + loadMatches({ router, location, matches }), + ).rejects.toMatchObject({ isNotFound: true }) + expect(notFoundPreload).toHaveBeenCalledTimes(1) + }) + + test('beforeLoad throwing a thenable is committed as a route error', async () => { + const thenable = { then: vi.fn() } + const errorPreload = vi.fn() + const rootRoute = new BaseRootRoute({}) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + beforeLoad: () => { + throw thenable + }, + errorComponent: { preload: errorPreload } as any, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([targetRoute]), + history: createMemoryHistory({ initialEntries: ['/target'] }), + }) + const location = router.latestLocation + const matches = router.matchRoutes(location) + router.stores.setPending(matches) + const targetMatch = matches.find( + (match) => match.routeId === targetRoute.id, + )! + + await expect(loadMatches({ router, location, matches })).resolves.toBe( + matches, + ) + const updatedMatch = getLaneMatch(matches, targetMatch.id) + expect(updatedMatch?.status).toBe('error') + expect(updatedMatch?.error).toBe(thenable) + expect(updatedMatch?._.loadPromise).toBeUndefined() + expect(errorPreload).toHaveBeenCalledTimes(1) + }) + + test('onError throwing a thenable is committed as a route error', async () => { + const thenable = { then: vi.fn() } + const errorPreload = vi.fn() + const rootRoute = new BaseRootRoute({}) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + ssr: 'data-only', + loader: () => { + throw new Error('loader failed') }, - { - name: 'falls back to root boundary when beforeLoad notFound uses unknown routeId', - throwAtIndex: 3 as const, - parentFailures: {} as ParentFailureMap, - expectedErrorKind: 'notFound' as const, - expectedErrorSource: 'beforeLoad-invalid-route', - expectedLoaderMaxIndex: 0, - expectedRenderedHeadMaxIndex: 0, - beforeLoadNotFoundFactory: () => - notFound({ - routeId: '/does-not-exist' as never, - data: { source: 'beforeLoad-invalid-route' }, - }), + onError: () => { + throw thenable }, - { - name: 'falls back to root boundary when beforeLoad notFound uses non-exact routeId', - throwAtIndex: 3 as const, - parentFailures: {} as ParentFailureMap, - expectedErrorKind: 'notFound' as const, - expectedErrorSource: 'beforeLoad-non-exact-route', - expectedLoaderMaxIndex: 0, - expectedRenderedHeadMaxIndex: 0, - beforeLoadNotFoundFactory: (routes) => - notFound({ - routeId: `${routes[1].id}/` as never, - data: { source: 'beforeLoad-non-exact-route' }, - }), + errorComponent: { preload: errorPreload } as any, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([targetRoute]), + history: createMemoryHistory({ initialEntries: ['/target'] }), + isServer: true, + }) + const location = router.latestLocation + const matches = router.matchRoutes(location) + router.stores.setPending(matches) + const targetMatch = matches.find( + (match) => match.routeId === targetRoute.id, + )! + + let result: unknown + await loadMatches({ router, location, matches }).then( + () => { + throw new Error('Expected loadMatches to reject') }, - { - name: 'assigns defaultNotFoundComponent on root when unknown routeId falls back to root', - throwAtIndex: 3 as const, - parentFailures: {} as ParentFailureMap, - expectedErrorKind: 'notFound' as const, - expectedErrorSource: 'beforeLoad-invalid-route-default', - expectedLoaderMaxIndex: 0, - expectedRenderedHeadMaxIndex: 0, - withDefaultNotFoundComponent: true, - expectRootNotFoundComponentAssigned: true, - beforeLoadNotFoundFactory: () => - notFound({ - routeId: '/does-not-exist' as never, - data: { source: 'beforeLoad-invalid-route-default' }, - }), + (err) => { + result = err }, - { - name: 'prioritizes redirect when parent loader throws redirect', - throwAtIndex: 3 as const, - parentFailures: { 0: 'redirect' } as ParentFailureMap, - expectedErrorKind: 'redirect' as const, - expectedErrorSource: undefined, - expectedLoaderMaxIndex: 2, - expectedRenderedHeadMaxIndex: -1, + ) + + const updatedMatch = getLaneMatch(matches, targetMatch.id) + expect(result).toBe(thenable) + expect(updatedMatch?.status).toBe('error') + expect(updatedMatch?.error).toBe(thenable) + expect(updatedMatch?._.loadPromise).toBeUndefined() + expect(errorPreload).toHaveBeenCalledTimes(1) + }) + + test('client load executes head and scripts but never headers', async () => { + const existingHeaders = { 'x-existing': 'yes' } + const head = vi.fn(() => ({ meta: [{ title: 'Client' }] })) + const scripts = vi.fn(() => [{ children: 'client-script' }]) + const headers = vi.fn(() => ({ 'x-new': 'nope' })) + const rootRoute = new BaseRootRoute({}) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + loader: () => 'loaded', + head, + scripts, + headers, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([targetRoute]), + history: createMemoryHistory({ initialEntries: ['/target'] }), + isServer: false, + }) + const location = router.latestLocation + const matches = router.matchRoutes(location) + const targetMatch = matches.find( + (match) => match.routeId === targetRoute.id, + )! + targetMatch.headers = existingHeaders + router.stores.setPending(matches) + + await loadMatches({ + router, + location, + matches, + }) + await projectAssets({ router, matches }) + router.stores.setPending(matches) + + const updatedMatch = router.getMatch(targetMatch.id) + expect(head).toHaveBeenCalledTimes(1) + expect(scripts).toHaveBeenCalledTimes(1) + expect(headers).not.toHaveBeenCalled() + expect(updatedMatch?.headers).toBe(existingHeaders) + }) + + test('server load executes head, scripts, and headers', async () => { + const returnedHeaders = { 'x-server': 'yes' } + const head = vi.fn(() => ({ meta: [{ title: 'Server' }] })) + const scripts = vi.fn(() => [{ children: 'server-script' }]) + const headers = vi.fn(() => returnedHeaders) + const rootRoute = new BaseRootRoute({}) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + loader: () => 'loaded', + head, + scripts, + headers, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([targetRoute]), + history: createMemoryHistory({ initialEntries: ['/target'] }), + isServer: true, + }) + const location = router.latestLocation + const matches = router.matchRoutes(location) + router.stores.setPending(matches) + const targetMatch = matches.find( + (match) => match.routeId === targetRoute.id, + )! + + await loadMatches({ + router, + location, + matches, + }) + router.stores.setPending(matches) + + const updatedMatch = router.getMatch(targetMatch.id) + expect(head).toHaveBeenCalledTimes(1) + expect(scripts).toHaveBeenCalledTimes(1) + expect(headers).toHaveBeenCalledTimes(1) + expect(updatedMatch?.headers).toBe(returnedHeaders) + }) + + test('navigation owns a separate loadPromise from private pending preload', async () => { + const loaderGate = createControlledPromise() + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loader: async () => { + await loaderGate + return 'foo' }, - { - name: 'prioritizes redirect over root-loader notFound when both appear in settled loaders', - throwAtIndex: 3 as const, - parentFailures: { 0: 'notFound', 1: 'redirect' } as ParentFailureMap, - expectedErrorKind: 'redirect' as const, - expectedErrorSource: undefined, - expectedLoaderMaxIndex: 2, - expectedRenderedHeadMaxIndex: -1, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([fooRoute]), + history: createMemoryHistory(), + }) + await router.load() + + const preloadPromise = router.preloadRoute({ to: '/foo' }) + await Promise.resolve() + expect(router.stores.cachedMatches.get()).toEqual([]) + expectNoCachedActiveOverlap(router) + + const navigationPromise = router.navigate({ to: '/foo' }) + await vi.waitFor(() => + expect(router.getMatch('/foo/foo', false)).toBeDefined(), + ) + const navigationMatch = router.getMatch('/foo/foo', false)! + const loadPromiseB = navigationMatch._.loadPromise! + + expect(loadPromiseB.status).toBe('pending') + + loaderGate.resolve() + await Promise.all([preloadPromise, navigationPromise]) + + const currentMatch = router.getMatch('/foo/foo', false)! + expect(currentMatch.status).toBe('success') + expect(currentMatch._.loadPromise).toBeUndefined() + expectNoCachedActiveOverlap(router) + }) + + test('preload-only work does not arm pendingUntil', async () => { + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + pendingMinMs: 10_000, + pendingComponent: () => null, + loader: () => 'target', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, targetRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + defaultPendingMs: 0, + }) + + await router.load() + const matches = (await router.preloadRoute({ to: '/target' }))! + const targetMatch = matches.find( + (match) => match.routeId === targetRoute.id, + )! + + expect(targetMatch._.loadPromise?.pendingUntil).toBeUndefined() + }) + + test('background reload does not arm pendingUntil', async () => { + const reloadGate = createControlledPromise() + let loaderCalls = 0 + const rootRoute = new BaseRootRoute({}) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + pendingMinMs: 10_000, + pendingComponent: () => null, + loader: () => { + loaderCalls++ + return loaderCalls === 1 ? 'initial' : reloadGate }, - { - 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, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([targetRoute]), + history: createMemoryHistory({ initialEntries: ['/target'] }), + defaultPendingMs: 0, + }) + + await router.load() + const targetMatch = router.state.matches.find( + (match) => match.routeId === targetRoute.id, + )! + expect(targetMatch.status).toBe('success') + expect(targetMatch.loaderData).toBe('initial') + + const reload = router.invalidate() + await vi.waitFor(() => expect(loaderCalls).toBe(2)) + + const reloadingMatch = router.getMatch(targetMatch.id)! + expect(reloadingMatch.status).toBe('success') + expect(reloadingMatch._.loadPromise).toBeUndefined() + expect(reloadingMatch._.loadPromise?.pendingUntil).toBeUndefined() + + reloadGate.resolve('reloaded') + await reload + + await vi.waitFor(() => + expect(router.getMatch(targetMatch.id)?.loaderData).toBe('reloaded'), + ) + }) + + test('reports undefined loader errors in development', async () => { + const rootRoute = new BaseRootRoute({}) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + loader: async () => { + throw undefined }, - ] satisfies Array + errorComponent: () => null, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([targetRoute]), + history: createMemoryHistory({ initialEntries: ['/target'] }), + }) + const location = router.latestLocation + const matches = router.matchRoutes(location) + router.stores.setPending(matches) + const targetMatch = matches.find( + (match) => match.routeId === targetRoute.id, + )! - test.each(scenarios)('$name', async (scenario) => { - const { router, routes, loaders, heads } = setupScenario({ - throwAtIndex: scenario.throwAtIndex, - parentFailures: scenario.parentFailures, - beforeLoadNotFoundFactory: scenario.beforeLoadNotFoundFactory, - withDefaultNotFoundComponent: scenario.withDefaultNotFoundComponent, + await expect( + loadMatches({ + router, + location, + matches, + }), + ).resolves.toBe(matches) + + const currentMatch = getLaneMatch(matches, targetMatch.id) + expect(currentMatch?.status).toBe('error') + expect(currentMatch?.error).toEqual( + expect.objectContaining({ + message: 'Route load failed with undefined', + }), + ) + }) + + test.each([ + ['client', false], + ['server', true], + ] as const)( + 'keeps a thrown undefined as an error match in production on the %s path', + async (_label, isServer) => { + const originalNodeEnv = process.env.NODE_ENV + process.env.NODE_ENV = 'production' + try { + const rootRoute = new BaseRootRoute({}) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + loader: async () => { + throw undefined + }, + errorComponent: () => null, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([targetRoute]), + history: createMemoryHistory({ initialEntries: ['/target'] }), + isServer, + }) + const location = router.latestLocation + const matches = router.matchRoutes(location) + router.stores.setPending(matches) + const targetMatch = matches.find( + (match) => match.routeId === targetRoute.id, + )! + + const result = await loadMatches({ + router, + location, + matches, + }).then( + () => ({ rejected: false as const }), + (error) => ({ rejected: true as const, error }), + ) + + const currentMatch = getLaneMatch(matches, targetMatch.id) + expect(result).toEqual( + isServer ? { rejected: true, error: undefined } : { rejected: false }, + ) + expect(currentMatch?.status).toBe('error') + expect(currentMatch?.error).toBeUndefined() + } finally { + process.env.NODE_ENV = originalNodeEnv + } + }, + ) + + test.each([ + ['beforeLoad', 'client', false], + ['beforeLoad', 'server', true], + ['loader', 'client', false], + ['loader', 'server', true], + ['shouldReload', 'client', false], + ] as const)( + 'keeps a thrown undefined from %s as a production error match on the %s path', + async (source, _label, isServer) => { + const originalNodeEnv = process.env.NODE_ENV + process.env.NODE_ENV = 'production' + try { + let shouldReloadThrows = false + const rootRoute = new BaseRootRoute({}) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + beforeLoad: + source === 'beforeLoad' + ? () => { + throw undefined + } + : undefined, + loader: + source === 'loader' + ? () => { + throw undefined + } + : () => 'loaded', + shouldReload: + source === 'shouldReload' + ? () => { + if (shouldReloadThrows) { + throw undefined + } + return true + } + : undefined, + staleTime: Infinity, + errorComponent: () => null, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([targetRoute]), + history: createMemoryHistory({ initialEntries: ['/target'] }), + isServer, + }) + + if (source === 'shouldReload') { + await router.load() + shouldReloadThrows = true + } + + await router.load({ sync: true }) + + const errorMatch = router.state.matches.find( + (match) => match.routeId === targetRoute.id, + ) + expect(errorMatch?.status).toBe('error') + expect(errorMatch?.error).toBeUndefined() + if (isServer) { + expect(router.statusCode).toBe(500) + } + } finally { + process.env.NODE_ENV = originalNodeEnv + } + }, + ) + ;[ + { name: 'false', value: false }, + { name: '0', value: 0 }, + { name: 'empty string', value: '' }, + { name: 'null', value: null }, + ].forEach(({ name, value }) => { + test(`wraps falsy validateSearch throw: ${name}`, () => { + const rootRoute = new BaseRootRoute({}) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + validateSearch: () => { + throw value + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([targetRoute as any]), + history: createMemoryHistory({ initialEntries: ['/target'] }), + }) + + expect(() => { + router.matchRoutes(router.latestLocation, { throwOnError: true }) + }).toThrow(SearchParamError) + expect(() => { + router.matchRoutes(router.latestLocation, { throwOnError: true }) + }).toThrow(String(value)) + }) + + test(`wraps falsy params parse throw: ${name}`, () => { + const rootRoute = new BaseRootRoute({}) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target/$id', + params: { + parse: () => { + throw value + }, + }, }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([targetRoute as any]), + history: createMemoryHistory({ initialEntries: ['/target/1'] }), + }) + + expect(() => { + router.matchRoutes(router.latestLocation, { throwOnError: true }) + }).toThrow(PathParamError) + expect(() => { + router.matchRoutes(router.latestLocation, { throwOnError: true }) + }).toThrow(String(value)) + }) + }) + test('early pending onReady receives a stable lane snapshot before final trimming', async () => { + vi.useFakeTimers() + try { + const beforeLoadGate = createControlledPromise() + const onReadyGate = createControlledPromise() + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + pendingMs: 0, + pendingComponent: () => null, + beforeLoad: async () => { + await beforeLoadGate + throw new Error('parent failed') + }, + errorComponent: () => null, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + parentRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + }) + const location = router.latestLocation + const matches = router.matchRoutes(location) + const initialIds = matches.map((match) => match.id) + router.stores.setPending(matches) + const calls: Array<{ ids: Array }> = [] + + const loadPromise = loadMatches({ + router, + location, + matches, + onReady: async (readyMatches) => { + calls.push({ + ids: readyMatches.map((match) => match.id), + }) + + await onReadyGate + }, + }) + + await vi.advanceTimersByTimeAsync(0) + await vi.waitFor(() => expect(calls).toHaveLength(1)) + + beforeLoadGate.resolve() + await Promise.resolve() + onReadyGate.resolve() + const loadedMatches = await loadPromise + + expect(calls).toEqual([{ ids: initialIds }]) + expect(loadedMatches.map((match) => match.id)).toEqual( + matches.slice(0, 2).map((match) => match.id), + ) + } finally { + vi.useRealTimers() + } + }) +}) - const { error, matches } = await runLoadMatchesAndCapture(router) +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, + }) - for (let i = 0; i < routes.length; i++) { - const loader = loaders[i]! - const expectedCalls = i <= scenario.expectedLoaderMaxIndex ? 1 : 0 - expect(loader).toHaveBeenCalledTimes(expectedCalls) - } + await loadRouteChunk(route, 'notFoundComponent') - for (let i = 0; i < heads.length; i++) { - const head = heads[i]! - const expectedCalls = i <= scenario.expectedRenderedHeadMaxIndex ? 1 : 0 - expect(head).toHaveBeenCalledTimes(expectedCalls) - } + expect(notFoundPreload).toHaveBeenCalledTimes(1) + expect(componentPreload).not.toHaveBeenCalled() + expect(errorPreload).not.toHaveBeenCalled() + expect((route as any)._componentsLoaded).not.toBe(true) - if (scenario.expectedErrorKind === 'redirect') { - expect(error).toEqual( - expect.objectContaining({ - redirectHandled: true, - options: expect.objectContaining({ - to: '/redirect-target', - }), - }), - ) - return - } + await loadRouteChunk(route) - if (scenario.expectedErrorKind === 'error') { - expect(error).toBeInstanceOf(Error) - expect((error as Error).message).toBe(scenario.expectedErrorSource) - return - } + expect(componentPreload).toHaveBeenCalledTimes(1) + expect(errorPreload).toHaveBeenCalledTimes(1) + expect(notFoundPreload).toHaveBeenCalledTimes(2) + expect((route as any)._componentsLoaded).toBe(true) - expect(error).toEqual( - expect.objectContaining({ - isNotFound: true, - data: { source: scenario.expectedErrorSource }, + 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, + }) - if (scenario.expectedErrorRouteIndex !== undefined) { - expect((error as { routeId?: string }).routeId).toBe( - routes[scenario.expectedErrorRouteIndex]!.id, - ) - } + const first = loadRouteChunk(route) + const second = loadRouteChunk(route) - if (scenario.expectRootNotFoundComponentAssigned) { - expect(routes[0].options.notFoundComponent).toBeTypeOf('function') - } + 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('fresh cache hits do not install a loader generation', async () => { + let router: AnyRouter + const seenGenerations: Array | undefined> = [] + const loader = vi.fn(() => ({ value: 'loaded' })) + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loader, + staleTime: Infinity, + shouldReload: () => { + const match = router.getMatch('/foo/foo') + seenGenerations.push(match?._.loadPromise) + return false + }, }) - test('sets globalNotFound on root match when beforeLoad notFound targets root boundary', async () => { - const { router, routes } = setupScenario({ - throwAtIndex: 3, - parentFailures: {}, - beforeLoadNotFoundFactory: (innerRoutes) => - notFound({ - routeId: innerRoutes[0].id as never, - data: { source: 'beforeLoad-root-explicit' }, - }), - }) + router = createTestRouter({ + routeTree: rootRoute.addChildren([fooRoute]), + history: createMemoryHistory({ initialEntries: ['/foo'] }), + }) - const { error, matches } = await runLoadMatchesAndCapture(router) + await router.load() + await router.load() - expect(error).toEqual( - expect.objectContaining({ - isNotFound: true, - data: { source: 'beforeLoad-root-explicit' }, - }), - ) + expect(loader).toHaveBeenCalledTimes(1) + expect(seenGenerations.map((promise) => promise?.status)).toEqual([ + 'resolved', + ]) + }) - const rootMatch = router.stores.pendingMatches - .get() - .find((m) => m.routeId === routes[0].id) + test('shouldReload exception commits a route error and trims descendants', async () => { + const thrown = new Error('shouldReload failed') + let shouldThrow = false + const parentLoader = vi.fn(() => ({ value: 'parent' })) + const childLoader = vi.fn(() => ({ value: 'child' })) + const childHead = vi.fn(() => ({ meta: [{ title: 'Child' }] })) - expect(rootMatch?.globalNotFound).toBe(true) - expect(rootMatch?.status).toBe('success') - expect(rootMatch?.error).toBeUndefined() + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: parentLoader, + shouldReload: () => { + if (shouldThrow) { + throw thrown + } + return true + }, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: childLoader, + head: childHead, }) - test('clears stale root globalNotFound on subsequent successful load', async () => { - const { router, routes } = setupScenario({ - throwAtIndex: 3, - parentFailures: {}, - beforeLoadNotFoundFactory: (innerRoutes) => - notFound({ - routeId: innerRoutes[0].id as never, - data: { source: 'beforeLoad-root-explicit' }, - }), - }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + }) + + await router.load() + expect(parentLoader).toHaveBeenCalledTimes(1) - const first = await runLoadMatchesAndCapture(router) - expect(first.error).toEqual(expect.objectContaining({ isNotFound: true })) + childLoader.mockClear() + childHead.mockClear() + shouldThrow = true - const throwingRoute = routes[3] - throwingRoute.options.beforeLoad = undefined + await router.invalidate({ sync: true }) - const second = await runLoadMatchesAndCapture(router) - expect(second.error).toBeUndefined() + const parentMatch = router.state.matches.find( + (match) => match.routeId === parentRoute.id, + )! - const rootMatch = router.stores.pendingMatches - .get() - .find((m) => m.routeId === routes[0].id) + expect(parentMatch.status).toBe('error') + expect(parentMatch.error).toBe(thrown) + expect(router.state.matches.map((match) => match.routeId)).toEqual([ + rootRoute.id, + parentRoute.id, + ]) + expect(childHead).not.toHaveBeenCalled() + }) - expect(rootMatch?.globalNotFound).toBe(false) + test('does not leave a loader generation when shouldReload throws', async () => { + const reloadError = new Error('shouldReload failed') + let shouldThrow = true + const loader = vi.fn(() => ({ value: 'loaded' })) + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loader, + shouldReload: () => { + if (shouldThrow) { + throw reloadError + } + return true + }, }) - test('clears stale root globalNotFound when root loader is skipped', async () => { - const rootLoader = vi.fn(() => ({ level: 0 })) - const rootRoute = new BaseRootRoute({ - loader: rootLoader, - staleTime: Infinity, - shouldReload: () => false, - }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([fooRoute]), + history: createMemoryHistory({ initialEntries: ['/foo'] }), + }) - const childLoader = vi.fn(() => ({ level: 1 })) - const childRoute = new BaseRoute({ + await router.load() + await router.invalidate({ sync: true }) + + const failedMatch = router.state.matches.find( + (match) => match.routeId === fooRoute.id, + )! + expect(failedMatch._.loadPromise).toBeUndefined() + + shouldThrow = false + await router.invalidate({ sync: true }) + + expect(loader).toHaveBeenCalledTimes(2) + expect( + router.state.matches.find((match) => match.routeId === fooRoute.id) + ?.loaderData, + ).toEqual({ value: 'loaded' }) + }) + ;[ + { name: 'false', value: false }, + { name: '0', value: 0 }, + { name: 'empty string', value: '' }, + { name: 'null', value: null }, + { name: 'undefined', value: undefined }, + ].forEach(({ name, value }) => { + test(`propagates falsy shouldReload throw: ${name}`, async () => { + const loader = vi.fn(() => ({ value: 'loaded' })) + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ getParentRoute: () => rootRoute, - path: '/test', - loader: childLoader, - staleTime: Infinity, - shouldReload: () => false, + path: '/foo', + loader, + shouldReload: () => { + throw value + }, }) - const routeTree = rootRoute.addChildren([childRoute]) - const router = createTestRouter({ - routeTree, - history: createMemoryHistory({ initialEntries: ['/test'] }), + routeTree: rootRoute.addChildren([fooRoute]), + history: createMemoryHistory({ initialEntries: ['/foo'] }), }) - const first = await runLoadMatchesAndCapture(router) - expect(first.error).toBeUndefined() - expect(rootLoader).toHaveBeenCalledTimes(1) - - const staleRootNotFound = notFound({ data: { source: 'stale-root' } }) - const currentRootMatchId = router.stores.pendingMatches - .get() - .find((m) => m.routeId === rootRoute.id)!.id - - router.updateMatch(currentRootMatchId, (prev) => ({ - ...prev, - status: 'success', - globalNotFound: true, - error: staleRootNotFound, - })) + await router.load() const location = router.latestLocation const matches = router.matchRoutes(location) - const pendingRootMatch = matches.find((m) => m.routeId === rootRoute.id)! - pendingRootMatch.status = 'success' - pendingRootMatch.globalNotFound = false - pendingRootMatch.error = undefined router.stores.setPending(matches) - await expect( - loadMatches({ - router, - location, - matches, - updateMatch: router.updateMatch, - }), - ).resolves.toBe(matches) - - expect(rootLoader).toHaveBeenCalledTimes(1) + const result = loadMatches({ router, location, matches }).then( + () => ({ rejected: false, value: undefined }), + (err) => ({ rejected: true, value: err }), + ) + const expectedValue = + value === undefined + ? expect.objectContaining({ + message: 'Route load failed with undefined', + }) + : value + + await expect(result).resolves.toEqual({ + rejected: false, + value: undefined, + }) - const rootMatch = router.stores.pendingMatches - .get() - .find((m) => m.routeId === rootRoute.id) + const failedMatch = getLaneMatch(matches, '/foo/foo') + expect(failedMatch?.status).toBe('error') + expect(failedMatch?.error).toEqual(expectedValue) + }) + }) + test('settles the generation when lazy route loading rejects', async () => { + const lazyError = new Error('lazy failed') + const lazyGate = createControlledPromise() + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + }).lazy(() => lazyGate) - expect(rootMatch?.globalNotFound).toBe(false) - expect(rootMatch?.error).toBeUndefined() + const router = createTestRouter({ + routeTree: rootRoute.addChildren([fooRoute]), + history: createMemoryHistory({ initialEntries: ['/foo'] }), }) + const location = router.latestLocation + const matches = router.matchRoutes(location) + router.stores.setPending(matches) + const fooMatch = matches.find((match) => match.routeId === fooRoute.id)! + + const load = loadMatches({ router, location, matches }).then( + () => undefined, + (err) => err, + ) + await vi.waitFor(() => + expect(router.getMatch(fooMatch.id)?._.loadPromise).toBeDefined(), + ) + const generation = router.getMatch(fooMatch.id)!._.loadPromise + + lazyGate.reject(lazyError) + + await expect(load).resolves.toBeUndefined() + expect(generation?.status).toBe('resolved') + const updatedMatch = getLaneMatch(matches, fooMatch.id) + expect(updatedMatch?.status).toBe('error') + expect(updatedMatch?.error).toBe(lazyError) + expect(updatedMatch?._.loadPromise).toBeUndefined() + expect( + updatedMatch?.status === 'pending' && !updatedMatch._.loadPromise, + ).toBe(false) }) -}) -describe('params.parse notFound', () => { - test('throws notFound on invalid params', async () => { + test('settles the generation when errorComponent preload rejects', async () => { + const componentError = new Error('error component failed') + const errorGate = createControlledPromise() + const errorPreload = vi.fn(() => errorGate) const rootRoute = new BaseRootRoute({}) - const testRoute = new BaseRoute({ + const fooRoute = new BaseRoute({ getParentRoute: () => rootRoute, - path: '/test/$id', - params: { - parse: ({ id }: { id: string }) => { - const parsed = parseInt(id, 10) - if (Number.isNaN(parsed)) { - throw notFound() - } - return { id: parsed } - }, + path: '/foo', + loader: () => { + throw new Error('loader failed') }, + errorComponent: { preload: errorPreload } as any, }) - const routeTree = rootRoute.addChildren([testRoute]) + const router = createTestRouter({ - routeTree, - history: createMemoryHistory({ initialEntries: ['/test/invalid'] }), + routeTree: rootRoute.addChildren([fooRoute]), + history: createMemoryHistory({ initialEntries: ['/foo'] }), }) + const location = router.latestLocation + const matches = router.matchRoutes(location) + router.stores.setPending(matches) + const fooMatch = matches.find((match) => match.routeId === fooRoute.id)! - await router.load() + const load = loadMatches({ router, location, matches }).then( + () => undefined, + (err) => err, + ) + await vi.waitFor(() => + expect(router.getMatch(fooMatch.id)?._.loadPromise).toBeDefined(), + ) + const generation = router.getMatch(fooMatch.id)!._.loadPromise - const match = router.stores.matches - .get() - .find((m) => m.routeId === testRoute.id) + errorGate.reject(componentError) + + await expect(load).resolves.toBeUndefined() + expect(generation?.status).toBe('resolved') + expect(errorPreload).toHaveBeenCalledTimes(1) + const updatedMatch = getLaneMatch(matches, fooMatch.id) + expect(updatedMatch?.status).toBe('error') + expect(updatedMatch?.error).toBe(componentError) + expect(updatedMatch?._.loadPromise).toBeUndefined() + expect( + updatedMatch?.status === 'pending' && !updatedMatch._.loadPromise, + ).toBe(false) + }) + + test('settles the generation when notFoundComponent preload rejects', async () => { + const componentError = new Error('notFound component failed') + const notFoundGate = createControlledPromise() + const notFoundPreload = vi.fn(() => notFoundGate) + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loader: () => notFound(), + notFoundComponent: { preload: notFoundPreload } as any, + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([fooRoute]), + history: createMemoryHistory({ initialEntries: ['/foo'] }), + }) + const location = router.latestLocation + const matches = router.matchRoutes(location) + router.stores.setPending(matches) + const fooMatch = matches.find((match) => match.routeId === fooRoute.id)! + + const load = loadMatches({ router, location, matches }).then( + () => undefined, + (err) => err, + ) + await vi.waitFor(() => + expect(router.getMatch(fooMatch.id)?._.loadPromise).toBeDefined(), + ) + const generation = router.getMatch(fooMatch.id)!._.loadPromise + + notFoundGate.reject(componentError) + + await expect(load).resolves.toBeUndefined() + expect(generation?.status).toBe('resolved') + expect(notFoundPreload).toHaveBeenCalledTimes(1) + const updatedMatch = getLaneMatch(matches, fooMatch.id) + expect(updatedMatch?.status).toBe('error') + expect(updatedMatch?.error).toBe(componentError) + expect(updatedMatch?._.loadPromise).toBeUndefined() + expect( + updatedMatch?.status === 'pending' && !updatedMatch._.loadPromise, + ).toBe(false) + }) + + test('navigation updates cached matches once during commit', async () => { + const loaderGate = createControlledPromise() + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loader: () => 'foo', + }) + const barRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/bar', + loader: () => loaderGate, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([fooRoute, barRoute]), + history: createMemoryHistory({ initialEntries: ['/foo'] }), + }) + await router.load() - expect(match?.status).toBe('notFound') + const setCached = vi.spyOn(router.stores, 'setCached') + try { + const navigation = router.navigate({ to: '/bar' }) + await vi.waitFor(() => + expect( + router.stores.pendingMatches + .get() + .some((match) => match.routeId === barRoute.id), + ).toBe(true), + ) + const setCachedAfterPending = setCached.mock.calls.length + + loaderGate.resolve('bar') + await navigation + + expect(setCached).toHaveBeenCalledTimes(setCachedAfterPending + 1) + expect( + router.stores.cachedMatches + .get() + .some((match) => match.routeId === fooRoute.id), + ).toBe(true) + } finally { + setCached.mockRestore() + } }) - test('succeeds on valid params', async () => { + test('internal updateMatch ignores cached match stores', async () => { const rootRoute = new BaseRootRoute({}) - const testRoute = new BaseRoute({ + const cachedRoute = new BaseRoute({ getParentRoute: () => rootRoute, - path: '/test/$id', - params: { - parse: ({ id }: { id: string }) => { - const parsed = parseInt(id, 10) - if (Number.isNaN(parsed)) { - throw notFound() - } - return { id: parsed } - }, - }, + path: '/cached', + loader: () => 'cached', }) - const routeTree = rootRoute.addChildren([testRoute]) const router = createTestRouter({ - routeTree, - history: createMemoryHistory({ initialEntries: ['/test/123'] }), + routeTree: rootRoute.addChildren([cachedRoute]), + history: createMemoryHistory(), }) - await router.load() + await router.preloadRoute({ to: '/cached' }) - const match = router.state.matches.find((m) => m.routeId === testRoute.id) - expect(match?.status).toBe('success') - expect(router.state.statusCode).toBe(200) - }) -}) + const cachedMatch = router.stores.cachedMatches + .get() + .find((match) => match.routeId === cachedRoute.id)! -describe('routeId in context options', () => { - test('beforeLoad and context receive correct routeId for root route', async () => { - const beforeLoad = vi.fn() - const context = vi.fn() - const rootRoute = new BaseRootRoute({ - beforeLoad, - context, - }) + router.updateMatch(cachedMatch.id, (match) => ({ + ...match, + status: 'pending', + error: new Error('should not write through updateMatch'), + })) - const routeTree = rootRoute.addChildren([]) + const currentCachedMatch = router.stores.cachedMatches + .get() + .find((match) => match.routeId === cachedRoute.id)! + expect(currentCachedMatch).toBe(cachedMatch) + expect(currentCachedMatch.status).toBe('success') + expect(currentCachedMatch.error).toBeUndefined() + }) + test('invalidate forcePending keeps cached matches as invalid success snapshots', async () => { + let calls = 0 + const loader = vi.fn(() => ({ value: ++calls })) + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const cachedRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/cached', + loader, + staleTime: Infinity, + }) const router = createTestRouter({ - routeTree, - history: createMemoryHistory(), + routeTree: rootRoute.addChildren([indexRoute, cachedRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), }) await router.load() + await router.preloadRoute({ to: '/cached' }) + expect(loader).toHaveBeenCalledTimes(1) - expect(beforeLoad).toHaveBeenCalledTimes(1) - expect(beforeLoad).toHaveBeenCalledWith( - expect.objectContaining({ - routeId: rootRouteId, - }), - ) + await router.invalidate({ forcePending: true, sync: true }) - expect(context).toHaveBeenCalledTimes(1) - expect(context).toHaveBeenCalledWith( - expect.objectContaining({ - routeId: rootRouteId, - }), - ) + const cachedMatch = router.stores.cachedMatches + .get() + .find((match) => match.routeId === cachedRoute.id)! + expect(cachedMatch.status).toBe('success') + expect(cachedMatch.invalid).toBe(true) + expect( + router.stores.cachedMatches + .get() + .every((match) => match.status === 'success'), + ).toBe(true) + + await router.navigate({ to: '/cached' }) + + const activeMatch = router.state.matches.find( + (match) => match.routeId === cachedRoute.id, + )! + expect(loader).toHaveBeenCalledTimes(2) + expect(activeMatch.loaderData).toEqual({ value: 2 }) + expect(activeMatch.invalid).toBe(false) }) - test('beforeLoad and context receive correct routeId for child route', async () => { - const beforeLoad = vi.fn() - const context = vi.fn() - const rootRoute = new BaseRootRoute({}) + test('successful preload cache entry expires and is evicted', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) - const fooRoute = new BaseRoute({ - getParentRoute: () => rootRoute, - path: '/foo', - beforeLoad, - context, - }) + try { + const rootRoute = new BaseRootRoute({}) + const preloadRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/preload', + loader: () => 'preload', + preloadGcTime: 10, + }) + const nextRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/next', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([preloadRoute, nextRoute]), + history: createMemoryHistory(), + }) - const routeTree = rootRoute.addChildren([fooRoute]) + await router.load() + await router.preloadRoute({ to: '/preload' }) - const router = createTestRouter({ - routeTree, - history: createMemoryHistory(), - }) + const preloadMatch = router.stores.cachedMatches + .get() + .find((match) => match.routeId === preloadRoute.id)! + + vi.setSystemTime(1_011) + await router.navigate({ to: '/next' }) + + expect( + router.stores.cachedMatches + .get() + .some((match) => match.routeId === preloadRoute.id), + ).toBe(false) + expect(preloadMatch.status).toBe('success') + } finally { + vi.useRealTimers() + } + }) - await router.navigate({ to: '/foo' }) + test('successful navigation cache entry expires and is evicted', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) - expect(beforeLoad).toHaveBeenCalledTimes(1) - expect(beforeLoad).toHaveBeenCalledWith( - expect.objectContaining({ - routeId: '/foo', - }), - ) + try { + const rootRoute = new BaseRootRoute({}) + const cachedRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/cached', + loader: () => 'cached', + gcTime: 10, + }) + const nextRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/next', + }) + const otherRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/other', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([cachedRoute, nextRoute, otherRoute]), + history: createMemoryHistory({ initialEntries: ['/cached'] }), + }) - expect(context).toHaveBeenCalledTimes(1) - expect(context).toHaveBeenCalledWith( - expect.objectContaining({ - routeId: '/foo', - }), - ) + await router.load() + await router.navigate({ to: '/next' }) + + expect( + router.stores.cachedMatches + .get() + .some((match) => match.routeId === cachedRoute.id), + ).toBe(true) + + vi.setSystemTime(1_011) + await router.navigate({ to: '/other' }) + + expect( + router.stores.cachedMatches + .get() + .some((match) => match.routeId === cachedRoute.id), + ).toBe(false) + } finally { + vi.useRealTimers() + } }) - test('beforeLoad and context receive correct routeId for nested route', async () => { - const parentBeforeLoad = vi.fn() - const parentContext = vi.fn() - const childBeforeLoad = vi.fn() - const childContext = vi.fn() - const rootRoute = new BaseRootRoute({}) + 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 parentRoute = new BaseRoute({ + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ getParentRoute: () => rootRoute, - path: '/parent', - beforeLoad: parentBeforeLoad, - context: parentContext, + path: '/', }) - - const childRoute = new BaseRoute({ - getParentRoute: () => parentRoute, - path: '/child', - beforeLoad: childBeforeLoad, - context: childContext, + 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 routeTree = rootRoute.addChildren([ - parentRoute.addChildren([childRoute]), - ]) const router = createTestRouter({ - routeTree, - history: createMemoryHistory(), + routeTree: rootRoute.addChildren([ + indexRoute, + slowRoute, + safeRoute, + redirectedRoute, + ]), + history: createMemoryHistory({ initialEntries: ['/'] }), }) + await router.load() - await router.navigate({ to: '/parent/child' }) + const staleNavigation = router.navigate({ to: '/slow' }) + await vi.waitFor(() => expect(slowBeforeLoadStarted).toHaveBeenCalled()) - expect(parentBeforeLoad).toHaveBeenCalledWith( - expect.objectContaining({ - routeId: '/parent', - }), - ) - expect(parentContext).toHaveBeenCalledWith( - expect.objectContaining({ - routeId: '/parent', - }), - ) - expect(childBeforeLoad).toHaveBeenCalledWith( - expect.objectContaining({ - routeId: '/parent/child', - }), - ) - expect(childContext).toHaveBeenCalledWith( - expect.objectContaining({ - routeId: '/parent/child', - }), - ) + 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.redirect).toBeUndefined() }) - test('beforeLoad and context receive correct routeId for route with dynamic params', async () => { - const beforeLoad = vi.fn() - const context = vi.fn() - const rootRoute = new BaseRootRoute({}) + test('notFound preload is evicted and does not affect a later navigation', async () => { + let calls = 0 + const loader = vi.fn(async () => { + calls++ + if (calls === 1) { + throw notFound() + } - const postRoute = new BaseRoute({ - getParentRoute: () => rootRoute, - path: '/posts/$postId', - beforeLoad, - context, + return { value: 'fresh' } }) - const routeTree = rootRoute.addChildren([postRoute]) - + 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() - await router.navigate({ to: '/posts/$postId', params: { postId: '123' } }) + await router.preloadRoute({ to: '/stale' }) + expect( + router.stores.cachedMatches + .get() + .some((match) => match.routeId === staleRoute.id), + ).toBe(false) - expect(beforeLoad).toHaveBeenCalledWith( - expect.objectContaining({ - routeId: '/posts/$postId', - }), - ) - expect(context).toHaveBeenCalledWith( - expect.objectContaining({ - routeId: '/posts/$postId', - }), - ) + await router.navigate({ to: '/stale' }) + + const match = router.state.matches.find((m) => m.routeId === staleRoute.id)! + expect(loader).toHaveBeenCalledTimes(2) + expect(match.status).toBe('success') + expect(match.loaderData).toEqual({ value: 'fresh' }) }) - test('beforeLoad and context receive correct routeId for layout route', async () => { - const beforeLoad = vi.fn() - const context = vi.fn() - const rootRoute = new BaseRootRoute({}) + test('redirecting preload is not cached and does not poison a later navigation', async () => { + let calls = 0 + const loader = vi.fn(async () => { + calls++ + if (calls === 1) { + throw redirect({ to: '/other' }) + } - const layoutRoute = new BaseRoute({ - getParentRoute: () => rootRoute, - id: '/_layout', - beforeLoad, - context, + return { value: 'fresh' } }) - const indexRoute = new BaseRoute({ - getParentRoute: () => layoutRoute, - path: '/', + const rootRoute = new BaseRootRoute({}) + const staleRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/stale', + loader, + staleTime: 0, + gcTime: 60_000, }) - - const routeTree = rootRoute.addChildren([ - layoutRoute.addChildren([indexRoute]), - ]) - + const otherRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/other', + }) + const routeTree = rootRoute.addChildren([staleRoute, otherRoute]) const router = createTestRouter({ routeTree, history: createMemoryHistory(), }) - await router.load() - expect(beforeLoad).toHaveBeenCalledWith( - expect.objectContaining({ - routeId: '/_layout', - }), - ) - expect(context).toHaveBeenCalledWith( - expect.objectContaining({ - routeId: '/_layout', - }), - ) + await router.preloadRoute({ to: '/stale' }) + expect(loader).toHaveBeenCalledTimes(1) + expect( + router.stores.cachedMatches + .get() + .some((match) => match.routeId === staleRoute.id), + ).toBe(false) + + await router.navigate({ to: '/stale' }) + + expect(router.state.location.pathname).toBe('/stale') + const match = router.state.matches.find((m) => m.routeId === staleRoute.id)! + expect(loader).toHaveBeenCalledTimes(2) + expect(match.status).toBe('success') + expect(match.loaderData).toEqual({ value: 'fresh' }) }) }) diff --git a/packages/router-devtools-core/src/AgeTicker.tsx b/packages/router-devtools-core/src/AgeTicker.tsx index 27d179fda1..6f1b386f57 100644 --- a/packages/router-devtools-core/src/AgeTicker.tsx +++ b/packages/router-devtools-core/src/AgeTicker.tsx @@ -35,7 +35,7 @@ export function AgeTicker({ return null } - const route = router().looseRoutesById[match.routeId]! + const route = router().routesById[match.routeId]! if (!route.options.loader) { return null 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/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 205e778689..2d0146c36e 100644 --- a/packages/solid-router/src/Match.tsx +++ b/packages/solid-router/src/Match.tsx @@ -1,10 +1,8 @@ import * as Solid from 'solid-js' import { - createControlledPromise, getLocationChangeInfo, invariant, isNotFound, - isRedirect, rootRouteId, } from '@tanstack/router-core' import { isServer } from '@tanstack/router-core/isServer' @@ -16,6 +14,7 @@ import { nearestMatchContext } from './matchContext' import { SafeFragment } from './SafeFragment' import { renderRouteNotFound } from './renderRouteNotFound' import { ScrollRestoration } from './scroll-restoration' +import { ClientOnly } from './ClientOnly' import type { AnyRoute, RootRouteOptions } from '@tanstack/router-core' export const Match = (props: { matchId: string }) => { @@ -165,15 +164,17 @@ export const Match = (props: { matchId: string }) => { }} > + + + - } > - + @@ -254,8 +255,7 @@ export const MatchInner = (): any => { id: currentMatch.id, status: currentMatch.status, error: currentMatch.error, - _forcePending: currentMatch._forcePending ?? false, - _displayPending: currentMatch._displayPending ?? false, + _: currentMatch._, }, } }) @@ -270,6 +270,49 @@ export const MatchInner = (): any => { const componentKey = () => currentMatchState().key ?? currentMatchState().match.id + const PendingComponent = () => + route().options.pendingComponent ?? + router.options.defaultPendingComponent + + const pendingReplacement = () => { + return router.stores.pendingMatches + .get() + .find( + (pending) => + pending.routeId === currentMatchState().routeId && + pending.status === 'pending' && + pending.id !== currentMatch().id, + ) + } + + const armPending = (pendingMatch: any) => { + const FallbackComponent = PendingComponent() + const pendingMinMs = + route().options.pendingMinMs ?? router.options.defaultPendingMinMs + const loadPromise = pendingMatch._.loadPromise + const promise = + loadPromise?.status === 'pending' + ? loadPromise + : router.latestLoadPromise + + if (process.env.NODE_ENV !== 'production' && !promise) { + throw new Error( + `Invariant failed: pending match "${pendingMatch.id}" has no loadPromise`, + ) + } + + if ( + !(isServer ?? router.isServer) && + FallbackComponent && + pendingMinMs && + loadPromise?.status === 'pending' + ) { + loadPromise.pendingUntil ??= Date.now() + pendingMinMs + } + + return FallbackComponent + } + const out = () => { const Comp = route().options.component ?? router.options.defaultComponent @@ -279,22 +322,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()} @@ -303,69 +330,41 @@ export const MatchInner = (): any => { return ( - - {(_) => { - const [displayPendingResult] = Solid.createResource( - () => - router.getMatch(currentMatch().id)?._nonReactive - .displayPendingPromise, - ) - - return <>{displayPendingResult()} - }} - - - {(_) => { - const [minPendingResult] = Solid.createResource( - () => - router.getMatch(currentMatch().id)?._nonReactive - .minPendingPromise, - ) - - return <>{minPendingResult()} + + {(pendingMatch) => { + const FallbackComponent = armPending(pendingMatch()) + return FallbackComponent ? ( + + ) : null }} {(_) => { - const pendingMinMs = - route().options.pendingMinMs ?? - router.options.defaultPendingMinMs - - if (pendingMinMs) { - const routerMatch = router.getMatch(currentMatch().id) - if ( - routerMatch && - !routerMatch._nonReactive.minPendingPromise - ) { - // Create a promise that will resolve after the minPendingMs - if (!(isServer ?? router.isServer)) { - const minPendingPromise = createControlledPromise() - - routerMatch._nonReactive.minPendingPromise = - minPendingPromise - - setTimeout(() => { - minPendingPromise.resolve() - // We've handled the minPendingPromise, so we can delete it - routerMatch._nonReactive.minPendingPromise = undefined - }, pendingMinMs) - } - } + const loadPromise = currentMatch()._.loadPromise + const promise = + loadPromise?.status === 'pending' + ? loadPromise + : router.latestLoadPromise + + if (process.env.NODE_ENV !== 'production' && !promise) { + throw new Error( + `Invariant failed: pending match "${currentMatch().id}" has no loadPromise`, + ) } + const FallbackComponent = + loadPromise?.status === 'pending' + ? armPending(currentMatch()) + : PendingComponent() + const [loaderResult] = Solid.createResource(async () => { await Promise.resolve() - return router.getMatch(currentMatch().id)?._nonReactive - .loadPromise + return promise }) - const FallbackComponent = - route().options.pendingComponent ?? - router.options.defaultPendingComponent - return ( <> - {FallbackComponent && pendingMinMs > 0 ? ( + {FallbackComponent ? ( ) : null} {loaderResult()} @@ -395,29 +394,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 +445,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/src/ssr/renderRouterToStream.tsx b/packages/solid-router/src/ssr/renderRouterToStream.tsx index 8914c117d9..01eb189dea 100644 --- a/packages/solid-router/src/ssr/renderRouterToStream.tsx +++ b/packages/solid-router/src/ssr/renderRouterToStream.tsx @@ -172,7 +172,7 @@ export const renderRouterToStream = async ({ return createSsrStreamResponse( router, new Response(responseStream as any, { - status: router.stores.statusCode.get(), + status: router.statusCode, headers: responseHeaders, }), ) diff --git a/packages/solid-router/src/ssr/renderRouterToString.tsx b/packages/solid-router/src/ssr/renderRouterToString.tsx index 7da982b392..a8745e9b5f 100644 --- a/packages/solid-router/src/ssr/renderRouterToString.tsx +++ b/packages/solid-router/src/ssr/renderRouterToString.tsx @@ -32,7 +32,7 @@ export const renderRouterToString = ({ html = html.replace(``, () => `${injectedHtml}`) } return new Response(`${html}`, { - status: router.stores.statusCode.get(), + status: router.statusCode, headers: responseHeaders, }) } catch (error) { diff --git a/packages/solid-router/src/useMatch.tsx b/packages/solid-router/src/useMatch.tsx index 858d75be6b..cd2b50a237 100644 --- a/packages/solid-router/src/useMatch.tsx +++ b/packages/solid-router/src/useMatch.tsx @@ -91,11 +91,7 @@ export function useMatch< ? Boolean(router.stores.pendingRouteIds.get()[opts.from!]) : (nearestMatch?.hasPending() ?? false) - if ( - !hasPendingMatch && - !router.stores.isTransitioning.get() && - (opts.shouldThrow ?? true) - ) { + if (!hasPendingMatch && (opts.shouldThrow ?? true)) { if (process.env.NODE_ENV !== 'production') { throw new Error( `Invariant failed: Could not find ${opts.from ? `an active match from "${opts.from}"` : 'a nearest match!'}`, @@ -114,10 +110,7 @@ export function useMatch< ? Boolean(router.stores.pendingRouteIds.get()[opts.from!]) : (nearestMatch?.hasPending() ?? false) - if ( - prev !== undefined && - (hasPendingMatch || router.stores.isTransitioning.get()) - ) { + if (prev !== undefined && hasPendingMatch) { return prev } diff --git a/packages/solid-router/tests/Matches.test.tsx b/packages/solid-router/tests/Matches.test.tsx index 2685500fd0..253a016372 100644 --- a/packages/solid-router/tests/Matches.test.tsx +++ b/packages/solid-router/tests/Matches.test.tsx @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test } from 'vitest' +import { afterEach, describe, expect, test, vi } from 'vitest' import { createSignal } from 'solid-js' import { cleanup, @@ -8,6 +8,7 @@ import { waitFor, } from '@solidjs/testing-library' import { createMemoryHistory } from '@tanstack/history' +import { createControlledPromise } from '@tanstack/router-core' import { Link, MatchRoute, @@ -154,6 +155,158 @@ test('should show pendingComponent of root route', async () => { expect(await rendered.findByTestId('root-content')).toBeInTheDocument() }) +test('pending fallback remains visible through pendingMinMs', async () => { + vi.useFakeTimers() + + try { + const root = createRootRoute({ + component: () => , + }) + const index = createRoute({ + getParentRoute: () => root, + path: '/', + component: () =>
Index
, + }) + const slow = createRoute({ + getParentRoute: () => root, + path: '/slow', + pendingMs: 0, + pendingMinMs: 100, + pendingComponent: () =>
Slow pending
, + loader: async () => { + await new Promise((resolve) => setTimeout(resolve, 10)) + }, + component: () =>
Slow content
, + }) + const router = createRouter({ + routeTree: root.addChildren([index, slow]), + history: createMemoryHistory({ initialEntries: ['/'] }), + defaultPendingMs: 0, + }) + + render(() => ) + expect(await screen.findByText('Index')).toBeInTheDocument() + + const navigation = router.navigate({ to: '/slow' }) + await vi.advanceTimersByTimeAsync(0) + expect(await screen.findByText('Slow pending')).toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(99) + expect(screen.getByText('Slow pending')).toBeInTheDocument() + expect(screen.queryByText('Slow content')).not.toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(1) + await navigation + expect(await screen.findByText('Slow content')).toBeInTheDocument() + } finally { + cleanup() + vi.useRealTimers() + } +}) + +test('pending match without fallback does not arm pendingMinMs', async () => { + vi.useFakeTimers() + + try { + const loaderGate = createControlledPromise() + const root = createRootRoute({ + component: () => , + }) + const slow = createRoute({ + getParentRoute: () => root, + path: '/slow', + pendingMinMs: 100, + loader: async () => { + await loaderGate + }, + component: () =>
Slow content
, + }) + const router = createRouter({ + routeTree: root.addChildren([slow]), + history: createMemoryHistory({ initialEntries: ['/slow'] }), + defaultPendingMs: 0, + }) + router.stores.setMatches(router.matchRoutes(router.latestLocation)) + + render(() => ) + + await vi.advanceTimersByTimeAsync(0) + + const slowMatch = router.state.matches.find( + (match) => match.routeId === slow.id, + )! + const loadPromise = slowMatch._.loadPromise + + expect(slowMatch.status).toBe('pending') + expect(loadPromise?.pendingUntil).toBeUndefined() + + loadPromise?.resolve() + loaderGate.resolve() + } finally { + cleanup() + vi.useRealTimers() + } +}) + +test('same-route pending replacement respects pendingMinMs', async () => { + const page2Gate = createControlledPromise() + const history = createMemoryHistory({ initialEntries: ['/posts?page=1'] }) + const root = createRootRoute({ + component: () => , + }) + + function Posts() { + const data = postsRoute.useLoaderData() + return
{data()}
+ } + + const postsRoute = createRoute({ + getParentRoute: () => root, + path: '/posts', + validateSearch: (search) => ({ + page: Number(search.page ?? 1), + }), + loaderDeps: ({ search }) => ({ page: search.page }), + pendingMs: 0, + pendingMinMs: 100, + pendingComponent: () =>
, + loader: async ({ deps }) => { + if (deps.page === 2) { + await page2Gate + } + + return `Page ${deps.page}` + }, + component: Posts, + }) + const router = createRouter({ + routeTree: root.addChildren([postsRoute]), + history, + defaultPendingMs: 0, + }) + + render(() => ) + expect(await screen.findByText('Page 1')).toBeInTheDocument() + + const navigation = router.navigate({ + to: '/posts', + search: { page: 2 }, + }) + + expect(await screen.findByTestId('replacement-pending')).toBeInTheDocument() + + const pendingMatch = router.stores.pendingMatches + .get() + .find((match) => match.routeId === postsRoute.id)! + + expect(pendingMatch._.loadPromise?.pendingUntil).toBeTypeOf('number') + + page2Gate.resolve() + await navigation + + expect(await screen.findByText('Page 2')).toBeInTheDocument() +}) + test('MatchRoute updates for navigation and reactive params changes', async () => { function Layout() { const [postId, setPostId] = createSignal('123') diff --git a/packages/solid-router/tests/loaders.test.tsx b/packages/solid-router/tests/loaders.test.tsx index 063660f592..4ecee0521e 100644 --- a/packages/solid-router/tests/loaders.test.tsx +++ b/packages/solid-router/tests/loaders.test.tsx @@ -342,12 +342,39 @@ test('throw abortError from loader upon initial load with basepath', async () => render(() => ) - const indexElement = await screen.findByText('Index route content') - expect(indexElement).toBeInTheDocument() - expect(screen.queryByTestId('index-error')).not.toBeInTheDocument() + const errorElement = await screen.findByTestId('index-error') + expect(errorElement).toBeInTheDocument() + expect(screen.queryByText('Index route content')).not.toBeInTheDocument() expect(window.location.pathname.startsWith('/app')).toBe(true) }) +test('aborted loader does not render the route component with undefined loaderData', async () => { + const rootRoute = createRootRoute({}) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + loader: async (): Promise<{ value: string }> => { + return Promise.reject(new DOMException('Aborted', 'AbortError')) + }, + component: () => { + const data = indexRoute.useLoaderData() + return
value: {data().value}
+ }, + errorComponent: () => ( +
indexErrorComponent
+ ), + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree }) + + render(() => ) + + expect(await screen.findByTestId('index-error')).toBeInTheDocument() + expect(screen.queryByTestId('index-content')).not.toBeInTheDocument() +}) + test('reproducer #4245', async () => { const LOADER_WAIT_TIME = 500 const rootRoute = createRootRoute({}) diff --git a/packages/solid-router/tests/redirect.test.tsx b/packages/solid-router/tests/redirect.test.tsx index 81bd1f6bc6..a9ec6f6ea4 100644 --- a/packages/solid-router/tests/redirect.test.tsx +++ b/packages/solid-router/tests/redirect.test.tsx @@ -337,9 +337,9 @@ describe('redirect', () => { await router.load() - expect(router.state.redirect).toBeDefined() - expect(router.state.redirect).toBeInstanceOf(Response) - const redirectResponse = router.state.redirect! + expect(router.redirect).toBeDefined() + expect(router.redirect).toBeInstanceOf(Response) + const redirectResponse = router.redirect! expect(redirectResponse.options).toEqual({ _fromLocation: expect.objectContaining({ @@ -388,7 +388,7 @@ describe('redirect', () => { await router.load() - const currentRedirect = router.state.redirect + const currentRedirect = router.redirect expect(currentRedirect).toBeDefined() expect(currentRedirect).toBeInstanceOf(Response) diff --git a/packages/solid-router/tests/renderRouterToStream.test.tsx b/packages/solid-router/tests/renderRouterToStream.test.tsx index ba5ee51ca0..691516a808 100644 --- a/packages/solid-router/tests/renderRouterToStream.test.tsx +++ b/packages/solid-router/tests/renderRouterToStream.test.tsx @@ -37,9 +37,9 @@ async function buildRouter() { }) const router = createRouter({ history: createMemoryHistory({ initialEntries: ['/'] }), + isServer: true, routeTree: rootRoute, }) - router.isServer = true attachRouterServerSsrUtils({ router, manifest: undefined }) await router.load() return router diff --git a/packages/solid-router/tests/route.test-d.tsx b/packages/solid-router/tests/route.test-d.tsx index f2dfcada86..adf83e39ec 100644 --- a/packages/solid-router/tests/route.test-d.tsx +++ b/packages/solid-router/tests/route.test-d.tsx @@ -9,7 +9,6 @@ import { import type { Accessor } from 'solid-js' import type { BuildLocationFn, - ControlledPromise, NavigateFn, NavigateOptions, ParsedLocation, @@ -1291,8 +1290,6 @@ test('when creating a child route with context, search, params, loader, loaderDe search: TExpectedSearch context: TExpectedContext loaderDeps: { detailPage: number; invoicePage: number } - beforeLoadPromise?: ControlledPromise - loaderPromise?: ControlledPromise componentsPromise?: Promise> loaderData?: TExpectedLoaderData } 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/router.test.tsx b/packages/solid-router/tests/router.test.tsx index 1ee017937a..8ca5833e5a 100644 --- a/packages/solid-router/tests/router.test.tsx +++ b/packages/solid-router/tests/router.test.tsx @@ -1720,48 +1720,9 @@ describe('does not strip search params if search validation fails', () => { }) }) -describe('statusCode reset on navigation', () => { - it('should reset statusCode to 200 when navigating from 404 to valid route', async () => { - const history = createMemoryHistory({ initialEntries: ['/'] }) - - const rootRoute = createRootRoute({ - component: () => , - }) - - const indexRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/', - component: () =>
Home
, - }) - - const validRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/valid', - component: () =>
Valid Route
, - }) - - const routeTree = rootRoute.addChildren([indexRoute, validRoute]) - const router = createRouter({ routeTree, history }) - - render(() => ) - - expect(router.state.statusCode).toBe(200) - - await router.navigate({ to: '/' }) - await waitFor(() => expect(router.state.statusCode).toBe(200)) - - await router.navigate({ to: '/non-existing' }) - await waitFor(() => expect(router.state.statusCode).toBe(404)) - - await router.navigate({ to: '/valid' }) - await waitFor(() => expect(router.state.statusCode).toBe(200)) - - await router.navigate({ to: '/another-non-existing' }) - await waitFor(() => expect(router.state.statusCode).toBe(404)) - }) - +describe('route error rendering', () => { describe.each([true, false])( - 'status code is set when loader/beforeLoad throws (isAsync=%s)', + 'loader/beforeLoad errors render the matching boundary (isAsync=%s)', (isAsync) => { const throwingFun = isAsync ? (toThrow: () => void) => async () => { @@ -1776,7 +1737,7 @@ describe('statusCode reset on navigation', () => { const throwError = throwingFun(() => { throw new Error('test-error') }) - it('should set statusCode to 404 when a route loader throws a notFound()', async () => { + it('renders notFound when a route loader throws a notFound()', async () => { const history = createMemoryHistory({ initialEntries: ['/'] }) const rootRoute = createRootRoute() @@ -1804,17 +1765,14 @@ describe('statusCode reset on navigation', () => { render(() => ) - expect(router.state.statusCode).toBe(200) - await router.navigate({ to: '/loader-throws-not-found' }) - await waitFor(() => expect(router.state.statusCode).toBe(404)) expect( await screen.findByTestId('not-found-component'), ).toBeInTheDocument() expect(screen.queryByTestId('route-component')).not.toBeInTheDocument() }) - it('should set statusCode to 404 when a route beforeLoad throws a notFound()', async () => { + it('renders notFound when a route beforeLoad throws a notFound()', async () => { const history = createMemoryHistory({ initialEntries: ['/'] }) const rootRoute = createRootRoute({ @@ -1849,17 +1807,14 @@ describe('statusCode reset on navigation', () => { render(() => ) - expect(router.state.statusCode).toBe(200) - await router.navigate({ to: '/beforeload-throws-not-found' }) - await waitFor(() => expect(router.state.statusCode).toBe(404)) expect( await screen.findByTestId('not-found-component'), ).toBeInTheDocument() expect(screen.queryByTestId('route-component')).not.toBeInTheDocument() }) - it('should set statusCode to 500 when a route loader throws an Error', async () => { + it('renders error when a route loader throws an Error', async () => { const history = createMemoryHistory({ initialEntries: ['/'] }) const rootRoute = createRootRoute() @@ -1885,15 +1840,12 @@ describe('statusCode reset on navigation', () => { render(() => ) - expect(router.state.statusCode).toBe(200) - await router.navigate({ to: '/loader-throws-error' }) - await waitFor(() => expect(router.state.statusCode).toBe(500)) expect(await screen.findByTestId('error-component')).toBeInTheDocument() expect(screen.queryByTestId('route-component')).not.toBeInTheDocument() }) - it('should set statusCode to 500 when a route beforeLoad throws an Error', async () => { + it('renders error when a route beforeLoad throws an Error', async () => { const history = createMemoryHistory({ initialEntries: ['/'] }) const rootRoute = createRootRoute() @@ -1922,10 +1874,7 @@ describe('statusCode reset on navigation', () => { render(() => ) - expect(router.state.statusCode).toBe(200) - await router.navigate({ to: '/beforeload-throws-error' }) - await waitFor(() => expect(router.state.statusCode).toBe(500)) expect(await screen.findByTestId('error-component')).toBeInTheDocument() expect(screen.queryByTestId('route-component')).not.toBeInTheDocument() }) @@ -2057,7 +2006,6 @@ describe('basepath', () => { }) expect(router.state.location.pathname).toBe('/') - expect(router.state.statusCode).toBe(200) }, ) diff --git a/packages/solid-router/tests/server/errorComponent.test.tsx b/packages/solid-router/tests/server/errorComponent.test.tsx index f342aaae0d..f8851f0aee 100644 --- a/packages/solid-router/tests/server/errorComponent.test.tsx +++ b/packages/solid-router/tests/server/errorComponent.test.tsx @@ -39,7 +39,7 @@ describe('errorComponent (server)', () => { )) - expect(router.state.statusCode).toBe(500) + expect(router.statusCode).toBe(500) expect(html).toContain('data-testid="error-component"') expect(html).toContain('loader boom') expect(html).not.toContain('Index route') 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..c25b933586 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(4) }) 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 () => { @@ -172,7 +177,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: Solid has different update counts than React due to different reactivity - expect(updates).toBe(5) + expect(updates).toBe(4) }) test('nothing', 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/start-server-core/src/createStartHandler.ts b/packages/start-server-core/src/createStartHandler.ts index 90d4e706f9..bbca84916e 100644 --- a/packages/start-server-core/src/createStartHandler.ts +++ b/packages/start-server-core/src/createStartHandler.ts @@ -589,8 +589,8 @@ export function createStartHandler( routerInstance.options.additionalContext = { serverContext } await routerInstance.load() - if (routerInstance.state.redirect) { - return normalizeSsrResponse(routerInstance.state.redirect) + if (routerInstance.redirect) { + return normalizeSsrResponse(routerInstance.redirect) } earlyHints?.collectDynamic(routerInstance.stores.matches.get()) diff --git a/packages/vue-router/src/Match.tsx b/packages/vue-router/src/Match.tsx index 44e9aca0cd..4e39e77575 100644 --- a/packages/vue-router/src/Match.tsx +++ b/packages/vue-router/src/Match.tsx @@ -1,10 +1,8 @@ import * as Vue from 'vue' import { - createControlledPromise, getLocationChangeInfo, invariant, isNotFound, - isRedirect, rootRouteId, } from '@tanstack/router-core' import { isServer } from '@tanstack/router-core/isServer' @@ -93,9 +91,8 @@ export const Match = Vue.defineComponent({ router?.options?.defaultPendingComponent, ) - const pendingElement = Vue.computed(() => - PendingComponent.value ? Vue.h(PendingComponent.value) : undefined, - ) + const pendingElement = () => + PendingComponent.value ? Vue.h(PendingComponent.value) : undefined const routeErrorComponent = Vue.computed( () => @@ -148,19 +145,19 @@ export const Match = Vue.defineComponent({ resolvedNoSsr || !!matchData.value?._displayPending const renderMatchContent = (): VNode => { - const matchInner = Vue.h(MatchInner, { matchId: actualMatchId }) + const matchInner = () => Vue.h(MatchInner, { matchId: actualMatchId }) let content: VNode = shouldClientOnly ? Vue.h( ClientOnly, { - fallback: pendingElement.value, + fallback: pendingElement(), }, { - default: () => matchInner, + default: matchInner, }, ) - : matchInner + : matchInner() // Wrap in NotFound boundary if needed if (routeNotFoundComponent.value) { @@ -333,9 +330,7 @@ export const MatchInner = Vue.defineComponent({ status: match.status, error: match.error, ssr: match.ssr, - _forcePending: match._forcePending, - _displayPending: match._displayPending, - _nonReactive: match._nonReactive, + _: match._, }, remountKey, } @@ -349,43 +344,10 @@ 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 - // Handle different match statuses - if (match.value._displayPending) { - const PendingComponent = - route.value.options.pendingComponent ?? - router.options.defaultPendingComponent - - return PendingComponent ? Vue.h(PendingComponent) : null - } - - if (match.value._forcePending) { - const PendingComponent = - route.value.options.pendingComponent ?? - router.options.defaultPendingComponent - - return PendingComponent ? Vue.h(PendingComponent) : null - } - if (match.value.status === 'notFound') { if (!isNotFound(match.value.error)) { if (process.env.NODE_ENV !== 'production') { @@ -397,17 +359,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 = @@ -434,29 +385,6 @@ export const MatchInner = Vue.defineComponent({ } if (match.value.status === 'pending') { - const pendingMinMs = - route.value.options.pendingMinMs ?? router.options.defaultPendingMinMs - - const routerMatch = router.getMatch(match.value.id) - if ( - pendingMinMs && - routerMatch && - !routerMatch._nonReactive.minPendingPromise - ) { - // Create a promise that will resolve after the minPendingMs - if (!(isServer ?? router.isServer)) { - const minPendingPromise = createControlledPromise() - - routerMatch._nonReactive.minPendingPromise = minPendingPromise - - setTimeout(() => { - minPendingPromise.resolve() - // We've handled the minPendingPromise, so we can delete it - routerMatch._nonReactive.minPendingPromise = undefined - }, pendingMinMs) - } - } - // In Vue, we render the pending component directly instead of throwing a promise // because Vue's Suspense doesn't catch thrown promises like React does const PendingComponent = @@ -464,6 +392,19 @@ export const MatchInner = Vue.defineComponent({ router.options.defaultPendingComponent if (PendingComponent) { + const promise = Vue.toRaw(match.value._).loadPromise + const pendingMinMs = + route.value.options.pendingMinMs ?? + router.options.defaultPendingMinMs + + if (process.env.NODE_ENV !== 'production' && !promise) { + invariant() + } + + if (promise && !(isServer ?? router.isServer) && pendingMinMs) { + promise.pendingUntil ??= Date.now() + pendingMinMs + } + return Vue.h(PendingComponent) } diff --git a/packages/vue-router/src/Transitioner.tsx b/packages/vue-router/src/Transitioner.tsx index b0133d965c..b3869f0e70 100644 --- a/packages/vue-router/src/Transitioner.tsx +++ b/packages/vue-router/src/Transitioner.tsx @@ -46,45 +46,28 @@ export function useTransitionerSetup() { // Implement startTransition similar to React/Solid // Vue doesn't have a native useTransition like React 18, so we simulate it - // We also update the router state's isTransitioning flag so useMatch can check it - router.startTransition = (fn: () => void | Promise) => { + router.startTransition = (fn: () => void) => { isTransitioning.value = true - // Also update the router state so useMatch knows we're transitioning try { - router.stores.isTransitioning.set(true) - } catch { - // Ignore errors if component is unmounted - } - - // Helper to end the transition - const endTransition = () => { + fn() + } finally { // Use nextTick to ensure Vue has processed all reactive updates - Vue.nextTick(() => { + void Vue.nextTick(() => { try { isTransitioning.value = false - router.stores.isTransitioning.set(false) } catch { // Ignore errors if component is unmounted } }) } - - // Execute the function synchronously - // The function internally may call startViewTransition which schedules async work - // via document.startViewTransition, but we don't need to wait for it here - // because Vue's reactivity will trigger re-renders when state changes - fn() - - // End the transition on next tick to allow Vue to process reactive updates - endTransition() } // 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 @@ -200,6 +183,12 @@ export function useTransitionerSetup() { router.stores.resolvedLocation.get(), ), }) + if (router.stores.status.get() === 'pending') { + batch(() => { + router.stores.status.set('idle') + router.stores.resolvedLocation.set(router.stores.location.get()) + }) + } } } catch { // Ignore errors if component is unmounted diff --git a/packages/vue-router/src/ssr/renderRouterToStream.tsx b/packages/vue-router/src/ssr/renderRouterToStream.tsx index efad4fc2eb..698695e697 100644 --- a/packages/vue-router/src/ssr/renderRouterToStream.tsx +++ b/packages/vue-router/src/ssr/renderRouterToStream.tsx @@ -112,7 +112,7 @@ export const renderRouterToStream = async ({ } return new Response(`${fullHtml}`, { - status: router.stores.statusCode.get(), + status: router.statusCode, headers: responseHeaders, }) } finally { @@ -217,7 +217,7 @@ export const renderRouterToStream = async ({ return createSsrStreamResponse( router, new Response(responseStream as any, { - status: router.stores.statusCode.get(), + status: router.statusCode, headers: responseHeaders, }), ) diff --git a/packages/vue-router/src/ssr/renderRouterToString.tsx b/packages/vue-router/src/ssr/renderRouterToString.tsx index b551dc5dfc..56ae7ce089 100644 --- a/packages/vue-router/src/ssr/renderRouterToString.tsx +++ b/packages/vue-router/src/ssr/renderRouterToString.tsx @@ -24,7 +24,7 @@ export const renderRouterToString = async ({ } return new Response(`${html}`, { - status: router.stores.statusCode.get(), + status: router.statusCode, headers: responseHeaders, }) } catch (error) { diff --git a/packages/vue-router/src/useMatch.tsx b/packages/vue-router/src/useMatch.tsx index 29047e5d48..f039eff944 100644 --- a/packages/vue-router/src/useMatch.tsx +++ b/packages/vue-router/src/useMatch.tsx @@ -144,11 +144,6 @@ export function useMatch< const hasPendingRouteMatch = opts.from ? useStore(router.stores.pendingRouteIds, (ids) => ids) : undefined - const isTransitioning = useStore( - router.stores.isTransitioning, - (value) => value, - { equal: Object.is }, - ) const result = Vue.computed(() => { const selectedMatch = match.value @@ -156,11 +151,7 @@ export function useMatch< const hasPendingMatch = opts.from ? Boolean(hasPendingRouteMatch?.value[opts.from!]) : hasPendingNearestMatch.value - if ( - !hasPendingMatch && - !isTransitioning.value && - (opts.shouldThrow ?? true) - ) { + if (!hasPendingMatch && (opts.shouldThrow ?? true)) { if (process.env.NODE_ENV !== 'production') { throw new Error( `Invariant failed: Could not find ${opts.from ? `an active match from "${opts.from}"` : 'a nearest match!'}`, diff --git a/packages/vue-router/tests/Matches.test.tsx b/packages/vue-router/tests/Matches.test.tsx index 4e9e9afa51..7e1b0cdb00 100644 --- a/packages/vue-router/tests/Matches.test.tsx +++ b/packages/vue-router/tests/Matches.test.tsx @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test } from 'vitest' +import { afterEach, describe, expect, test, vi } from 'vitest' import { cleanup, fireEvent, @@ -7,6 +7,7 @@ import { waitFor, } from '@testing-library/vue' import { createMemoryHistory } from '@tanstack/history' +import { createControlledPromise } from '@tanstack/router-core' import { Link, Outlet, @@ -158,6 +159,103 @@ test('should show pendingComponent of root route', async () => { expect(await rendered.findByTestId('root-content')).toBeInTheDocument() }) +test('pending fallback remains visible through pendingMinMs', async () => { + vi.useFakeTimers() + + try { + const root = createRootRoute({ + component: () => , + }) + const index = createRoute({ + getParentRoute: () => root, + path: '/', + component: () =>
Index
, + }) + const slow = createRoute({ + getParentRoute: () => root, + path: '/slow', + pendingMs: 0, + pendingMinMs: 100, + pendingComponent: () =>
Slow pending
, + loader: async () => { + await new Promise((resolve) => setTimeout(resolve, 10)) + }, + component: () =>
Slow content
, + }) + const router = createRouter({ + routeTree: root.addChildren([index, slow]), + history: createMemoryHistory({ initialEntries: ['/'] }), + defaultPendingMs: 0, + }) + + render() + expect(await screen.findByText('Index')).toBeInTheDocument() + + const navigation = router.navigate({ to: '/slow' }) + await vi.advanceTimersByTimeAsync(0) + expect(await screen.findByText('Slow pending')).toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(99) + expect(screen.getByText('Slow pending')).toBeInTheDocument() + expect(screen.queryByText('Slow content')).not.toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(1) + await navigation + expect(await screen.findByText('Slow content')).toBeInTheDocument() + } finally { + cleanup() + vi.useRealTimers() + } +}) + +test('pending match without fallback does not arm pendingMinMs', async () => { + vi.useFakeTimers() + + try { + const loaderGate = createControlledPromise() + const root = createRootRoute({ + component: () => , + }) + const slow = createRoute({ + getParentRoute: () => root, + path: '/slow', + pendingMinMs: 100, + loader: async () => { + await loaderGate + }, + component: () =>
Slow content
, + }) + const router = createRouter({ + routeTree: root.addChildren([slow]), + history: createMemoryHistory({ initialEntries: ['/slow'] }), + defaultPendingMs: 0, + }) + router.stores.setMatches(router.matchRoutes(router.latestLocation)) + + render() + + await vi.advanceTimersByTimeAsync(0) + + const slowMatch = router.state.matches.find( + (match) => match.routeId === slow.id, + )! + const loadPromise = slowMatch._.loadPromise + + expect(slowMatch.status).toBe('pending') + expect(loadPromise?.pendingUntil).toBeUndefined() + + loaderGate.resolve() + + await vi.advanceTimersByTimeAsync(0) + await router.latestLoadPromise + + expect(screen.getByText('Slow content')).toBeInTheDocument() + } finally { + cleanup() + vi.useRealTimers() + } +}) + describe('matching on different param types', () => { const testCases = [ { diff --git a/packages/vue-router/tests/link.test.tsx b/packages/vue-router/tests/link.test.tsx index e047fe5993..d8988a394a 100644 --- a/packages/vue-router/tests/link.test.tsx +++ b/packages/vue-router/tests/link.test.tsx @@ -1372,8 +1372,10 @@ describe('Link', () => { expect(window.location.pathname).toBe('/posts') expect(window.location.search).toBe('?page=2&filter=inactive') - expect(updatedPage).toHaveTextContent('Page: 2') - expect(updatedFilter).toHaveTextContent('Filter: inactive') + await waitFor(() => { + expect(updatedPage).toHaveTextContent('Page: 2') + expect(updatedFilter).toHaveTextContent('Filter: inactive') + }) }) test('when navigation to . from /posts while updating search from / and using base path', async () => { @@ -1493,8 +1495,10 @@ describe('Link', () => { const updatedPage = await screen.findByTestId('current-page') const updatedFilter = await screen.findByTestId('current-filter') - expect(updatedPage).toHaveTextContent('Page: 2') - expect(updatedFilter).toHaveTextContent('Filter: inactive') + await waitFor(() => { + expect(updatedPage).toHaveTextContent('Page: 2') + expect(updatedFilter).toHaveTextContent('Filter: inactive') + }) }) test('when navigating to /posts with invalid search', async () => { @@ -4153,6 +4157,7 @@ describe('Link', () => { const router = createRouter({ routeTree, defaultPreload: 'intent', + history, }) render() @@ -4454,6 +4459,7 @@ describe('Link', () => { const router = createRouter({ routeTree, defaultPreload: 'intent', + history, }) render() @@ -4557,6 +4563,7 @@ describe('Link', () => { const router = createRouter({ routeTree, defaultPreload: 'intent', + history, }) render() diff --git a/packages/vue-router/tests/loaders.test.tsx b/packages/vue-router/tests/loaders.test.tsx index ae267ac2ae..d69ff1022b 100644 --- a/packages/vue-router/tests/loaders.test.tsx +++ b/packages/vue-router/tests/loaders.test.tsx @@ -1,4 +1,10 @@ -import { cleanup, fireEvent, render, screen } from '@testing-library/vue' +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/vue' import { afterEach, describe, expect, test, vi } from 'vitest' @@ -276,6 +282,33 @@ test('throw error from loader upon initial load', async () => { expect(errorElement).toBeInTheDocument() }) +test('aborted loader does not render the route component with undefined loaderData', async () => { + const rootRoute = createRootRoute({}) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + loader: async (): Promise<{ value: string }> => { + return Promise.reject(new DOMException('Aborted', 'AbortError')) + }, + component: () => { + const data = indexRoute.useLoaderData() + return
value: {data.value.value}
+ }, + errorComponent: () => ( +
indexErrorComponent
+ ), + }) + + const routeTree = rootRoute.addChildren([indexRoute]) + const router = createRouter({ routeTree }) + + render() + + expect(await screen.findByTestId('index-error')).toBeInTheDocument() + expect(screen.queryByTestId('index-content')).not.toBeInTheDocument() +}) + test('throw error from beforeLoad when navigating to route', async () => { const rootRoute = createRootRoute({}) @@ -600,8 +633,9 @@ test('reproducer #4546', async () => { const routeContext = await screen.findByTestId('index-route-context') expect(routeContext).toHaveTextContent('3') - const loaderData = await screen.findByTestId('index-loader-data') - expect(loaderData).toHaveTextContent('3') + await waitFor(() => + expect(screen.getByTestId('index-loader-data')).toHaveTextContent('3'), + ) } fireEvent.click(invalidateRouterButton) @@ -615,8 +649,9 @@ test('reproducer #4546', async () => { const routeContext = await screen.findByTestId('index-route-context') expect(routeContext).toHaveTextContent('4') - const loaderData = await screen.findByTestId('index-loader-data') - expect(loaderData).toHaveTextContent('4') + await waitFor(() => + expect(screen.getByTestId('index-loader-data')).toHaveTextContent('4'), + ) } fireEvent.click(idLink) @@ -630,8 +665,9 @@ test('reproducer #4546', async () => { const routeContext = await screen.findByTestId('id-route-context') expect(routeContext).toHaveTextContent('5') - const loaderData = await screen.findByTestId('id-loader-data') - expect(loaderData).toHaveTextContent('5') + await waitFor(() => + expect(screen.getByTestId('id-loader-data')).toHaveTextContent('5'), + ) } }) diff --git a/packages/vue-router/tests/redirect.test.tsx b/packages/vue-router/tests/redirect.test.tsx index b7ba2e1af3..60286980b9 100644 --- a/packages/vue-router/tests/redirect.test.tsx +++ b/packages/vue-router/tests/redirect.test.tsx @@ -134,9 +134,14 @@ describe('redirect', () => { // 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( + await screen.findByTestId('lazy-route-page', undefined, { + timeout: 5_000, + }), + ).toBeInTheDocument() expect(screen.queryByTestId('pending')).not.toBeInTheDocument() expect(router.state.location.href).toBe('/posts') + expect(router.state.status).toBe('idle') expect(consoleError).not.toHaveBeenCalled() }) @@ -326,9 +331,9 @@ describe('redirect', () => { await router.load() - expect(router.state.redirect).toBeDefined() - expect(router.state.redirect).toBeInstanceOf(Response) - const redirectResponse = router.state.redirect! + expect(router.redirect).toBeDefined() + expect(router.redirect).toBeInstanceOf(Response) + const redirectResponse = router.redirect! expect(redirectResponse.options).toEqual({ _fromLocation: expect.objectContaining({ @@ -376,7 +381,7 @@ describe('redirect', () => { await router.load() - const currentRedirect = router.state.redirect + const currentRedirect = router.redirect expect(currentRedirect).toBeDefined() expect(currentRedirect).toBeInstanceOf(Response) diff --git a/packages/vue-router/tests/renderRouterToStream.test.tsx b/packages/vue-router/tests/renderRouterToStream.test.tsx index 527bfa0e03..fd904ff21b 100644 --- a/packages/vue-router/tests/renderRouterToStream.test.tsx +++ b/packages/vue-router/tests/renderRouterToStream.test.tsx @@ -43,9 +43,9 @@ async function buildRouter() { }) const router = createRouter({ history: createMemoryHistory({ initialEntries: ['/'] }), + isServer: true, routeTree: rootRoute, }) - router.isServer = true attachRouterServerSsrUtils({ router, manifest: undefined }) await router.load() return router diff --git a/packages/vue-router/tests/route.test-d.tsx b/packages/vue-router/tests/route.test-d.tsx index 5e32017413..b3b84d5193 100644 --- a/packages/vue-router/tests/route.test-d.tsx +++ b/packages/vue-router/tests/route.test-d.tsx @@ -9,7 +9,6 @@ import { import type * as Vue from 'vue' import type { BuildLocationFn, - ControlledPromise, NavigateFn, NavigateOptions, ParsedLocation, @@ -1285,8 +1284,6 @@ test('when creating a child route with context, search, params, loader, loaderDe search: TExpectedSearch context: TExpectedContext loaderDeps: { detailPage: number; invoicePage: number } - beforeLoadPromise?: ControlledPromise - loaderPromise?: ControlledPromise componentsPromise?: Promise> loaderData?: TExpectedLoaderData } diff --git a/packages/vue-router/tests/router.test.tsx b/packages/vue-router/tests/router.test.tsx index 44a7acd43c..6521d9fa13 100644 --- a/packages/vue-router/tests/router.test.tsx +++ b/packages/vue-router/tests/router.test.tsx @@ -1724,219 +1724,6 @@ describe('does not strip search params if search validation fails', () => { }) }) -describe('statusCode reset on navigation', () => { - it('should reset statusCode to 200 when navigating from 404 to valid route', async () => { - const history = createMemoryHistory({ initialEntries: ['/'] }) - - const rootRoute = createRootRoute({ - component: () => , - }) - - const indexRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/', - component: () =>
Home
, - }) - - const validRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/valid', - component: () =>
Valid Route
, - }) - - const routeTree = rootRoute.addChildren([indexRoute, validRoute]) - const router = createRouter({ routeTree, history }) - - render() - - expect(router.state.statusCode).toBe(200) - - await router.navigate({ to: '/' }) - await waitFor(() => expect(router.state.statusCode).toBe(200)) - - await router.navigate({ to: '/non-existing' }) - await waitFor(() => expect(router.state.statusCode).toBe(404)) - - await router.navigate({ to: '/valid' }) - await waitFor(() => expect(router.state.statusCode).toBe(200)) - - await router.navigate({ to: '/another-non-existing' }) - await waitFor(() => expect(router.state.statusCode).toBe(404)) - }) - - describe.each([true, false])( - 'status code is set when loader/beforeLoad throws (isAsync=%s)', - (isAsync) => { - const throwingFun = isAsync - ? (toThrow: () => void) => async () => { - await new Promise((resolve) => setTimeout(resolve, 10)) - toThrow() - } - : (toThrow: () => void) => toThrow - - const throwNotFound = throwingFun(() => { - throw notFound() - }) - const throwError = throwingFun(() => { - throw new Error('test-error') - }) - it('should set statusCode to 404 when a route loader throws a notFound()', async () => { - const history = createMemoryHistory({ initialEntries: ['/'] }) - - const rootRoute = createRootRoute() - - const indexRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/', - component: () =>
Home
, - }) - - const loaderThrowsRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/loader-throws-not-found', - loader: throwNotFound, - component: () => ( -
loader will throw
- ), - notFoundComponent: () => ( -
Not Found
- ), - }) - - const routeTree = rootRoute.addChildren([indexRoute, loaderThrowsRoute]) - const router = createRouter({ routeTree, history }) - - render() - - expect(router.state.statusCode).toBe(200) - - await router.navigate({ to: '/loader-throws-not-found' }) - await waitFor(() => expect(router.state.statusCode).toBe(404)) - expect( - await screen.findByTestId('not-found-component'), - ).toBeInTheDocument() - expect(screen.queryByTestId('route-component')).not.toBeInTheDocument() - }) - - it('should set statusCode to 404 when a route beforeLoad throws a notFound()', async () => { - const history = createMemoryHistory({ initialEntries: ['/'] }) - - const rootRoute = createRootRoute({ - notFoundComponent: () => ( -
Not Found
- ), - }) - - const indexRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/', - component: () =>
Home
, - }) - - const beforeLoadThrowsRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/beforeload-throws-not-found', - beforeLoad: throwNotFound, - component: () => ( -
beforeLoad will throw
- ), - notFoundComponent: () => ( -
Not Found
- ), - }) - - const routeTree = rootRoute.addChildren([ - indexRoute, - beforeLoadThrowsRoute, - ]) - const router = createRouter({ routeTree, history }) - - render() - - expect(router.state.statusCode).toBe(200) - - await router.navigate({ to: '/beforeload-throws-not-found' }) - await waitFor(() => expect(router.state.statusCode).toBe(404)) - expect( - await screen.findByTestId('not-found-component'), - ).toBeInTheDocument() - expect(screen.queryByTestId('route-component')).not.toBeInTheDocument() - }) - - it('should set statusCode to 500 when a route loader throws an Error', async () => { - const history = createMemoryHistory({ initialEntries: ['/'] }) - - const rootRoute = createRootRoute() - - const indexRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/', - component: () =>
Home
, - }) - - const loaderThrowsRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/loader-throws-error', - loader: throwError, - component: () => ( -
loader will throw
- ), - errorComponent: () =>
Error
, - }) - - const routeTree = rootRoute.addChildren([indexRoute, loaderThrowsRoute]) - const router = createRouter({ routeTree, history }) - - render() - - expect(router.state.statusCode).toBe(200) - - await router.navigate({ to: '/loader-throws-error' }) - await waitFor(() => expect(router.state.statusCode).toBe(500)) - expect(await screen.findByTestId('error-component')).toBeInTheDocument() - expect(screen.queryByTestId('route-component')).not.toBeInTheDocument() - }) - - it('should set statusCode to 500 when a route beforeLoad throws an Error', async () => { - const history = createMemoryHistory({ initialEntries: ['/'] }) - - const rootRoute = createRootRoute() - - const indexRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/', - component: () =>
Home
, - }) - - const beforeLoadThrowsRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/beforeload-throws-error', - beforeLoad: throwError, - component: () => ( -
beforeLoad will throw
- ), - errorComponent: () =>
Error
, - }) - - const routeTree = rootRoute.addChildren([ - indexRoute, - beforeLoadThrowsRoute, - ]) - const router = createRouter({ routeTree, history }) - - render() - - expect(router.state.statusCode).toBe(200) - - await router.navigate({ to: '/beforeload-throws-error' }) - await waitFor(() => expect(router.state.statusCode).toBe(500)) - expect(await screen.findByTestId('error-component')).toBeInTheDocument() - expect(screen.queryByTestId('route-component')).not.toBeInTheDocument() - }) - }, - ) -}) - describe('basepath', () => { it('should handle basic basepath rewriting with input', async () => { const rootRoute = createRootRoute({ @@ -2061,7 +1848,6 @@ describe('basepath', () => { }) expect(router.state.location.pathname).toBe('/') - expect(router.state.statusCode).toBe(200) }, ) 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..42a93972c2 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(4) }) test('redirection in preload', async () => { @@ -148,6 +148,7 @@ describe("Store doesn't update *too many* times during navigation", () => { }, }) + await waitFor(() => expect(router.stores.status.get()).toBe('idle')) const before = select.mock.calls.length await router.preloadRoute({ to: '/posts' }) const after = select.mock.calls.length @@ -157,7 +158,12 @@ 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(0) + expect( + router.stores.cachedMatches + .get() + .some((match) => match.pathname === '/posts'), + ).toBe(false) }) test('sync beforeLoad', async () => { @@ -174,7 +180,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(4) }) test('nothing', async () => { @@ -186,7 +192,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(6) + expect(updates).toBe(3) }) test('not found in beforeLoad', async () => { @@ -202,7 +208,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(3) }) test('hover preload, then navigate, w/ async loaders', async () => { @@ -229,7 +235,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(3) }) test('navigate, w/ preloaded & async loaders', async () => { @@ -246,7 +252,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(3) }) test('navigate, w/ preloaded & sync loaders', async () => { @@ -263,7 +269,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(6) + expect(updates).toBe(3) }) test('navigate, w/ previous navigation & async loader', async () => { @@ -280,7 +286,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(6) + expect(updates).toBe(3) }) test('preload a preloaded route w/ async loader', async () => { @@ -299,6 +305,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) }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a7c3a7348..a9c9b8077c 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': @@ -2524,6 +2589,49 @@ 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-start/issue-6221: + dependencies: + '@tanstack/react-router': + specifier: workspace:* + version: link:../../../packages/react-router + '@tanstack/react-start': + specifier: workspace:* + version: link:../../../packages/react-start + 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/node': + specifier: 25.0.9 + version: 25.0.9 + '@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)) + srvx: + specifier: ^0.11.9 + version: 0.11.15 + typescript: + specifier: ^6.0.2 + version: 6.0.2 + 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-start/query-integration: dependencies: '@tanstack/react-query': 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 |