diff --git a/packages/react-router/tests/issue-2182-root-pending.test.tsx b/packages/react-router/tests/issue-2182-root-pending.test.tsx new file mode 100644 index 0000000000..9cf3f82cb7 --- /dev/null +++ b/packages/react-router/tests/issue-2182-root-pending.test.tsx @@ -0,0 +1,55 @@ +import * as React from 'react' +import { act, cleanup, render, screen } from '@testing-library/react' +import { afterEach, expect, test, vi } from 'vitest' +import { + RouterProvider, + createControlledPromise, + createMemoryHistory, + createRootRoute, + createRouter, +} from '../src' + +afterEach(() => { + cleanup() + vi.useRealTimers() +}) + +// https://github.com/TanStack/router/issues/2182 +test('root pending fallback remains visible through pendingMinMs', async () => { + vi.useFakeTimers() + + const loaderGate = createControlledPromise() + const rootRoute = createRootRoute({ + pendingMs: 0, + pendingMinMs: 100, + pendingComponent: () =>
Pending
, + loader: () => loaderGate, + component: () =>
Loaded
, + }) + const router = createRouter({ + routeTree: rootRoute, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render() + + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) + expect(screen.getByTestId('root-pending')).toBeInTheDocument() + expect(screen.queryByTestId('root-content')).not.toBeInTheDocument() + + loaderGate.resolve('loaded') + + await act(async () => { + await vi.advanceTimersByTimeAsync(99) + }) + expect(screen.getByTestId('root-pending')).toBeInTheDocument() + expect(screen.queryByTestId('root-content')).not.toBeInTheDocument() + + await act(async () => { + await vi.advanceTimersByTimeAsync(1) + }) + expect(screen.queryByTestId('root-pending')).not.toBeInTheDocument() + expect(screen.getByTestId('root-content')).toBeInTheDocument() +}) diff --git a/packages/react-router/tests/issue-7367-pending-min-redirect.test.tsx b/packages/react-router/tests/issue-7367-pending-min-redirect.test.tsx new file mode 100644 index 0000000000..666f1d3e4b --- /dev/null +++ b/packages/react-router/tests/issue-7367-pending-min-redirect.test.tsx @@ -0,0 +1,78 @@ +import * as React from 'react' +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, expect, test, vi } from 'vitest' + +import { + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + redirect, +} from '../src' +import { sleep } from './utils' + +afterEach(() => { + vi.restoreAllMocks() + cleanup() +}) + +// https://github.com/TanStack/router/issues/7367 +// Root route shows a spinner immediately (pendingMs: 0) while beforeLoad +// decides where to send the user, keeps it up for pendingMinMs, and then +// redirects. This used to crash in MatchInnerImpl (white screen) because the +// redirected match was rendered/thrown after its loadPromise was cleared. +test('immediate pending spinner (pendingMs: 0 + pendingMinMs) with root beforeLoad redirect renders the target without render errors', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + let hasRedirected = false + + const rootRoute = createRootRoute({ + component: () => , + pendingMs: 0, + pendingMinMs: 100, + pendingComponent: () =>
loading
, + errorComponent: ({ error }) => ( +
{String(error)}
+ ), + beforeLoad: async () => { + await sleep(50) + if (!hasRedirected) { + hasRedirected = true + throw redirect({ to: '/welcome', replace: true }) + } + }, + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Index
, + }) + + const welcomeRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/welcome', + component: () =>
Welcome
, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, welcomeRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render() + + // pendingMs: 0 — the spinner must show right away. + expect(await screen.findByTestId('pending')).toBeInTheDocument() + + // The redirect must complete: the target renders, no error boundary output + // and no render crash. + expect( + await screen.findByTestId('welcome-page', undefined, { timeout: 5_000 }), + ).toBeInTheDocument() + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() + expect(screen.queryByTestId('root-error')).not.toBeInTheDocument() + expect(router.state.location.pathname).toBe('/welcome') + expect(consoleError).not.toHaveBeenCalled() +}) diff --git a/packages/react-router/tests/issue-7457-redirect-chain-first-load.test.tsx b/packages/react-router/tests/issue-7457-redirect-chain-first-load.test.tsx new file mode 100644 index 0000000000..3583190d79 --- /dev/null +++ b/packages/react-router/tests/issue-7457-redirect-chain-first-load.test.tsx @@ -0,0 +1,109 @@ +import * as React from 'react' +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, expect, test, vi } from 'vitest' + +import { + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + redirect, +} from '../src' +import { sleep } from './utils' + +afterEach(() => { + vi.restoreAllMocks() + 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. +test('chained layout beforeLoad redirects on first load render the final target without throwing from MatchInner', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const rootRoute = createRootRoute({ component: () => }) + + const userLayout = createRoute({ + id: 'user', + getParentRoute: () => rootRoute, + validateSearch: (search: Record): { flag?: boolean } => ({ + flag: search.flag === true || search.flag === 'true' ? true : undefined, + }), + beforeLoad: async ({ search }) => { + if (search.flag) { + await sleep(20) + throw redirect({ + to: '.', + replace: true, + search: (prev: any) => ({ ...prev, flag: undefined }), + }) + } + }, + component: () => , + }) + + const dashboardLayout = createRoute({ + id: 'dashboard', + getParentRoute: () => userLayout, + beforeLoad: async () => { + await sleep(30) + throw redirect({ to: '/intro', replace: true }) + }, + component: () => , + }) + + const homeRoute = createRoute({ + getParentRoute: () => dashboardLayout, + path: '/home', + component: () =>
Home
, + }) + + const introLayout = createRoute({ + getParentRoute: () => userLayout, + path: '/intro', + beforeLoad: ({ location }) => { + if (location.pathname !== '/intro/step') { + throw redirect({ to: '/intro/step', replace: true }) + } + }, + component: () => , + }) + + const introIndexRoute = createRoute({ + getParentRoute: () => introLayout, + path: '/', + component: () =>
Intro index
, + }) + + const introStepRoute = createRoute({ + getParentRoute: () => introLayout, + path: 'step', + component: () =>
Intro step
, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([ + userLayout.addChildren([ + dashboardLayout.addChildren([homeRoute]), + introLayout.addChildren([introIndexRoute, introStepRoute]), + ]), + ]), + defaultPendingMs: 0, + defaultPendingComponent: () =>
loading
, + history: createMemoryHistory({ initialEntries: ['/home?flag=true'] }), + }) + + render() + + expect( + await screen.findByTestId('intro-step', undefined, { timeout: 5_000 }), + ).toBeInTheDocument() + expect(router.state.location.pathname).toBe('/intro/step') + expect(consoleError).not.toHaveBeenCalled() +}) diff --git a/packages/react-router/tests/issue-7638-invalidate-transition-error.test.tsx b/packages/react-router/tests/issue-7638-invalidate-transition-error.test.tsx new file mode 100644 index 0000000000..eb31703b0a --- /dev/null +++ b/packages/react-router/tests/issue-7638-invalidate-transition-error.test.tsx @@ -0,0 +1,130 @@ +import * as React from 'react' +import { afterEach, expect, test, vi } from 'vitest' +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react' +import { + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + useRouter, +} from '../src' +import type { ErrorComponentProps } from '../src' + +afterEach(() => { + vi.restoreAllMocks() + cleanup() +}) + +// https://github.com/TanStack/router/issues/7638 +// router.invalidate() called inside React.startTransition while a nested +// route is showing its errorComponent must complete the reload and land back +// on the error UI without crashing React with +// "Rendered more hooks than during the previous render." +function setup({ failVia }: { failVia: 'render' | 'loader' }) { + const rootRoute = createRootRoute({ component: () => }) + + const testRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/test', + component: function TestComponent() { + const router = useRouter() + const [isPending, startTransition] = React.useTransition() + return ( +
+ + +
+ ) + }, + }) + + const childLoader = vi.fn(() => { + if (failVia === 'loader') { + throw new Error('test error') + } + return 'data' + }) + + const testIndexRoute = createRoute({ + getParentRoute: () => testRoute, + path: '/', + loader: childLoader, + component: function ChildComponent() { + if (failVia === 'render') { + throw new Error('test error') + } + return
child content
+ }, + }) + + let errorRenders = 0 + const router = createRouter({ + routeTree: rootRoute.addChildren([testRoute.addChildren([testIndexRoute])]), + history: createMemoryHistory({ initialEntries: ['/test'] }), + defaultErrorComponent: (props: ErrorComponentProps) => { + errorRenders++ + return
error: {props.error.message}
+ }, + }) + + return { router, childLoader, getErrorRenders: () => errorRenders } +} + +test.each(['render', 'loader'] as const)( + 'invalidate() inside startTransition through a nested %s-error route does not crash', + async (failVia) => { + // Error boundaries log caught errors through console.error, and so does a + // hooks-order crash. Capture instead of polluting the test output, then + // inspect the captured calls for the crash signature. + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const { router, childLoader, getErrorRenders } = setup({ failVia }) + render() + + expect(await screen.findByTestId('error-ui')).toBeInTheDocument() + const initialErrorRenders = getErrorRenders() + const initialLoaderCalls = childLoader.mock.calls.length + + fireEvent.click(screen.getByTestId('invalidate')) + + // The invalidated reload must actually complete: the loader re-ran ... + await waitFor(() => { + expect(childLoader.mock.calls.length).toBeGreaterThan(initialLoaderCalls) + }) + + // ... the error UI is rendered again after the reload ... + await waitFor(() => { + expect(screen.getByTestId('error-ui')).toBeInTheDocument() + expect(getErrorRenders()).toBeGreaterThan(initialErrorRenders) + }) + + // React must not have torn the tree down with a hooks-order violation. + const hooksCrash = consoleError.mock.calls.find((call) => + call.some((arg) => + String(arg?.message ?? arg).includes('Rendered more hooks'), + ), + ) + expect(hooksCrash).toBeUndefined() + // The surrounding route (the issue's "frozen" parent) is still mounted + // and interactive. + expect(screen.getByTestId('invalidate')).toBeInTheDocument() + }, +) diff --git a/packages/router-core/tests/issue-4684-head-on-beforeload-error.test.ts b/packages/router-core/tests/issue-4684-head-on-beforeload-error.test.ts new file mode 100644 index 0000000000..05960c8290 --- /dev/null +++ b/packages/router-core/tests/issue-4684-head-on-beforeload-error.test.ts @@ -0,0 +1,86 @@ +import { createMemoryHistory } from '@tanstack/history' +import { describe, expect, test, vi } from 'vitest' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter } from './routerTestUtils' + +// https://github.com/TanStack/router/issues/4684 +// +// When beforeLoad throws, head() must still execute for the retained lane — +// the root head owns global stylesheets/title that the errorComponent page +// depends on, and the failing route's own head can provide error-specific +// head content. +describe('issue #4684: head executes when beforeLoad throws', () => { + const setup = (isServer: boolean) => { + const beforeLoadError = new Error('beforeLoad failed') + const rootHead = vi.fn(() => ({ + meta: [{ title: 'Root title' }], + links: [{ rel: 'stylesheet', href: '/global.css' }], + })) + const failingHead = vi.fn(({ match }: any) => ({ + meta: [{ title: match.error ? 'Error title' : 'Success title' }], + })) + + const rootRoute = new BaseRootRoute({ head: rootHead }) + const failingRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/fail', + beforeLoad: () => { + throw beforeLoadError + }, + head: failingHead, + errorComponent: () => 'error', + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([failingRoute]), + history: createMemoryHistory({ initialEntries: ['/fail'] }), + isServer, + }) + + return { router, rootRoute, failingRoute, rootHead, failingHead } + } + + test('server load projects root and failing-route heads for the error lane', async () => { + const { router, rootRoute, failingRoute, rootHead, failingHead } = + setup(true) + + await router.load() + + expect(router.state.statusCode).toBe(500) + expect(rootHead).toHaveBeenCalledTimes(1) + expect(failingHead).toHaveBeenCalledTimes(1) + + const rootMatch = router.state.matches.find( + (match) => match.routeId === rootRoute.id, + ) + const failingMatch = router.state.matches.find( + (match) => match.routeId === failingRoute.id, + ) + expect(rootMatch?.meta).toEqual([{ title: 'Root title' }]) + expect(rootMatch?.links).toEqual([ + { rel: 'stylesheet', href: '/global.css' }, + ]) + expect(failingMatch?.status).toBe('error') + expect(failingMatch?.meta).toEqual([{ title: 'Error title' }]) + }) + + test('client load projects root and failing-route heads for the error lane', async () => { + const { router, rootRoute, failingRoute, rootHead, failingHead } = + setup(false) + + await router.load() + + expect(rootHead).toHaveBeenCalledTimes(1) + expect(failingHead).toHaveBeenCalledTimes(1) + + const rootMatch = router.state.matches.find( + (match) => match.routeId === rootRoute.id, + ) + const failingMatch = router.state.matches.find( + (match) => match.routeId === failingRoute.id, + ) + expect(rootMatch?.meta).toEqual([{ title: 'Root title' }]) + expect(failingMatch?.status).toBe('error') + expect(failingMatch?.meta).toEqual([{ title: 'Error title' }]) + }) +}) diff --git a/packages/router-core/tests/issue-6371-search-default-normalization-abort.test.ts b/packages/router-core/tests/issue-6371-search-default-normalization-abort.test.ts new file mode 100644 index 0000000000..a52a7f183d --- /dev/null +++ b/packages/router-core/tests/issue-6371-search-default-normalization-abort.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute, trimPathRight } from '../src' +import { createTestRouter } from './routerTestUtils' + +/** + * Repro for https://github.com/TanStack/router/issues/6371 + * + * Entering a route directly via URL without search params, while + * validateSearch supplies defaults, makes the framework Transitioner commit a + * canonical replace-navigation (URL with the defaulted search) right after + * the mount load already started. That replace supersedes the first load + * pass and aborts its match's abortController. A loader that forwarded that + * signal to fetch() re-throws an AbortError ("signal is aborted without + * reason"): it belongs to the abandoned pass and must not surface as a route + * error; the follow-up canonical load must complete normally. + */ + +const waitForMacrotask = () => + new Promise((resolve) => { + setTimeout(resolve, 0) + }) + +describe('issue #6371: search default normalization aborts the mount load silently', () => { + test('canonical replace after validateSearch defaults does not surface AbortError', async () => { + const invocations: Array<{ resolve: () => void; signal: AbortSignal }> = [] + + const rootRoute = new BaseRootRoute({}) + const aboutRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/about', + validateSearch: (search: Record) => ({ + page: typeof search.page === 'number' ? search.page : 1, + }), + loader: ({ abortController }: { abortController: AbortController }) => + new Promise((resolve, reject) => { + invocations.push({ + resolve: () => resolve('about data'), + signal: abortController.signal, + }) + abortController.signal.addEventListener('abort', () => { + const abortError = new Error('signal is aborted without reason') + abortError.name = 'AbortError' + reject(abortError) + }) + }), + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([aboutRoute]), + history: createMemoryHistory({ initialEntries: ['/about'] }), + }) + + // Mount load for the raw URL (no search params in the href yet). + const initialLoad = router.load() + await vi.waitFor(() => expect(invocations.length).toBe(1)) + + // What the framework Transitioner does on mount: rebuild the canonical + // location with validated search and replace if the public href differs. + const nextLocation = router.buildLocation({ + to: router.latestLocation.pathname, + search: true, + params: true, + hash: true, + state: true, + _includeValidateSearch: true, + } as any) + + expect(nextLocation.publicHref).toContain('page=1') + expect(trimPathRight(router.latestLocation.publicHref)).not.toBe( + trimPathRight(nextLocation.publicHref), + ) + + // In core (no history subscribers) commitLocation starts the second load. + void router.commitLocation({ ...nextLocation, replace: true } as any) + + await vi.waitFor(() => expect(invocations.length).toBe(2)) + + // The superseded mount-load generation was aborted (its fetch canceled). + expect(invocations[0]!.signal.aborted).toBe(true) + + // Give the aborted rejection time to (incorrectly) commit as an error. + await waitForMacrotask() + await waitForMacrotask() + + for (const match of router.state.matches) { + expect(match.status).not.toBe('error') + expect((match.error as Error | undefined)?.name).not.toBe('AbortError') + } + + invocations[1]!.resolve() + // Awaiting the superseded mount load also awaits the superseding chain. + await initialLoad + + // No runaway normalization loop: exactly one aborted + one canonical run. + expect(invocations.length).toBe(2) + + const aboutMatch = router.state.matches.find( + (match) => match.routeId === aboutRoute.id, + )! + expect(aboutMatch.status).toBe('success') + expect(aboutMatch.error).toBeUndefined() + expect(aboutMatch.loaderData).toBe('about data') + expect(aboutMatch.search).toEqual({ page: 1 }) + + expect(router.state.location.search).toEqual({ page: 1 }) + expect(router.latestLocation.publicHref).toContain('page=1') + }) +})