diff --git a/benchmarks/memory/client/scenarios/interrupted-navigations/shared.ts b/benchmarks/memory/client/scenarios/interrupted-navigations/shared.ts index f7f3a7cd8b..80f234f0c8 100644 --- a/benchmarks/memory/client/scenarios/interrupted-navigations/shared.ts +++ b/benchmarks/memory/client/scenarios/interrupted-navigations/shared.ts @@ -35,7 +35,6 @@ type RenderedEvent = { } type InterruptedNavigationRouter = { - latestLoadPromise: Promise | undefined load: () => Promise navigate: (options: { to: '/fast/$id' | '/slow/$id' @@ -104,18 +103,6 @@ function assertSlowNavigationSettlement(settlement: NavigationSettlement) { ) } -async function awaitExpectedLoadSettlement(loadPromise: Promise) { - try { - await loadPromise - } catch (reason) { - if (reasonHasAbortShape(reason) || reasonHasCancellationShape(reason)) { - return - } - - throw reason - } -} - function reasonHasAbortShape(reason: unknown) { return reason instanceof DOMException && reason.name === 'AbortError' } @@ -144,7 +131,6 @@ export function createWorkload( let navigateFast: (id: string) => Promise = uninitialized let startSlowNavigation: (id: string) => Promise = uninitializedSettlement - let getLatestLoadPromise: () => Promise | undefined = () => undefined function assertRenderedPage(page: 'shell' | 'fast', id?: string) { const element = container?.querySelector('[data-bench-page]') @@ -203,7 +189,6 @@ export function createWorkload( const mounted = mountTestApp(container) const router = mounted.router as InterruptedNavigationRouter unmount = mounted.unmount - getLatestLoadPromise = () => router.latestLoadPromise unsub = router.subscribe('onRendered', (event) => { if ( @@ -265,7 +250,6 @@ export function createWorkload( expectedRenderedPath = undefined navigateFast = uninitialized startSlowNavigation = uninitializedSettlement - getLatestLoadPromise = () => undefined } async function interrupt( @@ -276,12 +260,6 @@ export function createWorkload( const slowNavigation = startSlowNavigation(slowId) await waitForSlowLoader(slowId) - const slowLoadPromise = getLatestLoadPromise() - - if (!slowLoadPromise) { - throw new Error(`Slow navigation did not start a load for id: ${slowId}`) - } - await navigateFast(fastId) resolveSlowLoader(slowId) @@ -291,7 +269,6 @@ export function createWorkload( assertSlowNavigationSettlement(settlement) } - await awaitExpectedLoadSettlement(slowLoadPromise) await drainMicrotasks() } diff --git a/docs/router/guide/preloading.md b/docs/router/guide/preloading.md index 453294ab6e..092a869685 100644 --- a/docs/router/guide/preloading.md +++ b/docs/router/guide/preloading.md @@ -130,6 +130,8 @@ export const Route = createFileRoute('/posts/$postId')({ }) ``` +Client-side preloading also runs each new route's `beforeLoad` with `preload: true`. When a completed successful preload is still fresh under the built-in freshness and invalidation policy used for preloaded loader data, the client router can reuse the context returned by that invocation instead of calling `beforeLoad` again with `preload: false`. Reuse follows route match identity, including `loaderDeps`, and requires a reusable parent context. The `shouldReload` option remains loader-only. Pending, failed, invalidated, or stale preloads do not donate their `beforeLoad` context to a client navigation. + ## Preloading with External Libraries When integrating external caching libraries like React Query, which have their own mechanisms for determining stale data, you may want to override the default preloading and stale-while-revalidate logic of TanStack Router. These libraries often use options like staleTime to control the freshness of data. 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..3220a4dbb4 --- /dev/null +++ b/e2e/react-router/issue-7457/package.json @@ -0,0 +1,28 @@ +{ + "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-query": "^5.99.0", + "@tanstack/react-router": "workspace:^", + "@tanstack/router-plugin": "workspace:^", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@playwright/test": "^1.61.0", + "@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..61a995a815 --- /dev/null +++ b/e2e/react-router/issue-7457/playwright.config.ts @@ -0,0 +1,25 @@ +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 dev:e2e --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..70bf4bfacb --- /dev/null +++ b/e2e/react-router/issue-7457/src/main.tsx @@ -0,0 +1,30 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { QueryClient } from '@tanstack/react-query' +import { RouterProvider, createRouter } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' + +const queryClient = new QueryClient() +const router = createRouter({ + routeTree, + context: { queryClient }, + 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..988ba76263 --- /dev/null +++ b/e2e/react-router/issue-7457/src/routes/__root.tsx @@ -0,0 +1,25 @@ +import { Outlet, createRootRouteWithContext } from '@tanstack/react-router' +import type { QueryClient } from '@tanstack/react-query' + +export const Route = createRootRouteWithContext<{ + queryClient: QueryClient +}>()({ + beforeLoad: async ({ context }) => { + await context.queryClient.ensureQueryData({ + queryKey: ['issue-7457-root'], + queryFn: async () => { + await new Promise((resolve) => setTimeout(resolve, 1_500)) + return true + }, + }) + }, + 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..6b6caa55b3 --- /dev/null +++ b/e2e/react-router/issue-7457/src/routes/another.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/another')({ + component: () =>
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..daaae2aeed --- /dev/null +++ b/e2e/react-router/issue-7457/src/routes/index.tsx @@ -0,0 +1,8 @@ +import { createFileRoute, redirect } from '@tanstack/react-router' + +export const Route = createFileRoute('/')({ + beforeLoad: () => { + throw redirect({ to: '/another', replace: true }) + }, + component: () =>
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..8b20fac1eb --- /dev/null +++ b/e2e/react-router/issue-7457/tests/issue-7457.repro.spec.ts @@ -0,0 +1,35 @@ +import { expect, test } from '@playwright/test' + +test('#7457: initial child redirect after async root beforeLoad does not blank', 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$/) + const target = page.getByText('Hello "/another"!') + await expect + .poll(async () => ({ + targetCount: await target.count(), + pageErrors: [...pageErrors], + consoleErrors: [...consoleErrors], + })) + .toEqual({ targetCount: 1, pageErrors: [], consoleErrors: [] }) + await expect(target).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/basic/src/routes/posts.tsx b/e2e/react-start/basic/src/routes/posts.tsx index 0f69c18341..22fb7e60a8 100644 --- a/e2e/react-start/basic/src/routes/posts.tsx +++ b/e2e/react-start/basic/src/routes/posts.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react' import { Link, Outlet, createFileRoute } from '@tanstack/react-router' import { fetchPosts } from '~/utils/posts' @@ -16,9 +17,16 @@ export const Route = createFileRoute('/posts')({ function PostsComponent() { const posts = Route.useLoaderData() + const [hydrationCount, setHydrationCount] = useState(0) return (
+
    {[...posts, { id: 'i-do-not-exist', title: 'Non-existent Post' }].map( (post) => { diff --git a/e2e/react-start/basic/src/routes/specialChars/search.tsx b/e2e/react-start/basic/src/routes/specialChars/search.tsx index 9e4b42e888..5840d425ab 100644 --- a/e2e/react-start/basic/src/routes/specialChars/search.tsx +++ b/e2e/react-start/basic/src/routes/specialChars/search.tsx @@ -7,6 +7,9 @@ export const Route = createFileRoute('/specialChars/search')({ validateSearch: z.object({ searchParam: z.string(), }), + beforeLoad: () => ({ + beforeLoadOn: isBrowser ? 'client' : 'server', + }), loader: async () => { console.log(`[loader] Running on ${isBrowser ? 'client' : 'server'}`) return { @@ -18,11 +21,15 @@ export const Route = createFileRoute('/specialChars/search')({ function RouteComponent() { const search = Route.useSearch() + const { beforeLoadOn } = Route.useRouteContext() const { loadedOn } = Route.useLoaderData() return (
    Hello "/specialChars/search"! +
    + Before load on: {beforeLoadOn} +
    Loaded on: {loadedOn}
    {search.searchParam}
    diff --git a/e2e/react-start/basic/tests/not-found.spec.ts b/e2e/react-start/basic/tests/not-found.spec.ts index d6e91696e3..339f841b21 100644 --- a/e2e/react-start/basic/tests/not-found.spec.ts +++ b/e2e/react-start/basic/tests/not-found.spec.ts @@ -12,6 +12,24 @@ test.use({ ], }) test.describe('not-found', () => { + test('direct nested not-found hydrates its already-rendered parent route', async ({ + page, + }) => { + test.skip(isSpaMode, 'issue #5106 requires a server-rendered direct visit') + + const response = await page.goto('/posts/i-do-not-exist') + await page.waitForLoadState('networkidle') + + const serverHtml = await response?.text() + expect(serverHtml).toContain('posts-parent-hydration-counter') + expect(serverHtml).toContain('Post not found') + await expect(page.getByText('Post not found')).toBeInViewport() + const counter = page.getByTestId('posts-parent-hydration-counter') + await expect(counter).toHaveText('Parent hydration count: 0') + await counter.click() + await expect(counter).toHaveText('Parent hydration count: 1') + }) + test(`global not found`, async ({ page }) => { const response = await page.goto(`/this-page-does-not-exist/foo/bar`) diff --git a/e2e/react-start/basic/tests/special-characters.spec.ts b/e2e/react-start/basic/tests/special-characters.spec.ts index 7cf50c39c8..188d1f10a6 100644 --- a/e2e/react-start/basic/tests/special-characters.spec.ts +++ b/e2e/react-start/basic/tests/special-characters.spec.ts @@ -104,11 +104,16 @@ test.describe('Unicode route rendering', () => { const loadedOn = await page .getByTestId('special-search-loaded-info') .textContent() + const beforeLoadOn = await page + .getByTestId('special-search-before-load-info') + .textContent() if (isSpaMode) { expect(loadedOn).toBe('Loaded on: client') + expect(beforeLoadOn).toBe('Before load on: client') } else { expect(loadedOn).toBe('Loaded on: server') + expect(beforeLoadOn).toBe('Before load on: server') } }) diff --git a/e2e/react-start/selective-ssr/src/routeTree.gen.ts b/e2e/react-start/selective-ssr/src/routeTree.gen.ts index 188cd5c91a..ae0edad1e1 100644 --- a/e2e/react-start/selective-ssr/src/routeTree.gen.ts +++ b/e2e/react-start/selective-ssr/src/routeTree.gen.ts @@ -10,6 +10,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as PostsRouteImport } from './routes/posts' +import { Route as Issue4614RouteImport } from './routes/issue-4614' import { Route as IndexRouteImport } from './routes/index' import { Route as PostsPostIdRouteImport } from './routes/posts.$postId' @@ -18,6 +19,11 @@ const PostsRoute = PostsRouteImport.update({ path: '/posts', getParentRoute: () => rootRouteImport, } as any) +const Issue4614Route = Issue4614RouteImport.update({ + id: '/issue-4614', + path: '/issue-4614', + getParentRoute: () => rootRouteImport, +} as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', @@ -31,30 +37,34 @@ const PostsPostIdRoute = PostsPostIdRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/issue-4614': typeof Issue4614Route '/posts': typeof PostsRouteWithChildren '/posts/$postId': typeof PostsPostIdRoute } export interface FileRoutesByTo { '/': typeof IndexRoute + '/issue-4614': typeof Issue4614Route '/posts': typeof PostsRouteWithChildren '/posts/$postId': typeof PostsPostIdRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute + '/issue-4614': typeof Issue4614Route '/posts': typeof PostsRouteWithChildren '/posts/$postId': typeof PostsPostIdRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/posts' | '/posts/$postId' + fullPaths: '/' | '/issue-4614' | '/posts' | '/posts/$postId' fileRoutesByTo: FileRoutesByTo - to: '/' | '/posts' | '/posts/$postId' - id: '__root__' | '/' | '/posts' | '/posts/$postId' + to: '/' | '/issue-4614' | '/posts' | '/posts/$postId' + id: '__root__' | '/' | '/issue-4614' | '/posts' | '/posts/$postId' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + Issue4614Route: typeof Issue4614Route PostsRoute: typeof PostsRouteWithChildren } @@ -67,6 +77,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PostsRouteImport parentRoute: typeof rootRouteImport } + '/issue-4614': { + id: '/issue-4614' + path: '/issue-4614' + fullPath: '/issue-4614' + preLoaderRoute: typeof Issue4614RouteImport + parentRoute: typeof rootRouteImport + } '/': { id: '/' path: '/' @@ -96,6 +113,7 @@ const PostsRouteWithChildren = PostsRoute._addFileChildren(PostsRouteChildren) const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + Issue4614Route: Issue4614Route, PostsRoute: PostsRouteWithChildren, } export const routeTree = rootRouteImport diff --git a/e2e/react-start/selective-ssr/src/router.tsx b/e2e/react-start/selective-ssr/src/router.tsx index 82a730704a..d227bfa5d2 100644 --- a/e2e/react-start/selective-ssr/src/router.tsx +++ b/e2e/react-start/selective-ssr/src/router.tsx @@ -5,6 +5,9 @@ export function getRouter() { const router = createRouter({ routeTree, scrollRestoration: true, + defaultPreload: 'intent', + defaultPreloadDelay: 0, + defaultPreloadStaleTime: 0, }) return router diff --git a/e2e/react-start/selective-ssr/src/routes/__root.tsx b/e2e/react-start/selective-ssr/src/routes/__root.tsx index 1672e3ff68..3465abf298 100644 --- a/e2e/react-start/selective-ssr/src/routes/__root.tsx +++ b/e2e/react-start/selective-ssr/src/routes/__root.tsx @@ -9,10 +9,10 @@ import { createRootRoute, useRouterState, } from '@tanstack/react-router' +import { TanStackRouterDevtools } from '@tanstack/react-router-devtools' import { z } from 'zod' import { ssrSchema } from '~/search' import appCss from '~/styles/app.css?url' -import { TanStackRouterDevtools } from '@tanstack/react-router-devtools' export const Route = createRootRoute({ head: () => ({ @@ -30,7 +30,11 @@ export const Route = createRootRoute({ ], links: [{ rel: 'stylesheet', href: appCss }], }), - validateSearch: z.object({ root: ssrSchema }), + validateSearch: z.object({ + root: ssrSchema, + issue4614: z.string().optional(), + }), + loaderDeps: ({ search }) => ({ issue4614: search.issue4614 }), ssr: ({ search }) => { if (typeof window !== 'undefined') { const error = `ssr() for ${Route.id} should not be called on the client` @@ -41,7 +45,7 @@ export const Route = createRootRoute({ return search.value.root?.ssr } }, - beforeLoad: ({ search }) => { + beforeLoad: ({ search, location, cause, preload }) => { console.log( `beforeLoad for ${Route.id} called on the ${typeof window !== 'undefined' ? 'client' : 'server'}`, ) @@ -53,9 +57,21 @@ export const Route = createRootRoute({ console.error(error) throw new Error(error) } + const root = typeof window === 'undefined' ? 'server' : 'client' + const issue4614Context = `${root}:${search.issue4614 ?? 'cached'}` + if (typeof window !== 'undefined' && location.pathname === '/issue-4614') { + const calls = ((globalThis as any).__issue4614RootBeforeLoads ??= []) + calls.push({ + cause, + preload, + root, + issue4614Context, + }) + } return { - root: typeof window === 'undefined' ? 'server' : 'client', + root, search, + issue4614Context, } }, loader: ({ context }) => { @@ -133,6 +149,22 @@ function RootDocument({ children }: { children: React.ReactNode }) { > Home + + Issue 4614 cached parent + + + Issue 4614 reloaded parent +

diff --git a/e2e/react-start/selective-ssr/src/routes/issue-4614.tsx b/e2e/react-start/selective-ssr/src/routes/issue-4614.tsx new file mode 100644 index 0000000000..7e59b8d0ac --- /dev/null +++ b/e2e/react-start/selective-ssr/src/routes/issue-4614.tsx @@ -0,0 +1,15 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/issue-4614')({ + ssr: false, + beforeLoad: ({ context, cause, preload, search }) => { + ;(globalThis as any).__issue4614TargetBeforeLoad = { + cause, + preload, + rootContext: context.root, + issue4614Context: context.issue4614Context, + scenario: search.issue4614 ?? 'cached', + } + }, + component: () =>
Issue 4614
, +}) diff --git a/e2e/react-start/selective-ssr/tests/app.spec.ts b/e2e/react-start/selective-ssr/tests/app.spec.ts index aea216d506..d6289d5689 100644 --- a/e2e/react-start/selective-ssr/tests/app.spec.ts +++ b/e2e/react-start/selective-ssr/tests/app.spec.ts @@ -4,6 +4,69 @@ import { test } from '@tanstack/router-e2e-utils' const testCount = 7 test.describe('selective ssr', () => { + test('#4614: ssr false child receives context from its parent load generation', async ({ + page, + }) => { + await page.goto('/') + await page.getByTestId('issue-4614-cached-link').hover() + + await expect + .poll(() => + page.evaluate(() => (globalThis as any).__issue4614TargetBeforeLoad), + ) + .not.toBeUndefined() + + const { rootBeforeLoad, targetBeforeLoad } = await page.evaluate(() => { + const rootBeforeLoads = + (globalThis as any).__issue4614RootBeforeLoads ?? [] + return { + rootBeforeLoad: rootBeforeLoads.at(-1), + targetBeforeLoad: (globalThis as any).__issue4614TargetBeforeLoad, + } + }) + + expect(targetBeforeLoad).toMatchObject({ + cause: 'preload', + preload: true, + scenario: 'cached', + }) + expect(targetBeforeLoad.rootContext).toBe(rootBeforeLoad?.root ?? 'server') + expect(targetBeforeLoad.issue4614Context).toBe( + rootBeforeLoad?.issue4614Context ?? 'server:cached', + ) + }) + + test('child receives fresh parent context when loaderDeps force a same-preload reload', async ({ + page, + }) => { + await page.goto('/') + await page.getByTestId('issue-4614-reload-link').hover() + + await expect + .poll(() => + page.evaluate(() => (globalThis as any).__issue4614RootBeforeLoads), + ) + .toEqual([ + { + cause: 'preload', + preload: true, + root: 'client', + issue4614Context: 'client:reload', + }, + ]) + await expect + .poll(() => + page.evaluate(() => (globalThis as any).__issue4614TargetBeforeLoad), + ) + .toEqual({ + cause: 'preload', + preload: true, + rootContext: 'client', + issue4614Context: 'client:reload', + scenario: 'reload', + }) + }) + test('testcount matches', async ({ page }) => { await page.goto('/') 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/issue-7283-dev-hydration.spec.ts b/e2e/solid-start/selective-ssr/tests/issue-7283-dev-hydration.spec.ts new file mode 100644 index 0000000000..817467a0e2 --- /dev/null +++ b/e2e/solid-start/selective-ssr/tests/issue-7283-dev-hydration.spec.ts @@ -0,0 +1,122 @@ +import { spawn } from 'node:child_process' +import path from 'node:path' +import { expect, test } from '@playwright/test' +import type { ChildProcess } from 'node:child_process' +import type { Page } from '@playwright/test' + +// Regression test for https://github.com/TanStack/router/issues/7283 +// +// Selective SSR routes (`ssr: false` / `ssr: 'data-only'`) that declare a +// `pendingComponent` must hydrate cleanly in DEV mode. Solid only validates +// hydration markers in dev, so the production webServer configured in +// playwright.config.ts masks the mismatch ("template is not a function", +// route never reaches its loaded component). This spec therefore boots its +// own `vite dev` server on a dedicated port and drives the app against it. + +const DEV_PORT = 58283 +const devBaseURL = `http://localhost:${DEV_PORT}` + +let devServer: ChildProcess | undefined + +async function waitForServer(url: string, timeoutMs: number) { + const deadline = Date.now() + timeoutMs + let lastError: unknown + while (Date.now() < deadline) { + try { + const res = await fetch(url) + if (res.ok) return + } catch (err) { + lastError = err + } + await new Promise((resolve) => setTimeout(resolve, 1000)) + } + throw new Error(`dev server did not start on ${url}: ${lastError}`) +} + +test.beforeAll(async () => { + const appDir = path.resolve(import.meta.dirname, '..') + devServer = spawn( + 'pnpm', + [ + 'dev:e2e', + '--host', + 'localhost', + '--port', + String(DEV_PORT), + '--strictPort', + ], + { + cwd: appDir, + env: { + ...process.env, + VITE_SERVER_PORT: String(DEV_PORT), + NODE_ENV: 'development', + }, + stdio: 'ignore', + detached: true, + }, + ) + await waitForServer(`${devBaseURL}/`, 90_000) +}) + +test.afterAll(() => { + if (devServer?.pid) { + try { + process.kill(-devServer.pid, 'SIGTERM') + } catch { + // already gone + } + } +}) + +function collectHydrationFailures(page: Page) { + const failures: Array = [] + const isHydrationFailure = (text: string) => + text.includes('template is not a function') || + text.includes("wasn't caught by any route") || + text.includes('Hydration Mismatch') + page.on('pageerror', (err) => { + if (isHydrationFailure(err.message)) failures.push(err.message) + }) + page.on('console', (msg) => { + if (msg.type() === 'error' || msg.type() === 'warning') { + const text = msg.text() + if (isHydrationFailure(text)) failures.push(text) + } + }) + return failures +} + +test.describe('dev-mode hydration of selective SSR routes with pendingComponent', () => { + test.setTimeout(120_000) + + test('ssr:false route with pendingComponent hydrates cleanly and reaches its loaded component (issue #7283)', async ({ + page, + }) => { + const failures = collectHydrationFailures(page) + + await page.goto(`${devBaseURL}/ssr-false-pending-min`) + + // The loaded component must eventually replace the pending UI. + await expect(page.getByTestId('ssr-false-target')).toBeVisible({ + timeout: 15_000, + }) + + // No dev hydration mismatch may occur along the way. + expect(failures).toEqual([]) + }) + + test('data-only route with pendingComponent hydrates cleanly and reaches its loaded component (regression vs main)', async ({ + page, + }) => { + const failures = collectHydrationFailures(page) + + await page.goto(`${devBaseURL}/data-only-pending-component`) + + await expect( + page.getByTestId('data-only-pending-component-ready-label'), + ).toHaveText('OK - loader finished', { timeout: 15_000 }) + + expect(failures).toEqual([]) + }) +}) diff --git a/packages/react-router/package.json b/packages/react-router/package.json index 9f4bbd6b90..d1b6ddf285 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -100,6 +100,7 @@ "devDependencies": { "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.2.0", + "@tanstack/react-query": "catalog:", "@types/node": ">=20", "@vitejs/plugin-react": "^4.3.4", "combinate": "^1.1.11", diff --git a/packages/react-router/src/Match.tsx b/packages/react-router/src/Match.tsx index 5de5546aeb..8cce99a276 100644 --- a/packages/react-router/src/Match.tsx +++ b/packages/react-router/src/Match.tsx @@ -2,14 +2,7 @@ import * as React from 'react' import { useStore } from '@tanstack/react-store' -import { - createControlledPromise, - getLocationChangeInfo, - invariant, - isNotFound, - isRedirect, - rootRouteId, -} from '@tanstack/router-core' +import { invariant, isNotFound, rootRouteId } from '@tanstack/router-core' import { isServer } from '@tanstack/router-core/isServer' import { CatchBoundary, ErrorComponent } from './CatchBoundary' import { useRouter } from './useRouter' @@ -19,26 +12,27 @@ import { SafeFragment } from './SafeFragment' import { renderRouteNotFound } from './renderRouteNotFound' import { ScrollRestoration } from './scroll-restoration' import { ClientOnly } from './ClientOnly' -import { useLayoutEffect } from './utils' import type { AnyRoute, AnyRouteMatch, - ParsedLocation, RootRouteOptions, } from '@tanstack/router-core' type OutletMatchSelection = [ routeId: string | undefined, parentGlobalNotFound: boolean, + parentNotFoundError: unknown, ] const matchViewFieldsEqual = (a: AnyRouteMatch, b: AnyRouteMatch) => - a.routeId === b.routeId && a._displayPending === b._displayPending + a.routeId === b.routeId && + a.fetchCount === b.fetchCount && + a.status === b.status const outletMatchSelectionEqual = ( a: OutletMatchSelection, b: OutletMatchSelection, -) => a[0] === b[0] && a[1] === b[1] +) => a[0] === b[0] && a[1] === b[1] && a[2] === b[2] export const Match = React.memo(function MatchImpl({ matchId, @@ -67,11 +61,10 @@ export const Match = React.memo(function MatchImpl({ @@ -93,8 +86,6 @@ 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(() => { @@ -105,16 +96,15 @@ export const Match = React.memo(function MatchImpl({ return { routeId, ssr: match.ssr, - _displayPending: match._displayPending, parentRouteId: parentRouteId as string | undefined, } satisfies MatchViewState - }, [match._displayPending, match.routeId, match.ssr, router.routesById]) + }, [match.routeId, match.ssr, router.routesById]) return ( ) @@ -123,7 +113,6 @@ export const Match = React.memo(function MatchImpl({ type MatchViewState = { routeId: string ssr: boolean | 'data-only' | undefined - _displayPending: boolean | undefined parentRouteId: string | undefined } @@ -159,11 +148,9 @@ function MatchView({ const resolvedNoSsr = matchState.ssr === false || matchState.ssr === 'data-only' const ResolvedSuspenseBoundary = - // If we're on the root route, allow forcefully wrapping in suspense - (!route.isRoot || route.options.wrapInSuspense || resolvedNoSsr) && (route.options.wrapInSuspense ?? - PendingComponent ?? - ((route.options.errorComponent as any)?.preload || resolvedNoSsr)) + PendingComponent ?? + ((route.options.errorComponent as any)?.preload || resolvedNoSsr)) ? React.Suspense : SafeFragment @@ -183,7 +170,7 @@ function MatchView({ resetKey} + getResetKey={() => `${matchId}:${resetKey}`} errorComponent={routeErrorComponent || ErrorComponent} onCatch={(error, errorInfo) => { // Forward not found errors (we don't want to show the error component for these) @@ -213,7 +200,7 @@ function MatchView({ return React.createElement(routeNotFoundComponent, error as any) }} > - {resolvedNoSsr || matchState._displayPending ? ( + {resolvedNoSsr ? ( @@ -225,68 +212,14 @@ function MatchView({ {matchState.parentRouteId === rootRouteId ? ( - <> - - {router.options.scrollRestoration && (isServer ?? router.isServer) ? ( - - ) : null} - + router.options.scrollRestoration && (isServer ?? router.isServer) ? ( + + ) : null ) : null} ) } -// On Rendered can't happen above the root layout because it needs to run after -// the route subtree has committed below the root layout. Keeping it here lets -// us fire onRendered even after a hydration mismatch above the root layout -// (like bad head/link tags, which is common). -function OnRendered() { - const router = useRouter() - - if (isServer ?? router.isServer) { - return null - } - - // Track the resolvedLocation as of the last render so that onRendered can - // report the correct fromLocation. By the time this effect fires, - // resolvedLocation has already been updated to the new location by - // Transitioner, so we cannot use router.stores.resolvedLocation.get() - // directly as the fromLocation. - // @ts-expect-error -- init to `undefined` but don't write `undefined` to shave bytes - // eslint-disable-next-line react-hooks/rules-of-hooks - const prevResolvedLocationRef = React.useRef< - ParsedLocation | undefined - >() - // eslint-disable-next-line react-hooks/rules-of-hooks - const renderedLocationKey = useStore( - router.stores.resolvedLocation, - (resolvedLocation) => resolvedLocation?.state.__TSR_key, - ) - - // eslint-disable-next-line react-hooks/rules-of-hooks - useLayoutEffect(() => { - const currentResolvedLocation = router.stores.resolvedLocation.get() - const previousResolvedLocation = prevResolvedLocationRef.current - - if ( - currentResolvedLocation && - (!previousResolvedLocation || - previousResolvedLocation.href !== currentResolvedLocation.href) - ) { - router.emit({ - type: 'onRendered', - ...getLocationChangeInfo( - router.stores.location.get(), - previousResolvedLocation ?? currentResolvedLocation, - ), - }) - } - prevResolvedLocationRef.current = currentResolvedLocation - }, [renderedLocationKey, router]) - - return null -} - export const MatchInner = React.memo(function MatchInnerImpl({ matchId, }: { @@ -294,22 +227,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) { @@ -337,16 +254,8 @@ export const MatchInner = React.memo(function MatchInnerImpl({ const Comp = route.options.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') + invariant() } if (match.status === 'notFound') { @@ -360,17 +269,6 @@ export const MatchInner = React.memo(function MatchInnerImpl({ return renderRouteNotFound(router, route, match.error) } - if (match.status === 'redirected') { - if (!isRedirect(match.error)) { - if (process.env.NODE_ENV !== 'production') { - throw new Error('Invariant failed: Expected a redirect error') - } - - invariant() - } - throw getMatchPromise(match, 'loadPromise') - } - if (match.status === 'error') { const RouteErrorComponent = (route.options.errorComponent ?? @@ -434,37 +332,10 @@ export const MatchInner = React.memo(function MatchInnerImpl({ return }, [key, route.options.component, router.options.defaultComponent]) - if (match._displayPending) { - throw getMatchPromise(match, 'displayPendingPromise') - } - - if (match._forcePending) { - throw getMatchPromise(match, 'minPendingPromise') - } - - // 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) - } - } - } - throw getMatchPromise(match, 'loadPromise') + const PendingComponent = + route.options.pendingComponent ?? router.options.defaultPendingComponent + return PendingComponent ? : null } if (match.status === 'notFound') { @@ -478,22 +349,6 @@ export const MatchInner = React.memo(function MatchInnerImpl({ return renderRouteNotFound(router, route, match.error) } - if (match.status === 'redirected') { - // A match can be observed as redirected during an in-flight transition, - // especially when pending UI is already rendering. Suspend on the match's - // load promise so React can abandon this stale render and continue the - // redirect transition. - if (!isRedirect(match.error)) { - if (process.env.NODE_ENV !== 'production') { - throw new Error('Invariant failed: Expected a redirect error') - } - - invariant() - } - - throw getMatchPromise(match, 'loadPromise') - } - if (match.status === 'error') { // If we're on the server, we need to use React's new and super // wonky api for throwing errors from a server side render inside @@ -534,6 +389,7 @@ export const Outlet = React.memo(function OutletImpl() { let routeId: string | undefined let parentGlobalNotFound = false + let parentNotFoundError: unknown let childMatchId: string | undefined if (isServer ?? router.isServer) { @@ -544,6 +400,7 @@ export const Outlet = React.memo(function OutletImpl() { const parentMatch = parentIndex >= 0 ? matches[parentIndex] : undefined routeId = parentMatch?.routeId as string | undefined parentGlobalNotFound = parentMatch?.globalNotFound ?? false + parentNotFoundError = parentMatch?.error childMatchId = parentIndex >= 0 ? (matches[parentIndex + 1]?.id as string) : undefined } else { @@ -554,11 +411,12 @@ export const Outlet = React.memo(function OutletImpl() { : undefined // eslint-disable-next-line react-hooks/rules-of-hooks - ;[routeId, parentGlobalNotFound] = useStore( + ;[routeId, parentGlobalNotFound, parentNotFoundError] = useStore( parentMatchStore, (match): OutletMatchSelection => [ match?.routeId as string | undefined, match?.globalNotFound ?? false, + match?.error, ], outletMatchSelectionEqual, ) @@ -586,7 +444,7 @@ export const Outlet = React.memo(function OutletImpl() { invariant() } - return renderRouteNotFound(router, route, undefined) + return renderRouteNotFound(router, route, parentNotFoundError) } if (!childMatchId) { diff --git a/packages/react-router/src/Matches.tsx b/packages/react-router/src/Matches.tsx index 62d0fc42ae..bb8b1c962a 100644 --- a/packages/react-router/src/Matches.tsx +++ b/packages/react-router/src/Matches.tsx @@ -7,6 +7,7 @@ import { isServer } from '@tanstack/router-core/isServer' import { CatchBoundary, ErrorComponent } from './CatchBoundary' import { useRouter } from './useRouter' import { useStructuralSharing } from './useMatch' +import { useLayoutEffect } from './utils' import { Transitioner } from './Transitioner' import { matchContext } from './matchContext' import { Match } from './Match' @@ -61,10 +62,12 @@ export function Matches() { : React.Suspense const inner = ( - + <> {!(isServer ?? router.isServer) && } - - + + + + ) return router.options.InnerWrap ? ( @@ -77,14 +80,17 @@ export function Matches() { function MatchesInner() { const router = useRouter() const _isServer = isServer ?? router.isServer - const matchId = _isServer - ? router.stores.firstId.get() + const matches = _isServer + ? router.stores.matches.get() : // eslint-disable-next-line react-hooks/rules-of-hooks - useStore(router.stores.firstId, (id) => id) - const resetKey = _isServer - ? router.stores.loadedAt.get() - : // eslint-disable-next-line react-hooks/rules-of-hooks - useStore(router.stores.loadedAt, (loadedAt) => loadedAt) + useStore(router.stores.matches, (value) => value) + const match = matches[0] + const matchId = match?.id + const resetKey = match ? `${match.id}:${match.fetchCount}` : '' + + useLayoutEffect(() => { + router._rendered?.() + }, [matches, router]) const matchComponent = matchId ? : null diff --git a/packages/react-router/src/Transitioner.tsx b/packages/react-router/src/Transitioner.tsx index bf945571a6..ead1ca6c57 100644 --- a/packages/react-router/src/Transitioner.tsx +++ b/packages/react-router/src/Transitioner.tsx @@ -1,43 +1,62 @@ 'use client' import * as React from 'react' -import { batch, useStore } from '@tanstack/react-store' import { getLocationChangeInfo, trimPathRight } from '@tanstack/router-core' -import { useLayoutEffect, usePrevious } from './utils' +import { useLayoutEffect } from './utils' import { useRouter } from './useRouter' export function Transitioner() { const router = useRouter() - const mountLoadForRouter = React.useRef({ router, mounted: false }) + const mountLoadForRouter = React.useRef< + [typeof router, typeof router.history] | undefined + >(undefined) + const acknowledgements = React.useRef void>>( + [], + ).current - const [isTransitioning, setIsTransitioning] = React.useState(false) - // Track pending state changes - const isLoading = useStore(router.stores.isLoading, (value) => value) - const hasPending = useStore(router.stores.hasPending, (value) => value) - - const previousIsLoading = usePrevious(isLoading) - - const isAnyPending = isLoading || isTransitioning || hasPending - const previousIsAnyPending = usePrevious(isAnyPending) - - const isPagePending = isLoading || hasPending - const previousIsPagePending = usePrevious(isPagePending) - - router.startTransition = (fn: () => void) => { - setIsTransitioning(true) - React.startTransition(() => { - fn() - setIsTransitioning(false) - }) - } + useLayoutEffect(() => { + const previousTransition = router.startTransition + const previousRendered = router._rendered + const rendered = () => { + for (const resolve of acknowledgements.splice(0)) { + resolve(true) + } + } + const transition = (fn: () => void) => { + return new Promise((resolve) => { + acknowledgements.push(resolve) + React.startTransition(fn) + }) + } + router._rendered = rendered + router.startTransition = transition + return () => { + for (const resolve of acknowledgements.splice(0)) { + resolve(false) + } + if (router._rendered === rendered) { + router._rendered = previousRendered + } + if (router.startTransition === transition) { + router.startTransition = previousTransition + } + } + }, [acknowledgements, router]) - // Subscribe to location changes - // and try to load the new location - React.useEffect(() => { + // Subscribe before canonicalizing so the initial URL has exactly one load. + useLayoutEffect(() => { const unsub = router.history.subscribe(router.load) + const mounted = mountLoadForRouter.current + if (mounted?.[0] === router && mounted[1] === router.history) { + return unsub + } + mountLoadForRouter.current = [router, router.history] + + router.updateLatestLocation() + const location = router.latestLocation const nextLocation = router.buildLocation({ - to: router.latestLocation.pathname, + to: location.pathname, search: true, params: true, hash: true, @@ -49,83 +68,28 @@ export function Transitioner() { // Compare publicHref (browser-facing URL) for consistency with // the server-side redirect check in router.beforeLoad. if ( - trimPathRight(router.latestLocation.publicHref) !== + trimPathRight(location.publicHref) !== trimPathRight(nextLocation.publicHref) ) { router.commitLocation({ ...nextLocation, replace: true }) + return unsub } - return () => { - unsub() - } - }, [router, router.history]) - - // Try to load the initial location - useLayoutEffect(() => { + const resolvedLocation = router.stores.resolvedLocation.get() if ( - // if we are hydrating from SSR, loading is triggered in ssr-client - (typeof window !== 'undefined' && router.ssr) || - (mountLoadForRouter.current.router === router && - mountLoadForRouter.current.mounted) + resolvedLocation?.href === location.href && + resolvedLocation.state.__TSR_key === location.state.__TSR_key ) { - return - } - mountLoadForRouter.current = { router, mounted: true } - - const tryLoad = async () => { - try { - await router.load() - } catch (err) { - console.error(err) - } - } - - tryLoad() - }, [router]) - - useLayoutEffect(() => { - // The router was loading and now it's not - if (previousIsLoading && !isLoading) { router.emit({ - type: 'onLoad', // When the new URL has committed, when the new matches have been loaded into state.matches - ...getLocationChangeInfo( - router.stores.location.get(), - router.stores.resolvedLocation.get(), - ), + type: 'onRendered', + ...getLocationChangeInfo(resolvedLocation, resolvedLocation), }) + } else { + router.load().catch(console.error) } - }, [previousIsLoading, router, isLoading]) - useLayoutEffect(() => { - // emit onBeforeRouteMount - if (previousIsPagePending && !isPagePending) { - router.emit({ - type: 'onBeforeRouteMount', - ...getLocationChangeInfo( - router.stores.location.get(), - router.stores.resolvedLocation.get(), - ), - }) - } - }, [isPagePending, previousIsPagePending, router]) - - useLayoutEffect(() => { - if (previousIsAnyPending && !isAnyPending) { - const changeInfo = getLocationChangeInfo( - router.stores.location.get(), - router.stores.resolvedLocation.get(), - ) - router.emit({ - type: 'onResolved', - ...changeInfo, - }) - - batch(() => { - router.stores.status.set('idle') - router.stores.resolvedLocation.set(router.stores.location.get()) - }) - } - }, [isAnyPending, previousIsAnyPending, router]) + return unsub + }, [router, router.history]) return null } diff --git a/packages/react-router/src/lazyRouteComponent.tsx b/packages/react-router/src/lazyRouteComponent.tsx index b4fe8703c5..f918b55ea4 100644 --- a/packages/react-router/src/lazyRouteComponent.tsx +++ b/packages/react-router/src/lazyRouteComponent.tsx @@ -28,12 +28,14 @@ export function lazyRouteComponent< const load = () => { if (!loadPromise) { + error = undefined loadPromise = importer() .then((res) => { loadPromise = undefined comp = res[exportName ?? 'default'] }) .catch((err) => { + loadPromise = undefined // We don't want an error thrown from preload in this case, because // there's nothing we want to do about module not found during preload. // Record the error, the rest is handled during the render path. diff --git a/packages/react-router/src/ssr/renderRouterToStream.tsx b/packages/react-router/src/ssr/renderRouterToStream.tsx index 46275a5421..ee15b882fd 100644 --- a/packages/react-router/src/ssr/renderRouterToStream.tsx +++ b/packages/react-router/src/ssr/renderRouterToStream.tsx @@ -76,7 +76,10 @@ export const renderRouterToStream = async ({ return createSsrStreamResponse( router, new Response(responseStream as any, { - status: router.stores.statusCode.get(), + status: + router._serverResult?.type === 'render' + ? router._serverResult.status + : 200, headers: responseHeaders, }), ) @@ -199,7 +202,10 @@ export const renderRouterToStream = async ({ return createSsrStreamResponse( router, new Response(responseStream as any, { - status: router.stores.statusCode.get(), + status: + router._serverResult?.type === 'render' + ? router._serverResult.status + : 200, headers: responseHeaders, }), ) diff --git a/packages/react-router/src/ssr/renderRouterToString.tsx b/packages/react-router/src/ssr/renderRouterToString.tsx index e9fe4ec779..5e299cc215 100644 --- a/packages/react-router/src/ssr/renderRouterToString.tsx +++ b/packages/react-router/src/ssr/renderRouterToString.tsx @@ -21,7 +21,10 @@ export const renderRouterToString = async ({ } return new Response(`${html}`, { - status: router.stores.statusCode.get(), + status: + router._serverResult?.type === 'render' + ? router._serverResult.status + : 200, headers: responseHeaders, }) } catch (error) { diff --git a/packages/react-router/tests/Matches.test.tsx b/packages/react-router/tests/Matches.test.tsx index dd3ca3dfa6..94ed262cc4 100644 --- a/packages/react-router/tests/Matches.test.tsx +++ b/packages/react-router/tests/Matches.test.tsx @@ -126,11 +126,11 @@ test('when filtering useMatches by loaderData', async () => { test('should show pendingComponent of root route', async () => { const root = createRootRoute({ - pendingComponent: () =>
, + pendingComponent: () =>
, loader: async () => { await new Promise((r) => setTimeout(r, 50)) }, - component: () =>
, + component: () =>
, }) const router = createRouter({ @@ -145,6 +145,59 @@ test('should show pendingComponent of root route', async () => { expect(await rendered.findByTestId('root-content')).toBeInTheDocument() }) +test('a reused legacy notFoundRoute does not retain the previous parent layout', async () => { + const root = createRootRoute({ component: Outlet }) + const parent = createRoute({ + getParentRoute: () => root, + path: '/parent', + component: () => ( +
+ Parent layout + +
+ ), + }) + const known = createRoute({ + getParentRoute: () => parent, + path: '/known', + }) + const home = createRoute({ + getParentRoute: () => root, + path: '/home', + component: () =>
Home
, + }) + const legacyNotFound = createRoute({ + getParentRoute: () => root, + path: '/404', + loader: () => 'not found', + component: () =>
Legacy not found
, + staleTime: Infinity, + gcTime: Infinity, + }) + const router = createRouter({ + routeTree: root.addChildren([parent.addChildren([known]), home]), + history: createMemoryHistory({ initialEntries: ['/parent/missing'] }), + notFoundRoute: legacyNotFound, + }) + + const rendered = render() + expect(await rendered.findByText('Parent layout')).toBeInTheDocument() + expect(await rendered.findByText('Legacy not found')).toBeInTheDocument() + + await act(async () => { + await router.navigate({ to: '/home' }) + }) + expect(await rendered.findByText('Home')).toBeInTheDocument() + + await act(async () => { + await router.navigate({ to: '/missing' } as any) + }) + + expect(rendered.queryByText('Parent layout')).not.toBeInTheDocument() + expect(await rendered.findByText('Legacy not found')).toBeInTheDocument() + rendered.unmount() +}) + describe('matching on different param types', () => { const testCases = [ { diff --git a/packages/react-router/tests/ancestor-loader-child-pending-min.test.tsx b/packages/react-router/tests/ancestor-loader-child-pending-min.test.tsx new file mode 100644 index 0000000000..a4d587e2f3 --- /dev/null +++ b/packages/react-router/tests/ancestor-loader-child-pending-min.test.tsx @@ -0,0 +1,95 @@ +import * as React from 'react' +import { act, cleanup, render, screen } from '@testing-library/react' +import { afterEach, expect, test, vi } from 'vitest' +import { + Outlet, + RouterProvider, + createControlledPromise, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +afterEach(() => { + cleanup() + vi.useRealTimers() +}) + +test('a child fallback revealed after a fresh ancestor loader keeps its own pendingMinMs', async () => { + vi.useFakeTimers() + + const parentLoader = createControlledPromise() + const childLoader = createControlledPromise() + const rootRoute = createRootRoute({ + component: () => , + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Index
, + }) + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: () => parentLoader, + component: () => , + }) + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: '/child', + pendingMs: 0, + pendingMinMs: 100, + pendingComponent: () =>
Child pending
, + loader: () => childLoader, + component: () =>
Child content
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/'] }), + defaultPendingMs: 0, + }) + + await router.load() + render() + expect(screen.getByText('Index')).toBeInTheDocument() + + let navigation!: Promise + await act(async () => { + navigation = router.navigate({ to: '/parent/child' }) + await vi.advanceTimersByTimeAsync(25) + }) + + expect(screen.queryByText('Child pending')).not.toBeInTheDocument() + + await act(async () => { + parentLoader.resolve() + await vi.advanceTimersByTimeAsync(0) + }) + expect(screen.getByText('Child pending')).toBeInTheDocument() + expect(screen.queryByText('Child content')).not.toBeInTheDocument() + + await act(async () => { + await vi.advanceTimersByTimeAsync(25) + childLoader.resolve() + await Promise.resolve() + }) + + // The minimum is measured from the child's first visible frame, not from + // the navigation start or the ancestor's completion. + await act(async () => { + await vi.advanceTimersByTimeAsync(74) + }) + expect(screen.getByText('Child pending')).toBeInTheDocument() + expect(screen.queryByText('Child content')).not.toBeInTheDocument() + + await act(async () => { + await vi.advanceTimersByTimeAsync(1) + await navigation + }) + expect(screen.queryByText('Child pending')).not.toBeInTheDocument() + expect(screen.getByText('Child content')).toBeInTheDocument() +}) diff --git a/packages/react-router/tests/component-preload-retry-pending-min.test.tsx b/packages/react-router/tests/component-preload-retry-pending-min.test.tsx new file mode 100644 index 0000000000..25dee12a4e --- /dev/null +++ b/packages/react-router/tests/component-preload-retry-pending-min.test.tsx @@ -0,0 +1,121 @@ +import * as React from 'react' +import { act } from 'react' +import { afterEach, expect, test, vi } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { createControlledPromise } from '@tanstack/router-core' +import { + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + useRouter, +} from '../src' +import type { ErrorComponentProps } from '../src' + +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + cleanup() +}) + +/** + * A component-only route can fail while preloading its code, then retry from + * its error UI through invalidate(). The retry is a fresh pending generation: + * it needs a match-local load promise so the rendered pending component can + * attach pendingMinMs to the work that actually owns readiness. + */ +test('component preload retry owns readiness and honors pendingMinMs', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + + const retryChunk = createControlledPromise() + let preloadAttempt = 0 + let retryPromise: Promise | undefined + let retrySettled = false + + const Page = Object.assign( + () =>
Page content
, + { + preload: vi.fn(() => { + preloadAttempt++ + return preloadAttempt === 1 + ? Promise.reject(new Error('initial chunk request failed')) + : retryChunk + }), + }, + ) + + function RetryError({ reset }: ErrorComponentProps) { + const router = useRouter() + return ( + + ) + } + + const rootRoute = createRootRoute({}) + const pageRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/page', + component: Page, + errorComponent: RetryError, + pendingMs: 0, + pendingMinMs: 100, + pendingComponent: () => ( +
Loading page...
+ ), + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + }) + + render() + + expect( + await screen.findByRole('button', { name: 'Retry chunk' }), + ).toBeInTheDocument() + expect(Page.preload).toHaveBeenCalledTimes(1) + + vi.useFakeTimers() + fireEvent.click(screen.getByRole('button', { name: 'Retry chunk' })) + + // Let pendingMs: 0 publish the retry lane. + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) + expect(screen.getByTestId('page-pending')).toBeInTheDocument() + + await act(async () => { + retryChunk.resolve() + await Promise.resolve() + }) + + expect.soft(retrySettled).toBe(false) + expect.soft(screen.queryByTestId('page-pending')).toBeInTheDocument() + expect.soft(screen.queryByTestId('page-content')).not.toBeInTheDocument() + + await act(async () => { + await vi.advanceTimersByTimeAsync(99) + }) + expect(screen.getByTestId('page-pending')).toBeInTheDocument() + + await act(async () => { + await vi.advanceTimersByTimeAsync(1) + await retryPromise + }) + + expect(Page.preload).toHaveBeenCalledTimes(2) + expect(screen.getByTestId('page-content')).toBeInTheDocument() + expect(screen.queryByTestId('page-pending')).not.toBeInTheDocument() +}) diff --git a/packages/react-router/tests/component-preload-retry.test.tsx b/packages/react-router/tests/component-preload-retry.test.tsx new file mode 100644 index 0000000000..6111a2dd2e --- /dev/null +++ b/packages/react-router/tests/component-preload-retry.test.tsx @@ -0,0 +1,62 @@ +import * as React from 'react' +import { afterEach, expect, test, vi } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + lazyRouteComponent, + useRouter, +} from '../src' +import type { ErrorComponentProps } from '../src' + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +test('a failed component download is retried from the route error UI', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + + const PageContent = () =>
Page content
+ const importer = vi + .fn<() => Promise<{ default: typeof PageContent }>>() + .mockRejectedValueOnce(new Error('component download failed')) + .mockResolvedValue({ default: PageContent }) + const Page = lazyRouteComponent(importer) + + function RouteError({ reset }: ErrorComponentProps) { + const router = useRouter() + return ( + + ) + } + + const rootRoute = createRootRoute() + const pageRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/page', + component: Page, + errorComponent: RouteError, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + }) + + render() + + fireEvent.click(await screen.findByRole('button', { name: 'Retry' })) + + expect(await screen.findByText('Page content')).toBeInTheDocument() +}) diff --git a/packages/react-router/tests/errorComponent.test.tsx b/packages/react-router/tests/errorComponent.test.tsx index 0779c2e8c4..15dcfbfa8c 100644 --- a/packages/react-router/tests/errorComponent.test.tsx +++ b/packages/react-router/tests/errorComponent.test.tsx @@ -338,7 +338,6 @@ test('SSR errorComponent receives primitive errors thrown from beforeLoad', asyn await router.load() - expect(router.state.statusCode).toBe(500) const html = ReactDOMServer.renderToString() expect(html).toContain('Error:') expect(html).toContain('primitive error thrown') @@ -423,13 +422,11 @@ describe('notFoundComponent is rendered when an error is thrown in params.parse' render() - await act(() => router.latestLoadPromise) - expect(rootLoader).toHaveBeenCalledTimes(1) - const linkToRottenPizza = await screen.findByRole('link', { name: 'link to rotten pizza', }) + expect(rootLoader).toHaveBeenCalledTimes(1) expect(linkToRottenPizza).toBeInTheDocument() await act(() => fireEvent.mouseOver(linkToRottenPizza)) await act(() => fireEvent.click(linkToRottenPizza)) diff --git a/packages/react-router/tests/hydration-capped-boundary-pending.test.tsx b/packages/react-router/tests/hydration-capped-boundary-pending.test.tsx new file mode 100644 index 0000000000..b79a5ce751 --- /dev/null +++ b/packages/react-router/tests/hydration-capped-boundary-pending.test.tsx @@ -0,0 +1,176 @@ +import * as React from 'react' +import { act } from '@testing-library/react' +import { hydrateRoot } from 'react-dom/client' +import { renderToString } from 'react-dom/server' +import { afterEach, describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { hydrate } from '../src/ssr/client' +import { + Outlet, + RouterProvider, + createRootRoute, + createRoute, + createRouter, + notFound, +} from '../src' +import type { TsrSsrGlobal } from '../src/ssr/client' + +declare global { + interface Window { + $_TSR?: TsrSsrGlobal + } +} + +const testCleanups: Array<() => void | Promise> = [] + +afterEach(async () => { + while (testCleanups.length) { + await testCleanups.pop()!() + } + vi.restoreAllMocks() + window.$_TSR = undefined + document.body.innerHTML = '' +}) + +describe('hydrating a server-capped boundary lane', () => { + test.each([ + ['error', 'parent'], + ['notFound', 'parent'], + ['error', 'root'], + ['notFound', 'root'], + ] as const)( + 'keeps the server-rendered %s %s boundary visible', + async (outcome, boundary) => { + const routeFailure = + outcome === 'notFound' ? notFound() : new Error('server route failure') + const childLoader = vi.fn(() => 'child data') + + const makeRouteTree = () => { + const boundaryOptions = { + beforeLoad: () => { + throw routeFailure + }, + pendingComponent: () => ( +
Boundary pending
+ ), + errorComponent: () => ( +
Boundary error
+ ), + notFoundComponent: () => ( +
Boundary not found
+ ), + } + const rootRoute = createRootRoute({ + component: Outlet, + ...(boundary === 'root' ? boundaryOptions : {}), + }) + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + component: Outlet, + ...(boundary === 'parent' ? boundaryOptions : {}), + }) + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: childLoader, + component: () =>
Child
, + }) + return { + routeTree: rootRoute.addChildren([ + parentRoute.addChildren([childRoute]), + ]), + } + } + + const serverRouter = createRouter({ + ...makeRouteTree(), + ...(boundary === 'root' ? { isShell: true } : {}), + history: createMemoryHistory({ + initialEntries: ['/parent/child'], + }), + }) + serverRouter.isServer = true + await serverRouter.load() + + const serverMatches = serverRouter.stores.matches.get() + expect(serverMatches).toHaveLength(boundary === 'root' ? 1 : 2) + const serverBoundary = serverMatches.at(-1)! + if (outcome === 'notFound' && boundary === 'root') { + expect(serverBoundary).toMatchObject({ + status: 'success', + globalNotFound: true, + }) + } else { + expect(serverBoundary.status).toBe(outcome) + } + const serverHtml = renderToString( + , + ) + expect(serverHtml).toContain( + outcome === 'notFound' ? 'Boundary not found' : 'Boundary error', + ) + + const clientRouter = createRouter({ + ...makeRouteTree(), + history: createMemoryHistory({ + initialEntries: ['/parent/child'], + }), + }) + + window.$_TSR = { + router: { + manifest: { routes: {} }, + dehydratedData: {}, + matches: serverMatches.map((match) => ({ + i: match.id, + u: match.updatedAt, + s: match.status, + l: match.loaderData, + e: match.error, + ssr: match.ssr, + ...(match.globalNotFound ? { g: true } : {}), + })), + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + initialized: false, + } + + await hydrate(clientRouter) + + const container = document.createElement('div') + container.innerHTML = serverHtml + document.body.appendChild(container) + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}) + const root = hydrateRoot( + container, + , + ) + testCleanups.push(async () => { + await act(() => root.unmount()) + }) + + await act(async () => { + await Promise.resolve() + }) + + // A shorter dehydrated lane means SPA mode only for an actual shell. + // Here it is shorter because the server already rendered a terminal + // boundary, so hydration must not replace that boundary with pending UI. + expect(container).toHaveTextContent( + outcome === 'notFound' ? 'Boundary not found' : 'Boundary error', + ) + expect(container).not.toHaveTextContent('Boundary pending') + expect(consoleError.mock.calls.flat().join(' ')).not.toMatch( + /hydration|did not match/i, + ) + expect(childLoader).not.toHaveBeenCalled() + }, + ) +}) diff --git a/packages/react-router/tests/hydration-terminal-lane.test.tsx b/packages/react-router/tests/hydration-terminal-lane.test.tsx new file mode 100644 index 0000000000..d8e64c5944 --- /dev/null +++ b/packages/react-router/tests/hydration-terminal-lane.test.tsx @@ -0,0 +1,131 @@ +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, test, vi } from 'vitest' +import { hydrate } from '@tanstack/router-core/ssr/client' +import { + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' +import type { AnyRouteMatch } from '@tanstack/router-core' +import type { TsrSsrGlobal } from '@tanstack/router-core/ssr/client' + +function bootstrap( + matches: Array<{ + match: AnyRouteMatch + status: AnyRouteMatch['status'] + ssr: AnyRouteMatch['ssr'] + data?: unknown + error?: unknown + }>, +): void { + window.$_TSR = { + router: { + manifest: undefined, + matches: matches.map(({ match, status, ssr, data, error }) => ({ + i: match.id, + l: data, + e: error, + s: status, + ssr, + u: Date.now(), + })), + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + } as TsrSsrGlobal +} + +afterEach(() => { + cleanup() + delete window.$_TSR +}) + +describe('hydration terminal lane', () => { + test('keeps server data while loading only the missing client suffix', async () => { + const rootRoute = createRootRoute({ component: Outlet }) + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: () => 'client-parent', + component: () => ( + <> +
{parentRoute.useLoaderData()}
+ + + ), + }) + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: '/child', + ssr: false, + loader: () => 'client-child', + component: () =>
{childRoute.useLoaderData()}
, + }) + const router = createRouter({ + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + }) + const matches = router.matchRoutes(router.state.location) + bootstrap([ + { match: matches[0]!, status: 'success', ssr: true }, + { + match: matches[1]!, + status: 'success', + ssr: true, + data: 'server-parent', + }, + { match: matches[2]!, status: 'pending', ssr: false }, + ]) + + await hydrate(router) + render() + + expect(await screen.findByText('server-parent')).toBeInTheDocument() + expect(await screen.findByText('client-child')).toBeInTheDocument() + expect(screen.queryByText('client-parent')).not.toBeInTheDocument() + }) + + test('does not render serialized descendants below a server error', async () => { + const rootRoute = createRootRoute({ component: Outlet }) + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + errorComponent: ({ error }) =>
Parent failed: {error.message}
, + component: Outlet, + }) + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: '/child', + component: () =>
Child must stay omitted
, + }) + const router = createRouter({ + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + }) + const matches = router.matchRoutes(router.state.location) + bootstrap([ + { match: matches[0]!, status: 'success', ssr: true }, + { + match: matches[1]!, + status: 'error', + ssr: true, + error: new Error('server boom'), + }, + { match: matches[2]!, status: 'success', ssr: true }, + ]) + + await hydrate(router) + render() + + expect(await screen.findByText('Parent failed: server boom')).toBeVisible() + expect( + screen.queryByText('Child must stay omitted'), + ).not.toBeInTheDocument() + }) +}) diff --git a/packages/react-router/tests/issue-2905-root-beforeload-pending.test.tsx b/packages/react-router/tests/issue-2905-root-beforeload-pending.test.tsx new file mode 100644 index 0000000000..2821571c42 --- /dev/null +++ b/packages/react-router/tests/issue-2905-root-beforeload-pending.test.tsx @@ -0,0 +1,59 @@ +import * as React from 'react' +import { act, cleanup, render, screen } from '@testing-library/react' +import { afterEach, expect, test, vi } from 'vitest' +import { + RouterProvider, + createMemoryHistory, + createRootRoute, + createRouter, +} from '../src' + +afterEach(() => { + cleanup() + vi.useRealTimers() +}) + +// https://github.com/TanStack/router/issues/2905 +test('#2905: a root pendingComponent renders while root beforeLoad is pending', async () => { + vi.useFakeTimers() + + const rootRoute = createRootRoute({ + beforeLoad: async () => { + await new Promise((resolve) => setTimeout(resolve, 3_000)) + }, + pendingComponent: () =>
Root pending
, + component: () =>
Root
, + }) + const router = createRouter({ + routeTree: rootRoute, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render() + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) + + await act(async () => { + await vi.advanceTimersByTimeAsync(999) + }) + expect(screen.queryByTestId('root-pending')).not.toBeInTheDocument() + expect(screen.queryByTestId('root-content')).not.toBeInTheDocument() + + await act(async () => { + await vi.advanceTimersByTimeAsync(1) + }) + expect(screen.getByTestId('root-pending')).toBeInTheDocument() + expect(screen.queryByTestId('root-content')).not.toBeInTheDocument() + + await act(async () => { + await vi.advanceTimersByTimeAsync(1_999) + }) + expect(screen.getByTestId('root-pending')).toBeInTheDocument() + + await act(async () => { + await vi.advanceTimersByTimeAsync(1) + }) + expect(screen.queryByTestId('root-pending')).not.toBeInTheDocument() + expect(screen.getByTestId('root-content')).toBeInTheDocument() +}) diff --git a/packages/react-router/tests/issue-4476-react-query-cancellation.test.tsx b/packages/react-router/tests/issue-4476-react-query-cancellation.test.tsx new file mode 100644 index 0000000000..a7d060de6a --- /dev/null +++ b/packages/react-router/tests/issue-4476-react-query-cancellation.test.tsx @@ -0,0 +1,122 @@ +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react' +import { + QueryClient, + QueryClientProvider, + useQuery, +} from '@tanstack/react-query' +import { afterEach, expect, test, vi } from 'vitest' +import { + Link, + Outlet, + RouterProvider, + createControlledPromise, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +afterEach(cleanup) + +// https://github.com/TanStack/router/issues/4476 +test('#4476: pending navigation does not cancel fetchQuery when it unmounts the last useQuery observer', async () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: 0 } }, + }) + const queryGate = createControlledPromise() + const queryKey = ['issue-4476'] as const + const routeError = vi.fn() + const pageTwoBeforeLoad = vi.fn() + let querySignal: AbortSignal | undefined + + const rootRoute = createRootRoute({ + component: () => ( + <> + + Page two + + + + ), + }) + function PageOneComponent() { + const query = useQuery({ + queryKey, + queryFn: () => Promise.resolve(3), + }) + return
Page one: {query.data}
+ } + const pageOneRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: PageOneComponent, + }) + const pageTwoRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/page-two', + beforeLoad: async ({ preload }) => { + pageTwoBeforeLoad(preload) + if (preload) { + return + } + const data = await queryClient.fetchQuery({ + queryKey, + queryFn: ({ signal }) => { + querySignal = signal + return queryGate + }, + }) + return { data } + }, + errorComponent: ({ error }) => { + routeError(error) + return
{error.name}
+ }, + component: () => { + const { data } = pageTwoRoute.useRouteContext() + return
Page two: {data}
+ }, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([pageOneRoute, pageTwoRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + defaultPreload: 'intent', + defaultPreloadDelay: 0, + defaultPreloadStaleTime: 0, + defaultPendingMs: 0, + defaultPendingComponent: () =>
Loading page two
, + }) + + try { + render( + + + , + ) + expect(await screen.findByText('Page one: 3')).toBeInTheDocument() + + const link = screen.getByRole('link', { name: 'Page two' }) + fireEvent.mouseOver(link) + await waitFor(() => expect(pageTwoBeforeLoad).toHaveBeenCalledWith(true)) + fireEvent.click(link) + + await waitFor(() => expect(querySignal).toBeDefined()) + // Give the zero-delay pending transition a chance to unmount Page one. + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(querySignal?.aborted).toBe(false) + queryGate.resolve(10) + + expect(await screen.findByText('Page two: 10')).toBeInTheDocument() + expect(routeError).not.toHaveBeenCalled() + expect(screen.queryByTestId('page-two-error')).not.toBeInTheDocument() + } finally { + queryGate.resolve(10) + queryClient.clear() + } +}) diff --git a/packages/react-router/tests/issue-4759-pending-frame.test.tsx b/packages/react-router/tests/issue-4759-pending-frame.test.tsx new file mode 100644 index 0000000000..6a6435ee53 --- /dev/null +++ b/packages/react-router/tests/issue-4759-pending-frame.test.tsx @@ -0,0 +1,71 @@ +import * as React from 'react' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { act, cleanup, render, screen } from '@testing-library/react' +import { + RouterProvider, + createBrowserHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' +import type { RouterHistory } from '../src' + +let history: RouterHistory + +beforeEach(() => { + history = createBrowserHistory() + expect(window.location.pathname).toBe('/') +}) + +afterEach(() => { + history.destroy() + window.history.replaceState(null, 'root', '/') + vi.resetAllMocks() + cleanup() +}) + +// Repro for https://github.com/TanStack/router/issues/4759 +// +// With pendingMs: 0 the pending fallback must be visible from the very first +// paint of the initial load. Deferring pending publication to a macrotask +// (setTimeout) leaves at least one committed render where the router outputs +// nothing, which flashes the app shell background (the "red frame") before +// the pending UI appears. +describe('issue #4759: no blank frame before pending UI when pendingMs is 0', () => { + test('pending fallback is committed on mount without waiting for a macrotask', async () => { + let resolveLoader!: (value: string) => void + const loaderPromise = new Promise((resolve) => { + resolveLoader = resolve + }) + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + pendingMs: 0, + pendingMinMs: 0, + pendingComponent: () =>
pending...
, + loader: () => loaderPromise, + component: () =>
loaded
, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history, + }) + + render() + + // Flush microtasks only — deliberately no timer/macrotask turn. Any + // implementation that defers pending publication to setTimeout cannot + // have published yet, which is precisely the blank painted frame from + // the issue. The pending fallback must already be in the DOM here. + await act(async () => {}) + expect(screen.getByTestId('pending')).toBeInTheDocument() + + // Sanity: the load still completes normally afterwards. + resolveLoader('done') + expect(await screen.findByTestId('loaded')).toBeInTheDocument() + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() + }) +}) diff --git a/packages/react-router/tests/issue-5778-router-provider-context-preload.test.tsx b/packages/react-router/tests/issue-5778-router-provider-context-preload.test.tsx new file mode 100644 index 0000000000..b19aeffb6f --- /dev/null +++ b/packages/react-router/tests/issue-5778-router-provider-context-preload.test.tsx @@ -0,0 +1,102 @@ +import * as React from 'react' +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react' +import { afterEach, expect, test } from 'vitest' +import { + Link, + Outlet, + RouterProvider, + createMemoryHistory, + createRootRouteWithContext, + createRoute, + createRouter, +} from '../src' + +afterEach(cleanup) + +// https://github.com/TanStack/router/issues/5778 +test('#5778: intent preload sees a RouterProvider context update before the first navigation', async () => { + const seen: Array<{ + route: 'auth' | 'foo' + foo: string + cause: string + preload: boolean + }> = [] + const rootRoute = createRootRouteWithContext<{ foo: string }>()({ + component: () => ( + <> + + Foo + + + + ), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Home
, + }) + const authRoute = createRoute({ + getParentRoute: () => rootRoute, + id: '_authenticated', + beforeLoad: ({ context, cause, preload }) => { + seen.push({ route: 'auth', foo: context.foo, cause, preload }) + }, + component: () => , + }) + const fooRoute = createRoute({ + getParentRoute: () => authRoute, + path: '/foo', + beforeLoad: ({ context, cause, preload }) => { + seen.push({ route: 'foo', foo: context.foo, cause, preload }) + }, + component: () =>
Foo page
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + authRoute.addChildren([fooRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/'] }), + context: { foo: null! }, + defaultPreload: 'intent', + defaultPreloadDelay: 0, + }) + + function App() { + const [foo, setFoo] = React.useState('foo') + return ( + <> + + + + ) + } + + render() + expect(await screen.findByText('Home')).toBeInTheDocument() + expect(router.state.matches[0]?.context.foo).toBe('foo') + + fireEvent.click(screen.getByRole('button', { name: 'Update context' })) + await waitFor(() => expect(router.options.context.foo).toBe('baz')) + + seen.length = 0 + fireEvent.mouseOver(screen.getByRole('link', { name: 'Foo' })) + await waitFor(() => expect(seen).toHaveLength(2)) + expect(seen).toEqual([ + { route: 'auth', foo: 'baz', cause: 'preload', preload: true }, + { route: 'foo', foo: 'baz', cause: 'preload', preload: true }, + ]) +}) diff --git a/packages/react-router/tests/issue-6107-lazy-chunk-error-component.test.tsx b/packages/react-router/tests/issue-6107-lazy-chunk-error-component.test.tsx new file mode 100644 index 0000000000..b30446de5c --- /dev/null +++ b/packages/react-router/tests/issue-6107-lazy-chunk-error-component.test.tsx @@ -0,0 +1,84 @@ +import * as React from 'react' +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react' +import { afterEach, expect, test, vi } from 'vitest' +import { + Link, + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +afterEach(() => { + vi.restoreAllMocks() + cleanup() +}) + +// https://github.com/TanStack/router/issues/6107 +test('#6107: lazy chunk hover failure is non-fatal and navigation renders defaultErrorComponent', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const chunkError = new TypeError( + 'Failed to fetch dynamically imported module: /assets/posts.lazy.js', + ) + let lazyCalls = 0 + + const rootRoute = createRootRoute({ + component: () => ( + <> + + Posts + + + + ), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Index
, + }) + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + }).lazy(() => { + lazyCalls++ + return Promise.reject(chunkError) + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + defaultPreload: 'intent', + defaultPreloadDelay: 0, + defaultErrorComponent: ({ error }) => ( +
{error.message}
+ ), + }) + const preloadRoute = vi.spyOn(router, 'preloadRoute') + + render() + expect(await screen.findByText('Index')).toBeInTheDocument() + + const link = screen.getByRole('link', { name: 'Posts' }) + fireEvent.mouseOver(link) + await waitFor(() => expect(preloadRoute).toHaveBeenCalledTimes(1)) + await preloadRoute.mock.results[0]?.value + expect(lazyCalls).toBeGreaterThanOrEqual(1) + expect(screen.getByText('Index')).toBeInTheDocument() + expect(screen.queryByTestId('default-error')).not.toBeInTheDocument() + + const callsAfterPreload = lazyCalls + fireEvent.click(link) + expect(await screen.findByTestId('default-error')).toHaveTextContent( + chunkError.message, + ) + expect(lazyCalls).toBeGreaterThan(callsAfterPreload) + expect(router.state.location.pathname).toBe('/posts') +}) diff --git a/packages/react-router/tests/issue-7051-force-pending-suspense.test.tsx b/packages/react-router/tests/issue-7051-force-pending-suspense.test.tsx new file mode 100644 index 0000000000..7694b1d57e --- /dev/null +++ b/packages/react-router/tests/issue-7051-force-pending-suspense.test.tsx @@ -0,0 +1,92 @@ +import { act } from 'react' +import { afterEach, expect, test, vi } from 'vitest' +import { cleanup, render, screen } from '@testing-library/react' +import { + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +afterEach(() => { + vi.clearAllMocks() + cleanup() +}) + +// Ported from PR #7051. A forced-pending reload must keep showing its pending +// fallback until fresh content commits instead of exposing the error boundary. +test('invalidate({ forcePending: true }) keeps rendering the pending fallback instead of the error boundary', async () => { + const history = createMemoryHistory({ + initialEntries: ['/force-pending'], + }) + const errorComponentRendered = vi.fn() + let shouldSuspendReload = false + let resolveReload: (() => void) | undefined + + const rootRoute = createRootRoute({ + component: () => , + }) + + const forcePendingRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/force-pending', + pendingMs: 0, + pendingMinMs: 10, + loader: async () => { + if (shouldSuspendReload) { + await new Promise((resolve) => { + resolveReload = resolve + }) + } + + return 'done' + }, + component: () => ( +
+ {forcePendingRoute.useLoaderData()} +
+ ), + pendingComponent: () => ( +
Pending...
+ ), + errorComponent: ({ error }) => { + errorComponentRendered(error) + return
{String(error)}
+ }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([forcePendingRoute]), + history, + }) + + render() + + await act(() => router.load()) + expect(await screen.findByTestId('force-pending-route')).toHaveTextContent( + 'done', + ) + + shouldSuspendReload = true + let invalidation!: Promise + act(() => { + invalidation = router.invalidate({ forcePending: true }) + }) + + expect( + await screen.findByTestId('force-pending-fallback'), + ).toBeInTheDocument() + expect(errorComponentRendered).not.toHaveBeenCalled() + expect(screen.queryByTestId('force-pending-error')).not.toBeInTheDocument() + + act(() => { + resolveReload?.() + }) + + await act(() => invalidation) + expect(await screen.findByTestId('force-pending-route')).toHaveTextContent( + 'done', + ) +}) diff --git a/packages/react-router/tests/loaders.test.tsx b/packages/react-router/tests/loaders.test.tsx index d9b5968d72..307dcbcc66 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 UI when loaders finish before their pending delays', async () => { const defaultPendingComponentOnMountMock = vi.fn() const nestedPendingComponentOnMountMock = vi.fn() const fooPendingComponentOnMountMock = vi.fn() @@ -716,7 +716,6 @@ test('clears pendingTimeout when match resolves', async () => { }) render() - await act(() => router.latestLoadPromise) const linkToFoo = await screen.findByTestId('link-to-foo') fireEvent.click(linkToFoo) const fooElement = await screen.findByText('Nested Foo page') @@ -733,17 +732,20 @@ test('clears pendingTimeout when match resolves', async () => { test('throw abortError from loader upon initial load with basepath', async () => { window.history.replaceState(null, 'root', '/app') const rootRoute = createRootRoute({}) + const abortError = new DOMException('Aborted', 'AbortError') + const renderedError = vi.fn() const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', loader: async () => { - return Promise.reject(new DOMException('Aborted', 'AbortError')) + return Promise.reject(abortError) }, component: () =>
Index route content
, - errorComponent: () => ( -
indexErrorComponent
- ), + errorComponent: ({ error }) => { + renderedError(error) + return
indexErrorComponent
+ }, }) const routeTree = rootRoute.addChildren([indexRoute]) @@ -751,13 +753,58 @@ 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() + expect(await screen.findByTestId('index-error')).toBeInTheDocument() + expect(screen.queryByText('Index route content')).not.toBeInTheDocument() + expect(renderedError).toHaveBeenCalledWith(abortError) + expect( + router.state.matches.find((match) => match.routeId === indexRoute.id), + ).toMatchObject({ + status: 'error', + error: abortError, + }) expect(window.location.pathname.startsWith('/app')).toBe(true) }) -test('cancelMatches after pending timeout', async () => { +// https://github.com/TanStack/router/pull/7673 +test('#7673: aborted loader does not render the route component with undefined loaderData', async () => { + const abortError = new DOMException('Aborted', 'AbortError') + const routeComponentRendered = vi.fn() + const renderedError = vi.fn() + const rootRoute = createRootRoute({}) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + loader: (): Promise<{ value: string }> => Promise.reject(abortError), + component: () => { + routeComponentRendered() + const data = indexRoute.useLoaderData() + return
{data.value}
+ }, + errorComponent: ({ error }) => { + renderedError(error) + return
indexErrorComponent
+ }, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history, + }) + + render() + + expect(await screen.findByTestId('index-error')).toBeInTheDocument() + expect(screen.queryByTestId('index-content')).not.toBeInTheDocument() + expect(routeComponentRendered).not.toHaveBeenCalled() + expect(renderedError).toHaveBeenCalledWith(abortError) + expect( + router.state.matches.find((match) => match.routeId === indexRoute.id), + ).toMatchObject({ + status: 'error', + error: abortError, + }) +}) + +test('navigating away from a pending route aborts its loader', async () => { function getPendingComponent(onMount: () => void) { const PendingComponent = () => { useEffect(() => { @@ -811,7 +858,6 @@ test('cancelMatches after pending timeout', async () => { const routeTree = rootRoute.addChildren([fooRoute, barRoute]) const router = createRouter({ routeTree, history }) render() - await act(() => router.latestLoadPromise) const fooLink = await screen.findByTestId('link-to-foo') fireEvent.click(fooLink) await sleep(WAIT_TIME * 30) @@ -912,30 +958,25 @@ test('reproducer for #6388 - rapid navigation between parameterized routes shoul }) render() - await act(() => router.latestLoadPromise) - - const pendingComponent = screen.findByTestId('pending-component') expect(await screen.findByTestId('home-page')).toBeInTheDocument() - const param1Link = await screen.findByTestId('link-to-param-1') fireEvent.click(param1Link) - expect(await pendingComponent).toBeInTheDocument() + expect(await screen.findByTestId('pending-component')).toBeInTheDocument() const param2Link = await screen.findByTestId('link-to-param-2') fireEvent.click(param2Link) - expect(await pendingComponent).toBeInTheDocument() + expect(await screen.findByTestId('pending-component')).toBeInTheDocument() fireEvent.click(param1Link) - expect(await pendingComponent).toBeInTheDocument() + expect(await screen.findByTestId('pending-component')).toBeInTheDocument() - await act(() => router.latestLoadPromise) + const paramPage = await screen.findByTestId('param-page') expect(onAbortMock).toHaveBeenCalled() expect(errorComponentRenderCount).not.toHaveBeenCalled() expect(screen.queryByTestId('error-component')).not.toBeInTheDocument() - expect(await pendingComponent).not.toBeInTheDocument() + expect(screen.queryByTestId('pending-component')).not.toBeInTheDocument() - const paramPage = await screen.findByTestId('param-page') expect(paramPage).toBeInTheDocument() expect(paramPage).toHaveTextContent('Param Component 1 Done') expect(loaderCompleteMock).toHaveBeenCalled() diff --git a/packages/react-router/tests/on-rendered-same-href-state.test.tsx b/packages/react-router/tests/on-rendered-same-href-state.test.tsx new file mode 100644 index 0000000000..88e097a904 --- /dev/null +++ b/packages/react-router/tests/on-rendered-same-href-state.test.tsx @@ -0,0 +1,50 @@ +import { act } from 'react' +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import { afterEach, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { + Outlet, + RouterProvider, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +afterEach(() => { + cleanup() +}) + +test('onRendered fires for a same-href navigation with a new history key', async () => { + const rootRoute = createRootRoute({ component: () => }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Index
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render() + expect(await screen.findByText('Index')).toBeInTheDocument() + + const onRendered = vi.fn() + const unsubscribe = router.subscribe('onRendered', onRendered) + await act(() => + router.navigate({ + to: '/', + state: { sameHrefState: true } as any, + }), + ) + await waitFor(() => expect(onRendered).toHaveBeenCalledTimes(1)) + + const event = onRendered.mock.calls[0]![0] + expect(event.fromLocation?.state.sameHrefState).toBeUndefined() + expect(event.toLocation.state.sameHrefState).toBe(true) + expect(event.fromLocation?.href).toBe('/') + expect(event.toLocation.href).toBe('/') + expect(event.hrefChanged).toBe(false) + + unsubscribe() +}) diff --git a/packages/react-router/tests/pending-fallback-promise-replacement.test.tsx b/packages/react-router/tests/pending-fallback-promise-replacement.test.tsx new file mode 100644 index 0000000000..fc72418bf4 --- /dev/null +++ b/packages/react-router/tests/pending-fallback-promise-replacement.test.tsx @@ -0,0 +1,112 @@ +import * as React from 'react' +import { act } from 'react' +import { afterEach, expect, test, vi } from 'vitest' +import { cleanup, render, screen } from '@testing-library/react' +import { createControlledPromise } from '@tanstack/router-core' +import { + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +afterEach(() => { + vi.useRealTimers() + cleanup() +}) + +test('a mounted pending fallback follows a replacement load promise for the same match', async () => { + const firstReload = createControlledPromise() + const secondReload = createControlledPromise() + const reloads = [firstReload, secondReload] + let loaderCall = 0 + + const rootRoute = createRootRoute({ + component: () => , + }) + const pageRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/page', + pendingMs: 0, + pendingMinMs: 100, + pendingComponent: () =>
Loading page...
, + loader: () => { + const generation = ++loaderCall + const gate = reloads[generation - 2] + return gate ? gate.then(() => ({ generation })) : { generation } + }, + component: () => ( +
+ Generation {pageRoute.useLoaderData().generation} +
+ ), + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + }) + + render() + expect(await screen.findByText('Generation 1')).toBeInTheDocument() + + vi.useFakeTimers() + + let firstInvalidation!: Promise + await act(async () => { + firstInvalidation = router.invalidate({ forcePending: true }) + await vi.advanceTimersByTimeAsync(0) + }) + + expect(loaderCall).toBe(2) + expect(screen.getByTestId('pending')).toBeInTheDocument() + + await act(async () => { + await vi.advanceTimersByTimeAsync(25) + }) + + let secondInvalidation!: Promise + await act(async () => { + secondInvalidation = router.invalidate({ forcePending: true }) + await vi.advanceTimersByTimeAsync(0) + }) + + expect(loaderCall).toBe(3) + + await act(async () => { + firstReload.resolve() + await Promise.resolve() + }) + + // Completing the superseded generation cannot release the currently mounted + // fallback or restore its stale loader data. + expect(screen.getByTestId('pending')).toBeInTheDocument() + expect(screen.queryByText('Generation 2')).not.toBeInTheDocument() + + let secondSettled = false + void secondInvalidation.then(() => { + secondSettled = true + }) + await act(async () => { + secondReload.resolve() + await Promise.resolve() + }) + + expect(secondSettled).toBe(false) + expect(screen.getByTestId('pending')).toBeInTheDocument() + + await act(async () => { + await vi.advanceTimersByTimeAsync(74) + }) + expect(secondSettled).toBe(false) + expect(screen.getByTestId('pending')).toBeInTheDocument() + + await act(async () => { + await vi.advanceTimersByTimeAsync(1) + }) + await Promise.all([firstInvalidation, secondInvalidation]) + + expect(screen.getByText('Generation 3')).toBeInTheDocument() + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() +}) diff --git a/packages/react-router/tests/preloaded-mount-resolution.test.tsx b/packages/react-router/tests/preloaded-mount-resolution.test.tsx new file mode 100644 index 0000000000..2198e81348 --- /dev/null +++ b/packages/react-router/tests/preloaded-mount-resolution.test.tsx @@ -0,0 +1,96 @@ +import { afterEach, beforeEach, test, vi } from 'vitest' +import * as React from 'react' +import { createRoot } from 'react-dom/client' +import { createMemoryHistory } from '@tanstack/history' +import { + Outlet, + RouterProvider, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +/** + * A load that settles before RouterProvider mounts (or completes within the + * mount effect's batch) gives the Transitioner no isLoading flip to observe. + * The router status must still resolve to 'idle' with resolvedLocation set, + * and onRendered must fire — otherwise consumers waiting on those signals + * deadlock forever (this hung the memory-client benchmark for 6 hours). + * + * Uses a raw createRoot without the act() test environment: act-driven + * flushing re-renders between the isLoading toggles and masks the race. + * + * Note: vitest's jsdom scheduler still observes the flip more often than the + * benchmark's environment, so this test pins the CONTRACT; the deterministic + * regression guard for the original hang is the memory-client:react + * benchmark (benchmarks/memory/client/scenarios/mount-unmount), which CI runs. + */ + +let prevActEnv: unknown + +beforeEach(() => { + prevActEnv = (globalThis as any).IS_REACT_ACT_ENVIRONMENT + ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = false +}) + +afterEach(() => { + ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = prevActEnv +}) + +async function until(assert: () => boolean, what: string): Promise { + const deadline = Date.now() + 5000 + while (!assert()) { + if (Date.now() > deadline) { + throw new Error(`Timed out waiting for ${what}`) + } + await new Promise((resolve) => setTimeout(resolve, 10)) + } +} + +test('mounting after a settled load still resolves status and fires onRendered', async () => { + const rootRoute = createRootRoute({ + component: () => , + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + loader: () => 'home data', + component: () =>
Home
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + const onRendered = vi.fn() + const onResolved = vi.fn() + const onLoad = vi.fn() + const unsubscribers = [ + router.subscribe('onRendered', onRendered), + router.subscribe('onResolved', onResolved), + router.subscribe('onLoad', onLoad), + ] + const unsubscribe = () => unsubscribers.forEach((fn) => fn()) + const container = document.createElement('div') + document.body.appendChild(container) + const reactRoot = createRoot(container) + + try { + // Load fully settles before the provider mounts — the exact shape of the + // memory benchmark's mount/unmount cycle. + await router.load() + reactRoot.render() + + await until( + () => container.querySelector('[data-testid="home"]') !== null, + 'route content to render', + ) + await until(() => onRendered.mock.calls.length > 0, 'onRendered to fire') + await until(() => onResolved.mock.calls.length > 0, 'onResolved to fire') + await until(() => onLoad.mock.calls.length > 0, 'onLoad to fire') + } finally { + unsubscribe() + reactRoot.unmount() + container.remove() + } +}) diff --git a/packages/react-router/tests/issue-7457-redirect-chain-first-load.test.tsx b/packages/react-router/tests/redirect-chain-first-load.test.tsx similarity index 96% rename from packages/react-router/tests/issue-7457-redirect-chain-first-load.test.tsx rename to packages/react-router/tests/redirect-chain-first-load.test.tsx index 3583190d79..b3f4618a28 100644 --- a/packages/react-router/tests/issue-7457-redirect-chain-first-load.test.tsx +++ b/packages/react-router/tests/redirect-chain-first-load.test.tsx @@ -18,12 +18,13 @@ afterEach(() => { cleanup() }) -// https://github.com/TanStack/router/issues/7457 // A chain of async layout beforeLoad redirects during the very first load // (search-stripping self-redirect -> layout redirect -> child redirect) used // to leave a match rendering with a nulled loadPromise, crashing // MatchInnerImpl with an uncaught `undefined`. Pending UI is enabled for // every match (defaultPendingMs: 0) to force pending publication mid-chain. +// The production auto-code-splitting reproduction for issue #7457 lives in +// e2e/react-router/issue-7457. test('chained layout beforeLoad redirects on first load render the final target without throwing from MatchInner', async () => { const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) diff --git a/packages/react-router/tests/redirect.test.tsx b/packages/react-router/tests/redirect.test.tsx index cc15f0da36..11ff5696b9 100644 --- a/packages/react-router/tests/redirect.test.tsx +++ b/packages/react-router/tests/redirect.test.tsx @@ -14,7 +14,6 @@ import { Outlet, RouterProvider, createBrowserHistory, - createMemoryHistory, createRootRoute, createRoute, createRouter, @@ -45,6 +44,89 @@ const WAIT_TIME = 100 describe('redirect', () => { describe('SPA', () => { configure({ reactStrictMode: true }) + + test('allows a same-location redirect to settle after a side effect', async () => { + let firstLoad = true + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + loader: () => { + if (firstLoad) { + firstLoad = false + throw redirect({ to: '/' }) + } + }, + component: () =>
Index page
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history, + }) + + render() + + expect(await screen.findByText('Index page')).toBeInTheDocument() + expect(window.location.pathname).toBe('/') + }) + + test('renders an error for a same-location redirect cycle', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + loader: () => { + throw redirect({ to: '/' }) + }, + errorComponent: ({ error }) =>
{error.message}
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history, + }) + + render() + + expect( + await screen.findByText('Redirect cycle detected'), + ).toBeInTheDocument() + expect(window.location.pathname).toBe('/') + }) + + test('renders an error for an alternating redirect cycle', async () => { + const rootRoute = createRootRoute() + const errorComponent = ({ error }: { error: any }) => ( +
{error.message}
+ ) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + loader: () => { + throw redirect({ to: '/other' }) + }, + errorComponent, + }) + const otherRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/other', + loader: () => { + throw redirect({ to: '/' }) + }, + errorComponent, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, otherRoute]), + history, + }) + + render() + + expect( + await screen.findByText('Redirect cycle detected'), + ).toBeInTheDocument() + expect(['/', '/other']).toContain(window.location.pathname) + }) + test('when `redirect` is thrown in `beforeLoad`', async () => { const nestedLoaderMock = vi.fn() const nestedFooLoaderMock = vi.fn() @@ -155,7 +237,6 @@ describe('redirect', () => { expect(await screen.findByTestId('lazy-route-page')).toBeInTheDocument() expect(screen.queryByTestId('pending')).not.toBeInTheDocument() expect(router.state.location.href).toBe('/posts') - expect(router.state.status).toBe('idle') expect(consoleError).not.toHaveBeenCalled() }) @@ -311,116 +392,4 @@ describe('redirect', () => { expect(window.location.pathname).toBe('/final') }) }) - - describe('SSR', () => { - test('when `redirect` is thrown in `beforeLoad`', async () => { - const rootRoute = createRootRoute() - - const indexRoute = createRoute({ - path: '/', - getParentRoute: () => rootRoute, - beforeLoad: () => { - throw redirect({ - to: '/about', - }) - }, - }) - - const aboutRoute = createRoute({ - path: '/about', - getParentRoute: () => rootRoute, - component: () => { - return 'About' - }, - }) - - const router = createRouter({ - routeTree: rootRoute.addChildren([indexRoute, aboutRoute]), - // Mock server mode - isServer: true, - history: createMemoryHistory({ - initialEntries: ['/'], - }), - }) - - await router.load() - - expect(router.state.redirect).toBeDefined() - expect(router.state.redirect).toBeInstanceOf(Response) - const redirectResponse = router.state.redirect! - - expect(redirectResponse.options).toEqual({ - _fromLocation: expect.objectContaining({ - hash: '', - href: '/', - pathname: '/', - search: {}, - searchStr: '', - }), - to: '/about', - href: '/about', - statusCode: 307, - }) - }) - - test('when `redirect` is thrown in `loader`', async () => { - const rootRoute = createRootRoute() - - const indexRoute = createRoute({ - path: '/', - getParentRoute: () => rootRoute, - loader: () => { - throw redirect({ - to: '/about', - }) - }, - }) - - const aboutRoute = createRoute({ - path: '/about', - getParentRoute: () => rootRoute, - component: () => { - return 'About' - }, - }) - - const router = createRouter({ - history: createMemoryHistory({ - initialEntries: ['/'], - }), - routeTree: rootRoute.addChildren([indexRoute, aboutRoute]), - // Mock server mode - isServer: true, - }) - - await router.load() - - const currentRedirect = router.state.redirect - - expect(currentRedirect).toBeDefined() - expect(currentRedirect).toBeInstanceOf(Response) - const redirectResponse = currentRedirect! - expect(redirectResponse.status).toEqual(307) - expect(redirectResponse.headers.get('Location')).toEqual('/about') - expect(redirectResponse.options).toEqual({ - _fromLocation: { - external: false, - hash: '', - href: '/', - publicHref: '/', - pathname: '/', - search: {}, - searchStr: '', - state: { - __TSR_index: 0, - __TSR_key: redirectResponse.options._fromLocation!.state.__TSR_key, - key: redirectResponse.options._fromLocation!.state.key, - }, - }, - href: '/about', - to: '/about', - statusCode: 307, - }) - }) - }) }) diff --git a/packages/react-router/tests/root-pending-min.test.tsx b/packages/react-router/tests/root-pending-min.test.tsx new file mode 100644 index 0000000000..24ead693bb --- /dev/null +++ b/packages/react-router/tests/root-pending-min.test.tsx @@ -0,0 +1,375 @@ +import * as React from 'react' +import { act, cleanup, render, screen } from '@testing-library/react' +import { hydrateRoot } from 'react-dom/client' +import { renderToString } from 'react-dom/server' +import { afterEach, expect, test, vi } from 'vitest' +import { hydrate } from '../src/ssr/client' +import { + Outlet, + RouterProvider, + createControlledPromise, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +const testCleanups: Array<() => void | Promise> = [] + +afterEach(async () => { + while (testCleanups.length) { + await testCleanups.pop()!() + } + cleanup() + vi.useRealTimers() + delete window.$_TSR +}) + +test('root pending fallback remains visible through pendingMinMs', async () => { + vi.useFakeTimers() + + const loaderGate = createControlledPromise() + const rootRoute = createRootRoute({ + pendingMs: 0, + pendingMinMs: 100, + pendingComponent: () =>
Pending
, + loader: () => loaderGate, + component: () =>
Loaded
, + }) + const router = createRouter({ + routeTree: rootRoute, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render() + + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) + expect(screen.getByTestId('root-pending')).toBeInTheDocument() + expect(screen.queryByTestId('root-content')).not.toBeInTheDocument() + + loaderGate.resolve('loaded') + + await act(async () => { + await vi.advanceTimersByTimeAsync(99) + }) + expect(screen.getByTestId('root-pending')).toBeInTheDocument() + expect(screen.queryByTestId('root-content')).not.toBeInTheDocument() + + await act(async () => { + await vi.advanceTimersByTimeAsync(1) + }) + expect(screen.queryByTestId('root-pending')).not.toBeInTheDocument() + expect(screen.getByTestId('root-content')).toBeInTheDocument() +}) + +test('hydrated routers show the root pending fallback through pendingMinMs on a later reload', async () => { + const reloadGate = createControlledPromise() + const rootLoader = vi.fn(() => reloadGate.then(() => ({ generation: 2 }))) + const rootRoute = createRootRoute({ + pendingMs: 0, + pendingMinMs: 100, + pendingComponent: () =>
Pending
, + loader: rootLoader, + component: () => ( +
+ Generation {rootRoute.useLoaderData().generation} +
+ ), + }) + const router = createRouter({ + routeTree: rootRoute, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + const rootMatch = router.matchRoutes(router.latestLocation)[0]! + + // This is the same public bootstrap shape produced by the server. Calling + // hydrate() ensures router.ssr and the active match are established through + // the real client hydration path rather than by mutating router stores. + window.$_TSR = { + router: { + manifest: { routes: {} }, + dehydratedData: {}, + matches: [ + { + i: rootMatch.id, + s: 'success', + ssr: true, + l: { generation: 1 }, + u: Date.now(), + }, + ], + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + initialized: false, + } + + await hydrate(router) + expect(router.ssr).toBeDefined() + + render() + expect(screen.getByTestId('root-content')).toHaveTextContent('Generation 1') + expect(rootLoader).not.toHaveBeenCalled() + + vi.useFakeTimers() + + let invalidation!: Promise + await act(async () => { + invalidation = router.invalidate({ forcePending: true }) + await vi.advanceTimersByTimeAsync(0) + }) + + expect(rootLoader).toHaveBeenCalledTimes(1) + expect(screen.getByTestId('root-pending')).toBeInTheDocument() + expect(screen.queryByTestId('root-content')).not.toBeInTheDocument() + + await act(async () => { + reloadGate.resolve() + await Promise.resolve() + }) + + await act(async () => { + await vi.advanceTimersByTimeAsync(99) + }) + expect(screen.getByTestId('root-pending')).toBeInTheDocument() + expect(screen.queryByTestId('root-content')).not.toBeInTheDocument() + + await act(async () => { + await vi.advanceTimersByTimeAsync(1) + await invalidation + }) + expect(screen.queryByTestId('root-pending')).not.toBeInTheDocument() + expect(screen.getByTestId('root-content')).toHaveTextContent('Generation 2') +}) + +test('root pending fallback follows an overlapping forcePending generation', async () => { + const firstReload = createControlledPromise() + const secondReload = createControlledPromise() + const reloads = [firstReload, secondReload] + let loaderCall = 0 + + const rootRoute = createRootRoute({ + pendingMs: 0, + pendingMinMs: 100, + pendingComponent: () =>
Pending
, + loader: () => { + const generation = ++loaderCall + const gate = reloads[generation - 2] + return gate ? gate.then(() => ({ generation })) : { generation } + }, + component: () => ( +
+ Generation {rootRoute.useLoaderData().generation} +
+ ), + }) + const router = createRouter({ + routeTree: rootRoute, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render() + expect(await screen.findByText('Generation 1')).toBeInTheDocument() + + vi.useFakeTimers() + + let firstInvalidation!: Promise + await act(async () => { + firstInvalidation = router.invalidate({ forcePending: true }) + await vi.advanceTimersByTimeAsync(0) + }) + + expect(loaderCall).toBe(2) + expect(screen.getByTestId('root-pending')).toBeInTheDocument() + + await act(async () => { + await vi.advanceTimersByTimeAsync(25) + }) + + let secondInvalidation!: Promise + await act(async () => { + secondInvalidation = router.invalidate({ forcePending: true }) + await vi.advanceTimersByTimeAsync(0) + }) + + expect(loaderCall).toBe(3) + + await act(async () => { + firstReload.resolve() + await Promise.resolve() + }) + + expect(screen.getByTestId('root-pending')).toBeInTheDocument() + expect(screen.queryByText('Generation 2')).not.toBeInTheDocument() + + let secondSettled = false + void secondInvalidation.then(() => { + secondSettled = true + }) + await act(async () => { + secondReload.resolve() + await Promise.resolve() + }) + + expect(secondSettled).toBe(false) + expect(screen.getByTestId('root-pending')).toBeInTheDocument() + + await act(async () => { + await vi.advanceTimersByTimeAsync(74) + }) + expect(secondSettled).toBe(false) + expect(screen.getByTestId('root-pending')).toBeInTheDocument() + + await act(async () => { + await vi.advanceTimersByTimeAsync(1) + await Promise.all([firstInvalidation, secondInvalidation]) + }) + + expect(screen.getByTestId('root-content')).toHaveTextContent('Generation 3') + expect(screen.queryByTestId('root-pending')).not.toBeInTheDocument() +}) + +test('the root suspense boundary stays stable across hydration', async () => { + const mounts = vi.fn() + const unmounts = vi.fn() + const initializers = vi.fn(() => 'preserved') + + const rootRoute = createRootRoute({ + pendingComponent: () =>
Root pending
, + component: function RootComponent() { + const [value] = React.useState(initializers) + React.useEffect(() => { + mounts() + return unmounts + }, []) + return
{value}
+ }, + }) + const router = createRouter({ + routeTree: rootRoute, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + await router.load() + + // Model the server render and the client router produced by hydrate(). The + // outer root boundary is intentionally absent from both trees, while the + // route's own pending boundary is present in both. + router.ssr = { manifest: { routes: {} } } + router.isServer = true + const html = renderToString() + router.isServer = false + expect(html).toContain('') + + const container = document.createElement('div') + container.innerHTML = html + document.body.appendChild(container) + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const root = hydrateRoot(container, ) + testCleanups.push(async () => { + await act(() => root.unmount()) + consoleError.mockRestore() + container.remove() + }) + + await act(async () => { + await Promise.resolve() + }) + + expect(container).toHaveTextContent('preserved') + // One initializer belongs to the server render and one to client + // hydration. The stable boundary must preserve that hydrated client + // instance instead of creating a third one. + expect(initializers).toHaveBeenCalledTimes(2) + expect(mounts).toHaveBeenCalledTimes(1) + expect(unmounts).not.toHaveBeenCalled() + expect(consoleError).not.toHaveBeenCalled() +}) + +test('the root pending boundary contains a route component that suspends during server rendering', async () => { + const gate = createControlledPromise() + const rootRoute = createRootRoute({ + pendingComponent: () =>
Server root pending
, + component: () => { + throw gate + }, + }) + const router = createRouter({ + routeTree: rootRoute, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + router.isServer = true + await router.load() + + const html = renderToString() + + // renderToString cannot wait for Suspense, but the root route's stable + // boundary contains the suspension and emits its fallback. Streaming SSR + // can wait for the same boundary instead. + expect(html).toContain('Server root pending') +}) + +test('remounting a hydrated router loads a history change made while unmounted', async () => { + const rootRoute = createRootRoute({ component: Outlet }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Index route
, + }) + const nextRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/next', + component: () =>
Next route
, + }) + const history = createMemoryHistory({ initialEntries: ['/'] }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, nextRoute]), + history, + }) + const matches = router.matchRoutes(router.latestLocation) + const now = Date.now() + + window.$_TSR = { + router: { + manifest: { routes: {} }, + dehydratedData: {}, + matches: matches.map((match) => ({ + i: match.id, + s: 'success' as const, + ssr: true, + u: now, + })), + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + initialized: false, + } + + await hydrate(router) + const firstRender = render() + expect(screen.getByText('Index route')).toBeInTheDocument() + + firstRender.unmount() + history.push('/next') + + const secondRender = render() + expect(await screen.findByText('Next route')).toBeInTheDocument() + expect(router.state.location.pathname).toBe('/next') + + secondRender.unmount() + history.push('/next', { marker: 'new history entry' }) + + render() + await vi.waitFor(() => { + expect((router.state.location.state as any).marker).toBe( + 'new history entry', + ) + }) +}) diff --git a/packages/react-router/tests/router.test.tsx b/packages/react-router/tests/router.test.tsx index cf3b1c11fe..e8e216b549 100644 --- a/packages/react-router/tests/router.test.tsx +++ b/packages/react-router/tests/router.test.tsx @@ -8,7 +8,11 @@ import { waitFor, } from '@testing-library/react' import { z } from 'zod' -import { composeRewrites, notFound } from '@tanstack/router-core' +import { + composeRewrites, + createControlledPromise, + notFound, +} from '@tanstack/router-core' import { Link, Outlet, @@ -2205,11 +2209,13 @@ 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 () => { +describe('navigation outcomes', () => { + it('should recover from a not-found route when navigating to a valid route', async () => { const history = createMemoryHistory({ initialEntries: ['/'] }) - const rootRoute = createRootRoute() + const rootRoute = createRootRoute({ + notFoundComponent: () =>
Not Found
, + }) const indexRoute = createRoute({ getParentRoute: () => rootRoute, @@ -2228,23 +2234,21 @@ describe('statusCode', () => { render() - expect(router.state.statusCode).toBe(200) - await act(() => router.navigate({ to: '/' })) - expect(router.state.statusCode).toBe(200) + expect(await screen.findByText('Home')).toBeInTheDocument() await act(() => router.navigate({ to: '/non-existing' })) - expect(router.state.statusCode).toBe(404) + expect(await screen.findByText('Not Found')).toBeInTheDocument() await act(() => router.navigate({ to: '/valid' })) - expect(router.state.statusCode).toBe(200) + expect(await screen.findByText('Valid Route')).toBeInTheDocument() await act(() => router.navigate({ to: '/another-non-existing' })) - expect(router.state.statusCode).toBe(404) + expect(await screen.findByText('Not Found')).toBeInTheDocument() }) describe.each([true, false])( - 'status code is set when loader/beforeLoad throws (isAsync=%s)', + 'loader and beforeLoad outcomes are rendered (isAsync=%s)', async (isAsync) => { const throwingFun = isAsync ? (toThrow: () => void) => async () => { @@ -2259,7 +2263,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('should render notFoundComponent when a route loader throws a notFound()', async () => { const history = createMemoryHistory({ initialEntries: ['/'] }) const rootRoute = createRootRoute() @@ -2287,17 +2291,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('should render notFoundComponent when a route beforeLoad throws a notFound()', async () => { const history = createMemoryHistory({ initialEntries: ['/'] }) const rootRoute = createRootRoute({ @@ -2332,17 +2333,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('should render errorComponent when a route loader throws an Error', async () => { const history = createMemoryHistory({ initialEntries: ['/'] }) const rootRoute = createRootRoute() @@ -2368,15 +2366,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('should render errorComponent when a route beforeLoad throws an Error', async () => { const history = createMemoryHistory({ initialEntries: ['/'] }) const rootRoute = createRootRoute() @@ -2405,10 +2400,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() }) @@ -2417,7 +2409,7 @@ describe('statusCode', () => { }) describe('notFound in beforeLoad with pendingComponent', () => { - it('should transition router.state.status to idle when child beforeLoad throws notFound and parent has pendingComponent with pendingMs: 0', async () => { + it('renders notFound when child beforeLoad throws and parent has an immediate pending component', async () => { const history = createMemoryHistory({ initialEntries: ['/'] }) const rootRoute = createRootRoute({ @@ -2472,23 +2464,17 @@ describe('notFound in beforeLoad with pendingComponent', () => { render() - // Wait for initial load - await act(() => router.latestLoadPromise) - expect(router.state.status).toBe('idle') - expect(screen.getByTestId('home-page')).toBeInTheDocument() + expect(await screen.findByTestId('home-page')).toBeInTheDocument() - // Navigate to the child route that throws notFound in beforeLoad await act(() => router.navigate({ to: '/parent/child' })) - // The router status should eventually become idle - await waitFor(() => { - expect(router.state.status).toBe('idle') - }) - - expect(router.state.statusCode).toBe(404) + expect(await screen.findByTestId('parent-not-found')).toHaveTextContent( + 'Parent Not Found', + ) + expect(screen.queryByTestId('root-not-found')).not.toBeInTheDocument() }) - it('should transition router.state.status to idle when child beforeLoad throws notFound and parent has NO pendingComponent', async () => { + it('renders notFound when child beforeLoad throws without a pending component', async () => { const history = createMemoryHistory({ initialEntries: ['/'] }) const rootRoute = createRootRoute({ @@ -2521,19 +2507,14 @@ describe('notFound in beforeLoad with pendingComponent', () => { render() - await act(() => router.latestLoadPromise) - expect(router.state.status).toBe('idle') + expect(await screen.findByTestId('home-page')).toBeInTheDocument() await act(() => router.navigate({ to: '/child' })) - await waitFor(() => { - expect(router.state.status).toBe('idle') - }) - - expect(router.state.statusCode).toBe(404) + expect(await screen.findByText(/Not Found/)).toBeInTheDocument() }) - it('should transition router.state.status to idle when nested child beforeLoad throws notFound WITHOUT pendingComponent', async () => { + it('renders notFound when nested child beforeLoad throws without a pending component', async () => { const history = createMemoryHistory({ initialEntries: ['/'] }) const rootRoute = createRootRoute({ @@ -2580,20 +2561,16 @@ describe('notFound in beforeLoad with pendingComponent', () => { render() - await act(() => router.latestLoadPromise) - expect(router.state.status).toBe('idle') + expect(await screen.findByTestId('home-page')).toBeInTheDocument() await act(() => router.navigate({ to: '/parent/child' })) - await waitFor(() => { - expect(router.state.status).toBe('idle') - }) - - expect(router.state.statusCode).toBe(404) + expect(await screen.findByText(/Not Found/)).toBeInTheDocument() }) - it('should transition router.state.status to idle when child async beforeLoad throws notFound and parent has pendingComponent with pendingMs: 0', async () => { + it('renders the parent notFound after showing pending UI for the child', async () => { const history = createMemoryHistory({ initialEntries: ['/'] }) + const beforeLoad = createControlledPromise() const rootRoute = createRootRoute({ component: () => , @@ -2615,10 +2592,6 @@ describe('notFound in beforeLoad with pendingComponent', () => { const parentRoute = createRoute({ getParentRoute: () => rootRoute, path: '/parent', - pendingMs: 0, - pendingComponent: () => ( -
Loading...
- ), component: () => (
Parent @@ -2633,8 +2606,12 @@ describe('notFound in beforeLoad with pendingComponent', () => { const childRoute = createRoute({ getParentRoute: () => parentRoute, path: '/child', + pendingMs: 0, + pendingComponent: () => ( +
Loading...
+ ), beforeLoad: async () => { - await new Promise((resolve) => setTimeout(resolve, 10)) + await beforeLoad throw notFound() }, component: () =>
Child
, @@ -2648,20 +2625,28 @@ describe('notFound in beforeLoad with pendingComponent', () => { render() - // Wait for initial load - await act(() => router.latestLoadPromise) - expect(router.state.status).toBe('idle') - expect(screen.getByTestId('home-page')).toBeInTheDocument() + expect(await screen.findByTestId('home-page')).toBeInTheDocument() - // Navigate to the child route that throws notFound in beforeLoad - await act(() => router.navigate({ to: '/parent/child' })) - - // The router status should eventually become idle - await waitFor(() => { - expect(router.state.status).toBe('idle') + let navigation!: Promise + act(() => { + navigation = router.navigate({ to: '/parent/child' }) }) - expect(router.state.statusCode).toBe(404) + try { + expect(await screen.findByTestId('pending-component')).toHaveTextContent( + 'Loading...', + ) + } finally { + await act(async () => { + beforeLoad.resolve() + await navigation + }) + } + + expect(await screen.findByTestId('parent-not-found')).toHaveTextContent( + 'Parent Not Found', + ) + expect(screen.queryByTestId('root-not-found')).not.toBeInTheDocument() }) }) @@ -2867,7 +2852,6 @@ describe('Router rewrite functionality', () => { }, }) render() - await router.latestLoadPromise await waitFor(() => { expect(screen.getByTestId('component')).toHaveTextContent('test Users') }) @@ -3223,8 +3207,6 @@ describe('Router rewrite functionality', () => { const navigateBtn = await screen.findByTestId('navigate-btn') fireEvent.click(navigateBtn) - await router.latestLoadPromise - await screen.findByTestId('dashboard') // Router internal state should show the internal path @@ -3612,7 +3594,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..ec5901dfb9 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 () => { @@ -154,7 +154,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(2) }) test('sync beforeLoad', async () => { @@ -170,7 +170,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 +196,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/transactional-loading.test.tsx b/packages/react-router/tests/transactional-loading.test.tsx new file mode 100644 index 0000000000..001258f1f5 --- /dev/null +++ b/packages/react-router/tests/transactional-loading.test.tsx @@ -0,0 +1,210 @@ +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { + Link, + Outlet, + RouterProvider, + createBrowserHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' +import type { RouterHistory } from '../src' + +type Deferred = { + promise: Promise + resolve: (value: T) => void +} + +function deferred(): Deferred { + let resolve!: (value: T) => void + const promise = new Promise((resolver) => { + resolve = resolver + }) + return { promise, resolve } +} + +let history: RouterHistory + +beforeEach(() => { + history = createBrowserHistory() +}) + +afterEach(() => { + history.destroy() + window.history.replaceState(null, 'root', '/') + cleanup() + vi.useRealTimers() +}) + +describe('transactional route loading', () => { + test('publishes a parent and child background refresh atomically after the child observes fresh parent data', async () => { + const parentRefresh = deferred() + const childRefresh = deferred() + const childObservedFreshParent = deferred() + let parentLoads = 0 + let childLoads = 0 + + const rootRoute = createRootRoute({ + component: () => ( + <> + Other route + Data route + + + ), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Home route
, + }) + const otherRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/other', + component: () =>
Other route content
, + }) + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + staleTime: 0, + gcTime: 60_000, + loader: async () => { + parentLoads += 1 + if (parentLoads === 1) { + return 'parent-v1' + } + return parentRefresh.promise + }, + component: () => ( + <> +
{parentRoute.useLoaderData()}
+ + + ), + }) + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: '/child', + staleTime: 0, + gcTime: 60_000, + loader: async ({ parentMatchPromise }) => { + childLoads += 1 + const parentMatch = await parentMatchPromise + const parentData = parentMatch.loaderData as string + if (childLoads > 1) { + childObservedFreshParent.resolve(undefined) + await childRefresh.promise + } + return `child-saw-${parentData}` + }, + component: () =>
{childRoute.useLoaderData()}
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + otherRoute, + parentRoute.addChildren([childRoute]), + ]), + history, + }) + + render() + + expect(await screen.findByText('Home route')).toBeInTheDocument() + fireEvent.click(screen.getByText('Data route')) + + expect(await screen.findByText('parent-v1')).toBeInTheDocument() + expect(await screen.findByText('child-saw-parent-v1')).toBeInTheDocument() + + await new Promise((resolve) => setTimeout(resolve, 2)) + fireEvent.click(screen.getByText('Other route')) + expect(await screen.findByText('Other route content')).toBeInTheDocument() + + await new Promise((resolve) => setTimeout(resolve, 2)) + fireEvent.click(screen.getByText('Data route')) + + expect(await screen.findByText('parent-v1')).toBeInTheDocument() + expect(await screen.findByText('child-saw-parent-v1')).toBeInTheDocument() + + await act(async () => { + parentRefresh.resolve('parent-v2') + await childObservedFreshParent.promise + }) + + expect(screen.getByText('parent-v1')).toBeInTheDocument() + expect(screen.getByText('child-saw-parent-v1')).toBeInTheDocument() + expect(screen.queryByText('parent-v2')).not.toBeInTheDocument() + expect(screen.queryByText('child-saw-parent-v2')).not.toBeInTheDocument() + + await act(async () => { + childRefresh.resolve(undefined) + await Promise.resolve() + }) + + expect(await screen.findByText('parent-v2')).toBeInTheDocument() + expect(await screen.findByText('child-saw-parent-v2')).toBeInTheDocument() + expect(screen.queryByText('parent-v1')).not.toBeInTheDocument() + expect(screen.queryByText('child-saw-parent-v1')).not.toBeInTheDocument() + }) + + test('renders a leaf error at the leaf boundary when its background refresh fails', async () => { + let loads = 0 + const rootRoute = createRootRoute({ + component: () => ( + <> +
Root shell
+ + + ), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => Open child, + }) + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + component: () => ( + <> +
Parent shell
+ + + ), + }) + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: { + staleReloadMode: 'background', + handler: () => { + loads++ + if (loads > 1) { + throw new Error('background refresh failed') + } + return 'child data' + }, + }, + component: () =>
{childRoute.useLoaderData()}
, + errorComponent: () =>
Child refresh failed
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([childRoute]), + ]), + history, + }) + + render() + fireEvent.click(await screen.findByText('Open child')) + expect(await screen.findByText('child data')).toBeInTheDocument() + + await act(() => router.invalidate()) + + expect(await screen.findByText('Child refresh failed')).toBeInTheDocument() + expect(screen.getByText('Root shell')).toBeInTheDocument() + expect(screen.getByText('Parent shell')).toBeInTheDocument() + }) +}) diff --git a/packages/react-router/tests/transitioner-listener-errors.test.tsx b/packages/react-router/tests/transitioner-listener-errors.test.tsx new file mode 100644 index 0000000000..f380f68a88 --- /dev/null +++ b/packages/react-router/tests/transitioner-listener-errors.test.tsx @@ -0,0 +1,77 @@ +import * as React from 'react' +import { act, cleanup, render, screen } from '@testing-library/react' +import { afterEach, expect, test, vi } from 'vitest' +import { + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +const testCleanups: Array<() => void | Promise> = [] + +afterEach(async () => { + while (testCleanups.length) { + await testCleanups.pop()!() + } + cleanup() +}) + +test('a throwing load-event listener cannot interrupt route hooks or later navigations', async () => { + const firstOnEnter = vi.fn() + const secondOnEnter = vi.fn() + const listenerError = new Error('onLoad listener failed') + const laterOnLoad = vi.fn() + + const rootRoute = createRootRoute({ component: Outlet }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Index route
, + }) + const firstRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/first', + onEnter: firstOnEnter, + component: () =>
First route
, + }) + const secondRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/second', + onEnter: secondOnEnter, + component: () =>
Second route
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, firstRoute, secondRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render() + expect(await screen.findByText('Index route')).toBeInTheDocument() + + const unsubscribe = router.subscribe('onLoad', (event) => { + if (event.toLocation.pathname === '/first') { + throw listenerError + } + }) + const unsubscribeLater = router.subscribe('onLoad', (event) => { + if (event.toLocation.pathname !== '/') { + laterOnLoad(event) + } + }) + testCleanups.push(unsubscribeLater) + + await act(() => router.navigate({ to: '/first' })) + + expect(screen.getByText('First route')).toBeInTheDocument() + + unsubscribe() + await act(() => router.navigate({ to: '/second' })) + + expect(screen.getByText('Second route')).toBeInTheDocument() + expect(firstOnEnter).toHaveBeenCalledTimes(1) + expect(secondOnEnter).toHaveBeenCalledTimes(1) + expect(laterOnLoad).toHaveBeenCalledTimes(2) +}) diff --git a/packages/react-router/tests/transitioner-render-ack.test.tsx b/packages/react-router/tests/transitioner-render-ack.test.tsx new file mode 100644 index 0000000000..a60505eb5b --- /dev/null +++ b/packages/react-router/tests/transitioner-render-ack.test.tsx @@ -0,0 +1,85 @@ +import { StrictMode, act } from 'react' +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, expect, test } from 'vitest' +import { + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +afterEach(() => { + cleanup() +}) + +test('same-location invalidation resolves after its refreshed DOM commits', async () => { + let generation = 0 + const rootRoute = createRootRoute({ component: Outlet }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + loader: { + staleReloadMode: 'blocking', + handler: () => ++generation, + }, + component: () =>
Generation {indexRoute.useLoaderData()}
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render() + expect(await screen.findByText('Generation 1')).toBeInTheDocument() + + const refreshedDomWasVisible: Array = [] + const unsubscribe = router.subscribe('onResolved', () => { + refreshedDomWasVisible.push(screen.queryByText('Generation 2') !== null) + }) + + await act(() => router.invalidate()) + expect(await screen.findByText('Generation 2')).toBeInTheDocument() + expect(refreshedDomWasVisible).toEqual([true]) + + unsubscribe() +}) + +test('StrictMode effect replay preserves renderer commit sequencing', async () => { + const rootRoute = createRootRoute({ component: Outlet }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Index
, + }) + const nextRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/next', + component: () =>
Next
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, nextRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render( + + + , + ) + expect(await screen.findByText('Index')).toBeInTheDocument() + + const resolvedWithDestination = new Promise((resolve) => { + const unsubscribe = router.subscribe('onResolved', (event) => { + if (event.toLocation.pathname === '/next') { + expect(screen.getByText('Next')).toBeInTheDocument() + unsubscribe() + resolve() + } + }) + }) + + await act(() => router.navigate({ to: '/next' })) + await resolvedWithDestination +}) diff --git a/packages/react-router/tests/transitioner-router-swap.test.tsx b/packages/react-router/tests/transitioner-router-swap.test.tsx new file mode 100644 index 0000000000..32ca3be816 --- /dev/null +++ b/packages/react-router/tests/transitioner-router-swap.test.tsx @@ -0,0 +1,100 @@ +import { act } from 'react' +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import { afterEach, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { + Outlet, + RouterProvider, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +afterEach(() => { + cleanup() +}) + +function deferred() { + let resolve!: () => void + const promise = new Promise((r) => { + resolve = r + }) + return { promise, resolve } +} + +test('a load finishing on an old router does not resolve the replacement router', async () => { + const slowLoader = deferred() + const rootA = createRootRoute({ component: () => }) + const indexA = createRoute({ + getParentRoute: () => rootA, + path: '/', + component: () =>
Router A
, + }) + const slowA = createRoute({ + getParentRoute: () => rootA, + path: '/slow', + loader: () => slowLoader.promise, + component: () =>
Slow A
, + }) + const routerA = createRouter({ + routeTree: rootA.addChildren([indexA, slowA]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + const rootB = createRootRoute({ component: () => }) + const indexB = createRoute({ + getParentRoute: () => rootB, + path: '/b', + component: () =>
Router B
, + }) + const routerB = createRouter({ + routeTree: rootB.addChildren([indexB]), + history: createMemoryHistory({ initialEntries: ['/b'] }), + }) + + const view = render() + expect(await screen.findByText('Router A')).toBeInTheDocument() + + let oldNavigation!: Promise + act(() => { + oldNavigation = routerA.navigate({ to: '/slow' }) + }) + await waitFor(() => expect(routerA.state.isLoading).toBe(true)) + + const onRendered = vi.fn() + const unsubscribeRendered = routerB.subscribe('onRendered', onRendered) + view.rerender() + expect(await screen.findByText('Router B')).toBeInTheDocument() + await waitFor(() => expect(routerB.state.status).toBe('idle')) + await waitFor(() => expect(onRendered).toHaveBeenCalledTimes(1)) + expect(onRendered.mock.calls[0]![0].fromLocation).toBeUndefined() + expect(onRendered.mock.calls[0]![0].toLocation.pathname).toBe('/b') + + const onLoad = vi.fn() + const onBeforeRouteMount = vi.fn() + const onResolved = vi.fn() + const unsubscribers = [ + routerB.subscribe('onLoad', onLoad), + routerB.subscribe('onBeforeRouteMount', onBeforeRouteMount), + routerB.subscribe('onResolved', onResolved), + ] + + slowLoader.resolve() + await act(() => oldNavigation) + + // Flush the old transition's state update and the replacement provider's + // layout effects. Router B did no work during this interval. + await act(async () => {}) + + expect(onLoad).not.toHaveBeenCalled() + expect(onBeforeRouteMount).not.toHaveBeenCalled() + expect(onResolved).not.toHaveBeenCalled() + expect(routerA.state.isLoading).toBe(false) + expect(routerA.state.matches.at(-1)?.pathname).toBe('/slow') + expect(screen.getByText('Router B')).toBeInTheDocument() + expect(routerB.state.status).toBe('idle') + expect(routerB.state.resolvedLocation?.pathname).toBe('/b') + + unsubscribeRendered() + unsubscribers.forEach((unsubscribe) => unsubscribe()) +}) diff --git a/packages/react-router/tests/useNavigate.test.tsx b/packages/react-router/tests/useNavigate.test.tsx index a473e2c8b2..c255399da3 100644 --- a/packages/react-router/tests/useNavigate.test.tsx +++ b/packages/react-router/tests/useNavigate.test.tsx @@ -7,6 +7,7 @@ import { fireEvent, render, screen, + waitFor, } from '@testing-library/react' import { z } from 'zod' @@ -2415,9 +2416,9 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])( // Click the link and ensure the new location fireEvent.click(parentLink) - await router.latestLoadPromise - - expect(window.location.pathname).toBe(`${basepath}/a`) + await waitFor(() => { + expect(window.location.pathname).toBe(`${basepath}/a`) + }) }) test('should navigate to the parent route and keep params', async () => { @@ -2435,9 +2436,9 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])( // Click the link and ensure the new location fireEvent.click(parentLink) - await router.latestLoadPromise - - expect(window.location.pathname).toBe(`${basepath}/param/foo/a`) + await waitFor(() => { + expect(window.location.pathname).toBe(`${basepath}/param/foo/a`) + }) }) test('should navigate to the parent route and change params', async () => { @@ -2457,9 +2458,9 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])( // Click the link and ensure the new location fireEvent.click(parentLink) - await router.latestLoadPromise - - expect(window.location.pathname).toBe(`${basepath}/param/bar/a`) + await waitFor(() => { + expect(window.location.pathname).toBe(`${basepath}/param/bar/a`) + }) }) test('should navigate to a relative link based on render location with basepath', async () => { @@ -2476,9 +2477,9 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])( // Click the link and ensure the new location fireEvent.click(relativeLink) - await router.latestLoadPromise - - expect(window.location.pathname).toBe(`${basepath}/param/foo/a`) + await waitFor(() => { + expect(window.location.pathname).toBe(`${basepath}/param/foo/a`) + }) }) test('should navigate to a parent link based on render location', async () => { @@ -2497,9 +2498,9 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])( // Click the link and ensure the new location fireEvent.click(relativeLink) - await router.latestLoadPromise - - expect(window.location.pathname).toBe(`${basepath}/param/foo`) + await waitFor(() => { + expect(window.location.pathname).toBe(`${basepath}/param/foo`) + }) }) test('should navigate to a parent link based on active location', async () => { @@ -2515,9 +2516,9 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])( // Click the link and ensure the new location fireEvent.click(relativeLink) - await router.latestLoadPromise - - expect(window.location.pathname).toBe(`${basepath}/param/foo/a`) + await waitFor(() => { + expect(window.location.pathname).toBe(`${basepath}/param/foo/a`) + }) }) test('should navigate to same route with different params', async () => { @@ -2527,15 +2528,16 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])( await act(async () => { history.push(`${basepath}/param/foo/a/b`) - await router.latestLoadPromise }) - expect(window.location.pathname).toBe(`${basepath}/param/foo/a/b`) + await waitFor(() => { + expect(window.location.pathname).toBe(`${basepath}/param/foo/a/b`) + }) - const btn = screen.getByTestId('btn-param-bar') + const btn = await screen.findByTestId('btn-param-bar') fireEvent.click(btn) - await router.latestLoadPromise - - expect(window.location.pathname).toBe(`${basepath}/param/bar/a/b`) + await waitFor(() => { + expect(window.location.pathname).toBe(`${basepath}/param/bar/a/b`) + }) }) }, ) diff --git a/packages/router-core/INTERNALS.md b/packages/router-core/INTERNALS.md new file mode 100644 index 0000000000..cc05ae9555 --- /dev/null +++ b/packages/router-core/INTERNALS.md @@ -0,0 +1,969 @@ +# Router Match Loading Internals + +This document describes the match-loading architecture shared by router core and +the React, Solid, and Vue adapters. It is intended for maintainers changing +navigation, preloading, pending UI, SSR, hydration, route chunks, or route HMR. + +The implementation is deliberately a small ownership and publication protocol, +not a second match-status state machine. TypeScript brands constrain +phase-specific call sites, and closed outcome unions make control results +exhaustive. Reentrancy, cancellation, resource ownership, pending timing, and +publication remain runtime concerns enforced by the identities described below. + +All `_`-prefixed fields and functions discussed here are internal. Their exact +shape is not a compatibility contract. + +## Design constraints + +The loader is built around these rules: + +1. A foreground navigation builds a private lane. Only the current transaction + may commit it, and presentation remains separate from accepted semantics. +2. Context is materialized serially from parent to child. Independent loader and + chunk work starts concurrently, while each parallel phase consumes results in + structural route order. +3. Shared work never grants publication authority. Losing work releases its + resources and cannot publish after it becomes stale. +4. Runtime state exists only for a real ownership, publication, or completion + boundary. Phase naming and impossible local transitions belong in TypeScript. + +Correctness takes priority over size, but the architecture must remain lean. In +particular, do not reify TypeScript-only phases as runtime state, and do not add a +second cache for work already cached by the JavaScript module system. + +## Navigation at a glance + +The normal client path is: + +```text +Router.load lifecycle events + -> synchronous matching under foreground preflight ownership + -> install transaction and requested location + -> serial beforeLoad contextualization + -> concurrent loader and route-chunk work + -> phase-local reduction + -> projection + -> guarded foreground commit + -> transition acknowledgement + -> resolved location, idle status, and navigation completion +``` + +Pending presentation may publish while the private lane runs. A guarded +background reload may publish a later semantic replacement after the foreground +commit. Neither is a second foreground transaction. + +The location and match references intentionally advance at different moments: + +| Moment | `stores.location` | `stores.resolvedLocation` | `_committedMatches` | `stores.matches` | Status | +| --------------------------------------- | ----------------- | ------------------------- | ------------------- | ------------------------------ | --------- | +| Idle | old | old | old lane | old lane | `idle` | +| Transaction installed | new | old | old lane | old lane or new pending prefix | `pending` | +| Terminal commit, before acknowledgement | new | old | new lane | new lane | `pending` | +| Transition acknowledgement settled | new | new | new lane | new lane | `idle` | + +`latestLocation` is the parsed history input captured before planning. It is not +another published phase. In particular, `stores.location` advancing does not +mean that destination matches have committed or rendered. + +## File map + +- `src/router.ts` matches locations, owns the router-level authorities, and + starts client or server loads. +- `src/load-client.ts` contains client lanes, foreground transactions, loader + flights, preloads, pending presentation, background reloads, and atomic + commits. +- `src/load-server.ts` contains the request-local server lane and SSR selection. +- `src/hydrate.ts` reconstructs the server-resolved prefix and hands unresolved + work to the ordinary client loader. +- `src/route-chunks.ts` owns lazy-route option installation and component + preloading. +- `src/stores.ts` projects matches into fine-grained framework stores. These + stores are reactive presentation/indexing machinery, not another loading + authority. +- `src/ssr/` serializes and streams server results. +- `packages/{react,solid,vue}-router/src/Transitioner.*` connect history and + renderer transitions to router core. +- `packages/{react,solid,vue}-router/src/Match.*` render pending, error, + not-found, selective-SSR, and client-only boundaries. +- `packages/router-plugin/src/core/hmr/handle-route-update.ts` installs route + updates and asks core for a development-only refresh. + +## Vocabulary + +**Match** is the state for one route at one location. Its semantic fields include +route and `beforeLoad` context, loader data, status, and failure information. +Match identity is derived from route id, interpolated path, and loader +dependencies. It controls semantic and cache reuse. + +**Lane** is an ordered root-to-leaf array of matches for one location. Its +semantic result is private until commit; pending UI may project a presentation +prefix from it without acquiring semantic authority. + +**Transaction** is the identity that authorizes foreground publication for a +client navigation. It owns a lane, a cancellation controller, and a completion +promise. The latest transaction remains installed after settlement, so its mere +presence does not mean loading is active. + +**Presentation** is what `stores.matches` currently exposes to the framework. It +may temporarily be a shortened lane ending in a pending match. + +**Committed lane** is the accepted active semantic generation in +`router._committedMatches`. It normally contains the last terminal result and +remains the planning base while pending UI is presented. + +**Terminal lane** is a reduced and projected client lane ready for semantic +publication. It may end in success, an ordinary error, or not-found. + +**Resolved prefix** is the count of accepted leading matches whose semantic work +is already complete, usually from SSR hydration or the active lane. It is a +length, not a zero-based boundary index. + +**Terminal cache** is the set of off-screen semantic matches exposed by +`stores.cachedMatches`. The cache is neither active presentation nor a registry +of in-progress work. + +**Flight** is one loader generation and its outcome. Registry membership means a +flight may be joined; a lease means a particular match owns that generation and +its `AbortSignal`. + +**Projection** computes non-loader route output such as head metadata, scripts, +styles, and server headers after semantic outcomes have been reduced. + +**Authority** is an identity or state comparison whose equality permits a +publication or resource mutation. A boolean that merely restates an authority is +not a new authority. + +## The runtime authorities + +There are intentionally few independent authorities: + +| Authority | Meaning | Must not be used as | +| -------------------------- | -------------------------------------------------------------- | -------------------------------------- | +| `router._tx` | Latest foreground transaction; equality authorizes publication | A loading boolean; use `stores.status` | +| `router._committedMatches` | Accepted active semantic generation | The currently rendered lane | +| `stores.matches` | Current presentation | A semantic planning base | +| `stores.cachedMatches` | Off-screen semantic cache | Active routes or in-progress work | +| `stores.status` | Public loading-state projection | Foreground writer identity | +| `stores.location` | Requested location | Proof that loading finished | +| `stores.resolvedLocation` | Last location whose load/hydration settled | The requested location | +| `router._pending` | One pending reveal/minimum-duration session | A second navigation transaction | +| `router._preflight` | Owner of synchronous foreground planning | A second writer | +| `router._flights` | Joinable loader work by match id | Ownership of every flight | +| `route._lazy` | Current lazy-route import owner or loaded marker | A component-module cache | + +The most important separation is: + +```text +transaction/preload lanes -> private work; no semantic publication yet +_committedMatches -> active semantic reuse, planning, background CAS +stores.cachedMatches -> inactive semantic reuse and preload cache CAS +stores.matches -> renderer presentation, including pending prefixes +``` + +Pending presentation can remove hidden suffix entries from the reactive match +pool. Planning from it would therefore forget valid committed matches and make +presentation timing affect loader semantics. Once `_committedMatches` is +initialized, all semantic lookups use it and the terminal cache. + +### Completion scopes + +Promises also have deliberately narrow ownership: + +| Promise or acknowledgement | Scope | +| -------------------------- | ------------------------------------------------------ | +| Flight outcome | Shareable loader work | +| `tx.done` | One private foreground transaction | +| Pending/transition ack | Settlement and render confirmation for one publication | +| `commitLocationPromise` | History/navigation completion | +| `parentMatchPromise` | One loader's view of its semantic parent result | + +These scopes are not interchangeable. A new “latest load” promise usually +duplicates transaction or history-completion authority and makes supersession +ambiguous. + +## Planning a lane + +`matchRoutesInternal` performs synchronous, parent-first planning in two passes. + +The first pass: + +1. validates and accumulates search, +2. computes `loaderDeps`, +3. interpolates and parses params, +4. derives the match id, +5. reuses a committed or cached semantic match when allowed, and +6. creates a fresh work match otherwise. + +The second pass finalizes params and runs route `context` functions for fresh +matches in parent-first order. + +Route continuity and match reuse are related but distinct: + +- Match id controls semantic/cache reuse. +- Route id controls `enter`, `stay`, and `leave` lifecycle semantics. +- The previous active match for a route stabilizes params, search, and loader + dependencies. A cached match is not an active route merely because it is + reusable. + +### Why `_preflight` exists + +Planning calls user code synchronously: search validators, `loaderDeps`, param +parsers, route context, and related hooks can trigger reentrant navigation. +`assertMatchOwner` checkpoints after these calls. A new foreground plan aborts +the previous `_preflight`, so an obsolete stack frame cannot later install +itself as writer. + +`_preflight` is not a second completion authority. `_tx` remains the sole writer +until a replacement has finished matching successfully. Folding preflight into +`_tx` would require publishing a partially constructed transaction and then +rolling it back if matching throws. The small preflight controller represents a +real synchronous ownership boundary and avoids that invalid partial state. + +Client preloads do not replace `_preflight`. They use a local controller and a +snapshot of `_tx`, because speculative work may be canceled by foreground +navigation but may never claim foreground planning authority. A merely built or +blocked navigation likewise does not change `_tx`; only a successfully planned +foreground lane installs a replacement transaction. + +### Planning failures are not lane outcomes + +The owner of an error depends on whether matching produced a lane: + +- A foreground plan invalidated by reentrant navigation quietly follows the + successor transaction. A planning redirect starts a replacement navigation. + Any other planning failure leaves the previous committed lane and its + resources in place, then settles the pending history waiter. +- If client preload matching produces no lane because its local controller was + aborted, preload returns as canceled. If no lane was produced and the + controller is still current, the planning/input error is rethrown to the + caller. It must not become an unconditional core `console.error`. +- Once a lane exists, its ordinary route, loader, and chunk failures are reduced + as lane outcomes. The lane owner is then responsible for cleanup and redirect + handling. + +This boundary is why planning cancellation is detected from the existing +controller rather than another flag. + +## Typed lane pipeline + +Client lanes use phantom TypeScript phases: + +```text +MatchedLane -> ContextualizedLane -> ReducedLane -> ProjectedLane +``` + +The phase marker emits no runtime state and prevents accidental phase mixing in +phase-specific helper signatures. `executeClientLane` exposes only a projected +lane or a control outcome, and its current callers commit only the projected +branch. Runtime ownership, currentness, and the commit itself are not type +guarantees. The server uses corresponding matched, contextualized, and reduced +phases. + +Explicit construction casts mean these brands guard local orchestration, not the +whole runtime protocol. + +Client loader results are a closed tuple union: + +```text +success(data) | error(error) | notFound(error) | redirected(redirect) | canceled +``` + +The server counterpart uses `skipped` for an `ssr: false` loader instead of +`canceled`. + +The compact numeric representation is internal. The union matters because +cancellation and redirects are control outcomes, not match states, while errors +and not-found results become terminal match semantics at the reducer's selected +cutoff. Resolved and thrown redirects/not-found values normalize to the same +outcomes. `onError` may transform an ordinary error by throwing a control value. + +The client phase driver, `executeClientLane`, performs: + +```text +adopt flight leases + -> contextualize serially + -> start loader/chunk tasks concurrently + -> reduce outcomes in route order + -> project assets +``` + +No phase publishes router state. + +## Contextualization and concurrent work + +`beforeLoad` is deliberately serial. For each match, contextualization: + +1. handles param/search validation failures, +2. combines the completed parent context with route context, +3. reuses an accepted preloaded result only while the contiguous parent prefix + remains reusable, otherwise awaits `beforeLoad`, and +4. merges the selected result before advancing to the child. + +This guarantees that child `beforeLoad`, child loader context, and +`parentMatchPromise` never observe a partially updated parent context. + +### Match reuse, `beforeLoad` reuse, and loader reload are separate + +Matching first decides whether a semantic match object can be reused by id. +Execution then makes two independent decisions: + +- `beforeLoad` normally reruns for navigation. It is skipped only for an adopted + hydration prefix or a fresh, completed client-preload donor. The donor's + `beforeLoad` completion timestamp is its own freshness and cache-GC origin; + custom `shouldReload` remains loader-only. Donation is prefix-closed, so a + parent that cannot donate forces every descendant `beforeLoad` to rerun. +- Loader execution considers status, invalidation, configured `shouldReload`, + freshness, navigation cause/forced reload, and blocking versus background + reload mode. Reusing a match therefore does not imply reusing its loader data. + +An accepted `beforeLoad` donor may have run with `preload: true`. Navigation +intentionally reuses that context without rerunning the hook merely to call it +with `preload: false`. Pending or failed `beforeLoad` work is never donated. A +pending loader flight may still be shared while navigation runs its own serial +`beforeLoad` chain. + +`preload: false` does not suppress speculative `beforeLoad`: the hook still runs +with `preload: true`, but its context is not donated. The loader is skipped and +the match remains invalid, so navigation reruns `beforeLoad` and performs the +loader work. Normal component and pending-component readiness may still run for +the preload. + +On the client, after contextualization reaches its terminal prefix, task +creation starts loader and route-chunk work without awaiting siblings. Loaders +therefore run concurrently. Each client task exposes three related milestones: + +- `outcome`: the semantic loader result, +- `ready`: a successful outcome plus the route's render chunk readiness, and +- `match`: the parent-facing match promise. + +Separating `outcome` from `ready` is essential. A redirect or error can win +without waiting for irrelevant successful descendant chunks, while a successful +lane cannot commit before the chunks it needs to render are ready. + +`resolvedPrefix` skips repeated contextualization and loader work while +preserving the task chain needed for parent promises and chunk readiness. A +client preload derives it from the contiguous active successful same-id prefix. +The initial hydration handoff instead adopts its entire committed prefix or none +of it, under the href/key/id/currentness checks described below. Joinable work +already attached to an adopted prefix is still accounted for, and projection may +restart at its retained terminal match. + +## Phase-local reduction and failure boundaries + +Failure selection deliberately follows execution phases instead of running a +global comparator over every possible boundary. The client and server use the +same small reduction policy: + +1. Param/search validation and `beforeLoad` run serially. The first terminal + outcome stops contextualization. Route `context` ran earlier during planning; + if it throws, no lane exists and the planning-failure rules apply. +2. Loaders for the permitted ancestor prefix have already started concurrently. + Their promises are consumed from root to leaf. The first ordinary error or + not-found fills one loader-failure slot; redirect/cancel is kept separately as + control flow. +3. Control flow wins. Otherwise the loader-failure slot wins, falling back to + the serial failure when loaders succeeded. +4. The selected outcome determines one semantic cutoff. Only successful normal + chunks needed before that cutoff are considered, again in route order. The + first relevant chunk failure replaces the selected failure; if that failure + is a redirect, it becomes control flow. +5. Core applies the result once, trims once, and attempts the selected terminal + component once. Terminal-component preload is best-effort: rejection is + swallowed, does not call route `onError`, and does not restart selection. +6. Projection runs after semantic reduction and cannot replace its result. + +An ordinary serial failure still permits loaders above its effective boundary to +run, so the terminal render keeps the ancestor data it is allowed to expose. A +serial redirect/cancel starts no loader work. + +“First” inside a concurrent phase means structural route order, not promise +settlement timing. There is no error-over-not-found priority, render-boundary +ranking, sorting, or convergence loop. Consumption stops at the first structural +redirect/cancel; already-started later work may settle, but it is not considered +for this lane. + +An ordinary error marks its throwing match and trims descendants. The +framework's nested catch boundaries may ultimately render a parent, default, or +root error component. A not-found is assigned to the nearest eligible ancestor +not-found match before trimming. Its throwing route and terminal match may +therefore differ. A global root not-found remains a successful root match marked +`globalNotFound`, because the root shell still renders while presenting the +global not-found result. + +The server uses redirect control flow and `skipped` for `ssr: false`; the client +also has cancellation control flow. Those representation differences do not +change the failure-selection policy. + +A `globalNotFound` assigned while matching is planned presentation metadata, not +a thrown lane outcome. The client retains the planned lane and the framework +stops rendering below the marked boundary. The server caps and trims its +request-local lane at that boundary before rendering the 404. Do not feed this +case into the thrown-outcome reducer merely to make the two storage shapes look +identical. + +Redirect and cancellation outcomes never become committed match states. Internal +redirects are followed by a replacement navigation. Redirect depth is inherited +one hop at a time across the whole redirect-produced replacement chain and is +capped at 20. The current transaction's `redirecting` marker authorizes only its +redirect successor to inherit the count; an unrelated superseding navigation +must not inherit it. The count clears when a semantic lane completes without +redirecting. + +Projection runs only after reduction, so head/scripts/headers see the final lane +and loader data. Projection hooks for one route start together. Their results are +applied only if that group resolves; a rejection is logged and swallowed without +replacing the semantic result. Discarding a failed group means applying no new +projection output; reused matches may retain output from their previous +generation. + +## Foreground transaction lifecycle + +`RouterCore.load` refreshes `latestLocation` once, uses that same parsed object +for the history action and before-navigation events, and delegates to +`loadClientRouter`. The client loader must not parse a second location object; +location object identity is also how scroll restoration associates a history +action with the later lifecycle event. + +`loadClientRouter` first plans under `_preflight`. Only after planning succeeds +and both the preflight owner and previous writer are still current does it install +a `LoadTransaction` as `_tx`. Installation then: + +1. aborts and releases the previous writer's private lane, +2. publishes the requested location and `status: 'pending'` together, +3. offers pending presentation, and +4. runs the private lane pipeline. + +The transaction may publish a terminal lane only while `router._tx === tx`. +Currentness is checked before committing, inside the view transition, and after +the framework transition acknowledgement. A losing lane releases its matches and +returns without touching committed state. + +Expected route failures have already become typed lane outcomes by this point. +If orchestration itself rejects unexpectedly, the transaction aborts and +releases its private work, restores the committed lane as presentation, returns +the router to idle, and settles the pending history completion. It never +publishes a partially reduced lane or turns that orchestration failure into route +error UI. `tx.done` and `commitLocationPromise` are waiters, not publication +authority; equality with `_tx` remains the write guard. + +On success, `commitMatches`: + +1. derives the next terminal cache, +2. publishes presentation, `_committedMatches`, and the terminal cache in one + batch, +3. transfers flight leases from old/cache/private owners to the new owners, +4. clears the transaction's reference to its transferred lane, and +5. runs route enter/stay/leave callbacks. + +The `tx.matches = []` assignment is intentional. The same array has become the +committed lane, so setting its `.length = 0` would erase committed state. Replacing +the transaction's reference records the ownership transfer without adding an +ownership flag. + +Transition settlement gates resolved-location, idle, and history-completion +publication; a `true` acknowledgement additionally permits `onRendered`. + +`awaitCurrent` follows successor transactions, so a superseded call to +`router.load()` does not resolve while the navigation that replaced it is still +active. + +### Lifecycle event order + +Client lifecycle ordering is part of the protocol: + +1. `RouterCore.load` synchronously emits `onBeforeNavigate`, then `onBeforeLoad`. +2. Inside the framework transition, terminal matches/cache publish and route + `onLeave`/`onEnter`/`onStay` callbacks run. +3. If those callbacks did not supersede the writer, core emits `onLoad`, then + `onBeforeRouteMount`. The latter is therefore before destination mount/effects. +4. After the transition acknowledgement settles, core sets the resolved location + and idle status. It emits `onResolved` while the transaction is still current, + then emits `onRendered` only when the framework confirmed that the publication + rendered. + +Listeners may start another navigation. Currentness is checked while emitting +the pre-mount lifecycle: a route callback suppresses stale `onLoad`, and an +`onLoad` listener that supersedes the writer suppresses stale +`onBeforeRouteMount` and later completion publication. Listener exceptions are +isolated and do not stop later listeners or leave acknowledgement cleanup +unfinished. + +## Pending presentation and renderer acknowledgement + +Pending UI is a projection of an in-progress transaction, never a semantic lane. +`renderPending` publishes a cloned prefix ending at the selected pending +boundary. Every visible match is a snapshot with `_flight` removed, and only the +terminal snapshot changes status to `pending`. Presentation therefore never +shares a mutable match object with the private writer lane. A later failure may +bubble to a visible ancestor without mutating the pending UI in place or being +hidden from fine-grained match stores by unchanged object identity. + +The boundary is the shallowest non-success match, or the shallowest successful +match already visibly presented as pending. If that boundary has no route or +default pending component, or its effective `pendingMs` is non-numeric or +`Infinity`, no pending session is offered; core does not skip it to search for a deeper +fallback. Task readiness offers pending again, so the shallowest unresolved +boundary may move deeper as ancestors settle. An invalid boundary has zero +reveal delay; the pending-session rules still determine whether it is actually +presented and how long confirmed presentation remains. + +There is one `PendingSession`: + +```text +reveal deadline -> renderer acknowledgement -> minimum-visible deadline +``` + +Before acknowledgement, `deadline` is the time at which pending UI may be +offered. After the renderer reports that the pending lane was actually rendered, +the same field becomes the earliest time it may be replaced. This single absolute +deadline avoids copied timers and competing completion authorities. + +An acknowledgement **settles** when the framework transition is no longer +pending, even if it resolves `false`. A `true` value **confirms rendering**. +Settlement of the terminal commit gates resolved-location/idle publication; +confirmation alone gates `onRendered`, and confirmation of pending presentation +gates its minimum duration. + +A replacement transaction may adopt the session only if the same boundary index +has the same match id. Otherwise the old timer/session is discarded. A completed +transaction waits for `pendingMinMs` only when the renderer confirms that the +pending state appeared; queued work that never rendered does not incur a minimum +duration. If core reconstructs a session around an already-visible pending +boundary rather than adopting its original session, it conservatively starts the +minimum duration from the time it observes that presentation. + +Framework adapters implement `startTransition` as +`Promise`: + +- React queues acknowledgements around `React.startTransition`; `MatchesInner` + resolves them from a layout effect after the new matches render. Cleanup + resolves abandoned acknowledgements as `false`. +- Solid awaits `Solid.startTransition` and returns `true`. +- Vue runs the update, awaits `nextTick`, and returns `true`. +- Core's fallback applies synchronously and returns `false`, because it cannot + prove that a framework rendered the update. + +This acknowledgement is also why `onRendered` and navigation completion cannot +be inferred from a store write alone. In React, every publication passed through +`startTransition` must still cause the aggregate matches store to notify and the +layout effect to run. Reusing exact work objects while suppressing the match-store +write can strand the acknowledgement promise; preserve this liveness property +when optimizing store reconciliation. Test it through navigation completion and +rendered output, not `_rendered` itself. + +## Loader flights and resource ownership + +A `LoaderFlight` contains one outcome promise, its own abort controller, and a +lease count. The controller belongs to the shared work rather than any one +transaction. + +Two facts must remain separate: + +- `_flights.get(match.id) === flight` means the flight is joinable. +- `match._flight === flight` means that match owns one lease. + +`closeFlight` removes joinability but does not release the owner's lease. +`releaseFlight` removes one lease; only the last release aborts the loader and +removes the registry entry. `transferMatchResources` and +`discardMatchResources` are the bulk ownership operations, based on match object +identity. + +The lease intentionally may outlive promise settlement. Once successful data is +accepted, closing registry membership prevents new joins while the accepted +match keeps the loader generation's public `AbortSignal` alive. That signal is +aborted only when the last owner is replaced, unloaded, expired, or rejected from +cache. Promise state therefore cannot replace the lease count. + +Every semantic publication closes joinability for the published matches. A +later navigation cannot join already-published work through `_flights`, even +though the accepted matches continue to own their leases and signals. + +A navigation may join a preload's same-id flight. It adopts only a successful +outcome. If shared work yields an error, not-found, redirect, or cancellation, +the joining lane closes/releases that flight and runs its own loader. This lets +work be shared without allowing a preload's control outcome or failure context to +govern a navigation. + +Transaction cancellation races the shared promise through `waitFor`. Losing the +race releases that lane's lease; it does not abort work still leased elsewhere. +The flight also checks its own controller after both loader resolution and +rejection. A loader that ignores `AbortSignal` therefore still normalizes to +canceled and cannot resurrect obsolete cache state. + +Every losing, trimmed, superseded, expired, or cache-rejected match must release +its lease exactly once. Do not replace the lease protocol with boolean ownership +flags. + +## Client preloading and the terminal cache + +Client preloading uses the same matcher, contextualizer, task builder, reducer, +projector, and flight protocol as navigation, but it never becomes `_tx`. + +`beforeLoad` donation, loader reload, and `preload: false` behavior are defined in +the contextualization section above. Preloading adds speculative ownership, +redirect handling, and cache publication; it does not add another reuse policy. + +A preload snapshots the current writer and plans with an `_isCurrent` guard. It +derives the already-resolved active prefix, executes the lane, follows internal +redirects up to its redirect limit, and caches only an all-success result. +The planning cancellation/error distinction is described above. Routes with +`preload: false` still run `beforeLoad`, but skip their loader and remain invalid +as described in the reuse section. + +The `_isCurrent` guard protects synchronous planning. Once a preload lane exists, +a new foreground transaction does not blindly abort it: useful loader flights may +still be shared, while active-id and cache-entry identity checks prevent obsolete +cache publication. Unexpected post-plan exceptions belong to the preload lane. +It aborts its controller; while still current, it follows eligible redirects, +absorbs not-found, logs other unexpected failures, and releases its resources. A +superseded lane must likewise release its resources before returning. + +Cache publication is an identity compare-and-swap. A preload candidate is +discarded if it became active or if the corresponding cache entry changed since +planning. This prevents late preloads from overwriting fresher cache data without +adding a version counter. Development refreshes also reject context produced by a +`beforeLoad` function that is no longer installed, together with the descendant +suffix that depended on it. + +Foreground commits retain successful exiting matches only when they have either +fresh loader-backed data within the applicable normal/preload GC window or +reusable preloaded `beforeLoad` context within `preloadGcTime`. Active ids are +removed from the cache; failed, not-found, expired, and loaderless entries +without reusable context provenance are discarded. Clearing the cache releases +leases before publishing the retained cache entries. + +Server `preloadRoute` is different: it runs the request-local server lane and may +cache an all-success result, but it has no client transaction or loader-flight +protocol. + +## Background stale reloads + +A stale successful match may keep its current data visible while a private +candidate reloads in the background. This is used only for loader-backed, +non-preload, non-sync work whose effective stale reload mode is not blocking. + +Foreground reduction and commit proceed with the existing successful match. The +background candidate uses the same loader outcome and projection rules. It may +publish only if both conditions still hold: + +```text +router._tx === owningTransaction +router._committedMatches === exactForegroundBase +``` + +The writer check rejects superseding navigation. The reference-identity check is +an inexpensive compare-and-swap that rejects invalidation, rematching, another +background publication, or any reentrant commit based on the same location. Both +checks are required after asynchronous reduction/projection; one cannot replace +the other. + +A current background redirect uses the same guards and one-hop redirect depth +handoff. Losing background candidates release their resources. Background +publication updates matches through the normal publication primitive rather +than inventing a second commit protocol. + +A background lane intentionally has mixed ownership. Reload task indexes contain +private candidates, while untouched indexes still reference the committed base. +An ordinary failure can mutate its private candidate directly. If a not-found +bubbles to an untouched ancestor, reduction first clones that boundary and +removes `_flight`; the committed generation retains its object and lease until +the guarded publication succeeds. Trimming a background lane likewise leaves +shared suffix resources alone. The final transfer or discard of the background +batch is the single resource authority. + +Background publication replaces an already-resolved presentation directly. It +does not enter the pending/renderer-acknowledgement protocol, change +`stores.status` or `resolvedLocation`, or emit foreground navigation lifecycle +events. Framework stores still receive the ordinary match publication. + +## Invalidation + +Invalidation replaces the committed and cached semantic entries with invalid +generations, then routes the reload through the ordinary foreground transaction. +It does not mutate `stores.matches` merely to manufacture pending UI; the new +transaction owns that presentation decision. `forcePending` may mark invalidated +semantic entries as pending when the route has work. Even without `forcePending`, +an invalidated error or not-found entry with route work resets to pending and +clears its error. The renderer still changes only through pending presentation or +terminal commit. + +The invalidated clones carry the accepted generation's existing flight reference; +invalidation does not duplicate its lease or invent new loader ownership. + +Replacing `_committedMatches` also changes its array identity. Any background +candidate based on the previous generation therefore fails the existing +exact-base compare-and-swap. Invalidation needs no scheduler, version counter, or +background-specific cancellation flag. + +## Route chunks + +Normal successful readiness loads the route component and pending component. +Error and not-found component preloads are requested only for the semantic +terminal match selected by reduction. A framework may still bubble an ordinary +error to a visibly different ancestor catch boundary. Terminal-component +preloading is best-effort; failure is swallowed because semantic selection has +already finished. Client chunk work begins alongside loader work, but a normal +chunk failure is semantically considered only when its loader outcome succeeds. + +`route._lazy` has three compact states: + +```text +undefined = no current owner +Promise = current lazy option import +true = lazy options installed +``` + +Only the promise still stored in `_lazy` may install options or clear a failed +import. HMR clears `_lazy`, so a late obsolete import cannot mutate the updated +route. Lazy options never overwrite the generated route id. + +There is deliberately no component-promise cache. Dynamic import and framework +component preload machinery already cache loaded JavaScript modules. Repeating a +component preload call is cheaper and safer than maintaining another runtime +authority. `route._lazy` remains necessary because it owns lazy-option +installation, rejects obsolete HMR settlements, and resets failed imports for +retry. + +## Server loading + +Server loading is request-local and needs no global transaction coordinator. It +uses a separate typed lane with the same high-level ordering: + +```text +match -> resolve SSR + contextualize serially -> run loaders concurrently + -> select serial/loader result -> load normal chunks before its cutoff + -> apply and trim once -> preload terminal component once + -> project -> render or redirect +``` + +SSR policy is resolved parent-first before each route's `beforeLoad`: + +- `ssr: true` runs `beforeLoad`, loads data and render chunks, and projects + assets. +- `ssr: 'data-only'` loads data but does not render that route's component or + project its assets on the server. Its `beforeLoad` still runs. +- `ssr: false` skips server `beforeLoad`, loader, chunks, and assets for that + route. Its synchronous route context was already created during matching. +- A `false` parent forces descendants to `false`; a `data-only` parent caps any + `true` descendant at `data-only`, whether explicit, computed, or inherited. +- Shell mode renders only the root route. + +If an `ssr` callback throws, the server normalizes it through that route's +`onError`, records it as the serial failure for the route, and stops serial +descent just like a `beforeLoad` failure. + +Hydration adopts the semantic work produced by `true` and `data-only`; +`data-only` still requires client rendering, while `false` begins the unresolved +client suffix. Prefix adoption is governed by exact location and match identity, +not stale-time rules. The handoff rules are detailed below. + +Loaders start concurrently and retain parent match promises. The request-local +lane consumes their outcomes in route order, keeping redirect as control flow and +the first ordinary error/not-found as its loader failure. Only after loader +selection does the server start normal route-chunk work concurrently for the +permitted prefix and consume those results in route order. It applies and trims +once, then attempts the terminal component once. For a planned global 404, the +server preloads both the normal shell and not-found component at the boundary. + +Server projection runs only for `ssr: true` matches. Head, scripts, and headers +start together for each route. If any rejects, the rejection is logged and the +route's projection group is discarded; projection failure is non-fatal and never +overwrites loader semantics. + +The final result is a closed union: either a render result with status and +matches, or a redirect. `loadServerRouter` is the only publisher of that result +to router stores. + +## Hydration handoff + +Hydration reconstructs what the server actually resolved; it does not rerun a +parallel hydration loader. + +For the current location, `hydrate`: + +1. matches a fresh candidate lane, +2. walks the serialized server matches while ids agree, +3. copies serialized loader/before-load/error/SSR data into a local resolved + prefix, +4. loads the chunks needed to hydrate that prefix, +5. rebuilds complete route contexts before running any head/script hook, +6. rebuilds client head/script projection and derives the presentation frontier, + and +7. after all asynchronous work and currentness checks, installs + `_committedMatches` and publishes the presentation frontier. + +The presentation frontier is the candidate prefix initially shown to the +renderer. The committed prefix includes server-resolved `true` and `data-only` +matches and excludes the first `ssr: false`, pending, or otherwise unresolved +match. The frontier may include that first unresolved match, or a pending clone +for `data-only`. For the initial handoff, committed `beforeLoad` and loader work +is authoritative and does not rerun; hydration rebuilds route context, chunks, +and client head/script output as required. + +If the whole location was resolved, hydration sets `resolvedLocation`. Otherwise +the framework Transitioner observes the unresolved location and starts an +ordinary client transaction. That transaction adopts the whole committed prefix +or none of it. Adoption requires the same canonical href and history key, exact +match ids at every committed index, and no invalidated match or development +refresh. A terminal adopted prefix also caps the candidate lane, so descendants +omitted below a server error/not-found boundary cannot execute on the client. +These checks derive `resolvedPrefix` from existing authorities; there is no +hydration-specific scheduler, flag, or completion authority. + +Candidate matching may already have invoked route `context`. After lazy options +are installed, hydration deliberately rebuilds context parent-first for the +accepted prefix and awaits each result before projection. Context functions must +therefore tolerate this hydration re-entry. Hydration also copies each serialized +SSR decision onto the route options so later client planning and framework +rendering observe the server's resolved mode. + +Unlike ordinary decorative projection, a non-not-found head/script failure +during hydration rejects hydration. A not-found is logged and attached to its +match. Preserve this call-site-specific policy. + +Hydration snapshots `_tx` and checks it after asynchronous user/chunk work. A +navigation that starts during hydration wins; stale hydration does not overwrite +it. + +Framework `Match` implementations combine the SSR mode with match presentation. +`ssr: false` and `data-only` content is behind `ClientOnly`, with the pending +component as fallback. Solid's hydration signal must also respect +`Solid.sharedConfig.context`; a process-global “already hydrated” value alone can +otherwise reveal client-only content during a later hydration root. + +## Development route refresh + +Route HMR is a development-only client transaction, not direct mutation of live +match state. + +The plugin preserves generated route options and component identity where Fast +Refresh requires it, installs new route options, rebuilds route indexes, syncs +the hot module's route export, clears the lazy owner and path cache, and calls +`router._refreshRoute(routeId)`. Core then: + +1. aborts and retires joinable flights, +2. clears cached match leases, +3. plans synchronously while refusing semantic reuse for the changed route and + its descendants, and +4. commits through the ordinary foreground transaction protocol without pending + presentation. + +Flight/cache retirement is deliberately global because old route code may own +work outside the visible subtree. Semantic rematerialization remains scoped to +the changed route and descendants; ancestors stay reusable. + +The reuse predicate is planner-local. HMR does not temporarily mutate +`_committedMatches`, reach into loader-flight internals from the plugin, or create +a separate refresh lane. `_refreshRoute` and its special planning path must remain +dead-code-eliminable from production builds. + +## Framework bootstrap + +Each Transitioner subscribes to history before deciding whether an initial load +is needed. It canonicalizes the current URL, then either emits the initial +`onRendered` for an already-resolved location or calls `router.load()` once. + +On a canonical mismatch, the adapter subscribes first, performs a replacing +`commitLocation`, and returns; the resulting history notification performs the +sole load. For an already-resolved location, the canonical parsed href and +history state key must agree before the adapter skips loading and emits +`onRendered`. The same href with a new key is a distinct history entry and must +not be mistaken for an already-rendered mount. + +React additionally guards the router/history pair across Strict Mode effect +replay and refreshes `latestLocation` from history before canonicalization. +Swapping routers or histories still installs the correct subscription and initial +load. Listener errors must not prevent cleanup or leave transition +acknowledgements unresolved. + +## Invariants to preserve + +When modifying this system, keep these invariants explicit: + +1. `stores.matches` never becomes a semantic planning base after + `_committedMatches` exists. +2. Only the current `_tx` may redirect foreground client navigation, commit its + lane, or publish its completion; only the current `PendingSession` owner may + finish that session. +3. `_preflight` can invalidate synchronous foreground planning but cannot + publish; preload planning uses its local controller and foreground-owner + snapshot. +4. A foreground lane's semantic result remains private until one atomic terminal + commit; pending is presentation only. +5. Parent route context is materialized first, and parent `beforeLoad` context is + complete before child `beforeLoad` or loader work. +6. Match id controls reuse; route id controls enter/stay/leave semantics. +7. Concurrent loader and normal-chunk selection is structural, not + promise-settlement ordering. +8. Pending UI is a flight-free presentation clone and never semantic authority. +9. Flight registry membership and flight lease ownership are different facts; + an accepted generation's lease and public signal may outlive promise + settlement. +10. Every discarded match releases its resources exactly once. +11. A shared preload result may donate successful work, not control or failure + authority. +12. Preloaded `beforeLoad` provenance is independent from loader-data provenance, + and its reuse is prefix-closed. +13. Background publication requires both current-writer identity and exact-base + identity. +14. Hydration builds its resolved prefix privately, publishes it only after + currentness checks, and delegates the remainder to the normal client + transaction. +15. Planning cancellation is not a lane failure; a current preload planning + error remains the caller's error. +16. Invalidation replaces semantic generation identity and reloads through the + normal foreground protocol. +17. Route lazy ownership is not a general component cache. +18. Runtime state is added only for a real authority or resource owner; phase + naming and impossible transitions belong in TypeScript whenever possible. + +## Testing changes + +Tests should assert user-visible behavior: rendered content, loader calls and +signals, documented public router state, navigation promises, lifecycle events, +HTTP status, headers, redirects, hydration output, and absence of stale +publication. Avoid asserting private phase names, tuple tags, timers, internal +promises, or the exact shape of `_`-prefixed fields. + +When boundary selection is the behavior under test, give root, parent, and child +boundaries distinct visible output. Assert output unique to the intended boundary +and the absence of broader/default boundary output. A generic `/Not Found/` or +`/Error/` assertion is insufficient. + +Useful category-level suites include: + +- `packages/router-core/tests/client-lane-adversarial.test.ts` and + `preflight-reentrant-context.test.ts` for ordering and reentrancy; plus + `fatal-load-rejection.test.ts`, `blocked-navigation-current-load.test.ts`, and + `superseded-load-await.test.ts` for planning, rollback, and waiter ownership, +- `preload-adoption.test.ts`, `preload-public-cache-behavior.test.ts`, and + `preload-background-parent-coherence.test.ts` for shared work and cache + publication, and `preload-beforeload-reuse.test.ts` for serial context + donation, +- `background-assets-stale.test.ts`, `background-trim-abort.test.ts`, and + `invalidate-pre-rematch-failure.test.ts` for identity-CAS publication, +- `preload-public-signal-lifetime.test.ts` and `stay-match-abort.test.ts` for + flight lease and accepted-generation signal lifetime, +- `hydration-currentness.test.ts`, `hydration-boundary-chunks.test.ts`, and the + framework hydration suites for the server/client frontier, +- `server-concurrent-error-notfound.test.ts`, + `server-chunk-failure-lifecycle.test.ts`, and the selective-SSR E2E suites for + server reduction, +- framework `store-updates-during-navigation.test.*`, pending-min, transitioner + acknowledgement, and transactional-loading suites for rendered behavior. + +Framework coverage should include canonicalization, exact href/key reuse, +same-href new-key navigation, unmounted catch-up, remount/Strict Mode, +router/history swaps, transition acknowledgement, and listener failures. The +relevant suites follow the `transitioner-*`, `on-rendered-*`, +`preloaded-mount-*`, and listener-error naming patterns. + +Run category suites while developing, then the affected core/framework unit and +type suites. Changes to client runtime paths must also run the production bundle +benchmarks. Development-only helpers, diagnostics, and HMR paths must be verified +as absent from production output. + +If a regression appears to require another flag, counter, copied deadline, or +completion authority, stop and identify which existing authority is ambiguous. +Fix the category at the architecture boundary rather than layering a local patch +onto one test. diff --git a/packages/router-core/src/Matches.ts b/packages/router-core/src/Matches.ts index 852d186b67..78cd7d3aab 100644 --- a/packages/router-core/src/Matches.ts +++ b/packages/router-core/src/Matches.ts @@ -9,7 +9,7 @@ import type { RouteIds, } from './routeInfo' import type { AnyRouter, RegisteredRouter, SSROption } from './router' -import type { Constrain, ControlledPromise } from './utils' +import type { Constrain } from './utils' export type AnyMatchAndValue = { match: any; value: any } @@ -137,20 +137,6 @@ export interface RouteMatch< paramsError: unknown searchError: unknown updatedAt: number - _nonReactive: { - /** @internal */ - beforeLoadPromise?: ControlledPromise - /** @internal */ - loaderPromise?: ControlledPromise - /** @internal */ - pendingTimeout?: ReturnType - loadPromise?: ControlledPromise - displayPendingPromise?: Promise - minPendingPromise?: ControlledPromise - dehydrated?: boolean - /** @internal */ - error?: unknown - } loaderData?: TLoaderData /** @internal */ __routeContext?: Record @@ -170,8 +156,6 @@ export interface RouteMatch< staticData: StaticDataRouteOption /** This attribute is not reactive */ ssr?: SSROption - _forcePending?: boolean - _displayPending?: boolean } export interface PreValidationErrorHandlingRouteMatch< diff --git a/packages/router-core/src/hydrate.ts b/packages/router-core/src/hydrate.ts new file mode 100644 index 0000000000..233c6a30ee --- /dev/null +++ b/packages/router-core/src/hydrate.ts @@ -0,0 +1,246 @@ +import { invariant } from './invariant' +import { isNotFound } from './not-found' +import { loadRouteChunk } from './route-chunks' +import { hydrateSsrMatchId } from './ssr/ssr-match-id' +import type { AnyRouteMatch } from './Matches' +import type { AnyRoute } from './route' +import type { AnyRouter } from './router' +import type { GLOBAL_SEROVAL, GLOBAL_TSR } from './ssr/constants' +import type { AnySerializationAdapter } from './ssr/serializer/transformer' +import type { TsrSsrGlobal } from './ssr/types' + +declare global { + interface Window { + [GLOBAL_TSR]?: TsrSsrGlobal + [GLOBAL_SEROVAL]?: any + } +} + +export async function hydrate(router: AnyRouter): Promise { + if (!window.$_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!', + ) + } + invariant() + } + + const adapters = router.options.serializationAdapters as + | Array + | undefined + if (adapters?.length) { + window.$_TSR.t = new Map( + adapters.map((adapter) => [adapter.key, adapter.fromSerializable]), + ) + window.$_TSR.buffer.forEach((script) => script()) + } + window.$_TSR.initialized = true + + const dehydratedRouter = window.$_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!', + ) + } + invariant() + } + + for (const match of dehydratedRouter.matches) { + match.i = hydrateSsrMatchId(match.i) + } + router.ssr = { manifest: dehydratedRouter.manifest } + const nonce = ( + document.querySelector('meta[property="csp-nonce"]') as + | HTMLMetaElement + | undefined + )?.content + router.options.ssr = { nonce } + + const owner = router._tx + const isCurrent = () => router._tx === owner + + await router.options.hydrate?.(dehydratedRouter.dehydratedData) + if (!isCurrent()) { + return + } + router.updateLatestLocation() + const location = router.latestLocation + router.stores.location.set(location) + + const candidates = router.matchRoutes(location) + const committed: Array = [] + let index = 0 + let needsClientLoad: true | undefined + + for (const candidate of candidates) { + const dehydrated = dehydratedRouter.matches[index] + if (!dehydrated || dehydrated.i !== candidate.id) { + needsClientLoad = true + break + } + index++ + candidate.id = dehydrated.i + candidate.__beforeLoadContext = dehydrated.b + candidate.loaderData = dehydrated.l + candidate.status = dehydrated.s + candidate.ssr = dehydrated.ssr + ;(router.routesById as Record)[ + candidate.routeId + ]!.options.ssr = candidate.ssr + candidate.updatedAt = dehydrated.u + candidate.error = dehydrated.e + candidate.globalNotFound = dehydrated.g ?? candidate.globalNotFound + + if (candidate.ssr === false || candidate.status === 'pending') { + needsClientLoad = true + break + } + + committed.push(candidate) + if ( + candidate.status === 'error' || + candidate.status === 'notFound' || + candidate.globalNotFound + ) { + break + } + } + + try { + await Promise.all( + committed.map(async (match) => { + const route = (router.routesById as Record)[ + match.routeId + ]! + if (match.globalNotFound) { + await Promise.all([ + loadRouteChunk(route), + loadRouteChunk(route, 'notFoundComponent'), + ]) + } else { + await loadRouteChunk( + route, + match.status === 'error' + ? 'errorComponent' + : match.status === 'notFound' + ? 'notFoundComponent' + : undefined, + ) + } + }), + ) + } catch (cause) { + if (!isCurrent()) { + return + } + throw cause + } + if (!isCurrent()) { + return + } + + // Install lazy options first, then build every context before any head hook + // receives the lane through `matches`. + for (const match of committed) { + const route = (router.routesById as Record)[ + match.routeId + ]! + const parentContext = + committed[match.index - 1]?.context ?? router.options.context + if (route.options.context) { + match.__routeContext = + (await route.options.context({ + deps: match.loaderDeps, + params: match.params, + context: parentContext ?? {}, + location, + navigate: (opts: any) => + router.navigate({ ...opts, _fromLocation: location }), + buildLocation: router.buildLocation, + cause: match.cause, + abortController: match.abortController, + preload: false, + matches: committed, + routeId: route.id, + })) ?? undefined + if (!isCurrent()) { + return + } + } + match.context = { + ...parentContext, + ...match.__routeContext, + ...match.__beforeLoadContext, + } + } + + for (const match of committed) { + try { + const route = (router.routesById as Record)[ + match.routeId + ]! + const context = { + ssr: router.options.ssr, + matches: committed, + match, + params: match.params, + loaderData: match.loaderData, + } + const head = await route.options.head?.(context) + if (!isCurrent()) { + return + } + const scripts = await route.options.scripts?.(context) + if (!isCurrent()) { + return + } + match.meta = head?.meta + match.links = head?.links + match.headScripts = head?.scripts + match.styles = head?.styles + match.scripts = scripts + } catch (cause) { + if (!isCurrent()) { + return + } + if (isNotFound(cause)) { + match.error = { isNotFound: true } + console.error( + `NotFound error during hydration for routeId: ${match.routeId}`, + cause, + ) + } else { + match.error = cause + console.error( + `Error during hydration for route ${match.routeId}:`, + cause, + ) + throw cause + } + } + } + + let presented = candidates.slice(0, index) + for (let boundary = 0; boundary < committed.length; boundary++) { + if (committed[boundary]!.ssr === 'data-only') { + presented = presented.slice(0, boundary + 1) + presented[boundary] = { + ...presented[boundary]!, + status: 'pending', + } + needsClientLoad = true + break + } + } + + router._committedMatches = committed + router.batch(() => { + router.stores.setMatches(presented) + router.stores.status.set('idle') + if (!needsClientLoad) { + router.stores.resolvedLocation.set(router.stores.location.get()) + } + }) +} diff --git a/packages/router-core/src/isServer/client.ts b/packages/router-core/src/isServer/client.ts index f2f86b6ec7..bd0e6bfe84 100644 --- a/packages/router-core/src/isServer/client.ts +++ b/packages/router-core/src/isServer/client.ts @@ -1 +1,3 @@ export const isServer = false +export const loadServer: never = undefined as never +export const loadServerRouter: never = undefined as never diff --git a/packages/router-core/src/isServer/development.ts b/packages/router-core/src/isServer/development.ts index 3632fabf05..5a19543e21 100644 --- a/packages/router-core/src/isServer/development.ts +++ b/packages/router-core/src/isServer/development.ts @@ -1,2 +1,3 @@ // Development/test mode - returns undefined so fallback to router.isServer is used export const isServer: boolean | undefined = undefined +export { loadServer, loadServerRouter } from '../load-server' diff --git a/packages/router-core/src/isServer/server.ts b/packages/router-core/src/isServer/server.ts index cfa181440a..e363962e89 100644 --- a/packages/router-core/src/isServer/server.ts +++ b/packages/router-core/src/isServer/server.ts @@ -1 +1,2 @@ export const isServer = process.env.NODE_ENV === 'test' ? undefined : true +export { loadServer, loadServerRouter } from '../load-server' diff --git a/packages/router-core/src/load-client.ts b/packages/router-core/src/load-client.ts new file mode 100644 index 0000000000..f0e83c680d --- /dev/null +++ b/packages/router-core/src/load-client.ts @@ -0,0 +1,1582 @@ +// Keep this filename free of a secondary extension so declaration generation +// can rewrite relative imports for both ESM and CJS. +import { isNotFound } from './not-found' +import { isRedirect } from './redirect' +import { loadRouteChunk } from './route-chunks' +import { getLocationChangeInfo } from './router' +import type { ParsedLocation } from './location' +import type { AnyRouteMatch } from './Matches' +import type { NotFoundError } from './not-found' +import type { + AnyRoute, + BeforeLoadContextOptions, + LoaderFnContext, +} from './route' +import type { AnyRedirect } from './redirect' +import type { AnyRouter } from './router' + +declare const lanePhase: unique symbol + +type Phase = 'matched' | 'contextualized' | 'reduced' | 'projected' + +type Lane = { + readonly [lanePhase]?: TPhase + location: ParsedLocation + matches: Array + background?: Array +} + +type MatchedLane = Lane<'matched'> + +type ContextualizedLane = Lane<'contextualized'> + +type ReducedLane = Lane<'reduced'> + +export type ProjectedLane = Lane<'projected'> + +const SUCCESS = 0 +const ERROR = 1 +const NOT_FOUND = 2 +// Control outcomes stay contiguous so the hot path can test them together. +const REDIRECTED = 3 +const CANCELED = 4 + +type LoaderOutcome = + | [typeof SUCCESS, data: unknown] + | [typeof ERROR, error: unknown] + | [typeof NOT_FOUND, error: NotFoundError] + | [typeof REDIRECTED, redirect: AnyRedirect] + | [typeof CANCELED] + +type IndexedOutcome = [index: number, outcome: LoaderOutcome] + +export type LoaderFlight = [ + outcome: Promise, + controller: AbortController, + leases: number, +] + +type WorkMatch = AnyRouteMatch & { + _flight?: LoaderFlight + _preloadContext?: number + _preloadBeforeLoad?: AnyRoute['options']['beforeLoad'] +} + +type ClientRouter = AnyRouter & { + _flights?: Map +} + +export type LoadTransaction = { + location: ParsedLocation + controller: AbortController + matches: Array + done: Promise + redirects?: number + redirecting?: true +} + +export type PendingSession = { + owner: LoadTransaction + boundary: number + /** Pending reveal time until acknowledged, then minimum-visible-until time. */ + deadline: number + timer?: ReturnType + ack?: Promise +} + +type CoordinatorRouter = ClientRouter & { + _tx?: LoadTransaction + _committedMatches?: Array + _pending?: PendingSession + /** Cancels reentrant synchronous planning without replacing the current writer. */ + _preflight?: AbortController +} + +type WorkerRouter = Pick< + AnyRouter, + 'buildLocation' | 'navigate' | 'options' | 'routeTree' | 'routesById' +> + +type LoaderTask = { + outcome: Promise + ready: Promise + match?: Promise +} + +type BackgroundLoaderTask = LoaderTask & { + index: number + candidate: WorkMatch +} + +export type ExecuteLaneOptions = { + preload?: boolean + sync?: boolean + forceStaleReload?: boolean + controller: AbortController + base?: Array + resolvedPrefix?: number + redirects?: number + onReady?: () => void +} + +type ControlOutcome = + | [typeof REDIRECTED, redirect: AnyRedirect] + | [typeof CANCELED] + +export type LaneResult = ProjectedLane | ControlOutcome + +function waitFor( + value: T | PromiseLike, + signal: AbortSignal, +): Promise { + if (signal.aborted) { + return Promise.reject(signal) + } + return new Promise((resolve, reject) => { + const abort = () => reject(signal) + signal.addEventListener('abort', abort, { once: true }) + Promise.resolve(value) + .then(resolve, reject) + .finally(() => { + signal.removeEventListener('abort', abort) + }) + }) +} + +function getRoute(router: WorkerRouter, match: WorkMatch): AnyRoute { + return (router.routesById as Record)[match.routeId]! +} + +function normalize( + value: unknown, + rejected: boolean, + routeId?: string, +): LoaderOutcome { + if (isRedirect(value)) { + return [REDIRECTED, value] + } + if (isNotFound(value)) { + value.routeId ||= routeId + return [NOT_FOUND, value] + } + return rejected ? [ERROR, value] : [SUCCESS, value] +} + +function normalizeError(route: AnyRoute, cause: unknown): LoaderOutcome { + let outcome = normalize(cause, true, route.id) + if (outcome[0] !== ERROR) { + return outcome + } + try { + route.options.onError?.(cause) + } catch (onErrorCause) { + outcome = normalize(onErrorCause, true, route.id) + } + return outcome +} + +function navigateFrom(router: WorkerRouter, location: ParsedLocation) { + return (opts: any) => + router.navigate({ + ...opts, + _fromLocation: location, + }) +} + +async function contextualize( + router: WorkerRouter, + lane: MatchedLane, + options: ExecuteLaneOptions, +): Promise { + let reusePreloadContext = !options.preload + for ( + let index = options.resolvedPrefix ?? 0; + index < lane.matches.length; + index++ + ) { + const match = lane.matches[index]! + const route = getRoute(router, match) + const serialError = match.paramsError ?? match.searchError + + if (serialError !== undefined) { + releaseFlight(router as ClientRouter, match) + return [index, normalizeError(route, serialError)] + } + + // Fresh matches already own this lane's controller; cached matches do not. + reusePreloadContext &&= match.abortController !== options.controller + match.abortController = options.controller + // Contextualization is serial, so the previous match already contains the + // complete parent context for this route. + const parentContext = + lane.matches[index - 1]?.context ?? router.options.context ?? {} + const context = { + ...parentContext, + ...match.__routeContext, + } + + const beforeLoad = route.options.beforeLoad + if (!beforeLoad) { + match.__beforeLoadContext = {} + match.context = context + continue + } + + const preload = !!options.preload + // A child can only reuse context from the same parent generation. + const donor = match._preloadContext + const reuse = + reusePreloadContext && + donor != null && + !shouldReloadMatch(router, match, route, options, undefined, donor) + match._preloadContext = undefined + if (process.env.NODE_ENV !== 'production') { + match._preloadBeforeLoad = undefined + } + if (reuse) { + match.context = { + ...context, + ...match.__beforeLoadContext, + } + continue + } + reusePreloadContext = false + + const beforeLoadContext: BeforeLoadContextOptions< + any, + any, + any, + any, + any, + any, + any, + any, + any + > = { + search: match.search, + abortController: match.abortController, + params: match.params, + preload, + context, + location: lane.location, + navigate: navigateFrom(router, lane.location), + buildLocation: router.buildLocation, + cause: preload ? 'preload' : match.cause, + matches: lane.matches, + routeId: route.id, + ...router.options.additionalContext, + } + + try { + match.isFetching = 'beforeLoad' + const result = await waitFor( + beforeLoad(beforeLoadContext), + options.controller.signal, + ) + match.isFetching = false + const outcome = normalize(result, false, route.id) + if (outcome[0] !== SUCCESS) { + releaseFlight(router as ClientRouter, match) + return [index, outcome] + } + match.__beforeLoadContext = result + match.context = { + ...context, + ...result, + } + if (preload && route.options.preload !== false) { + match._preloadContext = Date.now() + if (process.env.NODE_ENV !== 'production') { + match._preloadBeforeLoad = beforeLoad + } + } + } catch (cause) { + match.isFetching = false + if (cause === options.controller.signal) { + return [index, [CANCELED]] + } + if (cause instanceof Promise) { + throw cause + } + releaseFlight(router as ClientRouter, match) + return [index, normalizeError(route, cause)] + } + } + + return +} + +function releaseFlight(router: ClientRouter, match: WorkMatch): void { + const flight = match._flight + if (!flight) { + return + } + match._flight = undefined + if (!--flight[2]) { + flight[1].abort() + if (router._flights?.get(match.id) === flight) { + router._flights.delete(match.id) + } + } +} + +export function discardMatchResources( + router: AnyRouter, + matches: Array, +): void { + transferMatchResources(router, matches, []) +} + +export function transferMatchResources( + router: AnyRouter, + previous: Array, + next: Array, +): void { + for (const match of previous as Array) { + if (!next.includes(match)) { + releaseFlight(router as ClientRouter, match) + } + } +} + +function startFlight( + router: ClientRouter, + key: string, + route: AnyRoute, + invoke: (controller: AbortController) => unknown, +): LoaderFlight { + const controller = new AbortController() + const flight: LoaderFlight = [ + Promise.resolve() + .then(() => invoke(controller)) + .then( + (value) => + controller.signal.aborted + ? [CANCELED] + : normalize(value, false, route.id), + (cause) => + controller.signal.aborted ? [CANCELED] : normalizeError(route, cause), + ), + controller, + 1, + ] + ;(router._flights ??= new Map()).set(key, flight) + return flight +} + +function closeFlight(router: ClientRouter, match: WorkMatch): void { + const flight = match._flight + if (!flight) { + return + } + if (router._flights?.get(match.id) === flight) { + router._flights.delete(match.id) + } +} + +function getLoaderContext( + router: WorkerRouter, + lane: ContextualizedLane, + match: WorkMatch, + route: AnyRoute, + controller: AbortController, + parentMatchPromise: Promise | undefined, + preload: boolean, +): LoaderFnContext { + return { + params: match.params, + deps: match.loaderDeps, + preload, + parentMatchPromise: parentMatchPromise as any, + abortController: controller, + context: match.context, + location: lane.location, + navigate: navigateFrom(router, lane.location), + cause: preload ? 'preload' : match.cause, + route, + ...router.options.additionalContext, + } +} + +async function loadResource( + router: ClientRouter, + workerRouter: WorkerRouter, + lane: ContextualizedLane, + match: WorkMatch, + route: AnyRoute, + parentMatchPromise: Promise | undefined, + preload: boolean, + signal: AbortSignal, +): Promise { + if (signal.aborted) { + return [CANCELED] + } + const routeLoader = route.options.loader + const loader = + typeof routeLoader === 'function' ? routeLoader : routeLoader?.handler + if (!loader) { + return [SUCCESS, undefined] + } + + let flight = match._flight + let joined = !!flight && router._flights?.get(match.id) === flight + for (;;) { + if (!joined) { + releaseFlight(router, match) + flight = startFlight(router, match.id, route, (controller) => + loader( + getLoaderContext( + workerRouter, + lane, + match, + route, + controller, + parentMatchPromise, + preload, + ), + ), + ) + } + match._flight = flight + match.abortController = flight![1] + match.isFetching = 'loader' + try { + const outcome = await waitFor(flight![0], signal) + if (!joined || outcome[0] === SUCCESS) { + return outcome + } + } catch (cause) { + if (cause === signal) { + releaseFlight(router, match) + return [CANCELED] + } + throw cause + } + closeFlight(router, match) + releaseFlight(router, match) + joined = false + } +} + +function shouldReloadMatch( + router: WorkerRouter, + match: WorkMatch, + route: AnyRoute, + options: ExecuteLaneOptions, + configured?: any, + donor?: number, +): boolean { + if (match.status !== 'success') { + return true + } + + const preload = !!options.preload + const preloadStaleTime = route.options.preloadStaleTime + const staleAge = + preload || + ((donor != null || match.preload) && preloadStaleTime !== undefined) + ? (preloadStaleTime ?? router.options.defaultPreloadStaleTime ?? 30_000) + : (route.options.staleTime ?? router.options.defaultStaleTime ?? 0) + return !!( + match.invalid || + configured || + (configured === undefined && + Date.now() - (donor ?? match.updatedAt) >= staleAge && + (options.forceStaleReload || + match.cause === 'enter' || + options.base?.some( + (candidate) => + candidate.routeId === match.routeId && candidate.id !== match.id, + ))) + ) +} + +function applySuccess(match: WorkMatch, data: unknown): void { + match.loaderData = data + match.error = undefined + match.status = 'success' + match.invalid = false + match.isFetching = false + match.updatedAt = Date.now() +} + +function createLoaderTask( + router: ClientRouter, + workerRouter: WorkerRouter, + lane: ContextualizedLane, + index: number, + tasks: Array, + semanticParent: Promise | undefined, + options: ExecuteLaneOptions, +): Promise { + const match = lane.matches[index]! + const route = getRoute(workerRouter, match) + const preload = !!options.preload + let reload = false + let reloadFailure: LoaderOutcome | undefined + try { + if (index < (options.resolvedPrefix ?? 0)) { + reload = !!router._flights?.get(match.id) + } else { + let configured + if (match.status === 'success') { + configured = route.options.shouldReload + if (typeof configured === 'function') { + configured = configured( + getLoaderContext( + workerRouter, + lane, + match, + route, + options.controller, + tasks[index - 1]?.match, + preload, + ), + ) + } + } + reload = shouldReloadMatch( + workerRouter, + match, + route, + options, + configured, + ) + } + } catch (cause) { + releaseFlight(router, match) + reloadFailure = normalizeError(route, cause) + } + const routeLoader = route.options.loader + const background = !!( + routeLoader && + reload && + match.status === 'success' && + !preload && + !options.sync && + ((typeof routeLoader === 'function' + ? undefined + : routeLoader?.staleReloadMode) ?? + workerRouter.options.defaultStaleReloadMode) !== 'blocking' + ) + const skippedPreload = preload && route.options.preload === false + const loaded = reload && !skippedPreload + if (skippedPreload) { + match.status = 'success' + match.invalid = true + match.isFetching = false + } + const rawOutcome = reloadFailure + ? Promise.resolve(reloadFailure) + : !loaded || background + ? Promise.resolve([SUCCESS, match.loaderData]) + : loadResource( + router, + workerRouter, + lane, + match, + route, + tasks[index - 1]?.match, + preload, + options.controller.signal, + ) + const outcome = rawOutcome.then((result) => { + if (loaded && !background && result[0] === SUCCESS) { + applySuccess(match, result[1]) + match.preload = preload + } + return result + }) + + const chunkFailure = Promise.resolve() + .then(() => loadRouteChunk(route)) + .then( + () => undefined, + (cause): IndexedOutcome => [index, normalizeError(route, cause)], + ) + const ready = outcome + .then((value) => (value[0] === SUCCESS ? chunkFailure : undefined)) + .then((value) => { + options.onReady?.() + return value + }) + + const task = { + outcome, + ready, + match: outcome.then(() => match), + } + tasks.push(task) + if (!background) { + return task.match + } + const candidate: WorkMatch = { + ...match, + _flight: undefined, + } + const backgroundOutcome = loadResource( + router, + workerRouter, + lane, + candidate, + route, + semanticParent, + false, + options.controller.signal, + ).then((result) => { + if (result[0] === SUCCESS) { + applySuccess(candidate, result[1]) + } + return result + }) + const backgroundMatch = backgroundOutcome.then(() => candidate) + const backgroundTask = { + index, + candidate, + outcome: backgroundOutcome, + ready: task.ready, + } + ;(lane.background ??= []).push(backgroundTask) + return backgroundMatch +} + +function getNotFoundBoundary( + router: WorkerRouter, + matches: Array, + indexed: IndexedOutcome, +): number { + const [throwingIndex, outcome] = indexed + const cause = outcome[1] as NotFoundError + let index = cause.routeId + ? matches.findIndex((match) => match.routeId === cause.routeId) + : throwingIndex + if (index < 0) { + index = 0 + } + for (let i = index; i >= 0; i--) { + if (getRoute(router, matches[i]!).options.notFoundComponent) { + return i + } + } + return cause.routeId ? index : 0 +} + +async function reduceLane( + router: WorkerRouter & ClientRouter, + lane: ContextualizedLane, + tasks: Array, + options: ExecuteLaneOptions, + serialFailure?: IndexedOutcome, +): Promise { + // Background reductions do not compare against the committed lane. + const background = !options.base + let control = + (serialFailure?.[1][0] ?? 0) >= REDIRECTED ? serialFailure : undefined + let loaderFailure: IndexedOutcome | undefined + + for (let index = 0; index < tasks.length; index++) { + const task = tasks[index]! + const outcome = await task.outcome + const taskIndex = (task as BackgroundLoaderTask).index ?? index + if (outcome[0] >= REDIRECTED) { + control = [taskIndex, outcome] + break + } + if (outcome[0] !== SUCCESS) { + loaderFailure ||= [taskIndex, outcome] + } + } + + let failure = loaderFailure ?? serialFailure + + if ( + control?.[1][0] === REDIRECTED && + !control[1][1].options.reloadDocument && + (options.redirects ?? 0) >= 20 + ) { + failure = [control[0], [ERROR, new Error('Redirect cycle detected')]] + control = undefined + } + + if (!control) { + const readinessEnd = failure + ? failure[1][0] === NOT_FOUND + ? getNotFoundBoundary(router, lane.matches, failure) + : failure[0] + : lane.matches.length + for (let index = 0; index < tasks.length; index++) { + const task = tasks[index]! + if (((task as BackgroundLoaderTask).index ?? index) >= readinessEnd) { + break + } + const chunkFailure = await task.ready + if (chunkFailure) { + if (chunkFailure[1][0] === REDIRECTED) { + if ( + !chunkFailure[1][1].options.reloadDocument && + (options.redirects ?? 0) >= 20 + ) { + failure = [ + chunkFailure[0], + [ERROR, new Error('Redirect cycle detected')], + ] + } else { + control = chunkFailure + } + } else { + failure = chunkFailure + } + break + } + } + } + + if (control) { + if (lane.background) { + discardMatchResources( + router as AnyRouter, + lane.background.map((task) => task.candidate), + ) + lane.background = undefined + } + return control[1] as ControlOutcome + } + + if (failure) { + const [index, outcome] = failure + const kind = outcome[0] + const boundary = + kind === NOT_FOUND + ? getNotFoundBoundary(router, lane.matches, failure) + : index + let match = lane.matches[boundary]! + if ( + background && + !(tasks as Array).some((task) => task.index === boundary) + ) { + match = lane.matches[boundary] = { ...match, _flight: undefined } + } + const cause = outcome[1] + match.globalNotFound = undefined + if (kind === ERROR) { + match.status = 'error' + } else { + ;(cause as NotFoundError).routeId = match.routeId + if (match.routeId === router.routeTree.id) { + match.status = 'success' + match.globalNotFound = true + } else { + match.status = 'notFound' + } + } + match.error = cause + match.isFetching = false + try { + await loadRouteChunk( + getRoute(router, match), + kind === ERROR ? 'errorComponent' : 'notFoundComponent', + ) + } catch {} + trimLane(router, lane, boundary + 1, background) + return lane as ReducedLane + } + + return lane as ReducedLane +} + +function trimLane( + router: ClientRouter, + lane: ContextualizedLane, + length: number, + background: boolean, +): void { + if (!background) { + discardMatchResources(router as AnyRouter, lane.matches.slice(length)) + } + lane.matches.length = length + if (!lane.background || background) { + return + } + let write = 0 + for (const task of lane.background) { + if (task.index < length) { + lane.background[write++] = task + } else { + discardMatchResources(router as AnyRouter, [task.candidate]) + } + } + lane.background.length = write +} + +async function projectLane( + router: WorkerRouter, + lane: ReducedLane, + start = 0, +): Promise { + for (let index = start; index < lane.matches.length; index++) { + const match = lane.matches[index]! + const routeOptions = getRoute(router, match).options + if (routeOptions.head || routeOptions.scripts) { + try { + const context = { + ssr: router.options.ssr, + matches: lane.matches, + match, + params: match.params, + loaderData: match.loaderData, + } + const [head, scripts] = await Promise.all([ + routeOptions.head?.(context), + routeOptions.scripts?.(context), + ]) + match.meta = head?.meta + match.links = head?.links + match.headScripts = head?.scripts + match.styles = head?.styles + match.scripts = scripts + } catch (cause) { + console.error(cause) + } + } + } + return lane as ProjectedLane +} + +export async function executeClientLane( + router: AnyRouter, + location: ParsedLocation, + matches: Array, + options: ExecuteLaneOptions, +): Promise { + const clientRouter = router as ClientRouter + const workerRouter = router as WorkerRouter + const matched = { + location, + matches: matches as Array, + } as MatchedLane + for (const match of matched.matches) { + const flight = clientRouter._flights?.get(match.id) ?? match._flight + if (flight) { + match._flight = flight + flight[2]++ + } + } + const failure = await contextualize(workerRouter, matched, options) + if (failure) { + options.sync = true + } + const contextualized = matched as ContextualizedLane + const tasks: Array = [] + let semanticParent: Promise | undefined + let end = failure?.[0] ?? contextualized.matches.length + if (failure?.[1][0] === NOT_FOUND) { + end = Math.min( + end, + getNotFoundBoundary(workerRouter, contextualized.matches, failure) + 1, + ) + } else if ((failure?.[1][0] ?? 0) >= REDIRECTED) { + end = 0 + } + for (let index = 0; index < end; index++) { + if (options.controller.signal.aborted) { + break + } + semanticParent = createLoaderTask( + clientRouter, + workerRouter, + contextualized, + index, + tasks, + semanticParent, + options, + ) + } + const reduced = await reduceLane( + router as WorkerRouter & ClientRouter, + contextualized, + tasks, + options, + failure, + ) + if (Array.isArray(reduced)) { + return reduced + } + return projectLane( + workerRouter, + reduced, + Math.min(options.resolvedPrefix ?? 0, reduced.matches.length - 1), + ) +} + +function semanticMatches(router: CoordinatorRouter): Array { + return router._committedMatches ?? router.stores.matches.get() +} + +function pendingConfig( + router: AnyRouter, + matches: Array, +): [delay: number, boundary: number, min: number] | undefined { + const presented = router.stores.matches.get() + for (let index = 0; index < matches.length; index++) { + const match = matches[index]! + const visible = + match.status === 'success' && + presented[index]?.id === match.id && + presented[index]?.status === 'pending' + if (match.status === 'success' && !visible) { + continue + } + const route = getRoute(router, match as WorkMatch) + const delay = visible + ? 0 + : match.invalid + ? 0 + : (route.options.pendingMs ?? router.options.defaultPendingMs) + return (route.options.pendingComponent ?? + (router.options as any).defaultPendingComponent) && + typeof delay === 'number' && + delay !== Infinity + ? [ + delay, + index, + route.options.pendingMinMs ?? router.options.defaultPendingMinMs ?? 0, + ] + : undefined + } + return undefined +} + +function renderPending( + router: CoordinatorRouter, + tx: LoadTransaction, + boundary: number, +): Promise { + const offered = tx.matches + .slice(0, boundary + 1) + .map((match) => ({ ...match, _flight: undefined })) + offered[boundary]!.status = 'pending' + return router.startTransition(() => { + router.stores.setMatches(offered) + }) +} + +function offerPending(router: CoordinatorRouter, tx: LoadTransaction): void { + if (router._tx !== tx) { + return + } + let session = router._pending + const sessionMatchId = session?.owner.matches[session.boundary]?.id + if (session?.owner !== tx) { + if (session && tx.matches[session.boundary]?.id === sessionMatchId) { + session.owner = tx + } else { + clearTimeout(session?.timer) + router._pending = session = undefined + } + } + const config = pendingConfig(router, tx.matches) + if (!config) { + return + } + const [delay, boundary, min] = config + const matchId = tx.matches[boundary]!.id + if (!session || session.boundary !== boundary || sessionMatchId !== matchId) { + clearTimeout(session?.timer) + const presented = router.stores.matches.get()[boundary] + const visible = presented?.id === matchId && presented.status === 'pending' + router._pending = session = { + owner: tx, + boundary, + deadline: Date.now() + (visible ? min : delay), + ack: visible ? Promise.resolve(true) : undefined, + } + } + if (session.ack) { + return + } + clearTimeout(session.timer) + const remaining = session.deadline - Date.now() + if (remaining > 0) { + session.timer = setTimeout(() => { + offerPending(router, tx) + }, remaining) + return + } + session.ack = renderPending(router, tx, boundary).then((rendered) => { + if (rendered && router._pending === session) { + session.deadline = Date.now() + min + } + return rendered + }) +} + +function finishPending(router: CoordinatorRouter, tx: LoadTransaction): void { + const session = router._pending + if (session?.owner === tx) { + clearTimeout(session.timer) + router._pending = undefined + } +} + +function publishMatches( + router: CoordinatorRouter, + matches: Array, + previous: Array, +): void { + for (let index = 0; index < matches.length; index++) { + const match = matches[index]! + const oldMatch = previous[index] + if (match !== oldMatch) { + match.fetchCount = + (oldMatch?.routeId === match.routeId ? oldMatch!.fetchCount : 0) + 1 + } + closeFlight(router, match as WorkMatch) + } + router.stores.setMatches(matches) + router._committedMatches = matches +} + +function commitMatches( + router: CoordinatorRouter, + tx: LoadTransaction, + matches: Array, +): void { + const stores = router.stores + const previous = semanticMatches(router) + const previousCached = stores.cachedMatches.get() + for (const match of matches) { + match.preload = false + } + const nextIds = new Set(matches.map((match) => match.id)) + const cached = previousCached.filter((match) => !nextIds.has(match.id)) + for (const match of previous) { + if (!nextIds.has(match.id)) { + cached.push({ + ...match, + _flight: undefined, + } as WorkMatch) + } + } + + const now = Date.now() + let write = 0 + for (const match of cached) { + const work = match as WorkMatch + const route = getRoute(router, work) + const preloadGcTime = + route.options.preloadGcTime ?? + router.options.defaultPreloadGcTime ?? + 300_000 + const donor = work._preloadContext + if ( + match.status === 'success' && + ((donor != null && now - donor < preloadGcTime) || + (route.options.loader && + now - match.updatedAt < + (match.preload + ? preloadGcTime + : (route.options.gcTime ?? + router.options.defaultGcTime ?? + 300_000)))) + ) { + cached[write++] = match + } + } + cached.length = write + + router.batch(() => { + publishMatches(router, matches, previous) + stores.setCached(cached) + }) + transferMatchResources( + router, + [...previousCached, ...previous], + [...matches, ...cached], + ) + tx.matches = [] + + const count = Math.max(previous.length, matches.length) + for (let index = 0; index < count; index++) { + const oldMatch = previous[index] + const match = matches[index] + if (oldMatch && oldMatch.routeId !== match?.routeId) { + try { + getRoute(router, oldMatch as WorkMatch).options.onLeave?.(oldMatch) + } catch (cause) { + Promise.reject(cause) + } + } + if (match) { + try { + getRoute(router, match as WorkMatch).options[ + oldMatch?.routeId === match.routeId ? 'onStay' : 'onEnter' + ]?.(match) + } catch (cause) { + Promise.reject(cause) + } + } + } +} + +async function awaitCurrent( + router: CoordinatorRouter, + owner?: LoadTransaction, +): Promise { + let current = router._tx + while (current && current !== owner) { + await current.done + if (router._tx === current) { + return + } + current = router._tx + } +} + +async function runBackground( + router: CoordinatorRouter, + tx: LoadTransaction, + base: Array, + tasks: Array, +): Promise { + const backgroundMatches = tasks.map((task) => task.candidate) + const next = base.slice() as Array + for (const task of tasks) { + next[task.index] = task.candidate + } + const lane = { + location: tx.location, + matches: next, + } as ContextualizedLane + const options: ExecuteLaneOptions = { + controller: tx.controller, + redirects: tx.redirects, + } + const reduced = await reduceLane( + router as WorkerRouter & ClientRouter, + lane, + tasks, + options, + ) + if (Array.isArray(reduced)) { + discardMatchResources(router, backgroundMatches) + if ( + reduced[0] === REDIRECTED && + router._tx === tx && + router._committedMatches === base + ) { + tx.redirects = (tx.redirects ?? 0) + 1 + tx.redirecting = true + await router.navigate({ + ...reduced[1].options, + replace: true, + ignoreBlocker: true, + }) + tx.redirecting = undefined + } + return + } + const projected = await projectLane(router as WorkerRouter, reduced) + if (router._tx !== tx || router._committedMatches !== base) { + discardMatchResources(router, backgroundMatches) + return + } + router.batch(() => { + publishMatches(router, projected.matches, base) + }) + transferMatchResources( + router, + [...base, ...backgroundMatches], + projected.matches, + ) +} + +async function runClientTransaction( + router: CoordinatorRouter, + tx: LoadTransaction, + forceStaleReload: boolean, + onReady?: () => void, + sync?: boolean, + resolvedPrefix?: number, +): Promise { + const options: ExecuteLaneOptions = { + controller: tx.controller, + forceStaleReload, + sync, + base: semanticMatches(router), + resolvedPrefix, + redirects: tx.redirects, + onReady, + } + const result = await executeClientLane( + router, + tx.location, + tx.matches, + options, + ) + + if (Array.isArray(result)) { + finishPending(router, tx) + discardMatchResources(router, tx.matches) + if (result[0] === REDIRECTED && router._tx === tx) { + tx.redirects = (tx.redirects ?? 0) + 1 + tx.redirecting = true + await router.navigate({ + ...result[1].options, + replace: true, + ignoreBlocker: true, + }) + tx.redirecting = undefined + } + return + } + tx.redirects = undefined + const pending = router._pending + if (pending?.owner === tx) { + clearTimeout(pending.timer) + if (pending.ack) { + const rendered = await pending.ack + if (rendered && router._pending === pending && pending.owner === tx) { + const remaining = pending.deadline - Date.now() + if (remaining > 0) { + await new Promise((resolve) => setTimeout(resolve, remaining)) + } + } + } + } + if (router._tx !== tx) { + finishPending(router, tx) + discardMatchResources(router, result.matches) + return + } + const toLocation = tx.location + const changeInfo = getLocationChangeInfo( + toLocation, + router.stores.resolvedLocation.get(), + ) + await router.startViewTransition(async () => { + if (router._tx !== tx) { + discardMatchResources(router, result.matches) + return + } + const commit = () => { + if (result.background?.length) { + void runBackground(router, tx, result.matches, result.background).catch( + console.error, + ) + } + finishPending(router, tx) + commitMatches(router, tx, result.matches) + if (router._tx !== tx) { + return + } + router.emit({ type: 'onLoad', ...changeInfo }) + if (router._tx === tx) { + router.emit({ type: 'onBeforeRouteMount', ...changeInfo }) + } + } + const rendered = await router.startTransition(commit) + if (router._tx !== tx) { + return + } + router.batch(() => { + router.stores.resolvedLocation.set(toLocation) + router.stores.status.set('idle') + if (router._tx === tx) { + router.emit({ type: 'onResolved', ...changeInfo }) + } + if (rendered) { + router.emit({ type: 'onRendered', ...changeInfo }) + } + }) + if (router._tx !== tx) { + return + } + router.commitLocationPromise?.resolve() + router.commitLocationPromise = undefined + }) +} + +export async function loadClientRouter( + router: AnyRouter, + opts?: { + sync?: boolean + /** @internal */ + _refreshRouteId?: string + }, +): Promise { + const clientRouter = router as CoordinatorRouter + const refreshRouteId = + process.env.NODE_ENV !== 'production' ? opts?._refreshRouteId : undefined + const canReuse = refreshRouteId + ? (route: AnyRoute) => { + for (let current: AnyRoute | undefined = route; current; ) { + if (current.id === refreshRouteId) { + return false + } + current = current.parentRoute as AnyRoute | undefined + } + return true + } + : undefined + const committed = (clientRouter._committedMatches ??= + router.stores.matches.get()) + const previousOwner = clientRouter._tx + const resolvedLocation = router.stores.resolvedLocation.get() + const previousLocation = resolvedLocation ?? router.stores.location.get() + const controller = new AbortController() + clientRouter._preflight?.abort() + clientRouter._preflight = controller + + const location = router.latestLocation + let matches: Array + try { + matches = router.matchRoutes( + location, + process.env.NODE_ENV !== 'production' && canReuse + ? { _controller: controller, _canReuse: canReuse } + : { _controller: controller }, + ) + } catch (cause) { + const stale = + controller.signal.aborted || clientRouter._tx !== previousOwner + controller.abort() + if (stale) { + await awaitCurrent(clientRouter, previousOwner) + return + } + if (!isRedirect(cause)) { + await awaitCurrent(clientRouter) + router.commitLocationPromise?.resolve() + router.commitLocationPromise = undefined + return + } + await router.navigate({ + ...cause.options, + replace: true, + ignoreBlocker: true, + }) + await awaitCurrent(clientRouter, previousOwner) + return + } + + if (controller.signal.aborted || clientRouter._tx !== previousOwner) { + controller.abort() + await awaitCurrent(clientRouter, previousOwner) + return + } + clientRouter._preflight = undefined + const sameHref = previousLocation.href === location.href + + let resolvedPrefix: number | undefined + if ( + !refreshRouteId && + router.ssr && + !resolvedLocation && + sameHref && + previousLocation.state.__TSR_key === location.state.__TSR_key && + committed.every( + (match, index) => !match.invalid && match.id === matches[index]?.id, + ) + ) { + resolvedPrefix = committed.length + if ( + resolvedPrefix && + (committed[resolvedPrefix - 1]!.status !== 'success' || + committed[resolvedPrefix - 1]!.globalNotFound) + ) { + matches.length = resolvedPrefix + } + } + + const tx: LoadTransaction = { + location, + controller, + matches, + done: Promise.resolve() + .then(() => + runClientTransaction( + clientRouter, + tx, + refreshRouteId ? false : sameHref, + refreshRouteId ? undefined : () => offerPending(clientRouter, tx), + opts?.sync, + resolvedPrefix, + ), + ) + .catch(() => { + if (clientRouter._tx === tx) { + finishPending(clientRouter, tx) + controller.abort() + discardMatchResources(router, tx.matches) + router.batch(() => { + router.stores.status.set('idle') + router.stores.setMatches(clientRouter._committedMatches ?? []) + }) + if (clientRouter._tx !== tx) { + return + } + router.commitLocationPromise?.resolve() + router.commitLocationPromise = undefined + } + }), + redirects: previousOwner?.redirecting ? previousOwner.redirects : undefined, + } + clientRouter._tx = tx + previousOwner?.controller.abort() + if (previousOwner) { + discardMatchResources(router, previousOwner.matches) + } + + router.batch(() => { + router.stores.status.set('pending') + router.stores.location.set(location) + }) + if (!refreshRouteId) { + offerPending(clientRouter, tx) + } + + try { + await tx.done + } finally { + await awaitCurrent(clientRouter, tx) + } +} + +export function refreshClientRoute( + router: AnyRouter, + routeId: string, +): Promise { + const clientRouter = router as CoordinatorRouter + if (clientRouter._flights) { + for (const flight of clientRouter._flights.values()) { + flight[1].abort() + } + clientRouter._flights.clear() + } + router.clearCache() + + return loadClientRouter(router, { sync: true, _refreshRouteId: routeId }) +} + +export async function preloadClientRoute( + router: AnyRouter, + opts: any, + redirects = 0, +): Promise | undefined> { + if (redirects > 20) { + return + } + const clientRouter = router as CoordinatorRouter + const owner = clientRouter._tx + const location = opts._builtLocation ?? router.buildLocation(opts) + const base = semanticMatches(clientRouter) + const plannedCache = router.stores.cachedMatches.get() + const controller = new AbortController() + let matches: Array | undefined + try { + matches = router.matchRoutes(location, { + throwOnError: true, + preload: true, + _controller: controller, + _isCurrent: () => clientRouter._tx === owner, + }) + let resolvedPrefix = 0 + while ( + base[resolvedPrefix]?.status === 'success' && + base[resolvedPrefix]?.id === matches[resolvedPrefix]?.id + ) { + resolvedPrefix++ + } + const result = await executeClientLane(router, location, matches, { + controller, + preload: true, + base, + resolvedPrefix, + }) + if (Array.isArray(result)) { + discardMatchResources(router, matches) + if (result[0] === REDIRECTED && !result[1].options.reloadDocument) { + return preloadClientRoute( + router, + { + ...result[1].options, + _fromLocation: location, + }, + redirects + 1, + ) + } + return + } + + matches = result.matches + if (matches.some((match) => match.status !== 'success')) { + discardMatchResources(router, matches) + return + } + + const active = new Set( + semanticMatches(clientRouter).map((match) => match.id), + ) + const previous = router.stores.cachedMatches.get() + const candidates: Array = [] + let obsoleteContext = false + for (const match of matches as Array) { + if (process.env.NODE_ENV !== 'production') { + obsoleteContext ||= + match._preloadContext != null && + match._preloadBeforeLoad !== + getRoute(router, match).options.beforeLoad + if (obsoleteContext) { + releaseFlight(clientRouter, match) + continue + } + } + if ( + active.has(match.id) || + previous.find((candidate) => candidate.id === match.id) !== + plannedCache.find((candidate) => candidate.id === match.id) + ) { + releaseFlight(clientRouter, match) + continue + } + closeFlight(clientRouter, match) + candidates.push(match) + } + const ids = new Set(candidates.map((match) => match.id)) + const cached = previous + .filter((match) => !ids.has(match.id)) + .concat(candidates) + router.stores.setCached(cached) + transferMatchResources(router, previous, [ + ...cached, + ...semanticMatches(clientRouter), + ]) + return matches + } catch (cause) { + if (!matches) { + if (controller.signal.aborted) { + return + } + throw cause + } + controller.abort() + discardMatchResources(router, matches) + if (clientRouter._tx !== owner) { + return + } + if (isRedirect(cause) && !cause.options.reloadDocument) { + return preloadClientRoute( + router, + { + ...cause.options, + _fromLocation: location, + }, + redirects + 1, + ) + } + if (!isNotFound(cause)) { + console.error(cause) + } + return + } +} diff --git a/packages/router-core/src/load-matches.ts b/packages/router-core/src/load-matches.ts deleted file mode 100644 index f901a0c97d..0000000000 --- a/packages/router-core/src/load-matches.ts +++ /dev/null @@ -1,1278 +0,0 @@ -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 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' - -/** - * 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 */ - router: AnyRouter - location: ParsedLocation - /** mutable state, scoped to a `loadMatches` call */ - firstBadMatchIndex?: number - /** mutable state, scoped to a `loadMatches` call */ - rendered?: boolean - serialError?: unknown - updateMatch: UpdateMatchFn - matches: Array - preload?: boolean - forceStaleReload?: boolean - onReady?: () => Promise - sync?: boolean -} - -const triggerOnReady = (inner: InnerLoadContext): void | Promise => { - if (!inner.rendered) { - inner.rendered = true - return inner.onReady?.() - } -} - -const hasForcePendingActiveMatch = (router: AnyRouter): boolean => { - return router.stores.matchesId.get().some((matchId) => { - return router.stores.matchStores.get(matchId)?.get()._forcePending - }) -} - -const resolvePreload = (inner: InnerLoadContext, matchId: string): boolean => { - return !!(inner.preload && !inner.router.stores.matchStores.has(matchId)) -} - -/** - * 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, - index: number, - includeCurrentMatch: boolean = true, -): Record => { - const context: Record = { - ...(inner.router.options.context ?? {}), - } - const end = includeCurrentMatch ? index : index - 1 - for (let i = 0; i <= end; i++) { - const innerMatch = inner.matches[i] - if (!innerMatch) continue - const m = inner.router.getMatch(innerMatch.id) - if (!m) continue - Object.assign(context, m.__routeContext, m.__beforeLoadContext) - } - return context -} - -const getNotFoundBoundaryIndex = ( - inner: InnerLoadContext, - err: NotFoundError, -): number | undefined => { - if (!inner.matches.length) { - return undefined - } - - const requestedRouteId = err.routeId - const matchedRootIndex = inner.matches.findIndex( - (m) => m.routeId === inner.router.routeTree.id, - ) - const rootIndex = matchedRootIndex >= 0 ? matchedRootIndex : 0 - - let startIndex = requestedRouteId - ? inner.matches.findIndex((match) => match.routeId === requestedRouteId) - : (inner.firstBadMatchIndex ?? inner.matches.length - 1) - - if (startIndex < 0) { - startIndex = rootIndex - } - - for (let i = startIndex; i >= 0; i--) { - const match = inner.matches[i]! - const route = inner.router.looseRoutesById[match.routeId]! - if (route.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, - } - }) -} - -const handleSerialError = ( - 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 - } - - 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) - } - 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() - } - - 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, - } -} - -const runLoader = async ( - 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 - } - inner.updateMatch(matchId, (prev) => ({ - ...prev, - status: prev.status === 'pending' ? 'success' : prev.status, - 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) - - 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() -} - -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/load-server.ts b/packages/router-core/src/load-server.ts new file mode 100644 index 0000000000..51256635ef --- /dev/null +++ b/packages/router-core/src/load-server.ts @@ -0,0 +1,670 @@ +// Keep this filename free of a secondary extension so declaration generation +// can rewrite relative imports for both ESM and CJS. +import { isNotFound } from './not-found' +import { isRedirect, redirect } from './redirect' +import { rootRouteId } from './root' +import { loadRouteChunk } from './route-chunks' +import type { ParsedLocation } from './location' +import type { AnyRouteMatch } from './Matches' +import type { NotFoundError } from './not-found' +import type { + AnyRoute, + BeforeLoadContextOptions, + LoaderFnContext, + SsrContextOptions, +} from './route' +import type { AnyRedirect } from './redirect' +import type { AnyRouter, SSROption } from './router' + +declare const serverLanePhase: unique symbol + +type ServerLane = { + readonly [serverLanePhase]: TPhase + location: ParsedLocation + matches: Array +} + +type MatchedLane = ServerLane<'matched'> + +type IndexedOutcome = [index: number, outcome: LoaderOutcome] + +type ContextualizedLane = ServerLane<'contextualized'> & { + end: number + failure?: IndexedOutcome +} + +type ReducedLane = ServerLane<'reduced'> + +const SUCCESS = 0 +const ERROR = 1 +const NOT_FOUND = 2 +const REDIRECTED = 3 +const SKIPPED = 4 + +type LoaderOutcome = + | [typeof SUCCESS, data: unknown] + | [typeof ERROR, error: unknown] + | [typeof NOT_FOUND, error: NotFoundError] + | [typeof REDIRECTED, redirect: AnyRedirect] + | [typeof SKIPPED] + +type LoaderTask = { + index: number + outcome: Promise + match: Promise +} + +type ServerWorker = Pick< + AnyRouter, + | 'buildLocation' + | 'isShell' + | 'navigate' + | 'options' + | 'resolveRedirect' + | 'routeTree' + | 'routesById' +> + +export type ServerLoadResult = + | { + type: 'render' + status: 200 | 404 | 500 + matches: Array + } + | { type: 'redirect'; redirect: AnyRedirect } + +function getRoute(router: ServerWorker, match: AnyRouteMatch): AnyRoute { + return (router.routesById as Record)[match.routeId]! +} + +function normalize(value: unknown, rejected: boolean): LoaderOutcome { + if (isRedirect(value)) { + return [REDIRECTED, value] + } + if (isNotFound(value)) { + return [NOT_FOUND, value] + } + return rejected ? [ERROR, value] : [SUCCESS, value] +} + +function normalizeError(route: AnyRoute, cause: unknown): LoaderOutcome { + let outcome = normalize(cause, true) + if (outcome[0] !== ERROR) { + return outcome + } + try { + route.options.onError?.(cause) + } catch (onErrorCause) { + outcome = normalize(onErrorCause, true) + } + return outcome +} + +function maybe( + value: TValue, + cause: unknown, +): { status: 'success'; value: TValue } | { status: 'error'; error: unknown } { + if (cause !== undefined) { + return { status: 'error', error: cause } + } + return { status: 'success', value } +} + +function navigateFrom(router: ServerWorker, location: ParsedLocation) { + return (options: any) => + router.navigate({ + ...options, + _fromLocation: location, + }) +} + +async function resolveSsr( + router: ServerWorker, + lane: MatchedLane, + index: number, +): Promise { + const match = lane.matches[index]! + const route = getRoute(router, match) + const parentSsr = lane.matches[index - 1]?.ssr + + if (router.isShell()) { + return route.id === rootRouteId + } + if (parentSsr === false) { + return false + } + + const inherit = (value: SSROption): SSROption => { + return value === true && parentSsr === 'data-only' ? 'data-only' : value + } + const defaultSsr = router.options.defaultSsr ?? true + const option = route.options.ssr + if (option === undefined) { + return inherit(defaultSsr) + } + if (typeof option !== 'function') { + return inherit(option) + } + + const context: SsrContextOptions = { + search: maybe(match.search, match.searchError), + params: maybe(match.params, match.paramsError), + location: lane.location, + matches: lane.matches.map((candidate) => ({ + index: candidate.index, + pathname: candidate.pathname, + fullPath: candidate.fullPath, + staticData: candidate.staticData, + id: candidate.id, + routeId: candidate.routeId, + search: maybe(candidate.search, candidate.searchError), + params: maybe(candidate.params, candidate.paramsError), + ssr: candidate.ssr, + })), + } + return inherit((await option(context)) ?? defaultSsr) +} + +function stampNotFound( + match: AnyRouteMatch, + outcome: LoaderOutcome, +): LoaderOutcome { + if (outcome[0] === NOT_FOUND && !outcome[1].routeId) { + outcome[1].routeId = match.routeId + } + return outcome +} + +async function contextualize( + router: ServerWorker, + lane: MatchedLane, + preload: boolean, +): Promise { + let end = lane.matches.length + let failure: IndexedOutcome | undefined + let parentContext: Record = { + ...(router.options.context ?? {}), + } + + for (let index = 0; index < lane.matches.length; index++) { + const match = lane.matches[index]! + const route = getRoute(router, match) + try { + match.ssr = await resolveSsr(router, lane, index) + } catch (cause) { + failure = [index, stampNotFound(match, normalizeError(route, cause))] + end = index + break + } + + const context = { + ...parentContext, + ...match.__routeContext, + } + match.context = context + + const validationError = match.paramsError ?? match.searchError + if (validationError !== undefined) { + failure = [ + index, + stampNotFound(match, normalizeError(route, validationError)), + ] + end = index + break + } + + if (match.ssr === false || !route.options.beforeLoad) { + parentContext = context + continue + } + + const abortController = match.abortController + const options: BeforeLoadContextOptions< + any, + any, + any, + any, + any, + any, + any, + any, + any + > = { + search: match.search, + abortController, + params: match.params, + preload, + context, + location: lane.location, + navigate: navigateFrom(router, lane.location), + buildLocation: router.buildLocation, + cause: preload ? 'preload' : match.cause, + matches: lane.matches, + routeId: route.id, + ...router.options.additionalContext, + } + + try { + const beforeLoadContext = await route.options.beforeLoad(options) + const outcome = stampNotFound(match, normalize(beforeLoadContext, false)) + if (outcome[0] !== SUCCESS) { + failure = [index, outcome] + end = index + break + } + match.__beforeLoadContext = beforeLoadContext + match.context = { + ...context, + ...beforeLoadContext, + } + parentContext = match.context + } catch (cause) { + if (cause instanceof Promise) { + throw cause + } + failure = [index, stampNotFound(match, normalizeError(route, cause))] + end = index + break + } + } + + return { + location: lane.location, + matches: lane.matches, + end, + failure, + } as ContextualizedLane +} + +function loaderContext( + router: ServerWorker, + lane: ContextualizedLane, + match: AnyRouteMatch, + route: AnyRoute, + index: number, + tasks: Array, + preload: boolean, +): LoaderFnContext { + return { + params: match.params, + deps: match.loaderDeps, + preload, + parentMatchPromise: tasks[index - 1]?.match as any, + abortController: match.abortController, + context: match.context, + location: lane.location, + navigate: navigateFrom(router, lane.location), + cause: preload ? 'preload' : match.cause, + route, + ...router.options.additionalContext, + } +} + +function startLoader( + router: ServerWorker, + lane: ContextualizedLane, + index: number, + tasks: Array, + preload: boolean, +): LoaderTask { + const match = lane.matches[index]! + const route = getRoute(router, match) + let outcome: Promise + + if (match.ssr === false) { + outcome = Promise.resolve([SKIPPED]) + } else { + const option = route.options.loader + const loader = typeof option === 'function' ? option : option?.handler + if (!loader) { + outcome = Promise.resolve([SUCCESS, undefined]) + } else { + let value: unknown + let rejected = false + try { + value = loader( + loaderContext(router, lane, match, route, index, tasks, preload), + ) + } catch (cause) { + value = cause + rejected = true + } + outcome = Promise.resolve(value).then( + (result) => { + const normalized = rejected + ? normalizeError(route, result) + : normalize(result, false) + return stampNotFound(match, normalized) + }, + (cause) => stampNotFound(match, normalizeError(route, cause)), + ) + } + } + + const parentMatch = outcome.then((result) => { + const snapshot = { ...match } + if (result[0] === SUCCESS) { + snapshot.loaderData = result[1] + snapshot.status = 'success' + snapshot.error = undefined + } else if (result[0] === ERROR) { + snapshot.status = 'error' + snapshot.error = result[1] + } else if (result[0] === NOT_FOUND) { + snapshot.status = 'notFound' + snapshot.error = result[1] + } + return snapshot + }) + + return { index, outcome, match: parentMatch } +} + +function getNotFoundBoundary( + router: ServerWorker, + matches: Array, + indexed: IndexedOutcome, +): number { + const [throwingIndex, outcome] = indexed + const cause = outcome[1] as NotFoundError + let index = cause.routeId + ? matches.findIndex((match) => match.routeId === cause.routeId) + : throwingIndex + if (index < 0) { + index = 0 + } + for (let candidate = index; candidate >= 0; candidate--) { + if (getRoute(router, matches[candidate]!).options.notFoundComponent) { + return candidate + } + } + return cause.routeId ? index : 0 +} + +function abortMatches(matches: Array, start = 0): void { + for (let index = start; index < matches.length; index++) { + matches[index]!.abortController.abort() + } +} + +function resolveServerRedirect( + router: ServerWorker, + location: ParsedLocation, + value: AnyRedirect, +): ServerLoadResult { + value.options._fromLocation = location + return { type: 'redirect', redirect: router.resolveRedirect(value) } +} + +function applyFailure( + router: ServerWorker, + lane: ContextualizedLane, + indexed: IndexedOutcome | undefined, +): { status: 200 | 404 | 500; boundary?: number; kind?: number } { + if (!indexed) { + const boundary = lane.matches.findIndex((match) => match.globalNotFound) + if (boundary >= 0) { + abortMatches(lane.matches, boundary + 1) + lane.matches.length = boundary + 1 + return { status: 404, boundary, kind: NOT_FOUND } + } + return { status: 200 } + } + + const [index, outcome] = indexed + if (outcome[0] === ERROR) { + const match = lane.matches[index]! + match.globalNotFound = undefined + match.status = 'error' + match.error = outcome[1] + match.isFetching = false + abortMatches(lane.matches, index + 1) + lane.matches.length = index + 1 + return { status: 500, boundary: index, kind: ERROR } + } + + const boundary = getNotFoundBoundary(router, lane.matches, indexed) + const match = lane.matches[boundary]! + const cause = outcome[1] as NotFoundError + cause.routeId = match.routeId + match.globalNotFound = undefined + if (match.routeId === router.routeTree.id) { + match.status = 'success' + match.globalNotFound = true + match.error = cause + } else { + match.status = 'notFound' + match.error = cause + } + match.isFetching = false + abortMatches(lane.matches, boundary + 1) + lane.matches.length = boundary + 1 + return { status: 404, boundary, kind: NOT_FOUND } +} + +async function loadNormalChunks( + router: ServerWorker, + lane: ContextualizedLane, + end: number, +): Promise { + const chunks = lane.matches.map(async (match, index) => { + if (index >= end || match.ssr !== true || match.status !== 'success') { + return undefined + } + const route = getRoute(router, match) + try { + await loadRouteChunk(route) + } catch (cause) { + return [ + index, + stampNotFound(match, normalizeError(route, cause)), + ] as IndexedOutcome + } + return undefined + }) + for (const chunk of chunks) { + const indexed = await chunk + if (indexed) { + return indexed + } + } + return undefined +} + +async function project(router: ServerWorker, lane: ReducedLane): Promise { + for (const match of lane.matches) { + const routeOptions = getRoute(router, match).options + if ( + match.ssr !== true || + (!routeOptions.head && !routeOptions.scripts && !routeOptions.headers) + ) { + continue + } + const context = { + ssr: router.options.ssr, + matches: lane.matches, + match, + params: match.params, + loaderData: match.loaderData, + } + try { + const [head, scripts, headers] = await Promise.all([ + routeOptions.head?.(context), + routeOptions.scripts?.(context), + routeOptions.headers?.(context), + ]) + match.meta = head?.meta + match.links = head?.links + match.headScripts = head?.scripts + match.styles = head?.styles + match.scripts = scripts + match.headers = headers + } catch (cause) { + console.error(cause) + } + } +} + +export async function loadServer( + router: AnyRouter, + location: ParsedLocation, + matchedMatches: Array, + preload = false, +): Promise { + const worker = router as ServerWorker + const matched = { + location, + matches: matchedMatches.map((match) => ({ + ...match, + __beforeLoadContext: undefined, + context: {}, + isFetching: false, + abortController: new AbortController(), + })), + } as MatchedLane + const lane = await contextualize(worker, matched, preload) + + let loaderEnd = lane.end + if (lane.failure?.[1][0] === REDIRECTED) { + loaderEnd = 0 + } else if (lane.failure?.[1][0] === NOT_FOUND) { + loaderEnd = Math.min( + loaderEnd, + getNotFoundBoundary(worker, lane.matches, lane.failure) + 1, + ) + } + + const tasks: Array = [] + for (let index = 0; index < loaderEnd; index++) { + const task = startLoader(worker, lane, index, tasks, preload) + tasks.push(task) + } + + let loaderFailure: IndexedOutcome | undefined + let control = lane.failure?.[1][0] === REDIRECTED ? lane.failure : undefined + for (const task of tasks) { + const outcome = await task.outcome + if (outcome[0] === SUCCESS) { + const match = lane.matches[task.index]! + match.loaderData = outcome[1] + match.status = 'success' + match.error = undefined + match.invalid = false + match.isFetching = false + match.updatedAt = Date.now() + } else if (outcome[0] === REDIRECTED) { + control = [task.index, outcome] + break + } else if (outcome[0] !== SKIPPED) { + loaderFailure ??= [task.index, outcome] + } + } + + if (control?.[1][0] === REDIRECTED) { + abortMatches(lane.matches) + return resolveServerRedirect(worker, location, control[1][1]) + } + + let failure = loaderFailure ?? lane.failure + const plannedBoundary = lane.matches.findIndex( + (match) => match.globalNotFound, + ) + const readinessEnd = failure + ? failure[1][0] === NOT_FOUND + ? getNotFoundBoundary(worker, lane.matches, failure) + : failure[0] + : plannedBoundary < 0 + ? lane.matches.length + : plannedBoundary + const chunkFailure = await loadNormalChunks(worker, lane, readinessEnd) + if (chunkFailure) { + if (chunkFailure[1][0] === REDIRECTED) { + abortMatches(lane.matches) + return resolveServerRedirect(worker, location, chunkFailure[1][1]) + } + failure = chunkFailure + } + + const terminal = applyFailure(worker, lane, failure) + if (terminal.boundary !== undefined) { + const match = lane.matches[terminal.boundary]! + if (match.ssr === true) { + const route = getRoute(worker, match) + try { + if (terminal.kind === ERROR) { + await loadRouteChunk(route, 'errorComponent') + } else if (match.globalNotFound) { + await Promise.all([ + loadRouteChunk(route), + loadRouteChunk(route, 'notFoundComponent'), + ]) + } else { + await loadRouteChunk(route, 'notFoundComponent') + } + } catch {} + } + } + + await project(worker, { + location: lane.location, + matches: lane.matches, + } as ReducedLane) + return { type: 'render', status: terminal.status, matches: lane.matches } +} + +export async function loadServerRouter( + router: AnyRouter, + _opts?: Parameters[0], +): Promise { + router.updateLatestLocation() + const next = router.latestLocation + let result: ServerLoadResult + try { + const canonical = router.buildLocation({ + to: next.pathname, + search: true, + params: true, + hash: true, + state: true, + _includeValidateSearch: true, + }) + if (next.publicHref !== canonical.publicHref) { + const href = canonical.publicHref || '/' + throw canonical.external + ? redirect({ href }) + : redirect({ href, _builtLocation: canonical }) + } + + const fromLocation = router.stores.resolvedLocation.get() + const changeInfo = { + fromLocation, + toLocation: next, + pathChanged: fromLocation?.pathname !== next.pathname, + hrefChanged: fromLocation?.href !== next.href, + hashChanged: fromLocation?.hash !== next.hash, + } + router.emit({ type: 'onBeforeNavigate', ...changeInfo }) + router.emit({ type: 'onBeforeLoad', ...changeInfo }) + result = await loadServer(router, next, router.matchRoutes(next)) + } catch (cause) { + if (!isRedirect(cause)) { + throw cause + } + result = { type: 'redirect', redirect: cause } + } + + router._serverResult = result + router.batch(() => { + router.stores.location.set(next) + router.stores.status.set('idle') + if (result.type === 'render') { + router.stores.setMatches(result.matches) + router.stores.resolvedLocation.set(next) + } + }) + if (result.type === 'render') { + router._committedMatches = result.matches + } + router.commitLocationPromise?.resolve() + router.commitLocationPromise = undefined +} diff --git a/packages/router-core/src/redirect.ts b/packages/router-core/src/redirect.ts index b10fac6d7a..2ac387daeb 100644 --- a/packages/router-core/src/redirect.ts +++ b/packages/router-core/src/redirect.ts @@ -21,7 +21,6 @@ export type Redirect< */ _builtLocation?: ParsedLocation } - redirectHandled?: boolean } export type RedirectOptions< diff --git a/packages/router-core/src/route-chunks.ts b/packages/router-core/src/route-chunks.ts new file mode 100644 index 0000000000..f1dcbf5c2a --- /dev/null +++ b/packages/router-core/src/route-chunks.ts @@ -0,0 +1,68 @@ +import type { AnyRoute } from './route' + +type RouteComponentType = + | 'component' + | 'pendingComponent' + | 'errorComponent' + | 'notFoundComponent' + +function preloadComponent( + route: AnyRoute, + type: RouteComponentType, +): Promise | undefined { + return (route.options[type] as any)?.preload?.() +} + +function loadComponents(route: AnyRoute): Promise | undefined { + const component = preloadComponent(route, 'component') + const pending = preloadComponent(route, 'pendingComponent') + if (component && pending) { + return Promise.all([component, pending]).then(() => {}) + } + return component ?? pending +} + +export function loadRouteChunk( + route: AnyRoute, + componentType?: 'errorComponent' | 'notFoundComponent', +): Promise | undefined { + const afterLazy = () => + componentType + ? preloadComponent(route, componentType) + : loadComponents(route) + + const current = route._lazy + if (current) { + return current === true ? afterLazy() : current.then(afterLazy) + } + if (!route.lazyFn) { + route._lazy = true + return afterLazy() + } + + const promise = route.lazyFn().then( + (lazyRoute) => { + // HMR clears the owner before an obsolete import can settle. + if (route._lazy === promise) { + const { id: _id, ...options } = lazyRoute.options + Object.assign(route.options, options) + route._lazy = true + } + }, + (error) => { + if (route._lazy === promise) { + route._lazy = undefined + } + throw error + }, + ) + route._lazy = promise + return promise.then(afterLazy) +} + +export function routeNeedsPreload(route: AnyRoute): boolean { + return !!( + (route.options.component as any)?.preload || + (route.options.pendingComponent as any)?.preload + ) +} diff --git a/packages/router-core/src/route.ts b/packages/router-core/src/route.ts index 2de7dc57f0..eae2f15e30 100644 --- a/packages/router-core/src/route.ts +++ b/packages/router-core/src/route.ts @@ -700,10 +700,6 @@ export interface Route< THandlers > isRoot: TParentRoute extends AnyRoute ? true : false - /** @internal */ - _componentsPromise?: Promise - /** @internal */ - _componentsLoaded?: boolean lazyFn?: () => Promise< LazyRoute< Route< @@ -729,9 +725,7 @@ export interface Route< > > /** @internal */ - _lazyPromise?: Promise - /** @internal */ - _lazyLoaded?: boolean + _lazy?: Promise | true rank: number to: TrimPathRight init: (opts: { originalIndex: number }) => void @@ -1711,10 +1705,7 @@ export class BaseRoute< > > /** @internal */ - _lazyPromise?: Promise - /** @internal */ - _componentsPromise?: Promise - + _lazy?: Promise | true constructor( options?: RouteOptions< TRegister, diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index df9251a739..24250b3aee 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -1,8 +1,11 @@ import { createBrowserHistory, parseHref } from '@tanstack/history' -import { isServer } from '@tanstack/router-core/isServer' +import { + isServer, + loadServer, + loadServerRouter, +} from '@tanstack/router-core/isServer' import { DEFAULT_PROTOCOL_ALLOWLIST, - createControlledPromise, decodePath, deepEqual, encodePathLikeUrl, @@ -35,8 +38,14 @@ 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 { + discardMatchResources, + loadClientRouter, + preloadClientRoute, + refreshClientRoute, +} from './load-client' +import { loadRouteChunk, routeNeedsPreload } from './route-chunks' import { composeRewrites, executeRewriteInput, @@ -51,6 +60,12 @@ import type { } from './new-process-route-tree' import type { SearchParser, SearchSerializer } from './searchParams' import type { AnyRedirect, ResolvedRedirect } from './redirect' +import type { + LoadTransaction, + LoaderFlight, + PendingSession, +} from './load-client' +import type { ServerLoadResult } from './load-server' import type { HistoryAction, HistoryLocation, @@ -62,7 +77,6 @@ import type { import type { Awaitable, Constrain, - ControlledPromise, NoInfer, NonNullableUpdater, PickAsRequired, @@ -106,7 +120,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 +557,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 { @@ -622,6 +631,23 @@ export interface MatchRoutesOpts { preload?: boolean throwOnError?: boolean dest?: BuildNextOptions + /** @internal */ + _controller?: AbortController + /** @internal */ + _isCurrent?: () => boolean + /** @internal */ + _canReuse?: (route: AnyRoute) => boolean +} + +function assertMatchOwner(opts?: MatchRoutesOpts) { + const controller = opts?._controller + if ( + controller && + (controller.signal.aborted || opts._isCurrent?.() === false) + ) { + controller.abort() + throw controller.signal + } } export type InferRouterContext = @@ -756,7 +782,7 @@ export type CommitLocationFn = ({ ...next }: ParsedLocation & CommitLocationOptions) => Promise -export type StartTransitionFn = (fn: () => void) => void +export type StartTransitionFn = (fn: () => void) => Promise export interface MatchRoutesFn { ( @@ -982,7 +1008,14 @@ export class RouterCore< shouldViewTransition?: boolean | ViewTransitionOptions = undefined isViewTransitionTypesSupported?: boolean = undefined subscribers = new Set>() - viewTransitionPromise?: ControlledPromise + _tx?: LoadTransaction + _flights?: Map + /** Terminal semantic lane; pending presentation never becomes a planning base. */ + _committedMatches?: Array + _pending?: PendingSession + _serverResult?: ServerLoadResult + _rendered?: () => void + declare _refreshRoute: ((routeId: string) => Promise) | undefined // Must build in constructor stores!: RouterStores @@ -1053,11 +1086,10 @@ export class RouterCore< } } - // This is a default implementation that can optionally be overridden - // by the router provider once rendered. We provide this so that the - // router can be used in a non-react environment if necessary - startTransition: StartTransitionFn = (fn) => fn() - + startTransition: StartTransitionFn = async (fn) => { + fn() + return false + } isShell() { return !!this.options.isShell } @@ -1295,7 +1327,9 @@ export class RouterCore< emit: EmitFn = (routerEvent) => { this.subscribers.forEach((listener) => { if (listener.eventType === routerEvent.type) { - listener.fn(routerEvent) + try { + listener.fn(routerEvent) + } catch {} } }) } @@ -1405,10 +1439,6 @@ export class RouterCore< return branch } - get looseRoutesById() { - return this.routesById as Record - } - matchRoutes: MatchRoutesFn = ( pathnameOrNext: string | ParsedLocation, locationSearchOrOpts?: AnySchema | MatchRoutesOpts, @@ -1437,10 +1467,20 @@ export class RouterCore< return parentContext } + private getSemanticMatch(matchId: string) { + const committed = this._committedMatches + return committed + ? (committed.find((match) => match.id === matchId) ?? + this.stores.cachedMatchStores.get(matchId)?.get()) + : this.stores.matchStores.get(matchId)?.get() + } + private matchRoutesInternal( next: ParsedLocation, opts?: MatchRoutesOpts, ): Array { + const canReuse = + process.env.NODE_ENV !== 'production' ? opts?._canReuse : undefined const matchedRoutesResult = this.getMatchedRoutes(next.pathname) const { foundRoute, routeParams } = matchedRoutesResult let { matchedRoutes } = matchedRoutesResult @@ -1471,13 +1511,15 @@ export class RouterCore< // 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()) + for (const match of this._committedMatches ?? this.stores.matches.get()) { + const route = (this.routesById as Record)[match.routeId] + if (route && canReuse?.(route) !== false) { + previousActiveMatchesByRouteId.set(match.routeId, match) } } for (let index = 0; index < matchedRoutes.length; index++) { + assertMatchOwner(opts) const route = matchedRoutes[index]! // Take each matched route and resolve + validate its search params // This has to happen serially because each route's search params @@ -1524,6 +1566,7 @@ export class RouterCore< searchError = searchParamError } } + assertMatchOwner(opts) // This is where we need to call route.options.loaderDeps() to get any additional // deps that the route's loader function might need to run. We need to do this @@ -1536,6 +1579,7 @@ export class RouterCore< }) ?? '' const loaderDepsHash = loaderDeps ? JSON.stringify(loaderDeps) : '' + assertMatchOwner(opts) const { interpolatedPath, usedParams } = interpolatePath({ path: route.fullPath, @@ -1558,7 +1602,8 @@ export class RouterCore< // explicit deps loaderDepsHash - const existingMatch = this.getMatch(matchId) + const existingMatch = + canReuse?.(route) === false ? undefined : this.getSemanticMatch(matchId) const previousMatch = previousActiveMatchesByRouteId.get(route.id) @@ -1629,12 +1674,9 @@ export class RouterCore< error: undefined, paramsError, __routeContext: undefined, - _nonReactive: { - loadPromise: createControlledPromise(), - }, __beforeLoadContext: undefined, context: {}, - abortController: new AbortController(), + abortController: opts?._controller ?? new AbortController(), fetchCount: 0, cause, loaderDeps: previousMatch @@ -1671,9 +1713,15 @@ export class RouterCore< } for (let index = 0; index < matches.length; index++) { + assertMatchOwner(opts) const match = matches[index]! - const route = this.looseRoutesById[match.routeId]! - const existingMatch = this.getMatch(match.id) + const route = (this.routesById as Record)[ + match.routeId + ]! + const existingMatch = + canReuse?.(route) === false + ? undefined + : this.getSemanticMatch(match.id) // Update the match's params const previousMatch = previousActiveMatchesByRouteId.get(match.routeId) @@ -1699,7 +1747,7 @@ export class RouterCore< buildLocation: this.buildLocation, cause: match.cause, abortController: match.abortController, - preload: !!match.preload, + preload: !!opts?.preload, matches, routeId: route.id, } @@ -1730,7 +1778,7 @@ export class RouterCore< /** * Lightweight route matching for buildLocation. * Only computes fullPath, accumulated search, and params - skipping expensive - * operations like AbortController, ControlledPromise, loaderDeps, and full match objects. + * operations like AbortController, loaderDeps, and full match objects. */ private matchRoutesLightweight( location: ParsedLocation, @@ -1805,37 +1853,6 @@ 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 = () => { - this.stores.pendingIds.get().forEach((matchId) => { - this.cancelMatch(matchId) - }) - - this.stores.matchesId.get().forEach((matchId) => { - if (this.stores.pendingMatchStores.has(matchId)) { - return - } - - const match = this.stores.matchStores.get(matchId)?.get() - if (!match) { - return - } - - if (match.status === 'pending' || match.isFetching === 'loader') { - this.cancelMatch(matchId) - } - }) - } - /** * Build the next ParsedLocation from navigation options without committing. * Resolves `to`/`from`, params/search/hash/state, applies search validation @@ -2151,7 +2168,7 @@ export class RouterCore< return buildWithMatches(opts) } - commitLocationPromise: undefined | ControlledPromise + commitLocationPromise: (Promise & { resolve: () => void }) | undefined /** * Commit a previously built location to history (push/replace), optionally @@ -2186,11 +2203,16 @@ export class RouterCore< const isSameUrl = trimPathRight(this.latestLocation.href) === trimPathRight(next.href) - let previousCommitPromise = this.commitLocationPromise - this.commitLocationPromise = createControlledPromise(() => { + const previousCommitPromise = this.commitLocationPromise + let resolve!: () => void + const commitPromise = new Promise((done) => { + resolve = done + }) as Promise & { resolve: () => void } + commitPromise.resolve = () => { + resolve() previousCommitPromise?.resolve() - previousCommitPromise = undefined - }) + } + this.commitLocationPromise = commitPromise // Don't commit to history if nothing changed if (isSameUrl && isSameState()) { @@ -2409,256 +2431,28 @@ 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) { - this._scroll.hash = - historyAction === 'PUSH' || historyAction === 'REPLACE' - } - 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 - - while ( - (this.latestLoadPromise as any) && - loadPromise !== this.latestLoadPromise - ) { - await this.latestLoadPromise + if (isServer ?? this.isServer) { + return loadServerRouter(this, opts) } - 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) - } + this._serverResult = undefined + this.updateLatestLocation() + const location = this.latestLocation + if (opts?.action) { + this._scroll.hash = + opts.action.type === 'PUSH' || opts.action.type === 'REPLACE' + } + const locationChangeInfo = getLocationChangeInfo( + location, + this.stores.resolvedLocation.get(), + ) + this.emit({ type: 'onBeforeNavigate', ...locationChangeInfo }) + this.emit({ type: 'onBeforeLoad', ...locationChangeInfo }) + await loadClientRouter(this, opts) } - startViewTransition = (fn: () => Promise) => { + startViewTransition = async (fn: () => Promise) => { // Determine if we should start a view transition from the navigation // or from the router default const shouldViewTransition = @@ -2693,7 +2487,7 @@ export class RouterCore< : shouldViewTransition.types if (resolvedViewTransitionTypes === false) { - fn() + await fn() return } @@ -2705,58 +2499,19 @@ export class RouterCore< startViewTransitionParams = fn } - document.startViewTransition(startViewTransitionParams) + await document.startViewTransition(startViewTransitionParams) + .updateCallbackDone } else { - fn() + await fn() } } - 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) - } - } - }) - } - - getMatch: GetMatchFn = (matchId: string): AnyRouteMatch | undefined => { - return ( - this.stores.cachedMatchStores.get(matchId)?.get() ?? - this.stores.pendingMatchStores.get(matchId)?.get() ?? - this.stores.matchStores.get(matchId)?.get() - ) - } - /** * Invalidate the current matches and optionally force them back into a pending state. * * - Marks all matches that pass the optional `filter` as `invalid: true`. - * - If `forcePending` is true, or a match is currently in `'error'` or `'notFound'` status, - * its status is reset to `'pending'` and its `error` cleared so that the loader is re-run - * on the next `load()` call (eg. after HMR or a manual invalidation). + * The next load decides when to publish pending UI, so invalidation does not + * mutate the currently rendered status. */ invalidate: InvalidateFn< RouterCore< @@ -2769,12 +2524,17 @@ export class RouterCore< > = (opts) => { const invalidate = (d: MakeRouteMatch) => { if (opts?.filter?.(d as MakeRouteMatchUnion) ?? true) { + const route = this.routesById[d.routeId] as AnyRoute return { ...d, invalid: true, - ...(opts?.forcePending || - d.status === 'error' || - d.status === 'notFound' + ...((opts?.forcePending || + d.status === 'error' || + d.status === 'notFound') && + (route.options.loader || + route.options.beforeLoad || + route.lazyFn || + routeNeedsPreload(route)) ? ({ status: 'pending', error: undefined } as const) : undefined), } @@ -2782,29 +2542,25 @@ export class RouterCore< return d } + const committed = (this._committedMatches ?? this.stores.matches.get()).map( + invalidate, + ) + this._committedMatches = committed 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.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) + const href = location.publicHref || '/' redirect.options.href = href redirect.headers.set('Location', href) } else if (locationHeader) { @@ -2841,43 +2597,17 @@ export class RouterCore< } clearCache: ClearCacheFn = (opts) => { + const cached = this.stores.cachedMatches.get() const filter = opts?.filter - if (filter !== undefined) { - this.stores.setCached( - this.stores.cachedMatches - .get() - .filter((m) => !filter(m as MakeRouteMatchUnion)), - ) - } else { - this.stores.setCached([]) - } - } - - 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 + const retained: Array = [] + for (const match of cached) { + if (filter && !filter(match as MakeRouteMatchUnion)) { + retained.push(match) + } else { + discardMatchResources(this, [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(retained) } loadRouteChunk = loadRouteChunk @@ -2888,50 +2618,49 @@ 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]) + if (!(isServer ?? this.isServer)) { + return preloadClientRoute(this, opts) } + const next = opts._builtLocation ?? this.buildLocation(opts as any) + 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) - } - }, - }) + const result = await loadServer( + this, + next, + this.matchRoutes(next, { + throwOnError: true, + preload: true, + dest: opts, + }), + true, + ) + if (result.type === 'redirect') { + if (result.redirect.options.reloadDocument) { + return + } + return this.preloadRoute({ + ...result.redirect.options, + _fromLocation: next, + }) + } + if (result.matches.some((match) => match.status !== 'success')) { + return + } - return matches + const active = new Set(this.stores.matchesId.get()) + const candidates = result.matches.filter((match) => { + match.preload = true + return !active.has(match.id) + }) + const ids = new Set(candidates.map((match) => match.id)) + this.stores.setCached( + this.stores.cachedMatches + .get() + .filter((match) => !ids.has(match.id)) + .concat(candidates), + ) + return result.matches } catch (err) { if (isRedirect(err)) { if (err.options.reloadDocument) { @@ -2972,7 +2701,9 @@ export class RouterCore< } const pending = - opts?.pending === undefined ? !this.stores.isLoading.get() : opts.pending + opts?.pending === undefined + ? this.stores.status.get() !== 'pending' + : opts.pending const baseLocation = pending ? this.latestLocation @@ -3012,11 +2743,19 @@ export class RouterCore< serverSsr?: ServerSsr serverSsrLifecycle?: RouterSsrLifecycle +} - hasNotFoundMatch = () => { - return this.stores.matches - .get() - .some((d) => d.status === 'notFound' || d.globalNotFound) +if (process.env.NODE_ENV !== 'production') { + RouterCore.prototype._refreshRoute = async function (routeId) { + this._serverResult = undefined + this.updateLatestLocation() + const locationChangeInfo = getLocationChangeInfo( + this.latestLocation, + this.stores.resolvedLocation.get(), + ) + this.emit({ type: 'onBeforeNavigate', ...locationChangeInfo }) + this.emit({ type: 'onBeforeLoad', ...locationChangeInfo }) + await refreshClientRoute(this, routeId) } } @@ -3053,14 +2792,11 @@ export function getInitialRouterState( location: ParsedLocation, ): RouterState { return { - loadedAt: 0, isLoading: false, - isTransitioning: false, status: 'idle', resolvedLocation: undefined, location, matches: [], - statusCode: 200, } } @@ -3250,11 +2986,16 @@ function findGlobalNotFoundRouteId( routes: ReadonlyArray, ) { if (notFoundMode !== 'root') { + let fallback for (let i = routes.length - 1; i >= 0; i--) { const route = routes[i]! - if (route.children) { + if (route.options.notFoundComponent) { return route.id } + fallback ||= route.children && route.id + } + if (fallback) { + return fallback } } return rootRouteId diff --git a/packages/router-core/src/ssr/createRequestHandler.ts b/packages/router-core/src/ssr/createRequestHandler.ts index 32377b2bea..d05b217804 100644 --- a/packages/router-core/src/ssr/createRequestHandler.ts +++ b/packages/router-core/src/ssr/createRequestHandler.ts @@ -84,9 +84,9 @@ function getRequestHeaders(opts: { router: AnyRouter }): Headers { } // Handle Redirects - const redirect = opts.router.stores.redirect.get() - if (redirect) { - matchHeaders.push(redirect.headers) + const result = opts.router._serverResult + if (result?.type === 'redirect') { + matchHeaders.push(result.redirect.headers) } return mergeHeaders( diff --git a/packages/router-core/src/ssr/ssr-client.ts b/packages/router-core/src/ssr/ssr-client.ts index 2aa5358ac0..9f79934964 100644 --- a/packages/router-core/src/ssr/ssr-client.ts +++ b/packages/router-core/src/ssr/ssr-client.ts @@ -1,310 +1 @@ -import { invariant } from '../invariant' -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 { AnyRouteMatch } from '../Matches' -import type { AnyRouter } from '../router' -import type { RouteContextOptions } from '../route' -import type { AnySerializationAdapter } from './serializer/transformer' - -declare global { - interface Window { - [GLOBAL_TSR]?: TsrSsrGlobal - [GLOBAL_SEROVAL]?: any - } -} - -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) { - 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!', - ) - } - - invariant() - } - - const serializationAdapters = router.options.serializationAdapters as - | Array - | undefined - - if (serializationAdapters?.length) { - const fromSerializableMap = new Map() - serializationAdapters.forEach((adapter) => { - fromSerializableMap.set(adapter.key, adapter.fromSerializable) - }) - window.$_TSR.t = fromSerializableMap - window.$_TSR.buffer.forEach((script) => script()) - } - window.$_TSR.initialized = true - - if (!window.$_TSR.router) { - 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!', - ) - } - - 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 - - router.ssr = { - manifest, - } - const meta = document.querySelector('meta[property="csp-nonce"]') as - | HTMLMetaElement - | undefined - const nonce = meta?.content - router.options.ssr = { - nonce, - } - - // Allow the user to handle custom hydration data before matching routes. - // This lets hydration install router config that affects matching, e.g. rewrites. - await router.options.hydrate?.(dehydratedData) - - // Hydrate the router state - const matches = router.matchRoutes(router.stores.location.get()) - - // kick off loading the route chunks - const routeChunkPromise = Promise.all( - matches.map((match) => - router.loadRouteChunk(router.looseRoutesById[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) - } - } - - function setRouteSsr(match: AnyRouteMatch) { - const route = router.looseRoutesById[match.routeId] - if (route) { - route.options.ssr = match.ssr - } - } - // 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) - - match._nonReactive.dehydrated = match.ssr !== false - - if (match.ssr === 'data-only' || match.ssr === false) { - if (firstNonSsrMatchIndex === undefined) { - firstNonSsrMatchIndex = match.index - setMatchForcePending(match) - } - } - }) - - router.stores.setMatches(matches) - - // 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 = - { - deps: match.loaderDeps, - params: match.params, - context: parentContext ?? {}, - location, - navigate: (opts: any) => - router.navigate({ - ...opts, - _fromLocation: location, - }), - buildLocation: router.buildLocation, - cause: match.cause, - abortController: match.abortController, - preload: false, - matches, - routeId: route.id, - } - match.__routeContext = - route.options.context(contextFnContext) ?? undefined - } - - match.context = { - ...parentContext, - ...match.__routeContext, - ...match.__beforeLoadContext, - } - - 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, - ) - throw 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 - }) - // 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 - } - - // 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() - .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.', - ) - } - - invariant() - } - setMatchForcePending(match) - - match._displayPending = true - match._nonReactive.displayPendingPromise = loadPromise - - loadPromise.then(() => { - router.batch(() => { - // ensure router is not in status 'pending' anymore - // this usually happens in Transitioner but if loading synchronously resolves, - // Transitioner won't be rendered while loading so it cannot track the change from loading:true to loading:false - if (router.stores.status.get() === 'pending') { - 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 routeChunkPromise -} +export { hydrate } from '../hydrate' diff --git a/packages/router-core/src/ssr/ssr-server.ts b/packages/router-core/src/ssr/ssr-server.ts index 31db2f5e41..110cf704b5 100644 --- a/packages/router-core/src/ssr/ssr-server.ts +++ b/packages/router-core/src/ssr/ssr-server.ts @@ -540,10 +540,6 @@ export function attachRouterServerSsrUtils({ manifest: manifestToDehydrate, matches, } - const lastMatchId = matchesToDehydrate[matchesToDehydrate.length - 1]?.id - if (lastMatchId) { - dehydratedRouter.lastMatchId = dehydrateSsrMatchId(lastMatchId) - } const dehydratedData = await router.options.dehydrate?.() if (dehydratedData) { dehydratedRouter.dehydratedData = dehydratedData diff --git a/packages/router-core/src/stores.ts b/packages/router-core/src/stores.ts index c2ab8311c3..5f86eef934 100644 --- a/packages/router-core/src/stores.ts +++ b/packages/router-core/src/stores.ts @@ -5,7 +5,6 @@ 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 { @@ -71,24 +70,13 @@ export function createNonReactiveReadonlyStore( 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 */ - cachedIds: RouterWritableStore> matches: ReadableStore> - pendingMatches: ReadableStore> cachedMatches: ReadableStore> - firstId: ReadableStore - hasPending: ReadableStore matchRouteDeps: ReadableStore<{ locationHref: string resolvedLocationHref: string | undefined @@ -97,7 +85,6 @@ export interface RouterStores { __store: RouterReadableStore> matchStores: Map - pendingMatchStores: Map cachedMatchStores: Map /** @@ -111,7 +98,6 @@ export interface RouterStores { ) => RouterReadableStore setMatches: (nextMatches: Array) => void - setPending: (nextMatches: Array) => void setCached: (nextMatches: Array) => void } @@ -123,39 +109,22 @@ export function createRouterStores( // non reactive utilities const matchStores = new Map() - const pendingMatchStores = new Map() const cachedMatchStores = new Map() // atoms 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>([]) // 1st order derived stores const matches = createReadonlyStore(() => readPoolMatches(matchStores, matchesId.get()), ) - const pendingMatches = createReadonlyStore(() => - readPoolMatches(pendingMatchStores, pendingIds.get()), - ) const cachedMatches = createReadonlyStore(() => readPoolMatches(cachedMatchStores, cachedIds.get()), ) - const firstId = createReadonlyStore(() => matchesId.get()[0]) - const hasPending = createReadonlyStore(() => - matchesId.get().some((matchId) => { - const store = matchStores.get(matchId) - return store?.get().status === 'pending' - }), - ) const matchRouteDeps = createReadonlyStore(() => ({ locationHref: location.get().href, resolvedLocationHref: resolvedLocation.get()?.href, @@ -165,14 +134,10 @@ export function createRouterStores( // compatibility "big" state store const __store = createReadonlyStore(() => ({ status: status.get(), - loadedAt: loadedAt.get(), - isLoading: isLoading.get(), - isTransitioning: isTransitioning.get(), + isLoading: status.get() === 'pending', matches: matches.get(), location: location.get(), resolvedLocation: resolvedLocation.get(), - statusCode: statusCode.get(), - redirect: redirect.get(), })) // Per-routeId computed store cache. @@ -215,28 +180,17 @@ export function createRouterStores( const store = { // atoms status, - loadedAt, - isLoading, - isTransitioning, location, resolvedLocation, - statusCode, - redirect, matchesId, - pendingIds, - cachedIds, // derived matches, - pendingMatches, cachedMatches, - firstId, - hasPending, matchRouteDeps, // non-reactive state matchStores, - pendingMatchStores, cachedMatchStores, // compatibility "big" state @@ -247,7 +201,6 @@ export function createRouterStores( // methods setMatches, - setPending, setCached, } @@ -266,16 +219,6 @@ export function createRouterStores( ) } - function setPending(nextMatches: Array) { - reconcileMatchPool( - nextMatches, - pendingMatchStores, - pendingIds, - createMutableStore, - batch, - ) - } - function setCached(nextMatches: Array) { reconcileMatchPool( nextMatches, diff --git a/packages/router-core/tests/background-assets-stale.test.ts b/packages/router-core/tests/background-assets-stale.test.ts new file mode 100644 index 0000000000..72d478317e --- /dev/null +++ b/packages/router-core/tests/background-assets-stale.test.ts @@ -0,0 +1,77 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter } from './routerTestUtils' + +describe('background decorative asset failure', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(0) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + test('commits fresh loader data while preserving the previous projected assets', async () => { + let resolveStaleReload!: (data: { title: string }) => void + let loaderCalls = 0 + let headCalls = 0 + const loader = () => { + loaderCalls += 1 + if (loaderCalls === 1) { + return { title: 'old' } + } + + return new Promise<{ title: string }>((resolve) => { + resolveStaleReload = resolve + }) + } + + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loader, + head: ({ loaderData }) => { + headCalls += 1 + // The loader always resolves before head runs in this test, so + // loaderData is never undefined here. + if (loaderData!.title === 'fresh') { + throw new Error('head projection failed') + } + + return { + meta: [{ title: loaderData!.title }], + } + }, + 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 getMatch = () => + router.state.matches.find((match) => match.id === matchId) + + expect(getMatch()?.loaderData).toEqual({ title: 'old' }) + expect(getMatch()?.meta).toEqual([{ title: 'old' }]) + + await vi.advanceTimersByTimeAsync(1) + await router.load() + await vi.waitFor(() => expect(loaderCalls).toBe(2)) + + resolveStaleReload({ title: 'fresh' }) + await vi.waitFor(() => + expect(getMatch()?.loaderData).toEqual({ title: 'fresh' }), + ) + + expect(getMatch()?.meta).toEqual([{ title: 'old' }]) + }) +}) diff --git a/packages/router-core/tests/background-trim-abort.test.ts b/packages/router-core/tests/background-trim-abort.test.ts new file mode 100644 index 0000000000..b680460120 --- /dev/null +++ b/packages/router-core/tests/background-trim-abort.test.ts @@ -0,0 +1,175 @@ +import { expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { + BaseRootRoute, + BaseRoute, + createControlledPromise, + notFound, +} from '../src' +import { createTestRouter } from './routerTestUtils' + +test('background error trimming aborts a discarded descendant loader signal', async () => { + let parentLoads = 0 + let childLoads = 0 + let retainedParentSignal: AbortSignal | undefined + let discardedChildSignal: AbortSignal | undefined + const parentError = new Error('parent background reload failed') + + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: ({ abortController }) => { + if (++parentLoads > 1) { + retainedParentSignal = abortController.signal + throw parentError + } + return 'initial parent data' + }, + errorComponent: () => null, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: ({ abortController }) => { + if (++childLoads > 1) { + discardedChildSignal = abortController.signal + } + return 'child data' + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + }) + + await router.load() + await router.invalidate() + + await expect.poll(() => router.state.matches.at(-1)?.status).toBe('error') + + expect(router.state.matches.map((match) => match.routeId)).toEqual([ + rootRoute.id, + parentRoute.id, + ]) + expect(retainedParentSignal).toBeDefined() + expect(discardedChildSignal).toBeDefined() + expect(discardedChildSignal).not.toBe(retainedParentSignal) + expect(retainedParentSignal?.aborted).toBe(false) + expect(discardedChildSignal?.aborted).toBe(true) +}) + +test('a background notFound stays private until its parent boundary is ready', async () => { + const boundaryReady = createControlledPromise() + const ParentNotFound = Object.assign(() => null, { + preload: () => boundaryReady, + }) + let childLoads = 0 + + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + notFoundComponent: ParentNotFound, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: () => { + if (++childLoads > 1) { + throw notFound() + } + return 'initial child data' + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + }) + + await router.load() + await router.invalidate({ + filter: (match) => match.routeId === childRoute.id, + }) + await vi.waitFor(() => expect(childLoads).toBe(2)) + + expect(router.state.matches.map((match) => match.status)).toEqual([ + 'success', + 'success', + 'success', + ]) + + boundaryReady.resolve() + await vi.waitFor(() => { + expect(router.state.matches.map((match) => match.routeId)).toEqual([ + rootRoute.id, + parentRoute.id, + ]) + expect(router.state.matches.at(-1)?.status).toBe('notFound') + }) +}) + +test('foreground supersession aborts every loader in a background batch', async () => { + let parentLoads = 0 + let childLoads = 0 + const backgroundSignals: Array = [] + + const waitForAbort = (signal: AbortSignal) => + new Promise((_resolve, reject) => { + signal.addEventListener( + 'abort', + () => { + const error = new Error('aborted') + error.name = 'AbortError' + reject(error) + }, + { once: true }, + ) + }) + + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: ({ abortController }) => { + if (++parentLoads > 1) { + backgroundSignals.push(abortController.signal) + return waitForAbort(abortController.signal) + } + return 'initial parent data' + }, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: ({ abortController }) => { + if (++childLoads > 1) { + backgroundSignals.push(abortController.signal) + return waitForAbort(abortController.signal) + } + return 'initial child data' + }, + }) + const otherRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/other', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + parentRoute.addChildren([childRoute]), + otherRoute, + ]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + }) + + await router.load() + const revalidation = router.invalidate() + await vi.waitFor(() => expect(backgroundSignals).toHaveLength(2)) + + await router.navigate({ to: '/other' }) + await revalidation + + expect(backgroundSignals[0]).not.toBe(backgroundSignals[1]) + expect(backgroundSignals.every((signal) => signal.aborted)).toBe(true) + expect(router.state.matches.at(-1)?.routeId).toBe(otherRoute.id) +}) diff --git a/packages/router-core/tests/blocked-navigation-current-load.test.ts b/packages/router-core/tests/blocked-navigation-current-load.test.ts new file mode 100644 index 0000000000..3722c08d51 --- /dev/null +++ b/packages/router-core/tests/blocked-navigation-current-load.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute, createControlledPromise } from '../src' +import { createTestRouter } from './routerTestUtils' + +/** + * A history blocker can discard a navigation commit AFTER + * buildAndCommitLocation set router.pendingBuiltLocation (it is cleared a + * microtask later). A continuation of the still-current in-flight load that + * resumes inside that window must not treat the merely-built location as + * ownership loss: a blocked commit never starts a replacement load, so + * abandoning the pass would leave the router with status 'pending', + * isLoading true, and a populated pending pool forever. + */ + +describe('blocked navigation does not cancel the current load', () => { + test('loader continuation resuming while a blocked navigation is built still commits', async () => { + const loaderGate = createControlledPromise() + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const slowRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/slow', + loader: () => loaderGate, + }) + const otherRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/other', + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, slowRoute, otherRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + + const navigation = router.navigate({ to: '/slow' }) + + // Discard every later navigation commit. The blocker is async, so the + // discarded commit still sets pendingBuiltLocation for one microtask. + const unblock = router.history.block({ blockerFn: async () => true }) + + // Same tick: settle the loader, then issue a navigation the blocker + // discards. The loader continuation resumes inside the window where + // pendingBuiltLocation still points at /other. + loaderGate.resolve('slow data') + void router.navigate({ to: '/other' }) + + await navigation + + expect(router.state.location.pathname).toBe('/slow') + expect( + router.state.matches.find((m) => m.routeId === slowRoute.id), + ).toMatchObject({ + status: 'success', + loaderData: 'slow data', + }) + unblock() + }) +}) diff --git a/packages/router-core/tests/boundary-component-chunk.test.ts b/packages/router-core/tests/boundary-component-chunk.test.ts new file mode 100644 index 0000000000..4616f20476 --- /dev/null +++ b/packages/router-core/tests/boundary-component-chunk.test.ts @@ -0,0 +1,83 @@ +import { afterEach, describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute, createControlledPromise } from '../src' +import { createTestRouter } from './routerTestUtils' + +/** + * boundary component preloads should not be blocked by an unrelated + * normal route component preload. A route load starts the normal component + * preload before the loader settles; if that loader throws, the error boundary + * asks to load only the route's errorComponent. + * + * This test uses a real client router load. It keeps the normal route component + * preload pending, resolves the errorComponent preload, and expects the route + * error state to be committed without waiting for the normal component chunk. + */ + +const pendingGates: Array>> = [] +const pendingLoads: Array> = [] + +afterEach(async () => { + for (const gate of pendingGates) { + gate.resolve() + } + + await Promise.allSettled(pendingLoads) + + pendingGates.length = 0 + pendingLoads.length = 0 +}) + +describe('route boundary component preloads', () => { + test('errorComponent preload resolves without waiting for a pending route component preload', async () => { + const componentGate = createControlledPromise() + const errorComponentGate = createControlledPromise() + const routeError = new Error('loader failed') + let errorComponentPreloadCalls = 0 + pendingGates.push(componentGate, errorComponentGate) + + const SlowRouteComponent = Object.assign(() => null, { + preload: () => componentGate, + }) + const ErrorBoundary = Object.assign(() => null, { + preload: () => { + errorComponentPreloadCalls++ + return errorComponentGate + }, + }) + + const rootRoute = new BaseRootRoute({}) + const route = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/chunked', + loader: () => { + throw routeError + }, + component: SlowRouteComponent as any, + errorComponent: ErrorBoundary as any, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([route]), + history: createMemoryHistory({ initialEntries: ['/chunked'] }), + }) + + const loadPromise = router.load() + pendingLoads.push(loadPromise) + + await vi.waitFor(() => expect(errorComponentPreloadCalls).toBe(1)) + errorComponentGate.resolve() + + // The route component chunk (componentGate) is still pending: the error + // state must commit without waiting for it. + await vi.waitFor(() => { + const match = router.state.matches.find( + (item) => item.routeId === route.id, + ) + expect(match?.status).toBe('error') + expect(match?.error).toBe(routeError) + }) + + componentGate.resolve() + await loadPromise + }) +}) diff --git a/packages/router-core/tests/callbacks.test.ts b/packages/router-core/tests/callbacks.test.ts index 1673626f7e..7a1b062d4f 100644 --- a/packages/router-core/tests/callbacks.test.ts +++ b/packages/router-core/tests/callbacks.test.ts @@ -200,10 +200,6 @@ describe('callbacks', () => { )?.id expect(page2MatchId).toBeDefined() expect(page2MatchId).not.toBe(page1MatchId) - expect( - router.stores.cachedIds.get().some((id) => id === page1MatchId), - ).toBe(true) - await router.navigate({ to: '/foo', search: { page: '1' } }) expect(loader).toHaveBeenCalledTimes(2) expect(router.state.matches.some((d) => d.id === page1MatchId)).toBe(true) @@ -225,10 +221,6 @@ describe('callbacks', () => { )?.id expect(post2MatchId).toBeDefined() expect(post2MatchId).not.toBe(post1MatchId) - expect( - router.stores.cachedIds.get().some((id) => id === post1MatchId), - ).toBe(true) - await router.navigate({ to: '/posts/$postId', params: { postId: '1' } }) expect(loader).toHaveBeenCalledTimes(2) expect(router.state.matches.some((d) => d.id === post1MatchId)).toBe(true) diff --git a/packages/router-core/tests/chunk-failure-lifecycle.test.ts b/packages/router-core/tests/chunk-failure-lifecycle.test.ts new file mode 100644 index 0000000000..bf9fa369d4 --- /dev/null +++ b/packages/router-core/tests/chunk-failure-lifecycle.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter } from './routerTestUtils' + +/** + * primary route chunk failures should go through the same route-error + * lifecycle as loader and beforeLoad failures. Skipping normalization means + * route onError callbacks, redirects, and notFound values from lazy/chunk + * loading can behave differently from other route failures. + * + * This test uses a real router navigation to a lazy route whose chunk rejects. + * The match reaches error state with the chunk error, but the route's onError + * callback should also receive that error as part of normal route failure + * handling. + */ + +describe('route chunk failure lifecycle', () => { + test('a rejected lazy chunk is retried on the next load instead of replaying forever', async () => { + let lazyCalls = 0 + const chunkError = new Error('lazy chunk failed once') + + const rootRoute = new BaseRootRoute({}) + const lazyRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/lazy', + loader: () => 'loaded', + }).lazy(() => { + lazyCalls++ + if (lazyCalls === 1) { + return Promise.reject(chunkError) + } + return Promise.resolve({ options: {} } as any) + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([lazyRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.navigate({ to: '/lazy' }) + expect( + router.state.matches.find((match) => match.routeId === lazyRoute.id) + ?.status, + ).toBe('error') + + await router.invalidate() + expect(lazyCalls).toBe(2) + expect( + router.state.matches.find((match) => match.routeId === lazyRoute.id) + ?.status, + ).toBe('success') + }) + + test('calls route onError when lazy route chunk rejects during navigation', async () => { + const chunkError = new Error('lazy chunk failed') + let capturedError: unknown + + const rootRoute = new BaseRootRoute({}) + const lazyRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/lazy', + onError: (error) => { + capturedError = error + }, + }).lazy(() => Promise.reject(chunkError)) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([lazyRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.navigate({ to: '/lazy' }) + + const lazyMatch = router.state.matches.find( + (match) => match.routeId === lazyRoute.id, + ) + expect(lazyMatch?.status).toBe('error') + expect(lazyMatch?.error).toBe(chunkError) + expect(capturedError).toBe(chunkError) + }) +}) diff --git a/packages/router-core/tests/client-lane-adversarial.test.ts b/packages/router-core/tests/client-lane-adversarial.test.ts new file mode 100644 index 0000000000..d84fad1c57 --- /dev/null +++ b/packages/router-core/tests/client-lane-adversarial.test.ts @@ -0,0 +1,448 @@ +import { afterEach, describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { + BaseRootRoute, + BaseRoute, + createControlledPromise, + notFound, + redirect, +} from '../src' +import { createTestRouter } from './routerTestUtils' + +afterEach(() => { + vi.useRealTimers() +}) + +function abortAwareGate(signal: AbortSignal): Promise { + return new Promise((_resolve, reject) => { + signal.addEventListener( + 'abort', + () => { + const error = new Error('aborted') + error.name = 'AbortError' + reject(error) + }, + { once: true }, + ) + }) +} + +describe('adversarial client lane ownership', () => { + test.each(['onBeforeNavigate', 'onBeforeLoad'] as const)( + 'a throwing %s listener cannot interrupt later listeners or navigation finalization', + async (eventType) => { + const listenerError = new Error(`${eventType} listener failed`) + const laterListener = vi.fn() + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const targetLoader = vi.fn(() => 'target data') + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + loader: targetLoader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, targetRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + router.subscribe(eventType, () => { + throw listenerError + }) + router.subscribe(eventType, laterListener) + + await router.navigate({ to: '/target' }) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(laterListener).toHaveBeenCalledTimes(1) + expect(router.state.location.pathname).toBe('/target') + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: targetRoute.id, + status: 'success', + }) + expect(targetLoader).toHaveBeenCalledTimes(1) + }, + ) + + test('superseding a published pending lane diffs lifecycle hooks from the last final lane', async () => { + const aOnLeave = vi.fn() + const bOnEnter = vi.fn() + const bOnLeave = vi.fn() + const cOnEnter = vi.fn() + + const rootRoute = new BaseRootRoute({}) + const aRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/a', + onLeave: aOnLeave, + }) + const bRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/b', + pendingMs: 0, + pendingComponent: () => null, + loader: ({ abortController }) => abortAwareGate(abortController.signal), + onEnter: bOnEnter, + onLeave: bOnLeave, + }) + const cRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/c', + onEnter: cOnEnter, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([aRoute, bRoute, cRoute]), + history: createMemoryHistory({ initialEntries: ['/a'] }), + }) + + await router.load() + aOnLeave.mockClear() + + const bNavigation = router.navigate({ to: '/b' }) + await vi.waitFor(() => + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: bRoute.id, + status: 'pending', + }), + ) + + await router.navigate({ to: '/c' }) + await bNavigation + + expect(router.state.matches.at(-1)?.routeId).toBe(cRoute.id) + expect(aOnLeave).toHaveBeenCalledTimes(1) + expect(bOnEnter).not.toHaveBeenCalled() + expect(bOnLeave).not.toHaveBeenCalled() + expect(cOnEnter).toHaveBeenCalledTimes(1) + }) + + test('commits a failing parent loader without projecting its trimmed child', async () => { + const parentError = new Error('parent failed') + const childHead = vi.fn(() => ({ + meta: [{ title: 'unreachable child' }], + })) + const childOnEnter = vi.fn() + + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: () => { + throw parentError + }, + errorComponent: () => null, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: () => { + throw notFound() + }, + notFoundComponent: () => null, + head: childHead, + onEnter: childOnEnter, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + }) + + await router.load() + + expect(router.state.matches.map((match) => match.routeId)).toEqual([ + rootRoute.id, + parentRoute.id, + ]) + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: parentRoute.id, + status: 'error', + error: parentError, + }) + expect(childHead).not.toHaveBeenCalled() + expect(childOnEnter).not.toHaveBeenCalled() + }) + + test('a redirect aborts the discarded loader generation before pending UI publishes', async () => { + let redirectSignal: AbortSignal | undefined + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const sourceRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/source', + loader: ({ abortController }) => { + redirectSignal = abortController.signal + throw redirect({ to: '/target' }) + }, + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, sourceRoute, targetRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + await router.navigate({ to: '/source' }) + + expect(router.state.location.pathname).toBe('/target') + expect(router.state.matches.at(-1)?.routeId).toBe(targetRoute.id) + expect(redirectSignal?.aborted).toBe(true) + }) + + test('trimming a successful descendant aborts its discarded loader generation', async () => { + const parentError = new Error('parent failed') + let childSignal: AbortSignal | undefined + let deferredRequestAborted = false + + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: () => { + throw parentError + }, + errorComponent: () => null, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: ({ abortController }) => { + childSignal = abortController.signal + const deferredRequest = new Promise<'aborted'>((resolve) => { + abortController.signal.addEventListener( + 'abort', + () => { + deferredRequestAborted = true + resolve('aborted') + }, + { once: true }, + ) + }) + return { deferredRequest } + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + }) + + await router.load() + + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: parentRoute.id, + status: 'error', + error: parentError, + }) + expect(childSignal?.aborted).toBe(true) + expect(deferredRequestAborted).toBe(true) + }) + + test('preload trimming aborts a successful descendant owned only by the discarded lane', async () => { + const parentError = new Error('preload parent failed') + let childSignal: AbortSignal | undefined + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: () => { + throw parentError + }, + errorComponent: () => null, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: ({ abortController }) => { + childSignal = abortController.signal + return { + deferredRequest: new Promise<'aborted'>((resolve) => { + abortController.signal.addEventListener( + 'abort', + () => resolve('aborted'), + { once: true }, + ) + }), + } + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + await router.preloadRoute({ to: '/parent/child' }) + + expect(childSignal?.aborted).toBe(true) + }) + + test('navigation started by route context cannot be overwritten by the stale matching pass', async () => { + const bLoaderGate = createControlledPromise() + const bLoader = vi.fn(() => bLoaderGate) + let retainedLoaderSignal: AbortSignal | undefined + let abandonedContextSignal: AbortSignal | undefined + let redirected = false + + const rootRoute = new BaseRootRoute({ + staleTime: Infinity, + loader: ({ abortController }) => { + retainedLoaderSignal = abortController.signal + return 'root data' + }, + }) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const aRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/a', + context: ({ navigate, abortController }) => { + abandonedContextSignal = abortController.signal + if (!redirected) { + redirected = true + void navigate({ to: '/b' }) + } + return { fromA: true } + }, + }) + const bRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/b', + loader: bLoader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, aRoute, bRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + const navigation = router.navigate({ to: '/a' }) + await vi.waitFor(() => expect(bLoader).toHaveBeenCalledTimes(1)) + + bLoaderGate.resolve() + await navigation + + expect(router.state.location.pathname).toBe('/b') + expect(router.state.matches.at(-1)?.routeId).toBe(bRoute.id) + expect(abandonedContextSignal?.aborted).toBe(true) + expect(retainedLoaderSignal?.aborted).toBe(false) + }) + + test('a context failure aborts pass controllers from the partial preflight lane', async () => { + const contextError = new Error('context failed after starting work') + + let contextSignal: AbortSignal | undefined + let contextWorkAborted = false + const safeLoader = vi.fn(() => 'safe data') + + const rootRoute = new BaseRootRoute({}) + const brokenRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/broken', + context: ({ abortController }) => { + contextSignal = abortController.signal + abortController.signal.addEventListener( + 'abort', + () => { + contextWorkAborted = true + }, + { once: true }, + ) + throw contextError + }, + }) + const safeRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/safe', + loader: safeLoader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([brokenRoute, safeRoute]), + history: createMemoryHistory({ initialEntries: ['/broken'] }), + }) + + await router.load() + expect(contextSignal?.aborted).toBe(true) + expect(contextWorkAborted).toBe(true) + + await router.navigate({ to: '/safe' }) + expect(router.state.location.pathname).toBe('/safe') + expect(safeLoader).toHaveBeenCalledTimes(1) + }) + + test('a failed preflight cannot abort and strand the pending lane it attempted to supersede', async () => { + const preflightError = new Error('next loaderDeps failed') + const pendingGate = createControlledPromise() + let pendingSignal: AbortSignal | undefined + const pendingStarted = createControlledPromise() + + const rootRoute = new BaseRootRoute({}) + const aRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/a', + }) + const pendingRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/pending', + pendingMs: 0, + pendingComponent: () => null, + loader: ({ abortController }) => { + pendingSignal = abortController.signal + pendingStarted.resolve() + return Promise.race([ + pendingGate, + abortAwareGate(abortController.signal), + ]) + }, + }) + const brokenRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/broken', + loaderDeps: (): Record => { + throw preflightError + }, + loader: () => 'never runs', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([aRoute, pendingRoute, brokenRoute]), + history: createMemoryHistory({ initialEntries: ['/a'] }), + }) + + await router.load() + const pendingNavigation = router.navigate({ to: '/pending' }) + await pendingStarted + + const brokenNavigation = router.navigate({ to: '/broken' }) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(pendingSignal?.aborted).toBe(false) + + pendingGate.resolve() + await Promise.all([pendingNavigation, brokenNavigation]) + + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: pendingRoute.id, + status: 'success', + }) + }) +}) diff --git a/packages/router-core/tests/fatal-load-rejection.test.ts b/packages/router-core/tests/fatal-load-rejection.test.ts new file mode 100644 index 0000000000..93c0c262c6 --- /dev/null +++ b/packages/router-core/tests/fatal-load-rejection.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute, redirect } from '../src' +import { createTestRouter } from './routerTestUtils' + +/** + * A genuinely fatal router rejection (not a route outcome — for example, + * redirect resolution throwing while finalizing a serial beforeLoad + * redirect) must settle without publishing matches whose load + * promises never settled: the serial redirect capped the loader prefix at 0, + * so the loader phase never ran for ancestor matches and their pending + * loadPromise would hang Suspense forever. + */ + +describe('fatal load rejection', () => { + test('fatal rejection during a serial-redirect-capped pass settles the lane', async () => { + const boom = new Error('resolveRedirect failed') + let blockErrorComponentPreload = true + const errorComponentPreload = vi.fn(() => + blockErrorComponentPreload + ? new Promise(() => {}) + : Promise.resolve(), + ) + + const rootRoute = new BaseRootRoute({ + // Never runs: the serial redirect caps the loader prefix at 0. Its + // loadPromise (created for the beforeLoad phase) must still settle. + loader: () => 'root data', + errorComponent: { preload: errorComponentPreload } as any, + }) + const badRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/bad', + beforeLoad: () => { + throw redirect({ + to: '/bad', + search: () => { + throw boom + }, + }) + }, + }) + const safeLoader = vi.fn(() => 'safe data') + const safeRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/safe', + loader: safeLoader, + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([badRoute, safeRoute]), + history: createMemoryHistory({ initialEntries: ['/bad'] }), + }) + + await router.load() + expect(errorComponentPreload).not.toHaveBeenCalled() + + blockErrorComponentPreload = false + await router.navigate({ to: '/safe' }) + expect(router.state.location.pathname).toBe('/safe') + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: safeRoute.id, + status: 'success', + loaderData: 'safe data', + }) + expect(safeLoader).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/router-core/tests/granular-stores.test.ts b/packages/router-core/tests/granular-stores.test.ts index 54d2a4efab..4b9affe7f2 100644 --- a/packages/router-core/tests/granular-stores.test.ts +++ b/packages/router-core/tests/granular-stores.test.ts @@ -31,60 +31,8 @@ function createRouter() { }) } -function createLoaderRouter({ - initialEntries = ['/posts/123'], - staleTime = 0, -}: { - initialEntries?: Array - staleTime?: number -} = {}) { - let resolveLoader: (() => void) | undefined - let callCount = 0 - - const rootRoute = new BaseRootRoute({}) - - const indexRoute = new BaseRoute({ - getParentRoute: () => rootRoute, - path: '/', - }) - - const aboutRoute = new BaseRoute({ - getParentRoute: () => rootRoute, - path: '/about', - }) - - const postRoute = new BaseRoute({ - getParentRoute: () => rootRoute, - path: '/posts/$postId', - staleTime, - loader: () => { - callCount += 1 - - if (callCount === 1) { - return { version: 'initial' } - } - - return new Promise<{ version: string }>((resolve) => { - resolveLoader = () => resolve({ version: 'reloaded' }) - }) - }, - }) - - const routeTree = rootRoute.addChildren([indexRoute, aboutRoute, postRoute]) - - return { - router: createTestRouter({ - routeTree, - history: createMemoryHistory({ - initialEntries, - }), - }), - resolveLoader: () => resolveLoader?.(), - } -} - describe('granular stores', () => { - test('keeps pool stores correct across active/pending/cached transitions', async () => { + test('keeps pool stores correct across active/cached transitions', async () => { const router = createRouter() await router.navigate({ to: '/posts/123' }) @@ -104,41 +52,16 @@ describe('granular stores', () => { ) }) - const pendingMatches = [...activeMatches].reverse().map((match, index) => ({ - ...match, - id: `${match.id}__pending_${index}`, - })) const cachedMatches = [...activeMatches].map((match, index) => ({ ...match, id: `${match.id}__cached_${index}`, })) - router.stores.setPending(pendingMatches) router.stores.setCached(cachedMatches) expect(router.stores.matchesId.get()).toEqual( activeMatches.map((match) => match.id), ) - expect(router.stores.pendingIds.get()).toEqual( - pendingMatches.map((match) => match.id), - ) - expect(router.stores.cachedIds.get()).toEqual( - cachedMatches.map((match) => match.id), - ) - - // Pending pool has correct routeIds - pendingMatches.forEach((match) => { - const pendingStore = router.stores.pendingMatchStores.get(match.id) - expect(pendingStore).toBeDefined() - expect(pendingStore!.routeId).toBe(match.routeId) - // Pending match is NOT in the active pool - expect(router.stores.matchStores.get(match.id)).toBeUndefined() - // Active pool still has a match for this routeId - expect( - router.stores.getRouteMatchStore(match.routeId).get(), - ).toBeDefined() - }) - const nextActiveMatches = activeMatches.map((match, index) => ({ ...match, id: `${match.id}__active_next_${index}`, @@ -158,43 +81,6 @@ describe('granular stores', () => { }) }) - test('match store updates are isolated to the touched active match', async () => { - const router = createRouter() - await router.navigate({ to: '/posts/123' }) - - const rootMatch = router.state.matches[0] - const leafMatch = router.state.matches[1] - - expect(rootMatch).toBeDefined() - expect(leafMatch).toBeDefined() - - if (!rootMatch || !leafMatch) { - throw new Error('Expected root and leaf matches to exist') - } - - const rootStore = router.stores.matchStores.get(rootMatch.id) - const leafStore = router.stores.matchStores.get(leafMatch.id) - - expect(rootStore).toBeDefined() - expect(leafStore).toBeDefined() - - if (!rootStore || !leafStore) { - throw new Error('Expected root and leaf match stores to exist') - } - - const rootBefore = rootStore.get() - const leafBefore = leafStore.get() - - router.updateMatch(leafMatch.id, (prev) => ({ - ...prev, - status: 'pending', - })) - - expect(rootStore.get()).toBe(rootBefore) - expect(leafStore.get()).not.toBe(leafBefore) - expect(leafStore.get().status).toBe('pending') - }) - test('getRouteMatchStore caches store instances and clears when route is inactive', async () => { const router = createRouter() await router.navigate({ to: '/posts/123' }) @@ -231,124 +117,5 @@ describe('granular stores', () => { expect(store).toBe(activeStoresBefore[i]) expect(store?.get()).toBe(activeStatesBefore[i]) } - - const pendingMatches = activeMatches.map((match) => ({ - ...match, - id: `${match.id}__pending`, - status: 'pending' as const, - })) - - router.stores.setPending(pendingMatches) - - const pendingIdsBefore = router.stores.pendingIds.get() - const pendingStoresBefore = pendingMatches.map((match) => - router.stores.pendingMatchStores.get(match.id), - ) - const pendingStatesBefore = pendingStoresBefore.map((store) => store?.get()) - - router.stores.setPending(pendingMatches) - - expect(router.stores.pendingIds.get()).toBe(pendingIdsBefore) - for (let i = 0; i < pendingMatches.length; i++) { - const match = pendingMatches[i]! - const store = router.stores.pendingMatchStores.get(match.id) - expect(store).toBe(pendingStoresBefore[i]) - expect(store?.get()).toBe(pendingStatesBefore[i]) - } - }) - - test('updateMatch prefers the pending pool when active and pending share an id', async () => { - const { router, resolveLoader } = createLoaderRouter() - - await router.load() - - const activeLeaf = router.state.matches[1] - - expect(activeLeaf).toBeDefined() - - if (!activeLeaf) { - throw new Error('Expected active leaf match to exist') - } - - const activeStore = router.stores.matchStores.get(activeLeaf.id) - - expect(activeStore).toBeDefined() - - if (!activeStore) { - throw new Error('Expected active leaf store to exist') - } - - const activeBefore = activeStore.get() - - const reloadPromise = router.load() - await Promise.resolve() - - const pendingStore = router.stores.pendingMatchStores.get(activeLeaf.id) - - expect(pendingStore).toBeDefined() - expect(router.stores.matchStores.get(activeLeaf.id)).toBe(activeStore) - - if (!pendingStore) { - throw new Error('Expected pending leaf store to exist') - } - - router.updateMatch(activeLeaf.id, (prev) => ({ - ...prev, - status: 'error', - error: new Error('pending-only-update'), - })) - - expect(activeStore.get()).toBe(activeBefore) - expect(activeStore.get().status).toBe('success') - expect(pendingStore.get().status).toBe('error') - expect(pendingStore.get().error).toEqual(new Error('pending-only-update')) - - resolveLoader() - await reloadPromise - }) - - test('supports duplicate ids across pools without cross-pool contamination', async () => { - const router = createRouter() - await router.navigate({ to: '/posts/123' }) - - const activeLeaf = router.state.matches[1]! - const duplicatedId = activeLeaf.id - const pendingDuplicate = { - ...activeLeaf, - status: 'pending' as const, - } - const cachedDuplicate = { - ...activeLeaf, - status: 'success' as const, - } - - router.stores.setPending([pendingDuplicate]) - router.stores.setCached([cachedDuplicate]) - - router.stores.setMatches( - router.state.matches.map((match) => - match.id === duplicatedId - ? { - ...match, - status: 'error' as const, - error: new Error('active-only-update'), - } - : match, - ), - ) - - expect(router.stores.matchStores.get(duplicatedId)?.get().status).toBe( - 'error', - ) - expect( - router.stores.getRouteMatchStore(activeLeaf.routeId).get()?.status, - ).toBe('error') - // Pending pool has its own store for this id - expect( - router.stores.pendingMatchStores.get(duplicatedId)?.get().status, - ).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') }) }) diff --git a/packages/router-core/tests/hydrate.test.ts b/packages/router-core/tests/hydrate.test.ts index efac230e49..e3440974c6 100644 --- a/packages/router-core/tests/hydrate.test.ts +++ b/packages/router-core/tests/hydrate.test.ts @@ -91,7 +91,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 @@ -100,7 +100,6 @@ describe('hydrate', () => { router: { manifest: testManifest, dehydratedData: {}, - lastMatchId: '/', matches: [], }, h: vi.fn(), @@ -129,7 +128,6 @@ describe('hydrate', () => { router: { manifest: testManifest, dehydratedData: {}, - lastMatchId: '/', matches: [], }, h: vi.fn(), @@ -151,7 +149,6 @@ describe('hydrate', () => { router: { manifest: testManifest, dehydratedData: {}, - lastMatchId: '/', matches: [], }, h: vi.fn(), @@ -176,14 +173,12 @@ describe('hydrate', () => { routeId: '/', index: 0, ssr: undefined, - _nonReactive: {}, }, { id: '/other', routeId: '/other', index: 1, ssr: undefined, - _nonReactive: {}, }, ] @@ -204,7 +199,6 @@ describe('hydrate', () => { router: { manifest: testManifest, dehydratedData: {}, - lastMatchId: '/', matches: dehydratedMatches, }, h: vi.fn(), @@ -217,7 +211,8 @@ describe('hydrate', () => { await hydrate(mockRouter) - const { id, loaderData, ssr, status } = mockMatches[0] as AnyRouteMatch + const { id, loaderData, ssr, status } = mockRouter.state + .matches[0] as AnyRouteMatch expect(id).toBe('/') expect(loaderData).toEqual({ indexData: 'server-data' }) expect(status).toBe('success') @@ -231,7 +226,6 @@ describe('hydrate', () => { routeId: '/', index: 0, ssr: undefined, - _nonReactive: {}, }, ] @@ -252,7 +246,6 @@ describe('hydrate', () => { router: { manifest: testManifest, dehydratedData: {}, - lastMatchId: '/', matches: dehydratedMatches, }, h: vi.fn(), @@ -265,7 +258,9 @@ describe('hydrate', () => { await hydrate(mockRouter) - expect((mockMatches[0] as AnyRouteMatch).globalNotFound).toBe(true) + expect((mockRouter.state.matches[0] as AnyRouteMatch).globalNotFound).toBe( + true, + ) }) it('should leave globalNotFound undefined when dehydrated flag is omitted', async () => { @@ -275,7 +270,6 @@ describe('hydrate', () => { routeId: '/', index: 0, ssr: undefined, - _nonReactive: {}, }, ] @@ -295,7 +289,6 @@ describe('hydrate', () => { router: { manifest: testManifest, dehydratedData: {}, - lastMatchId: '/', matches: dehydratedMatches, }, h: vi.fn(), @@ -308,7 +301,9 @@ describe('hydrate', () => { await hydrate(mockRouter) - expect((mockMatches[0] as AnyRouteMatch).globalNotFound).toBeUndefined() + expect( + (mockRouter.state.matches[0] as AnyRouteMatch).globalNotFound, + ).toBeUndefined() }) it('should preserve existing globalNotFound when dehydrated flag is omitted', async () => { @@ -318,7 +313,6 @@ describe('hydrate', () => { routeId: '/', index: 0, ssr: undefined, - _nonReactive: {}, globalNotFound: true, }, ] @@ -339,7 +333,6 @@ describe('hydrate', () => { router: { manifest: testManifest, dehydratedData: {}, - lastMatchId: '/', matches: dehydratedMatches, }, h: vi.fn(), @@ -352,7 +345,9 @@ describe('hydrate', () => { await hydrate(mockRouter) - expect((mockMatches[0] as AnyRouteMatch).globalNotFound).toBe(true) + expect((mockRouter.state.matches[0] as AnyRouteMatch).globalNotFound).toBe( + true, + ) }) it('should decode dehydrated match ids before hydration lookup and SPA-mode checks', async () => { @@ -364,7 +359,6 @@ describe('hydrate', () => { routeId: '/', index: 0, ssr: undefined, - _nonReactive: {}, }, ] @@ -375,7 +369,6 @@ describe('hydrate', () => { router: { manifest: testManifest, dehydratedData: {}, - lastMatchId: dehydrateSsrMatchId('/'), matches: [ { i: dehydrateSsrMatchId('/'), @@ -434,7 +427,6 @@ describe('hydrate', () => { router: { manifest: testManifest, dehydratedData: { rewrite: true }, - lastMatchId: '/internal/internal', matches: [ { i: '__root__/', @@ -473,9 +465,7 @@ describe('hydrate', () => { 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 @@ -484,7 +474,6 @@ describe('hydrate', () => { router: { manifest: testManifest, dehydratedData: {}, - lastMatchId: '/', matches: [ { i: '/', diff --git a/packages/router-core/tests/hydrated-stay-match-data.test.ts b/packages/router-core/tests/hydrated-stay-match-data.test.ts new file mode 100644 index 0000000000..fdc4ff5f7b --- /dev/null +++ b/packages/router-core/tests/hydrated-stay-match-data.test.ts @@ -0,0 +1,103 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { hydrate } from '../src/ssr/client' +import { createTestRouter } from './routerTestUtils' +import type { TsrSsrGlobal } from '../src/ssr/types' +import type { Manifest } from '../src/manifest' + +const testManifest: Manifest = { routes: {} } + +describe('hydrated stay match data preservation', () => { + let mockWindow: { $_TSR?: TsrSsrGlobal } + + beforeEach(() => { + mockWindow = {} + ;(global as any).window = mockWindow + }) + + afterEach(() => { + delete (global as any).window + vi.restoreAllMocks() + }) + + it('keeps server loader data on a stay match during ordinary client loading', async () => { + const rootBeforeLoad = vi.fn(() => ({ auth: 'client' })) + const rootLoader = vi.fn(() => ({ root: 'client' })) + const history = createMemoryHistory({ initialEntries: ['/a'] }) + + const rootRoute = new BaseRootRoute({ + beforeLoad: rootBeforeLoad, + loader: rootLoader, + }) + const aRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/a', + ssr: false, + loader: () => 'a client data', + }) + const bRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/b', + loader: () => 'b client data', + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([aRoute, bRoute]), + history, + isServer: false, + }) + + const matches = router.matchRoutes(router.stores.location.get()) + const rootMatch = matches[0]! + + mockWindow.$_TSR = { + router: { + manifest: testManifest, + dehydratedData: {}, + matches: [ + { + i: rootMatch.id, + s: 'success' as const, + ssr: true, + b: { auth: 'server' }, + l: { root: 'server' }, + u: Date.now(), + }, + ], + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + initialized: false, + } + + await hydrate(router) + await router.load() + + expect( + router.state.matches.find((m) => m.routeId === aRoute.id)?.loaderData, + ).toBe('a client data') + expect(rootBeforeLoad).not.toHaveBeenCalled() + expect(rootLoader).not.toHaveBeenCalled() + expect( + router.state.matches.find((m) => m.routeId === rootRoute.id), + ).toMatchObject({ + context: { auth: 'server' }, + loaderData: { root: 'server' }, + }) + + // Client-side navigation: root is a stay match and must keep server data. + await router.navigate({ to: '/b' }) + + expect( + router.state.matches.find((m) => m.routeId === bRoute.id)?.loaderData, + ).toBe('b client data') + expect(rootLoader).not.toHaveBeenCalled() + expect( + router.state.matches.find((m) => m.routeId === rootRoute.id)?.loaderData, + ).toEqual({ root: 'server' }) + }) +}) diff --git a/packages/router-core/tests/hydration-asset-context-order.test.ts b/packages/router-core/tests/hydration-asset-context-order.test.ts new file mode 100644 index 0000000000..49113fe819 --- /dev/null +++ b/packages/router-core/tests/hydration-asset-context-order.test.ts @@ -0,0 +1,62 @@ +import { afterEach, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { hydrate } from '../src/ssr/client' +import { createTestRouter } from './routerTestUtils' +import type { TsrSsrGlobal } from '../src/ssr/types' + +afterEach(() => { + delete (globalThis as any).window +}) + +test('hydration reconstructs every match context before ancestor head reads the lane', async () => { + let childContextSeenByRootHead: unknown + const rootRoute = new BaseRootRoute({ + head: ({ matches }) => { + childContextSeenByRootHead = matches[1]?.context + return { meta: [{ title: 'hydrated' }] } + }, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/child', + beforeLoad: () => ({ user: 'client fallback' }), + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([childRoute]), + history: createMemoryHistory({ initialEntries: ['/child'] }), + isServer: false, + }) + const matches = router.matchRoutes(router.stores.location.get()) + + ;(globalThis as any).window = { + $_TSR: { + router: { + manifest: { routes: {} }, + dehydratedData: {}, + matches: matches.map((match) => ({ + i: match.id, + s: 'success' as const, + ssr: true, + u: Date.now(), + b: + match.routeId === childRoute.id + ? { user: 'server authenticated user' } + : undefined, + })), + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + initialized: false, + } satisfies TsrSsrGlobal, + } + + await hydrate(router) + + expect(childContextSeenByRootHead).toMatchObject({ + user: 'server authenticated user', + }) +}) diff --git a/packages/router-core/tests/hydration-boundary-chunks.test.ts b/packages/router-core/tests/hydration-boundary-chunks.test.ts new file mode 100644 index 0000000000..8983df61d6 --- /dev/null +++ b/packages/router-core/tests/hydration-boundary-chunks.test.ts @@ -0,0 +1,168 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute, notFound } from '../src' +import { hydrate } from '../src/ssr/client' +import { createTestRouter } from './routerTestUtils' +import type { TsrSsrGlobal } from '../src/ssr/types' +import type { Manifest } from '../src/manifest' + +const testManifest: Manifest = { routes: {} } + +describe('hydration route chunks below a server boundary', () => { + let mockWindow: { $_TSR?: TsrSsrGlobal } + + beforeEach(() => { + mockWindow = {} + ;(global as any).window = mockWindow + }) + + afterEach(() => { + vi.restoreAllMocks() + delete (global as any).window + }) + + test('hydrates an error boundary without requiring its normal component chunk', async () => { + const serverError = new Error('server beforeLoad failed') + const normalChunkError = new Error('normal component chunk unavailable') + const normalComponentPreload = vi.fn(() => Promise.reject(normalChunkError)) + const errorComponentPreload = vi.fn(() => Promise.resolve()) + const NormalComponent = Object.assign(() => null, { + preload: normalComponentPreload, + }) + const ErrorComponent = Object.assign(() => null, { + preload: errorComponentPreload, + }) + + const rootRoute = new BaseRootRoute({}) + const appRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/app', + beforeLoad: () => { + // This is the server-side failure represented by the dehydrated + // boundary below. Hydration must preserve that committed outcome. + throw serverError + }, + component: NormalComponent, + errorComponent: ErrorComponent, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([appRoute]), + history: createMemoryHistory({ initialEntries: ['/app'] }), + isServer: false, + }) + const matches = router.matchRoutes(router.stores.location.get()) + + mockWindow.$_TSR = { + router: { + manifest: testManifest, + dehydratedData: {}, + matches: [ + { + i: matches[0]!.id, + s: 'success', + ssr: true, + u: Date.now(), + }, + { + i: matches[1]!.id, + s: 'error', + e: serverError, + ssr: true, + u: Date.now(), + }, + ], + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + initialized: false, + } + + await expect(hydrate(router)).resolves.toBeUndefined() + + const appMatch = router.state.matches.find( + (match) => match.routeId === appRoute.id, + ) + expect(appMatch?.status).toBe('error') + expect(appMatch?.error).toBe(serverError) + expect(normalComponentPreload).not.toHaveBeenCalled() + expect(errorComponentPreload).toHaveBeenCalledTimes(1) + }) + + test('hydrates an ancestor notFound boundary without loading an omitted descendant chunk', async () => { + const childChunkError = new Error('omitted child chunk unavailable') + const childComponentPreload = vi.fn(() => Promise.reject(childChunkError)) + const ChildComponent = Object.assign(() => null, { + preload: childComponentPreload, + }) + + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + notFoundComponent: () => 'not found', + }) + const serverNotFound = notFound({ routeId: parentRoute.id }) + const childLoader = vi.fn(() => { + // This is the server loader outcome that selected and dehydrated the + // parent boundary. The hydration replay must keep this loader skipped. + throw serverNotFound + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: childLoader, + component: ChildComponent, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + isServer: false, + }) + const matches = router.matchRoutes(router.stores.location.get()) + + mockWindow.$_TSR = { + router: { + manifest: testManifest, + dehydratedData: {}, + // The server lane stopped at the parent boundary and never rendered + // or dehydrated the child. + matches: [ + { + i: matches[0]!.id, + s: 'success', + ssr: true, + u: Date.now(), + }, + { + i: matches[1]!.id, + s: 'notFound', + e: serverNotFound, + ssr: true, + u: Date.now(), + }, + ], + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + initialized: false, + } + + await expect(hydrate(router)).resolves.toBeUndefined() + + await vi.waitFor(() => { + expect(router.state.matches.map((match) => match.routeId)).toEqual([ + rootRoute.id, + parentRoute.id, + ]) + }) + expect(router.state.matches[1]?.status).toBe('notFound') + expect(childComponentPreload).not.toHaveBeenCalled() + expect(childLoader).not.toHaveBeenCalled() + }) +}) diff --git a/packages/router-core/tests/hydration-currentness.test.ts b/packages/router-core/tests/hydration-currentness.test.ts new file mode 100644 index 0000000000..58db76d1c3 --- /dev/null +++ b/packages/router-core/tests/hydration-currentness.test.ts @@ -0,0 +1,498 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute, createControlledPromise } from '../src' +import { hydrate } from '../src/ssr/client' +import { createTestRouter } from './routerTestUtils' +import type { TsrSsrGlobal } from '../src/ssr/types' +import type { Manifest } from '../src/manifest' + +const testManifest: Manifest = { routes: {} } + +/** + * Hydration asset work is another private lane generation. If a public + * navigation commits while an old hydration head is pending, the hydration + * continuation must not execute more old-lane hooks or mark its captured + * location as resolved. + */ +describe('hydration asset currentness', () => { + let mockWindow: { $_TSR?: TsrSsrGlobal } + + beforeEach(() => { + mockWindow = {} + ;(global as any).window = mockWindow + }) + + afterEach(() => { + vi.restoreAllMocks() + delete (global as any).window + }) + + test('does not resume an old hydration lane after a newer navigation commits', async () => { + const oldHeadGate = createControlledPromise<{ + meta: Array<{ title: string }> + }>() + const oldHead = vi.fn(() => oldHeadGate) + const oldScripts = vi.fn(() => [ + { children: 'window.oldHydrationLaneRan = true' }, + ]) + const newHead = vi.fn(() => ({ + meta: [{ title: 'New route' }], + })) + + const rootRoute = new BaseRootRoute({}) + const oldRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/old', + head: oldHead, + scripts: oldScripts, + }) + const newRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/new', + head: newHead, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([oldRoute, newRoute]), + history: createMemoryHistory({ initialEntries: ['/old'] }), + isServer: false, + }) + const oldMatches = router.matchRoutes(router.stores.location.get()) + + mockWindow.$_TSR = { + router: { + manifest: testManifest, + dehydratedData: {}, + matches: oldMatches.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(router) + await vi.waitFor(() => expect(oldHead).toHaveBeenCalledTimes(1)) + + // No framework history subscriber exists during the initial hydration + // handoff, so navigate() exercises its public direct router.load() path. + await router.navigate({ to: '/new' }) + + expect(router.state.location.pathname).toBe('/new') + expect(router.state.matches.map((match) => match.routeId)).toEqual([ + rootRoute.id, + newRoute.id, + ]) + const resolvedAfterNavigation = router.state.resolvedLocation?.pathname + expect(resolvedAfterNavigation).not.toBe('/old') + + oldHeadGate.resolve({ meta: [{ title: 'Old route' }] }) + await hydration + + expect({ + oldScriptsCalls: oldScripts.mock.calls.length, + newHeadCalls: newHead.mock.calls.length, + location: router.state.location.pathname, + routeIds: router.state.matches.map((match) => match.routeId), + hydrationMovedResolvedLocationBackToOld: + resolvedAfterNavigation !== '/old' && + router.state.resolvedLocation?.pathname === '/old', + }).toEqual({ + oldScriptsCalls: 0, + newHeadCalls: 1, + location: '/new', + routeIds: [rootRoute.id, newRoute.id], + hydrationMovedResolvedLocationBackToOld: false, + }) + }) + + test('a synchronous context navigation prevents stale hydration assets from running', async () => { + const oldHead = vi.fn(() => ({ meta: [{ title: 'Old route' }] })) + const oldScripts = vi.fn(() => [ + { children: 'window.oldHydrationLaneRan = true' }, + ]) + const newHead = vi.fn(() => ({ meta: [{ title: 'New route' }] })) + + let router!: ReturnType + let navigation: Promise | undefined + let navigateDuringHydration = false + const rootRoute = new BaseRootRoute({}) + const oldRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/old', + context: () => { + if (navigateDuringHydration) { + navigateDuringHydration = false + navigation = router.navigate({ to: '/new' }) + } + return { source: 'old' } + }, + head: oldHead, + scripts: oldScripts, + }) + const newRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/new', + head: newHead, + }) + router = createTestRouter({ + routeTree: rootRoute.addChildren([oldRoute, newRoute]), + history: createMemoryHistory({ initialEntries: ['/old'] }), + isServer: false, + }) + const oldMatches = router.matchRoutes(router.stores.location.get()) + + mockWindow.$_TSR = { + router: { + manifest: testManifest, + dehydratedData: {}, + matches: oldMatches.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, + } + + navigateDuringHydration = true + await hydrate(router) + await navigation + + expect(router.state.location.pathname).toBe('/new') + expect(oldHead).not.toHaveBeenCalled() + expect(oldScripts).not.toHaveBeenCalled() + expect(newHead).toHaveBeenCalledTimes(1) + }) + + test('a navigation during hydration handoff does not adopt a different route prefix', async () => { + const oldBeforeLoad = vi.fn(() => ({ source: 'old-client' })) + const oldLoader = vi.fn(() => 'old-client') + const newBeforeLoad = vi.fn(() => ({ source: 'new-client' })) + const newLoader = vi.fn(() => 'new-client') + const rootRoute = new BaseRootRoute({}) + const oldRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/old', + beforeLoad: oldBeforeLoad, + loader: oldLoader, + }) + const newRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/new', + beforeLoad: newBeforeLoad, + loader: newLoader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([oldRoute, newRoute]), + history: createMemoryHistory({ initialEntries: ['/old'] }), + isServer: false, + }) + const oldMatches = router.matchRoutes(router.stores.location.get()) + + mockWindow.$_TSR = { + router: { + manifest: testManifest, + dehydratedData: {}, + matches: [ + { + i: oldMatches[0]!.id, + s: 'success', + ssr: true, + u: Date.now(), + }, + { + i: oldMatches[1]!.id, + s: 'success', + ssr: 'data-only', + b: { source: 'old-server' }, + l: 'old-server', + u: Date.now(), + }, + ], + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + initialized: false, + } + + await hydrate(router) + await router.navigate({ to: '/new' }) + + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: newRoute.id, + status: 'success', + context: { source: 'new-client' }, + loaderData: 'new-client', + }) + expect(newBeforeLoad).toHaveBeenCalledTimes(1) + expect(newLoader).toHaveBeenCalledTimes(1) + expect(oldBeforeLoad).not.toHaveBeenCalled() + expect(oldLoader).not.toHaveBeenCalled() + }) + + test('same-location hydration only adopts the matching server match identities', async () => { + let version = 1 + const beforeLoad = vi.fn(() => ({ source: 'client' })) + const loader = vi.fn(() => 'client') + const rootRoute = new BaseRootRoute({}) + const pageRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/page', + loaderDeps: () => ({ version }), + beforeLoad, + loader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + isServer: false, + }) + const serverMatches = router.matchRoutes(router.stores.location.get()) + + mockWindow.$_TSR = { + router: { + manifest: testManifest, + dehydratedData: {}, + matches: [ + { + i: serverMatches[0]!.id, + s: 'success', + ssr: true, + u: Date.now(), + }, + { + i: serverMatches[1]!.id, + s: 'success', + ssr: 'data-only', + b: { source: 'server' }, + l: 'server', + u: Date.now(), + }, + ], + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + initialized: false, + } + + await hydrate(router) + version = 2 + await router.load() + + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: pageRoute.id, + status: 'success', + context: { source: 'client' }, + loaderData: 'client', + }) + expect(beforeLoad).toHaveBeenCalledTimes(1) + expect(loader).toHaveBeenCalledTimes(1) + }) + + test('invalidation during hydration handoff reloads the server prefix', async () => { + const rootLoader = vi.fn(() => 'client') + const rootRoute = new BaseRootRoute({ loader: rootLoader }) + const pageRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/page', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + isServer: false, + }) + const serverMatches = router.matchRoutes(router.stores.location.get()) + + mockWindow.$_TSR = { + router: { + manifest: testManifest, + dehydratedData: {}, + matches: [ + { + i: serverMatches[0]!.id, + s: 'success', + ssr: true, + l: 'server', + u: Date.now(), + }, + { + i: serverMatches[1]!.id, + s: 'success', + ssr: 'data-only', + u: Date.now(), + }, + ], + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + initialized: false, + } + + await hydrate(router) + await router.invalidate({ + filter: (match) => match.routeId === rootRoute.id, + }) + + expect(rootLoader).toHaveBeenCalledTimes(1) + expect(router.state.matches[0]).toMatchObject({ + routeId: rootRoute.id, + status: 'success', + loaderData: 'client', + }) + }) + + test('an adopted data-only error does not run its server-omitted descendants', async () => { + const childBeforeLoad = vi.fn() + const childLoader = vi.fn() + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + beforeLoad: childBeforeLoad, + loader: childLoader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + isServer: false, + }) + const serverMatches = router.matchRoutes(router.stores.location.get()) + const serverError = new Error('server failed') + + mockWindow.$_TSR = { + router: { + manifest: testManifest, + dehydratedData: {}, + matches: [ + { + i: serverMatches[0]!.id, + s: 'success', + ssr: true, + u: Date.now(), + }, + { + i: serverMatches[1]!.id, + s: 'error', + ssr: 'data-only', + e: serverError, + u: Date.now(), + }, + ], + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + initialized: false, + } + + await hydrate(router) + await router.load() + + expect(router.state.matches.map((match) => match.routeId)).toEqual([ + rootRoute.id, + parentRoute.id, + ]) + expect(router.state.matches.at(-1)).toMatchObject({ + status: 'error', + error: serverError, + }) + expect(childBeforeLoad).not.toHaveBeenCalled() + expect(childLoader).not.toHaveBeenCalled() + }) + + test('a same-href load owns route hooks when an older hydration chunk settles later', async () => { + const routeChunkGate = createControlledPromise() + const Page = Object.assign(() => null, { + preload: vi.fn(() => routeChunkGate), + }) + const routeContext = vi.fn(() => ({ source: 'same-href' })) + const head = vi.fn(() => ({ meta: [{ title: 'Same href' }] })) + const scripts = vi.fn(() => [ + { children: 'window.sameHrefHydrationRan = true' }, + ]) + + const rootRoute = new BaseRootRoute({}) + const pageRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/page', + component: Page, + context: routeContext, + head, + scripts, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + isServer: false, + }) + const dehydratedMatches = router.matchRoutes(router.stores.location.get()) + + mockWindow.$_TSR = { + router: { + manifest: testManifest, + dehydratedData: {}, + matches: dehydratedMatches.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(router) + await vi.waitFor(() => expect(Page.preload).toHaveBeenCalledTimes(1)) + + routeContext.mockClear() + head.mockClear() + scripts.mockClear() + const replacement = router.load() + routeChunkGate.resolve() + + await Promise.all([hydration, replacement]) + + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: pageRoute.id, + status: 'success', + meta: [{ title: 'Same href' }], + scripts: [{ children: 'window.sameHrefHydrationRan = true' }], + }) + expect(routeContext).toHaveBeenCalledTimes(1) + expect(head).toHaveBeenCalledTimes(1) + expect(scripts).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/router-core/tests/invalidate-pre-rematch-failure.test.ts b/packages/router-core/tests/invalidate-pre-rematch-failure.test.ts new file mode 100644 index 0000000000..50f57d5973 --- /dev/null +++ b/packages/router-core/tests/invalidate-pre-rematch-failure.test.ts @@ -0,0 +1,52 @@ +import { expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter } from './routerTestUtils' + +/** + * A failed rematch must preserve resources owned by the accepted route and + * leave the router usable for a later retry. + */ +test('a fatal pre-rematch failure preserves the active generation and permits retry', async () => { + const boom = new Error('loaderDeps failed during invalidation rematch') + let failLoaderDeps = false + let activeSignal: AbortSignal | undefined + + const rootRoute = new BaseRootRoute({}) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + loaderDeps: () => { + if (failLoaderDeps) { + throw boom + } + return {} + }, + loader: ({ abortController }) => { + activeSignal = abortController.signal + return 'target data' + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([targetRoute]), + history: createMemoryHistory({ initialEntries: ['/target'] }), + }) + + await router.load() + expect(router.state.matches.at(-1)?.status).toBe('success') + + failLoaderDeps = true + await router.invalidate({ forcePending: true }) + + // The failed replacement never acquired a lane, so it must not cancel + // resources owned by the still-active successful loader generation. + expect(activeSignal?.aborted).toBe(false) + + failLoaderDeps = false + await router.invalidate() + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: targetRoute.id, + status: 'success', + loaderData: 'target data', + }) +}) diff --git a/packages/router-core/tests/issue-3179-preload-cached-cause.test.ts b/packages/router-core/tests/issue-3179-preload-cached-cause.test.ts new file mode 100644 index 0000000000..dcaa50c1cb --- /dev/null +++ b/packages/router-core/tests/issue-3179-preload-cached-cause.test.ts @@ -0,0 +1,57 @@ +import { expect, test } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter } from './routerTestUtils' + +// https://github.com/TanStack/router/issues/3179 +test('#3179: preload flags are not inherited from the active match generation', async () => { + const calls: Array<{ + phase: 'beforeLoad' | 'loader' + cause: string + preload: boolean + }> = [] + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + staleTime: 0, + preloadStaleTime: 0, + beforeLoad: ({ cause, preload }) => { + calls.push({ phase: 'beforeLoad', cause, preload }) + }, + loader: ({ cause, preload }) => { + calls.push({ phase: 'loader', cause, preload }) + }, + }) + const aboutRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/about', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, aboutRoute]), + history: createMemoryHistory({ initialEntries: ['/about'] }), + }) + + await router.load() + + // First hover Home from About, then click it. This is the sequence from + // the issue that used to cache the navigation's flags on the match. + await router.preloadRoute({ to: '/' }) + expect(calls).toEqual([ + { phase: 'beforeLoad', cause: 'preload', preload: true }, + { phase: 'loader', cause: 'preload', preload: true }, + ]) + + calls.length = 0 + await router.navigate({ to: '/' }) + expect(calls).toEqual([ + { phase: 'beforeLoad', cause: 'enter', preload: false }, + { phase: 'loader', cause: 'enter', preload: false }, + ]) + + // Hover Home again while it is active. Reusing the active match must not + // rerun callbacks with the cached { cause: 'enter', preload: false } pair. + calls.length = 0 + await router.preloadRoute({ to: '/' }) + expect(calls).toEqual([]) +}) diff --git a/packages/router-core/tests/issue-3293-on-enter-after-loader.test.ts b/packages/router-core/tests/issue-3293-on-enter-after-loader.test.ts new file mode 100644 index 0000000000..c1b6b77f91 --- /dev/null +++ b/packages/router-core/tests/issue-3293-on-enter-after-loader.test.ts @@ -0,0 +1,52 @@ +import { expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute, createControlledPromise } from '../src' +import { createTestRouter } from './routerTestUtils' + +// https://github.com/TanStack/router/issues/3293 +test('#3293: onEnter runs only after a cold match has completed beforeLoad and loader', async () => { + const beforeLoadGate = createControlledPromise<{ ready: true }>() + const loaderGate = createControlledPromise<{ someData: 42 }>() + const beforeLoad = vi.fn(() => beforeLoadGate) + const loader = vi.fn(() => loaderGate) + const onEnter = vi.fn() + + const rootRoute = new BaseRootRoute({}) + const aboutRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/about', + beforeLoad, + loader, + onEnter, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([aboutRoute]), + history: createMemoryHistory({ initialEntries: ['/about'] }), + }) + + const load = router.load() + await vi.waitFor(() => expect(beforeLoad).toHaveBeenCalledTimes(1)) + expect(loader).not.toHaveBeenCalled() + expect(onEnter).not.toHaveBeenCalled() + + beforeLoadGate.resolve({ ready: true }) + await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(1)) + expect(loader).toHaveBeenCalledWith( + expect.objectContaining({ + context: expect.objectContaining({ ready: true }), + }), + ) + expect(onEnter).not.toHaveBeenCalled() + + loaderGate.resolve({ someData: 42 }) + await load + + expect(onEnter).toHaveBeenCalledTimes(1) + expect(onEnter).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'success', + context: expect.objectContaining({ ready: true }), + loaderData: { someData: 42 }, + }), + ) +}) diff --git a/packages/router-core/tests/issue-3928-rapid-reload-abort.test.ts b/packages/router-core/tests/issue-3928-rapid-reload-abort.test.ts new file mode 100644 index 0000000000..a5df035609 --- /dev/null +++ b/packages/router-core/tests/issue-3928-rapid-reload-abort.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter } from './routerTestUtils' + +/** + * Repro for https://github.com/TanStack/router/issues/3928 + * + * Rapid same-route reloads (loaderDeps changing on every keystroke) abort the + * superseded in-flight loader generation. Loaders that forward + * abortController.signal to fetch() re-throw an AbortError when that happens. + * That AbortError belongs to an abandoned load pass: it must never be + * committed as a route error (historically it surfaced through the root + * error boundary as "signal is aborted without reason"), and the parent + * stay-match must keep its own un-aborted signal and settled data. + */ + +const waitForMacrotask = () => + new Promise((resolve) => { + setTimeout(resolve, 0) + }) + +describe('issue #3928: rapid reloads abort superseded loaders silently', () => { + test('superseded loader AbortErrors never surface as route errors', async () => { + let rootLoaderCalls = 0 + let rootSignal: AbortSignal | undefined + + const indexInvocations = new Map< + string, + { resolve: () => void; signal: AbortSignal } + >() + + const rootRoute = new BaseRootRoute({ + loader: ({ abortController }: { abortController: AbortController }) => { + rootLoaderCalls++ + rootSignal = abortController.signal + return 'root data' + }, + }) + + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + validateSearch: (search: Record) => ({ + filter: typeof search.filter === 'string' ? search.filter : '', + }), + loaderDeps: ({ search }: { search: { filter: string } }) => ({ + filter: search.filter, + }), + loader: ({ + deps, + abortController, + }: { + deps: { filter: string } + abortController: AbortController + }) => + new Promise((resolve, reject) => { + indexInvocations.set(deps.filter, { + resolve: () => resolve(`data:${deps.filter}`), + signal: abortController.signal, + }) + abortController.signal.addEventListener('abort', () => { + const abortError = new Error('signal is aborted without reason') + abortError.name = 'AbortError' + reject(abortError) + }) + }), + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + const initialLoad = router.load() + await vi.waitFor(() => expect(indexInvocations.has('')).toBe(true)) + indexInvocations.get('')!.resolve() + await initialLoad + + expect(rootLoaderCalls).toBe(1) + expect(rootSignal?.aborted).toBe(false) + + // Rapid "keystroke" navigations: each one changes loaderDeps, so each + // starts a new index loader generation and supersedes the previous one. + const nav1 = router.navigate({ to: '/', search: { filter: 'a' } }) + await vi.waitFor(() => expect(indexInvocations.has('a')).toBe(true)) + + const nav2 = router.navigate({ to: '/', search: { filter: 'ab' } }) + await vi.waitFor(() => expect(indexInvocations.has('ab')).toBe(true)) + // The superseded generation's signal is aborted (its fetch is canceled). + await vi.waitFor(() => + expect(indexInvocations.get('a')!.signal.aborted).toBe(true), + ) + + const nav3 = router.navigate({ to: '/', search: { filter: 'abc' } }) + await vi.waitFor(() => expect(indexInvocations.has('abc')).toBe(true)) + await vi.waitFor(() => + expect(indexInvocations.get('ab')!.signal.aborted).toBe(true), + ) + + // Give the aborted generations' rejections time to (incorrectly) commit. + await waitForMacrotask() + await waitForMacrotask() + + for (const match of router.state.matches) { + expect(match.status).not.toBe('error') + expect((match.error as Error | undefined)?.name).not.toBe('AbortError') + } + + indexInvocations.get('abc')!.resolve() + await Promise.all([nav1, nav2, nav3]) + + expect(router.state.location.search).toEqual({ filter: 'abc' }) + + const indexMatch = router.state.matches.find( + (match) => match.routeId === indexRoute.id, + )! + expect(indexMatch.status).toBe('success') + expect(indexMatch.error).toBeUndefined() + expect(indexMatch.loaderData).toBe('data:abc') + + const rootMatch = router.state.matches[0]! + expect(rootMatch.status).toBe('success') + expect(rootMatch.error).toBeUndefined() + expect(rootMatch.loaderData).toBe('root data') + + // The parent stay-match was never re-fetched or aborted by the churn. + expect(rootLoaderCalls).toBe(1) + expect(rootSignal?.aborted).toBe(false) + }) +}) diff --git a/packages/router-core/tests/issue-4078-loader-notfound-root-boundary.test.ts b/packages/router-core/tests/issue-4078-loader-notfound-root-boundary.test.ts new file mode 100644 index 0000000000..3efab8bcb9 --- /dev/null +++ b/packages/router-core/tests/issue-4078-loader-notfound-root-boundary.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute, notFound, rootRouteId } from '../src' +import { createTestRouter } from './routerTestUtils' + +// https://github.com/TanStack/router/issues/4078 +// https://github.com/TanStack/router/issues/2255 +// Throwing an untargeted notFound() in a child loader used to always render the +// defaultNotFoundComponent, even when __root__ defined a notFoundComponent +// (#4078). #2255 is the same asymmetry: a path-mismatch notFound renders the +// root component + notFoundComponent, but a loader-thrown notFound did not. +// +// The lane-model getNotFoundBoundaryIndex walks up from the throwing route to +// the nearest route with a notFoundComponent; for an untargeted notFound with +// no boundary below root that is __root__ itself. When the boundary is the root +// route the root match commits `status: 'success'` with `globalNotFound: true`, +// so the root's own notFoundComponent renders (not the default). +describe('issue #4078 / #2255 - loader notFound resolves to the root boundary', () => { + const setup = (initialEntries: Array) => { + const rootRoute = new BaseRootRoute({ + component: () => 'Root', + notFoundComponent: () => 'Root not found', + }) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const aboutRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/about', + loader: () => { + throw notFound() + }, + component: () => 'About', + }) + + return createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, aboutRoute]), + history: createMemoryHistory({ initialEntries }), + }) + } + + it('navigating to a route whose loader throws notFound() selects the root boundary', async () => { + const router = setup(['/']) + await router.load() + + await router.navigate({ to: '/about' }) + await router.load() + + const rootMatch = router.state.matches.find( + (match) => match.routeId === rootRouteId, + ) + // Root boundary selected: root renders its own notFoundComponent, not the + // default one. + expect(rootMatch?.status).toBe('success') + expect(rootMatch?.globalNotFound).toBe(true) + }) + + it('initial load of that route selects the root boundary too (#2255 asymmetry)', async () => { + const router = setup(['/about']) + await router.load() + + const rootMatch = router.state.matches.find( + (match) => match.routeId === rootRouteId, + ) + expect(rootMatch?.status).toBe('success') + expect(rootMatch?.globalNotFound).toBe(true) + }) +}) diff --git a/packages/router-core/tests/issue-4444-param-parse-error-lazy-child.test.ts b/packages/router-core/tests/issue-4444-param-parse-error-lazy-child.test.ts new file mode 100644 index 0000000000..c4b7db5c1f --- /dev/null +++ b/packages/router-core/tests/issue-4444-param-parse-error-lazy-child.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute, PathParamError } from '../src' +import { createTestRouter } from './routerTestUtils' + +/** + * Issue #4444: when a route's params.parse throws and one of its children is + * lazily loaded, the router used to stay "pending" forever: the lazy child's + * chunk was never requested, its match never settled, and framework + * transitioners could never reach idle. + * + * Desired behavior: the param parse error is a serial failure — the failing + * route commits status 'error' for its error boundary, descendants below the + * boundary are trimmed out of the lane with their load promises settled, and + * router.load() resolves with no match left pending. + */ +describe('issue #4444: param parse error on a route with a lazy child', () => { + test('load settles, commits the error boundary, and leaves no match stuck pending', async () => { + const lazyFn = vi.fn(() => + Promise.resolve({ options: { component: () => null } } as any), + ) + + const rootRoute = new BaseRootRoute({}) + const langRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/lang/$lang', + params: { + parse: ({ lang }: { lang: string }) => { + if (lang !== 'en') { + throw new Error(`Unsupported language: ${lang}`) + } + return { lang } + }, + stringify: ({ lang }: { lang: string }) => ({ lang }), + }, + errorComponent: () => null, + }) + const lazyChildRoute = new BaseRoute({ + getParentRoute: () => langRoute, + path: '/lazy', + }).lazy(lazyFn) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + langRoute.addChildren([lazyChildRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/lang/es/lazy'] }), + }) + + // The reported bug: this load never reached a settled render state. + await router.load() + + // The failing route commits the parse error for its error boundary… + const langMatch = router.state.matches.find( + (match) => match.routeId === langRoute.id, + ) + expect(langMatch?.status).toBe('error') + expect(langMatch?.error).toBeInstanceOf(PathParamError) + + // …and the lazy child below the boundary is trimmed out of the lane, so + // nothing is left pending on a chunk that will never load. + expect( + router.state.matches.find((match) => match.routeId === lazyChildRoute.id), + ).toBeUndefined() + expect( + router.state.matches.find((match) => match.status === 'pending'), + ).toBeUndefined() + expect(router.state.isLoading).toBe(false) + }) +}) diff --git a/packages/router-core/tests/issue-4572-preload-root-beforeload-flags.test.ts b/packages/router-core/tests/issue-4572-preload-root-beforeload-flags.test.ts new file mode 100644 index 0000000000..7498396172 --- /dev/null +++ b/packages/router-core/tests/issue-4572-preload-root-beforeload-flags.test.ts @@ -0,0 +1,108 @@ +import { expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter } from './routerTestUtils' + +/** + * Issue #4572: with defaultPreload 'intent', hovering a Link used to + * re-trigger the root's beforeLoad with cause 'enter' and preload false + * (and nested hovers reported 'enter'/false on already-active ancestors). + * + * Desired behavior: + * - Hover-preloading a sibling route does not re-run beforeLoad of + * already-active ancestors: the preload borrows them read-only. + * - The preloaded route's own beforeLoad sees cause 'preload' and + * preload true. + * - Hover-preloading the currently active route runs no beforeLoad at all. + */ + +test('hover preload does not re-run active ancestors and passes correct flags to new matches', async () => { + const rootBeforeLoad = vi.fn() + const indexBeforeLoad = vi.fn() + const aboutBeforeLoad = vi.fn() + const bagBeforeLoad = vi.fn() + const nestedBeforeLoad = vi.fn() + + const rootRoute = new BaseRootRoute({ beforeLoad: rootBeforeLoad }) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + beforeLoad: indexBeforeLoad, + }) + const aboutRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/about', + beforeLoad: aboutBeforeLoad, + }) + const bagRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '_bag', + beforeLoad: bagBeforeLoad, + }) + const nestedRoute = new BaseRoute({ + getParentRoute: () => bagRoute, + path: '/nested', + beforeLoad: nestedBeforeLoad, + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + aboutRoute, + bagRoute.addChildren([nestedRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + expect(rootBeforeLoad).toHaveBeenCalledTimes(1) + expect(indexBeforeLoad).toHaveBeenCalledTimes(1) + expect(rootBeforeLoad).toHaveBeenCalledWith( + expect.objectContaining({ cause: 'enter', preload: false }), + ) + + // Hover a sibling route: the active root match is borrowed, its + // beforeLoad must NOT be re-invoked (previously it re-ran with + // cause 'enter' and preload false). + await router.preloadRoute({ to: '/about' } as any) + expect(rootBeforeLoad).toHaveBeenCalledTimes(1) + expect(aboutBeforeLoad).toHaveBeenCalledTimes(1) + expect(aboutBeforeLoad).toHaveBeenCalledWith( + expect.objectContaining({ cause: 'preload', preload: true }), + ) + + // Hover the currently active route: everything is borrowed, nothing runs. + await router.preloadRoute({ to: '/' } as any) + expect(rootBeforeLoad).toHaveBeenCalledTimes(1) + expect(indexBeforeLoad).toHaveBeenCalledTimes(1) + expect(aboutBeforeLoad).toHaveBeenCalledTimes(1) + + // The report also covered a pathless parent and its nested active route. + // After navigating there, hovering that same link must not replay any + // ancestor's old enter/stay flags as a preload. + await router.navigate({ to: '/_bag/nested' }) + expect(rootBeforeLoad).toHaveBeenLastCalledWith( + expect.objectContaining({ preload: false }), + ) + expect(bagBeforeLoad).toHaveBeenCalledWith( + expect.objectContaining({ cause: 'enter', preload: false }), + ) + expect(nestedBeforeLoad).toHaveBeenCalledWith( + expect.objectContaining({ cause: 'enter', preload: false }), + ) + expect( + router.state.matches.some((match) => match.routeId === nestedRoute.id), + ).toBe(true) + rootBeforeLoad.mockClear() + indexBeforeLoad.mockClear() + aboutBeforeLoad.mockClear() + bagBeforeLoad.mockClear() + nestedBeforeLoad.mockClear() + + await router.preloadRoute({ to: '/_bag/nested' }) + expect(rootBeforeLoad).not.toHaveBeenCalled() + expect(indexBeforeLoad).not.toHaveBeenCalled() + expect(aboutBeforeLoad).not.toHaveBeenCalled() + expect(bagBeforeLoad).not.toHaveBeenCalled() + expect(nestedBeforeLoad).not.toHaveBeenCalled() +}) diff --git a/packages/router-core/tests/issue-4684-head-on-beforeload-error.test.ts b/packages/router-core/tests/issue-4684-head-on-beforeload-error.test.ts index 05960c8290..828bbfebd7 100644 --- a/packages/router-core/tests/issue-4684-head-on-beforeload-error.test.ts +++ b/packages/router-core/tests/issue-4684-head-on-beforeload-error.test.ts @@ -1,7 +1,7 @@ import { createMemoryHistory } from '@tanstack/history' import { describe, expect, test, vi } from 'vitest' import { BaseRootRoute, BaseRoute } from '../src' -import { createTestRouter } from './routerTestUtils' +import { createTestRouter, loadServerResponse } from './routerTestUtils' // https://github.com/TanStack/router/issues/4684 // @@ -44,9 +44,9 @@ describe('issue #4684: head executes when beforeLoad throws', () => { const { router, rootRoute, failingRoute, rootHead, failingHead } = setup(true) - await router.load() + const response = await loadServerResponse(router, '/fail') - expect(router.state.statusCode).toBe(500) + expect(response.status).toBe(500) expect(rootHead).toHaveBeenCalledTimes(1) expect(failingHead).toHaveBeenCalledTimes(1) diff --git a/packages/router-core/tests/issue-4696-parent-context-search-normalization.test.ts b/packages/router-core/tests/issue-4696-parent-context-search-normalization.test.ts new file mode 100644 index 0000000000..02f38c5e80 --- /dev/null +++ b/packages/router-core/tests/issue-4696-parent-context-search-normalization.test.ts @@ -0,0 +1,86 @@ +import { expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter } from './routerTestUtils' + +// https://github.com/TanStack/router/issues/4696 +test('#4696: normalized child search preserves parent context across reused matches', async () => { + const rootLoader = vi.fn( + ({ context }: { context: Record }) => context, + ) + const dashboardBeforeLoad = vi.fn( + ({ context }: { context: Record }) => { + if (!context.isAuthenticated) { + throw new Error('Authentication context was lost') + } + }, + ) + const rootRoute = new BaseRootRoute({ + beforeLoad: async () => { + await Promise.resolve() + return { + initialData: { + user: { email: 'mr.user@gmail.com', role: 'user' }, + }, + initializationError: undefined, + isAuthenticated: true, + isAdmin: false, + } + }, + loader: rootLoader, + }) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const dashboardRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/dashboard', + validateSearch: () => ({ page: 0 }), + beforeLoad: dashboardBeforeLoad, + }) + const history = createMemoryHistory({ initialEntries: ['/'] }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, dashboardRoute]), + history, + }) + + await router.load() + router.update({ + history: createMemoryHistory({ + initialEntries: ['/dashboard?page=0'], + }), + }) + await router.load() + expect( + router.state.matches.find((match) => match.routeId === rootRoute.id) + ?.loaderData, + ).toMatchObject({ isAuthenticated: true }) + + dashboardBeforeLoad.mockClear() + router.update({ + history: createMemoryHistory({ initialEntries: ['/dashboard'] }), + }) + await router.load() + + expect(dashboardBeforeLoad).toHaveBeenCalledWith( + expect.objectContaining({ + context: expect.objectContaining({ isAuthenticated: true }), + }), + ) + const rootMatch = router.state.matches.find( + (match) => match.routeId === rootRoute.id, + ) + const dashboardMatch = router.state.matches.find( + (match) => match.routeId === dashboardRoute.id, + ) + expect(rootMatch?.loaderData).toMatchObject({ + isAuthenticated: true, + isAdmin: false, + }) + expect(dashboardMatch).toMatchObject({ + status: 'success', + search: { page: 0 }, + searchError: undefined, + }) +}) diff --git a/packages/router-core/tests/issue-5106-hydrated-notfound-boundary.test.ts b/packages/router-core/tests/issue-5106-hydrated-notfound-boundary.test.ts new file mode 100644 index 0000000000..abc3e5656d --- /dev/null +++ b/packages/router-core/tests/issue-5106-hydrated-notfound-boundary.test.ts @@ -0,0 +1,242 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute, isNotFound } from '../src' +import { hydrate } from '../src/ssr/client' +import { createTestRouter } from './routerTestUtils' +import type { TsrSsrGlobal } from '../src/ssr/types' +import type { Manifest } from '../src/manifest' + +const testManifest: Manifest = { routes: {} } + +// https://github.com/TanStack/router/issues/5106 +// SSR: a child loader throws notFound(). The server commits a notFound-capped +// lane and the client hydrates it. The parent route must not "freeze": every +// hydrated match has to settle (a still-pending loadPromise suspends the +// parent subtree forever in React) and no server loader may re-run client-side. +describe('issue #5106 - hydrating a server-committed notFound boundary', () => { + let mockWindow: { $_TSR?: TsrSsrGlobal } + + beforeEach(() => { + mockWindow = {} + ;(global as any).window = mockWindow + }) + + afterEach(() => { + delete (global as any).window + vi.restoreAllMocks() + }) + + it('child-owned boundary settles all matches without re-running loaders', async () => { + const postsLoader = vi.fn(() => 'posts-data') + const postLoader = vi.fn(() => 'post-data') + const history = createMemoryHistory({ + initialEntries: ['/posts/i-do-not-exist'], + }) + + const rootRoute = new BaseRootRoute({}) + const postsRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + loader: postsLoader, + component: () => 'Posts', + }) + const postRoute = new BaseRoute({ + getParentRoute: () => postsRoute, + path: '/$postId', + loader: postLoader, + component: () => 'Post', + notFoundComponent: () => 'Post not found', + }) + const safeLoader = vi.fn(() => 'safe-data') + const safeRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/safe', + loader: safeLoader, + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + postsRoute.addChildren([postRoute]), + safeRoute, + ]), + history, + isServer: false, + }) + + const matches = router.matchRoutes(router.stores.location.get()) + expect(matches).toHaveLength(3) + + mockWindow.$_TSR = { + router: { + manifest: testManifest, + dehydratedData: {}, + // The child is its own notFound boundary, so the server lane still + // ends at the child match. + matches: [ + { + i: matches[0]!.id, + s: 'success' as const, + ssr: true, + u: Date.now(), + }, + { + i: matches[1]!.id, + s: 'success' as const, + l: 'posts-data', + ssr: true, + u: Date.now(), + }, + { + i: matches[2]!.id, + s: 'notFound' as const, + e: { isNotFound: true }, + ssr: true, + u: Date.now(), + }, + ], + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + initialized: false, + } + + await hydrate(router) + + const stateMatches = router.state.matches + expect(stateMatches).toHaveLength(3) + + // Parent keeps its server data and status. + expect(stateMatches[1]!.status).toBe('success') + expect(stateMatches[1]!.loaderData).toBe('posts-data') + + // Child hydrated as the notFound boundary. + expect(stateMatches[2]!.status).toBe('notFound') + expect(isNotFound(stateMatches[2]!.error)).toBe(true) + + // No server loader re-ran on the client. + expect(postsLoader).not.toHaveBeenCalled() + expect(postLoader).not.toHaveBeenCalled() + + // Prove the hydrated lane did not freeze the router by leaving it able to + // complete an ordinary client navigation. This observes the consequence + // that matters without reading hydration/readiness bookkeeping. + await router.navigate({ to: '/safe' }) + expect(router.state.location.pathname).toBe('/safe') + expect(router.state.isLoading).toBe(false) + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: safeRoute.id, + status: 'success', + loaderData: 'safe-data', + }) + expect(safeLoader).toHaveBeenCalledTimes(1) + expect(postsLoader).not.toHaveBeenCalled() + expect(postLoader).not.toHaveBeenCalled() + }) + + it('ancestor boundary replays the dehydrated notFound in the follow-up load instead of loading the omitted child', async () => { + const postsLoader = vi.fn(() => 'posts-data') + const postLoader = vi.fn(() => 'post-data') + const history = createMemoryHistory({ + initialEntries: ['/posts/i-do-not-exist'], + }) + + const rootRoute = new BaseRootRoute({}) + const postsRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + loader: postsLoader, + component: () => 'Posts', + notFoundComponent: () => 'Posts not found', + }) + // The child has no notFoundComponent, so the server selected the parent + // as the boundary and omitted the child match from the dehydrated lane. + const postRoute = new BaseRoute({ + getParentRoute: () => postsRoute, + path: '/$postId', + loader: postLoader, + component: () => 'Post', + }) + const safeLoader = vi.fn(() => 'safe-data') + const safeRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/safe', + loader: safeLoader, + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + postsRoute.addChildren([postRoute]), + safeRoute, + ]), + history, + isServer: false, + }) + + const matches = router.matchRoutes(router.stores.location.get()) + expect(matches).toHaveLength(3) + + mockWindow.$_TSR = { + router: { + manifest: testManifest, + dehydratedData: {}, + // Server lane was capped at the parent boundary. + matches: [ + { + i: matches[0]!.id, + s: 'success' as const, + ssr: true, + u: Date.now(), + }, + { + i: matches[1]!.id, + s: 'notFound' as const, + l: 'posts-data', + e: { isNotFound: true }, + ssr: true, + u: Date.now(), + }, + ], + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + initialized: false, + } + + await hydrate(router) + + // The server boundary is a complete terminal prefix. The omitted child + // is not client work and must remain absent. + expect(router.state.matches).toHaveLength(2) + + const stateMatches = router.state.matches + expect(stateMatches[1]!.routeId).toBe(postsRoute.id) + expect(stateMatches[1]!.status).toBe('notFound') + expect(isNotFound(stateMatches[1]!.error)).toBe(true) + // The boundary keeps the parent's server loader data (the e2e + // parent-boundary spec renders it). + expect(stateMatches[1]!.loaderData).toBe('posts-data') + + // Neither the omitted child nor the dehydrated parent re-ran client-side. + expect(postLoader).not.toHaveBeenCalled() + expect(postsLoader).not.toHaveBeenCalled() + + // The replayed boundary must not strand subsequent client work. + await router.navigate({ to: '/safe' }) + expect(router.state.location.pathname).toBe('/safe') + expect(router.state.isLoading).toBe(false) + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: safeRoute.id, + status: 'success', + loaderData: 'safe-data', + }) + expect(safeLoader).toHaveBeenCalledTimes(1) + expect(postLoader).not.toHaveBeenCalled() + expect(postsLoader).not.toHaveBeenCalled() + }) +}) diff --git a/packages/router-core/tests/issue-6107-preload-chunk-failure.test.ts b/packages/router-core/tests/issue-6107-preload-chunk-failure.test.ts new file mode 100644 index 0000000000..a38b0cbcea --- /dev/null +++ b/packages/router-core/tests/issue-6107-preload-chunk-failure.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter } from './routerTestUtils' + +/** + * Issue #6107: going offline and hovering a link (preload intent) made the + * failed dynamic import surface as an unhandled rejection in the console, and + * the error never reached the route's error component. + * + * Desired behavior: + * - a hover preload whose lazy chunk rejects resolves non-fatally and leaves + * no unhandled rejection (only a deliberate dev-only console.error), and + * - the failed chunk is evicted, so the subsequent real navigation retries + * the import and commits the chunk error onto the match (status 'error'), + * which is what framework error components render. + */ +describe('issue #6107: failed dynamic import of a lazy route chunk', () => { + test('hover preload failure is non-fatal and navigation surfaces the chunk error to the error boundary', async () => { + const unhandledRejection = vi.fn() + process.on('unhandledRejection', unhandledRejection) + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + try { + const chunkError = new TypeError( + 'Failed to fetch dynamically imported module: /assets/posts.lazy-abc123.js', + ) + let lazyCalls = 0 + const rootRoute = new BaseRootRoute({}) + const postsRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + }).lazy(() => { + lazyCalls++ + return Promise.reject(chunkError) + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([postsRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + await router.load() + + // Hovering a while offline: the preload must resolve without + // throwing and without leaking an unhandled rejection. + await router.preloadRoute({ to: '/posts' }) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(unhandledRejection).not.toHaveBeenCalled() + // The preload attempted the import at least once (the error path may + // legitimately retry once more for the errorComponent chunk). + expect(lazyCalls).toBeGreaterThanOrEqual(1) + const lazyCallsAfterPreload = lazyCalls + + // Clicking the link: the rejected chunk was evicted (not cached), so + // navigation retries the import; when it fails again the error is + // committed on the match for the error component to render. + await router.navigate({ to: '/posts' }) + const postsMatch = router.state.matches.find( + (match) => match.routeId === postsRoute.id, + ) + // The rejected chunk was not replayed from cache: navigation retried + // the import. + expect(lazyCalls).toBeGreaterThan(lazyCallsAfterPreload) + expect(postsMatch?.status).toBe('error') + expect(postsMatch?.error).toBe(chunkError) + expect(unhandledRejection).not.toHaveBeenCalled() + } finally { + consoleError.mockRestore() + process.off('unhandledRejection', unhandledRejection) + } + }) +}) diff --git a/packages/router-core/tests/issue-6221-head-waits-for-loader.test.ts b/packages/router-core/tests/issue-6221-head-waits-for-loader.test.ts new file mode 100644 index 0000000000..279411ef39 --- /dev/null +++ b/packages/router-core/tests/issue-6221-head-waits-for-loader.test.ts @@ -0,0 +1,130 @@ +import { createMemoryHistory } from '@tanstack/history' +import { describe, expect, test, vi } from 'vitest' +import { BaseRootRoute, BaseRoute, notFound } from '../src' +import { createTestRouter } from './routerTestUtils' + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +// https://github.com/TanStack/router/issues/6221 +// +// head() must never compute from loaderData older than the data the lane +// commits. Two reported shapes: +// 1. Revisiting a route whose previous visit ended in notFound — the head +// must see the fresh successful loaderData, not run before the loader. +// 2. Revisiting a cached success match that gets a stale reload — the head +// title must not lag one loaderData run behind. +describe('issue #6221: head does not run before loader data is ready', () => { + test('head sees fresh loaderData when a previously-notFound route loads successfully', async () => { + let authed = false + const headLoaderData: Array = [] + + const articleLoader = vi.fn(async () => { + await sleep(5) + if (!authed) throw notFound() + return { title: 'Article 123' } + }) + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const articleRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/article', + loader: articleLoader, + notFoundComponent: () => 'Not found', + head: ({ loaderData }) => { + headLoaderData.push(loaderData) + return { + meta: [{ title: (loaderData as any)?.title ?? 'Generic title' }], + } + }, + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, articleRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + + // Logged out: the article loader throws notFound. + await router.navigate({ to: '/article' }) + const notFoundMatch = router.state.matches.find( + (match) => match.routeId === articleRoute.id, + ) + expect(notFoundMatch?.status).toBe('notFound') + + // Log in, go elsewhere, then come back (the issue's back-button step). + authed = true + await router.navigate({ to: '/' }) + await router.navigate({ to: '/article' }) + + await vi.waitFor(() => { + const articleMatch = router.state.matches.find( + (match) => match.routeId === articleRoute.id, + ) + expect(articleMatch?.status).toBe('success') + expect(articleMatch?.meta).toEqual([{ title: 'Article 123' }]) + }) + + // The head executed for the successful lane saw the fresh loaderData. + expect(headLoaderData[headLoaderData.length - 1]).toEqual({ + title: 'Article 123', + }) + }) + + test('head title does not lag one loaderData run behind on revisits', async () => { + let version = 0 + const quoteLoader = vi.fn(async () => { + await sleep(5) + version += 1 + return { author: `author-${version}` } + }) + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const quoteRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/quote', + loader: quoteLoader, + head: ({ loaderData }) => ({ + meta: [{ title: `Quote by ${(loaderData as any)?.author ?? '...'}` }], + }), + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, quoteRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + + await router.navigate({ to: '/quote' }) + await vi.waitFor(() => { + const quoteMatch = router.state.matches.find( + (match) => match.routeId === quoteRoute.id, + ) + expect(quoteMatch?.meta).toEqual([{ title: 'Quote by author-1' }]) + }) + + await router.navigate({ to: '/' }) + await router.navigate({ to: '/quote' }) + + // The revisit reloads the stale match. Whether that reload is foreground + // or background, the committed head must reflect the loaderData it was + // committed with — never the previous run's data. + await vi.waitFor(() => { + expect(quoteLoader).toHaveBeenCalledTimes(2) + const quoteMatch = router.state.matches.find( + (match) => match.routeId === quoteRoute.id, + ) + expect(quoteMatch?.loaderData).toEqual({ author: 'author-2' }) + expect(quoteMatch?.meta).toEqual([{ title: 'Quote by author-2' }]) + }) + }) +}) diff --git a/packages/router-core/tests/issue-6351-fuzzy-notfound-layout.test.ts b/packages/router-core/tests/issue-6351-fuzzy-notfound-layout.test.ts new file mode 100644 index 0000000000..25e212b8f5 --- /dev/null +++ b/packages/router-core/tests/issue-6351-fuzzy-notfound-layout.test.ts @@ -0,0 +1,99 @@ +import { expect, test } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter } from './routerTestUtils' + +/** + * https://github.com/TanStack/router/issues/6351 + * + * A fuzzy (unmatched-URL) 404 must be attributed to the nearest matched + * ancestor that can actually render it: the deepest matched route with + * children that defines a `notFoundComponent`. Attributing it to the + * deepest matched route with children regardless of whether it has a + * `notFoundComponent` skips pathless-layout/ancestor `notFoundComponent`s + * and makes the framework fall back to `defaultNotFoundComponent`. + * + * When no matched route defines a `notFoundComponent`, the previous + * behavior (deepest matched route with children) is preserved, and + * `notFoundMode: 'root'` still attributes the 404 to the root route. + */ + +function setup(opts: { + layoutNotFoundComponent?: () => unknown + agentsNotFoundComponent?: () => unknown + notFoundMode?: 'fuzzy' | 'root' +}) { + const rootRoute = new BaseRootRoute({}) + const layoutRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + id: '_authenticated', + notFoundComponent: opts.layoutNotFoundComponent, + }) + const agentsRoute = new BaseRoute({ + getParentRoute: () => layoutRoute, + path: '/agents', + notFoundComponent: opts.agentsNotFoundComponent, + }) + const skillAgentRoute = new BaseRoute({ + getParentRoute: () => agentsRoute, + path: '/skill-agent', + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + layoutRoute.addChildren([agentsRoute.addChildren([skillAgentRoute])]), + ]), + history: createMemoryHistory({ initialEntries: ['/agents/skill-agen'] }), + notFoundMode: opts.notFoundMode, + }) + + return { router, rootRoute, layoutRoute, agentsRoute } +} + +function globalNotFoundRouteIds(router: { + state: { matches: Array<{ routeId: string; globalNotFound?: boolean }> } +}) { + return router.state.matches + .filter((match) => match.globalNotFound) + .map((match) => match.routeId) +} + +test('fuzzy notFound lands on the pathless layout that defines a notFoundComponent', async () => { + const { router, layoutRoute } = setup({ + layoutNotFoundComponent: () => 'layout not found', + }) + + await router.load() + + expect(globalNotFoundRouteIds(router)).toEqual([layoutRoute.id]) +}) + +test('fuzzy notFound prefers the deepest matched route with a notFoundComponent', async () => { + const { router, agentsRoute } = setup({ + layoutNotFoundComponent: () => 'layout not found', + agentsNotFoundComponent: () => 'agents not found', + }) + + await router.load() + + expect(globalNotFoundRouteIds(router)).toEqual([agentsRoute.id]) +}) + +test('fuzzy notFound falls back to the deepest matched route with children when no notFoundComponent exists', async () => { + const { router, agentsRoute } = setup({}) + + await router.load() + + expect(globalNotFoundRouteIds(router)).toEqual([agentsRoute.id]) +}) + +test("notFoundMode 'root' attributes the notFound to the root route even when a layout has a notFoundComponent", async () => { + const { router, rootRoute } = setup({ + layoutNotFoundComponent: () => 'layout not found', + notFoundMode: 'root', + }) + + await router.load() + + expect(globalNotFoundRouteIds(router)).toEqual([rootRoute.id]) +}) diff --git a/packages/router-core/tests/issue-7379-head-matches-direct-load.test.ts b/packages/router-core/tests/issue-7379-head-matches-direct-load.test.ts new file mode 100644 index 0000000000..9eafcea725 --- /dev/null +++ b/packages/router-core/tests/issue-7379-head-matches-direct-load.test.ts @@ -0,0 +1,83 @@ +import { createMemoryHistory } from '@tanstack/history' +import { describe, expect, test } from 'vitest' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter } from './routerTestUtils' + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +// https://github.com/TanStack/router/issues/7379 +// +// On a direct (initial) load, head({ matches }) must observe loaderData and +// context on every ancestor match so a breadcrumb-style title can be built. +describe('issue #7379: head({ matches }) has loaderData and context on direct load', () => { + test('initial load executes head after loaders with populated matches', async () => { + const seenMatches: Array< + Array<{ routeId: string; loaderData: unknown; context: unknown }> + > = [] + + const rootRoute = new BaseRootRoute({}) + const nestedRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/nested', + beforeLoad: async () => { + await sleep(5) + return { section: 'nested-section' } + }, + loader: async () => { + await sleep(5) + return { crumb: 'Nested' } + }, + }) + const evenRoute = new BaseRoute({ + getParentRoute: () => nestedRoute, + path: 'even', + loader: async () => { + await sleep(5) + return { crumb: 'Even' } + }, + head: ({ matches }) => { + seenMatches.push( + matches.map((match: any) => ({ + routeId: match.routeId, + loaderData: match.loaderData, + context: match.context, + })), + ) + const crumbs = matches + .map((match: any) => match.loaderData?.crumb) + .filter(Boolean) + return { + meta: [{ title: [...crumbs, 'Company Inc.'].join(' - ') }], + } + }, + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([nestedRoute.addChildren([evenRoute])]), + history: createMemoryHistory({ initialEntries: ['/nested/even'] }), + }) + + // Direct load: this is the first and only load of the router. + await router.load() + + expect(seenMatches).toHaveLength(1) + const seen = seenMatches[0]! + + const seenNested = seen.find((entry) => entry.routeId === nestedRoute.id) + const seenEven = seen.find((entry) => entry.routeId === evenRoute.id) + + // loaderData is present on every match handed to head(). + expect(seenNested?.loaderData).toEqual({ crumb: 'Nested' }) + expect(seenEven?.loaderData).toEqual({ crumb: 'Even' }) + + // context (incl. beforeLoad context) is present as well. + expect(seenNested?.context).toMatchObject({ section: 'nested-section' }) + expect(seenEven?.context).toMatchObject({ section: 'nested-section' }) + + // The breadcrumb title is complete on the committed match. + const evenMatch = router.state.matches.find( + (match) => match.routeId === evenRoute.id, + ) + expect(evenMatch?.meta).toEqual([{ title: 'Nested - Even - Company Inc.' }]) + }) +}) diff --git a/packages/router-core/tests/issue-7602-parent-beforeload-context-child-loader.test.ts b/packages/router-core/tests/issue-7602-parent-beforeload-context-child-loader.test.ts new file mode 100644 index 0000000000..19538776b0 --- /dev/null +++ b/packages/router-core/tests/issue-7602-parent-beforeload-context-child-loader.test.ts @@ -0,0 +1,76 @@ +import { expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute, createControlledPromise } from '../src' +import { createTestRouter } from './routerTestUtils' + +// https://github.com/TanStack/router/issues/7602 +test('#7602: browser Back republishes a cached child with fresh parent beforeLoad context', async () => { + const loaderContextNumbers: Array = [] + const reloadGate = createControlledPromise() + let loaderRuns = 0 + + const rootRoute = new BaseRootRoute({}) + const homeRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const reproRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/repro', + beforeLoad: async () => { + await Promise.resolve() + return { number: 42 } + }, + }) + const reproIndexRoute = new BaseRoute({ + getParentRoute: () => reproRoute, + path: '/', + loader: async ({ context }: { context: { number?: number } }) => { + loaderRuns++ + loaderContextNumbers.push(context.number) + if (loaderRuns === 2) { + await reloadGate + } + return { visit: loaderRuns } + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + homeRoute, + reproRoute.addChildren([reproIndexRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/repro'] }), + }) + const getChildMatch = () => + router.state.matches.find((match) => match.routeId === reproIndexRoute.id) + // RouterProvider's Transitioner installs this subscription in applications. + // It is what turns a browser Back history event into a router load. + const unsubscribe = router.history.subscribe(router.load) + + try { + await router.load() + expect(loaderContextNumbers).toEqual([42]) + + await router.navigate({ to: '/' }) + expect(getChildMatch()).toBeUndefined() + + router.history.back() + await vi.waitFor(() => expect(loaderContextNumbers).toHaveLength(2)) + + // The cached success is visible while its stale loader reloads. It must + // never be published with the parent's context contribution missing. + expect(router.state.location.pathname).toBe('/repro') + expect(getChildMatch()?.status).toBe('success') + expect(getChildMatch()?.context).toMatchObject({ number: 42 }) + expect(loaderContextNumbers).toEqual([42, 42]) + + reloadGate.resolve() + await vi.waitFor(() => + expect(getChildMatch()?.loaderData).toEqual({ visit: 2 }), + ) + expect(getChildMatch()?.context).toMatchObject({ number: 42 }) + } finally { + reloadGate.resolve() + unsubscribe() + } +}) diff --git a/packages/router-core/tests/issue-7635-dehydrated-error-child-head.test.ts b/packages/router-core/tests/issue-7635-dehydrated-error-child-head.test.ts new file mode 100644 index 0000000000..7dd6972851 --- /dev/null +++ b/packages/router-core/tests/issue-7635-dehydrated-error-child-head.test.ts @@ -0,0 +1,121 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { hydrate } from '../src/ssr/client' +import { createTestRouter } from './routerTestUtils' +import type { TsrSsrGlobal } from '../src/ssr/types' +import type { Manifest } from '../src/manifest' + +const testManifest: Manifest = { routes: {} } + +// https://github.com/TanStack/router/issues/7635 +// +// Server: `_app.beforeLoad` throws, so the server lane is capped at +// [__root, _app(error)] and the SSR document title is the error title. +// Client: hydration matches the full branch [__root, _app, child]. The +// follow-up client load must replay the dehydrated error boundary instead of +// loading the child and executing its head — otherwise the child head +// overwrites the error document title on the client. +describe('issue #7635: dehydrated parent beforeLoad error caps child head', () => { + let mockWindow: { $_TSR?: TsrSsrGlobal } + + beforeEach(() => { + mockWindow = {} + ;(global as any).window = mockWindow + }) + + afterEach(() => { + vi.restoreAllMocks() + delete (global as any).window + }) + + it('does not run child loader/head after hydrating a server error boundary', async () => { + const serverError = new Error('App beforeLoad failed') + const appHead = vi.fn(({ match }: any) => ({ + meta: [{ title: match.error ? 'App error title' : 'App success title' }], + })) + const childHead = vi.fn(() => ({ + meta: [{ title: 'Child success title' }], + })) + const childLoader = vi.fn(() => ({ child: 'data' })) + + const rootRoute = new BaseRootRoute({}) + const appRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/app', + head: appHead, + errorComponent: () => 'App error', + }) + const childRoute = new BaseRoute({ + getParentRoute: () => appRoute, + path: '/child', + loader: childLoader, + head: childHead, + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([appRoute.addChildren([childRoute])]), + history: createMemoryHistory({ initialEntries: ['/app/child'] }), + isServer: false, + }) + + // The dehydrated payload only contains what the server rendered: + // the root and the _app error boundary. The child match is absent. + const matches = router.matchRoutes(router.stores.location.get()) + const rootMatch = matches[0]! + const appMatch = matches[1]! + + mockWindow.$_TSR = { + router: { + manifest: testManifest, + dehydratedData: {}, + matches: [ + { + i: rootMatch.id, + s: 'success' as const, + ssr: true, + u: Date.now(), + }, + { + i: appMatch.id, + s: 'error' as const, + e: serverError, + ssr: true, + u: Date.now(), + }, + ], + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + initialized: false, + } + + await hydrate(router) + + // The follow-up client load must cap the lane at the server error + // boundary exactly like the server did. + await vi.waitFor(() => { + expect(router.state.matches.map((match) => match.routeId)).toEqual([ + rootRoute.id, + appRoute.id, + ]) + }) + + const committedApp = router.state.matches.find( + (match) => match.routeId === appRoute.id, + ) + expect(committedApp?.status).toBe('error') + + // The child was never rendered by the server; its loader and head must + // not run on the client either. + expect(childLoader).not.toHaveBeenCalled() + expect(childHead).not.toHaveBeenCalled() + + // The error boundary head owns the document title. + expect(appHead).toHaveBeenCalled() + expect(committedApp?.meta).toEqual([{ title: 'App error title' }]) + }) +}) diff --git a/packages/router-core/tests/load-route-chunk.test.ts b/packages/router-core/tests/load-route-chunk.test.ts new file mode 100644 index 0000000000..73179f9f32 --- /dev/null +++ b/packages/router-core/tests/load-route-chunk.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter } from './routerTestUtils' + +test('router.loadRouteChunk exposes a failed lazy import and remains retryable', async () => { + const chunkError = new TypeError('Failed to fetch lazy route') + const component = () => null + let fail = true + const rootRoute = new BaseRootRoute({}) + const route = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/route', + }).lazy(() => { + if (fail) { + fail = false + return Promise.reject(chunkError) + } + return Promise.resolve({ options: { component } } as any) + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([route]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await expect(router.loadRouteChunk(route as any)).rejects.toBe(chunkError) + await router.loadRouteChunk(route as any) + + expect(route.options.component).toBe(component) +}) diff --git a/packages/router-core/tests/load.test.ts b/packages/router-core/tests/load.test.ts index 1ea6fca30e..e569b8de00 100644 --- a/packages/router-core/tests/load.test.ts +++ b/packages/router-core/tests/load.test.ts @@ -8,9 +8,7 @@ import { rootRouteId, } from '../src' import { createTestRouter } from './routerTestUtils' -import { loadMatches } from '../src/load-matches' import type { - AnyRouter, LoaderStaleReloadMode, RootRouteOptions, RouterCore, @@ -72,12 +70,18 @@ describe('redirect resolution', () => { await router.load() - expect(router.state.redirect).toEqual( + expect(router._serverResult).toEqual( expect.objectContaining({ - options: expect.objectContaining({ href: '/undefined' }), + type: 'redirect', + redirect: expect.objectContaining({ + options: expect.objectContaining({ href: '/undefined' }), + }), }), ) - expect(router.state.redirect?.headers.get('Location')).toBe('/undefined') + expect( + router._serverResult?.type === 'redirect' && + router._serverResult.redirect.headers.get('Location'), + ).toBe('/undefined') }, ) }) @@ -153,12 +157,7 @@ describe('beforeLoad skip or exec', () => { 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 + await router.navigate({ to: '/foo' }) expect(router.state.location.pathname).toBe('/foo') expect(router.state.matches).toEqual( expect.arrayContaining([ @@ -181,7 +180,6 @@ describe('beforeLoad skip or exec', () => { await router.navigate({ to: '/foo' }) - expect(router.state.statusCode).toBe(500) expect(router.state.matches).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -202,7 +200,6 @@ describe('beforeLoad skip or exec', () => { await router.navigate({ to: '/foo' }) - expect(router.state.statusCode).toBe(500) expect(router.state.matches.find((d) => d.id === '/foo/foo')?.error).toBe( thrown, ) @@ -213,12 +210,10 @@ describe('beforeLoad skip or exec', () => { 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(router.state.location.pathname).toBe('/foo') expect(beforeLoad).toHaveBeenCalledTimes(2) }) @@ -227,11 +222,9 @@ describe('beforeLoad skip or exec', () => { const router = setup({ beforeLoad }) router.preloadRoute({ to: '/foo' }) await Promise.resolve() - expect(router.stores.cachedMatches.get()).toEqual( - expect.arrayContaining([expect.objectContaining({ id: '/foo/foo' })]), - ) await router.navigate({ to: '/foo' }) + expect(router.state.location.pathname).toBe('/foo') expect(beforeLoad).toHaveBeenCalledTimes(2) }) @@ -274,16 +267,10 @@ describe('beforeLoad skip or exec', () => { 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' }) expect(router.state.location.pathname).toBe('/foo') - expect( - router.stores.cachedMatches.get().some((d) => d.status === 'redirected'), - ).toBe(false) expect(beforeLoad).toHaveBeenCalledTimes(2) }) @@ -297,15 +284,9 @@ describe('beforeLoad skip or exec', () => { }) router.preloadRoute({ to: '/foo' }) await Promise.resolve() - expect( - router.stores.cachedMatches.get().some((d) => d.status === 'redirected'), - ).toBe(false) await router.navigate({ to: '/foo' }) expect(router.state.location.pathname).toBe('/foo') - expect( - router.stores.cachedMatches.get().some((d) => d.status === 'redirected'), - ).toBe(false) expect(beforeLoad).toHaveBeenCalledTimes(2) }) @@ -421,12 +402,7 @@ describe('loader skip or exec', () => { 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 + await router.navigate({ to: '/foo' }) expect(router.state.location.pathname).toBe('/foo') expect(router.state.matches).toEqual( expect.arrayContaining([ @@ -445,12 +421,10 @@ describe('loader skip or exec', () => { 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(router.state.location.pathname).toBe('/foo') expect(loader).toHaveBeenCalledTimes(2) }) @@ -458,12 +432,10 @@ describe('loader skip or exec', () => { 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(router.state.location.pathname).toBe('/foo') expect(loader).toHaveBeenCalledTimes(1) }) @@ -472,11 +444,9 @@ describe('loader skip or exec', () => { const router = setup({ loader }) router.preloadRoute({ to: '/foo' }) await Promise.resolve() - expect(router.stores.cachedMatches.get()).toEqual( - expect.arrayContaining([expect.objectContaining({ id: '/foo/foo' })]), - ) await router.navigate({ to: '/foo' }) + expect(router.state.location.pathname).toBe('/foo') expect(loader).toHaveBeenCalledTimes(1) }) @@ -495,7 +465,7 @@ describe('loader skip or exec', () => { expect(loader).toHaveBeenCalledTimes(2) }) - test('skip if pending preload (notFound)', async () => { + test('exec if pending preload returns notFound', async () => { const loader: Loader = vi.fn(async ({ preload }) => { await sleep(100) if (preload) throw notFound() @@ -507,7 +477,9 @@ describe('loader skip or exec', () => { await Promise.resolve() await router.navigate({ to: '/foo' }) - expect(loader).toHaveBeenCalledTimes(1) + expect(router.state.location.pathname).toBe('/foo') + expect(router.state.matches.at(-1)?.status).toBe('success') + expect(loader).toHaveBeenCalledTimes(2) }) test('exec if rejected preload (redirect)', async () => { @@ -519,20 +491,14 @@ describe('loader skip or exec', () => { loader, }) 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' }) expect(router.state.location.pathname).toBe('/foo') - expect( - router.stores.cachedMatches.get().some((d) => d.status === 'redirected'), - ).toBe(false) expect(loader).toHaveBeenCalledTimes(2) }) - test('skip if pending preload (redirect)', async () => { + test('exec if pending preload redirects', async () => { const loader: Loader = vi.fn(async ({ preload }) => { await sleep(100) if (preload) throw redirect({ to: '/bar' }) @@ -542,38 +508,11 @@ describe('loader skip or exec', () => { }) router.preloadRoute({ to: '/foo' }) await Promise.resolve() - expect( - router.stores.cachedMatches.get().some((d) => d.status === 'redirected'), - ).toBe(false) await router.navigate({ to: '/foo' }) - expect(router.state.location.pathname).toBe('/bar') - expect( - router.stores.cachedMatches.get().some((d) => d.status === 'redirected'), - ).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.stores.cachedMatches.get().some((d) => d.id === '/foo/foo'), - ).toBe(false) - expect( - router.stores.cachedMatches.get().some((d) => d.status === 'redirected'), - ).toBe(false) + expect(router.state.location.pathname).toBe('/foo') + expect(router.state.matches.at(-1)?.status).toBe('success') + expect(loader).toHaveBeenCalledTimes(2) }) test('exec if rejected preload (error)', async () => { @@ -591,7 +530,7 @@ describe('loader skip or exec', () => { expect(loader).toHaveBeenCalledTimes(2) }) - test('skip if pending preload (error)', async () => { + test('exec if pending preload errors', async () => { const loader: Loader = vi.fn(async ({ preload }) => { await sleep(100) if (preload) throw new Error('error') @@ -603,7 +542,9 @@ describe('loader skip or exec', () => { await Promise.resolve() await router.navigate({ to: '/foo' }) - expect(loader).toHaveBeenCalledTimes(1) + expect(router.state.location.pathname).toBe('/foo') + expect(router.state.matches.at(-1)?.status).toBe('success') + expect(loader).toHaveBeenCalledTimes(2) }) }) @@ -698,22 +639,13 @@ describe('stale loader reload triggers', () => { 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) + ) => router.state.matches.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 setup = ({ loader, staleTime, @@ -784,22 +716,18 @@ describe('stale loader reload triggers', () => { await vi.advanceTimersByTimeAsync(1) const revisit = router.navigate({ to: '/foo' }) - await Promise.resolve() - expect(loader).toHaveBeenCalledTimes(2) + await vi.waitFor(() => { + 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', - }) 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', @@ -819,26 +747,20 @@ describe('stale loader reload triggers', () => { 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(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: 'first', }) resolveStaleReload() - await backgroundReloadPromise - - expect(getMatchById(router, '/foo/foo')?.loaderData).toEqual({ - value: 'second', + await vi.waitFor(() => { + expect(getMatchById(router, '/foo/foo')?.loaderData).toEqual({ + value: 'second', + }) }) } @@ -1154,7 +1076,7 @@ describe('stale loader reload triggers', () => { }) }) -test('cancelMatches after pending timeout', async () => { +test('navigating away from a pending route aborts its loader', async () => { const WAIT_TIME = 5 const onAbortMock = vi.fn() const rootRoute = new BaseRootRoute({}) @@ -1187,36 +1109,27 @@ test('cancelMatches after pending timeout', async () => { 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 pending timeout was cleared expect(onAbortMock).toHaveBeenCalled() - const cancelledFooMatch = router.getMatch('/foo/foo') - expect(cancelledFooMatch?._nonReactive.pendingTimeout).toBeUndefined() }) describe('head execution', () => { + const getTitle = (match: { meta?: unknown }) => + (match.meta as Array<{ title?: string }> | undefined)?.[0]?.title + 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 makeLoader = (index: number) => async () => { + await new Promise((resolve) => { + loaderResolvers[index] = resolve }) + return { level: index } + } - const makeHead = (label: string) => - vi.fn(() => ({ meta: [{ title: label }] })) + const makeHead = (label: string) => () => ({ meta: [{ title: label }] }) const rootRoute = new BaseRootRoute({ loader: makeLoader(0), @@ -1274,25 +1187,15 @@ describe('head execution', () => { }) 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, } } const assertBeforeLoadNotFoundHierarchy = async (throwAtIndex: 1 | 2 | 3) => { - const { router, routes, loaders, heads, loaderResolvers } = + const { router, routes, loaderResolvers } = setupBeforeLoadNotFoundHierarchy(throwAtIndex) let loadResolved = false @@ -1303,26 +1206,20 @@ describe('head execution', () => { 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) + await vi.waitFor(() => { + expect(loaderResolvers.slice(0, throwAtIndex)).not.toContain(undefined) + }) 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) - } + expect(router.state.matches.map(getTitle)).toEqual( + ['Root', 'Level 1', 'Level 2', 'Level 3'].slice(0, throwAtIndex + 1), + ) for (let i = 0; i < throwAtIndex; i++) { const route = routes[i]! @@ -1343,8 +1240,7 @@ describe('head execution', () => { }) }) - test('executes head once when loader throws notFound', async () => { - const head = vi.fn(() => ({ meta: [{ title: 'Test' }] })) + test('projects head when loader throws notFound', async () => { const rootRoute = new BaseRootRoute({}) const testRoute = new BaseRoute({ getParentRoute: () => rootRoute, @@ -1352,7 +1248,7 @@ describe('head execution', () => { loader: () => { throw notFound() }, - head, + head: () => ({ meta: [{ title: 'Test' }] }), }) const routeTree = rootRoute.addChildren([testRoute]) const router = createTestRouter({ @@ -1362,32 +1258,26 @@ describe('head execution', () => { await router.load() - expect(head).toHaveBeenCalledTimes(1) const match = router.state.matches.find((m) => m.routeId === testRoute.id) expect(match?.status).toBe('notFound') + expect(match?.meta).toEqual([{ title: 'Test' }]) }) 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, + loader: () => ({ level: 0 }), + head: () => ({ meta: [{ title: 'Root' }] }), }) - 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, + loader: () => ({ level: 1 }), + head: () => ({ meta: [{ title: 'Child' }] }), }) const routeTree = rootRoute.addChildren([childRoute]) @@ -1396,40 +1286,24 @@ describe('head execution', () => { history: createMemoryHistory({ initialEntries: ['/test'] }), }) - const location = router.latestLocation - const matches = router.matchRoutes(location) - router.stores.setPending(matches) + await router.load() - await expect( - loadMatches({ - router, - location, - matches, - updateMatch: router.updateMatch, - }), - ).rejects.toBe(beforeLoadError) + expect( + router.state.matches.find((match) => match.routeId === childRoute.id) + ?.error, + ).toBe(beforeLoadError) - 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) + expect(router.state.matches[0]?.loaderData).toEqual({ level: 0 }) + expect(router.state.matches.map(getTitle)).toEqual(['Root', 'Child']) }) 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, + loader: () => ({ level: 0 }), + head: () => ({ meta: [{ title: 'Root' }] }), }) - const childLoader = vi.fn(() => ({ level: 1 })) - const childHead = vi.fn(() => ({ meta: [{ title: 'Child' }] })) - const childRoute = new BaseRoute({ getParentRoute: () => rootRoute, path: '/test', @@ -1437,8 +1311,8 @@ describe('head execution', () => { await Promise.resolve() throw beforeLoadError }, - loader: childLoader, - head: childHead, + loader: () => ({ level: 1 }), + head: () => ({ meta: [{ title: 'Child' }] }), }) const routeTree = rootRoute.addChildren([childRoute]) @@ -1447,25 +1321,15 @@ describe('head execution', () => { history: createMemoryHistory({ initialEntries: ['/test'] }), }) - const location = router.latestLocation - const matches = router.matchRoutes(location) - router.stores.setPending(matches) + await router.load() - await expect( - loadMatches({ - router, - location, - matches, - updateMatch: router.updateMatch, - }), - ).rejects.toBe(beforeLoadError) + expect( + router.state.matches.find((match) => match.routeId === childRoute.id) + ?.error, + ).toBe(beforeLoadError) - 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) + expect(router.state.matches[0]?.loaderData).toEqual({ level: 0 }) + expect(router.state.matches.map(getTitle)).toEqual(['Root', 'Child']) }) describe('beforeLoad notFound parent loader outcomes', () => { @@ -1476,34 +1340,36 @@ describe('head execution', () => { name: string throwAtIndex: ThrowAtIndex parentFailures: ParentFailureMap - expectedErrorKind: 'notFound' | 'redirect' | 'error' + expectedErrorKind: 'notFound' | 'redirect' expectedErrorSource?: string - expectedErrorRouteIndex?: 0 | 1 | 2 | 3 - expectedLoaderMaxIndex: number - expectedRenderedHeadMaxIndex: number - withDefaultNotFoundComponent?: boolean + expectedBoundaryIndex?: 0 | 1 | 2 | 3 + expectedHeadTitles: Array beforeLoadNotFoundFactory?: ( routes: readonly [any, any, any, any], ) => ReturnType - expectRootNotFoundComponentAssigned?: boolean } 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 makeHead = (label: string) => () => ({ meta: [{ title: label }] }) + + const makeLoader = + (index: number) => + ({ location }: { location: { pathname: string } }) => { + // Keep failures scoped to the route under test. A root loader which + // redirects unconditionally would also redirect the destination and + // describe an infinite redirect loop rather than precedence between + // the original lane's settled outcomes. + if (location.pathname === '/redirect-target') { + return { level: index } + } const failure = parentFailures[index as 0 | 1 | 2] if (failure === 'notFound') { throw notFound({ data: { source: `loader-${index}` } }) @@ -1511,8 +1377,11 @@ describe('head execution', () => { if (failure === 'redirect') { throw redirect({ to: '/redirect-target' }) } + if (failure === 'error') { + throw new Error(`loader-${index}-error`) + } return { level: index } - }) + } const rootRoute = new BaseRootRoute({ loader: makeLoader(0), @@ -1552,14 +1421,6 @@ describe('head execution', () => { const routes = [rootRoute, level1Route, level2Route, level3Route] as const - ;([0, 1, 2] as const).forEach((index) => { - if (parentFailures[index] === 'error') { - ;(routes[index].options as any).shouldReload = () => { - throw new Error(`loader-${index}-error`) - } - } - }) - const throwRoute = routes[throwAtIndex]! throwRoute.options.beforeLoad = () => { const beforeLoadNotFound = beforeLoadNotFoundFactory @@ -1573,41 +1434,11 @@ describe('head execution', () => { 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) => { - const location = router.latestLocation - const matches = router.matchRoutes(location) - router.stores.setPending(matches) - - try { - await loadMatches({ - router, - location, - matches, - updateMatch: router.updateMatch, - }) - return { error: undefined, matches } - } catch (error) { - return { error, matches } } } @@ -1618,8 +1449,8 @@ describe('head execution', () => { parentFailures: {} as ParentFailureMap, expectedErrorKind: 'notFound' as const, expectedErrorSource: 'beforeLoad-3', - expectedLoaderMaxIndex: 2, - expectedRenderedHeadMaxIndex: 3, + expectedBoundaryIndex: 3, + expectedHeadTitles: ['Root', 'Level 1', 'Level 2', 'Level 3'], }, { name: 'uses parent loader notFound when parent loader throws notFound', @@ -1627,8 +1458,8 @@ describe('head execution', () => { parentFailures: { 1: 'notFound' } as ParentFailureMap, expectedErrorKind: 'notFound' as const, expectedErrorSource: 'loader-1', - expectedLoaderMaxIndex: 2, - expectedRenderedHeadMaxIndex: 1, + expectedBoundaryIndex: 1, + expectedHeadTitles: ['Root', 'Level 1'], }, { name: 'uses first parent loader notFound when multiple parent loaders throw notFound', @@ -1636,8 +1467,8 @@ describe('head execution', () => { parentFailures: { 1: 'notFound', 2: 'notFound' } as ParentFailureMap, expectedErrorKind: 'notFound' as const, expectedErrorSource: 'loader-1', - expectedLoaderMaxIndex: 2, - expectedRenderedHeadMaxIndex: 1, + expectedBoundaryIndex: 1, + expectedHeadTitles: ['Root', 'Level 1'], }, { name: 'uses parent loader notFound when root loader throws notFound', @@ -1645,8 +1476,8 @@ describe('head execution', () => { parentFailures: { 0: 'notFound' } as ParentFailureMap, expectedErrorKind: 'notFound' as const, expectedErrorSource: 'loader-0', - expectedLoaderMaxIndex: 1, - expectedRenderedHeadMaxIndex: 0, + expectedBoundaryIndex: 0, + expectedHeadTitles: ['Root'], }, { name: 'uses explicit routeId from beforeLoad notFound to target ancestor boundary', @@ -1654,9 +1485,8 @@ describe('head execution', () => { parentFailures: {} as ParentFailureMap, expectedErrorKind: 'notFound' as const, expectedErrorSource: 'beforeLoad-explicit-level1', - expectedErrorRouteIndex: 1, - expectedLoaderMaxIndex: 1, - expectedRenderedHeadMaxIndex: 1, + expectedBoundaryIndex: 1, + expectedHeadTitles: ['Root', 'Level 1'], beforeLoadNotFoundFactory: (routes) => notFound({ routeId: routes[1].id as never, @@ -1669,8 +1499,8 @@ describe('head execution', () => { parentFailures: {} as ParentFailureMap, expectedErrorKind: 'notFound' as const, expectedErrorSource: 'beforeLoad-invalid-route', - expectedLoaderMaxIndex: 0, - expectedRenderedHeadMaxIndex: 0, + expectedBoundaryIndex: 0, + expectedHeadTitles: ['Root'], beforeLoadNotFoundFactory: () => notFound({ routeId: '/does-not-exist' as never, @@ -1683,38 +1513,21 @@ describe('head execution', () => { parentFailures: {} as ParentFailureMap, expectedErrorKind: 'notFound' as const, expectedErrorSource: 'beforeLoad-non-exact-route', - expectedLoaderMaxIndex: 0, - expectedRenderedHeadMaxIndex: 0, + expectedBoundaryIndex: 0, + expectedHeadTitles: ['Root'], beforeLoadNotFoundFactory: (routes) => notFound({ routeId: `${routes[1].id}/` as never, data: { source: 'beforeLoad-non-exact-route' }, }), }, - { - 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' }, - }), - }, { 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, + expectedHeadTitles: ['Root'], }, { name: 'prioritizes redirect over root-loader notFound when both appear in settled loaders', @@ -1722,76 +1535,61 @@ describe('head execution', () => { parentFailures: { 0: 'notFound', 1: 'redirect' } as ParentFailureMap, expectedErrorKind: 'redirect' as const, expectedErrorSource: undefined, - expectedLoaderMaxIndex: 2, - expectedRenderedHeadMaxIndex: -1, + expectedHeadTitles: ['Root'], }, { - name: 'propagates regular loader error when mixed with loader notFound in settled loaders', + name: 'keeps the first loader notFound when a later loader errors', throwAtIndex: 3 as const, parentFailures: { 1: 'notFound', 2: 'error' } as ParentFailureMap, - expectedErrorKind: 'error' as const, - expectedErrorSource: 'loader-2-error', - expectedLoaderMaxIndex: 1, - expectedRenderedHeadMaxIndex: -1, + expectedErrorKind: 'notFound' as const, + expectedErrorSource: 'loader-1', + expectedBoundaryIndex: 1, + expectedHeadTitles: ['Root', 'Level 1'], }, ] satisfies Array test.each(scenarios)('$name', async (scenario) => { - const { router, routes, loaders, heads } = setupScenario({ + const { router, routes } = setupScenario({ throwAtIndex: scenario.throwAtIndex, parentFailures: scenario.parentFailures, beforeLoadNotFoundFactory: scenario.beforeLoadNotFoundFactory, - withDefaultNotFoundComponent: scenario.withDefaultNotFoundComponent, }) - const { error, matches } = await runLoadMatchesAndCapture(router) - - 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) - } + await router.load() + const matches = router.state.matches if (scenario.expectedErrorKind === 'redirect') { - expect(error).toEqual( + expect(router.state.location.pathname).toBe('/redirect-target') + expect(matches.at(-1)).toEqual( expect.objectContaining({ - redirectHandled: true, - options: expect.objectContaining({ - to: '/redirect-target', - }), + pathname: '/redirect-target', + status: 'success', }), ) - return - } - - if (scenario.expectedErrorKind === 'error') { - expect(error).toBeInstanceOf(Error) - expect((error as Error).message).toBe(scenario.expectedErrorSource) - return - } - - expect(error).toEqual( - expect.objectContaining({ - isNotFound: true, - data: { source: scenario.expectedErrorSource }, - }), - ) - - if (scenario.expectedErrorRouteIndex !== undefined) { - expect((error as { routeId?: string }).routeId).toBe( - routes[scenario.expectedErrorRouteIndex]!.id, + } else { + const boundary = matches.at(-1)! + expect(boundary.routeId).toBe( + routes[scenario.expectedBoundaryIndex!]!.id, ) - } - if (scenario.expectRootNotFoundComponentAssigned) { - expect(routes[0].options.notFoundComponent).toBeTypeOf('function') + if (scenario.expectedBoundaryIndex === 0) { + expect(boundary.status).toBe('success') + expect(boundary.globalNotFound).toBe(true) + } else { + expect(boundary.status).toBe('notFound') + expect(boundary.error).toEqual( + expect.objectContaining({ + isNotFound: true, + routeId: boundary.routeId, + data: { source: scenario.expectedErrorSource }, + }), + ) + } } + + expect( + matches.map(getTitle).filter((title) => title !== undefined), + ).toEqual(scenario.expectedHeadTitles) }) test('sets globalNotFound on root match when beforeLoad notFound targets root boundary', async () => { @@ -1805,22 +1603,20 @@ describe('head execution', () => { }), }) - const { error, matches } = await runLoadMatchesAndCapture(router) + await router.load() + + const rootMatch = router.state.matches.find( + (m) => m.routeId === routes[0].id, + ) - expect(error).toEqual( + expect(rootMatch?.globalNotFound).toBe(true) + expect(rootMatch?.status).toBe('success') + expect(rootMatch?.error).toEqual( expect.objectContaining({ isNotFound: true, data: { source: 'beforeLoad-root-explicit' }, }), ) - - const rootMatch = router.stores.pendingMatches - .get() - .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 on subsequent successful load', async () => { @@ -1834,87 +1630,19 @@ describe('head execution', () => { }), }) - const first = await runLoadMatchesAndCapture(router) - expect(first.error).toEqual(expect.objectContaining({ isNotFound: true })) + await router.load() + expect(router.state.matches[0]?.globalNotFound).toBe(true) const throwingRoute = routes[3] throwingRoute.options.beforeLoad = undefined - const second = await runLoadMatchesAndCapture(router) - expect(second.error).toBeUndefined() - - const rootMatch = router.stores.pendingMatches - .get() - .find((m) => m.routeId === routes[0].id) - - expect(rootMatch?.globalNotFound).toBe(false) - }) - - 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: ['/test'] }), - }) - - 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, - })) - - 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) + await router.load() - const rootMatch = router.stores.pendingMatches - .get() - .find((m) => m.routeId === rootRoute.id) + const rootMatch = router.state.matches.find( + (m) => m.routeId === routes[0].id, + ) expect(rootMatch?.globalNotFound).toBe(false) - expect(rootMatch?.error).toBeUndefined() }) }) }) @@ -1975,7 +1703,6 @@ describe('params.parse notFound', () => { const match = router.state.matches.find((m) => m.routeId === testRoute.id) expect(match?.status).toBe('success') - expect(router.state.statusCode).toBe(200) }) }) diff --git a/packages/router-core/tests/preflight-reentrant-context.test.ts b/packages/router-core/tests/preflight-reentrant-context.test.ts new file mode 100644 index 0000000000..5577f356bf --- /dev/null +++ b/packages/router-core/tests/preflight-reentrant-context.test.ts @@ -0,0 +1,148 @@ +import { expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter } from './routerTestUtils' + +test('synchronous navigation from route context stops abandoned descendant context work', async () => { + let redirected = false + let redirectNavigation: Promise | undefined + const childContext = vi.fn(() => ({ child: true })) + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + context: ({ navigate }) => { + if (!redirected) { + redirected = true + redirectNavigation = navigate({ to: '/target' }) + } + return { parent: true } + }, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + context: childContext, + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([childRoute]), + targetRoute, + ]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + await router.navigate({ to: '/parent/child' }) + await redirectNavigation + + expect(router.state.location.pathname).toBe('/target') + expect(childContext).not.toHaveBeenCalled() +}) + +test('synchronous navigation from preloaded route context stops abandoned descendant context work', async () => { + let redirected = false + let redirectNavigation: Promise | undefined + const childContext = vi.fn(() => ({ child: true })) + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + context: ({ navigate, preload }) => { + if (preload && !redirected) { + redirected = true + redirectNavigation = navigate({ to: '/target' }) + } + return { parent: true } + }, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + context: childContext, + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([childRoute]), + targetRoute, + ]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + await router.preloadRoute({ to: '/parent/child' }) + await redirectNavigation + + expect(router.state.location.pathname).toBe('/target') + expect(childContext).not.toHaveBeenCalled() +}) + +test('navigation from loaderDeps toJSON stops later abandoned callbacks', async () => { + let router!: ReturnType + let redirected = false + let redirectNavigation: Promise | undefined + const childContext = vi.fn(() => ({ child: true })) + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loaderDeps: () => ({ + toJSON: () => { + if (!redirected) { + redirected = true + redirectNavigation = router.navigate({ to: '/target' }) + } + return { key: 'parent' } + }, + }), + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + context: childContext, + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + }) + router = createTestRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([childRoute]), + targetRoute, + ]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + await router.navigate({ to: '/parent/child' }) + await redirectNavigation + + expect(router.state.location.pathname).toBe('/target') + expect(childContext).not.toHaveBeenCalled() +}) diff --git a/packages/router-core/tests/preload-adoption.test.ts b/packages/router-core/tests/preload-adoption.test.ts new file mode 100644 index 0000000000..809e04e529 --- /dev/null +++ b/packages/router-core/tests/preload-adoption.test.ts @@ -0,0 +1,373 @@ +import { afterEach, describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute, createControlledPromise } from '../src' +import { createTestRouter } from './routerTestUtils' + +afterEach(() => { + vi.useRealTimers() +}) + +/** + * Preload adoption edge cases. The happy path (navigation adopts an + * in-flight preload's successful loader run) and the control-flow + * non-leakage path are pinned in load.test.ts; this file pins the + * adoption boundary: a donor must have its loader genuinely in flight. A + * preload still in its serial phase can itself be waiting on the navigation + * through the borrow protocol, while a stale successful donor can still have + * fresh loader work pending behind its cached snapshot. + */ + +describe('preload adoption', () => { + test('navigation waits for fresh data from an in-flight stale preload revalidation', async () => { + vi.useFakeTimers() + vi.setSystemTime(0) + + const revalidationGate = createControlledPromise<{ + notifications: Array + }>() + let loaderCalls = 0 + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const notificationsRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/notifications', + staleTime: 0, + preloadStaleTime: 0, + gcTime: 60_000, + loader: { + staleReloadMode: 'blocking', + handler: () => { + loaderCalls++ + if (loaderCalls === 1) { + return { notifications: ['old'] } + } + + return revalidationGate + }, + }, + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, notificationsRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + await router.preloadRoute({ to: '/notifications' } as any) + + expect(loaderCalls).toBe(1) + + // The cached successful preload is now stale. A second hover starts a + // genuine revalidation while the user clicks the link. + await vi.advanceTimersByTimeAsync(1) + const revalidation = router.preloadRoute({ + to: '/notifications', + } as any) + await vi.waitFor(() => expect(loaderCalls).toBe(2)) + + let navigationSettled = false + const navigation = router.navigate({ to: '/notifications' }).then(() => { + navigationSettled = true + }) + await Promise.resolve() + + // The navigation may share the revalidation, but it must not treat the + // stale cached snapshot as the completed result while fresh work runs. + expect(navigationSettled).toBe(false) + + revalidationGate.resolve({ notifications: ['fresh'] }) + await Promise.all([revalidation, navigation]) + + // A fix may either share the fresh revalidation or run a navigation + // loader of its own; correctness only requires publishing fresh data. + expect(loaderCalls).toBeGreaterThanOrEqual(2) + expect( + router.state.matches.find( + (match) => match.routeId === notificationsRoute.id, + )?.loaderData, + ).toEqual({ notifications: ['fresh'] }) + }) + + test('navigating during the preload serial phase does not deadlock (adoption declined)', async () => { + const beforeLoadGate = createControlledPromise() + let beforeLoadCalls = 0 + const loader = vi.fn(() => 'data') + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + beforeLoad: async () => { + beforeLoadCalls++ + if (beforeLoadCalls === 1) { + // Keep the PRELOAD's serial phase in flight; the navigation's + // own beforeLoad (call 2) proceeds immediately. + await beforeLoadGate + } + }, + loader, + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, fooRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + + // Preload is stuck in its serial phase — its loader has NOT started. + const preload = router.preloadRoute({ to: '/foo' } as any) + await vi.waitFor(() => expect(beforeLoadCalls).toBe(1)) + + // The navigation must not join a donor whose loader is not in flight; + // it runs its own loader and completes without waiting on the preload. + await router.navigate({ to: '/foo' }) + expect(loader).toHaveBeenCalledTimes(1) + expect( + router.state.matches.find((match) => match.routeId === fooRoute.id) + ?.status, + ).toBe('success') + + beforeLoadGate.resolve() + await preload + }) + + test("a sibling preload adopts another preload lane's in-flight loader", async () => { + const loaderGate = createControlledPromise() + const loader = vi.fn(() => loaderGate) + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + preloadStaleTime: Infinity, + loader, + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, fooRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + + const first = router.preloadRoute({ to: '/foo' } as any) + await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(1)) + + const second = router.preloadRoute({ to: '/foo' } as any) + loaderGate.resolve('once') + await Promise.all([first, second]) + + await router.navigate({ to: '/foo' }) + + expect(loader).toHaveBeenCalledTimes(1) + expect( + router.state.matches.find((match) => match.routeId === fooRoute.id) + ?.loaderData, + ).toBe('once') + }) + + test('a fulfilled undefined loader result is adopted as success', async () => { + const loaderGate = createControlledPromise() + const loader = vi.fn(() => loaderGate) + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loader, + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, fooRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + + const preload = router.preloadRoute({ to: '/foo' } as any) + await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(1)) + const navigation = router.navigate({ to: '/foo' }) + + loaderGate.resolve(undefined) + await Promise.all([preload, navigation]) + + expect(loader).toHaveBeenCalledTimes(1) + const match = router.state.matches.find( + (candidate) => candidate.routeId === fooRoute.id, + ) + expect(match?.status).toBe('success') + expect(match?.loaderData).toBeUndefined() + }) + + test('a non-joinable earlier lane does not hide a later lane with its loader in flight', async () => { + const beforeLoadGate = createControlledPromise() + let beforeLoadCalls = 0 + const loaderGate = createControlledPromise() + const loader = vi.fn(() => loaderGate) + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + beforeLoad: async () => { + beforeLoadCalls++ + if (beforeLoadCalls === 1) { + // Keep the FIRST preload's serial phase in flight so its lane + // holds a non-joinable donor; later calls proceed immediately. + await beforeLoadGate + } + }, + loader, + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, fooRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + + // First lane: registered first, stuck in its serial phase. + const first = router.preloadRoute({ to: '/foo' } as any) + await vi.waitFor(() => expect(beforeLoadCalls).toBe(1)) + + // Second lane: its loader is in flight and therefore joinable. + const second = router.preloadRoute({ to: '/foo' } as any) + await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(1)) + + // The navigation must scan PAST the first (serial-phase) lane and adopt + // the second lane's in-flight loader run instead of re-running it. + const navigation = router.navigate({ to: '/foo' }) + await vi.waitFor(() => expect(beforeLoadCalls).toBe(3)) + loaderGate.resolve('once') + await Promise.all([navigation, second]) + + expect(loader).toHaveBeenCalledTimes(1) + expect( + router.state.matches.find((match) => match.routeId === fooRoute.id) + ?.loaderData, + ).toBe('once') + + beforeLoadGate.resolve() + await first + }) + + test('navigation retries a failed joined preload without starving sibling work', async () => { + let parentLoads = 0 + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: async ({ preload }) => { + parentLoads++ + if (preload) { + await Promise.resolve() + throw new Error('preload failed') + } + return 'navigation data' + }, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: async () => { + await new Promise((resolve) => setTimeout(resolve, 10)) + return 'child data' + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + const preload = router.preloadRoute({ to: '/parent/child' } as any) + await vi.waitFor(() => expect(parentLoads).toBe(1)) + + await router.navigate({ to: '/parent/child' }) + await preload + + expect(parentLoads).toBe(2) + expect(router.state.location.pathname).toBe('/parent/child') + expect( + router.state.matches.find((match) => match.routeId === parentRoute.id) + ?.loaderData, + ).toBe('navigation data') + expect( + router.state.matches.find((match) => match.routeId === childRoute.id) + ?.loaderData, + ).toBe('child data') + }) + + test('a route with preload disabled does not discard its preloaded ancestor', async () => { + const parentLoader = vi.fn(() => 'parent data') + const childLoader = vi.fn(() => 'child data') + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + preloadStaleTime: Infinity, + loader: parentLoader, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + preload: false, + loader: childLoader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + await router.preloadRoute({ to: '/parent/child' } as any) + await router.navigate({ to: '/parent/child' }) + + expect(parentLoader).toHaveBeenCalledTimes(1) + expect(childLoader).toHaveBeenCalledTimes(1) + expect( + router.state.matches.find((match) => match.routeId === parentRoute.id) + ?.loaderData, + ).toBe('parent data') + expect( + router.state.matches.find((match) => match.routeId === childRoute.id) + ?.loaderData, + ).toBe('child data') + }) +}) diff --git a/packages/router-core/tests/preload-background-parent-coherence.test.ts b/packages/router-core/tests/preload-background-parent-coherence.test.ts new file mode 100644 index 0000000000..1293d33653 --- /dev/null +++ b/packages/router-core/tests/preload-background-parent-coherence.test.ts @@ -0,0 +1,87 @@ +import { expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute, createControlledPromise } from '../src' +import { createTestRouter } from './routerTestUtils' + +/** + * A preload that borrows an active parent while that parent is revalidating in + * the background must derive descendant data from the revalidated generation. + * Otherwise the preload can cache a child snapshot based on stale parent data, + * then combine it with the freshly committed parent on navigation. + */ +test('child preload stays coherent with an overlapping parent background reload', async () => { + const backgroundResponse = createControlledPromise<{ revision: number }>() + let parentLoadCount = 0 + + const parentLoader = vi.fn(() => { + parentLoadCount++ + return parentLoadCount === 1 ? { revision: 1 } : backgroundResponse + }) + const childLoader = vi.fn(async ({ parentMatchPromise }) => { + const parentMatch = await parentMatchPromise + return { + parentRevision: (parentMatch.loaderData as { revision: number }).revision, + } + }) + + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: parentLoader, + staleTime: Infinity, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: childLoader, + staleTime: Infinity, + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory({ initialEntries: ['/parent'] }), + }) + + await router.load() + expect(parentLoader).toHaveBeenCalledTimes(1) + + // Invalidating a successful loader uses the default background revalidation + // mode. The public invalidate promise settles once that private reload has + // started; its network response can remain pending. + await router.invalidate({ + filter: (match) => match.routeId === parentRoute.id, + }) + expect(parentLoader).toHaveBeenCalledTimes(2) + + const childPreload = router.preloadRoute({ to: '/parent/child' }) + + // Let the preload reach the borrowed parent while revision 2 is still in + // flight. This does not require the child to have started: a correct join is + // allowed to wait for the parent response. + await new Promise((resolve) => setTimeout(resolve, 0)) + + backgroundResponse.resolve({ revision: 2 }) + await childPreload + + await vi.waitFor(() => { + const parentMatch = router.state.matches.find( + (match) => match.routeId === parentRoute.id, + ) + expect(parentMatch?.loaderData).toEqual({ revision: 2 }) + }) + + await router.navigate({ to: '/parent/child' }) + + const parentMatch = router.state.matches.find( + (match) => match.routeId === parentRoute.id, + ) + const childMatch = router.state.matches.find( + (match) => match.routeId === childRoute.id, + ) + + expect(parentLoader).toHaveBeenCalledTimes(2) + expect(childLoader).toHaveBeenCalledTimes(1) + expect(parentMatch?.loaderData).toEqual({ revision: 2 }) + expect(childMatch?.loaderData).toEqual({ parentRevision: 2 }) +}) diff --git a/packages/router-core/tests/preload-beforeload-reuse.test.ts b/packages/router-core/tests/preload-beforeload-reuse.test.ts new file mode 100644 index 0000000000..916fd23ee4 --- /dev/null +++ b/packages/router-core/tests/preload-beforeload-reuse.test.ts @@ -0,0 +1,607 @@ +import { afterEach, describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute, createControlledPromise } from '../src' +import { createTestRouter } from './routerTestUtils' + +afterEach(() => { + vi.useRealTimers() +}) + +describe('preloaded beforeLoad context reuse', () => { + test('reuses fresh nested context for navigation and child loaders', async () => { + const parentBeforeLoad = vi.fn(({ preload }: { preload: boolean }) => ({ + parent: preload ? 'preloaded parent' : 'loaded parent', + })) + const childBeforeLoad = vi.fn( + ({ + context, + preload, + }: { + context: { parent: string } + preload: boolean + }) => ({ + child: `${context.parent}:${preload}`, + }), + ) + const childLoader = vi.fn( + ({ + context, + preload, + }: { + context: { parent: string; child: string } + preload: boolean + }) => ({ context, preload }), + ) + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + staleTime: Infinity, + beforeLoad: parentBeforeLoad, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + staleTime: Infinity, + beforeLoad: childBeforeLoad, + loader: childLoader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + await router.preloadRoute({ to: '/parent/child' }) + await router.navigate({ to: '/parent/child' }) + + expect(parentBeforeLoad).toHaveBeenCalledTimes(1) + expect(parentBeforeLoad).toHaveBeenCalledWith( + expect.objectContaining({ preload: true }), + ) + expect(childBeforeLoad).toHaveBeenCalledTimes(1) + expect(childBeforeLoad).toHaveBeenCalledWith( + expect.objectContaining({ + context: expect.objectContaining({ parent: 'preloaded parent' }), + preload: true, + }), + ) + expect(childLoader).toHaveBeenCalledTimes(1) + const match = router.state.matches.find( + (candidate) => candidate.routeId === childRoute.id, + ) + expect(match?.context).toEqual({ + parent: 'preloaded parent', + child: 'preloaded parent:true', + }) + expect(match?.loaderData).toEqual({ + context: { + parent: 'preloaded parent', + child: 'preloaded parent:true', + }, + preload: true, + }) + }) + + test('keeps beforeLoad independent while joining a pending preload loader', async () => { + const loaderGate = createControlledPromise() + const beforeLoad = vi.fn(({ preload }: { preload: boolean }) => ({ + guard: preload ? 'preloaded' : 'loaded', + })) + const loader = vi.fn(() => loaderGate) + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const guardedRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/guarded', + preloadStaleTime: Infinity, + beforeLoad, + loader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, guardedRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + const preload = router.preloadRoute({ to: '/guarded' }) + await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(1)) + + const navigation = router.navigate({ to: '/guarded' }) + await vi.waitFor(() => expect(beforeLoad).toHaveBeenCalledTimes(2)) + expect(beforeLoad.mock.calls.map(([context]) => context.preload)).toEqual([ + true, + false, + ]) + expect(loader).toHaveBeenCalledTimes(1) + + loaderGate.resolve('shared loader data') + await Promise.all([preload, navigation]) + + expect(router.state.matches.at(-1)?.context).toEqual({ guard: 'loaded' }) + expect(router.state.matches.at(-1)?.loaderData).toBe('shared loader data') + }) + + test.each([ + { age: 50, expected: [false, true], guard: 'preloaded' }, + { age: 100, expected: [false, true, false], guard: 'loaded' }, + ])( + 'uses the beforeLoad completion time when loader data remains navigation-owned at age $age', + async ({ age, expected, guard }) => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + const beforeLoad = vi.fn(({ preload }: { preload: boolean }) => ({ + guard: preload ? 'preloaded' : 'loaded', + })) + const loader = vi.fn(({ preload }: { preload: boolean }) => preload) + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const guardedRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/guarded', + staleTime: Infinity, + preloadStaleTime: 100, + beforeLoad, + shouldReload: false, + loader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, guardedRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + await router.navigate({ to: '/guarded' }) + await router.navigate({ to: '/' }) + vi.setSystemTime(5_000) + await router.preloadRoute({ to: '/guarded' }) + vi.setSystemTime(5_000 + age) + await router.navigate({ to: '/guarded' }) + + expect(beforeLoad.mock.calls.map(([context]) => context.preload)).toEqual( + expected, + ) + expect(loader.mock.calls.map(([context]) => context.preload)).toEqual([ + false, + ]) + expect(router.state.matches.at(-1)?.context).toEqual({ guard }) + }, + ) + + test('keeps a fresh beforeLoad-only preload across an unrelated navigation', async () => { + const beforeLoad = vi.fn(({ preload }: { preload: boolean }) => ({ + guard: preload ? 'preloaded' : 'loaded', + })) + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const otherRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/other', + }) + const guardedRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/guarded', + preloadStaleTime: Infinity, + preloadGcTime: Infinity, + beforeLoad, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, otherRoute, guardedRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + await router.preloadRoute({ to: '/guarded' }) + await router.navigate({ to: '/other' }) + await router.navigate({ to: '/guarded' }) + + expect(beforeLoad.mock.calls.map(([context]) => context.preload)).toEqual([ + true, + ]) + expect(router.state.matches.at(-1)?.context).toEqual({ + guard: 'preloaded', + }) + }) + + test('does not reuse child context under a different parent match', async () => { + const childBeforeLoad = vi.fn( + ({ + context, + preload, + }: { + context: { version: number } + preload: boolean + }) => ({ + childVersion: context.version, + }), + ) + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loaderDeps: ({ search }: { search: Record }) => ({ + version: Number(search.version), + }), + context: ({ deps }: { deps: { version: number } }) => ({ + version: deps.version, + }), + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + preloadStaleTime: Infinity, + beforeLoad: childBeforeLoad, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + await router.preloadRoute({ + to: '/parent/child', + search: { version: 1 }, + } as any) + await router.navigate({ + to: '/parent/child', + search: { version: 2 }, + } as any) + + expect( + childBeforeLoad.mock.calls.map(([context]) => context.preload), + ).toEqual([true, false]) + expect(router.state.matches.at(-1)?.context).toEqual({ + version: 2, + childVersion: 2, + }) + }) + + test('does not publish context from a beforeLoad replaced during preload', async () => { + const oldBeforeLoadGate = createControlledPromise() + const oldBeforeLoad = vi.fn(async () => { + await oldBeforeLoadGate + return { version: 'old' } + }) + const newBeforeLoad = vi.fn(() => ({ version: 'new' })) + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const guardedRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/guarded', + preloadStaleTime: Infinity, + beforeLoad: oldBeforeLoad, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, guardedRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + const preload = router.preloadRoute({ to: '/guarded' }) + await vi.waitFor(() => expect(oldBeforeLoad).toHaveBeenCalledTimes(1)) + + guardedRoute.options.beforeLoad = newBeforeLoad as any + await router._refreshRoute?.(guardedRoute.id) + oldBeforeLoadGate.resolve() + await preload + await router.navigate({ to: '/guarded' }) + + expect(oldBeforeLoad).toHaveBeenCalledTimes(1) + expect(newBeforeLoad).toHaveBeenCalledTimes(1) + expect(router.state.matches.at(-1)?.context).toEqual({ version: 'new' }) + }) + + test('does not publish descendants of a beforeLoad replaced during preload', async () => { + const oldBeforeLoadGate = createControlledPromise() + const oldBeforeLoad = vi.fn(async () => { + await oldBeforeLoadGate + return { version: 'old' } + }) + const newBeforeLoad = vi.fn(() => ({ version: 'new' })) + const childLoader = vi.fn( + ({ context }: { context: { version: string } }) => context.version, + ) + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + preloadStaleTime: Infinity, + beforeLoad: oldBeforeLoad, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + staleTime: Infinity, + preloadStaleTime: Infinity, + loader: childLoader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + const preload = router.preloadRoute({ to: '/parent/child' }) + await vi.waitFor(() => expect(oldBeforeLoad).toHaveBeenCalledTimes(1)) + + parentRoute.options.beforeLoad = newBeforeLoad as any + await router._refreshRoute?.(parentRoute.id) + oldBeforeLoadGate.resolve() + await preload + await router.navigate({ to: '/parent/child' }) + + expect(oldBeforeLoad).toHaveBeenCalledTimes(1) + expect(newBeforeLoad).toHaveBeenCalledTimes(1) + expect(childLoader).toHaveBeenCalledTimes(2) + expect(router.state.matches.at(-1)?.loaderData).toBe('new') + }) + + test('reruns beforeLoad when the completed preload reaches its stale boundary', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + const seen: Array = [] + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const guardedRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/guarded', + staleTime: Infinity, + preloadStaleTime: 100, + beforeLoad: ({ preload }) => { + seen.push(preload) + return { generation: preload ? 'preload' : 'navigation' } + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, guardedRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + await router.preloadRoute({ to: '/guarded' }) + vi.setSystemTime(1_100) + await router.navigate({ to: '/guarded' }) + + expect(seen).toEqual([true, false]) + expect(router.state.matches.at(-1)?.context).toEqual({ + generation: 'navigation', + }) + }) + + test('reruns descendant context after an ancestor becomes stale', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + const parentSeen: Array = [] + const childSeen: Array = [] + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + staleTime: Infinity, + preloadStaleTime: 100, + beforeLoad: ({ preload }) => { + parentSeen.push(preload) + return { parent: preload ? 'preloaded parent' : 'loaded parent' } + }, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + staleTime: Infinity, + preloadStaleTime: 1_000, + beforeLoad: ({ context, preload }) => { + childSeen.push(preload) + return { child: `${context.parent}:${preload}` } + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + await router.preloadRoute({ to: '/parent/child' }) + vi.setSystemTime(1_100) + await router.navigate({ to: '/parent/child' }) + + expect(parentSeen).toEqual([true, false]) + expect(childSeen).toEqual([true, false]) + expect(router.state.matches.at(-1)?.context).toEqual({ + parent: 'loaded parent', + child: 'loaded parent:false', + }) + }) + + test('invalidation prevents reuse of completed preload context', async () => { + const seen: Array = [] + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const guardedRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/guarded', + staleTime: Infinity, + beforeLoad: ({ preload }) => { + seen.push(preload) + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, guardedRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + await router.preloadRoute({ to: '/guarded' }) + await router.invalidate({ + filter: (match) => match.routeId === guardedRoute.id, + }) + await router.navigate({ to: '/guarded' }) + + expect(seen).toEqual([true, false]) + }) + + test('preload false leaves beforeLoad context unresolved for navigation', async () => { + const seen: Array = [] + const loader = vi.fn() + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const guardedRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/guarded', + preload: false, + staleTime: Infinity, + beforeLoad: ({ preload }) => { + seen.push(preload) + }, + loader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, guardedRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + await router.preloadRoute({ to: '/guarded' }) + await router.navigate({ to: '/guarded' }) + + expect(seen).toEqual([true, false]) + expect(loader).toHaveBeenCalledTimes(1) + }) + + test('shouldReload remains scoped to loader data', async () => { + const beforeLoad = vi.fn(({ preload }: { preload: boolean }) => ({ + guard: preload ? 'preloaded' : 'loaded', + })) + const loader = vi.fn(({ preload }: { preload: boolean }) => preload) + const shouldReload = vi.fn(() => true) + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const guardedRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/guarded', + staleTime: Infinity, + beforeLoad, + shouldReload, + loader: { + staleReloadMode: 'blocking', + handler: loader, + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, guardedRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + await router.preloadRoute({ to: '/guarded' }) + await router.navigate({ to: '/guarded' }) + + expect(beforeLoad).toHaveBeenCalledTimes(1) + expect(loader).toHaveBeenCalledTimes(2) + expect(loader.mock.calls.map(([context]) => context.preload)).toEqual([ + true, + false, + ]) + expect(shouldReload).toHaveBeenCalledTimes(1) + expect(shouldReload).toHaveBeenCalledWith( + expect.objectContaining({ + context: expect.objectContaining({ guard: 'preloaded' }), + preload: false, + }), + ) + }) + + test('shouldReload false does not keep stale beforeLoad context', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + const beforeLoad = vi.fn(({ preload }: { preload: boolean }) => ({ + guard: preload ? 'preloaded' : 'loaded', + })) + const loader = vi.fn(({ preload }: { preload: boolean }) => preload) + const shouldReload = vi.fn(() => false) + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const guardedRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/guarded', + staleTime: Infinity, + preloadStaleTime: 100, + beforeLoad, + shouldReload, + loader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, guardedRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + await router.preloadRoute({ to: '/guarded' }) + vi.setSystemTime(1_100) + await router.navigate({ to: '/guarded' }) + + expect(beforeLoad.mock.calls.map(([context]) => context.preload)).toEqual([ + true, + false, + ]) + expect(loader.mock.calls.map(([context]) => context.preload)).toEqual([ + true, + ]) + expect(shouldReload).toHaveBeenCalledTimes(1) + expect(router.state.matches.at(-1)?.context).toEqual({ guard: 'loaded' }) + }) +}) diff --git a/packages/router-core/tests/preload-navigation-adoption.test.ts b/packages/router-core/tests/preload-navigation-adoption.test.ts new file mode 100644 index 0000000000..ab18f703df --- /dev/null +++ b/packages/router-core/tests/preload-navigation-adoption.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute, createControlledPromise } from '../src' +import { createTestRouter } from './routerTestUtils' + +// A navigation that adopts an in-flight preload's loader run must reuse that +// run: it must not abort the preload's loader signal or rerun the loader. This +// is generic router adoption coverage; issue #4476 is covered separately with +// the React Query observer-unmount sequence from its reproduction. +describe('navigation adopting an in-flight preload', () => { + test('adopted preload loader runs once and its signal is not aborted', async () => { + const loaderGate = createControlledPromise() + let capturedSignal: AbortSignal | undefined + const loader = vi.fn( + ({ abortController }: { abortController: AbortController }) => { + capturedSignal = abortController.signal + return loaderGate + }, + ) + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loader, + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, fooRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + + // Start the preload and wait until its loader is actually in flight. + const preload = router.preloadRoute({ to: '/foo' } as any) + await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(1)) + expect(capturedSignal).toBeDefined() + expect(capturedSignal?.aborted).toBe(false) + + // Navigate while the preload's loader is still pending — the navigation + // must adopt the in-flight run rather than aborting and re-issuing it. + const navigation = router.navigate({ to: '/foo' }) + + // Resolve the shared loader and let both settle. + loaderGate.resolve('adopted') + await Promise.all([navigation, preload]) + + // Loader ran exactly once; the adopted run's signal was never aborted. + expect(loader).toHaveBeenCalledTimes(1) + expect(capturedSignal?.aborted).toBe(false) + + // Navigation committed with the adopted loaderData. + const committed = router.state.matches.find( + (match) => match.routeId === fooRoute.id, + ) + expect(committed?.status).toBe('success') + expect(committed?.loaderData).toBe('adopted') + + // Adoption transfers loader-data lifetime ownership to the active match. + // The shared request remains alive while rendered, then aborts on unload. + await router.navigate({ to: '/' }) + expect(capturedSignal?.aborted).toBe(true) + }) +}) diff --git a/packages/router-core/tests/preload-public-cache-behavior.test.ts b/packages/router-core/tests/preload-public-cache-behavior.test.ts new file mode 100644 index 0000000000..15499f6a70 --- /dev/null +++ b/packages/router-core/tests/preload-public-cache-behavior.test.ts @@ -0,0 +1,206 @@ +import { afterEach, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter } from './routerTestUtils' + +afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() +}) + +function deferred() { + let resolve!: (value?: T | PromiseLike) => void + const promise = new Promise((done) => { + resolve = (value) => done(value as T) + }) + return { promise, resolve } +} + +// Public contract: preloading a descendant must reuse an already accepted +// ancestor generation instead of executing the active layout loader again. +test('preloading children borrows the active ancestor without rerunning it', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + + const layoutLoader = vi.fn(() => 'layout data') + const childLoader = vi.fn(({ params }: any) => `child ${params.childId}`) + const rootRoute = new BaseRootRoute({}) + const layoutRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/layout', + staleTime: 10, + loader: layoutLoader, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => layoutRoute, + path: '$childId', + loader: childLoader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([layoutRoute.addChildren([childRoute])]), + history: createMemoryHistory({ initialEntries: ['/layout'] }), + }) + + await router.load() + vi.setSystemTime(2_000) + + await router.preloadRoute({ + to: '/layout/$childId', + params: { childId: 'one' }, + } as any) + await router.preloadRoute({ + to: '/layout/$childId', + params: { childId: 'two' }, + } as any) + await router.preloadRoute({ + to: '/layout/$childId', + params: { childId: 'one' }, + } as any) + + expect(layoutLoader).toHaveBeenCalledTimes(1) + expect(childLoader).toHaveBeenCalledTimes(2) + + await router.navigate({ + to: '/layout/$childId', + params: { childId: 'one' }, + } as any) + expect(layoutLoader).toHaveBeenCalledTimes(1) + // No preloadStaleTime was configured, so navigation follows the existing + // staleTime policy and reloads the leaf while still borrowing the layout. + expect(childLoader).toHaveBeenCalledTimes(3) + expect(router.state.matches.at(-1)?.loaderData).toBe('child one') +}) + +// Public contract: the public stale/gc options determine whether user loader +// code runs. A read-only preload must not silently shorten a navigation-owned +// cache entry's lifetime. +test('fresh navigation data keeps its gc policy when a preload reuses it', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + + let revision = 0 + const loader = vi.fn(() => ++revision) + const rootRoute = new BaseRootRoute({}) + const homeRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const reportsRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/reports', + gcTime: 30 * 60_000, + preloadGcTime: 30_000, + preloadStaleTime: 10 * 60_000, + staleTime: Infinity, + loader, + }) + const otherRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/other', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([homeRoute, reportsRoute, otherRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + await router.navigate({ to: '/reports' }) + await router.navigate({ to: '/' }) + expect(loader).toHaveBeenCalledTimes(1) + + vi.setSystemTime(61_000) + await router.preloadRoute({ to: '/reports' }) + await router.navigate({ to: '/other' }) + await router.navigate({ to: '/reports' }) + expect(loader).toHaveBeenCalledTimes(1) + expect(router.state.matches.at(-1)?.loaderData).toBe(1) + + await router.navigate({ to: '/' }) + vi.setSystemTime(1_000 + 31 * 60_000) + await router.navigate({ to: '/other' }) + await router.navigate({ to: '/reports' }) + expect(loader).toHaveBeenCalledTimes(2) + expect(router.state.matches.at(-1)?.loaderData).toBe(2) +}) + +// Public contract: clearCache must remain authoritative even if an older +// preload finishes unrelated asset work after the clear. +test('clearCache during an in-flight preload cannot resurrect its borrowed data', async () => { + const headGate = deferred<{ meta: Array<{ title: string }> }>() + let revision = 0 + let headCalls = 0 + const loader = vi.fn(() => ++revision) + const rootRoute = new BaseRootRoute({}) + const homeRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const reportsRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/reports', + staleTime: Infinity, + preloadStaleTime: Infinity, + loader, + head: () => { + headCalls++ + return headCalls === 1 + ? { meta: [{ title: 'Reports' }] } + : headGate.promise + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([homeRoute, reportsRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + await router.navigate({ to: '/reports' }) + await router.navigate({ to: '/' }) + expect(loader).toHaveBeenCalledTimes(1) + + const preload = router.preloadRoute({ to: '/reports' }) + await vi.waitFor(() => expect(headCalls).toBe(2)) + router.clearCache() + headGate.resolve({ meta: [{ title: 'Preloaded Reports' }] }) + await preload + + await router.navigate({ to: '/reports' }) + expect(loader).toHaveBeenCalledTimes(2) + expect(router.state.matches.at(-1)?.loaderData).toBe(2) +}) + +// Probe, not a required cancellation policy: invalidating the accepted route +// need not cancel unrelated private preload work, but the public operations +// must both settle and leave navigation usable. +test('invalidate and an unrelated in-flight preload both settle cleanly', async () => { + const loaderGate = deferred() + const loader = vi.fn(() => loaderGate.promise) + const rootRoute = new BaseRootRoute({}) + const homeRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + preloadStaleTime: Infinity, + staleTime: Infinity, + loader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([homeRoute, targetRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + const preload = router.preloadRoute({ to: '/target' }) + await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(1)) + await router.invalidate() + loaderGate.resolve('target data') + await preload + + await router.navigate({ to: '/target' }) + expect(router.state.location.pathname).toBe('/target') + expect(router.state.matches.at(-1)?.loaderData).toBe('target data') + expect(loader).toHaveBeenCalledTimes(1) +}) diff --git a/packages/router-core/tests/preload-public-signal-lifetime.test.ts b/packages/router-core/tests/preload-public-signal-lifetime.test.ts new file mode 100644 index 0000000000..f3678963c3 --- /dev/null +++ b/packages/router-core/tests/preload-public-signal-lifetime.test.ts @@ -0,0 +1,121 @@ +import { expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter } from './routerTestUtils' + +function deferred() { + let resolve!: (value?: T | PromiseLike) => void + const promise = new Promise((done) => { + resolve = (value) => done(value as T) + }) + return { promise, resolve } +} + +// Public contract: the AbortSignal supplied to loader code belongs to the +// accepted data generation. Adopting preloaded data keeps it alive; accepting +// replacement data aborts it; unloading aborts the replacement generation. +test('an adopted loader signal lives until public replacement and unload', async () => { + const replacementGate = deferred<{ generation: number }>() + const signals: Array = [] + const loader = vi.fn( + ({ abortController }: { abortController: AbortController }) => { + signals.push(abortController.signal) + const generation = signals.length + return generation === 1 ? { generation } : replacementGate.promise + }, + ) + + const rootRoute = new BaseRootRoute({}) + const homeRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const reportsRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/reports', + staleTime: Infinity, + preloadStaleTime: Infinity, + loader: { + staleReloadMode: 'blocking', + handler: loader, + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([homeRoute, reportsRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + await router.preloadRoute({ to: '/reports' }) + const adoptedSignal = signals[0] + expect(adoptedSignal?.aborted).toBe(false) + + await router.navigate({ to: '/reports' }) + expect(loader).toHaveBeenCalledTimes(1) + expect(router.state.matches.at(-1)?.loaderData).toEqual({ generation: 1 }) + expect(adoptedSignal?.aborted).toBe(false) + + const replacement = router.invalidate({ forcePending: true }) + await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(2)) + const replacementSignal = signals[1] + + // Until replacement data is accepted, the currently rendered data still + // owns the adopted signal. + expect(adoptedSignal?.aborted).toBe(false) + expect(replacementSignal?.aborted).toBe(false) + + replacementGate.resolve({ generation: 2 }) + await replacement + expect(router.state.matches.at(-1)?.loaderData).toEqual({ generation: 2 }) + expect(adoptedSignal?.aborted).toBe(true) + expect(replacementSignal?.aborted).toBe(false) + + await router.navigate({ to: '/' }) + expect(replacementSignal?.aborted).toBe(true) +}) + +test('a superseded preload releases its borrowed loader signal lease', async () => { + const signals: Array = [] + let navigation!: Promise + + const rootRoute = new BaseRootRoute({}) + const homeRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: ({ abortController }) => { + signals.push(abortController.signal) + return 'parent data' + }, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + beforeLoad: ({ navigate, preload }) => { + if (preload) { + navigation = navigate({ to: '/' }) + throw navigation + } + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + homeRoute, + parentRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/parent'] }), + }) + + await router.load() + const signal = signals[0] + expect(signal?.aborted).toBe(false) + + await router.preloadRoute({ to: '/parent/child' }) + await navigation + + expect(router.state.location.pathname).toBe('/') + expect(signal?.aborted).toBe(true) +}) diff --git a/packages/router-core/tests/routerTestUtils.ts b/packages/router-core/tests/routerTestUtils.ts index b71c54e347..8078068954 100644 --- a/packages/router-core/tests/routerTestUtils.ts +++ b/packages/router-core/tests/routerTestUtils.ts @@ -5,8 +5,10 @@ import { createNonReactiveMutableStore, createNonReactiveReadonlyStore, } from '../src' +import { createRequestHandler } from '../src/ssr/createRequestHandler' import type { RouterHistory } from '@tanstack/history' import type { + AnyRouter, AnyRoute, GetStoreConfig, RouterConstructorOptions, @@ -46,3 +48,20 @@ export function createTestRouter< ) { return new RouterCore(options, getStoreConfig) } + +/** Materialize the request-local server result as the HTTP response users see. */ +export function loadServerResponse(router: AnyRouter, path: string) { + return createRequestHandler({ + createRouter: () => router, + request: new Request(`http://localhost${path}`), + })(({ router: loadedRouter, responseHeaders }) => { + const result = loadedRouter._serverResult + return new Response(null, { + status: + result?.type === 'redirect' + ? result.redirect.status + : (result?.status ?? 500), + headers: responseHeaders, + }) + }) +} diff --git a/packages/router-core/tests/serial-failure-foreground-prefix.test.ts b/packages/router-core/tests/serial-failure-foreground-prefix.test.ts new file mode 100644 index 0000000000..6a5a5e2108 --- /dev/null +++ b/packages/router-core/tests/serial-failure-foreground-prefix.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter } from './routerTestUtils' + +/** + * When the serial beforeLoad phase records a failure, retained ancestor + * prefix loaders belong to the foreground failure lane: a stale ancestor + * must reload in the foreground commit rather than being deferred into a + * background batch that the boundary trim would discard. + */ + +describe('serial failure keeps ancestor reloads in the foreground lane', () => { + test('a stale ancestor reloads with fresh data when a child beforeLoad fails', async () => { + let parentRuns = 0 + let shouldFail = false + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + staleTime: 0, + loader: () => `parent run ${++parentRuns}`, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + errorComponent: {}, + beforeLoad: () => { + if (shouldFail) { + throw new Error('child beforeLoad failed') + } + }, + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + }) + + await router.load() + expect(parentRuns).toBe(1) + + await router.navigate({ to: '/' }) + shouldFail = true + await router.navigate({ to: '/parent/child' }) + + const parentMatch = router.state.matches.find( + (match) => match.routeId === parentRoute.id, + ) + const childMatch = router.state.matches.find( + (match) => match.routeId === childRoute.id, + ) + + // The child committed its serial failure... + expect(childMatch?.status).toBe('error') + // ...and the stale parent reloaded in the SAME foreground lane: fresh + // loaderData is committed with the failure, not deferred to a discarded + // background batch. + await vi.waitFor(() => { + expect(parentRuns).toBe(2) + expect( + router.state.matches.find((match) => match.routeId === parentRoute.id) + ?.loaderData, + ).toBe('parent run 2') + }) + expect(parentMatch?.status).toBe('success') + }) +}) diff --git a/packages/router-core/tests/server-async-headers-decorative-hang.test.ts b/packages/router-core/tests/server-async-headers-decorative-hang.test.ts new file mode 100644 index 0000000000..ea514a4e7a --- /dev/null +++ b/packages/router-core/tests/server-async-headers-decorative-hang.test.ts @@ -0,0 +1,37 @@ +import { expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter, loadServerResponse } from './routerTestUtils' + +test('a rejected projection hook is logged without failing the response', async () => { + const projectionError = new Error('scripts failed') + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + const head = vi.fn(() => ({ meta: [{ title: 'discarded' }] })) + const scripts = vi.fn(async () => { + throw projectionError + }) + const headers = vi.fn(() => ({ 'x-response-header': 'discarded' })) + + const rootRoute = new BaseRootRoute({}) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + head, + scripts, + headers, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([targetRoute]), + history: createMemoryHistory({ initialEntries: ['/target'] }), + isServer: true, + }) + + const response = await loadServerResponse(router, '/target') + + expect(response.status).toBe(200) + expect(response.headers.get('x-response-header')).toBeNull() + expect(head).toHaveBeenCalledTimes(1) + expect(scripts).toHaveBeenCalledTimes(1) + expect(headers).toHaveBeenCalledTimes(1) + expect(log).toHaveBeenCalledWith(projectionError) +}) diff --git a/packages/router-core/tests/server-beforeload-preload-flag.test.ts b/packages/router-core/tests/server-beforeload-preload-flag.test.ts new file mode 100644 index 0000000000..22509a86c7 --- /dev/null +++ b/packages/router-core/tests/server-beforeload-preload-flag.test.ts @@ -0,0 +1,25 @@ +import { createMemoryHistory } from '@tanstack/history' +import { expect, test } from 'vitest' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter } from './routerTestUtils' + +test('server beforeLoad receives preload false', async () => { + const seen: Array = [] + const rootRoute = new BaseRootRoute({}) + const childRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/child', + beforeLoad: ({ preload }) => { + seen.push(preload) + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([childRoute]), + history: createMemoryHistory({ initialEntries: ['/child'] }), + isServer: true, + }) + + await router.load() + + expect(seen).toEqual([false]) +}) diff --git a/packages/router-core/tests/server-chunk-failure-lifecycle.test.ts b/packages/router-core/tests/server-chunk-failure-lifecycle.test.ts new file mode 100644 index 0000000000..67b58e2239 --- /dev/null +++ b/packages/router-core/tests/server-chunk-failure-lifecycle.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, test } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute, notFound, redirect } from '../src' +import { createTestRouter, loadServerResponse } from './routerTestUtils' + +/** + * A primary route chunk failure observed by the server loader phase (a route + * component preload or lazyFn rejection) must go through the same route + * failure lifecycle as loader failures: the route's onError runs, + * redirect/notFound conversion applies, and the errorComponent boundary + * chunk is loaded. Only boundary component chunk failures commit directly + * without recursing into another boundary chunk. + * + * These tests use a real server router load with a route component whose + * preload rejects. + */ + +describe('server route chunk failure lifecycle', () => { + test('calls route onError once and loads the errorComponent chunk when the route component chunk rejects', async () => { + const chunkError = new Error('component chunk failed') + const capturedErrors: Array = [] + const events: Array = [] + + const FailingComponent = Object.assign(() => null, { + preload: () => Promise.reject(chunkError), + }) + const ErrorBoundary = Object.assign(() => null, { + preload: () => { + events.push('errorComponent-preload') + return Promise.resolve() + }, + }) + + const rootRoute = new BaseRootRoute({}) + const chunkedRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/chunked', + onError: (error: unknown) => { + capturedErrors.push(error) + events.push('onError') + }, + component: FailingComponent as any, + errorComponent: ErrorBoundary as any, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([chunkedRoute]), + history: createMemoryHistory({ initialEntries: ['/chunked'] }), + isServer: true, + }) + + const response = await loadServerResponse(router, '/chunked') + + const match = router.state.matches.find( + (item) => item.routeId === chunkedRoute.id, + ) + expect(capturedErrors).toEqual([chunkError]) + expect(match?.status).toBe('error') + expect(match?.error).toBe(chunkError) + // Failure finalization loads the errorComponent boundary chunk after + // onError ran. (The initial whole-route preload also requests it before + // the failure, hence lastIndexOf.) + expect(events.lastIndexOf('errorComponent-preload')).toBeGreaterThan( + events.indexOf('onError'), + ) + expect(response.status).toBe(500) + }) + + test('a notFound thrown from onError for a route chunk failure sets 404 instead of 500', async () => { + const chunkError = new Error('component chunk failed') + + const FailingComponent = Object.assign(() => null, { + preload: () => Promise.reject(chunkError), + }) + + const rootRoute = new BaseRootRoute({}) + const chunkedRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/chunked', + onError: () => { + throw notFound() + }, + component: FailingComponent as any, + notFoundComponent: () => null, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([chunkedRoute]), + history: createMemoryHistory({ initialEntries: ['/chunked'] }), + isServer: true, + }) + + const response = await loadServerResponse(router, '/chunked') + + const match = router.state.matches.find( + (item) => item.routeId === chunkedRoute.id, + ) + expect(response.status).toBe(404) + expect(match?.status).toBe('notFound') + expect(match?.error).toEqual(expect.objectContaining({ isNotFound: true })) + }) + + test('the first route-order chunk failure determines the response', async () => { + const rootError = new Error('root component failed') + const RootComponent = Object.assign(() => null, { + preload: () => Promise.reject(rootError), + }) + const ChildComponent = Object.assign(() => null, { + preload: () => Promise.reject(new Error('child component failed')), + }) + + const rootRoute = new BaseRootRoute({ + component: RootComponent as any, + errorComponent: () => null, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/child', + component: ChildComponent as any, + onError: () => { + throw redirect({ to: '/elsewhere' }) + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([childRoute]), + history: createMemoryHistory({ initialEntries: ['/child'] }), + isServer: true, + }) + + const response = await loadServerResponse(router, '/child') + + expect(response.status).toBe(500) + expect(router.state.matches).toHaveLength(1) + expect(router.state.matches[0]).toMatchObject({ + routeId: rootRoute.id, + status: 'error', + error: rootError, + }) + }) +}) diff --git a/packages/router-core/tests/server-concurrent-error-notfound.test.ts b/packages/router-core/tests/server-concurrent-error-notfound.test.ts new file mode 100644 index 0000000000..e5fa5428d0 --- /dev/null +++ b/packages/router-core/tests/server-concurrent-error-notfound.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, test } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute, notFound, redirect } from '../src' +import { createTestRouter, loadServerResponse } from './routerTestUtils' + +describe('server concurrent route failure ordering', () => { + test('commits the first failing route loader and aborts its descendants', async () => { + const parentError = new Error('parent loader failed') + let parentSignal: AbortSignal | undefined + let childSignal: AbortSignal | undefined + + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: ({ abortController }) => { + parentSignal = abortController.signal + throw parentError + }, + errorComponent: () => null, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: ({ abortController }) => { + childSignal = abortController.signal + throw notFound() + }, + notFoundComponent: () => null, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + isServer: true, + }) + + const response = await loadServerResponse(router, '/parent/child') + + expect(response.status).toBe(500) + expect(router.stores.matches.get().map((match) => match.routeId)).toEqual([ + rootRoute.id, + parentRoute.id, + ]) + expect(router.stores.matches.get()[1]).toMatchObject({ + status: 'error', + error: parentError, + }) + expect(parentSignal?.aborted).toBe(false) + expect(childSignal?.aborted).toBe(true) + }) + + test('does not replace an earlier loader notFound with a later error', async () => { + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: () => { + throw notFound() + }, + notFoundComponent: () => null, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: () => { + throw new Error('child loader failed') + }, + errorComponent: () => null, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + isServer: true, + }) + + const response = await loadServerResponse(router, '/parent/child') + + expect(response.status).toBe(404) + expect(router.stores.matches.get().map((match) => match.routeId)).toEqual([ + rootRoute.id, + parentRoute.id, + ]) + expect(router.stores.matches.get()[1]).toMatchObject({ + status: 'notFound', + }) + }) + + test('a redirect aborts every generation in its discarded server lane', async () => { + const signals: Array = [] + const rootRoute = new BaseRootRoute({ + loader: ({ abortController }) => { + signals.push(abortController.signal) + return 'root data' + }, + }) + const redirectRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/redirect', + loader: ({ abortController }) => { + signals.push(abortController.signal) + throw redirect({ to: '/target' }) + }, + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([redirectRoute, targetRoute]), + history: createMemoryHistory({ initialEntries: ['/redirect'] }), + isServer: true, + }) + + const response = await loadServerResponse(router, '/redirect') + + expect(response.status).toBe(307) + expect(response.headers.get('Location')).toBe('/target') + expect(signals).toHaveLength(2) + expect(signals.every((signal) => signal.aborted)).toBe(true) + }) +}) diff --git a/packages/router-core/tests/server-loader-abort-error.test.ts b/packages/router-core/tests/server-loader-abort-error.test.ts new file mode 100644 index 0000000000..b3772692ef --- /dev/null +++ b/packages/router-core/tests/server-loader-abort-error.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter, loadServerResponse } from './routerTestUtils' + +/** + * A server loader that throws an AbortError while the match's own signal is + * NOT aborted (e.g. the loader's own fetch timed out) is a route failure. + * Only a router-aborted generation may discard AbortError as cancellation. + */ + +describe('server loader AbortError', () => { + test('settles as an error when the match signal is not aborted', async () => { + const rootRoute = new BaseRootRoute({}) + const abortingRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/aborting', + loader: () => { + throw new DOMException('The operation was aborted.', 'AbortError') + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([abortingRoute]), + history: createMemoryHistory({ initialEntries: ['/aborting'] }), + isServer: true, + }) + + const response = await loadServerResponse(router, '/aborting') + + const match = router.state.matches.find( + (item) => item.routeId === abortingRoute.id, + ) + expect(response.status).toBe(500) + expect(match?.status).toBe('error') + expect(match?.error).toMatchObject({ + name: 'AbortError', + message: 'The operation was aborted.', + }) + }) +}) diff --git a/packages/router-core/tests/server-serial-ssr-notfound.test.ts b/packages/router-core/tests/server-serial-ssr-notfound.test.ts new file mode 100644 index 0000000000..346d4a36c1 --- /dev/null +++ b/packages/router-core/tests/server-serial-ssr-notfound.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute, notFound } from '../src' +import { createTestRouter, loadServerResponse } from './routerTestUtils' + +/** + * A notFound thrown from a route's ssr() option happens during the serial + * phase, before beforeLoad has run for that route and its descendants. It + * must cap the loader prefix like a beforeLoad notFound: the throwing route + * and its descendants must not run their loaders (their context is + * incomplete and their ssr status unresolved), and the response must be a + * 404 — a descendant loader failure must not be able to turn the outcome + * into a 200/500. + */ + +function setupRouter(childLoader: () => unknown) { + const loaderCalls: Array = [] + + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + notFoundComponent: () => null, + ssr: () => { + throw notFound() + }, + loader: () => { + loaderCalls.push('parent') + }, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: () => { + loaderCalls.push('child') + return childLoader() + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + isServer: true, + }) + + return { router, parentRoute, loaderCalls } +} + +describe('server serial ssr() notFound', () => { + test('caps the loader prefix at the boundary and sets 404', async () => { + const { router, parentRoute, loaderCalls } = setupRouter(() => 'child') + + const response = await loadServerResponse(router, '/parent/child') + + const parentMatch = router.state.matches.find( + (item) => item.routeId === parentRoute.id, + ) + expect(loaderCalls).toEqual([]) + expect(response.status).toBe(404) + expect(parentMatch?.status).toBe('notFound') + expect(parentMatch?.error).toEqual( + expect.objectContaining({ isNotFound: true }), + ) + // The lane is trimmed to the notFound boundary. + expect(router.state.matches.at(-1)?.routeId).toBe(parentRoute.id) + }) + + test('a descendant loader that would reject fatally cannot displace the 404', async () => { + const { router, loaderCalls } = setupRouter(() => { + throw new Error('child loader failed') + }) + + const response = await loadServerResponse(router, '/parent/child') + + expect(loaderCalls).toEqual([]) + expect(response.status).toBe(404) + }) +}) diff --git a/packages/router-core/tests/server-ssr-false-assets.test.ts b/packages/router-core/tests/server-ssr-false-assets.test.ts new file mode 100644 index 0000000000..fa33c36b49 --- /dev/null +++ b/packages/router-core/tests/server-ssr-false-assets.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter, loadServerResponse } from './routerTestUtils' + +/** + * `ssr: false` makes a route client-only. Its beforeLoad, loader, component, + * and route assets must all stay out of the server response. Descendants + * inherit that restriction even if they request `ssr: true` themselves. + */ +describe('server assets for ssr:false routes', () => { + test('does not execute or publish assets for the client-only branch', async () => { + const parentHead = vi.fn(() => ({ + meta: [{ title: 'client-only parent' }], + })) + const parentScripts = vi.fn(() => [ + { + children: 'window.parentAssetRan = true', + }, + ]) + const parentHeaders = vi.fn(() => ({ + 'x-client-only-parent': 'unexpected', + })) + const childHead = vi.fn(() => ({ + meta: [{ title: 'client-only child' }], + })) + const childScripts = vi.fn(() => [ + { + children: 'window.childAssetRan = true', + }, + ]) + const childHeaders = vi.fn(() => ({ + 'x-client-only-child': 'unexpected', + })) + + const rootRoute = new BaseRootRoute({}) + const clientOnlyRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/client-only', + ssr: false, + head: parentHead, + scripts: parentScripts, + headers: parentHeaders, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => clientOnlyRoute, + path: '/child', + // A child cannot relax its parent's client-only restriction. + ssr: true, + head: childHead, + scripts: childScripts, + headers: childHeaders, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + clientOnlyRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory({ + initialEntries: ['/client-only/child'], + }), + isServer: true, + }) + + const response = await loadServerResponse(router, '/client-only/child') + + expect({ + calls: { + parentHead: parentHead.mock.calls.length, + parentScripts: parentScripts.mock.calls.length, + parentHeaders: parentHeaders.mock.calls.length, + childHead: childHead.mock.calls.length, + childScripts: childScripts.mock.calls.length, + childHeaders: childHeaders.mock.calls.length, + }, + responseHeaders: { + parent: response.headers.get('x-client-only-parent'), + child: response.headers.get('x-client-only-child'), + }, + }).toEqual({ + calls: { + parentHead: 0, + parentScripts: 0, + parentHeaders: 0, + childHead: 0, + childScripts: 0, + childHeaders: 0, + }, + responseHeaders: { parent: null, child: null }, + }) + }) +}) diff --git a/packages/router-core/tests/server-ssr-option-error.test.ts b/packages/router-core/tests/server-ssr-option-error.test.ts new file mode 100644 index 0000000000..bbbc471281 --- /dev/null +++ b/packages/router-core/tests/server-ssr-option-error.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter, loadServerResponse } from './routerTestUtils' + +/** + * A functional ssr() option is user route code in the server serial phase. + * If it throws an ordinary error, the route must not be committed as a + * successful 200 response with missing loader data. It follows the same + * renderable route-error lifecycle as beforeLoad and loader failures. + */ +describe('server functional ssr() errors', () => { + test('commits the throwing route as an error and returns 500', async () => { + const boom = new Error('feature flag lookup failed') + const loader = vi.fn(() => 'reports data') + + const rootRoute = new BaseRootRoute({}) + const reportsRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/reports', + ssr: () => { + throw boom + }, + loader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([reportsRoute]), + history: createMemoryHistory({ initialEntries: ['/reports'] }), + isServer: true, + }) + + const response = await loadServerResponse(router, '/reports') + + const reportsMatch = router.state.matches.find( + (match) => match.routeId === reportsRoute.id, + ) + expect(loader).not.toHaveBeenCalled() + expect(response.status).toBe(500) + expect(reportsMatch).toMatchObject({ + status: 'error', + error: boom, + }) + }) +}) diff --git a/packages/router-core/tests/stay-match-abort.test.ts b/packages/router-core/tests/stay-match-abort.test.ts new file mode 100644 index 0000000000..209ddcc082 --- /dev/null +++ b/packages/router-core/tests/stay-match-abort.test.ts @@ -0,0 +1,249 @@ +import { afterEach, describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { + BaseRootRoute, + BaseRoute, + createControlledPromise, + notFound, +} from '../src' +import { createTestRouter } from './routerTestUtils' + +afterEach(() => { + vi.restoreAllMocks() +}) + +/** + * A settled success stay-match must keep its abortController un-aborted + * across navigations and invalidations: loaders can hand that signal to + * still-streaming deferred data (fetch(url, { signal }) consumed via + * ), and the documented contract only cancels the signal when the + * route unloads or the loader call becomes outdated. Only pending or + * actively-fetching matches are cancelled at load start. + */ + +function setup(reloadGate?: Promise) { + let capturedSignal: AbortSignal | undefined + let deferredRequestAbortCount = 0 + let loaderCalls = 0 + + const rootRoute = new BaseRootRoute({}) + const dashboardRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/dashboard', + staleTime: Infinity, + loader: ({ abortController }: { abortController: AbortController }) => { + loaderCalls++ + capturedSignal = abortController.signal + + // A common streaming pattern is to return immediately-available data + // alongside a request consumed later by . That request must stay + // alive while this layout is reused, then be cancelled when it unloads. + const deferredRequest = new Promise<'aborted'>((resolve) => { + abortController.signal.addEventListener( + 'abort', + () => { + deferredRequestAbortCount++ + resolve('aborted') + }, + { once: true }, + ) + }) + + const data = { critical: 'dashboard data', deferredRequest } + return reloadGate && loaderCalls > 1 ? reloadGate.then(() => data) : data + }, + }) + const settingsRoute = new BaseRoute({ + getParentRoute: () => dashboardRoute, + path: '/settings', + }) + const loginRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/login', + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + dashboardRoute.addChildren([settingsRoute]), + loginRoute, + ]), + history: createMemoryHistory({ initialEntries: ['/dashboard'] }), + }) + + return { + router, + getSignal: () => capturedSignal, + getDeferredRequestAbortCount: () => deferredRequestAbortCount, + } +} + +describe('stay-match abort scope', () => { + test('navigating to a child route does not abort a fresh success stay-match', async () => { + const { router, getSignal } = setup() + + await router.load() + expect(getSignal()?.aborted).toBe(false) + + await router.navigate({ to: '/dashboard/settings' }) + expect(getSignal()?.aborted).toBe(false) + }) + + test('unloading a reused match aborts the signal exposed to its loader', async () => { + const { router, getSignal, getDeferredRequestAbortCount } = setup() + + await router.load() + const dashboardLoaderSignal = getSignal() + + // Reusing the dashboard layout for a child must preserve the lifetime of + // its loader's deferred request. + await router.navigate({ to: '/dashboard/settings' }) + expect(dashboardLoaderSignal?.aborted).toBe(false) + expect(getDeferredRequestAbortCount()).toBe(0) + + // Leaving the layout altogether must now cancel that same request. + await router.navigate({ to: '/login' }) + expect(dashboardLoaderSignal?.aborted).toBe(true) + expect(getDeferredRequestAbortCount()).toBe(1) + }) + + test('background invalidation keeps the old loader signal until fresh data commits', async () => { + const reloadGate = createControlledPromise() + const { router, getSignal } = setup(reloadGate) + + await router.load() + const firstSignal = getSignal() + expect(firstSignal?.aborted).toBe(false) + + const invalidation = router.invalidate() + await vi.waitFor(() => expect(getSignal()).not.toBe(firstSignal)) + + // The old loader data is still the published generation while the + // background replacement is private. + expect(firstSignal?.aborted).toBe(false) + + reloadGate.resolve() + await invalidation + await vi.waitFor(() => expect(firstSignal?.aborted).toBe(true)) + }) + + test.each(['beforeLoad', 'shouldReload'] as const)( + '%s failure replacing a successful stay aborts its loader signal', + async (failureStage) => { + const failure = new Error(`${failureStage} failed`) + const onAbort = vi.fn() + let shouldFail = false + let loaderSignal: AbortSignal | undefined + + const rootRoute = new BaseRootRoute({}) + const accountRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/account', + staleTime: Infinity, + beforeLoad: () => { + if (shouldFail && failureStage === 'beforeLoad') { + throw failure + } + }, + shouldReload: () => { + if (shouldFail && failureStage === 'shouldReload') { + throw failure + } + return false + }, + loader: ({ abortController }: { abortController: AbortController }) => { + loaderSignal = abortController.signal + loaderSignal.addEventListener('abort', onAbort, { once: true }) + return { user: 'Flo' } + }, + errorComponent: () => null, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([accountRoute]), + history: createMemoryHistory({ initialEntries: ['/account'] }), + }) + + await router.load() + expect(loaderSignal?.aborted).toBe(false) + + shouldFail = true + await router.load() + + const match = router.state.matches.find( + (candidate) => candidate.routeId === accountRoute.id, + ) + expect(match?.status).toBe('error') + expect(match?.error).toBe(failure) + expect(loaderSignal?.aborted).toBe(true) + expect(onAbort).toHaveBeenCalledTimes(1) + }, + ) + + test('background notFound replacing successful data aborts its old loader signal', async () => { + let loaderCalls = 0 + let initialSignal: AbortSignal | undefined + + const rootRoute = new BaseRootRoute({}) + const accountRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/account', + staleTime: Infinity, + loader: ({ abortController }: { abortController: AbortController }) => { + loaderCalls++ + if (loaderCalls === 1) { + initialSignal = abortController.signal + return { user: 'Flo' } + } + throw notFound() + }, + notFoundComponent: () => null, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([accountRoute]), + history: createMemoryHistory({ initialEntries: ['/account'] }), + }) + + await router.load() + expect(initialSignal?.aborted).toBe(false) + + await router.invalidate() + await vi.waitFor(() => + expect(router.state.matches.at(-1)?.status).toBe('notFound'), + ) + expect(initialSignal?.aborted).toBe(true) + }) + + test('decorative background asset failure still transfers loader signal ownership', async () => { + vi.spyOn(console, 'error').mockImplementation(() => undefined) + let loaderCalls = 0 + const loaderSignals: Array = [] + + const rootRoute = new BaseRootRoute({}) + const accountRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/account', + staleTime: Infinity, + loader: ({ abortController }: { abortController: AbortController }) => { + loaderSignals.push(abortController.signal) + return { revision: ++loaderCalls } + }, + head: ({ loaderData }) => { + if (loaderData?.revision === 2) { + throw new Error('fresh head failed') + } + return { meta: [{ title: 'account' }] } + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([accountRoute]), + history: createMemoryHistory({ initialEntries: ['/account'] }), + }) + + await router.load() + await router.invalidate() + + expect(router.state.matches.at(-1)?.loaderData).toEqual({ revision: 2 }) + expect(loaderSignals).toHaveLength(2) + expect(loaderSignals[0]?.aborted).toBe(true) + expect(loaderSignals[1]?.aborted).toBe(false) + }) +}) diff --git a/packages/router-core/tests/superseded-load-await.test.ts b/packages/router-core/tests/superseded-load-await.test.ts new file mode 100644 index 0000000000..471fb9b189 --- /dev/null +++ b/packages/router-core/tests/superseded-load-await.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute, createControlledPromise } from '../src' +import { createTestRouter } from './routerTestUtils' + +/** + * awaiting router.load() or router.invalidate() should remain a + * reliable synchronization point even when that load is superseded. If the + * abandoned load resolves immediately, callers can observe router state before + * the newer load has settled. + * + * This test starts a real public router.load() for /one, supersedes it with a + * real navigation to /two, and resolves only the abandoned /one loader. The + * original await should not settle until the superseding /two loader settles. + */ + +const waitForMacrotask = () => + new Promise((resolve) => { + setTimeout(resolve, 0) + }) + +describe('superseded load await', () => { + test('router.load waits for a superseding navigation load to settle', async () => { + const firstLoaderGate = createControlledPromise() + const secondLoaderGate = createControlledPromise() + let firstLoaderCalls = 0 + let secondLoaderCalls = 0 + let secondLoaderSettled = false + let supersededLoadSettled = false + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const firstRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/one', + loader: async () => { + firstLoaderCalls++ + await firstLoaderGate + return 'one' + }, + }) + const secondRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/two', + loader: async () => { + secondLoaderCalls++ + await secondLoaderGate + secondLoaderSettled = true + return 'two' + }, + }) + + const history = createMemoryHistory({ initialEntries: ['/'] }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, firstRoute, secondRoute]), + history, + }) + + await router.load() + + history.push('/one') + const supersededLoad = router.load().then(() => { + supersededLoadSettled = true + }) + await vi.waitFor(() => expect(firstLoaderCalls).toBe(1)) + + const latestNavigation = router.navigate({ to: '/two' }) + await vi.waitFor(() => expect(secondLoaderCalls).toBe(1)) + + firstLoaderGate.resolve() + await waitForMacrotask() + + expect(secondLoaderSettled).toBe(false) + expect(supersededLoadSettled).toBe(false) + + secondLoaderGate.resolve() + await Promise.all([supersededLoad, latestNavigation]) + + expect(secondLoaderSettled).toBe(true) + expect(supersededLoadSettled).toBe(true) + }) +}) diff --git a/packages/router-devtools-core/src/AgeTicker.tsx b/packages/router-devtools-core/src/AgeTicker.tsx index 27d179fda1..e3bd657358 100644 --- a/packages/router-devtools-core/src/AgeTicker.tsx +++ b/packages/router-devtools-core/src/AgeTicker.tsx @@ -1,6 +1,6 @@ import { clsx as cx } from 'clsx' import { useStyles } from './useStyles' -import type { AnyRouteMatch, AnyRouter } from '@tanstack/router-core' +import type { AnyRoute, AnyRouteMatch, AnyRouter } from '@tanstack/router-core' import type { Accessor } from 'solid-js' function formatTime(ms: number) { @@ -35,7 +35,9 @@ export function AgeTicker({ return null } - const route = router().looseRoutesById[match.routeId]! + const route = (router().routesById as Record)[ + match.routeId + ]! if (!route.options.loader) { return null diff --git a/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx b/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx index 79566656b0..5c09dc1b59 100644 --- a/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx +++ b/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx @@ -282,30 +282,19 @@ export const BaseTanStackRouterDevtoolsPanel = const [history, setHistory] = createSignal>([]) const [hasHistoryOverflowed, setHasHistoryOverflowed] = createSignal(false) - let pendingMatches: Accessor> + const pendingMatches = () => { + const matches = routerState().matches + return matches.some((match: AnyRouteMatch) => match.status === 'pending') + ? matches + : [] + } let cachedMatches: Accessor> // subscribable implementation - if ('subscribe' in router().stores.pendingMatches) { - const [_pendingMatches, setPending] = createSignal>( - [], - ) - pendingMatches = _pendingMatches - + if ('subscribe' in router().stores.cachedMatches) { const [_cachedMatches, setCached] = createSignal>([]) cachedMatches = _cachedMatches type Subscribe = (fn: () => void) => { unsubscribe: () => void } - createEffect(() => { - const pendingMatchesStore = router().stores.pendingMatches - setPending(pendingMatchesStore.get()) - const subscription = ( - (pendingMatchesStore as any).subscribe as Subscribe - )(() => { - setPending(pendingMatchesStore.get()) - }) - onCleanup(() => subscription.unsubscribe()) - }) - createEffect(() => { const cachedMatchesStore = router().stores.cachedMatches setCached(cachedMatchesStore.get()) @@ -319,7 +308,6 @@ export const BaseTanStackRouterDevtoolsPanel = } // signal implementation else { - pendingMatches = () => router().stores.pendingMatches.get() cachedMatches = () => router().stores.cachedMatches.get() } @@ -392,7 +380,6 @@ export const BaseTanStackRouterDevtoolsPanel = 'stores', 'basepath', 'subscribers', - 'latestLoadPromise', '_scroll', 'tempLocationKey', 'latestLocation', @@ -719,7 +706,9 @@ export const BaseTanStackRouterDevtoolsPanel =
State:
- {pendingMatches().find((d) => d.id === activeMatch()?.id) + {pendingMatches().find( + (d: AnyRouteMatch) => d.id === activeMatch()?.id, + ) ? 'Pending' : routerState().matches.find( (d: any) => d.id === activeMatch()?.id, 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..dcf8c9dcc9 100644 --- a/packages/router-plugin/src/core/hmr/handle-route-update.ts +++ b/packages/router-plugin/src/core/hmr/handle-route-update.ts @@ -1,15 +1,9 @@ -import type { - AnyRoute, - AnyRouteMatch, - AnyRouter, - RouterWritableStore, -} from '@tanstack/router-core' +import type { AnyRoute, AnyRouter } from '@tanstack/router-core' type AnyRouteWithPrivateProps = AnyRoute & { options: Record parentRoute: AnyRoute - _componentsPromise?: Promise - _lazyPromise?: Promise + _lazy?: Promise | true update: (options: Record) => unknown _path: string _id: string @@ -17,37 +11,18 @@ type AnyRouteWithPrivateProps = AnyRoute & { _to: string } -type AnyRouterWithPrivateMaps = AnyRouter & { +type AnyRouterWithPrivateState = AnyRouter & { routesById: Record buildRouteTree: () => Parameters[0] setRoutes: AnyRouter['setRoutes'] - stores: AnyRouter['stores'] & { - cachedMatchStores: Map< - string, - Pick, 'get' | 'set'> - > - pendingMatchStores: Map< - string, - Pick, 'get' | 'set'> - > - matchStores: Map< - string, - Pick, 'get' | 'set'> - > - } -} - -type AnyRouteMatchWithPrivateProps = AnyRouteMatch & { - __beforeLoadContext?: unknown - __routeContext?: Record - context?: Record + _refreshRoute?: (routeId: string) => Promise } function handleRouteUpdate( routeId: string, newRoute: AnyRouteWithPrivateProps, ) { - const router = window.__TSR_ROUTER__ as AnyRouterWithPrivateMaps + const router = window.__TSR_ROUTER__ as AnyRouterWithPrivateState const oldRoute = router.routesById[routeId] as | AnyRouteWithPrivateProps | undefined @@ -66,14 +41,6 @@ function handleRouteUpdate( } }) - const removedKeys = new Set() - Object.keys(oldRoute.options).forEach((key) => { - if (!generatedRouteOptionKeys.has(key) && !(key in newRoute.options)) { - removedKeys.add(key) - delete oldRoute.options[key] - } - }) - const oldHasShellComponent = 'shellComponent' in oldRoute.options const newHasShellComponent = 'shellComponent' in newRoute.options const preserveComponentIdentity = @@ -106,60 +73,12 @@ function handleRouteUpdate( oldRoute.options = nextOptions oldRoute.update(nextOptions) - oldRoute._componentsPromise = undefined - oldRoute._lazyPromise = undefined + oldRoute._lazy = undefined router.setRoutes(router.buildRouteTree()) syncHotRouteExport(oldRoute) router.resolvePathCache.clear() - - const filter = (m: AnyRouteMatch) => m.routeId === oldRoute.id - const activeMatch = router.stores.matches.get().find(filter) - const pendingMatch = router.stores.pendingMatches.get().find(filter) - const cachedMatches = router.stores.cachedMatches.get().filter(filter) - - if (activeMatch || pendingMatch || cachedMatches.length > 0) { - // Clear stale match data for removed route options BEFORE invalidating. - // Without this, router.invalidate() -> matchRoutes() reuses the existing - // 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. - if (removedKeys.has('loader') || removedKeys.has('beforeLoad')) { - const matchIds = [ - activeMatch?.id, - pendingMatch?.id, - ...cachedMatches.map((match) => match.id), - ].filter(Boolean) as Array - router.batch(() => { - for (const matchId of matchIds) { - const store = - router.stores.pendingMatchStores.get(matchId) || - router.stores.matchStores.get(matchId) || - router.stores.cachedMatchStores.get(matchId) - if (store) { - store.set((prev) => { - const next: AnyRouteMatchWithPrivateProps = { ...prev } - - if (removedKeys.has('loader')) { - next.loaderData = undefined - } - if (removedKeys.has('beforeLoad')) { - next.__beforeLoadContext = undefined - next.context = rebuildMatchContextWithoutBeforeLoad(next) - } - - return next - }) - } - } - }) - } - - router.invalidate({ filter, sync: true }) - } + void router._refreshRoute?.(oldRoute.id) function syncHotRouteExport(liveRoute: AnyRouteWithPrivateProps) { // routeTree.gen.ts mutates the original module export with generated @@ -172,66 +91,6 @@ function handleRouteUpdate( newRoute._fullPath = liveRoute._fullPath newRoute._to = liveRoute._to } - - function getStoreMatch(matchId: string) { - return ( - router.stores.pendingMatchStores.get(matchId)?.get() || - router.stores.matchStores.get(matchId)?.get() || - router.stores.cachedMatchStores.get(matchId)?.get() - ) - } - - function getMatchList(matchId: string) { - const pendingMatches = router.stores.pendingMatches.get() - if (pendingMatches.some((match) => match.id === matchId)) { - return pendingMatches - } - - const activeMatches = router.stores.matches.get() - if (activeMatches.some((match) => match.id === matchId)) { - return activeMatches - } - - const cachedMatches = router.stores.cachedMatches.get() - if (cachedMatches.some((match) => match.id === matchId)) { - return cachedMatches - } - - return [] - } - - function getParentMatch(match: AnyRouteMatch) { - const matchList = getMatchList(match.id) - const matchIndex = matchList.findIndex((item) => item.id === match.id) - - if (matchIndex <= 0) { - return undefined - } - - const parentMatch = matchList[matchIndex - 1]! - return getStoreMatch(parentMatch.id) || parentMatch - } - - function rebuildMatchContextWithoutBeforeLoad( - match: AnyRouteMatchWithPrivateProps, - ) { - const parentMatch = getParentMatch(match) - const getParentContext = ( - router as unknown as { - getParentContext?: ( - parentMatch?: AnyRouteMatch, - ) => Record | undefined - } - ).getParentContext - const parentContext = getParentContext - ? getParentContext.call(router, parentMatch) - : (parentMatch?.context ?? router.options.context) - - return { - ...(parentContext ?? {}), - ...(match.__routeContext ?? {}), - } - } } const handleRouteUpdateStr = handleRouteUpdate.toString() diff --git a/packages/router-plugin/tests/add-hmr.test.ts b/packages/router-plugin/tests/add-hmr.test.ts index 4430cef74c..3e1a9135f4 100644 --- a/packages/router-plugin/tests/add-hmr.test.ts +++ b/packages/router-plugin/tests/add-hmr.test.ts @@ -111,7 +111,6 @@ describe('add-hmr works', () => { expect(output).toContain('webpackHot') expect(output).not.toContain('import.meta.hot') expect(output).toContain('oldHasShellComponent') - expect(output).toContain('__routeContext') }) it('supports configurable Vite unsplittable HMR generation', async () => { diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@true.tsx index a463d9ab22..7c4cf61b8d 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@true.tsx @@ -52,13 +52,6 @@ if (import.meta.hot) { generatedRouteOptions[key] = oldRoute.options[key]; } }); - const removedKeys = new Set(); - Object.keys(oldRoute.options).forEach(key => { - if (!generatedRouteOptionKeys.has(key) && !(key in newRoute.options)) { - removedKeys.add(key); - delete oldRoute.options[key]; - } - }); const oldHasShellComponent = "shellComponent" in oldRoute.options; const newHasShellComponent = "shellComponent" in newRoute.options; const preserveComponentIdentity = oldHasShellComponent === newHasShellComponent; @@ -77,48 +70,11 @@ if (import.meta.hot) { }; oldRoute.options = nextOptions; oldRoute.update(nextOptions); - oldRoute._componentsPromise = undefined; - oldRoute._lazyPromise = undefined; + oldRoute._lazy = undefined; router.setRoutes(router.buildRouteTree()); syncHotRouteExport(oldRoute); router.resolvePathCache.clear(); - const filter = m => m.routeId === oldRoute.id; - const activeMatch = router.stores.matches.get().find(filter); - const pendingMatch = router.stores.pendingMatches.get().find(filter); - const cachedMatches = router.stores.cachedMatches.get().filter(filter); - if (activeMatch || pendingMatch || cachedMatches.length > 0) { - if (removedKeys.has("loader") || removedKeys.has("beforeLoad")) { - const matchIds = [activeMatch?.id, pendingMatch?.id, ...cachedMatches.map(match => match.id)].filter(Boolean); - router.batch(() => { - for (const matchId of matchIds) { - const store = router.stores.pendingMatchStores.get(matchId) || router.stores.matchStores.get(matchId) || router.stores.cachedMatchStores.get(matchId); - if (store) { - store.set(prev => { - const next = { - ...prev - }; - if (removedKeys.has("loader")) { - next.loaderData = undefined; - } - ; - if (removedKeys.has("beforeLoad")) { - next.__beforeLoadContext = undefined; - next.context = rebuildMatchContextWithoutBeforeLoad(next); - } - ; - return next; - }); - } - } - }); - } - ; - router.invalidate({ - filter, - sync: true - }); - } - ; + void router._refreshRoute?.(oldRoute.id); function syncHotRouteExport(liveRoute) { newRoute.options = liveRoute.options; newRoute.parentRoute = liveRoute.parentRoute; @@ -127,46 +83,6 @@ if (import.meta.hot) { newRoute._fullPath = liveRoute._fullPath; newRoute._to = liveRoute._to; } - function getStoreMatch(matchId) { - return router.stores.pendingMatchStores.get(matchId)?.get() || router.stores.matchStores.get(matchId)?.get() || router.stores.cachedMatchStores.get(matchId)?.get(); - } - function getMatchList(matchId) { - const pendingMatches = router.stores.pendingMatches.get(); - if (pendingMatches.some(match => match.id === matchId)) { - return pendingMatches; - } - ; - const activeMatches = router.stores.matches.get(); - if (activeMatches.some(match => match.id === matchId)) { - return activeMatches; - } - ; - const cachedMatches = router.stores.cachedMatches.get(); - if (cachedMatches.some(match => match.id === matchId)) { - return cachedMatches; - } - ; - return []; - } - function getParentMatch(match) { - const matchList = getMatchList(match.id); - const matchIndex = matchList.findIndex(item => item.id === match.id); - if (matchIndex <= 0) { - return undefined; - } - ; - const parentMatch = matchList[matchIndex - 1]; - return getStoreMatch(parentMatch.id) || parentMatch; - } - function rebuildMatchContextWithoutBeforeLoad(match) { - const parentMatch = getParentMatch(match); - const getParentContext = router.getParentContext; - const parentContext = getParentContext ? getParentContext.call(router, parentMatch) : parentMatch?.context ?? router.options.context; - return { - ...(parentContext ?? {}), - ...(match.__routeContext ?? {}) - }; - } }; const initialRouteId = Route.id ?? hotData['tsr-route-id']; if (initialRouteId) { diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@webpack-hot.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@webpack-hot.tsx index 44908e32a9..ac6ddeef6e 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@webpack-hot.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@webpack-hot.tsx @@ -38,13 +38,6 @@ if (import.meta.webpackHot) { generatedRouteOptions[key] = oldRoute.options[key]; } }); - const removedKeys = new Set(); - Object.keys(oldRoute.options).forEach(key => { - if (!generatedRouteOptionKeys.has(key) && !(key in newRoute.options)) { - removedKeys.add(key); - delete oldRoute.options[key]; - } - }); const oldHasShellComponent = "shellComponent" in oldRoute.options; const newHasShellComponent = "shellComponent" in newRoute.options; const preserveComponentIdentity = oldHasShellComponent === newHasShellComponent; @@ -63,48 +56,11 @@ if (import.meta.webpackHot) { }; oldRoute.options = nextOptions; oldRoute.update(nextOptions); - oldRoute._componentsPromise = undefined; - oldRoute._lazyPromise = undefined; + oldRoute._lazy = undefined; router.setRoutes(router.buildRouteTree()); syncHotRouteExport(oldRoute); router.resolvePathCache.clear(); - const filter = m => m.routeId === oldRoute.id; - const activeMatch = router.stores.matches.get().find(filter); - const pendingMatch = router.stores.pendingMatches.get().find(filter); - const cachedMatches = router.stores.cachedMatches.get().filter(filter); - if (activeMatch || pendingMatch || cachedMatches.length > 0) { - if (removedKeys.has("loader") || removedKeys.has("beforeLoad")) { - const matchIds = [activeMatch?.id, pendingMatch?.id, ...cachedMatches.map(match => match.id)].filter(Boolean); - router.batch(() => { - for (const matchId of matchIds) { - const store = router.stores.pendingMatchStores.get(matchId) || router.stores.matchStores.get(matchId) || router.stores.cachedMatchStores.get(matchId); - if (store) { - store.set(prev => { - const next = { - ...prev - }; - if (removedKeys.has("loader")) { - next.loaderData = undefined; - } - ; - if (removedKeys.has("beforeLoad")) { - next.__beforeLoadContext = undefined; - next.context = rebuildMatchContextWithoutBeforeLoad(next); - } - ; - return next; - }); - } - } - }); - } - ; - router.invalidate({ - filter, - sync: true - }); - } - ; + void router._refreshRoute?.(oldRoute.id); function syncHotRouteExport(liveRoute) { newRoute.options = liveRoute.options; newRoute.parentRoute = liveRoute.parentRoute; @@ -113,46 +69,6 @@ if (import.meta.webpackHot) { newRoute._fullPath = liveRoute._fullPath; newRoute._to = liveRoute._to; } - function getStoreMatch(matchId) { - return router.stores.pendingMatchStores.get(matchId)?.get() || router.stores.matchStores.get(matchId)?.get() || router.stores.cachedMatchStores.get(matchId)?.get(); - } - function getMatchList(matchId) { - const pendingMatches = router.stores.pendingMatches.get(); - if (pendingMatches.some(match => match.id === matchId)) { - return pendingMatches; - } - ; - const activeMatches = router.stores.matches.get(); - if (activeMatches.some(match => match.id === matchId)) { - return activeMatches; - } - ; - const cachedMatches = router.stores.cachedMatches.get(); - if (cachedMatches.some(match => match.id === matchId)) { - return cachedMatches; - } - ; - return []; - } - function getParentMatch(match) { - const matchList = getMatchList(match.id); - const matchIndex = matchList.findIndex(item => item.id === match.id); - if (matchIndex <= 0) { - return undefined; - } - ; - const parentMatch = matchList[matchIndex - 1]; - return getStoreMatch(parentMatch.id) || parentMatch; - } - function rebuildMatchContextWithoutBeforeLoad(match) { - const parentMatch = getParentMatch(match); - const getParentContext = router.getParentContext; - const parentContext = getParentContext ? getParentContext.call(router, parentMatch) : parentMatch?.context ?? router.options.context; - return { - ...(parentContext ?? {}), - ...(match.__routeContext ?? {}) - }; - } })(routeId, Route); try { const tsrReactRefreshUtils = typeof __react_refresh_utils__ !== 'undefined' ? __react_refresh_utils__ : undefined; diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute-inline-component@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute-inline-component@true.tsx index 9ef182f4b7..ce3efafbe6 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute-inline-component@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute-inline-component@true.tsx @@ -41,13 +41,6 @@ if (import.meta.hot) { generatedRouteOptions[key] = oldRoute.options[key]; } }); - const removedKeys = new Set(); - Object.keys(oldRoute.options).forEach(key => { - if (!generatedRouteOptionKeys.has(key) && !(key in newRoute.options)) { - removedKeys.add(key); - delete oldRoute.options[key]; - } - }); const oldHasShellComponent = "shellComponent" in oldRoute.options; const newHasShellComponent = "shellComponent" in newRoute.options; const preserveComponentIdentity = oldHasShellComponent === newHasShellComponent; @@ -66,48 +59,11 @@ if (import.meta.hot) { }; oldRoute.options = nextOptions; oldRoute.update(nextOptions); - oldRoute._componentsPromise = undefined; - oldRoute._lazyPromise = undefined; + oldRoute._lazy = undefined; router.setRoutes(router.buildRouteTree()); syncHotRouteExport(oldRoute); router.resolvePathCache.clear(); - const filter = m => m.routeId === oldRoute.id; - const activeMatch = router.stores.matches.get().find(filter); - const pendingMatch = router.stores.pendingMatches.get().find(filter); - const cachedMatches = router.stores.cachedMatches.get().filter(filter); - if (activeMatch || pendingMatch || cachedMatches.length > 0) { - if (removedKeys.has("loader") || removedKeys.has("beforeLoad")) { - const matchIds = [activeMatch?.id, pendingMatch?.id, ...cachedMatches.map(match => match.id)].filter(Boolean); - router.batch(() => { - for (const matchId of matchIds) { - const store = router.stores.pendingMatchStores.get(matchId) || router.stores.matchStores.get(matchId) || router.stores.cachedMatchStores.get(matchId); - if (store) { - store.set(prev => { - const next = { - ...prev - }; - if (removedKeys.has("loader")) { - next.loaderData = undefined; - } - ; - if (removedKeys.has("beforeLoad")) { - next.__beforeLoadContext = undefined; - next.context = rebuildMatchContextWithoutBeforeLoad(next); - } - ; - return next; - }); - } - } - }); - } - ; - router.invalidate({ - filter, - sync: true - }); - } - ; + void router._refreshRoute?.(oldRoute.id); function syncHotRouteExport(liveRoute) { newRoute.options = liveRoute.options; newRoute.parentRoute = liveRoute.parentRoute; @@ -116,46 +72,6 @@ if (import.meta.hot) { newRoute._fullPath = liveRoute._fullPath; newRoute._to = liveRoute._to; } - function getStoreMatch(matchId) { - return router.stores.pendingMatchStores.get(matchId)?.get() || router.stores.matchStores.get(matchId)?.get() || router.stores.cachedMatchStores.get(matchId)?.get(); - } - function getMatchList(matchId) { - const pendingMatches = router.stores.pendingMatches.get(); - if (pendingMatches.some(match => match.id === matchId)) { - return pendingMatches; - } - ; - const activeMatches = router.stores.matches.get(); - if (activeMatches.some(match => match.id === matchId)) { - return activeMatches; - } - ; - const cachedMatches = router.stores.cachedMatches.get(); - if (cachedMatches.some(match => match.id === matchId)) { - return cachedMatches; - } - ; - return []; - } - function getParentMatch(match) { - const matchList = getMatchList(match.id); - const matchIndex = matchList.findIndex(item => item.id === match.id); - if (matchIndex <= 0) { - return undefined; - } - ; - const parentMatch = matchList[matchIndex - 1]; - return getStoreMatch(parentMatch.id) || parentMatch; - } - function rebuildMatchContextWithoutBeforeLoad(match) { - const parentMatch = getParentMatch(match); - const getParentContext = router.getParentContext; - const parentContext = getParentContext ? getParentContext.call(router, parentMatch) : parentMatch?.context ?? router.options.context; - return { - ...(parentContext ?? {}), - ...(match.__routeContext ?? {}) - }; - } }; const initialRouteId = Route.id ?? hotData['tsr-route-id']; if (initialRouteId) { diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute@true.tsx index 9789370efc..96f918525e 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute@true.tsx @@ -43,13 +43,6 @@ if (import.meta.hot) { generatedRouteOptions[key] = oldRoute.options[key]; } }); - const removedKeys = new Set(); - Object.keys(oldRoute.options).forEach(key => { - if (!generatedRouteOptionKeys.has(key) && !(key in newRoute.options)) { - removedKeys.add(key); - delete oldRoute.options[key]; - } - }); const oldHasShellComponent = "shellComponent" in oldRoute.options; const newHasShellComponent = "shellComponent" in newRoute.options; const preserveComponentIdentity = oldHasShellComponent === newHasShellComponent; @@ -68,48 +61,11 @@ if (import.meta.hot) { }; oldRoute.options = nextOptions; oldRoute.update(nextOptions); - oldRoute._componentsPromise = undefined; - oldRoute._lazyPromise = undefined; + oldRoute._lazy = undefined; router.setRoutes(router.buildRouteTree()); syncHotRouteExport(oldRoute); router.resolvePathCache.clear(); - const filter = m => m.routeId === oldRoute.id; - const activeMatch = router.stores.matches.get().find(filter); - const pendingMatch = router.stores.pendingMatches.get().find(filter); - const cachedMatches = router.stores.cachedMatches.get().filter(filter); - if (activeMatch || pendingMatch || cachedMatches.length > 0) { - if (removedKeys.has("loader") || removedKeys.has("beforeLoad")) { - const matchIds = [activeMatch?.id, pendingMatch?.id, ...cachedMatches.map(match => match.id)].filter(Boolean); - router.batch(() => { - for (const matchId of matchIds) { - const store = router.stores.pendingMatchStores.get(matchId) || router.stores.matchStores.get(matchId) || router.stores.cachedMatchStores.get(matchId); - if (store) { - store.set(prev => { - const next = { - ...prev - }; - if (removedKeys.has("loader")) { - next.loaderData = undefined; - } - ; - if (removedKeys.has("beforeLoad")) { - next.__beforeLoadContext = undefined; - next.context = rebuildMatchContextWithoutBeforeLoad(next); - } - ; - return next; - }); - } - } - }); - } - ; - router.invalidate({ - filter, - sync: true - }); - } - ; + void router._refreshRoute?.(oldRoute.id); function syncHotRouteExport(liveRoute) { newRoute.options = liveRoute.options; newRoute.parentRoute = liveRoute.parentRoute; @@ -118,46 +74,6 @@ if (import.meta.hot) { newRoute._fullPath = liveRoute._fullPath; newRoute._to = liveRoute._to; } - function getStoreMatch(matchId) { - return router.stores.pendingMatchStores.get(matchId)?.get() || router.stores.matchStores.get(matchId)?.get() || router.stores.cachedMatchStores.get(matchId)?.get(); - } - function getMatchList(matchId) { - const pendingMatches = router.stores.pendingMatches.get(); - if (pendingMatches.some(match => match.id === matchId)) { - return pendingMatches; - } - ; - const activeMatches = router.stores.matches.get(); - if (activeMatches.some(match => match.id === matchId)) { - return activeMatches; - } - ; - const cachedMatches = router.stores.cachedMatches.get(); - if (cachedMatches.some(match => match.id === matchId)) { - return cachedMatches; - } - ; - return []; - } - function getParentMatch(match) { - const matchList = getMatchList(match.id); - const matchIndex = matchList.findIndex(item => item.id === match.id); - if (matchIndex <= 0) { - return undefined; - } - ; - const parentMatch = matchList[matchIndex - 1]; - return getStoreMatch(parentMatch.id) || parentMatch; - } - function rebuildMatchContextWithoutBeforeLoad(match) { - const parentMatch = getParentMatch(match); - const getParentContext = router.getParentContext; - const parentContext = getParentContext ? getParentContext.call(router, parentMatch) : parentMatch?.context ?? router.options.context; - return { - ...(parentContext ?? {}), - ...(match.__routeContext ?? {}) - }; - } }; const initialRouteId = Route.id ?? hotData['tsr-route-id']; if (initialRouteId) { diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRouteWithContext-type-args@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRouteWithContext-type-args@true.tsx index 6c7073a8f3..ec488de92c 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRouteWithContext-type-args@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRouteWithContext-type-args@true.tsx @@ -46,13 +46,6 @@ if (import.meta.hot) { generatedRouteOptions[key] = oldRoute.options[key]; } }); - const removedKeys = new Set(); - Object.keys(oldRoute.options).forEach(key => { - if (!generatedRouteOptionKeys.has(key) && !(key in newRoute.options)) { - removedKeys.add(key); - delete oldRoute.options[key]; - } - }); const oldHasShellComponent = "shellComponent" in oldRoute.options; const newHasShellComponent = "shellComponent" in newRoute.options; const preserveComponentIdentity = oldHasShellComponent === newHasShellComponent; @@ -71,48 +64,11 @@ if (import.meta.hot) { }; oldRoute.options = nextOptions; oldRoute.update(nextOptions); - oldRoute._componentsPromise = undefined; - oldRoute._lazyPromise = undefined; + oldRoute._lazy = undefined; router.setRoutes(router.buildRouteTree()); syncHotRouteExport(oldRoute); router.resolvePathCache.clear(); - const filter = m => m.routeId === oldRoute.id; - const activeMatch = router.stores.matches.get().find(filter); - const pendingMatch = router.stores.pendingMatches.get().find(filter); - const cachedMatches = router.stores.cachedMatches.get().filter(filter); - if (activeMatch || pendingMatch || cachedMatches.length > 0) { - if (removedKeys.has("loader") || removedKeys.has("beforeLoad")) { - const matchIds = [activeMatch?.id, pendingMatch?.id, ...cachedMatches.map(match => match.id)].filter(Boolean); - router.batch(() => { - for (const matchId of matchIds) { - const store = router.stores.pendingMatchStores.get(matchId) || router.stores.matchStores.get(matchId) || router.stores.cachedMatchStores.get(matchId); - if (store) { - store.set(prev => { - const next = { - ...prev - }; - if (removedKeys.has("loader")) { - next.loaderData = undefined; - } - ; - if (removedKeys.has("beforeLoad")) { - next.__beforeLoadContext = undefined; - next.context = rebuildMatchContextWithoutBeforeLoad(next); - } - ; - return next; - }); - } - } - }); - } - ; - router.invalidate({ - filter, - sync: true - }); - } - ; + void router._refreshRoute?.(oldRoute.id); function syncHotRouteExport(liveRoute) { newRoute.options = liveRoute.options; newRoute.parentRoute = liveRoute.parentRoute; @@ -121,46 +77,6 @@ if (import.meta.hot) { newRoute._fullPath = liveRoute._fullPath; newRoute._to = liveRoute._to; } - function getStoreMatch(matchId) { - return router.stores.pendingMatchStores.get(matchId)?.get() || router.stores.matchStores.get(matchId)?.get() || router.stores.cachedMatchStores.get(matchId)?.get(); - } - function getMatchList(matchId) { - const pendingMatches = router.stores.pendingMatches.get(); - if (pendingMatches.some(match => match.id === matchId)) { - return pendingMatches; - } - ; - const activeMatches = router.stores.matches.get(); - if (activeMatches.some(match => match.id === matchId)) { - return activeMatches; - } - ; - const cachedMatches = router.stores.cachedMatches.get(); - if (cachedMatches.some(match => match.id === matchId)) { - return cachedMatches; - } - ; - return []; - } - function getParentMatch(match) { - const matchList = getMatchList(match.id); - const matchIndex = matchList.findIndex(item => item.id === match.id); - if (matchIndex <= 0) { - return undefined; - } - ; - const parentMatch = matchList[matchIndex - 1]; - return getStoreMatch(parentMatch.id) || parentMatch; - } - function rebuildMatchContextWithoutBeforeLoad(match) { - const parentMatch = getParentMatch(match); - const getParentContext = router.getParentContext; - const parentContext = getParentContext ? getParentContext.call(router, parentMatch) : parentMatch?.context ?? router.options.context; - return { - ...(parentContext ?? {}), - ...(match.__routeContext ?? {}) - }; - } }; const initialRouteId = Route.id ?? hotData['tsr-route-id']; if (initialRouteId) { diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/explicit-undefined-component@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/explicit-undefined-component@true.tsx index bee793d372..a953380aa9 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/explicit-undefined-component@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/explicit-undefined-component@true.tsx @@ -40,13 +40,6 @@ if (import.meta.hot) { generatedRouteOptions[key] = oldRoute.options[key]; } }); - const removedKeys = new Set(); - Object.keys(oldRoute.options).forEach(key => { - if (!generatedRouteOptionKeys.has(key) && !(key in newRoute.options)) { - removedKeys.add(key); - delete oldRoute.options[key]; - } - }); const oldHasShellComponent = "shellComponent" in oldRoute.options; const newHasShellComponent = "shellComponent" in newRoute.options; const preserveComponentIdentity = oldHasShellComponent === newHasShellComponent; @@ -65,48 +58,11 @@ if (import.meta.hot) { }; oldRoute.options = nextOptions; oldRoute.update(nextOptions); - oldRoute._componentsPromise = undefined; - oldRoute._lazyPromise = undefined; + oldRoute._lazy = undefined; router.setRoutes(router.buildRouteTree()); syncHotRouteExport(oldRoute); router.resolvePathCache.clear(); - const filter = m => m.routeId === oldRoute.id; - const activeMatch = router.stores.matches.get().find(filter); - const pendingMatch = router.stores.pendingMatches.get().find(filter); - const cachedMatches = router.stores.cachedMatches.get().filter(filter); - if (activeMatch || pendingMatch || cachedMatches.length > 0) { - if (removedKeys.has("loader") || removedKeys.has("beforeLoad")) { - const matchIds = [activeMatch?.id, pendingMatch?.id, ...cachedMatches.map(match => match.id)].filter(Boolean); - router.batch(() => { - for (const matchId of matchIds) { - const store = router.stores.pendingMatchStores.get(matchId) || router.stores.matchStores.get(matchId) || router.stores.cachedMatchStores.get(matchId); - if (store) { - store.set(prev => { - const next = { - ...prev - }; - if (removedKeys.has("loader")) { - next.loaderData = undefined; - } - ; - if (removedKeys.has("beforeLoad")) { - next.__beforeLoadContext = undefined; - next.context = rebuildMatchContextWithoutBeforeLoad(next); - } - ; - return next; - }); - } - } - }); - } - ; - router.invalidate({ - filter, - sync: true - }); - } - ; + void router._refreshRoute?.(oldRoute.id); function syncHotRouteExport(liveRoute) { newRoute.options = liveRoute.options; newRoute.parentRoute = liveRoute.parentRoute; @@ -115,46 +71,6 @@ if (import.meta.hot) { newRoute._fullPath = liveRoute._fullPath; newRoute._to = liveRoute._to; } - function getStoreMatch(matchId) { - return router.stores.pendingMatchStores.get(matchId)?.get() || router.stores.matchStores.get(matchId)?.get() || router.stores.cachedMatchStores.get(matchId)?.get(); - } - function getMatchList(matchId) { - const pendingMatches = router.stores.pendingMatches.get(); - if (pendingMatches.some(match => match.id === matchId)) { - return pendingMatches; - } - ; - const activeMatches = router.stores.matches.get(); - if (activeMatches.some(match => match.id === matchId)) { - return activeMatches; - } - ; - const cachedMatches = router.stores.cachedMatches.get(); - if (cachedMatches.some(match => match.id === matchId)) { - return cachedMatches; - } - ; - return []; - } - function getParentMatch(match) { - const matchList = getMatchList(match.id); - const matchIndex = matchList.findIndex(item => item.id === match.id); - if (matchIndex <= 0) { - return undefined; - } - ; - const parentMatch = matchList[matchIndex - 1]; - return getStoreMatch(parentMatch.id) || parentMatch; - } - function rebuildMatchContextWithoutBeforeLoad(match) { - const parentMatch = getParentMatch(match); - const getParentContext = router.getParentContext; - const parentContext = getParentContext ? getParentContext.call(router, parentMatch) : parentMatch?.context ?? router.options.context; - return { - ...(parentContext ?? {}), - ...(match.__routeContext ?? {}) - }; - } }; const initialRouteId = Route.id ?? hotData['tsr-route-id']; if (initialRouteId) { diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/function-declaration@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/function-declaration@true.tsx index 75b457b778..52164d8bee 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/function-declaration@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/function-declaration@true.tsx @@ -52,13 +52,6 @@ if (import.meta.hot) { generatedRouteOptions[key] = oldRoute.options[key]; } }); - const removedKeys = new Set(); - Object.keys(oldRoute.options).forEach(key => { - if (!generatedRouteOptionKeys.has(key) && !(key in newRoute.options)) { - removedKeys.add(key); - delete oldRoute.options[key]; - } - }); const oldHasShellComponent = "shellComponent" in oldRoute.options; const newHasShellComponent = "shellComponent" in newRoute.options; const preserveComponentIdentity = oldHasShellComponent === newHasShellComponent; @@ -77,48 +70,11 @@ if (import.meta.hot) { }; oldRoute.options = nextOptions; oldRoute.update(nextOptions); - oldRoute._componentsPromise = undefined; - oldRoute._lazyPromise = undefined; + oldRoute._lazy = undefined; router.setRoutes(router.buildRouteTree()); syncHotRouteExport(oldRoute); router.resolvePathCache.clear(); - const filter = m => m.routeId === oldRoute.id; - const activeMatch = router.stores.matches.get().find(filter); - const pendingMatch = router.stores.pendingMatches.get().find(filter); - const cachedMatches = router.stores.cachedMatches.get().filter(filter); - if (activeMatch || pendingMatch || cachedMatches.length > 0) { - if (removedKeys.has("loader") || removedKeys.has("beforeLoad")) { - const matchIds = [activeMatch?.id, pendingMatch?.id, ...cachedMatches.map(match => match.id)].filter(Boolean); - router.batch(() => { - for (const matchId of matchIds) { - const store = router.stores.pendingMatchStores.get(matchId) || router.stores.matchStores.get(matchId) || router.stores.cachedMatchStores.get(matchId); - if (store) { - store.set(prev => { - const next = { - ...prev - }; - if (removedKeys.has("loader")) { - next.loaderData = undefined; - } - ; - if (removedKeys.has("beforeLoad")) { - next.__beforeLoadContext = undefined; - next.context = rebuildMatchContextWithoutBeforeLoad(next); - } - ; - return next; - }); - } - } - }); - } - ; - router.invalidate({ - filter, - sync: true - }); - } - ; + void router._refreshRoute?.(oldRoute.id); function syncHotRouteExport(liveRoute) { newRoute.options = liveRoute.options; newRoute.parentRoute = liveRoute.parentRoute; @@ -127,46 +83,6 @@ if (import.meta.hot) { newRoute._fullPath = liveRoute._fullPath; newRoute._to = liveRoute._to; } - function getStoreMatch(matchId) { - return router.stores.pendingMatchStores.get(matchId)?.get() || router.stores.matchStores.get(matchId)?.get() || router.stores.cachedMatchStores.get(matchId)?.get(); - } - function getMatchList(matchId) { - const pendingMatches = router.stores.pendingMatches.get(); - if (pendingMatches.some(match => match.id === matchId)) { - return pendingMatches; - } - ; - const activeMatches = router.stores.matches.get(); - if (activeMatches.some(match => match.id === matchId)) { - return activeMatches; - } - ; - const cachedMatches = router.stores.cachedMatches.get(); - if (cachedMatches.some(match => match.id === matchId)) { - return cachedMatches; - } - ; - return []; - } - function getParentMatch(match) { - const matchList = getMatchList(match.id); - const matchIndex = matchList.findIndex(item => item.id === match.id); - if (matchIndex <= 0) { - return undefined; - } - ; - const parentMatch = matchList[matchIndex - 1]; - return getStoreMatch(parentMatch.id) || parentMatch; - } - function rebuildMatchContextWithoutBeforeLoad(match) { - const parentMatch = getParentMatch(match); - const getParentContext = router.getParentContext; - const parentContext = getParentContext ? getParentContext.call(router, parentMatch) : parentMatch?.context ?? router.options.context; - return { - ...(parentContext ?? {}), - ...(match.__routeContext ?? {}) - }; - } }; const initialRouteId = Route.id ?? hotData['tsr-route-id']; if (initialRouteId) { diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/multi-component@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/multi-component@true.tsx index 7f28b7b3d3..fbd9a5b59d 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/multi-component@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/multi-component@true.tsx @@ -62,13 +62,6 @@ if (import.meta.hot) { generatedRouteOptions[key] = oldRoute.options[key]; } }); - const removedKeys = new Set(); - Object.keys(oldRoute.options).forEach(key => { - if (!generatedRouteOptionKeys.has(key) && !(key in newRoute.options)) { - removedKeys.add(key); - delete oldRoute.options[key]; - } - }); const oldHasShellComponent = "shellComponent" in oldRoute.options; const newHasShellComponent = "shellComponent" in newRoute.options; const preserveComponentIdentity = oldHasShellComponent === newHasShellComponent; @@ -87,48 +80,11 @@ if (import.meta.hot) { }; oldRoute.options = nextOptions; oldRoute.update(nextOptions); - oldRoute._componentsPromise = undefined; - oldRoute._lazyPromise = undefined; + oldRoute._lazy = undefined; router.setRoutes(router.buildRouteTree()); syncHotRouteExport(oldRoute); router.resolvePathCache.clear(); - const filter = m => m.routeId === oldRoute.id; - const activeMatch = router.stores.matches.get().find(filter); - const pendingMatch = router.stores.pendingMatches.get().find(filter); - const cachedMatches = router.stores.cachedMatches.get().filter(filter); - if (activeMatch || pendingMatch || cachedMatches.length > 0) { - if (removedKeys.has("loader") || removedKeys.has("beforeLoad")) { - const matchIds = [activeMatch?.id, pendingMatch?.id, ...cachedMatches.map(match => match.id)].filter(Boolean); - router.batch(() => { - for (const matchId of matchIds) { - const store = router.stores.pendingMatchStores.get(matchId) || router.stores.matchStores.get(matchId) || router.stores.cachedMatchStores.get(matchId); - if (store) { - store.set(prev => { - const next = { - ...prev - }; - if (removedKeys.has("loader")) { - next.loaderData = undefined; - } - ; - if (removedKeys.has("beforeLoad")) { - next.__beforeLoadContext = undefined; - next.context = rebuildMatchContextWithoutBeforeLoad(next); - } - ; - return next; - }); - } - } - }); - } - ; - router.invalidate({ - filter, - sync: true - }); - } - ; + void router._refreshRoute?.(oldRoute.id); function syncHotRouteExport(liveRoute) { newRoute.options = liveRoute.options; newRoute.parentRoute = liveRoute.parentRoute; @@ -137,46 +93,6 @@ if (import.meta.hot) { newRoute._fullPath = liveRoute._fullPath; newRoute._to = liveRoute._to; } - function getStoreMatch(matchId) { - return router.stores.pendingMatchStores.get(matchId)?.get() || router.stores.matchStores.get(matchId)?.get() || router.stores.cachedMatchStores.get(matchId)?.get(); - } - function getMatchList(matchId) { - const pendingMatches = router.stores.pendingMatches.get(); - if (pendingMatches.some(match => match.id === matchId)) { - return pendingMatches; - } - ; - const activeMatches = router.stores.matches.get(); - if (activeMatches.some(match => match.id === matchId)) { - return activeMatches; - } - ; - const cachedMatches = router.stores.cachedMatches.get(); - if (cachedMatches.some(match => match.id === matchId)) { - return cachedMatches; - } - ; - return []; - } - function getParentMatch(match) { - const matchList = getMatchList(match.id); - const matchIndex = matchList.findIndex(item => item.id === match.id); - if (matchIndex <= 0) { - return undefined; - } - ; - const parentMatch = matchList[matchIndex - 1]; - return getStoreMatch(parentMatch.id) || parentMatch; - } - function rebuildMatchContextWithoutBeforeLoad(match) { - const parentMatch = getParentMatch(match); - const getParentContext = router.getParentContext; - const parentContext = getParentContext ? getParentContext.call(router, parentMatch) : parentMatch?.context ?? router.options.context; - return { - ...(parentContext ?? {}), - ...(match.__routeContext ?? {}) - }; - } }; const initialRouteId = Route.id ?? hotData['tsr-route-id']; if (initialRouteId) { diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/string-literal-keys@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/string-literal-keys@true.tsx index 6bee992948..fe97ac99a0 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/string-literal-keys@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/string-literal-keys@true.tsx @@ -62,13 +62,6 @@ if (import.meta.hot) { generatedRouteOptions[key] = oldRoute.options[key]; } }); - const removedKeys = new Set(); - Object.keys(oldRoute.options).forEach(key => { - if (!generatedRouteOptionKeys.has(key) && !(key in newRoute.options)) { - removedKeys.add(key); - delete oldRoute.options[key]; - } - }); const oldHasShellComponent = "shellComponent" in oldRoute.options; const newHasShellComponent = "shellComponent" in newRoute.options; const preserveComponentIdentity = oldHasShellComponent === newHasShellComponent; @@ -87,48 +80,11 @@ if (import.meta.hot) { }; oldRoute.options = nextOptions; oldRoute.update(nextOptions); - oldRoute._componentsPromise = undefined; - oldRoute._lazyPromise = undefined; + oldRoute._lazy = undefined; router.setRoutes(router.buildRouteTree()); syncHotRouteExport(oldRoute); router.resolvePathCache.clear(); - const filter = m => m.routeId === oldRoute.id; - const activeMatch = router.stores.matches.get().find(filter); - const pendingMatch = router.stores.pendingMatches.get().find(filter); - const cachedMatches = router.stores.cachedMatches.get().filter(filter); - if (activeMatch || pendingMatch || cachedMatches.length > 0) { - if (removedKeys.has("loader") || removedKeys.has("beforeLoad")) { - const matchIds = [activeMatch?.id, pendingMatch?.id, ...cachedMatches.map(match => match.id)].filter(Boolean); - router.batch(() => { - for (const matchId of matchIds) { - const store = router.stores.pendingMatchStores.get(matchId) || router.stores.matchStores.get(matchId) || router.stores.cachedMatchStores.get(matchId); - if (store) { - store.set(prev => { - const next = { - ...prev - }; - if (removedKeys.has("loader")) { - next.loaderData = undefined; - } - ; - if (removedKeys.has("beforeLoad")) { - next.__beforeLoadContext = undefined; - next.context = rebuildMatchContextWithoutBeforeLoad(next); - } - ; - return next; - }); - } - } - }); - } - ; - router.invalidate({ - filter, - sync: true - }); - } - ; + void router._refreshRoute?.(oldRoute.id); function syncHotRouteExport(liveRoute) { newRoute.options = liveRoute.options; newRoute.parentRoute = liveRoute.parentRoute; @@ -137,46 +93,6 @@ if (import.meta.hot) { newRoute._fullPath = liveRoute._fullPath; newRoute._to = liveRoute._to; } - function getStoreMatch(matchId) { - return router.stores.pendingMatchStores.get(matchId)?.get() || router.stores.matchStores.get(matchId)?.get() || router.stores.cachedMatchStores.get(matchId)?.get(); - } - function getMatchList(matchId) { - const pendingMatches = router.stores.pendingMatches.get(); - if (pendingMatches.some(match => match.id === matchId)) { - return pendingMatches; - } - ; - const activeMatches = router.stores.matches.get(); - if (activeMatches.some(match => match.id === matchId)) { - return activeMatches; - } - ; - const cachedMatches = router.stores.cachedMatches.get(); - if (cachedMatches.some(match => match.id === matchId)) { - return cachedMatches; - } - ; - return []; - } - function getParentMatch(match) { - const matchList = getMatchList(match.id); - const matchIndex = matchList.findIndex(item => item.id === match.id); - if (matchIndex <= 0) { - return undefined; - } - ; - const parentMatch = matchList[matchIndex - 1]; - return getStoreMatch(parentMatch.id) || parentMatch; - } - function rebuildMatchContextWithoutBeforeLoad(match) { - const parentMatch = getParentMatch(match); - const getParentContext = router.getParentContext; - const parentContext = getParentContext ? getParentContext.call(router, parentMatch) : parentMatch?.context ?? router.options.context; - return { - ...(parentContext ?? {}), - ...(match.__routeContext ?? {}) - }; - } }; const initialRouteId = Route.id ?? hotData['tsr-route-id']; if (initialRouteId) { diff --git a/packages/router-plugin/tests/add-hmr/snapshots/solid/arrow-function@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/solid/arrow-function@true.tsx index 2d1be7e32b..a14f7e3895 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/solid/arrow-function@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/solid/arrow-function@true.tsx @@ -23,13 +23,6 @@ if (import.meta.hot) { generatedRouteOptions[key] = oldRoute.options[key]; } }); - const removedKeys = new Set(); - Object.keys(oldRoute.options).forEach(key => { - if (!generatedRouteOptionKeys.has(key) && !(key in newRoute.options)) { - removedKeys.add(key); - delete oldRoute.options[key]; - } - }); const oldHasShellComponent = "shellComponent" in oldRoute.options; const newHasShellComponent = "shellComponent" in newRoute.options; const preserveComponentIdentity = oldHasShellComponent === newHasShellComponent; @@ -48,48 +41,11 @@ if (import.meta.hot) { }; oldRoute.options = nextOptions; oldRoute.update(nextOptions); - oldRoute._componentsPromise = undefined; - oldRoute._lazyPromise = undefined; + oldRoute._lazy = undefined; router.setRoutes(router.buildRouteTree()); syncHotRouteExport(oldRoute); router.resolvePathCache.clear(); - const filter = m => m.routeId === oldRoute.id; - const activeMatch = router.stores.matches.get().find(filter); - const pendingMatch = router.stores.pendingMatches.get().find(filter); - const cachedMatches = router.stores.cachedMatches.get().filter(filter); - if (activeMatch || pendingMatch || cachedMatches.length > 0) { - if (removedKeys.has("loader") || removedKeys.has("beforeLoad")) { - const matchIds = [activeMatch?.id, pendingMatch?.id, ...cachedMatches.map(match => match.id)].filter(Boolean); - router.batch(() => { - for (const matchId of matchIds) { - const store = router.stores.pendingMatchStores.get(matchId) || router.stores.matchStores.get(matchId) || router.stores.cachedMatchStores.get(matchId); - if (store) { - store.set(prev => { - const next = { - ...prev - }; - if (removedKeys.has("loader")) { - next.loaderData = undefined; - } - ; - if (removedKeys.has("beforeLoad")) { - next.__beforeLoadContext = undefined; - next.context = rebuildMatchContextWithoutBeforeLoad(next); - } - ; - return next; - }); - } - } - }); - } - ; - router.invalidate({ - filter, - sync: true - }); - } - ; + void router._refreshRoute?.(oldRoute.id); function syncHotRouteExport(liveRoute) { newRoute.options = liveRoute.options; newRoute.parentRoute = liveRoute.parentRoute; @@ -98,46 +54,6 @@ if (import.meta.hot) { newRoute._fullPath = liveRoute._fullPath; newRoute._to = liveRoute._to; } - function getStoreMatch(matchId) { - return router.stores.pendingMatchStores.get(matchId)?.get() || router.stores.matchStores.get(matchId)?.get() || router.stores.cachedMatchStores.get(matchId)?.get(); - } - function getMatchList(matchId) { - const pendingMatches = router.stores.pendingMatches.get(); - if (pendingMatches.some(match => match.id === matchId)) { - return pendingMatches; - } - ; - const activeMatches = router.stores.matches.get(); - if (activeMatches.some(match => match.id === matchId)) { - return activeMatches; - } - ; - const cachedMatches = router.stores.cachedMatches.get(); - if (cachedMatches.some(match => match.id === matchId)) { - return cachedMatches; - } - ; - return []; - } - function getParentMatch(match) { - const matchList = getMatchList(match.id); - const matchIndex = matchList.findIndex(item => item.id === match.id); - if (matchIndex <= 0) { - return undefined; - } - ; - const parentMatch = matchList[matchIndex - 1]; - return getStoreMatch(parentMatch.id) || parentMatch; - } - function rebuildMatchContextWithoutBeforeLoad(match) { - const parentMatch = getParentMatch(match); - const getParentContext = router.getParentContext; - const parentContext = getParentContext ? getParentContext.call(router, parentMatch) : parentMatch?.context ?? router.options.context; - return { - ...(parentContext ?? {}), - ...(match.__routeContext ?? {}) - }; - } }; const initialRouteId = Route.id ?? hotData['tsr-route-id']; if (initialRouteId) { diff --git a/packages/router-plugin/tests/handle-route-update.test.ts b/packages/router-plugin/tests/handle-route-update.test.ts index 9a80bfbeb4..c7e69bee1c 100644 --- a/packages/router-plugin/tests/handle-route-update.test.ts +++ b/packages/router-plugin/tests/handle-route-update.test.ts @@ -6,7 +6,8 @@ import { createNonReactiveReadonlyStore, trimPathRight, } from '@tanstack/router-core' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' +import { createMemoryHistory } from '../../history/src' import { getHandleRouteUpdateCode } from '../src/core/hmr' import type { AnyRoute, GetStoreConfig } from '@tanstack/router-core' @@ -62,6 +63,18 @@ function createTestRouter(routeTree: AnyRoute) { ) } +function createClientTestRouter(routeTree: AnyRoute, initialEntry: string) { + return new RouterCore( + { + routeTree, + history: createMemoryHistory({ initialEntries: [initialEntry] }), + isServer: false, + origin: 'http://localhost', + }, + getStoreConfig, + ) +} + function withWindowRouter(router: RouterCore) { const previousWindow = (globalThis as any).window ;(globalThis as any).window = { __TSR_ROUTER__: router } @@ -75,6 +88,19 @@ function withWindowRouter(router: RouterCore) { } } +function runHandleRouteUpdate( + router: RouterCore, + routeId: string, + newRoute: AnyRoute, +) { + const restoreWindow = withWindowRouter(router) + try { + getHandleRouteUpdate()(routeId, newRoute) + } finally { + restoreWindow() + } +} + function getProcessedTreeRouteForPath( router: RouterCore, path: string, @@ -217,4 +243,244 @@ describe('handleRouteUpdate', () => { expect((newRoute as any).parentRoute).toBe(rootRoute) expect((newRoute as any).options).toBe((itemRoute as any).options) }) + + it('removes stale loader data when a hot route removes its loader', async () => { + const rootRoute = new BaseRootRoute({ + loader: () => 'stale loader data', + }) + const router = createTestRouter(rootRoute) + await router.load() + + expect(router.state.matches[0]?.loaderData).toBe('stale loader data') + + const restoreWindow = withWindowRouter(router) + try { + getHandleRouteUpdate()(rootRoute.id, new BaseRootRoute({})) + await vi.waitFor(() => { + expect(router.state.matches[0]?.loaderData).toBeUndefined() + }) + } finally { + restoreWindow() + } + }) + + it('keeps hot loader data when an older refresh settles later', async () => { + let resolveStaleRefresh!: (value: string) => void + const staleRefresh = new Promise((resolve) => { + resolveStaleRefresh = resolve + }) + const oldLoader = vi + .fn<() => string | Promise>() + .mockReturnValueOnce('initial') + .mockReturnValueOnce(staleRefresh) + const newLoader = vi.fn(() => 'hot') + const rootRoute = new BaseRootRoute({ loader: oldLoader }) + const router = createClientTestRouter(rootRoute, '/') + + await router.load() + expect(router.state.matches[0]?.loaderData).toBe('initial') + + const oldRefresh = router.invalidate() + await vi.waitFor(() => expect(oldLoader).toHaveBeenCalledTimes(2)) + + runHandleRouteUpdate( + router, + rootRoute.id, + new BaseRootRoute({ loader: newLoader }), + ) + + await vi.waitFor(() => { + expect(router.state.matches[0]?.loaderData).toBe('hot') + }) + + resolveStaleRefresh('stale') + await oldRefresh + + expect(newLoader).toHaveBeenCalled() + expect(router.state.matches[0]?.loaderData).toBe('hot') + }) + + it('does not cache an obsolete inactive preload that settles after a hot update', async () => { + let resolveOldLoader!: (value: string) => void + const oldLoaderResult = new Promise((resolve) => { + resolveOldLoader = resolve + }) + const oldLoader = vi.fn(() => oldLoaderResult) + const newLoader = vi.fn(() => 'hot') + const rootRoute = new BaseRootRoute({}) + const hotRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/hot', + loader: oldLoader, + preloadStaleTime: Infinity, + }) + const router = createClientTestRouter( + rootRoute.addChildren([hotRoute]), + '/', + ) + + await router.load() + const preload = router.preloadRoute({ to: '/hot' }) + await vi.waitFor(() => expect(oldLoader).toHaveBeenCalledOnce()) + + runHandleRouteUpdate( + router, + hotRoute.id, + new BaseRoute({ loader: newLoader } as any), + ) + + resolveOldLoader('obsolete') + await preload + await router.navigate({ to: '/hot' }) + + expect(newLoader).toHaveBeenCalledOnce() + expect(router.state.matches.at(-1)?.loaderData).toBe('hot') + }) + + it('observes hot parser and route-context changes on the active route', async () => { + const rootRoute = new BaseRootRoute({}) + const itemRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/items/$itemId', + params: { + parse: ({ itemId }: { itemId: string }) => ({ + itemId, + parser: 'old', + }), + }, + context: () => ({ source: 'old' }), + }) + const router = createClientTestRouter( + rootRoute.addChildren([itemRoute]), + '/items/abc', + ) + + await router.load() + expect(router.state.matches[1]?.params).toMatchObject({ parser: 'old' }) + expect(router.state.matches[1]?.context).toMatchObject({ source: 'old' }) + + runHandleRouteUpdate( + router, + itemRoute.id, + new BaseRoute({ + params: { + parse: ({ itemId }: { itemId: string }) => ({ + itemId, + parser: 'new', + }), + }, + context: () => ({ source: 'new' }), + } as any), + ) + + await vi.waitFor(() => { + expect(router.state.matches[1]?.params).toMatchObject({ parser: 'new' }) + expect(router.state.matches[1]?.context).toMatchObject({ source: 'new' }) + }) + }) + + it('rematerializes descendants when a parent route definition changes', async () => { + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + context: () => ({ source: 'old' }), + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + context: ({ context }) => ({ inheritedSource: context.source }), + }) + const router = createClientTestRouter( + rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + '/parent/child', + ) + + await router.load() + expect(router.state.matches[2]?.context).toMatchObject({ + inheritedSource: 'old', + }) + + runHandleRouteUpdate( + router, + parentRoute.id, + new BaseRoute({ context: () => ({ source: 'new' }) } as any), + ) + + await vi.waitFor(() => { + expect(router.state.matches[2]?.context).toMatchObject({ + inheritedSource: 'new', + }) + }) + }) + + it('does not run removed loader or beforeLoad callbacks on a later visit', async () => { + const beforeLoad = vi.fn(() => ({ hotBeforeLoad: true })) + const loader = vi.fn(() => 'hot loader data') + const rootRoute = new BaseRootRoute({}) + const hotRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/hot', + beforeLoad, + loader, + staleTime: Infinity, + gcTime: Infinity, + }) + const otherRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/other', + }) + const router = createClientTestRouter( + rootRoute.addChildren([hotRoute, otherRoute]), + '/hot', + ) + + await router.load() + await router.navigate({ to: '/other' }) + expect(beforeLoad).toHaveBeenCalledTimes(1) + expect(loader).toHaveBeenCalledTimes(1) + + runHandleRouteUpdate(router, hotRoute.id, new BaseRoute({} as any)) + await router.navigate({ to: '/hot' }) + + const match = router.state.matches.find( + (candidate) => candidate.routeId === hotRoute.id, + ) + expect(beforeLoad).toHaveBeenCalledTimes(1) + expect(loader).toHaveBeenCalledTimes(1) + expect(match?.loaderData).toBeUndefined() + expect(match?.context).not.toHaveProperty('hotBeforeLoad') + }) + + it('removes projected head and body scripts after a hot update', async () => { + const rootRoute = new BaseRootRoute({}) + const hotRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/hot', + head: () => ({ + meta: [{ title: 'hot title' }], + scripts: [{ src: '/hot-head.js' }], + }), + scripts: () => [{ src: '/hot-body.js' }], + }) + const router = createClientTestRouter( + rootRoute.addChildren([hotRoute]), + '/hot', + ) + + await router.load() + expect(router.state.matches[1]).toMatchObject({ + meta: [{ title: 'hot title' }], + headScripts: [{ src: '/hot-head.js' }], + scripts: [{ src: '/hot-body.js' }], + }) + + runHandleRouteUpdate(router, hotRoute.id, new BaseRoute({} as any)) + + await vi.waitFor(() => { + expect(router.state.matches[1]?.meta).toBeUndefined() + expect(router.state.matches[1]?.headScripts).toBeUndefined() + expect(router.state.matches[1]?.scripts).toBeUndefined() + }) + }) }) diff --git a/packages/solid-router/src/ClientOnly.tsx b/packages/solid-router/src/ClientOnly.tsx index a7c4b71819..621d41974a 100644 --- a/packages/solid-router/src/ClientOnly.tsx +++ b/packages/solid-router/src/ClientOnly.tsx @@ -59,7 +59,9 @@ export function ClientOnly(props: ClientOnlyProps) { let globalHydrated = false export function useHydrated(): Solid.Accessor { - const [hydrated, setHydrated] = Solid.createSignal(globalHydrated) + const [hydrated, setHydrated] = Solid.createSignal( + globalHydrated && !Solid.sharedConfig.context, + ) Solid.onMount(() => { globalHydrated = true diff --git a/packages/solid-router/src/Match.tsx b/packages/solid-router/src/Match.tsx index 205e778689..3909a7ff4a 100644 --- a/packages/solid-router/src/Match.tsx +++ b/packages/solid-router/src/Match.tsx @@ -1,12 +1,5 @@ import * as Solid from 'solid-js' -import { - createControlledPromise, - getLocationChangeInfo, - invariant, - isNotFound, - isRedirect, - rootRouteId, -} from '@tanstack/router-core' +import { invariant, isNotFound, rootRouteId } from '@tanstack/router-core' import { isServer } from '@tanstack/router-core/isServer' import { Dynamic } from 'solid-js/web' import { CatchBoundary, ErrorComponent } from './CatchBoundary' @@ -16,6 +9,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 }) => { @@ -32,7 +26,6 @@ export const Match = (props: { matchId: string }) => { if (!currentMatch) { return null } - const routeId = currentMatch.routeId as string const parentRouteId = (router.routesById[routeId] as AnyRoute)?.parentRoute ?.id @@ -40,23 +33,17 @@ export const Match = (props: { matchId: string }) => { return { matchId: currentMatch.id, routeId, + fetchCount: currentMatch.fetchCount, + status: currentMatch.status, ssr: currentMatch.ssr, - _displayPending: currentMatch._displayPending, parentRouteId: parentRouteId as string | undefined, } }) - const hasPendingMatch = Solid.createMemo(() => { - const currentRouteId = rawMatchState()?.routeId - return currentRouteId - ? Boolean(router.stores.pendingRouteIds.get()[currentRouteId]) - : false - }) const nearestMatch = { matchId: () => rawMatchState()?.matchId, routeId: () => rawMatchState()?.routeId, match, - hasPending: hasPendingMatch, } return ( @@ -104,6 +91,15 @@ export const Match = (props: { matchId: string }) => { SafeFragment) : SafeFragment + const MatchContent = () => ( + } + > + + + ) + return ( @@ -119,7 +115,7 @@ export const Match = (props: { matchId: string }) => { > router.stores.loadedAt.get()} + getResetKey={() => currentMatchState().fetchCount} errorComponent={routeErrorComponent() || ErrorComponent} onCatch={(error: Error) => { // Forward not found errors (we don't want to show the error component for these) @@ -166,17 +162,16 @@ export const Match = (props: { matchId: string }) => { > - } > - - + + - + @@ -185,13 +180,10 @@ export const Match = (props: { matchId: string }) => { {currentMatchState().parentRouteId === rootRouteId ? ( - <> - - {router.options.scrollRestoration && - (isServer ?? router.isServer) ? ( - - ) : null} - + router.options.scrollRestoration && + (isServer ?? router.isServer) ? ( + + ) : null ) : null} ) @@ -200,30 +192,6 @@ export const Match = (props: { matchId: string }) => { ) } -// On Rendered can't happen above the root layout because it needs to run -// after the app has committed below the root layout. Keeping it here lets us -// fire onRendered even after a hydration mismatch above the root layout -// (like bad head/link tags, which is common). -function OnRendered() { - const router = useRouter() - - const location = Solid.createMemo( - () => router.stores.resolvedLocation.get()?.state.__TSR_key, - ) - Solid.createEffect( - Solid.on([location], () => { - router.emit({ - type: 'onRendered', - ...getLocationChangeInfo( - router.stores.location.get(), - router.stores.resolvedLocation.get(), - ), - }) - }), - ) - return null -} - export const MatchInner = (): any => { const router = useRouter() const match = Solid.useContext(nearestMatchContext).match @@ -233,7 +201,6 @@ export const MatchInner = (): any => { if (!currentMatch) { return null } - const routeId = currentMatch.routeId as string const remountFn = @@ -254,8 +221,6 @@ export const MatchInner = (): any => { id: currentMatch.id, status: currentMatch.status, error: currentMatch.error, - _forcePending: currentMatch._forcePending ?? false, - _displayPending: currentMatch._displayPending ?? false, }, } }) @@ -279,22 +244,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,76 +252,6 @@ 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()} - }} - - - {(_) => { - 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 [loaderResult] = Solid.createResource(async () => { - await Promise.resolve() - return router.getMatch(currentMatch().id)?._nonReactive - .loadPromise - }) - - const FallbackComponent = - route().options.pendingComponent ?? - router.options.defaultPendingComponent - - return ( - <> - {FallbackComponent && pendingMinMs > 0 ? ( - - ) : null} - {loaderResult()} - - ) - }} - {(_) => { if (!isNotFound(currentMatch().error)) { @@ -395,29 +274,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 +325,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 @@ -492,7 +341,7 @@ export const Outlet = () => { fallback={ {(resolvedRoute) => - renderRouteNotFound(router, resolvedRoute(), undefined) + renderRouteNotFound(router, resolvedRoute(), parentMatch()?.error) } } diff --git a/packages/solid-router/src/Matches.tsx b/packages/solid-router/src/Matches.tsx index 75586f862d..63f2a8f4c7 100644 --- a/packages/solid-router/src/Matches.tsx +++ b/packages/solid-router/src/Matches.tsx @@ -62,20 +62,15 @@ export function Matches() { function MatchesInner() { const router = useRouter() - const matchId = () => router.stores.firstId.get() + const matchId = () => router.stores.matchesId.get()[0] const routeId = () => (matchId() ? rootRouteId : undefined) const match = () => routeId() ? router.stores.getRouteMatchStore(rootRouteId).get() : undefined - const hasPendingMatch = () => - routeId() - ? Boolean(router.stores.pendingRouteIds.get()[rootRouteId]) - : false - const resetKey = () => router.stores.loadedAt.get() + const resetKey = () => match()?.fetchCount ?? 0 const nearestMatch = { matchId, routeId, match, - hasPending: hasPendingMatch, } const matchComponent = () => { diff --git a/packages/solid-router/src/Transitioner.tsx b/packages/solid-router/src/Transitioner.tsx index 331aff5864..72d5f10300 100644 --- a/packages/solid-router/src/Transitioner.tsx +++ b/packages/solid-router/src/Transitioner.tsx @@ -5,34 +5,28 @@ import { useRouter } from './useRouter' export function Transitioner() { const router = useRouter() - let mountLoadForRouter = { router, mounted: false } - const isLoading = Solid.createMemo(() => router.stores.isLoading.get()) if (isServer ?? router.isServer) { return null } - const [isSolidTransitioning, startSolidTransition] = Solid.useTransition() - - // Track pending state changes - const hasPending = Solid.createMemo(() => router.stores.hasPending.get()) - - const isAnyPending = Solid.createMemo( - () => isLoading() || isSolidTransitioning() || hasPending(), - ) - - const isPagePending = Solid.createMemo(() => isLoading() || hasPending()) - - router.startTransition = (fn: () => void | Promise) => { - Solid.startTransition(() => { - startSolidTransition(fn) - }) + const previousTransition = router.startTransition + const transition = async (fn: () => void) => { + await Solid.startTransition(fn) + return true } + router.startTransition = transition + Solid.onCleanup(() => { + if (router.startTransition === transition) { + router.startTransition = previousTransition + } + }) // Subscribe to location changes // and try to load the new location Solid.onMount(() => { const unsub = router.history.subscribe(router.load) + Solid.onCleanup(unsub) const nextLocation = router.buildLocation({ to: router.latestLocation.pathname, @@ -51,87 +45,21 @@ export function Transitioner() { trimPathRight(nextLocation.publicHref) ) { router.commitLocation({ ...nextLocation, replace: true }) + return } - Solid.onCleanup(() => { - unsub() - }) - }) - - // Try to load the initial location - Solid.createRenderEffect(() => { - Solid.untrack(() => { - if ( - // if we are hydrating from SSR, loading is triggered in ssr-client - (typeof window !== 'undefined' && router.ssr) || - (mountLoadForRouter.router === router && mountLoadForRouter.mounted) - ) { - return - } - mountLoadForRouter = { router, mounted: true } - const tryLoad = async () => { - try { - await router.load() - } catch (err) { - console.error(err) - } - } - tryLoad() - }) - }) - - Solid.createRenderEffect((previousIsLoading = false) => { - const currentIsLoading = isLoading() - - if (previousIsLoading && !currentIsLoading) { - router.emit({ - type: 'onLoad', - ...getLocationChangeInfo( - router.stores.location.get(), - router.stores.resolvedLocation.get(), - ), - }) - } - - return currentIsLoading - }) - - Solid.createComputed((previousIsPagePending = false) => { - const currentIsPagePending = isPagePending() - - if (previousIsPagePending && !currentIsPagePending) { - router.emit({ - type: 'onBeforeRouteMount', - ...getLocationChangeInfo( - router.stores.location.get(), - router.stores.resolvedLocation.get(), - ), - }) - } - - return currentIsPagePending - }) - - Solid.createRenderEffect((previousIsAnyPending = false) => { - const currentIsAnyPending = isAnyPending() - - if (previousIsAnyPending && !currentIsAnyPending) { - const changeInfo = getLocationChangeInfo( - router.stores.location.get(), - router.stores.resolvedLocation.get(), - ) + const resolvedLocation = router.stores.resolvedLocation.get() + if ( + resolvedLocation?.href === router.latestLocation.href && + resolvedLocation.state.__TSR_key === router.latestLocation.state.__TSR_key + ) { router.emit({ - type: 'onResolved', - ...changeInfo, - }) - - Solid.batch(() => { - router.stores.status.set('idle') - router.stores.resolvedLocation.set(router.stores.location.get()) + type: 'onRendered', + ...getLocationChangeInfo(resolvedLocation, resolvedLocation), }) + } else { + router.load().catch(console.error) } - - return currentIsAnyPending }) return null diff --git a/packages/solid-router/src/lazyRouteComponent.tsx b/packages/solid-router/src/lazyRouteComponent.tsx index 424940522b..4c842bbe61 100644 --- a/packages/solid-router/src/lazyRouteComponent.tsx +++ b/packages/solid-router/src/lazyRouteComponent.tsx @@ -18,6 +18,7 @@ export function lazyRouteComponent< const load = () => { if (!loadPromise) { + error = undefined loadPromise = importer() .then((res) => { loadPromise = undefined @@ -25,6 +26,7 @@ export function lazyRouteComponent< return comp }) .catch((err) => { + loadPromise = undefined error = err }) } diff --git a/packages/solid-router/src/matchContext.tsx b/packages/solid-router/src/matchContext.tsx index f6efc37c6b..3eeb2440cc 100644 --- a/packages/solid-router/src/matchContext.tsx +++ b/packages/solid-router/src/matchContext.tsx @@ -5,14 +5,12 @@ export type NearestMatchContextValue = { matchId: Solid.Accessor routeId: Solid.Accessor match: Solid.Accessor - hasPending: Solid.Accessor } const defaultNearestMatchContext: NearestMatchContextValue = { matchId: () => undefined, routeId: () => undefined, match: () => undefined, - hasPending: () => false, } export const nearestMatchContext = diff --git a/packages/solid-router/src/routerStores.ts b/packages/solid-router/src/routerStores.ts index 11c06a35ef..51be801aec 100644 --- a/packages/solid-router/src/routerStores.ts +++ b/packages/solid-router/src/routerStores.ts @@ -16,8 +16,6 @@ declare module '@tanstack/router-core' { export interface RouterStores { /** Maps each active routeId to the matchId of its child in the match tree. */ childMatchIdByRouteId: RouterReadableStore> - /** Maps each pending routeId to true for quick lookup. */ - pendingRouteIds: RouterReadableStore> } } @@ -38,18 +36,6 @@ function initRouterStores( } return obj }) - - stores.pendingRouteIds = createReadonlyStore(() => { - const ids = stores.pendingIds.get() - const obj: Record = {} - for (const id of ids) { - const store = stores.pendingMatchStores.get(id) - if (store?.routeId) { - obj[store.routeId] = true - } - } - return obj - }) } function createSolidMutableStore( diff --git a/packages/solid-router/src/ssr/renderRouterToStream.tsx b/packages/solid-router/src/ssr/renderRouterToStream.tsx index 8914c117d9..8c284c0375 100644 --- a/packages/solid-router/src/ssr/renderRouterToStream.tsx +++ b/packages/solid-router/src/ssr/renderRouterToStream.tsx @@ -172,7 +172,10 @@ export const renderRouterToStream = async ({ return createSsrStreamResponse( router, new Response(responseStream as any, { - status: router.stores.statusCode.get(), + status: + router._serverResult?.type === 'render' + ? router._serverResult.status + : 200, headers: responseHeaders, }), ) diff --git a/packages/solid-router/src/ssr/renderRouterToString.tsx b/packages/solid-router/src/ssr/renderRouterToString.tsx index 7da982b392..421ac234bc 100644 --- a/packages/solid-router/src/ssr/renderRouterToString.tsx +++ b/packages/solid-router/src/ssr/renderRouterToString.tsx @@ -32,7 +32,10 @@ export const renderRouterToString = ({ html = html.replace(``, () => `${injectedHtml}`) } return new Response(`${html}`, { - status: router.stores.statusCode.get(), + status: + router._serverResult?.type === 'render' + ? router._serverResult.status + : 200, headers: responseHeaders, }) } catch (error) { diff --git a/packages/solid-router/src/useMatch.tsx b/packages/solid-router/src/useMatch.tsx index 858d75be6b..12ef9bb16f 100644 --- a/packages/solid-router/src/useMatch.tsx +++ b/packages/solid-router/src/useMatch.tsx @@ -70,9 +70,8 @@ export function useMatch< ThrowOrOptional, TThrow> > { const router = useRouter() - const nearestMatch = opts.from - ? undefined - : Solid.useContext(nearestMatchContext) + const contextMatch = Solid.useContext(nearestMatchContext) + const nearestMatch = opts.from ? undefined : contextMatch const match = () => { if (opts.from) { @@ -87,15 +86,7 @@ export function useMatch< return } - const hasPendingMatch = opts.from - ? Boolean(router.stores.pendingRouteIds.get()[opts.from!]) - : (nearestMatch?.hasPending() ?? false) - - if ( - !hasPendingMatch && - !router.stores.isTransitioning.get() && - (opts.shouldThrow ?? true) - ) { + if (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!'}`, @@ -110,17 +101,6 @@ export function useMatch< const selectedMatch = match() if (selectedMatch === undefined) { - const hasPendingMatch = opts.from - ? Boolean(router.stores.pendingRouteIds.get()[opts.from!]) - : (nearestMatch?.hasPending() ?? false) - - if ( - prev !== undefined && - (hasPendingMatch || router.stores.isTransitioning.get()) - ) { - return prev - } - return undefined } diff --git a/packages/solid-router/tests/Transitioner.test.tsx b/packages/solid-router/tests/Transitioner.test.tsx index dffe2bd4fd..26fa0da5b4 100644 --- a/packages/solid-router/tests/Transitioner.test.tsx +++ b/packages/solid-router/tests/Transitioner.test.tsx @@ -31,7 +31,6 @@ describe('Transitioner', () => { const loadSpy = vi.spyOn(router, 'load') render(() => ) - await router.latestLoadPromise // Wait for the createRenderEffect to run and call router.load() await waitFor(() => { diff --git a/packages/solid-router/tests/component-preload-retry.test.tsx b/packages/solid-router/tests/component-preload-retry.test.tsx new file mode 100644 index 0000000000..41ec6bbc1a --- /dev/null +++ b/packages/solid-router/tests/component-preload-retry.test.tsx @@ -0,0 +1,61 @@ +import { afterEach, expect, test, vi } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library' +import { + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + lazyRouteComponent, + useRouter, +} from '../src' +import type { ErrorComponentProps } from '../src' + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +test('a failed component download is retried from the route error UI', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + + const PageContent = () =>
Page content
+ const importer = vi + .fn<() => Promise<{ default: typeof PageContent }>>() + .mockRejectedValueOnce(new Error('component download failed')) + .mockResolvedValue({ default: PageContent }) + const Page = lazyRouteComponent(importer) + + function RouteError(props: ErrorComponentProps) { + const router = useRouter() + return ( + + ) + } + + const rootRoute = createRootRoute() + const pageRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/page', + component: Page, + errorComponent: RouteError, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + }) + + render(() => ) + + fireEvent.click(await screen.findByRole('button', { name: 'Retry' })) + + expect(await screen.findByText('Page content')).toBeInTheDocument() +}) diff --git a/packages/solid-router/tests/link.test.tsx b/packages/solid-router/tests/link.test.tsx index d0fc7ae469..154b6bbf23 100644 --- a/packages/solid-router/tests/link.test.tsx +++ b/packages/solid-router/tests/link.test.tsx @@ -1426,10 +1426,12 @@ describe('Link', () => { expect(window.location.pathname).toBe('/Dashboard/posts') expect(window.location.search).toBe('?page=2&filter=inactive') - 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(screen.getByTestId('current-page')).toHaveTextContent('Page: 2') + expect(screen.getByTestId('current-filter')).toHaveTextContent( + 'Filter: inactive', + ) + }) }) test('when navigating to /posts with invalid search', async () => { diff --git a/packages/solid-router/tests/loaders.test.tsx b/packages/solid-router/tests/loaders.test.tsx index 063660f592..e3bb87076d 100644 --- a/packages/solid-router/tests/loaders.test.tsx +++ b/packages/solid-router/tests/loaders.test.tsx @@ -11,7 +11,6 @@ import { createRootRoute, createRoute, createRouter, - useLoaderData, useRouter, } from '../src' @@ -324,17 +323,20 @@ test('throw error from beforeLoad when navigating to route', async () => { test('throw abortError from loader upon initial load with basepath', async () => { window.history.replaceState(null, 'root', '/app') const rootRoute = createRootRoute({}) + const abortError = new DOMException('Aborted', 'AbortError') + const renderedError = vi.fn() const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', loader: async () => { - return Promise.reject(new DOMException('Aborted', 'AbortError')) + return Promise.reject(abortError) }, component: () =>
Index route content
, - errorComponent: () => ( -
indexErrorComponent
- ), + errorComponent: ({ error }) => { + renderedError(error) + return
indexErrorComponent
+ }, }) const routeTree = rootRoute.addChildren([indexRoute]) @@ -342,12 +344,68 @@ 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() + expect(await screen.findByTestId('index-error')).toBeInTheDocument() + expect(screen.queryByText('Index route content')).not.toBeInTheDocument() + // jsdom creates DOMException in another realm, so Solid wraps the error. + expect(renderedError).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Unknown error', + cause: abortError, + }), + ) + expect( + router.state.matches.find((match) => match.routeId === indexRoute.id), + ).toMatchObject({ + status: 'error', + error: abortError, + }) expect(window.location.pathname.startsWith('/app')).toBe(true) }) +// https://github.com/TanStack/router/pull/7673 +test('#7673: aborted loader does not render the route component with undefined loaderData', async () => { + const abortError = new DOMException('Aborted', 'AbortError') + const routeComponentRendered = vi.fn() + const renderedError = vi.fn() + const rootRoute = createRootRoute({}) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + loader: (): Promise<{ value: string }> => Promise.reject(abortError), + component: () => { + routeComponentRendered() + const data = indexRoute.useLoaderData() + return
{data().value}
+ }, + errorComponent: ({ error }) => { + renderedError(error) + return
indexErrorComponent
+ }, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + }) + + render(() => ) + + expect(await screen.findByTestId('index-error')).toBeInTheDocument() + expect(screen.queryByTestId('index-content')).not.toBeInTheDocument() + expect(routeComponentRendered).not.toHaveBeenCalled() + // jsdom creates DOMException in another realm, so Solid wraps the error. + expect(renderedError).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Unknown error', + cause: abortError, + }), + ) + expect( + router.state.matches.find((match) => match.routeId === indexRoute.id), + ).toMatchObject({ + status: 'error', + error: abortError, + }) +}) + test('reproducer #4245', async () => { const LOADER_WAIT_TIME = 500 const rootRoute = createRootRoute({}) @@ -660,7 +718,7 @@ test('reproducer #4546', async () => { } }) -test('clears pendingTimeout when match resolves', async () => { +test('does not show pending UI when loaders finish before their pending delays', async () => { const defaultPendingComponentOnMountMock = vi.fn() const nestedPendingComponentOnMountMock = vi.fn() const fooPendingComponentOnMountMock = vi.fn() @@ -728,7 +786,6 @@ test('clears pendingTimeout when match resolves', async () => { }) render(() => ) - await router.latestLoadPromise const linkToFoo = await screen.findByTestId('link-to-foo') fireEvent.click(linkToFoo) const fooElement = await screen.findByText('Nested Foo page') @@ -742,52 +799,7 @@ test('clears pendingTimeout when match resolves', async () => { expect(fooPendingComponentOnMountMock).not.toHaveBeenCalled() }) -test('useLoaderData retains previous data while route match is pending', async () => { - const history = createMemoryHistory({ initialEntries: ['/app'] }) - const rootRoute = createRootRoute({ - component: () => { - const loaderData = useLoaderData({ from: '/app' }) - - return ( - <> -
{`${loaderData().length}:0`}
- - - ) - }, - }) - const appRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/app', - loader: () => 'loaded', - component: () =>
App route
, - }) - const routeTree = rootRoute.addChildren([appRoute]) - const router = createRouter({ routeTree, history }) - - render(() => ) - - expect(await screen.findByTestId('combined')).toHaveTextContent('6:0') - - const appMatch = router.state.matches.find( - (match) => match.routeId === '/app', - ) - - expect(appMatch).toBeDefined() - - if (!appMatch) { - throw new Error('Expected /app match to be active') - } - - router.stores.setPending([{ ...appMatch, id: `${appMatch.id}__pending` }]) - router.stores.setMatches( - router.state.matches.filter((match) => match.routeId !== '/app'), - ) - - expect(screen.getByTestId('combined')).toHaveTextContent('6:0') -}) - -test('cancelMatches after pending timeout', async () => { +test('navigating away from a pending route aborts its loader', async () => { function getPendingComponent(onMount: () => void) { const PendingComponent = () => { onMount() @@ -839,7 +851,6 @@ test('cancelMatches after pending timeout', async () => { const routeTree = rootRoute.addChildren([fooRoute, barRoute]) const router = createRouter({ routeTree, history }) render(() => ) - await router.latestLoadPromise const fooLink = await screen.findByTestId('link-to-foo') fireEvent.click(fooLink) await sleep(WAIT_TIME * 30) diff --git a/packages/solid-router/tests/on-rendered-change-info.test.tsx b/packages/solid-router/tests/on-rendered-change-info.test.tsx new file mode 100644 index 0000000000..bf5d53cefb --- /dev/null +++ b/packages/solid-router/tests/on-rendered-change-info.test.tsx @@ -0,0 +1,64 @@ +import { cleanup, render, screen, waitFor } from '@solidjs/testing-library' +import { afterEach, expect, test, vi } from 'vitest' +import { + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +afterEach(() => { + cleanup() +}) + +test('onRendered describes the navigation from the previously rendered location', async () => { + const rootRoute = createRootRoute({ component: () => }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Index
, + }) + const nextRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/next', + component: () =>
Next
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, nextRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render(() => ) + expect(await screen.findByText('Index')).toBeInTheDocument() + await waitFor(() => expect(router.state.status).toBe('idle')) + + const onRendered = vi.fn() + const unsubscribe = router.subscribe('onRendered', onRendered) + + await router.navigate({ to: '/next' }) + expect(await screen.findByText('Next')).toBeInTheDocument() + await waitFor(() => expect(onRendered).toHaveBeenCalledTimes(1)) + + const event = onRendered.mock.calls[0]![0] + expect(event.fromLocation?.pathname).toBe('/') + expect(event.toLocation.pathname).toBe('/next') + expect(event.pathChanged).toBe(true) + expect(event.hrefChanged).toBe(true) + + await router.navigate({ + to: '/next', + state: { sameHrefState: true } as any, + }) + await waitFor(() => expect(onRendered).toHaveBeenCalledTimes(2)) + + const stateEvent = onRendered.mock.calls[1]![0] + expect(stateEvent.fromLocation?.state.sameHrefState).toBeUndefined() + expect(stateEvent.toLocation.state.sameHrefState).toBe(true) + expect(stateEvent.fromLocation?.href).toBe('/next') + expect(stateEvent.toLocation.href).toBe('/next') + expect(stateEvent.hrefChanged).toBe(false) + + unsubscribe() +}) diff --git a/packages/solid-router/tests/pending-fallback-promise-replacement.test.tsx b/packages/solid-router/tests/pending-fallback-promise-replacement.test.tsx new file mode 100644 index 0000000000..a6b84fedba --- /dev/null +++ b/packages/solid-router/tests/pending-fallback-promise-replacement.test.tsx @@ -0,0 +1,179 @@ +import { cleanup, render, screen } from '@solidjs/testing-library' +import { afterEach, expect, test, vi } from 'vitest' +import { createControlledPromise } from '@tanstack/router-core' +import { + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +const testCleanups: Array<() => void | Promise> = [] + +afterEach(async () => { + vi.useRealTimers() + while (testCleanups.length) { + await testCleanups.pop()!() + } + cleanup() +}) + +test('a mounted child pending fallback follows a replacement load promise for the same match', async () => { + const firstReload = createControlledPromise() + const secondReload = createControlledPromise() + const reloads = [firstReload, secondReload] + let loaderCall = 0 + + const rootRoute = createRootRoute({ component: () => }) + const pageRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/page', + pendingMs: 0, + pendingMinMs: 100, + pendingComponent: () =>
Pending
, + loader: () => { + const generation = ++loaderCall + const gate = reloads[generation - 2] + return gate ? gate.then(() => generation) : generation + }, + component: () =>
Generation {pageRoute.useLoaderData()()}
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + }) + + render(() => ) + expect(await screen.findByText('Generation 1')).toBeInTheDocument() + + vi.useFakeTimers() + const firstInvalidation = router.invalidate({ forcePending: true }) + const invalidations = [firstInvalidation] + testCleanups.push(async () => { + firstReload.resolve() + secondReload.resolve() + await Promise.allSettled(invalidations) + }) + await vi.advanceTimersByTimeAsync(0) + expect(screen.getByTestId('pending')).toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(25) + let secondSettled = false + const secondInvalidation = router + .invalidate({ forcePending: true }) + .then(() => { + secondSettled = true + }) + invalidations.push(secondInvalidation) + + firstReload.resolve() + secondReload.resolve() + await Promise.resolve() + + await vi.advanceTimersByTimeAsync(74) + expect(secondSettled).toBe(false) + expect(screen.getByTestId('pending')).toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(1) + await Promise.all(invalidations) + expect(screen.getByText('Generation 3')).toBeInTheDocument() + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() +}) + +test('the root pending fallback follows a replacement load promise for the same match', async () => { + const firstReload = createControlledPromise() + const secondReload = createControlledPromise() + const reloads = [firstReload, secondReload] + let loaderCall = 0 + + const rootRoute = createRootRoute({ + pendingMs: 0, + pendingMinMs: 100, + pendingComponent: () =>
Pending
, + loader: () => { + const generation = ++loaderCall + const gate = reloads[generation - 2] + return gate ? gate.then(() => generation) : generation + }, + component: () =>
Generation {rootRoute.useLoaderData()()}
, + }) + const router = createRouter({ + routeTree: rootRoute, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render(() => ) + expect(await screen.findByText('Generation 1')).toBeInTheDocument() + + vi.useFakeTimers() + const firstInvalidation = router.invalidate({ forcePending: true }) + const invalidations = [firstInvalidation] + testCleanups.push(async () => { + firstReload.resolve() + secondReload.resolve() + await Promise.allSettled(invalidations) + }) + await vi.advanceTimersByTimeAsync(0) + expect(screen.getByTestId('root-pending')).toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(25) + let secondSettled = false + const secondInvalidation = router + .invalidate({ forcePending: true }) + .then(() => { + secondSettled = true + }) + invalidations.push(secondInvalidation) + + firstReload.resolve() + secondReload.resolve() + await Promise.resolve() + + await vi.advanceTimersByTimeAsync(74) + expect(secondSettled).toBe(false) + expect(screen.getByTestId('root-pending')).toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(1) + await Promise.all(invalidations) + expect(screen.getByText('Generation 3')).toBeInTheDocument() + expect(screen.queryByTestId('root-pending')).not.toBeInTheDocument() +}) + +test('forcePending honors pendingMinMs when the reload settles before pendingMs', async () => { + const reload = createControlledPromise() + let loaderCall = 0 + + const rootRoute = createRootRoute({ component: () => }) + const pageRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/page', + pendingMinMs: 100, + pendingComponent: () =>
Pending
, + loader: async () => { + if (++loaderCall > 1) { + await reload + } + return loaderCall + }, + component: () =>
Generation {pageRoute.useLoaderData()()}
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + }) + + render(() => ) + expect(await screen.findByText('Generation 1')).toBeInTheDocument() + + const invalidation = router.invalidate({ forcePending: true }) + expect(await screen.findByTestId('fast-pending')).toBeInTheDocument() + reload.resolve() + + await new Promise((resolve) => setTimeout(resolve, 25)) + expect(screen.getByTestId('fast-pending')).toBeInTheDocument() + + await invalidation + expect(await screen.findByText('Generation 2')).toBeInTheDocument() +}) diff --git a/packages/solid-router/tests/redirect.test.tsx b/packages/solid-router/tests/redirect.test.tsx index 81bd1f6bc6..e045383d31 100644 --- a/packages/solid-router/tests/redirect.test.tsx +++ b/packages/solid-router/tests/redirect.test.tsx @@ -7,7 +7,6 @@ import { Outlet, RouterProvider, createBrowserHistory, - createMemoryHistory, createRootRoute, createRoute, createRouter, @@ -147,7 +146,6 @@ describe('redirect', () => { expect(await screen.findByTestId('lazy-route-page')).toBeInTheDocument() expect(screen.queryByTestId('pending')).not.toBeInTheDocument() expect(router.state.location.href).toBe('/posts') - expect(router.state.status).toBe('idle') expect(consoleError).not.toHaveBeenCalled() }) @@ -303,116 +301,4 @@ describe('redirect', () => { expect(window.location.pathname).toBe('/final') }) }) - - describe('SSR', () => { - test('when `redirect` is thrown in `beforeLoad`', async () => { - const rootRoute = createRootRoute() - - const indexRoute = createRoute({ - path: '/', - getParentRoute: () => rootRoute, - beforeLoad: () => { - throw redirect({ - to: '/about', - }) - }, - }) - - const aboutRoute = createRoute({ - path: '/about', - getParentRoute: () => rootRoute, - component: () => { - return 'About' - }, - }) - - const router = createRouter({ - routeTree: rootRoute.addChildren([indexRoute, aboutRoute]), - // Mock server mode - isServer: true, - history: createMemoryHistory({ - initialEntries: ['/'], - }), - }) - - await router.load() - - expect(router.state.redirect).toBeDefined() - expect(router.state.redirect).toBeInstanceOf(Response) - const redirectResponse = router.state.redirect! - - expect(redirectResponse.options).toEqual({ - _fromLocation: expect.objectContaining({ - hash: '', - href: '/', - pathname: '/', - search: {}, - searchStr: '', - }), - to: '/about', - href: '/about', - statusCode: 307, - }) - }) - }) - - test('when `redirect` is thrown in `loader`', async () => { - const rootRoute = createRootRoute() - - const indexRoute = createRoute({ - path: '/', - getParentRoute: () => rootRoute, - loader: () => { - throw redirect({ - to: '/about', - }) - }, - }) - - const aboutRoute = createRoute({ - path: '/about', - getParentRoute: () => rootRoute, - component: () => { - return 'About' - }, - }) - - const router = createRouter({ - history: createMemoryHistory({ - initialEntries: ['/'], - }), - routeTree: rootRoute.addChildren([indexRoute, aboutRoute]), - // Mock server mode - isServer: true, - }) - - await router.load() - - const currentRedirect = router.state.redirect - - expect(currentRedirect).toBeDefined() - expect(currentRedirect).toBeInstanceOf(Response) - const redirectResponse = currentRedirect! - expect(redirectResponse.status).toEqual(307) - expect(redirectResponse.headers.get('Location')).toEqual('/about') - expect(redirectResponse.options).toEqual({ - _fromLocation: { - external: false, - publicHref: '/', - hash: '', - href: '/', - pathname: '/', - search: {}, - searchStr: '', - state: { - __TSR_index: 0, - __TSR_key: redirectResponse.options._fromLocation!.state.__TSR_key, - key: redirectResponse.options._fromLocation!.state.key, - }, - }, - href: '/about', - to: '/about', - statusCode: 307, - }) - }) }) diff --git a/packages/solid-router/tests/router.test.tsx b/packages/solid-router/tests/router.test.tsx index 1ee017937a..dcec6f0012 100644 --- a/packages/solid-router/tests/router.test.tsx +++ b/packages/solid-router/tests/router.test.tsx @@ -1720,12 +1720,13 @@ 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 () => { +describe('navigation outcomes', () => { + it('should recover from a not-found route when navigating to a valid route', async () => { const history = createMemoryHistory({ initialEntries: ['/'] }) const rootRoute = createRootRoute({ component: () => , + notFoundComponent: () =>
Not Found
, }) const indexRoute = createRoute({ @@ -1745,23 +1746,21 @@ describe('statusCode reset on navigation', () => { render(() => ) - expect(router.state.statusCode).toBe(200) - await router.navigate({ to: '/' }) - await waitFor(() => expect(router.state.statusCode).toBe(200)) + expect(await screen.findByText('Home')).toBeInTheDocument() await router.navigate({ to: '/non-existing' }) - await waitFor(() => expect(router.state.statusCode).toBe(404)) + expect(await screen.findByText('Not Found')).toBeInTheDocument() await router.navigate({ to: '/valid' }) - await waitFor(() => expect(router.state.statusCode).toBe(200)) + expect(await screen.findByText('Valid Route')).toBeInTheDocument() await router.navigate({ to: '/another-non-existing' }) - await waitFor(() => expect(router.state.statusCode).toBe(404)) + expect(await screen.findByText('Not Found')).toBeInTheDocument() }) describe.each([true, false])( - 'status code is set when loader/beforeLoad throws (isAsync=%s)', + 'loader and beforeLoad outcomes are rendered (isAsync=%s)', (isAsync) => { const throwingFun = isAsync ? (toThrow: () => void) => async () => { @@ -1776,7 +1775,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('should render notFoundComponent when a route loader throws a notFound()', async () => { const history = createMemoryHistory({ initialEntries: ['/'] }) const rootRoute = createRootRoute() @@ -1804,17 +1803,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('should render notFoundComponent when a route beforeLoad throws a notFound()', async () => { const history = createMemoryHistory({ initialEntries: ['/'] }) const rootRoute = createRootRoute({ @@ -1849,17 +1845,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('should render errorComponent when a route loader throws an Error', async () => { const history = createMemoryHistory({ initialEntries: ['/'] }) const rootRoute = createRootRoute() @@ -1885,15 +1878,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('should render errorComponent when a route beforeLoad throws an Error', async () => { const history = createMemoryHistory({ initialEntries: ['/'] }) const rootRoute = createRootRoute() @@ -1922,10 +1912,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 +2044,6 @@ describe('basepath', () => { }) expect(router.state.location.pathname).toBe('/') - expect(router.state.statusCode).toBe(200) }, ) diff --git a/packages/solid-router/tests/same-route-pending-blank.test.tsx b/packages/solid-router/tests/same-route-pending-blank.test.tsx new file mode 100644 index 0000000000..755f28c835 --- /dev/null +++ b/packages/solid-router/tests/same-route-pending-blank.test.tsx @@ -0,0 +1,103 @@ +import { cleanup, render, screen } from '@solidjs/testing-library' +import { afterEach, expect, test } from 'vitest' +import { + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +/** + * Solid same-route pending replacement should not blank stale content + * before pending UI is ready. Core can expose a replacement pending match while + * the route has no pending fallback and a long pendingMs delay. + * + * This test renders a real Solid RouterProvider, navigates from page 1 to page + * 2 on the same route, and leaves the page 2 loader unresolved. Until pendingMs + * elapses, the previously committed page 1 content should remain visible. + */ + +let resolvePendingPage2: (() => void) | undefined +let pendingNavigation: Promise | undefined + +afterEach(async () => { + resolvePendingPage2?.() + if (pendingNavigation) { + await Promise.allSettled([pendingNavigation]) + } + resolvePendingPage2 = undefined + pendingNavigation = undefined + cleanup() +}) + +function deferred() { + let resolve!: (value: T | PromiseLike) => void + const promise = new Promise((resolver) => { + resolve = resolver + }) + + return { promise, resolve } +} + +test('same-route pending replacement without fallback keeps stale content until pendingMs', async () => { + const pendingMs = 10_000 + const page2Gate = deferred() + const page2Started = deferred() + resolvePendingPage2 = page2Gate.resolve + 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, + loader: async ({ deps }) => { + if (deps.page === 2) { + page2Started.resolve() + await page2Gate.promise + } + + return `Page ${deps.page}` + }, + component: Posts, + }) + const router = createRouter({ + routeTree: root.addChildren([postsRoute]), + history, + defaultPendingMs: pendingMs, + }) + + render(() => ) + expect(await screen.findByText('Page 1')).toBeInTheDocument() + + const navigation = router.navigate({ + to: '/posts', + search: { page: 2 }, + }) + pendingNavigation = navigation + + await page2Started.promise + + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(screen.getByTestId('post-content')).toHaveTextContent('Page 1') + + page2Gate.resolve() + await navigation + pendingNavigation = undefined + + expect(await screen.findByText('Page 2')).toBeInTheDocument() +}) diff --git a/packages/solid-router/tests/server/errorComponent.test.tsx b/packages/solid-router/tests/server/errorComponent.test.tsx index f342aaae0d..618c9a0039 100644 --- a/packages/solid-router/tests/server/errorComponent.test.tsx +++ b/packages/solid-router/tests/server/errorComponent.test.tsx @@ -39,7 +39,6 @@ describe('errorComponent (server)', () => { )) - expect(router.state.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..ded5e5bc2f 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(3) }) test('redirection in preload', async () => { @@ -172,7 +172,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(3) }) test('nothing', async () => { @@ -183,7 +183,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(3) + expect(updates).toBe(2) }) test('not found in beforeLoad', async () => { @@ -198,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(2) }) test('hover preload, then navigate, w/ async loaders', async () => { @@ -240,7 +240,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(3) + expect(updates).toBe(2) }) test('navigate, w/ preloaded & sync loaders', async () => { @@ -256,7 +256,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(3) + expect(updates).toBe(2) }) test('navigate, w/ previous navigation & async loader', async () => { @@ -272,7 +272,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(3) + expect(updates).toBe(2) }) test('preload a preloaded route w/ async loader', async () => { diff --git a/packages/solid-router/tests/transitioner-listener-errors.test.tsx b/packages/solid-router/tests/transitioner-listener-errors.test.tsx new file mode 100644 index 0000000000..58d7ed392a --- /dev/null +++ b/packages/solid-router/tests/transitioner-listener-errors.test.tsx @@ -0,0 +1,61 @@ +import { cleanup, render, screen } from '@solidjs/testing-library' +import { afterEach, expect, test, vi } from 'vitest' +import { + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +afterEach(() => { + cleanup() +}) + +test('a throwing load-event listener cannot interrupt route hooks or later navigations', async () => { + const firstOnEnter = vi.fn() + const secondOnEnter = vi.fn() + const listenerError = new Error('onLoad listener failed') + + const rootRoute = createRootRoute({ component: () => }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Index route
, + }) + const firstRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/first', + onEnter: firstOnEnter, + component: () =>
First route
, + }) + const secondRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/second', + onEnter: secondOnEnter, + component: () =>
Second route
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, firstRoute, secondRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render(() => ) + expect(await screen.findByText('Index route')).toBeInTheDocument() + + const unsubscribe = router.subscribe('onLoad', (event) => { + if (event.toLocation.pathname === '/first') { + throw listenerError + } + }) + + await router.navigate({ to: '/first' }) + expect(await screen.findByText('First route')).toBeInTheDocument() + expect(firstOnEnter).toHaveBeenCalledTimes(1) + + unsubscribe() + await router.navigate({ to: '/second' }) + expect(await screen.findByText('Second route')).toBeInTheDocument() + expect(secondOnEnter).toHaveBeenCalledTimes(1) +}) diff --git a/packages/solid-router/tests/transitioner-remount.test.tsx b/packages/solid-router/tests/transitioner-remount.test.tsx new file mode 100644 index 0000000000..cbccf15df2 --- /dev/null +++ b/packages/solid-router/tests/transitioner-remount.test.tsx @@ -0,0 +1,82 @@ +import { cleanup, render, screen, waitFor } from '@solidjs/testing-library' +import { afterEach, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { + Outlet, + RouterProvider, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +afterEach(() => { + cleanup() +}) + +test('remounting an SSR-marked router loads a history change that happened while unmounted', async () => { + const rootRoute = createRootRoute({ component: () => }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Index
, + }) + const nextRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/next', + component: () =>
Next
, + }) + const history = createMemoryHistory({ initialEntries: ['/'] }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, nextRoute]), + history, + }) + + // This is the stable post-hydration shape: server matches are active and + // the persistent SSR marker tells the first Transitioner mount not to load. + await router.load() + router.ssr = { manifest: { routes: {} } } + + const firstRender = render(() => ) + expect(await screen.findByText('Index')).toBeInTheDocument() + + firstRender.unmount() + history.push('/next') + + const secondRender = render(() => ) + expect(await screen.findByText('Next')).toBeInTheDocument() + expect(router.state.location.pathname).toBe('/next') + + secondRender.unmount() + history.replace('/next', { remounted: true }) + + render(() => ) + await waitFor(() => { + expect((router.state.location.state as any).remounted).toBe(true) + }) +}) + +test('remounting the provider emits onRendered for the newly mounted DOM', async () => { + const rootRoute = createRootRoute({ component: () => }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Index
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + const onRendered = vi.fn() + const unsubscribe = router.subscribe('onRendered', onRendered) + + const firstRender = render(() => ) + expect(await screen.findByText('Index')).toBeInTheDocument() + await waitFor(() => expect(onRendered).toHaveBeenCalledTimes(1)) + + firstRender.unmount() + render(() => ) + expect(await screen.findByText('Index')).toBeInTheDocument() + await waitFor(() => expect(onRendered).toHaveBeenCalledTimes(2)) + + unsubscribe() +}) diff --git a/packages/solid-router/tests/transitioner-render-ack.test.tsx b/packages/solid-router/tests/transitioner-render-ack.test.tsx new file mode 100644 index 0000000000..99f09d7bc0 --- /dev/null +++ b/packages/solid-router/tests/transitioner-render-ack.test.tsx @@ -0,0 +1,158 @@ +import { cleanup, render, screen, waitFor } from '@solidjs/testing-library' +import { afterEach, expect, test } from 'vitest' +import { + Outlet, + RouterProvider, + createControlledPromise, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +afterEach(() => { + cleanup() +}) + +test('onRendered observes the committed destination DOM', async () => { + const nextLoader = createControlledPromise() + const rootRoute = createRootRoute({ component: () => }) + const firstRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/first', + component: () =>
First
, + }) + const nextRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/next', + loader: () => nextLoader, + component: () =>
Next
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([firstRoute, nextRoute]), + history: createMemoryHistory({ initialEntries: ['/first'] }), + }) + + render(() => ) + expect(await screen.findByText('First')).toBeInTheDocument() + await waitFor(() => expect(router.state.status).toBe('idle')) + + const renderedDom: Array<{ destination: boolean; outgoing: boolean }> = [] + const unsubscribe = router.subscribe('onRendered', () => { + renderedDom.push({ + destination: screen.queryByText('Next') !== null, + outgoing: screen.queryByText('First') !== null, + }) + }) + + const navigation = router.navigate({ to: '/next' }) + + expect(screen.queryByText('Next')).toBeNull() + expect(renderedDom).toEqual([]) + + nextLoader.resolve() + await navigation + await waitFor(() => expect(renderedDom).toHaveLength(1)) + + expect(renderedDom).toEqual([{ destination: true, outgoing: false }]) + + unsubscribe() +}) + +test('an older rendered destination cannot resolve a superseding navigation', async () => { + const nextLoader = createControlledPromise() + const rootRoute = createRootRoute({ component: () => }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Index
, + }) + const firstRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/first', + component: () =>
First
, + }) + const nextRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/next', + loader: () => nextLoader, + component: () =>
Next
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, firstRoute, nextRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render(() => ) + expect(await screen.findByText('Index')).toBeInTheDocument() + await waitFor(() => expect(router.state.status).toBe('idle')) + + const renderedPaths: Array = [] + const resolvedPaths: Array = [] + let nextNavigation: Promise | undefined + const unsubscribers = [ + router.subscribe('onLoad', (event) => { + if (event.toLocation.pathname === '/first') { + nextNavigation = router.navigate({ to: '/next' }) + } + }), + router.subscribe('onRendered', (event) => { + renderedPaths.push(event.toLocation.pathname) + }), + router.subscribe('onResolved', (event) => { + resolvedPaths.push(event.toLocation.pathname) + }), + ] + + const firstNavigation = router.navigate({ to: '/first' }) + + await waitFor(() => { + expect(nextNavigation).toBeDefined() + }) + expect(screen.getByText('First')).toBeInTheDocument() + expect(screen.queryByText('Next')).not.toBeInTheDocument() + expect(renderedPaths).toEqual([]) + expect(resolvedPaths).toEqual([]) + + nextLoader.resolve() + await Promise.all([firstNavigation, nextNavigation!]) + + await waitFor(() => { + expect(screen.getByText('Next')).toBeInTheDocument() + }) + expect(renderedPaths).toEqual(['/next']) + expect(resolvedPaths).toEqual(['/next']) + + for (const unsubscribe of unsubscribers) { + unsubscribe() + } +}) + +test('same-location invalidation resolves after its refreshed DOM commits', async () => { + let generation = 0 + const rootRoute = createRootRoute({ component: () => }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + loader: () => ++generation, + component: () =>
Generation {indexRoute.useLoaderData()()}
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render(() => ) + expect(await screen.findByText('Generation 1')).toBeInTheDocument() + + const refreshedDomWasVisible: Array = [] + const unsubscribe = router.subscribe('onResolved', () => { + refreshedDomWasVisible.push(screen.queryByText('Generation 2') !== null) + }) + + await router.invalidate() + expect(await screen.findByText('Generation 2')).toBeInTheDocument() + expect(refreshedDomWasVisible).toEqual([true]) + + unsubscribe() +}) diff --git a/packages/solid-router/tests/use-match-outgoing-transition.test.tsx b/packages/solid-router/tests/use-match-outgoing-transition.test.tsx new file mode 100644 index 0000000000..c76c4d78ef --- /dev/null +++ b/packages/solid-router/tests/use-match-outgoing-transition.test.tsx @@ -0,0 +1,108 @@ +import * as Solid from 'solid-js' +import { cleanup, render, screen } from '@solidjs/testing-library' +import { afterEach, expect, test } from 'vitest' +import { + Outlet, + RouterProvider, + createControlledPromise, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + useMatch, +} from '../src' + +afterEach(() => { + cleanup() +}) + +test('an outgoing component never observes its own active match disappear', async () => { + const observedRouteIds: Array = [] + const rootRoute = createRootRoute({ component: () => }) + const firstRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/first', + component: () => { + const match = useMatch({ from: '/first', shouldThrow: false }) + Solid.createRenderEffect(() => { + observedRouteIds.push(match()?.routeId) + }) + return
First
+ }, + }) + const nextRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/next', + component: () =>
Next
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([firstRoute, nextRoute]), + history: createMemoryHistory({ initialEntries: ['/first'] }), + }) + + render(() => ) + expect(await screen.findByText('First')).toBeInTheDocument() + + await router.navigate({ to: '/next' }) + expect(await screen.findByText('Next')).toBeInTheDocument() + + expect(observedRouteIds).toEqual(['/first']) +}) + +test('a persistent observer releases an explicit match only with the destination render', async () => { + const nextLoader = createControlledPromise() + const observations: Array<{ + routeId: string | undefined + destinationRendered: boolean + }> = [] + + const rootRoute = createRootRoute({ + component: () => { + const routeId = useMatch({ + from: '/first', + shouldThrow: false, + select: (match) => match.routeId, + }) + Solid.createRenderEffect(() => { + observations.push({ + routeId: routeId(), + destinationRendered: screen.queryByText('Next') !== null, + }) + }) + return + }, + }) + const firstRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/first', + component: () =>
First
, + }) + const nextRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/next', + loader: () => nextLoader, + component: () =>
Next
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([firstRoute, nextRoute]), + history: createMemoryHistory({ initialEntries: ['/first'] }), + }) + + render(() => ) + expect(await screen.findByText('First')).toBeInTheDocument() + + const navigation = router.navigate({ to: '/next' }) + await Promise.resolve() + expect(screen.queryByText('Next')).toBeNull() + expect(observations.every(({ routeId }) => routeId === '/first')).toBe(true) + + nextLoader.resolve() + await navigation + expect(await screen.findByText('Next')).toBeInTheDocument() + + const missing = observations.filter(({ routeId }) => routeId === undefined) + expect(missing.length).toBeGreaterThan(0) + expect(missing.every(({ destinationRendered }) => destinationRendered)).toBe( + true, + ) +}) diff --git a/packages/start-server-core/src/createStartHandler.ts b/packages/start-server-core/src/createStartHandler.ts index 90d4e706f9..edafbbc43c 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._serverResult?.type === 'redirect') { + return normalizeSsrResponse(routerInstance._serverResult.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..17e479aa65 100644 --- a/packages/vue-router/src/Match.tsx +++ b/packages/vue-router/src/Match.tsx @@ -1,23 +1,12 @@ import * as Vue from 'vue' -import { - createControlledPromise, - getLocationChangeInfo, - invariant, - isNotFound, - isRedirect, - rootRouteId, -} from '@tanstack/router-core' +import { invariant, isNotFound, rootRouteId } from '@tanstack/router-core' import { isServer } from '@tanstack/router-core/isServer' import { useStore } from '@tanstack/vue-store' import { CatchBoundary, ErrorComponent } from './CatchBoundary' import { ClientOnly } from './ClientOnly' import { useRouter } from './useRouter' import { CatchNotFound } from './not-found' -import { - matchContext, - pendingMatchContext, - routeIdContext, -} from './matchContext' +import { matchContext, routeIdContext } from './matchContext' import { renderRouteNotFound } from './renderRouteNotFound' import { ScrollRestoration } from './scroll-restoration' import type { VNode } from 'vue' @@ -61,13 +50,6 @@ export const Match = Vue.defineComponent({ router.stores.getRouteMatchStore(routeId), (value) => value, ) - const isPendingMatchRef = useStore( - router.stores.pendingRouteIds, - (pendingRouteIds) => Boolean(pendingRouteIds[routeId]), - { equal: Object.is }, - ) - const loadedAt = useStore(router.stores.loadedAt, (value) => value) - const matchData = Vue.computed(() => { const match = activeMatch.value if (!match) { @@ -77,9 +59,8 @@ export const Match = Vue.defineComponent({ return { matchId: match.id, routeId, - loadedAt: loadedAt.value, + fetchCount: match.fetchCount, ssr: match.ssr, - _displayPending: match._displayPending, } }) @@ -137,15 +118,12 @@ export const Match = Vue.defineComponent({ ) Vue.provide(matchContext, matchIdRef) - Vue.provide(pendingMatchContext, isPendingMatchRef) - return (): VNode => { const actualMatchId = matchData.value?.matchId ?? props.matchId const resolvedNoSsr = matchData.value?.ssr === false || matchData.value?.ssr === 'data-only' - const shouldClientOnly = - resolvedNoSsr || !!matchData.value?._displayPending + const shouldClientOnly = resolvedNoSsr const renderMatchContent = (): VNode => { const matchInner = Vue.h(MatchInner, { matchId: actualMatchId }) @@ -186,7 +164,7 @@ export const Match = Vue.defineComponent({ // Wrap in error boundary if needed if (routeErrorComponent.value) { content = CatchBoundary({ - getResetKey: () => matchData.value?.loadedAt ?? 0, + getResetKey: () => matchData.value?.fetchCount ?? 0, errorComponent: routeErrorComponent.value || ErrorComponent, onCatch: (error: Error) => { // Forward not found errors (we don't want to show the error component for these) @@ -208,7 +186,6 @@ export const Match = Vue.defineComponent({ content, isChildOfRoot ? Vue.h(Vue.Fragment, null, [ - Vue.h(OnRendered), router.options.scrollRestoration && (isServer ?? router.isServer) ? Vue.h(ScrollRestoration) @@ -239,48 +216,6 @@ export const Match = Vue.defineComponent({ }) // On Rendered can't happen above the root layout because it actually -// renders a dummy dom element to track the rendered state of the app. -// We render a script tag with a key that changes based on the current -// location state.__TSR_key. Also, because it's below the root layout, it -// allows us to fire onRendered events even after a hydration mismatch -// error that occurred above the root layout (like bad head/link tags, -// which is common). -const OnRendered = Vue.defineComponent({ - name: 'OnRendered', - setup() { - const router = useRouter() - - const location = useStore( - router.stores.resolvedLocation, - (resolvedLocation) => resolvedLocation?.state.__TSR_key, - ) - - let prevHref: string | undefined - - Vue.watch( - location, - () => { - if (location.value) { - const currentHref = router.latestLocation.href - if (prevHref === undefined || prevHref !== currentHref) { - router.emit({ - type: 'onRendered', - ...getLocationChangeInfo( - router.stores.location.get(), - router.stores.resolvedLocation.get(), - ), - }) - prevHref = currentHref - } - } - }, - { immediate: true }, - ) - - return () => null - }, -}) - export const MatchInner = Vue.defineComponent({ name: 'MatchInner', props: { @@ -333,9 +268,6 @@ export const MatchInner = Vue.defineComponent({ status: match.status, error: match.error, ssr: match.ssr, - _forcePending: match._forcePending, - _displayPending: match._displayPending, - _nonReactive: match._nonReactive, }, remountKey, } @@ -349,43 +281,11 @@ 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 +297,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 +323,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 = @@ -540,7 +406,11 @@ export const Outlet = Vue.defineComponent({ if (!route.value) { return null } - return renderRouteNotFound(router, route.value, undefined) + return renderRouteNotFound( + router, + route.value, + parentMatch.value?.error, + ) } if (!childMatchData.value) { diff --git a/packages/vue-router/src/Matches.tsx b/packages/vue-router/src/Matches.tsx index da8220db29..740e423a02 100644 --- a/packages/vue-router/src/Matches.tsx +++ b/packages/vue-router/src/Matches.tsx @@ -99,8 +99,12 @@ const MatchesInner = Vue.defineComponent({ setup() { const router = useRouter() - const matchId = useStore(router.stores.firstId, (id) => id) - const resetKey = useStore(router.stores.loadedAt, (loadedAt) => loadedAt) + const matchId = useStore(router.stores.matchesId, (ids) => ids[0]) + const resetKey = Vue.computed(() => + matchId.value + ? (router.stores.matchStores.get(matchId.value)?.get().fetchCount ?? 0) + : 0, + ) // Create a ref for the match id to provide const matchIdRef = Vue.computed(() => matchId.value) diff --git a/packages/vue-router/src/Transitioner.tsx b/packages/vue-router/src/Transitioner.tsx index b0133d965c..6cec279a49 100644 --- a/packages/vue-router/src/Transitioner.tsx +++ b/packages/vue-router/src/Transitioner.tsx @@ -1,107 +1,23 @@ import * as Vue from 'vue' import { getLocationChangeInfo, trimPathRight } from '@tanstack/router-core' import { isServer } from '@tanstack/router-core/isServer' -import { batch, useStore } from '@tanstack/vue-store' import { useRouter } from './useRouter' -import { usePrevious } from './utils' -// Track mount state per router to avoid double-loading -let mountLoadForRouter = { router: null as any, mounted: false } - -/** - * Composable that sets up router transition logic. - * This is called from MatchesContent to set up: - * - router.startTransition - * - router.startViewTransition - * - History subscription - * - Router event watchers - * - * Must be called during component setup phase. - */ export function useTransitionerSetup() { const router = useRouter() - - // Skip on server - no transitions needed if (isServer ?? router.isServer) { return } - const isLoading = useStore(router.stores.isLoading, (value) => value) - - // Track if we're in a transition - using a ref to track async transitions - const isTransitioning = Vue.ref(false) - - // Track pending state changes - const hasPending = useStore(router.stores.hasPending, (value) => value) - - const previousIsLoading = usePrevious(() => isLoading.value) - - const isAnyPending = Vue.computed( - () => isLoading.value || isTransitioning.value || hasPending.value, - ) - const previousIsAnyPending = usePrevious(() => isAnyPending.value) - - const isPagePending = Vue.computed(() => isLoading.value || hasPending.value) - const previousIsPagePending = usePrevious(() => isPagePending.value) - - // 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) => { - 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 = () => { - // Use nextTick to ensure Vue has processed all reactive updates - 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 + const previousTransition = router.startTransition + const transition = async (fn: () => void) => { 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) = - (router as any).__tsrOriginalStartViewTransition ?? - router.startViewTransition - - ;(router as any).__tsrOriginalStartViewTransition = - originalStartViewTransition - - router.startViewTransition = (fn: () => Promise) => { - return originalStartViewTransition?.(async () => { - await fn() - await Vue.nextTick() - }) + await Vue.nextTick() + return true } + router.startTransition = transition - // Subscribe to location changes - // and try to load the new location let unsubscribe: (() => void) | undefined - Vue.onMounted(() => { unsubscribe = router.history.subscribe(router.load) @@ -114,130 +30,37 @@ export function useTransitionerSetup() { _includeValidateSearch: true, }) - // Check if the current URL matches the canonical form. - // Compare publicHref (browser-facing URL) for consistency with - // the server-side redirect check in router.beforeLoad. if ( trimPathRight(router.latestLocation.publicHref) !== trimPathRight(nextLocation.publicHref) ) { router.commitLocation({ ...nextLocation, replace: true }) + return } - }) - - // Track if component is mounted to prevent updates after unmount - const isMounted = Vue.ref(false) - - Vue.onMounted(() => { - isMounted.value = true - if (!isAnyPending.value) { - if (router.stores.status.get() === 'pending') { - batch(() => { - router.stores.status.set('idle') - router.stores.resolvedLocation.set(router.stores.location.get()) - }) - } - } - }) - - Vue.onUnmounted(() => { - isMounted.value = false - if (unsubscribe) { - unsubscribe() - } - }) - // Try to load the initial location - Vue.onMounted(() => { + const resolvedLocation = router.stores.resolvedLocation.get() if ( - (typeof window !== 'undefined' && router.ssr) || - (mountLoadForRouter.router === router && mountLoadForRouter.mounted) + resolvedLocation?.href === router.latestLocation.href && + resolvedLocation.state.__TSR_key === router.latestLocation.state.__TSR_key ) { - return - } - mountLoadForRouter = { router, mounted: true } - const tryLoad = async () => { - try { - await router.load() - } catch (err) { - console.error(err) - } - } - tryLoad() - }) - - // Setup watchers for emitting events - // All watchers check isMounted to prevent updates after unmount - Vue.watch( - () => isLoading.value, - (newValue) => { - if (!isMounted.value) return - try { - if (previousIsLoading.value.previous && !newValue) { - router.emit({ - type: 'onLoad', - ...getLocationChangeInfo( - router.stores.location.get(), - router.stores.resolvedLocation.get(), - ), - }) - } - } catch { - // Ignore errors if component is unmounted - } - }, - ) - - Vue.watch(isPagePending, (newValue) => { - if (!isMounted.value) return - try { - // emit onBeforeRouteMount - if (previousIsPagePending.value.previous && !newValue) { - router.emit({ - type: 'onBeforeRouteMount', - ...getLocationChangeInfo( - router.stores.location.get(), - router.stores.resolvedLocation.get(), - ), - }) - } - } catch { - // Ignore errors if component is unmounted + router.emit({ + type: 'onRendered', + ...getLocationChangeInfo(resolvedLocation, resolvedLocation), + }) + } else { + router.load().catch(console.error) } }) - Vue.watch(isAnyPending, (newValue) => { - if (!isMounted.value) return - try { - if (!newValue && router.stores.status.get() === 'pending') { - batch(() => { - router.stores.status.set('idle') - router.stores.resolvedLocation.set(router.stores.location.get()) - }) - } - - // The router was pending and now it's not - if (previousIsAnyPending.value.previous && !newValue) { - const changeInfo = getLocationChangeInfo( - router.stores.location.get(), - router.stores.resolvedLocation.get(), - ) - router.emit({ - type: 'onResolved', - ...changeInfo, - }) - } - } catch { - // Ignore errors if component is unmounted + Vue.onUnmounted(() => { + unsubscribe?.() + if (router.startTransition === transition) { + router.startTransition = previousTransition } }) } -/** - * @deprecated Use useTransitionerSetup() composable instead. - * This component is kept for backwards compatibility but the setup logic - * has been moved to useTransitionerSetup() for better SSR hydration. - */ +/** @deprecated Use useTransitionerSetup() instead. */ export const Transitioner = Vue.defineComponent({ name: 'Transitioner', setup() { diff --git a/packages/vue-router/src/lazyRouteComponent.tsx b/packages/vue-router/src/lazyRouteComponent.tsx index 77a2233573..527f3d15ad 100644 --- a/packages/vue-router/src/lazyRouteComponent.tsx +++ b/packages/vue-router/src/lazyRouteComponent.tsx @@ -44,6 +44,7 @@ export function lazyRouteComponent< // Use existing promise or create new one if (!loadPromise) { + error = undefined loadPromise = importer() .then((res) => { loadPromise = undefined diff --git a/packages/vue-router/src/matchContext.tsx b/packages/vue-router/src/matchContext.tsx index 1a8b7fb167..201408b5d0 100644 --- a/packages/vue-router/src/matchContext.tsx +++ b/packages/vue-router/src/matchContext.tsx @@ -1,39 +1,14 @@ -import * as Vue from 'vue' +import type { InjectionKey, Ref } from 'vue' // Reactive nearest-match context used by hooks that work relative to the // current match in the tree. -export const matchContext = Symbol('TanStackRouterMatch') as Vue.InjectionKey< - Vue.Ref +export const matchContext = Symbol('TanStackRouterMatch') as InjectionKey< + Ref > -// Pending match context for nearest-match lookups -export const pendingMatchContext = Symbol( - 'TanStackRouterPendingMatch', -) as Vue.InjectionKey> - -// Dummy pending context when nearest pending state is not needed -export const dummyPendingMatchContext = Symbol( - 'TanStackRouterDummyPendingMatch', -) as Vue.InjectionKey> - // Stable routeId context — a plain string (not reactive) that identifies // which route this component belongs to. Provided by Match, consumed by // MatchInner, Outlet, and useMatch for routeId-based store lookups. export const routeIdContext = Symbol( 'TanStackRouterRouteId', -) as Vue.InjectionKey - -/** - * Retrieves nearest pending-match state from the component tree - */ -export function injectPendingMatch(): Vue.Ref { - return Vue.inject(pendingMatchContext, Vue.ref(false)) -} - -/** - * Retrieves dummy pending-match state from the component tree - * This only exists so we can conditionally inject a value when we are not interested in the nearest pending match - */ -export function injectDummyPendingMatch(): Vue.Ref { - return Vue.inject(dummyPendingMatchContext, Vue.ref(false)) -} +) as InjectionKey diff --git a/packages/vue-router/src/routerStores.ts b/packages/vue-router/src/routerStores.ts index da943fe990..dec689c47c 100644 --- a/packages/vue-router/src/routerStores.ts +++ b/packages/vue-router/src/routerStores.ts @@ -11,8 +11,6 @@ declare module '@tanstack/router-core' { export interface RouterStores { /** Maps each active routeId to the matchId of its child in the match tree. */ childMatchIdByRouteId: RouterReadableStore> - /** Maps each pending routeId to true for quick lookup. */ - pendingRouteIds: RouterReadableStore> } } @@ -37,18 +35,6 @@ export const getStoreFactory: GetStoreConfig = (_opts) => { } return obj }) - - stores.pendingRouteIds = createAtom(() => { - const ids = stores.pendingIds.get() - const obj: Record = {} - for (const id of ids) { - const store = stores.pendingMatchStores.get(id) - if (store?.routeId) { - obj[store.routeId] = true - } - } - return obj - }) }, } } diff --git a/packages/vue-router/src/ssr/renderRouterToStream.tsx b/packages/vue-router/src/ssr/renderRouterToStream.tsx index efad4fc2eb..64b7040006 100644 --- a/packages/vue-router/src/ssr/renderRouterToStream.tsx +++ b/packages/vue-router/src/ssr/renderRouterToStream.tsx @@ -112,7 +112,10 @@ export const renderRouterToStream = async ({ } return new Response(`${fullHtml}`, { - status: router.stores.statusCode.get(), + status: + router._serverResult?.type === 'render' + ? router._serverResult.status + : 200, headers: responseHeaders, }) } finally { @@ -217,7 +220,10 @@ export const renderRouterToStream = async ({ return createSsrStreamResponse( router, new Response(responseStream as any, { - status: router.stores.statusCode.get(), + status: + router._serverResult?.type === 'render' + ? router._serverResult.status + : 200, headers: responseHeaders, }), ) diff --git a/packages/vue-router/src/ssr/renderRouterToString.tsx b/packages/vue-router/src/ssr/renderRouterToString.tsx index b551dc5dfc..723aa0b91e 100644 --- a/packages/vue-router/src/ssr/renderRouterToString.tsx +++ b/packages/vue-router/src/ssr/renderRouterToString.tsx @@ -24,7 +24,10 @@ export const renderRouterToString = async ({ } return new Response(`${html}`, { - status: router.stores.statusCode.get(), + status: + router._serverResult?.type === 'render' + ? router._serverResult.status + : 200, headers: responseHeaders, }) } catch (error) { diff --git a/packages/vue-router/src/useMatch.tsx b/packages/vue-router/src/useMatch.tsx index 29047e5d48..96e1937567 100644 --- a/packages/vue-router/src/useMatch.tsx +++ b/packages/vue-router/src/useMatch.tsx @@ -2,11 +2,7 @@ import * as Vue from 'vue' import { invariant } from '@tanstack/router-core' import { useStore } from '@tanstack/vue-store' import { isServer } from '@tanstack/router-core/isServer' -import { - injectDummyPendingMatch, - injectPendingMatch, - routeIdContext, -} from './matchContext' +import { routeIdContext } from './matchContext' import { useRouter } from './useRouter' import type { AnyRouter, @@ -114,11 +110,9 @@ export function useMatch< > } - const hasPendingNearestMatch = opts.from - ? injectDummyPendingMatch() - : injectPendingMatch() // Set up reactive match value based on lookup strategy. let match: Readonly> + const nearestRouteId = Vue.inject(routeIdContext) if (opts.from) { // routeId case: single subscription via per-routeId computed store. @@ -129,7 +123,6 @@ export function useMatch< // matchId case: use routeId from context for stable store lookup. // The routeId is provided by the nearest Match component and doesn't // change for the component's lifetime, so the store is stable. - const nearestRouteId = Vue.inject(routeIdContext) if (nearestRouteId) { match = useStore( router.stores.getRouteMatchStore(nearestRouteId), @@ -141,26 +134,10 @@ 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 result = Vue.computed(() => { const selectedMatch = match.value if (selectedMatch === undefined) { - const hasPendingMatch = opts.from - ? Boolean(hasPendingRouteMatch?.value[opts.from!]) - : hasPendingNearestMatch.value - if ( - !hasPendingMatch && - !isTransitioning.value && - (opts.shouldThrow ?? true) - ) { + if (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..b398fd98c7 100644 --- a/packages/vue-router/tests/Matches.test.tsx +++ b/packages/vue-router/tests/Matches.test.tsx @@ -134,7 +134,7 @@ test('when filtering useMatches by loaderData', async () => { // Vue Suspense requires async setup functions or top-level await to trigger fallback. // Since TanStack Router throws Promises from render (not setup), Vue doesn't show // the pending component during initial load. Navigation-triggered pending states -// DO work - see loaders.test.tsx 'cancelMatches after pending timeout'. +// DO work - see loaders.test.tsx 'navigating away from a pending route aborts its loader'. test('should show pendingComponent of root route', async () => { const root = createRootRoute({ pendingComponent: () =>
, diff --git a/packages/vue-router/tests/Transitioner.test.tsx b/packages/vue-router/tests/Transitioner.test.tsx index 8fc6095f94..5d463c9dc8 100644 --- a/packages/vue-router/tests/Transitioner.test.tsx +++ b/packages/vue-router/tests/Transitioner.test.tsx @@ -9,7 +9,7 @@ import { import { RouterProvider } from '../src/RouterProvider' describe('Transitioner', () => { - it('should call router.load() when Transitioner mounts on the client', async () => { + it('loads the initial route when the provider mounts', async () => { const loader = vi.fn() const rootRoute = createRootRoute() const indexRoute = createRoute({ @@ -27,18 +27,11 @@ describe('Transitioner', () => { }), }) - // Mock router.load() to verify it gets called - const loadSpy = vi.spyOn(router, 'load') + const view = render() - render() - await router.latestLoadPromise - - // Wait for the createRenderEffect to run and call router.load() await waitFor(() => { - expect(loadSpy).toHaveBeenCalledTimes(1) expect(loader).toHaveBeenCalledTimes(1) + expect(view.getByText('Index')).toBeInTheDocument() }) - - loadSpy.mockRestore() }) }) diff --git a/packages/vue-router/tests/component-preload-retry.test.tsx b/packages/vue-router/tests/component-preload-retry.test.tsx new file mode 100644 index 0000000000..0cf7e0f19e --- /dev/null +++ b/packages/vue-router/tests/component-preload-retry.test.tsx @@ -0,0 +1,61 @@ +import { afterEach, expect, test, vi } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/vue' +import { + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + lazyRouteComponent, + useRouter, +} from '../src' +import type { ErrorComponentProps } from '../src' + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +test('a failed component download is retried from the route error UI', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + + const PageContent = () =>
Page content
+ const importer = vi + .fn<() => Promise<{ default: typeof PageContent }>>() + .mockRejectedValueOnce(new Error('component download failed')) + .mockResolvedValue({ default: PageContent }) + const Page = lazyRouteComponent(importer) + + function RouteError(props: ErrorComponentProps) { + const router = useRouter() + return ( + + ) + } + + const rootRoute = createRootRoute() + const pageRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/page', + component: Page, + errorComponent: RouteError, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + }) + + render() + + await fireEvent.click(await screen.findByRole('button', { name: 'Retry' })) + + expect(await screen.findByText('Page content')).toBeInTheDocument() +}) diff --git a/packages/vue-router/tests/hydration-capped-boundary-pending.test.tsx b/packages/vue-router/tests/hydration-capped-boundary-pending.test.tsx new file mode 100644 index 0000000000..02eb2e8002 --- /dev/null +++ b/packages/vue-router/tests/hydration-capped-boundary-pending.test.tsx @@ -0,0 +1,161 @@ +import * as Vue from 'vue' +import { renderToString } from 'vue/server-renderer' +import { afterEach, describe, expect, test, vi } from 'vitest' +import { hydrate as hydrateRouter } from '@tanstack/router-core/ssr/client' +import { + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + notFound, +} from '../src' +import type { TsrSsrGlobal } from '@tanstack/router-core/ssr/client' + +declare global { + interface Window { + $_TSR?: TsrSsrGlobal + } +} + +const testCleanups: Array<() => void | Promise> = [] + +afterEach(async () => { + while (testCleanups.length) { + await testCleanups.pop()!() + } + vi.restoreAllMocks() + window.$_TSR = undefined + document.body.innerHTML = '' +}) + +describe('hydrating a server-capped boundary lane', () => { + test.each([ + ['error', 'parent'], + ['notFound', 'root'], + ] as const)( + 'keeps the server-rendered %s %s boundary visible while the client replays it', + async (outcome, boundary) => { + const childLoader = vi.fn(() => 'child data') + const makeRouteTree = () => { + const boundaryOptions = { + beforeLoad: () => { + throw outcome === 'notFound' + ? notFound() + : new Error('server route failure') + }, + pendingComponent: () => ( +
Boundary pending
+ ), + errorComponent: () => ( +
Boundary error
+ ), + notFoundComponent: () => ( +
Boundary not found
+ ), + } + const rootRoute = createRootRoute({ + component: Outlet, + ...(boundary === 'root' ? boundaryOptions : {}), + }) + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + component: Outlet, + ...(boundary === 'parent' ? boundaryOptions : {}), + }) + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: childLoader, + component: () =>
Child
, + }) + return rootRoute.addChildren([parentRoute.addChildren([childRoute])]) + } + + const serverRouter = createRouter({ + routeTree: makeRouteTree(), + ...(boundary === 'root' ? { isShell: true } : {}), + history: createMemoryHistory({ + initialEntries: ['/parent/child'], + }), + }) + serverRouter.isServer = true + await serverRouter.load() + + const serverMatches = serverRouter.stores.matches.get() + expect(serverMatches).toHaveLength(boundary === 'root' ? 1 : 2) + expect(serverMatches.at(-1)).toMatchObject( + boundary === 'root' + ? { status: 'success', globalNotFound: true } + : { status: 'error' }, + ) + + const serverApp = Vue.createSSRApp( + Vue.defineComponent({ + setup: () => () => , + }), + ) + const serverHtml = await renderToString(serverApp) + const expectedBoundary = + outcome === 'notFound' ? 'Boundary not found' : 'Boundary error' + expect(serverHtml).toContain(expectedBoundary) + + const clientRouter = createRouter({ + routeTree: makeRouteTree(), + history: createMemoryHistory({ + initialEntries: ['/parent/child'], + }), + }) + + window.$_TSR = { + router: { + manifest: { routes: {} }, + dehydratedData: {}, + matches: serverMatches.map((match) => ({ + i: match.id, + u: match.updatedAt, + s: match.status, + l: match.loaderData, + e: match.error, + ssr: match.ssr, + ...(match.globalNotFound ? { g: true } : {}), + })), + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + initialized: false, + } + + await hydrateRouter(clientRouter) + + const container = document.createElement('div') + container.innerHTML = serverHtml + document.body.appendChild(container) + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}) + const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const clientApp = Vue.createSSRApp( + Vue.defineComponent({ + setup: () => () => , + }), + ) + testCleanups.push(() => clientApp.unmount()) + + clientApp.mount(container) + await Vue.nextTick() + + expect(container).toHaveTextContent(expectedBoundary) + expect(container).not.toHaveTextContent('Boundary pending') + expect( + [consoleError.mock.calls, consoleWarn.mock.calls].flat(2).join(' '), + ).not.toMatch(/hydration|mismatch/i) + expect(childLoader).not.toHaveBeenCalled() + }, + ) +}) diff --git a/packages/vue-router/tests/link.test.tsx b/packages/vue-router/tests/link.test.tsx index e047fe5993..be5e9bf70b 100644 --- a/packages/vue-router/tests/link.test.tsx +++ b/packages/vue-router/tests/link.test.tsx @@ -1365,15 +1365,16 @@ describe('Link', () => { expect(window.location.search).toBe('?page=2&filter=inactive') }) - const updatedPage = await screen.findByTestId('current-page') - const updatedFilter = await screen.findByTestId('current-filter') - // Verify search was updated 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(screen.getByTestId('current-page')).toHaveTextContent('Page: 2') + expect(screen.getByTestId('current-filter')).toHaveTextContent( + 'Filter: inactive', + ) + }) }) test('when navigation to . from /posts while updating search from / and using base path', async () => { @@ -1491,10 +1492,12 @@ describe('Link', () => { expect(window.location.pathname).toBe('/Dashboard/posts') expect(window.location.search).toBe('?page=2&filter=inactive') - 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(screen.getByTestId('current-page')).toHaveTextContent('Page: 2') + expect(screen.getByTestId('current-filter')).toHaveTextContent( + 'Filter: inactive', + ) + }) }) test('when navigating to /posts with invalid search', async () => { diff --git a/packages/vue-router/tests/loaders.test.tsx b/packages/vue-router/tests/loaders.test.tsx index ae267ac2ae..a94b1579e3 100644 --- a/packages/vue-router/tests/loaders.test.tsx +++ b/packages/vue-router/tests/loaders.test.tsx @@ -276,6 +276,44 @@ test('throw error from loader upon initial load', async () => { expect(errorElement).toBeInTheDocument() }) +// https://github.com/TanStack/router/pull/7673 +test('#7673: aborted loader does not render the route component with undefined loaderData', async () => { + const abortError = new DOMException('Aborted', 'AbortError') + const routeComponentRendered = vi.fn() + const renderedError = vi.fn() + const rootRoute = createRootRoute({}) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + loader: (): Promise<{ value: string }> => Promise.reject(abortError), + component: () => { + routeComponentRendered() + const data = indexRoute.useLoaderData() + return
{data.value.value}
+ }, + errorComponent: ({ error }) => { + renderedError(error) + return
indexErrorComponent
+ }, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + }) + + render() + + expect(await screen.findByTestId('index-error')).toBeInTheDocument() + expect(screen.queryByTestId('index-content')).not.toBeInTheDocument() + expect(routeComponentRendered).not.toHaveBeenCalled() + expect(renderedError).toHaveBeenCalledWith(abortError) + expect( + router.state.matches.find((match) => match.routeId === indexRoute.id), + ).toMatchObject({ + status: 'error', + error: abortError, + }) +}) + test('throw error from beforeLoad when navigating to route', async () => { const rootRoute = createRootRoute({}) @@ -635,7 +673,7 @@ test('reproducer #4546', async () => { } }) -test('clears pendingTimeout when match resolves', async () => { +test('does not show pending UI when routes resolve before their thresholds', async () => { const defaultPendingComponentOnMountMock = vi.fn() const nestedPendingComponentOnMountMock = vi.fn() const fooPendingComponentOnMountMock = vi.fn() @@ -703,7 +741,7 @@ test('clears pendingTimeout when match resolves', async () => { }) render() - await router.latestLoadPromise + await screen.findByText('Index page') const linkToFoo = await screen.findByTestId('link-to-foo') fireEvent.click(linkToFoo) const fooElement = await screen.findByText('Nested Foo page') @@ -717,7 +755,7 @@ test('clears pendingTimeout when match resolves', async () => { expect(fooPendingComponentOnMountMock).not.toHaveBeenCalled() }) -test('cancelMatches after pending timeout', async () => { +test('navigating away from pending UI aborts its loader', async () => { function getPendingComponent(onMount: () => void) { const PendingComponent = () => { onMount() @@ -769,7 +807,7 @@ test('cancelMatches after pending timeout', async () => { const routeTree = rootRoute.addChildren([fooRoute, barRoute]) const router = createRouter({ routeTree, history }) render() - await router.latestLoadPromise + await screen.findByText('Index page') const fooLink = await screen.findByTestId('link-to-foo') fireEvent.click(fooLink) await sleep(WAIT_TIME * 30) diff --git a/packages/vue-router/tests/pending-fallback-promise-replacement.test.tsx b/packages/vue-router/tests/pending-fallback-promise-replacement.test.tsx new file mode 100644 index 0000000000..8cab6d41f0 --- /dev/null +++ b/packages/vue-router/tests/pending-fallback-promise-replacement.test.tsx @@ -0,0 +1,78 @@ +import { cleanup, render, screen } from '@testing-library/vue' +import { afterEach, expect, test, vi } from 'vitest' +import { createControlledPromise } from '@tanstack/router-core' +import { + RouterProvider, + createMemoryHistory, + createRootRoute, + createRouter, +} from '../src' + +const testCleanups: Array<() => void | Promise> = [] + +afterEach(async () => { + vi.useRealTimers() + while (testCleanups.length) { + await testCleanups.pop()!() + } + cleanup() +}) + +test('a continuously visible fallback keeps its deadline across replacement loads', async () => { + const firstReload = createControlledPromise() + const secondReload = createControlledPromise() + const reloads = [firstReload, secondReload] + let loaderCall = 0 + + const rootRoute = createRootRoute({ + pendingMs: 0, + pendingMinMs: 100, + pendingComponent: () =>
Pending
, + loader: () => { + const generation = ++loaderCall + const gate = reloads[generation - 2] + return gate ? gate.then(() => generation) : generation + }, + component: () =>
Content
, + }) + const router = createRouter({ + routeTree: rootRoute, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render() + expect(await screen.findByText('Content')).toBeInTheDocument() + + vi.useFakeTimers() + const firstInvalidation = router.invalidate({ forcePending: true }) + const invalidations = [firstInvalidation] + testCleanups.push(async () => { + firstReload.resolve() + secondReload.resolve() + await Promise.allSettled(invalidations) + }) + await vi.advanceTimersByTimeAsync(0) + expect(screen.getByTestId('pending')).toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(25) + let secondSettled = false + const secondInvalidation = router + .invalidate({ forcePending: true }) + .then(() => { + secondSettled = true + }) + invalidations.push(secondInvalidation) + + firstReload.resolve() + secondReload.resolve() + await Promise.resolve() + + await vi.advanceTimersByTimeAsync(74) + expect(secondSettled).toBe(false) + expect(screen.getByTestId('pending')).toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(1) + await Promise.all(invalidations) + expect(screen.getByText('Content')).toBeInTheDocument() + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() +}) diff --git a/packages/vue-router/tests/redirect.test.tsx b/packages/vue-router/tests/redirect.test.tsx index b7ba2e1af3..3f475652f3 100644 --- a/packages/vue-router/tests/redirect.test.tsx +++ b/packages/vue-router/tests/redirect.test.tsx @@ -292,108 +292,4 @@ describe('redirect', () => { expect(window.location.pathname).toBe('/final') }) }) - - describe('SSR', () => { - test('when `redirect` is thrown in `beforeLoad`', async () => { - const rootRoute = createRootRoute() - - const indexRoute = createRoute({ - path: '/', - getParentRoute: () => rootRoute, - beforeLoad: () => { - throw redirect({ - to: '/about', - }) - }, - }) - - const aboutRoute = createRoute({ - path: '/about', - getParentRoute: () => rootRoute, - component: () => { - return <>About - }, - }) - - const router = createRouter({ - routeTree: rootRoute.addChildren([indexRoute, aboutRoute]), - // Mock server mode - isServer: true, - history: createMemoryHistory({ - initialEntries: ['/'], - }), - }) - - await router.load() - - expect(router.state.redirect).toBeDefined() - expect(router.state.redirect).toBeInstanceOf(Response) - const redirectResponse = router.state.redirect! - - expect(redirectResponse.options).toEqual({ - _fromLocation: expect.objectContaining({ - hash: '', - href: '/', - pathname: '/', - search: {}, - searchStr: '', - }), - to: '/about', - href: '/about', - statusCode: 307, - }) - }) - - test('when `redirect` is thrown in `loader`', async () => { - const rootRoute = createRootRoute() - - const indexRoute = createRoute({ - path: '/', - getParentRoute: () => rootRoute, - loader: () => { - throw redirect({ - to: '/about', - }) - }, - }) - - const aboutRoute = createRoute({ - path: '/about', - getParentRoute: () => rootRoute, - component: () => { - return <>About - }, - }) - - const router = createRouter({ - history: createMemoryHistory({ - initialEntries: ['/'], - }), - routeTree: rootRoute.addChildren([indexRoute, aboutRoute]), - // Mock server mode - isServer: true, - }) - - await router.load() - - const currentRedirect = router.state.redirect - - expect(currentRedirect).toBeDefined() - expect(currentRedirect).toBeInstanceOf(Response) - const redirectResponse = currentRedirect! - - expect(redirectResponse.options).toEqual({ - _fromLocation: expect.objectContaining({ - hash: '', - href: '/', - pathname: '/', - search: {}, - searchStr: '', - }), - to: '/about', - href: '/about', - statusCode: 307, - }) - }) - }) }) diff --git a/packages/vue-router/tests/router.test.tsx b/packages/vue-router/tests/router.test.tsx index 44a7acd43c..9f2ccdea62 100644 --- a/packages/vue-router/tests/router.test.tsx +++ b/packages/vue-router/tests/router.test.tsx @@ -1724,12 +1724,13 @@ 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 () => { +describe('route result reset on navigation', () => { + it('renders the requested route after navigating away from a not-found result', async () => { const history = createMemoryHistory({ initialEntries: ['/'] }) const rootRoute = createRootRoute({ component: () => , + notFoundComponent: () =>
Not Found
, }) const indexRoute = createRoute({ @@ -1749,23 +1750,23 @@ describe('statusCode reset on navigation', () => { render() - expect(router.state.statusCode).toBe(200) - - await router.navigate({ to: '/' }) - await waitFor(() => expect(router.state.statusCode).toBe(200)) + expect(await screen.findByText('Home')).toBeInTheDocument() await router.navigate({ to: '/non-existing' }) - await waitFor(() => expect(router.state.statusCode).toBe(404)) + expect(await screen.findByText('Not Found')).toBeInTheDocument() + expect(screen.queryByText('Home')).not.toBeInTheDocument() await router.navigate({ to: '/valid' }) - await waitFor(() => expect(router.state.statusCode).toBe(200)) + expect(await screen.findByText('Valid Route')).toBeInTheDocument() + expect(screen.queryByText('Not Found')).not.toBeInTheDocument() await router.navigate({ to: '/another-non-existing' }) - await waitFor(() => expect(router.state.statusCode).toBe(404)) + expect(await screen.findByText('Not Found')).toBeInTheDocument() + expect(screen.queryByText('Valid Route')).not.toBeInTheDocument() }) describe.each([true, false])( - 'status code is set when loader/beforeLoad throws (isAsync=%s)', + 'load failures render their route boundary (isAsync=%s)', (isAsync) => { const throwingFun = isAsync ? (toThrow: () => void) => async () => { @@ -1808,10 +1809,7 @@ 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() @@ -1853,10 +1851,7 @@ 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() @@ -1889,10 +1884,7 @@ 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() }) @@ -1926,10 +1918,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() }) @@ -2061,7 +2050,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..6235e04406 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(3) }) test('redirection in preload', async () => { @@ -157,7 +157,7 @@ describe("Store doesn't update *too many* times during navigation", () => { // that needs to be done during a navigation. // Any change that increases this number should be investigated. // Note: Vue has different update counts than React/Solid due to different reactivity - expect(updates).toBe(5) + expect(updates).toBe(2) }) test('sync beforeLoad', async () => { @@ -174,7 +174,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(3) }) test('nothing', async () => { @@ -186,7 +186,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(2) }) test('not found in beforeLoad', async () => { @@ -202,7 +202,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(2) }) test('hover preload, then navigate, w/ async loaders', async () => { @@ -229,7 +229,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 +246,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(2) }) test('navigate, w/ preloaded & sync loaders', async () => { @@ -263,7 +263,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(2) }) test('navigate, w/ previous navigation & async loader', async () => { @@ -280,7 +280,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(2) }) test('preload a preloaded route w/ async loader', async () => { @@ -299,6 +299,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/packages/vue-router/tests/transitioner-idle-after-render.test.tsx b/packages/vue-router/tests/transitioner-idle-after-render.test.tsx new file mode 100644 index 0000000000..4cfd74f86f --- /dev/null +++ b/packages/vue-router/tests/transitioner-idle-after-render.test.tsx @@ -0,0 +1,46 @@ +import { cleanup, render, screen } from '@testing-library/vue' +import { afterEach, expect, test } from 'vitest' +import { + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +afterEach(() => { + cleanup() +}) + +test('onResolved fires only after Vue commits the destination DOM', async () => { + const rootRoute = createRootRoute({ component: () => }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Index
, + }) + const nextRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/next', + component: () =>
Next
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, nextRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render() + expect(await screen.findByText('Index')).toBeInTheDocument() + + const destinationWasRenderedWhenResolved: Array = [] + const unsubscribe = router.subscribe('onResolved', () => { + destinationWasRenderedWhenResolved.push(screen.queryByText('Next') !== null) + }) + + await router.navigate({ to: '/next' }) + expect(await screen.findByText('Next')).toBeInTheDocument() + + expect(destinationWasRenderedWhenResolved).toEqual([true]) + unsubscribe() +}) diff --git a/packages/vue-router/tests/transitioner-listener-errors.test.tsx b/packages/vue-router/tests/transitioner-listener-errors.test.tsx new file mode 100644 index 0000000000..c3efef7119 --- /dev/null +++ b/packages/vue-router/tests/transitioner-listener-errors.test.tsx @@ -0,0 +1,61 @@ +import { cleanup, render, screen } from '@testing-library/vue' +import { afterEach, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { + Outlet, + RouterProvider, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +afterEach(() => { + cleanup() +}) + +test('a throwing load-event listener cannot interrupt route hooks or later navigations', async () => { + const firstOnEnter = vi.fn() + const secondOnEnter = vi.fn() + const listenerError = new Error('onLoad listener failed') + + const rootRoute = createRootRoute({ component: () => }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Index route
, + }) + const firstRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/first', + onEnter: firstOnEnter, + component: () =>
First route
, + }) + const secondRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/second', + onEnter: secondOnEnter, + component: () =>
Second route
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, firstRoute, secondRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render() + expect(await screen.findByText('Index route')).toBeTruthy() + + const unsubscribe = router.subscribe('onLoad', (event) => { + if (event.toLocation.pathname === '/first') { + throw listenerError + } + }) + + await router.navigate({ to: '/first' }) + expect(await screen.findByText('First route')).toBeTruthy() + expect(firstOnEnter).toHaveBeenCalledTimes(1) + + unsubscribe() + await router.navigate({ to: '/second' }) + expect(await screen.findByText('Second route')).toBeTruthy() + expect(secondOnEnter).toHaveBeenCalledTimes(1) +}) diff --git a/packages/vue-router/tests/transitioner-remount-rendered.test.tsx b/packages/vue-router/tests/transitioner-remount-rendered.test.tsx new file mode 100644 index 0000000000..d5c5b63f15 --- /dev/null +++ b/packages/vue-router/tests/transitioner-remount-rendered.test.tsx @@ -0,0 +1,129 @@ +import { cleanup, render, screen, waitFor } from '@testing-library/vue' +import { afterEach, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { + Outlet, + RouterProvider, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +afterEach(() => { + cleanup() +}) + +function setup() { + const rootRoute = createRootRoute({ component: () => }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Index
, + }) + const nextRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/next', + component: () =>
Next
, + }) + const history = createMemoryHistory({ initialEntries: ['/'] }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, nextRoute]), + history, + }) + return { history, router } +} + +test('remounting the same router loads a history change that happened while unmounted', async () => { + const { history, router } = setup() + const firstRender = render() + expect(await screen.findByText('Index')).toBeTruthy() + + firstRender.unmount() + history.push('/next') + + render() + expect(await screen.findByText('Next')).toBeTruthy() + expect(router.state.location.pathname).toBe('/next') +}) + +test('remounting the provider emits onRendered for the newly mounted DOM', async () => { + const { router } = setup() + const onRendered = vi.fn() + const unsubscribe = router.subscribe('onRendered', onRendered) + + const firstRender = render() + expect(await screen.findByText('Index')).toBeTruthy() + await waitFor(() => expect(onRendered).toHaveBeenCalledTimes(1)) + + firstRender.unmount() + render() + expect(await screen.findByText('Index')).toBeTruthy() + await waitFor(() => expect(onRendered).toHaveBeenCalledTimes(2)) + + unsubscribe() +}) + +test('remounting an SSR-marked router loads a history change that happened while unmounted', async () => { + const { history, router } = setup() + + // This is the stable post-hydration shape: server matches are active and + // the persistent SSR marker tells the first Transitioner mount not to load. + await router.load() + router.ssr = { manifest: { routes: {} } } + + const firstRender = render() + expect(await screen.findByText('Index')).toBeTruthy() + + firstRender.unmount() + history.push('/next') + + render() + expect(await screen.findByText('Next')).toBeTruthy() + expect(router.state.location.pathname).toBe('/next') +}) + +test('onRendered runs after the destination DOM has committed', async () => { + const { router } = setup() + const initialRendered = vi.fn() + const unsubscribeInitial = router.subscribe('onRendered', initialRendered) + render() + expect(await screen.findByText('Index')).toBeTruthy() + await waitFor(() => expect(initialRendered).toHaveBeenCalledTimes(1)) + unsubscribeInitial() + + const destinationWasRendered: Array = [] + const unsubscribe = router.subscribe('onRendered', () => { + destinationWasRendered.push(screen.queryByText('Next') !== null) + }) + + await router.navigate({ to: '/next' }) + await waitFor(() => expect(destinationWasRendered).toHaveLength(1)) + expect(destinationWasRendered).toEqual([true]) + + unsubscribe() +}) + +test('onRendered fires for a same-href navigation with a new history key', async () => { + const { router } = setup() + const onRendered = vi.fn() + const unsubscribe = router.subscribe('onRendered', onRendered) + render() + expect(await screen.findByText('Index')).toBeTruthy() + await waitFor(() => expect(onRendered).toHaveBeenCalledTimes(1)) + onRendered.mockClear() + + await router.navigate({ + to: '/', + state: { sameHrefState: true } as any, + }) + await waitFor(() => expect(onRendered).toHaveBeenCalledTimes(1)) + + const event = onRendered.mock.calls[0]![0] + expect(event.fromLocation?.state.sameHrefState).toBeUndefined() + expect(event.toLocation.state.sameHrefState).toBe(true) + expect(event.fromLocation?.href).toBe('/') + expect(event.toLocation.href).toBe('/') + expect(event.hrefChanged).toBe(false) + + unsubscribe() +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a91aaea20f..e658307e76 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1433,6 +1433,43 @@ 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-7457: + dependencies: + '@tanstack/react-query': + specifier: ^5.99.0 + version: 5.99.0(react@19.2.3) + '@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.61.0 + version: 1.61.1 + '@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/view-transitions: dependencies: '@tailwindcss/vite': @@ -12601,6 +12638,9 @@ importers: specifier: ^5.1.22 version: 5.1.28 devDependencies: + '@tanstack/react-query': + specifier: ^5.99.0 + version: 5.99.0(react@19.2.3) '@testing-library/jest-dom': specifier: ^6.6.3 version: 6.6.3