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-7120/index.html b/e2e/react-router/issue-7120/index.html new file mode 100644 index 0000000000..04a3530c9d --- /dev/null +++ b/e2e/react-router/issue-7120/index.html @@ -0,0 +1,10 @@ + + + + + + Issues 7120 and 7367 + + + + diff --git a/e2e/react-router/issue-7120/package.json b/e2e/react-router/issue-7120/package.json new file mode 100644 index 0000000000..a16155eb61 --- /dev/null +++ b/e2e/react-router/issue-7120/package.json @@ -0,0 +1,27 @@ +{ + "name": "tanstack-router-e2e-react-issue-7120", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --port 3000", + "dev:e2e": "vite", + "build": "vite build && tsc --noEmit", + "preview": "vite preview", + "start": "vite", + "test:e2e": "rm -rf port*.txt; playwright test --project=chromium" + }, + "dependencies": { + "@tanstack/react-router": "workspace:^", + "@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-7120/playwright.config.ts b/e2e/react-router/issue-7120/playwright.config.ts new file mode 100644 index 0000000000..61a995a815 --- /dev/null +++ b/e2e/react-router/issue-7120/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-7120/src/main.tsx b/e2e/react-router/issue-7120/src/main.tsx new file mode 100644 index 0000000000..0c983251af --- /dev/null +++ b/e2e/react-router/issue-7120/src/main.tsx @@ -0,0 +1,21 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { RouterProvider, createRouter } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' + +const router = createRouter({ + routeTree, + defaultViewTransition: true, +}) + +declare module '@tanstack/react-router' { + interface Register { + router: typeof router + } +} + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/e2e/react-router/issue-7120/src/redirectGate.ts b/e2e/react-router/issue-7120/src/redirectGate.ts new file mode 100644 index 0000000000..399d303aac --- /dev/null +++ b/e2e/react-router/issue-7120/src/redirectGate.ts @@ -0,0 +1,35 @@ +type RedirectGate = { + waiting: boolean + released: boolean + release: () => void +} + +declare global { + interface Window { + redirectGate: RedirectGate + } +} + +let releasePromise!: () => void +const promise = new Promise((resolve) => { + releasePromise = resolve +}) + +const redirectGate: RedirectGate = { + waiting: false, + released: false, + release: () => { + if (!redirectGate.released) { + redirectGate.released = true + releasePromise() + } + }, +} + +window.redirectGate = redirectGate + +export async function waitForRedirectGate() { + redirectGate.waiting = true + await promise + redirectGate.waiting = false +} diff --git a/e2e/react-router/issue-7120/src/routeTree.gen.ts b/e2e/react-router/issue-7120/src/routeTree.gen.ts new file mode 100644 index 0000000000..fc671589c7 --- /dev/null +++ b/e2e/react-router/issue-7120/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 PostsRouteImport } from './routes/posts' +import { Route as IndexRouteImport } from './routes/index' + +const PostsRoute = PostsRouteImport.update({ + id: '/posts', + path: '/posts', + getParentRoute: () => rootRouteImport, +} as any).lazy(() => import('./routes/posts.lazy').then((d) => d.Route)) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/posts': typeof PostsRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/posts': typeof PostsRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/posts': typeof PostsRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' | '/posts' + fileRoutesByTo: FileRoutesByTo + to: '/' | '/posts' + id: '__root__' | '/' | '/posts' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + PostsRoute: typeof PostsRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/posts': { + id: '/posts' + path: '/posts' + fullPath: '/posts' + preLoaderRoute: typeof PostsRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + PostsRoute: PostsRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/e2e/react-router/issue-7120/src/routes/__root.tsx b/e2e/react-router/issue-7120/src/routes/__root.tsx new file mode 100644 index 0000000000..2f01516a55 --- /dev/null +++ b/e2e/react-router/issue-7120/src/routes/__root.tsx @@ -0,0 +1,33 @@ +import { Outlet, createRootRoute, redirect } from '@tanstack/react-router' +import { waitForRedirectGate } from '../redirectGate' + +let shouldRedirect = true + +export const Route = createRootRoute({ + pendingMs: 0, + pendingMinMs: 500, + pendingComponent: RootPendingComponent, + beforeLoad: async ({ location }) => { + if (location.pathname !== '/' || !shouldRedirect) { + return + } + + await waitForRedirectGate() + + if (!shouldRedirect) { + return + } + + shouldRedirect = false + throw redirect({ to: '/posts', replace: true }) + }, + component: () => , +}) + +function RootPendingComponent() { + return ( +
+ Loading route +
+ ) +} diff --git a/e2e/react-router/issue-7120/src/routes/index.tsx b/e2e/react-router/issue-7120/src/routes/index.tsx new file mode 100644 index 0000000000..1626ce706b --- /dev/null +++ b/e2e/react-router/issue-7120/src/routes/index.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/')({ + component: () =>
Home
, +}) diff --git a/e2e/react-router/issue-7120/src/routes/posts.lazy.tsx b/e2e/react-router/issue-7120/src/routes/posts.lazy.tsx new file mode 100644 index 0000000000..dc0b0cfd7f --- /dev/null +++ b/e2e/react-router/issue-7120/src/routes/posts.lazy.tsx @@ -0,0 +1,14 @@ +import { createLazyFileRoute } from '@tanstack/react-router' + +export const Route = createLazyFileRoute('/posts')({ + component: PostsComponent, +}) + +function PostsComponent() { + return ( +
+

Posts loaded

+

The redirected lazy route rendered.

+
+ ) +} diff --git a/e2e/react-router/issue-7120/src/routes/posts.tsx b/e2e/react-router/issue-7120/src/routes/posts.tsx new file mode 100644 index 0000000000..cc0519b74e --- /dev/null +++ b/e2e/react-router/issue-7120/src/routes/posts.tsx @@ -0,0 +1,3 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/posts')({}) diff --git a/e2e/react-router/issue-7120/src/vite-env.d.ts b/e2e/react-router/issue-7120/src/vite-env.d.ts new file mode 100644 index 0000000000..11f02fe2a0 --- /dev/null +++ b/e2e/react-router/issue-7120/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/e2e/react-router/issue-7120/tests/issue-7120.repro.spec.ts b/e2e/react-router/issue-7120/tests/issue-7120.repro.spec.ts new file mode 100644 index 0000000000..081ae73691 --- /dev/null +++ b/e2e/react-router/issue-7120/tests/issue-7120.repro.spec.ts @@ -0,0 +1,33 @@ +import { expect, test } from '@playwright/test' + +test('#7120 / #7367: a root redirect cannot commit a stale match during a view transition', 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('/') + + const pending = page.getByTestId('root-pending') + await expect(pending).toBeVisible() + expect(await page.evaluate(() => window.redirectGate.waiting)).toBe(true) + + await page.evaluate(() => window.redirectGate.release()) + + await expect(page).toHaveURL(/\/posts$/) + await expect( + page.getByRole('heading', { name: 'Posts loaded' }), + ).toBeVisible() + await expect(pending).not.toBeVisible() + expect(pageErrors).toEqual([]) + expect(consoleErrors).toEqual([]) +}) diff --git a/e2e/react-router/issue-7120/tsconfig.json b/e2e/react-router/issue-7120/tsconfig.json new file mode 100644 index 0000000000..4f6089bc08 --- /dev/null +++ b/e2e/react-router/issue-7120/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "target": "ESNext", + "moduleResolution": "Bundler", + "module": "ESNext", + "resolveJsonModule": true, + "allowJs": true, + "skipLibCheck": true, + "types": ["vite/client"] + }, + "exclude": ["node_modules", "dist"] +} diff --git a/e2e/react-router/issue-7120/vite.config.ts b/e2e/react-router/issue-7120/vite.config.ts new file mode 100644 index 0000000000..5b1c613906 --- /dev/null +++ b/e2e/react-router/issue-7120/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import { tanstackRouter } from '@tanstack/router-plugin/vite' + +export default defineConfig({ + plugins: [tanstackRouter({ target: 'react' }), react()], +}) diff --git a/e2e/react-router/issue-7457/index.html b/e2e/react-router/issue-7457/index.html new file mode 100644 index 0000000000..76369b5957 --- /dev/null +++ b/e2e/react-router/issue-7457/index.html @@ -0,0 +1,10 @@ + + + + + + Issue 7457 + + + + diff --git a/e2e/react-router/issue-7457/package.json b/e2e/react-router/issue-7457/package.json new file mode 100644 index 0000000000..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..1b6805edc0 --- /dev/null +++ b/e2e/react-router/issue-7457/src/main.tsx @@ -0,0 +1,32 @@ +import { StrictMode, useEffect } 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() { + useEffect(() => { + ;(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-router/scroll-restoration-sandbox-vite/tests/app.spec.ts b/e2e/react-router/scroll-restoration-sandbox-vite/tests/app.spec.ts index f72d2d06ab..9c2d944dd5 100644 --- a/e2e/react-router/scroll-restoration-sandbox-vite/tests/app.spec.ts +++ b/e2e/react-router/scroll-restoration-sandbox-vite/tests/app.spec.ts @@ -2,6 +2,21 @@ import { expect, test } from '@playwright/test' import { linkOptions } from '@tanstack/react-router' import { toRuntimePath } from '@tanstack/router-e2e-utils' +type ScrollPaintTestState = { + sourceFrameScrollYs: Array + sourceWasReset: boolean + destinationMounted: boolean + destinationCommitScrollY: number | null + destinationFrameScrollYs: Array + cleanup: () => void +} + +declare global { + interface Window { + __scrollPaintTestState?: ScrollPaintTestState + } +} + test('Smoke - Renders home', async ({ page }) => { await page.goto(toRuntimePath('/')) await expect( @@ -9,6 +24,175 @@ test('Smoke - Renders home', async ({ page }) => { ).toBeVisible() }) +test('PUSH resets scroll before the destination can render a frame (#7815)', async ({ + page, +}) => { + await page.goto(toRuntimePath('/issue-7040-source')) + await expect(page.getByTestId('issue-7040-source-top')).toBeVisible() + + const sourceScrollY = await page.evaluate(async () => { + window.scrollTo(0, 500) + await new Promise((resolve) => requestAnimationFrame(() => resolve())) + return window.scrollY + }) + + expect(sourceScrollY).toBeGreaterThan(0) + + await page.evaluate((expectedSourceScrollY) => { + const root = document.querySelector('#app') + if (!root) { + throw new Error('App root not found') + } + + const geometry = document.createElement('div') + geometry.setAttribute('aria-hidden', 'true') + geometry.style.height = `${window.innerHeight + expectedSourceScrollY + 100}px` + geometry.style.pointerEvents = 'none' + geometry.style.width = '1px' + document.body.append(geometry) + + const state: ScrollPaintTestState = { + sourceFrameScrollYs: [], + sourceWasReset: false, + destinationMounted: false, + destinationCommitScrollY: null, + destinationFrameScrollYs: [], + cleanup: () => {}, + } + + const getDestinationHeading = () => + Array.from(document.querySelectorAll('h3')).find( + (heading) => heading.textContent === 'normal-page', + ) + + const commitObserver = new MutationObserver(() => { + const destinationHeading = getDestinationHeading() + + if (!destinationHeading) { + return + } + + commitObserver.disconnect() + state.destinationMounted = true + state.destinationCommitScrollY = window.scrollY + }) + + commitObserver.observe(root, { childList: true, subtree: true }) + + const handleScroll = () => { + if ( + document.querySelector('[data-testid="issue-7040-source-top"]') && + Math.abs(window.scrollY - expectedSourceScrollY) > 1 + ) { + state.sourceWasReset = true + } + } + window.addEventListener('scroll', handleScroll, { passive: true }) + + const originalScrollTo = window.scrollTo + window.scrollTo = ((...args: Array) => { + const requestedTop = + typeof args[0] === 'number' + ? args[1] + : typeof args[0] === 'object' && args[0] !== null + ? (args[0] as ScrollToOptions).top + : undefined + if ( + typeof requestedTop === 'number' && + Math.abs(requestedTop - expectedSourceScrollY) > 1 && + document.querySelector('[data-testid="issue-7040-source-top"]') + ) { + state.sourceWasReset = true + } + Reflect.apply(originalScrollTo, window, args) + }) as typeof window.scrollTo + + let animationFrameId = 0 + const recordFrame = () => { + if (getDestinationHeading()) { + state.destinationFrameScrollYs.push(window.scrollY) + } else if ( + document.querySelector('[data-testid="issue-7040-source-top"]') + ) { + state.sourceFrameScrollYs.push(window.scrollY) + } + animationFrameId = requestAnimationFrame(recordFrame) + } + animationFrameId = requestAnimationFrame(recordFrame) + + state.cleanup = () => { + commitObserver.disconnect() + cancelAnimationFrame(animationFrameId) + window.removeEventListener('scroll', handleScroll) + window.scrollTo = originalScrollTo + geometry.remove() + delete window.__scrollPaintTestState + } + window.__scrollPaintTestState = state + }, sourceScrollY) + + await page.evaluate( + () => + new Promise((resolve) => { + requestAnimationFrame(() => resolve()) + }), + ) + expect(await page.evaluate(() => window.scrollY)).toBe(sourceScrollY) + + await page + .getByRole('link', { name: 'Head-/normal-page' }) + .dispatchEvent('click') + + await page.waitForFunction(() => { + const state = window.__scrollPaintTestState + if (!state?.destinationMounted) { + return false + } + + const lastFrames = state.destinationFrameScrollYs.slice(-2) + return ( + lastFrames.length === 2 && + lastFrames.every((scrollY) => Math.abs(scrollY) <= 1) + ) + }) + + const observation = await page.evaluate(() => { + const state = window.__scrollPaintTestState + if (!state) { + throw new Error('Scroll paint observer not found') + } + + return { + sourceFrameScrollYs: state.sourceFrameScrollYs, + sourceWasReset: state.sourceWasReset, + destinationCommitScrollY: state.destinationCommitScrollY, + destinationFrameScrollYs: state.destinationFrameScrollYs, + destinationMaxScrollY: + document.documentElement.scrollHeight - window.innerHeight, + finalScrollY: window.scrollY, + } + }) + + expect(observation.sourceFrameScrollYs.length).toBeGreaterThan(0) + expect( + observation.sourceFrameScrollYs.every( + (scrollY) => Math.abs(scrollY - sourceScrollY) <= 1, + ), + ).toBe(true) + expect(observation.sourceWasReset).toBe(false) + expect(observation.destinationMaxScrollY).toBeGreaterThanOrEqual( + sourceScrollY, + ) + expect(observation.finalScrollY).toBe(0) + expect(observation.destinationFrameScrollYs.length).toBeGreaterThanOrEqual(2) + expect( + observation.destinationFrameScrollYs.every( + (scrollY) => Math.abs(scrollY) <= 1, + ), + `Destination paint opportunities: ${observation.destinationFrameScrollYs.join(', ')}, commit: ${observation.destinationCommitScrollY}, final: ${observation.finalScrollY}`, + ).toBe(true) +}) + test('restores the prior scroll position after browser back then forward', async ({ page, }) => { 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/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..3d7053f6fa 100644 --- a/e2e/react-start/selective-ssr/tests/app.spec.ts +++ b/e2e/react-start/selective-ssr/tests/app.spec.ts @@ -4,6 +4,68 @@ 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 { rootBeforeLoads, targetBeforeLoad } = await page.evaluate(() => { + const rootBeforeLoads = + (globalThis as any).__issue4614RootBeforeLoads ?? [] + return { + rootBeforeLoads, + targetBeforeLoad: (globalThis as any).__issue4614TargetBeforeLoad, + } + }) + + expect(rootBeforeLoads).toEqual([]) + expect(targetBeforeLoad).toEqual({ + cause: 'preload', + preload: true, + rootContext: 'server', + issue4614Context: 'server:cached', + scenario: 'cached', + }) + }) + + test('new loaderDeps match generation propagates fresh parent context to child (control)', async ({ + page, + }) => { + await page.goto('/') + await page.getByTestId('issue-4614-reload-link').hover() + + await 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/dev-server.ts b/e2e/solid-start/selective-ssr/dev-server.ts new file mode 100644 index 0000000000..519075f261 --- /dev/null +++ b/e2e/solid-start/selective-ssr/dev-server.ts @@ -0,0 +1,5 @@ +import { getTestServerPort } from '@tanstack/router-e2e-utils' +import packageJson from './package.json' with { type: 'json' } + +export const devPort = await getTestServerPort(`${packageJson.name}-dev`) +export const devBaseURL = `http://localhost:${devPort}` diff --git a/e2e/solid-start/selective-ssr/playwright.config.ts b/e2e/solid-start/selective-ssr/playwright.config.ts index badd6db0eb..09e5c50410 100644 --- a/e2e/solid-start/selective-ssr/playwright.config.ts +++ b/e2e/solid-start/selective-ssr/playwright.config.ts @@ -1,6 +1,7 @@ import { defineConfig, devices } from '@playwright/test' import { getTestServerPort } from '@tanstack/router-e2e-utils' import packageJson from './package.json' with { type: 'json' } +import { devBaseURL, devPort } from './dev-server' const PORT = await getTestServerPort(packageJson.name) const baseURL = `http://localhost:${PORT}` @@ -18,12 +19,21 @@ export default defineConfig({ baseURL, }, - webServer: { - command: `VITE_SERVER_PORT=${PORT} pnpm build && NODE_ENV=production PORT=${PORT} VITE_SERVER_PORT=${PORT} pnpm start`, - url: baseURL, - reuseExistingServer: !process.env.CI, - stdout: 'pipe', - }, + webServer: [ + { + command: `VITE_SERVER_PORT=${PORT} pnpm build && NODE_ENV=production PORT=${PORT} VITE_SERVER_PORT=${PORT} pnpm start`, + url: baseURL, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + }, + { + command: `NODE_ENV=development VITE_SERVER_PORT=${devPort} pnpm dev:e2e --host localhost --port ${devPort} --strictPort`, + url: devBaseURL, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + timeout: 90_000, + }, + ], projects: [ { 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..a054a0433a --- /dev/null +++ b/e2e/solid-start/selective-ssr/tests/issue-7283-dev-hydration.spec.ts @@ -0,0 +1,84 @@ +import { expect, test } from '@playwright/test' +import { devBaseURL } from '../dev-server' +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). Playwright therefore owns a +// separate `vite dev` server and drives the app against it. + +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 is the issue oracle: main never reaches it. + await expect(page.getByTestId('ssr-false-target')).toBeVisible({ + timeout: 15_000, + }) + + // No dev hydration mismatch may occur along the way. + expect(failures).toEqual([]) + + await expect(page.getByTestId('ssr-false-pending')).not.toBeAttached() + + const lifecycleEvents = await page.evaluate(() => + ((globalThis as any).__events ?? []) + .map((event: { type: string }) => event.type) + .filter((type: string) => + ['pending-mounted', 'pending-unmounted', 'target-mounted'].includes( + type, + ), + ), + ) + expect(lifecycleEvents.slice(-3)).toEqual([ + 'pending-mounted', + 'pending-unmounted', + 'target-mounted', + ]) + }) + + 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-pending'), + ).toBeAttached() + 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..f77a939a6a 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('legacy notFoundRoute drops a stale parent layout after navigation', 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..b7963c0fa6 --- /dev/null +++ b/packages/react-router/tests/component-preload-retry-pending-min.test.tsx @@ -0,0 +1,124 @@ +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: + * its fallback must remain visible until the retried component preload is + * ready and pendingMinMs has elapsed. + */ +test('component preload retry remains pending through pendingMinMs', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + + const retryChunk = createControlledPromise() + let preloadAttempt = 0 + let retryInvalidation!: Promise + 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(Page.preload).toHaveBeenCalledTimes(2) + expect(screen.getByTestId('page-pending')).toBeInTheDocument() + expect( + screen.queryByRole('button', { name: 'Retry chunk' }), + ).not.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 retryInvalidation + }) + + 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..4672228875 --- /dev/null +++ b/packages/react-router/tests/component-preload-retry.test.tsx @@ -0,0 +1,69 @@ +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() + + const retryButton = await screen.findByRole('button', { name: 'Retry' }) + expect(importer).toHaveBeenCalledTimes(1) + + fireEvent.click(retryButton) + + expect(await screen.findByText('Page content')).toBeInTheDocument() + expect(importer).toHaveBeenCalledTimes(2) + expect( + screen.queryByRole('button', { name: 'Retry' }), + ).not.toBeInTheDocument() +}) diff --git a/packages/react-router/tests/errorComponent.test.tsx b/packages/react-router/tests/errorComponent.test.tsx index 296c0caf34..560b1b5bfa 100644 --- a/packages/react-router/tests/errorComponent.test.tsx +++ b/packages/react-router/tests/errorComponent.test.tsx @@ -1,6 +1,5 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import ReactDOMServer from 'react-dom/server' import { HeadContent, @@ -320,7 +319,6 @@ test('errorComponent receives primitive errors thrown from beforeLoad', async () }) test('SSR errorComponent receives primitive errors thrown from beforeLoad', async () => { - const history = createMemoryHistory({ initialEntries: ['/about'] }) const rootRoute = createRootRoute({ component: function Root() { return @@ -336,16 +334,25 @@ test('SSR errorComponent receives primitive errors thrown from beforeLoad', asyn errorComponent: ({ error }) =>
Error: {String(error)}
, }) - const router = createRouter({ - routeTree: rootRoute.addChildren([aboutRoute]), - history, + const handler = createRequestHandler({ + request: new Request('http://localhost/about'), + createRouter: () => + createRouter({ + routeTree: rootRoute.addChildren([aboutRoute]), + isServer: true, + }), }) - router.isServer = true - await router.load() + const response = await handler(({ router, responseHeaders }) => + renderRouterToString({ + router, + responseHeaders, + children: , + }), + ) - expect(router.state.statusCode).toBe(500) - const html = ReactDOMServer.renderToString() + expect(response.status).toBe(500) + const html = await response.text() expect(html).toContain('Error:') expect(html).toContain('primitive error thrown') }) @@ -496,13 +503,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..d4607c946f --- /dev/null +++ b/packages/react-router/tests/hydration-capped-boundary-pending.test.tsx @@ -0,0 +1,185 @@ +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 childLoader = vi.fn(() => 'child data') + const boundaryCommits = vi.fn() + + function BoundaryError() { + React.useEffect(() => { + boundaryCommits() + }, []) + return
Boundary error
+ } + + function BoundaryNotFound() { + React.useEffect(() => { + boundaryCommits() + }, []) + return
Boundary not found
+ } + + const makeRouteTree = () => { + const boundaryOptions = { + beforeLoad: () => { + throw outcome === 'notFound' + ? notFound() + : new Error('server route failure') + }, + pendingComponent: () => ( +
Boundary pending
+ ), + errorComponent: BoundaryError, + notFoundComponent: BoundaryNotFound, + } + 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(), + 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', + ) + expect(boundaryCommits).not.toHaveBeenCalled() + + 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(() => {}) + let root!: ReturnType + await act(async () => { + root = hydrateRoot(container, ) + testCleanups.push(async () => { + await act(() => root.unmount()) + }) + 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(boundaryCommits).toHaveBeenCalledTimes(1) + 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..868f19ab27 --- /dev/null +++ b/packages/react-router/tests/hydration-terminal-lane.test.tsx @@ -0,0 +1,97 @@ +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 parentLoader = vi.fn(() => 'client-parent') + const childLoader = vi.fn(() => 'client-child') + const rootRoute = createRootRoute({ component: Outlet }) + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: parentLoader, + component: () => ( + <> +
{parentRoute.useLoaderData()}
+ + + ), + }) + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: '/child', + ssr: false, + loader: childLoader, + 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() + expect(parentLoader).not.toHaveBeenCalled() + expect(childLoader).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/react-router/tests/issue-4467-lazy-route-pending.test.tsx b/packages/react-router/tests/issue-4467-lazy-route-pending.test.tsx new file mode 100644 index 0000000000..5a765c46ea --- /dev/null +++ b/packages/react-router/tests/issue-4467-lazy-route-pending.test.tsx @@ -0,0 +1,80 @@ +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, + createLazyRoute, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +afterEach(cleanup) + +// https://github.com/TanStack/router/issues/4467 +test('default pending component renders while lazy route options load', async () => { + const rootRoute = createRootRoute({ + component: Outlet, + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>

Index page

, + }) + const lazyPageOptions = createLazyRoute('/page')({ + component: () =>

Page

, + }) + const lazyOptions = createControlledPromise() + const loadLazyOptions = vi.fn(() => lazyOptions) + const pageRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/page', + }).lazy(loadLazyOptions) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, pageRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + defaultPendingMs: 0, + defaultPendingMinMs: 0, + defaultPendingComponent: () =>

Loading page

, + }) + let navigationPromise: Promise | undefined + + try { + render() + + expect( + await screen.findByRole('heading', { name: 'Index page' }), + ).toBeInTheDocument() + + act(() => { + navigationPromise = router.navigate({ to: '/page' }) + }) + + expect(await screen.findByRole('status')).toHaveTextContent('Loading page') + expect( + screen.queryByRole('heading', { name: 'Page' }), + ).not.toBeInTheDocument() + expect(lazyOptions.status).toBe('pending') + expect(loadLazyOptions).toHaveBeenCalledTimes(1) + + await act(async () => { + lazyOptions.resolve(lazyPageOptions) + await navigationPromise + }) + + expect(screen.getByRole('heading', { name: 'Page' })).toBeInTheDocument() + expect(screen.queryByRole('status')).not.toBeInTheDocument() + expect(loadLazyOptions).toHaveBeenCalledTimes(1) + } finally { + await act(async () => { + if (lazyOptions.status === 'pending') { + lazyOptions.resolve(lazyPageOptions) + } + await navigationPromise + }) + } +}) 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..080880517d --- /dev/null +++ b/packages/react-router/tests/issue-4476-react-query-cancellation.test.tsx @@ -0,0 +1,129 @@ +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 keeps the query observer mounted and its fetchQuery signal alive', 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() + const pendingComponentRendered = 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: () => { + pendingComponentRendered() + return
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()) + expect(screen.getByTestId('page-one')).toBeInTheDocument() + expect(screen.queryByTestId('page-two-pending')).not.toBeInTheDocument() + expect(querySignal?.aborted).toBe(false) + queryGate.resolve(10) + + expect(await screen.findByText('Page two: 10')).toBeInTheDocument() + expect(routeError).not.toHaveBeenCalled() + expect(screen.queryByTestId('page-one')).not.toBeInTheDocument() + expect(screen.queryByTestId('page-two-pending')).not.toBeInTheDocument() + expect(screen.queryByTestId('page-two-error')).not.toBeInTheDocument() + expect(router.state.location.pathname).toBe('/page-two') + } 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..5903f2960b --- /dev/null +++ b/packages/react-router/tests/issue-4759-pending-frame.test.tsx @@ -0,0 +1,92 @@ +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 +// +// JSDOM cannot observe browser paints. This unit reduction verifies the event +// ordering behind the issue: pending DOM must be published before the first +// macrotask when pendingMs is 0. +describe('issue #4759: pendingMs 0 publishes pending DOM before a macrotask', () => { + test('pending fallback is committed on mount without waiting for a macrotask', async () => { + vi.useFakeTimers() + let resolveLoader!: (value: string) => void + const loaderPromise = new Promise((resolve) => { + resolveLoader = resolve + }) + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + loader: () => loaderPromise, + component: () =>
loaded
, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history, + }) + let resolveRendered!: () => void + const rendered = new Promise((resolve) => { + resolveRendered = resolve + }) + const unsubscribe = router.subscribe('onRendered', (event) => { + if (event.toLocation.pathname === '/') { + resolveRendered() + } + }) + + try { + render( +
+ ( +
pending...
+ )} + /> +
, + ) + + // Fake timers keep the first macrotask frozen. An implementation that + // publishes pending state with setTimeout cannot satisfy this assertion. + await act(async () => {}) + expect(screen.getByTestId('pending')).toBeInTheDocument() + + // Sanity: the load still completes normally afterwards. + resolveLoader('done') + await act(() => rendered) + expect(screen.getByTestId('loaded')).toBeInTheDocument() + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() + } finally { + unsubscribe() + resolveLoader('done') + vi.useRealTimers() + } + }) +}) 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..22440fe119 --- /dev/null +++ b/packages/react-router/tests/issue-6107-lazy-chunk-error-component.test.tsx @@ -0,0 +1,89 @@ +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', + ) + const defaultErrorRendered = vi.fn() + 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: ['/'] }), + defaultPreloadDelay: 0, + defaultErrorComponent: ({ error }) => { + defaultErrorRendered(error) + return
{error.message}
+ }, + }) + const preloadRoute = vi.spyOn(router, 'preloadRoute') + + render() + expect(await screen.findByText('Index')).toBeInTheDocument() + + const link = screen.getByRole('link', { name: 'Posts' }) + fireEvent.mouseEnter(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() + expect(defaultErrorRendered).not.toHaveBeenCalled() + + const callsAfterPreload = lazyCalls + fireEvent.click(link) + expect(await screen.findByTestId('default-error')).toHaveTextContent( + chunkError.message, + ) + expect(lazyCalls).toBeGreaterThan(callsAfterPreload) + expect(defaultErrorRendered).toHaveBeenCalledWith(chunkError) + expect(screen.queryByText('Index')).not.toBeInTheDocument() + expect(router.state.location.pathname).toBe('/posts') + expect(router.state.status).toBe('idle') +}) diff --git a/packages/react-router/tests/issue-6371-search-default-normalization-abort.test.tsx b/packages/react-router/tests/issue-6371-search-default-normalization-abort.test.tsx new file mode 100644 index 0000000000..2c29748040 --- /dev/null +++ b/packages/react-router/tests/issue-6371-search-default-normalization-abort.test.tsx @@ -0,0 +1,129 @@ +import { afterEach, expect, test, vi } from 'vitest' +import { act, cleanup, render, screen } from '@testing-library/react' +import { + Outlet, + RouterProvider, + createControlledPromise, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + useLocation, +} from '../src' + +afterEach(() => { + cleanup() +}) + +test('#6371: initial search defaults produce one live canonical loader', async () => { + const loaderGate = createControlledPromise() + const canonicalLocation = createControlledPromise() + const abortedLoaderData = 'discarded aborted loader' + const loaderSignals: Array = [] + const loaderLocations: Array = [] + const errorComponentRendered = vi.fn() + const loader = vi.fn( + ({ + abortController, + location, + }: { + abortController: AbortController + location: { href: string } + }) => { + const signal = abortController.signal + loaderSignals.push(signal) + loaderLocations.push(location.href) + + return new Promise((resolve, reject) => { + const onAbort = () => { + resolve(abortedLoaderData) + } + + if (signal.aborted) { + onAbort() + return + } + + signal.addEventListener('abort', onAbort, { once: true }) + loaderGate.then((data) => { + signal.removeEventListener('abort', onAbort) + resolve(data) + }, reject) + }) + }, + ) + + const PendingLocation = () => { + const href = useLocation({ select: (location) => location.href }) + return
{href}
+ } + + const rootRoute = createRootRoute({ + component: () => , + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + validateSearch: (search: Record) => ({ + page: typeof search.page === 'number' ? search.page : 1, + }), + loader, + component: () => ( +
{aboutRoute.useLoaderData()}
+ ), + errorComponent: ({ error }) => { + errorComponentRendered(error) + return
{error.message}
+ }, + }) + const history = createMemoryHistory({ initialEntries: ['/about'] }) + const unsubscribeHistory = history.subscribe(() => { + if (history.location.href === '/about?page=1') { + canonicalLocation.resolve() + } + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([aboutRoute]), + history, + defaultPendingMs: 0, + defaultPendingMinMs: 0, + defaultPendingComponent: PendingLocation, + }) + + try { + render() + + await act(async () => { + await canonicalLocation + }) + + expect(loader).toHaveBeenCalledTimes(1) + expect(loaderLocations).toEqual(['/about?page=1']) + expect(loaderSignals).toHaveLength(1) + expect(loaderSignals[0]?.aborted).toBe(false) + + expect( + await screen.findByText('/about?page=1', { + selector: '[data-testid="pending-location"]', + }), + ).toBeInTheDocument() + + await act(() => { + loaderGate.resolve('about data') + }) + + expect(await screen.findByTestId('about-data')).toHaveTextContent( + 'about data', + ) + expect(loader).toHaveBeenCalledTimes(1) + expect(loaderSignals[0]?.aborted).toBe(false) + expect(errorComponentRendered).not.toHaveBeenCalled() + expect(screen.queryByTestId('about-error')).not.toBeInTheDocument() + } finally { + unsubscribeHistory() + await act(() => { + canonicalLocation.resolve() + loaderGate.resolve('about data') + }) + } +}) 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..5640cb7477 --- /dev/null +++ b/packages/react-router/tests/issue-7051-force-pending-suspense.test.tsx @@ -0,0 +1,195 @@ +import { act } from 'react' +import { afterEach, expect, test, vi } from 'vitest' +import { cleanup, render, screen } from '@testing-library/react' +import { + Outlet, + RouterProvider, + createControlledPromise, + 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 + const reloadGate = createControlledPromise() + + const rootRoute = createRootRoute({ + component: () => , + }) + + const forcePendingRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/force-pending', + pendingMs: 0, + pendingMinMs: 10, + loader: async () => { + if (shouldSuspendReload) { + await reloadGate + } + + 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(() => { + reloadGate.resolve() + }) + + await act(() => invalidation) + expect(await screen.findByTestId('force-pending-route')).toHaveTextContent( + 'done', + ) + expect(screen.queryByTestId('force-pending-fallback')).not.toBeInTheDocument() + expect(screen.queryByTestId('force-pending-error')).not.toBeInTheDocument() + expect(errorComponentRendered).not.toHaveBeenCalled() + expect(router.state.location.pathname).toBe('/force-pending') + expect(router.state.status).toBe('idle') +}) + +test('regular navigation keeps the current pending fallback while its loader is aborted', async () => { + const firstLoaderAborted = createControlledPromise() + const secondLoaderStarted = createControlledPromise() + const secondLoaderGate = createControlledPromise() + const firstErrorComponentRendered = vi.fn() + let firstSignal: AbortSignal | undefined + + const rootRoute = createRootRoute({ + component: () => , + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Home page
, + }) + const firstRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/first', + pendingMs: 0, + loader: async ({ abortController }) => { + firstSignal = abortController.signal + await new Promise((_resolve, reject) => { + abortController.signal.addEventListener( + 'abort', + () => { + firstLoaderAborted.resolve() + reject(new DOMException('Aborted', 'AbortError')) + }, + { once: true }, + ) + }) + return 'first' + }, + component: () => ( +
{firstRoute.useLoaderData()}
+ ), + pendingComponent: () => ( +
Pending first route
+ ), + errorComponent: ({ error }) => { + firstErrorComponentRendered(error) + return
{String(error)}
+ }, + }) + const secondRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/second', + loader: async () => { + secondLoaderStarted.resolve() + await secondLoaderGate + return 'second' + }, + component: () => ( +
{secondRoute.useLoaderData()}
+ ), + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, firstRoute, secondRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + defaultPreload: false, + }) + + render() + expect(await screen.findByTestId('home-page')).toBeInTheDocument() + + act(() => { + void router.navigate({ to: '/first' }) + }) + expect(await screen.findByTestId('first-pending')).toBeInTheDocument() + expect(firstSignal?.aborted).toBe(false) + + let secondNavigation!: Promise + act(() => { + secondNavigation = router.navigate({ to: '/second' }) + }) + await act(async () => { + await Promise.all([firstLoaderAborted, secondLoaderStarted]) + }) + + expect(firstSignal?.aborted).toBe(true) + expect(screen.getByTestId('first-pending')).toBeInTheDocument() + expect(screen.queryByTestId('first-error')).not.toBeInTheDocument() + expect(firstErrorComponentRendered).not.toHaveBeenCalled() + expect(router.state.location.pathname).toBe('/second') + expect(router.state.status).toBe('pending') + + act(() => { + secondLoaderGate.resolve() + }) + await act(() => secondNavigation) + + expect(await screen.findByTestId('second-page')).toHaveTextContent('second') + expect(screen.queryByTestId('first-pending')).not.toBeInTheDocument() + expect(screen.queryByTestId('first-error')).not.toBeInTheDocument() + expect(firstErrorComponentRendered).not.toHaveBeenCalled() + expect(router.state.location.pathname).toBe('/second') + expect(router.state.status).toBe('idle') +}) diff --git a/packages/react-router/tests/issue-7635-error-head-after-navigation.test.tsx b/packages/react-router/tests/issue-7635-error-head-after-navigation.test.tsx new file mode 100644 index 0000000000..56aed7b375 --- /dev/null +++ b/packages/react-router/tests/issue-7635-error-head-after-navigation.test.tsx @@ -0,0 +1,100 @@ +import { createPortal } from 'react-dom' +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react' +import { afterEach, expect, test, vi } from 'vitest' +import { + HeadContent, + Link, + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +afterEach(() => { + cleanup() + document.head.innerHTML = '' +}) + +// https://github.com/TanStack/router/issues/7635 +test('#7635: a parent beforeLoad error replaces the previous child title', async () => { + const appError = new Error('App beforeLoad failed') + const appErrorRendered = vi.fn() + const childHead = vi.fn(() => ({ + meta: [{ title: 'Child success title' }], + })) + + const rootRoute = createRootRoute({ + component: () => ( + <> + {createPortal(, document.head)} + + Fail app load + + + + ), + }) + const appRoute = createRoute({ + getParentRoute: () => rootRoute, + id: '_app', + validateSearch: (search: Record) => ({ + fail: search.fail === true || search.fail === 'true', + }), + beforeLoad: ({ search }) => { + if (search.fail) { + throw appError + } + }, + head: ({ match }) => ({ + meta: [ + { + title: match.error ? 'App error title' : 'App success title', + }, + ], + }), + component: Outlet, + errorComponent: ({ error }) => { + appErrorRendered(error) + return
{error.message}
+ }, + }) + const childRoute = createRoute({ + getParentRoute: () => appRoute, + path: '/child', + head: childHead, + component: () =>
Child content
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([appRoute.addChildren([childRoute])]), + history: createMemoryHistory({ + initialEntries: ['/child?fail=false'], + }), + }) + + render() + + expect(await screen.findByTestId('child-content')).toBeInTheDocument() + await waitFor(() => expect(document.title).toBe('Child success title')) + expect(childHead).toHaveBeenCalled() + childHead.mockClear() + + fireEvent.click(screen.getByRole('link', { name: 'Fail app load' })) + + expect(await screen.findByTestId('app-error')).toHaveTextContent( + appError.message, + ) + expect(appErrorRendered).toHaveBeenCalledWith(appError) + expect(screen.queryByTestId('child-content')).not.toBeInTheDocument() + await waitFor(() => expect(document.title).toBe('App error title')) + expect(childHead).not.toHaveBeenCalled() + expect(router.state.location.href).toBe('/child?fail=true') + expect(router.state.status).toBe('idle') +}) diff --git a/packages/react-router/tests/loaders.test.tsx b/packages/react-router/tests/loaders.test.tsx index 869aeec76c..6859b963e2 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') @@ -730,20 +729,32 @@ test('clears pendingTimeout when match resolves', async () => { expect(fooPendingComponentOnMountMock).not.toHaveBeenCalled() }) -test('throw abortError from loader upon initial load with basepath', async () => { - window.history.replaceState(null, 'root', '/app') +// https://github.com/TanStack/router/pull/7673 +test('#7673: a spontaneous loader AbortError renders the boundary without executing the route component', async () => { + history.replace('/app') + history.flush() const rootRoute = createRootRoute({}) + const abortError = new DOMException('Aborted', 'AbortError') + const routeComponentRendered = vi.fn() + const renderedError = vi.fn() + let routeSignal: AbortSignal | undefined const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', - loader: async () => { - return Promise.reject(new DOMException('Aborted', 'AbortError')) + loader: async ({ abortController }): Promise<{ value: string }> => { + routeSignal = abortController.signal + return Promise.reject(abortError) + }, + component: () => { + routeComponentRendered() + const data = indexRoute.useLoaderData() + return
{data.value}
+ }, + errorComponent: ({ error }) => { + renderedError(error) + return
indexErrorComponent
}, - component: () =>
Index route content
, - errorComponent: () => ( -
indexErrorComponent
- ), }) const routeTree = rootRoute.addChildren([indexRoute]) @@ -751,13 +762,21 @@ 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(window.location.pathname.startsWith('/app')).toBe(true) + expect(await screen.findByTestId('index-error')).toBeInTheDocument() + expect(screen.queryByTestId('index-content')).not.toBeInTheDocument() + expect(routeComponentRendered).not.toHaveBeenCalled() + expect(renderedError).toHaveBeenCalledWith(abortError) + expect(routeSignal?.aborted).toBe(false) + expect( + router.state.matches.find((match) => match.routeId === indexRoute.id), + ).toMatchObject({ + status: 'error', + error: abortError, + }) + expect(window.location.pathname).toBe('/app') }) -test('cancelMatches after pending timeout', async () => { +test('navigating away from a pending route aborts its loader', async () => { function getPendingComponent(onMount: () => void) { const PendingComponent = () => { useEffect(() => { @@ -770,6 +789,7 @@ test('cancelMatches after pending timeout', async () => { } const onAbortMock = vi.fn() const fooPendingComponentOnMountMock = vi.fn() + let fooSignal: AbortSignal | undefined const rootRoute = createRootRoute({ component: () => (
@@ -787,17 +807,18 @@ test('cancelMatches after pending timeout', async () => { const fooRoute = createRoute({ getParentRoute: () => rootRoute, path: '/foo', - pendingMs: WAIT_TIME * 20, + pendingMs: 0, loader: async ({ abortController }) => { + fooSignal = abortController.signal await new Promise((resolve) => { - const timer = setTimeout(() => { - resolve() - }, WAIT_TIME * 40) - abortController.signal.addEventListener('abort', () => { - onAbortMock() - clearTimeout(timer) - resolve() - }) + abortController.signal.addEventListener( + 'abort', + () => { + onAbortMock() + resolve() + }, + { once: true }, + ) }) }, pendingComponent: getPendingComponent(fooPendingComponentOnMountMock), @@ -811,18 +832,22 @@ 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) const pendingElement = await screen.findByText('Pending...') expect(pendingElement).toBeInTheDocument() - const barLink = await screen.findByTestId('link-to-bar') - fireEvent.click(barLink) - const barElement = await screen.findByText('Bar page') + expect(fooSignal?.aborted).toBe(false) + await act(() => router.navigate({ to: '/bar' })) + const barElement = screen.getByText('Bar page') expect(barElement).toBeInTheDocument() + expect(fooPendingComponentOnMountMock).toHaveBeenCalled() - expect(onAbortMock).toHaveBeenCalled() + expect(onAbortMock).toHaveBeenCalledTimes(1) + expect(fooSignal?.aborted).toBe(true) + expect(screen.queryByText('Pending...')).not.toBeInTheDocument() + expect(screen.queryByText('Foo page')).not.toBeInTheDocument() + expect(router.state.location.href).toBe('/bar') + expect(router.state.status).toBe('idle') }) test('reproducer for #6388 - rapid navigation between parameterized routes should not trigger errorComponent', async () => { 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..76e7a0cb9d --- /dev/null +++ b/packages/react-router/tests/on-rendered-same-href-state.test.tsx @@ -0,0 +1,66 @@ +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' + +const testCleanups: Array<() => void> = [] + +afterEach(() => { + while (testCleanups.length) { + testCleanups.pop()!() + } + 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() + await waitFor(() => { + expect(router.state.status).toBe('idle') + expect(router.state.resolvedLocation?.href).toBe('/') + }) + const initialHistoryKey = router.state.resolvedLocation?.state.__TSR_key + expect(initialHistoryKey).toBeDefined() + + const onRendered = vi.fn() + const unsubscribe = router.subscribe('onRendered', onRendered) + testCleanups.push(unsubscribe) + 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) + expect(event.fromLocation?.state.__TSR_key).toBe(initialHistoryKey) + expect(event.toLocation.state.__TSR_key).toBeDefined() + expect(event.toLocation.state.__TSR_key).not.toBe(initialHistoryKey) + expect(router.state.resolvedLocation?.state.__TSR_key).toBe( + event.toLocation.state.__TSR_key, + ) +}) 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..0f5ca3703d --- /dev/null +++ b/packages/react-router/tests/pending-fallback-promise-replacement.test.tsx @@ -0,0 +1,138 @@ +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' +import type { AnyRouter } from '../src' + +afterEach(() => { + vi.useRealTimers() + cleanup() +}) + +test.each(['child', 'root'] as const)( + 'a mounted %s pending fallback follows an overlapping load generation', + async (routeLevel) => { + const firstReload = createControlledPromise() + const secondReload = createControlledPromise() + const reloads = [firstReload, secondReload] + let loaderCall = 0 + + const routeOptions = { + pendingMs: 0, + pendingMinMs: 100, + pendingComponent: () =>
Loading...
, + loader: () => { + const generation = ++loaderCall + const gate = reloads[generation - 2] + return gate ? gate.then(() => ({ generation })) : { generation } + }, + } + + const makeRouter = (): AnyRouter => { + if (routeLevel === 'root') { + const rootRoute = createRootRoute({ + ...routeOptions, + component: () => ( +
+ Generation {rootRoute.useLoaderData().generation} +
+ ), + }) + return createRouter({ + routeTree: rootRoute, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + } + + const rootRoute = createRootRoute({ + component: () => , + }) + const pageRoute = createRoute({ + ...routeOptions, + getParentRoute: () => rootRoute, + path: '/page', + component: () => ( +
+ Generation {pageRoute.useLoaderData().generation} +
+ ), + }) + return createRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + }) + } + const router = makeRouter() + + 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..3b5fd0d173 --- /dev/null +++ b/packages/react-router/tests/preloaded-mount-resolution.test.tsx @@ -0,0 +1,103 @@ +import { afterEach, beforeEach, expect, 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 +}) + +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: ['/'] }), + }) + + let resolveRendered!: () => void + const rendered = new Promise((resolve) => { + resolveRendered = resolve + }) + const onRendered = vi.fn(() => resolveRendered()) + 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) + let renderedTimeout: ReturnType | undefined + + try { + // Load fully settles before the provider mounts — the exact shape of the + // memory benchmark's mount/unmount cycle. + await router.load() + expect(router.state.status).toBe('idle') + expect(router.state.resolvedLocation?.pathname).toBe('/') + expect(onLoad).toHaveBeenCalledTimes(1) + expect(onResolved).toHaveBeenCalledTimes(1) + expect(onRendered).not.toHaveBeenCalled() + + reactRoot.render() + await Promise.race([ + rendered, + new Promise((_, reject) => { + renderedTimeout = setTimeout(() => { + reject(new Error('Timed out waiting for onRendered')) + }, 2000) + }), + ]) + + expect(container.querySelector('[data-testid="home"]')).not.toBeNull() + expect(onRendered).toHaveBeenCalledTimes(1) + expect(router.state.status).toBe('idle') + expect(router.state.resolvedLocation?.pathname).toBe('/') + } finally { + clearTimeout(renderedTimeout) + 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..c3b045d511 100644 --- a/packages/react-router/tests/redirect.test.tsx +++ b/packages/react-router/tests/redirect.test.tsx @@ -1,5 +1,6 @@ import * as React from 'react' import { + act, cleanup, configure, fireEvent, @@ -8,13 +9,13 @@ import { } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { createControlledPromise } from '@tanstack/router-core' import { Link, Outlet, RouterProvider, createBrowserHistory, - createMemoryHistory, createRootRoute, createRoute, createRouter, @@ -45,6 +46,114 @@ 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 loader = vi.fn(() => { + if (firstLoad) { + firstLoad = false + throw redirect({ to: '/' }) + } + }) + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + loader, + component: () =>
Index page
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history, + }) + + render() + + expect(await screen.findByText('Index page')).toBeInTheDocument() + expect(window.location.pathname).toBe('/') + expect(loader).toHaveBeenCalledTimes(2) + expect(router.state.status).toBe('idle') + }) + + test('renders an error for a same-location redirect cycle', async () => { + const loader = vi.fn(() => { + throw redirect({ to: '/' }) + }) + const rootRoute = createRootRoute({ + errorComponent: ({ error }) => ( +
Root: {error.message}
+ ), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + loader, + errorComponent: ({ error }) => ( +
Index: {error.message}
+ ), + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history, + }) + + render() + + expect(await screen.findByTestId('index-error')).toHaveTextContent( + 'Index: Redirect cycle detected', + ) + expect(screen.queryByTestId('root-error')).not.toBeInTheDocument() + expect(window.location.pathname).toBe('/') + expect(loader).toHaveBeenCalledTimes(21) + expect(router.state.status).toBe('idle') + }) + + test('renders an error for an alternating redirect cycle', async () => { + const indexLoader = vi.fn(() => { + throw redirect({ to: '/other' }) + }) + const otherLoader = vi.fn(() => { + throw redirect({ to: '/' }) + }) + const rootRoute = createRootRoute({ + errorComponent: ({ error }) => ( +
Root: {error.message}
+ ), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + loader: indexLoader, + errorComponent: ({ error }) => ( +
Index: {error.message}
+ ), + }) + const otherRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/other', + loader: otherLoader, + errorComponent: ({ error }) => ( +
Other: {error.message}
+ ), + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, otherRoute]), + history, + }) + + render() + + expect(await screen.findByTestId('index-error')).toHaveTextContent( + 'Index: Redirect cycle detected', + ) + expect(screen.queryByTestId('other-error')).not.toBeInTheDocument() + expect(screen.queryByTestId('root-error')).not.toBeInTheDocument() + expect(window.location.pathname).toBe('/') + expect(indexLoader).toHaveBeenCalledTimes(11) + expect(otherLoader).toHaveBeenCalledTimes(10) + expect(router.state.status).toBe('idle') + }) + test('when `redirect` is thrown in `beforeLoad`', async () => { const nestedLoaderMock = vi.fn() const nestedFooLoaderMock = vi.fn() @@ -115,6 +224,7 @@ describe('redirect', () => { test('when root `beforeLoad` redirects while root pendingComponent is showing and the target route is lazy', async () => { let hasRedirected = false + const beforeLoad = createControlledPromise() const consoleError = vi .spyOn(console, 'error') .mockImplementation(() => {}) @@ -124,7 +234,7 @@ describe('redirect', () => { pendingMs: 0, pendingComponent: () =>
loading
, beforeLoad: async () => { - await sleep(WAIT_TIME) + await beforeLoad if (!hasRedirected) { hasRedirected = true throw redirect({ to: '/posts' }) @@ -150,6 +260,14 @@ describe('redirect', () => { render() + try { + expect(await screen.findByTestId('pending')).toBeInTheDocument() + } finally { + await act(() => { + beforeLoad.resolve() + }) + } + // The lazy target route adds the async boundary that exposes the stale // redirected-match render path this regression is guarding. expect(await screen.findByTestId('lazy-route-page')).toBeInTheDocument() @@ -311,116 +429,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..636f871274 --- /dev/null +++ b/packages/react-router/tests/root-pending-min.test.tsx @@ -0,0 +1,250 @@ +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('a post-hydration root reload keeps its fallback through pendingMinMs', 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 route hydration preserves component state across its Suspense boundary', 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('') + expect(html).toContain('preserved') + + const container = document.createElement('div') + container.innerHTML = html + document.body.appendChild(container) + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + let root!: ReturnType + await act(async () => { + root = hydrateRoot(container, ) + testCleanups.push(async () => { + await act(() => root.unmount()) + consoleError.mockRestore() + container.remove() + }) + 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('server rendering uses the root pending boundary for route component suspension', 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('Transitioner remount loads hydrated history changes 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..8ddf69db18 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,22 +2209,26 @@ 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, path: '/', - component: () =>
Home
, + component: () =>
Home
, }) const validRoute = createRoute({ getParentRoute: () => rootRoute, path: '/valid', - component: () =>
Valid Route
, + component: () =>
Valid Route
, }) const routeTree = rootRoute.addChildren([indexRoute, validRoute]) @@ -2228,27 +2236,28 @@ describe('statusCode', () => { render() - expect(router.state.statusCode).toBe(200) - await act(() => router.navigate({ to: '/' })) - expect(router.state.statusCode).toBe(200) + expect(await screen.findByTestId('home-page')).toBeInTheDocument() await act(() => router.navigate({ to: '/non-existing' })) - expect(router.state.statusCode).toBe(404) + expect(await screen.findByTestId('root-not-found')).toBeInTheDocument() + expect(screen.queryByTestId('home-page')).not.toBeInTheDocument() await act(() => router.navigate({ to: '/valid' })) - expect(router.state.statusCode).toBe(200) + expect(await screen.findByTestId('valid-page')).toBeInTheDocument() + expect(screen.queryByTestId('root-not-found')).not.toBeInTheDocument() await act(() => router.navigate({ to: '/another-non-existing' })) - expect(router.state.statusCode).toBe(404) + expect(await screen.findByTestId('root-not-found')).toBeInTheDocument() + expect(screen.queryByTestId('valid-page')).not.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 () => { - await new Promise((resolve) => setTimeout(resolve, 10)) + await Promise.resolve() toThrow() } : (toThrow: () => void) => toThrow @@ -2259,15 +2268,19 @@ 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() + const rootRoute = createRootRoute({ + notFoundComponent: () => ( +
Root Not Found
+ ), + }) const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', - component: () =>
Home
, + component: () =>
Home
, }) const loaderThrowsRoute = createRoute({ @@ -2278,7 +2291,7 @@ describe('statusCode', () => {
loader will throw
), notFoundComponent: () => ( -
Not Found
+
Route Not Found
), }) @@ -2287,29 +2300,27 @@ describe('statusCode', () => { render() - expect(router.state.statusCode).toBe(200) - + expect(await screen.findByTestId('home-page')).toBeInTheDocument() await act(() => router.navigate({ to: '/loader-throws-not-found' })) - expect(router.state.statusCode).toBe(404) - expect( - await screen.findByTestId('not-found-component'), - ).toBeInTheDocument() + expect(await screen.findByTestId('route-not-found')).toBeInTheDocument() + expect(screen.queryByTestId('root-not-found')).not.toBeInTheDocument() expect(screen.queryByTestId('route-component')).not.toBeInTheDocument() + expect(screen.queryByTestId('home-page')).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({ notFoundComponent: () => ( -
Not Found
+
Root Not Found
), }) const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', - component: () =>
Home
, + component: () =>
Home
, }) const beforeLoadThrowsRoute = createRoute({ @@ -2320,7 +2331,7 @@ describe('statusCode', () => {
beforeLoad will throw
), notFoundComponent: () => ( -
Not Found
+
Route Not Found
), }) @@ -2332,17 +2343,15 @@ describe('statusCode', () => { render() - expect(router.state.statusCode).toBe(200) - + expect(await screen.findByTestId('home-page')).toBeInTheDocument() await act(() => router.navigate({ to: '/beforeload-throws-not-found' })) - expect(router.state.statusCode).toBe(404) - expect( - await screen.findByTestId('not-found-component'), - ).toBeInTheDocument() + expect(await screen.findByTestId('route-not-found')).toBeInTheDocument() + expect(screen.queryByTestId('root-not-found')).not.toBeInTheDocument() expect(screen.queryByTestId('route-component')).not.toBeInTheDocument() + expect(screen.queryByTestId('home-page')).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 +2377,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 +2411,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 +2420,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 +2475,25 @@ 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() + expect(screen.queryByTestId('pending-component')).not.toBeInTheDocument() + expect(screen.queryByTestId('child-component')).not.toBeInTheDocument() + expect(screen.queryByTestId('home-page')).not.toBeInTheDocument() + expect(router.state.matches.map((match) => match.routeId)).toEqual([ + rootRoute.id, + parentRoute.id, + ]) + expect(router.state.status).toBe('idle') }) - 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 +2526,20 @@ 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.findByTestId('child-not-found')).toHaveTextContent( + 'Child Not Found', + ) + expect(screen.queryByTestId('root-not-found')).not.toBeInTheDocument() + expect(screen.queryByTestId('child-component')).not.toBeInTheDocument() + expect(screen.queryByTestId('home-page')).not.toBeInTheDocument() + expect(router.state.status).toBe('idle') }) - 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,89 +2586,143 @@ 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.findByTestId('parent-not-found')).toHaveTextContent( + 'Parent Not Found', + ) + expect(screen.queryByTestId('root-not-found')).not.toBeInTheDocument() + expect(screen.queryByTestId('parent-component')).not.toBeInTheDocument() + expect(screen.queryByTestId('child-component')).not.toBeInTheDocument() + expect(screen.queryByTestId('home-page')).not.toBeInTheDocument() + expect(router.state.matches.map((match) => match.routeId)).toEqual([ + rootRoute.id, + parentRoute.id, + ]) + expect(router.state.status).toBe('idle') }) - it('should transition router.state.status to idle when child async beforeLoad throws notFound and parent has pendingComponent with pendingMs: 0', async () => { - const history = createMemoryHistory({ initialEntries: ['/'] }) + it.each(['ancestor', 'child'] as const)( + 'renders the parent notFound after showing %s pending UI', + async (pendingOwner) => { + const history = createMemoryHistory({ initialEntries: ['/'] }) + const beforeLoad = createControlledPromise() - const rootRoute = createRootRoute({ - component: () => , - notFoundComponent: () => ( -
Root Not Found
- ), - }) + const rootRoute = createRootRoute({ + component: () => , + notFoundComponent: () => ( +
Root Not Found
+ ), + }) - const indexRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/', - component: () => ( -
- Go to child -
- ), - }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Home
, + }) - const parentRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/parent', - pendingMs: 0, - pendingComponent: () => ( -
Loading...
- ), - component: () => ( -
- Parent - -
- ), - notFoundComponent: () => ( -
Parent Not Found
- ), - }) + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + ...(pendingOwner === 'ancestor' + ? { + beforeLoad: () => beforeLoad, + pendingMs: 0, + pendingComponent: () => ( +
Loading ancestor...
+ ), + } + : {}), + component: () => ( +
+ Parent + +
+ ), + notFoundComponent: () => ( +
Parent Not Found
+ ), + }) - const childRoute = createRoute({ - getParentRoute: () => parentRoute, - path: '/child', - beforeLoad: async () => { - await new Promise((resolve) => setTimeout(resolve, 10)) - throw notFound() - }, - component: () =>
Child
, - }) + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: '/child', + ...(pendingOwner === 'child' + ? { + pendingMs: 0, + pendingComponent: () => ( +
Loading child...
+ ), + } + : {}), + beforeLoad: async () => { + if (pendingOwner === 'child') { + await beforeLoad + } + throw notFound() + }, + component: () =>
Child
, + }) - const routeTree = rootRoute.addChildren([ - indexRoute, - parentRoute.addChildren([childRoute]), - ]) - const router = createRouter({ routeTree, history }) + const routeTree = rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([childRoute]), + ]) + const router = createRouter({ + routeTree, + history, + defaultPendingMinMs: 0, + }) - render() + 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' })) + vi.useFakeTimers() + let navigation!: Promise + const pendingTestId = `${pendingOwner}-pending` + try { + await act(async () => { + navigation = router.navigate({ to: '/parent/child' }) + await vi.advanceTimersByTimeAsync(0) + }) - // The router status should eventually become idle - await waitFor(() => { - expect(router.state.status).toBe('idle') - }) + expect(screen.getByTestId(pendingTestId)).toBeInTheDocument() + if (pendingOwner === 'ancestor') { + expect( + screen.queryByTestId('parent-component'), + ).not.toBeInTheDocument() + } else { + expect(screen.getByTestId('parent-component')).toBeInTheDocument() + } + } finally { + try { + await act(async () => { + beforeLoad.resolve() + await navigation + }) + } finally { + vi.useRealTimers() + } + } - expect(router.state.statusCode).toBe(404) - }) + expect(screen.getByTestId('parent-not-found')).toHaveTextContent( + 'Parent Not Found', + ) + expect(screen.queryByTestId('root-not-found')).not.toBeInTheDocument() + expect(screen.queryByTestId(pendingTestId)).not.toBeInTheDocument() + expect(screen.queryByTestId('parent-component')).not.toBeInTheDocument() + expect(screen.queryByTestId('child-component')).not.toBeInTheDocument() + expect(screen.queryByTestId('home-page')).not.toBeInTheDocument() + expect(router.state.matches.map((match) => match.routeId)).toEqual([ + rootRoute.id, + parentRoute.id, + ]) + expect(router.state.status).toBe('idle') + }, + ) }) describe('Router rewrite functionality', () => { @@ -2867,7 +2927,6 @@ describe('Router rewrite functionality', () => { }, }) render() - await router.latestLoadPromise await waitFor(() => { expect(screen.getByTestId('component')).toHaveTextContent('test Users') }) @@ -3223,8 +3282,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 +3669,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..33f8046285 --- /dev/null +++ b/packages/react-router/tests/transactional-loading.test.tsx @@ -0,0 +1,237 @@ +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, +} 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() + await waitFor(() => { + expect(router.state.status).toBe('idle') + expect(router.state.resolvedLocation?.pathname).toBe('/') + }) + fireEvent.click(screen.getByText('Data route')) + + expect(await screen.findByText('parent-v1')).toBeInTheDocument() + expect(await screen.findByText('child-saw-parent-v1')).toBeInTheDocument() + await waitFor(() => { + expect(router.state.status).toBe('idle') + expect(router.state.resolvedLocation?.pathname).toBe('/parent/child') + }) + + fireEvent.click(screen.getByText('Other route')) + expect(await screen.findByText('Other route content')).toBeInTheDocument() + await waitFor(() => { + expect(router.state.status).toBe('idle') + expect(router.state.resolvedLocation?.pathname).toBe('/other') + }) + + fireEvent.click(screen.getByText('Data route')) + + expect(await screen.findByText('parent-v1')).toBeInTheDocument() + expect(await screen.findByText('child-saw-parent-v1')).toBeInTheDocument() + await waitFor(() => { + expect(router.state.status).toBe('idle') + expect(router.state.resolvedLocation?.pathname).toBe('/parent/child') + }) + + 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() + }) + + await waitFor(() => { + expect(screen.getByText('parent-v2')).toBeInTheDocument() + expect(screen.getByText('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 waitFor(() => { + expect(router.state.status).toBe('idle') + expect(router.state.resolvedLocation?.pathname).toBe('/parent/child') + }) + + 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..9682b8f60c --- /dev/null +++ b/packages/react-router/tests/transitioner-listener-errors.test.tsx @@ -0,0 +1,81 @@ +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 loadedPaths: Array = [] + + 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 !== '/') { + loadedPaths.push(event.toLocation.pathname) + laterOnLoad(event) + } + }) + testCleanups.push(unsubscribe, unsubscribeLater) + + await act(() => router.navigate({ to: '/first' })) + + expect(screen.getByText('First route')).toBeInTheDocument() + expect(loadedPaths).toEqual(['/first']) + + 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) + expect(loadedPaths).toEqual(['/first', '/second']) +}) 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..fd88a6a998 --- /dev/null +++ b/packages/react-router/tests/transitioner-render-ack.test.tsx @@ -0,0 +1,105 @@ +import { StrictMode, act } from 'react' +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import { afterEach, expect, test } from 'vitest' +import { + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +const testCleanups: Array<() => void> = [] + +afterEach(() => { + while (testCleanups.length) { + testCleanups.pop()!() + } + 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() + await waitFor(() => { + expect(router.state.status).toBe('idle') + expect(router.state.resolvedLocation?.pathname).toBe('/') + }) + + const refreshedDomWasVisible: Array = [] + const unsubscribe = router.subscribe('onResolved', () => { + refreshedDomWasVisible.push(screen.queryByText('Generation 2') !== null) + }) + testCleanups.push(unsubscribe) + + await act(() => router.invalidate()) + expect(screen.getByText('Generation 2')).toBeInTheDocument() + expect(screen.queryByText('Generation 1')).not.toBeInTheDocument() + expect(refreshedDomWasVisible).toEqual([true]) +}) + +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() + await waitFor(() => { + expect(router.state.status).toBe('idle') + expect(router.state.resolvedLocation?.pathname).toBe('/') + }) + + const eventLog: Array = [] + const unsubscribers = [ + router.subscribe('onResolved', (event) => { + if (event.toLocation.pathname === '/next') { + eventLog.push('onResolved:/next') + } + }), + router.subscribe('onRendered', (event) => { + if (event.toLocation.pathname === '/next') { + eventLog.push('onRendered:/next') + } + }), + ] + testCleanups.push(...unsubscribers) + + await act(() => router.navigate({ to: '/next' })) + expect(eventLog).toEqual(['onResolved:/next', 'onRendered:/next']) + expect(screen.getByText('Next')).toBeInTheDocument() + expect(screen.queryByText('Index')).not.toBeInTheDocument() +}) 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..9367591324 --- /dev/null +++ b/packages/react-router/tests/transitioner-router-swap.test.tsx @@ -0,0 +1,112 @@ +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' + +const testCleanups: Array<() => void> = [] + +afterEach(() => { + while (testCleanups.length) { + testCleanups.pop()!() + } + 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() + await waitFor(() => { + expect(routerA.state.status).toBe('idle') + expect(routerA.state.resolvedLocation?.pathname).toBe('/') + }) + + 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) + testCleanups.push(unsubscribeRendered) + view.rerender() + expect(await screen.findByText('Router B')).toBeInTheDocument() + await waitFor(() => { + expect(routerB.state.status).toBe('idle') + expect(routerB.state.resolvedLocation?.pathname).toBe('/b') + }) + 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), + ] + testCleanups.push(...unsubscribers) + + await act(async () => { + slowLoader.resolve() + await oldNavigation + }) + + expect(onLoad).not.toHaveBeenCalled() + expect(onBeforeRouteMount).not.toHaveBeenCalled() + expect(onResolved).not.toHaveBeenCalled() + expect(routerA.state.isLoading).toBe(false) + expect(routerA.state.status).toBe('idle') + expect(routerA.state.resolvedLocation?.pathname).toBe('/slow') + 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') + expect(onRendered).toHaveBeenCalledTimes(1) +}) diff --git a/packages/react-router/tests/useNavigate.test.tsx b/packages/react-router/tests/useNavigate.test.tsx index a473e2c8b2..1ec1072a26 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' @@ -2312,9 +2313,11 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])( path: 'param/$param', component: function ParamRoute() { const navigate = useNavigate() + const params = paramRoute.useParams() return ( <>

Param Route

+ {params.param} + ) + } + + 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..951c315861 100644 --- a/packages/solid-router/tests/link.test.tsx +++ b/packages/solid-router/tests/link.test.tsx @@ -1414,22 +1414,17 @@ describe('Link', () => { '/Dashboard/posts?page=2&filter=inactive', ) - await fireEvent.click(updateSearchLink) + fireEvent.click(updateSearchLink) - // Wait for navigation to complete and search params to update await waitFor(() => { + expect(window.location.pathname).toBe('/Dashboard/posts') expect(window.location.search).toBe('?page=2&filter=inactive') + expect(screen.getByTestId('current-page')).toHaveTextContent('Page: 2') + expect(screen.getByTestId('current-filter')).toHaveTextContent( + 'Filter: inactive', + ) }) - - await screen.findByTestId('current-page') - // Verify search was updated - 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 vi.waitFor(() => expect(router.state.status).toBe('idle')) }) 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..28769bc7ae 100644 --- a/packages/solid-router/tests/loaders.test.tsx +++ b/packages/solid-router/tests/loaders.test.tsx @@ -11,16 +11,16 @@ import { createRootRoute, createRoute, createRouter, - useLoaderData, useRouter, } from '../src' import { sleep } from './utils' afterEach(() => { + cleanup() + vi.useRealTimers() vi.resetAllMocks() window.history.replaceState(null, 'root', '/') - cleanup() }) const WAIT_TIME = 100 @@ -321,31 +321,64 @@ test('throw error from beforeLoad when navigating to route', async () => { expect(indexElement).toBeInTheDocument() }) -test('throw abortError from loader upon initial load with basepath', async () => { +// https://github.com/TanStack/router/pull/7673 +test('#7673: a spontaneous loader AbortError renders the boundary without executing the route component', async () => { window.history.replaceState(null, 'root', '/app') const rootRoute = createRootRoute({}) + const abortError = new DOMException('Aborted', 'AbortError') + const routeComponentRendered = vi.fn() + const renderedError = vi.fn() + let routeSignal: AbortSignal | undefined const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', - loader: async () => { - return Promise.reject(new DOMException('Aborted', 'AbortError')) + loader: async ({ abortController }): Promise<{ value: string }> => { + routeSignal = abortController.signal + return Promise.reject(abortError) + }, + component: () => { + routeComponentRendered() + const data = indexRoute.useLoaderData() + return
{data().value}
+ }, + errorComponent: ({ error }) => { + renderedError(error) + return
indexErrorComponent
}, - component: () =>
Index route content
, - errorComponent: () => ( -
indexErrorComponent
- ), }) const routeTree = rootRoute.addChildren([indexRoute]) const router = createRouter({ routeTree, basepath: '/app' }) + const rendered = new Promise((resolve) => { + const unsubscribe = router.subscribe('onRendered', () => { + unsubscribe() + resolve() + }) + }) render(() => ) - const indexElement = await screen.findByText('Index route content') - expect(indexElement).toBeInTheDocument() - expect(screen.queryByTestId('index-error')).not.toBeInTheDocument() - expect(window.location.pathname.startsWith('/app')).toBe(true) + expect(await screen.findByTestId('index-error')).toBeInTheDocument() + await rendered + 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(routeSignal?.aborted).toBe(false) + expect( + router.state.matches.find((match) => match.routeId === indexRoute.id), + ).toMatchObject({ + status: 'error', + error: abortError, + }) + expect(window.location.pathname).toBe('/app') + expect(router.state.status).toBe('idle') }) test('reproducer #4245', async () => { @@ -660,7 +693,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,13 +761,16 @@ 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') + await screen.findByTestId('link-to-foo') + vi.useFakeTimers() + const navigation = router.navigate({ to: '/nested/foo' }) + await vi.advanceTimersByTimeAsync(WAIT_TIME * 5) + await navigation + const fooElement = screen.getByText('Nested Foo page') expect(fooElement).toBeInTheDocument() expect(router.state.location.href).toBe('/nested/foo') + expect(router.state.status).toBe('idle') // none of the pending components should have been called expect(defaultPendingComponentOnMountMock).not.toHaveBeenCalled() @@ -742,52 +778,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() @@ -797,6 +788,7 @@ test('cancelMatches after pending timeout', async () => { } const onAbortMock = vi.fn() const fooPendingComponentOnMountMock = vi.fn() + let fooSignal: AbortSignal | undefined const history = createMemoryHistory({ initialEntries: ['/'] }) const rootRoute = createRootRoute({ component: () => ( @@ -815,17 +807,18 @@ test('cancelMatches after pending timeout', async () => { const fooRoute = createRoute({ getParentRoute: () => rootRoute, path: '/foo', - pendingMs: WAIT_TIME * 20, + pendingMs: 0, loader: async ({ abortController }) => { + fooSignal = abortController.signal await new Promise((resolve) => { - const timer = setTimeout(() => { - resolve() - }, WAIT_TIME * 40) - abortController.signal.addEventListener('abort', () => { - onAbortMock() - clearTimeout(timer) - resolve() - }) + abortController.signal.addEventListener( + 'abort', + () => { + onAbortMock() + resolve() + }, + { once: true }, + ) }) }, pendingComponent: getPendingComponent(fooPendingComponentOnMountMock), @@ -839,16 +832,20 @@ 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) const pendingElement = await screen.findByText('Pending...') expect(pendingElement).toBeInTheDocument() - const barLink = await screen.findByTestId('link-to-bar') - fireEvent.click(barLink) - const barElement = await screen.findByText('Bar page') + expect(fooSignal?.aborted).toBe(false) + await router.navigate({ to: '/bar' }) + const barElement = screen.getByText('Bar page') expect(barElement).toBeInTheDocument() + expect(fooPendingComponentOnMountMock).toHaveBeenCalled() - expect(onAbortMock).toHaveBeenCalled() + expect(onAbortMock).toHaveBeenCalledTimes(1) + expect(fooSignal?.aborted).toBe(true) + expect(screen.queryByText('Pending...')).not.toBeInTheDocument() + expect(screen.queryByText('Foo page')).not.toBeInTheDocument() + expect(router.state.location.href).toBe('/bar') + expect(router.state.status).toBe('idle') }) 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..1b1ac2268c --- /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' +import type { AnyRouter } from '../src' + +const testCleanups: Array<() => void | Promise> = [] + +afterEach(async () => { + const pendingCleanups = testCleanups + .splice(0) + .reverse() + .map((testCleanup) => testCleanup()) + if (vi.isFakeTimers()) { + await vi.runAllTimersAsync() + } + await Promise.allSettled(pendingCleanups) + cleanup() + vi.useRealTimers() +}) + +test.each(['child', 'root'] as const)( + 'a mounted %s pending fallback follows an overlapping load generation', + async (routeLevel) => { + const firstReload = createControlledPromise() + const secondReload = createControlledPromise() + const reloads = [firstReload, secondReload] + let loaderCall = 0 + + const routeOptions = { + pendingMs: 0, + pendingMinMs: 100, + pendingComponent: () =>
Pending
, + loader: () => { + const generation = ++loaderCall + const gate = reloads[generation - 2] + return gate ? gate.then(() => generation) : generation + }, + } + + const makeRouter = (): AnyRouter => { + if (routeLevel === 'root') { + const rootRoute = createRootRoute({ + ...routeOptions, + component: () =>
Generation {rootRoute.useLoaderData()()}
, + }) + return createRouter({ + routeTree: rootRoute, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + } + + const rootRoute = createRootRoute({ component: () => }) + const pageRoute = createRoute({ + ...routeOptions, + getParentRoute: () => rootRoute, + path: '/page', + component: () =>
Generation {pageRoute.useLoaderData()()}
, + }) + return createRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + }) + } + const router = makeRouter() + + 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(loaderCall).toBe(2) + expect(screen.getByTestId('pending')).toBeInTheDocument() + expect(screen.queryByText('Generation 1')).not.toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(25) + const secondInvalidation = router.invalidate({ forcePending: true }) + invalidations.push(secondInvalidation) + await vi.advanceTimersByTimeAsync(0) + expect(loaderCall).toBe(3) + + firstReload.resolve() + await vi.advanceTimersByTimeAsync(0) + + expect(screen.getByTestId('pending')).toBeInTheDocument() + expect(screen.queryByText('Generation 2')).not.toBeInTheDocument() + + let secondSettled = false + void secondInvalidation.then(() => { + secondSettled = true + }) + secondReload.resolve() + await vi.advanceTimersByTimeAsync(0) + + expect(secondSettled).toBe(false) + expect(screen.getByTestId('pending')).toBeInTheDocument() + + 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.queryByText('Generation 1')).not.toBeInTheDocument() + expect(screen.queryByText('Generation 2')).not.toBeInTheDocument() + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() + expect(router.state.status).toBe('idle') + }, +) + +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() + + vi.useFakeTimers() + const invalidation = router.invalidate({ forcePending: true }) + testCleanups.push(async () => { + reload.resolve() + await Promise.allSettled([invalidation]) + }) + await vi.advanceTimersByTimeAsync(0) + expect(screen.getByTestId('fast-pending')).toBeInTheDocument() + + let settled = false + void invalidation.then(() => { + settled = true + }) + reload.resolve() + await vi.advanceTimersByTimeAsync(0) + + await vi.advanceTimersByTimeAsync(99) + expect(settled).toBe(false) + expect(screen.getByTestId('fast-pending')).toBeInTheDocument() + expect(screen.queryByText('Generation 2')).not.toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(1) + await invalidation + expect(screen.getByText('Generation 2')).toBeInTheDocument() + expect(screen.queryByText('Generation 1')).not.toBeInTheDocument() + expect(screen.queryByTestId('fast-pending')).not.toBeInTheDocument() + expect(router.state.status).toBe('idle') +}) diff --git a/packages/solid-router/tests/redirect.test.tsx b/packages/solid-router/tests/redirect.test.tsx index 81bd1f6bc6..2192f4f962 100644 --- a/packages/solid-router/tests/redirect.test.tsx +++ b/packages/solid-router/tests/redirect.test.tsx @@ -1,13 +1,13 @@ import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library' import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { createControlledPromise } from '@tanstack/router-core' import { Link, Outlet, RouterProvider, createBrowserHistory, - createMemoryHistory, createRootRoute, createRoute, createRouter, @@ -107,6 +107,7 @@ describe('redirect', () => { test('when root `beforeLoad` redirects while root pendingComponent is showing and the target route is lazy', async () => { let hasRedirected = false + const beforeLoad = createControlledPromise() const consoleError = vi .spyOn(console, 'error') .mockImplementation(() => {}) @@ -116,7 +117,7 @@ describe('redirect', () => { pendingMs: 0, pendingComponent: () =>
loading
, beforeLoad: async () => { - await sleep(WAIT_TIME) + await beforeLoad if (!hasRedirected) { hasRedirected = true throw redirect({ to: '/posts' }) @@ -142,12 +143,20 @@ describe('redirect', () => { render(() => ) + try { + expect(await screen.findByTestId('pending')).toBeInTheDocument() + expect(screen.queryByTestId('index-page')).not.toBeInTheDocument() + } finally { + beforeLoad.resolve() + } + // The lazy target route adds the async boundary that exposes the stale // redirected-match render path this regression is guarding. expect(await screen.findByTestId('lazy-route-page')).toBeInTheDocument() expect(screen.queryByTestId('pending')).not.toBeInTheDocument() + expect(screen.queryByTestId('index-page')).not.toBeInTheDocument() expect(router.state.location.href).toBe('/posts') - expect(router.state.status).toBe('idle') + await vi.waitFor(() => expect(router.state.status).toBe('idle')) expect(consoleError).not.toHaveBeenCalled() }) @@ -303,116 +312,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..ac0b2d145d 100644 --- a/packages/solid-router/tests/router.test.tsx +++ b/packages/solid-router/tests/router.test.tsx @@ -1720,24 +1720,27 @@ 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({ getParentRoute: () => rootRoute, path: '/', - component: () =>
Home
, + component: () =>
Home
, }) const validRoute = createRoute({ getParentRoute: () => rootRoute, path: '/valid', - component: () =>
Valid Route
, + component: () =>
Valid Route
, }) const routeTree = rootRoute.addChildren([indexRoute, validRoute]) @@ -1745,27 +1748,29 @@ 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.findByTestId('home-page')).toBeInTheDocument() await router.navigate({ to: '/non-existing' }) - await waitFor(() => expect(router.state.statusCode).toBe(404)) + expect(await screen.findByTestId('root-not-found')).toBeInTheDocument() + expect(screen.queryByTestId('home-page')).not.toBeInTheDocument() await router.navigate({ to: '/valid' }) - await waitFor(() => expect(router.state.statusCode).toBe(200)) + expect(await screen.findByTestId('valid-page')).toBeInTheDocument() + expect(screen.queryByTestId('root-not-found')).not.toBeInTheDocument() await router.navigate({ to: '/another-non-existing' }) - await waitFor(() => expect(router.state.statusCode).toBe(404)) + expect(await screen.findByTestId('root-not-found')).toBeInTheDocument() + expect(screen.queryByTestId('valid-page')).not.toBeInTheDocument() + expect(router.state.status).toBe('idle') }) 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 () => { - await new Promise((resolve) => setTimeout(resolve, 10)) + await Promise.resolve() toThrow() } : (toThrow: () => void) => toThrow @@ -1776,15 +1781,19 @@ 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() + const rootRoute = createRootRoute({ + notFoundComponent: () => ( +
Root Not Found
+ ), + }) const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', - component: () =>
Home
, + component: () =>
Home
, }) const loaderThrowsRoute = createRoute({ @@ -1795,7 +1804,7 @@ describe('statusCode reset on navigation', () => {
loader will throw
), notFoundComponent: () => ( -
Not Found
+
Route Not Found
), }) @@ -1804,29 +1813,28 @@ describe('statusCode reset on navigation', () => { render(() => ) - expect(router.state.statusCode).toBe(200) - + expect(await screen.findByTestId('home-page')).toBeInTheDocument() 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(await screen.findByTestId('route-not-found')).toBeInTheDocument() + expect(screen.queryByTestId('root-not-found')).not.toBeInTheDocument() expect(screen.queryByTestId('route-component')).not.toBeInTheDocument() + expect(screen.queryByTestId('home-page')).not.toBeInTheDocument() + expect(router.state.status).toBe('idle') }) - 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({ notFoundComponent: () => ( -
Not Found
+
Root Not Found
), }) const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', - component: () =>
Home
, + component: () =>
Home
, }) const beforeLoadThrowsRoute = createRoute({ @@ -1837,7 +1845,7 @@ describe('statusCode reset on navigation', () => {
beforeLoad will throw
), notFoundComponent: () => ( -
Not Found
+
Route Not Found
), }) @@ -1849,25 +1857,26 @@ describe('statusCode reset on navigation', () => { render(() => ) - expect(router.state.statusCode).toBe(200) - + expect(await screen.findByTestId('home-page')).toBeInTheDocument() 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(await screen.findByTestId('route-not-found')).toBeInTheDocument() + expect(screen.queryByTestId('root-not-found')).not.toBeInTheDocument() expect(screen.queryByTestId('route-component')).not.toBeInTheDocument() + expect(screen.queryByTestId('home-page')).not.toBeInTheDocument() + expect(router.state.status).toBe('idle') }) - 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() + const rootRoute = createRootRoute({ + errorComponent: () =>
Root Error
, + }) const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', - component: () =>
Home
, + component: () =>
Home
, }) const loaderThrowsRoute = createRoute({ @@ -1877,7 +1886,7 @@ describe('statusCode reset on navigation', () => { component: () => (
loader will throw
), - errorComponent: () =>
Error
, + errorComponent: () =>
Error
, }) const routeTree = rootRoute.addChildren([indexRoute, loaderThrowsRoute]) @@ -1885,23 +1894,26 @@ describe('statusCode reset on navigation', () => { render(() => ) - expect(router.state.statusCode).toBe(200) - + expect(await screen.findByTestId('home-page')).toBeInTheDocument() await router.navigate({ to: '/loader-throws-error' }) - await waitFor(() => expect(router.state.statusCode).toBe(500)) - expect(await screen.findByTestId('error-component')).toBeInTheDocument() + expect(await screen.findByTestId('route-error')).toBeInTheDocument() + expect(screen.queryByTestId('root-error')).not.toBeInTheDocument() expect(screen.queryByTestId('route-component')).not.toBeInTheDocument() + expect(screen.queryByTestId('home-page')).not.toBeInTheDocument() + expect(router.state.status).toBe('idle') }) - 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() + const rootRoute = createRootRoute({ + errorComponent: () =>
Root Error
, + }) const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', - component: () =>
Home
, + component: () =>
Home
, }) const beforeLoadThrowsRoute = createRoute({ @@ -1911,7 +1923,7 @@ describe('statusCode reset on navigation', () => { component: () => (
beforeLoad will throw
), - errorComponent: () =>
Error
, + errorComponent: () =>
Error
, }) const routeTree = rootRoute.addChildren([ @@ -1922,12 +1934,13 @@ describe('statusCode reset on navigation', () => { render(() => ) - expect(router.state.statusCode).toBe(200) - + expect(await screen.findByTestId('home-page')).toBeInTheDocument() await router.navigate({ to: '/beforeload-throws-error' }) - await waitFor(() => expect(router.state.statusCode).toBe(500)) - expect(await screen.findByTestId('error-component')).toBeInTheDocument() + expect(await screen.findByTestId('route-error')).toBeInTheDocument() + expect(screen.queryByTestId('root-error')).not.toBeInTheDocument() expect(screen.queryByTestId('route-component')).not.toBeInTheDocument() + expect(screen.queryByTestId('home-page')).not.toBeInTheDocument() + expect(router.state.status).toBe('idle') }) }, ) @@ -2057,7 +2070,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..c65e78fc6e --- /dev/null +++ b/packages/solid-router/tests/same-route-pending-blank.test.tsx @@ -0,0 +1,100 @@ +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' + +/** + * 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() + vi.useRealTimers() +}) + +test('same-route pending replacement without fallback keeps stale content until pendingMs', async () => { + const pendingMs = 100 + const page2Gate = createControlledPromise() + const page2Started = createControlledPromise() + 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 + } + + return `Page ${deps.page}` + }, + component: Posts, + }) + const router = createRouter({ + routeTree: root.addChildren([postsRoute]), + history, + defaultPendingMs: pendingMs, + }) + + render(() => ) + expect(await screen.findByText('Page 1')).toBeInTheDocument() + + vi.useFakeTimers() + const navigation = router.navigate({ + to: '/posts', + search: { page: 2 }, + }) + pendingNavigation = navigation + + await page2Started + await vi.advanceTimersByTimeAsync(pendingMs - 1) + + expect(screen.getByTestId('post-content')).toHaveTextContent('Page 1') + expect(screen.queryByText('Page 2')).not.toBeInTheDocument() + expect(router.state.status).toBe('pending') + + page2Gate.resolve() + await navigation + pendingNavigation = undefined + + expect(screen.getByText('Page 2')).toBeInTheDocument() + expect(screen.queryByText('Page 1')).not.toBeInTheDocument() + expect(router.state.status).toBe('idle') +}) diff --git a/packages/solid-router/tests/server/errorComponent.test.tsx b/packages/solid-router/tests/server/errorComponent.test.tsx index f342aaae0d..4ab6e79658 100644 --- a/packages/solid-router/tests/server/errorComponent.test.tsx +++ b/packages/solid-router/tests/server/errorComponent.test.tsx @@ -1,12 +1,10 @@ import { describe, expect, it } from 'vitest' -import { renderToStringAsync } from 'solid-js/web' +import { createRootRoute, createRoute, createRouter } from '../../src' import { - RouterProvider, - createMemoryHistory, - createRootRoute, - createRoute, - createRouter, -} from '../../src' + RouterServer, + createRequestHandler, + renderRouterToString, +} from '../../src/ssr/server' describe('errorComponent (server)', () => { it('renders the route error component when a loader throws during SSR', async () => { @@ -25,21 +23,21 @@ describe('errorComponent (server)', () => { }) const routeTree = rootRoute.addChildren([indexRoute]) - const router = createRouter({ - routeTree, - history: createMemoryHistory({ - initialEntries: ['/'], - }), - isServer: true, + const handler = createRequestHandler({ + request: new Request('http://localhost/'), + createRouter: () => createRouter({ routeTree, isServer: true }), }) - await router.load() - - const html = await renderToStringAsync(() => ( - - )) + const response = await handler(({ router, responseHeaders }) => + renderRouterToString({ + router, + responseHeaders, + children: () => , + }), + ) - expect(router.state.statusCode).toBe(500) + expect(response.status).toBe(500) + const html = await response.text() 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..83f39a5791 --- /dev/null +++ b/packages/solid-router/tests/transitioner-listener-errors.test.tsx @@ -0,0 +1,81 @@ +import { cleanup, render, screen } from '@solidjs/testing-library' +import { afterEach, expect, test } 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 lifecycle: Array = [] + 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: () => lifecycle.push('enter:/first'), + component: () =>
First route
, + }) + const secondRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/second', + onEnter: () => lifecycle.push('enter:/second'), + component: () =>
Second route
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, firstRoute, secondRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render(() => ) + expect(await screen.findByText('Index route')).toBeInTheDocument() + + const unsubscribers = [ + router.subscribe('onLoad', (event) => { + if (event.toLocation.pathname === '/first') { + lifecycle.push('throw:/first') + throw listenerError + } + }), + router.subscribe('onLoad', (event) => { + if (event.toLocation.pathname !== '/') { + lifecycle.push(`load:${event.toLocation.pathname}`) + } + }), + ] + + try { + await router.navigate({ to: '/first' }) + expect(await screen.findByText('First route')).toBeInTheDocument() + expect(screen.queryByText('Index route')).not.toBeInTheDocument() + expect(lifecycle).toEqual(['enter:/first', 'throw:/first', 'load:/first']) + + await router.navigate({ to: '/second' }) + expect(await screen.findByText('Second route')).toBeInTheDocument() + expect(screen.queryByText('First route')).not.toBeInTheDocument() + expect(lifecycle).toEqual([ + 'enter:/first', + 'throw:/first', + 'load:/first', + 'enter:/second', + 'load:/second', + ]) + } finally { + for (const unsubscribe of unsubscribers) { + unsubscribe() + } + } +}) 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..7594b8a596 --- /dev/null +++ b/packages/solid-router/tests/transitioner-remount.test.tsx @@ -0,0 +1,109 @@ +import { cleanup, render, screen, waitFor } from '@solidjs/testing-library' +import { afterEach, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { hydrate } from '@tanstack/router-core/ssr/client' +import { + Outlet, + RouterProvider, + createRootRoute, + createRoute, + createRouter, +} from '../src' +import type { TsrSsrGlobal } from '@tanstack/router-core/ssr/client' + +afterEach(() => { + cleanup() + delete window.$_TSR +}) + +test('remounting a hydrated 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, + }) + + const matches = router.matchRoutes(router.latestLocation) + window.$_TSR = { + router: { + manifest: { routes: {} }, + matches: matches.map((match) => ({ + i: match.id, + s: 'success' as const, + ssr: true, + u: Date.now(), + })), + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + } satisfies TsrSsrGlobal + await hydrate(router) + expect(router.ssr).toBeDefined() + + 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) + }) + expect(screen.getByText('Next')).toBeInTheDocument() +}) + +test('remounting the provider emits onRendered for the newly mounted DOM', async () => { + let mountLabel = 'First mount' + const rootRoute = createRootRoute({ component: () => }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
{mountLabel}
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + const renderedDom: Array = [] + const unsubscribe = router.subscribe('onRendered', () => { + renderedDom.push(screen.queryByTestId('index-route')?.textContent ?? null) + }) + + try { + const firstRender = render(() => ) + expect(await screen.findByText('First mount')).toBeInTheDocument() + await waitFor(() => expect(renderedDom).toEqual(['First mount'])) + + firstRender.unmount() + mountLabel = 'Second mount' + render(() => ) + expect(await screen.findByText('Second mount')).toBeInTheDocument() + await waitFor(() => + expect(renderedDom).toEqual(['First mount', 'Second mount']), + ) + } finally { + 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..c7fbfb3dcc --- /dev/null +++ b/packages/solid-router/tests/transitioner-render-ack.test.tsx @@ -0,0 +1,338 @@ +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' +import type { HistoryState } from '../src' + +afterEach(() => { + cleanup() +}) + +test('onResolved precedes onRendered and both observe 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 lifecycle: Array<{ + event: 'onResolved' | 'onRendered' + pathname: string + destination: boolean + outgoing: boolean + }> = [] + const unsubscribers = [ + router.subscribe('onResolved', (event) => { + lifecycle.push({ + event: 'onResolved', + pathname: event.toLocation.pathname, + destination: screen.queryByText('Next') !== null, + outgoing: screen.queryByText('First') !== null, + }) + }), + router.subscribe('onRendered', (event) => { + lifecycle.push({ + event: 'onRendered', + pathname: event.toLocation.pathname, + destination: screen.queryByText('Next') !== null, + outgoing: screen.queryByText('First') !== null, + }) + }), + ] + + try { + const navigation = router.navigate({ to: '/next' }) + + expect(screen.queryByText('Next')).toBeNull() + expect(screen.getByText('First')).toBeInTheDocument() + expect(lifecycle).toEqual([]) + + nextLoader.resolve() + await navigation + await waitFor(() => + expect(lifecycle).toEqual([ + { + event: 'onResolved', + pathname: '/next', + destination: true, + outgoing: false, + }, + { + event: 'onRendered', + pathname: '/next', + destination: true, + outgoing: false, + }, + ]), + ) + } finally { + nextLoader.resolve() + for (const unsubscribe of unsubscribers) { + unsubscribe() + } + } +}) + +test('onRendered describes each committed navigation from the previously rendered location', async () => { + type SameHrefTestState = HistoryState & { sameHrefState: true } + const isSameHrefTestState = ( + state: HistoryState | undefined, + ): state is SameHrefTestState => + state !== undefined && + 'sameHrefState' in state && + state.sameHrefState === true + + 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 renderedChanges: Array<{ + fromPathname: string | undefined + toPathname: string + fromHref: string | undefined + toHref: string + pathChanged: boolean + hrefChanged: boolean + fromSameHrefState: boolean + toSameHrefState: boolean + renderedRoute: 'Index' | 'Next' | undefined + }> = [] + const unsubscribe = router.subscribe('onRendered', (event) => { + renderedChanges.push({ + fromPathname: event.fromLocation?.pathname, + toPathname: event.toLocation.pathname, + fromHref: event.fromLocation?.href, + toHref: event.toLocation.href, + pathChanged: event.pathChanged, + hrefChanged: event.hrefChanged, + fromSameHrefState: isSameHrefTestState(event.fromLocation?.state), + toSameHrefState: isSameHrefTestState(event.toLocation.state), + renderedRoute: screen.queryByText('Next') + ? 'Next' + : screen.queryByText('Index') + ? 'Index' + : undefined, + }) + }) + + try { + await router.navigate({ to: '/next' }) + expect(await screen.findByText('Next')).toBeInTheDocument() + await waitFor(() => + expect(renderedChanges).toEqual([ + { + fromPathname: '/', + toPathname: '/next', + fromHref: '/', + toHref: '/next', + pathChanged: true, + hrefChanged: true, + fromSameHrefState: false, + toSameHrefState: false, + renderedRoute: 'Next', + }, + ]), + ) + + const sameHrefState: SameHrefTestState = { sameHrefState: true } + await router.navigate({ + to: '/next', + state: sameHrefState, + }) + await waitFor(() => + expect(renderedChanges).toEqual([ + { + fromPathname: '/', + toPathname: '/next', + fromHref: '/', + toHref: '/next', + pathChanged: true, + hrefChanged: true, + fromSameHrefState: false, + toSameHrefState: false, + renderedRoute: 'Next', + }, + { + fromPathname: '/next', + toPathname: '/next', + fromHref: '/next', + toHref: '/next', + pathChanged: false, + hrefChanged: false, + fromSameHrefState: false, + toSameHrefState: true, + renderedRoute: 'Next', + }, + ]), + ) + } finally { + 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 lifecycle: Array<{ + event: 'onResolved' | 'onRendered' + pathname: string + destination: boolean + superseded: boolean + }> = [] + let nextNavigation: Promise | undefined + const unsubscribers = [ + router.subscribe('onLoad', (event) => { + if (event.toLocation.pathname === '/first') { + nextNavigation = router.navigate({ to: '/next' }) + } + }), + router.subscribe('onResolved', (event) => { + lifecycle.push({ + event: 'onResolved', + pathname: event.toLocation.pathname, + destination: screen.queryByText('Next') !== null, + superseded: screen.queryByText('First') !== null, + }) + }), + router.subscribe('onRendered', (event) => { + lifecycle.push({ + event: 'onRendered', + pathname: event.toLocation.pathname, + destination: screen.queryByText('Next') !== null, + superseded: screen.queryByText('First') !== null, + }) + }), + ] + + try { + const firstNavigation = router.navigate({ to: '/first' }) + + await waitFor(() => { + expect(nextNavigation).toBeDefined() + }) + expect(screen.getByText('First')).toBeInTheDocument() + expect(screen.queryByText('Next')).not.toBeInTheDocument() + expect(lifecycle).toEqual([]) + + nextLoader.resolve() + await Promise.all([firstNavigation, nextNavigation!]) + + await waitFor(() => { + expect(screen.getByText('Next')).toBeInTheDocument() + }) + expect(lifecycle).toEqual([ + { + event: 'onResolved', + pathname: '/next', + destination: true, + superseded: false, + }, + { + event: 'onRendered', + pathname: '/next', + destination: true, + superseded: false, + }, + ]) + } finally { + nextLoader.resolve() + 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) + }) + + try { + await router.invalidate() + expect(await screen.findByText('Generation 2')).toBeInTheDocument() + expect(refreshedDomWasVisible).toEqual([true]) + } finally { + 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..b383b6e98d --- /dev/null +++ b/packages/solid-router/tests/use-match-outgoing-transition.test.tsx @@ -0,0 +1,126 @@ +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() + expect(observedRouteIds).toContain('/first') + + await router.navigate({ to: '/next' }) + expect(await screen.findByText('Next')).toBeInTheDocument() + expect(screen.queryByText('First')).not.toBeInTheDocument() + + expect(observedRouteIds).not.toContain(undefined) +}) + +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).toContainEqual({ + routeId: '/first', + destinationRendered: false, + }) + expect(observations.some(({ routeId }) => routeId === undefined)).toBe(false) + + nextLoader.resolve() + await navigation + expect(await screen.findByText('Next')).toBeInTheDocument() + expect(screen.queryByText('First')).not.toBeInTheDocument() + + const retainedWhilePending = observations.findIndex( + ({ routeId, destinationRendered }) => + routeId === '/first' && !destinationRendered, + ) + const releasedWithDestination = observations.findIndex( + ({ routeId, destinationRendered }) => + routeId === undefined && destinationRendered, + ) + expect(retainedWhilePending).toBeGreaterThanOrEqual(0) + expect(releasedWithDestination).toBeGreaterThan(retainedWhilePending) + expect( + observations.some( + ({ routeId, destinationRendered }) => + routeId === undefined && !destinationRendered, + ), + ).toBe(false) +}) 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..9fb8ad5f5a --- /dev/null +++ b/packages/vue-router/tests/component-preload-retry.test.tsx @@ -0,0 +1,65 @@ +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() + + const retryButton = await screen.findByRole('button', { name: 'Retry' }) + expect(importer).toHaveBeenCalledTimes(1) + + await fireEvent.click(retryButton) + + expect(await screen.findByText('Page content')).toBeInTheDocument() + expect(importer).toHaveBeenCalledTimes(2) +}) 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..bfc3e298fc --- /dev/null +++ b/packages/vue-router/tests/hydration-capped-boundary-pending.test.tsx @@ -0,0 +1,153 @@ +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 { dehydrateToBootstrap } from './ssr-test-utils' +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', 'parent'], + ['error', 'root'], + ['notFound', 'root'], + ] as const)( + 'keeps the server-rendered %s %s boundary visible through hydration', + 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(), + history: createMemoryHistory({ + initialEntries: ['/parent/child'], + }), + }) + serverRouter.isServer = true + testCleanups.push(() => serverRouter.serverSsr?.cleanup()) + window.$_TSR = await dehydrateToBootstrap(serverRouter) + + 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 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'], + }), + }) + + 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: () => () => , + }), + ) + let clientAppMounted = false + testCleanups.push(() => { + if (clientAppMounted) { + clientApp.unmount() + } + container.remove() + }) + + clientApp.mount(container) + clientAppMounted = true + 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..543a32548e 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 () => { @@ -1780,7 +1781,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() @@ -1808,17 +1809,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({ @@ -1853,17 +1851,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() @@ -1889,15 +1884,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() @@ -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/ssr-test-utils.ts b/packages/vue-router/tests/ssr-test-utils.ts new file mode 100644 index 0000000000..7d8c5c80f3 --- /dev/null +++ b/packages/vue-router/tests/ssr-test-utils.ts @@ -0,0 +1,43 @@ +import { runInNewContext } from 'node:vm' +import { attachRouterServerSsrUtils } from '@tanstack/router-core/ssr/server' +import type { AnyRouter } from '@tanstack/router-core' +import type { TsrSsrGlobal } from '@tanstack/router-core/ssr/client' + +export async function dehydrateToBootstrap( + router: AnyRouter, +): Promise { + try { + attachRouterServerSsrUtils({ router, manifest: { routes: {} } }) + await router.load() + await router.serverSsr!.dehydrate() + + const script = router.serverSsr!.takeBufferedScripts() + if (typeof script?.children !== 'string') { + throw new Error( + 'Expected server dehydration to produce a bootstrap script', + ) + } + + const context: { + document: { currentScript: { remove: () => void } } + self?: unknown + $_TSR?: TsrSsrGlobal + } = { + document: { + currentScript: { + remove() {}, + }, + }, + } + context.self = context + runInNewContext(script.children, context) + + if (!context.$_TSR) { + throw new Error('Expected bootstrap script to initialize $_TSR') + } + return context.$_TSR + } catch (error) { + router.serverSsr?.cleanup() + throw error + } +} 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..c53a0e02f5 --- /dev/null +++ b/packages/vue-router/tests/transitioner-listener-errors.test.tsx @@ -0,0 +1,76 @@ +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 later listeners, route hooks, or navigations', async () => { + const firstOnEnter = vi.fn() + const secondOnEnter = vi.fn() + const listenerError = new Error('onLoad listener failed') + const throwingOnLoad = vi.fn() + const laterOnLoad = vi.fn() + + 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 unsubscribeThrowing = router.subscribe('onLoad', (event) => { + throwingOnLoad(event.toLocation.pathname) + if (event.toLocation.pathname === '/first') { + throw listenerError + } + }) + const unsubscribeLater = router.subscribe('onLoad', (event) => { + laterOnLoad(event.toLocation.pathname) + }) + + try { + await router.navigate({ to: '/first' }) + expect(await screen.findByText('First route')).toBeTruthy() + expect(firstOnEnter).toHaveBeenCalledTimes(1) + expect(throwingOnLoad.mock.calls).toEqual([['/first']]) + expect(laterOnLoad.mock.calls).toEqual([['/first']]) + + await router.navigate({ to: '/second' }) + expect(await screen.findByText('Second route')).toBeTruthy() + expect(firstOnEnter).toHaveBeenCalledTimes(1) + expect(secondOnEnter).toHaveBeenCalledTimes(1) + expect(throwingOnLoad.mock.calls).toEqual([['/first'], ['/second']]) + expect(laterOnLoad.mock.calls).toEqual([['/first'], ['/second']]) + } finally { + unsubscribeThrowing() + unsubscribeLater() + } +}) 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..c04127316a --- /dev/null +++ b/packages/vue-router/tests/transitioner-remount-rendered.test.tsx @@ -0,0 +1,170 @@ +import * as Vue from 'vue' +import { renderToString } from 'vue/server-renderer' +import { cleanup, render, screen, waitFor } from '@testing-library/vue' +import { afterEach, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { hydrate as hydrateRouter } from '@tanstack/router-core/ssr/client' +import { + Outlet, + RouterProvider, + createRootRoute, + createRoute, + createRouter, +} from '../src' +import { dehydrateToBootstrap } from './ssr-test-utils' +import type { TsrSsrGlobal } from '@tanstack/router-core/ssr/client' + +declare global { + interface Window { + $_TSR?: TsrSsrGlobal + } +} + +afterEach(() => { + cleanup() + window.$_TSR = undefined + document.body.innerHTML = '' +}) + +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 a hydrated router loads a history change that happened while unmounted', async () => { + const { router: serverRouter } = setup() + serverRouter.isServer = true + + try { + window.$_TSR = await dehydrateToBootstrap(serverRouter) + const serverApp = Vue.createSSRApp( + Vue.defineComponent({ + setup: () => () => , + }), + ) + const serverHtml = await renderToString(serverApp) + + const { history, router } = setup() + await hydrateRouter(router) + + const container = document.createElement('div') + container.innerHTML = serverHtml + document.body.appendChild(container) + const clientApp = Vue.createSSRApp( + Vue.defineComponent({ + setup: () => () => , + }), + ) + let clientAppMounted = false + try { + clientApp.mount(container) + clientAppMounted = true + await Vue.nextTick() + expect(await screen.findByText('Index')).toBeTruthy() + } finally { + if (clientAppMounted) { + clientApp.unmount() + } + container.remove() + } + + history.push('/next') + + render() + expect(await screen.findByText('Next')).toBeTruthy() + expect(router.state.location.pathname).toBe('/next') + } finally { + serverRouter.serverSsr?.cleanup() + } +}) + +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..e602c8f53f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1433,6 +1433,77 @@ importers: specifier: ^8.0.14 version: 8.0.14(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.7.0)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.9.0) + e2e/react-router/issue-7120: + dependencies: + '@tanstack/react-router': + specifier: workspace:* + version: link:../../../packages/react-router + '@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/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 +12672,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