From 1eaad6a2fc6727ab4aa7c6605b8c0def9d7c5ce7 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 28 Jun 2026 23:20:04 +0200 Subject: [PATCH 1/3] reproduction for fix-match-loading edge-cases --- e2e/react-start/basic/src/routeTree.gen.ts | 56 +++++++++ .../routes/hydration-capped-assets/child.tsx | 25 ++++ .../routes/hydration-capped-assets/route.tsx | 11 ++ .../tests/hydration-capped-assets.spec.ts | 49 ++++++++ ...repro-issue-1-route-chunk-boundary.test.ts | 78 ++++++++++++ ...sue-10-preload-pending-publication.test.ts | 93 ++++++++++++++ ...o-issue-11-background-assets-stale.test.ts | 86 +++++++++++++ ...ro-issue-2-chunk-failure-lifecycle.test.ts | 46 +++++++ ...epro-issue-3-superseded-load-await.test.ts | 85 +++++++++++++ ...repro-issue-5-server-redirect-leak.test.ts | 59 +++++++++ ...o-issue-6-view-transition-fallback.test.ts | 105 ++++++++++++++++ ...sue-8-server-headers-asset-failure.test.ts | 63 ++++++++++ ...-issue-9-redirect-status-overwrite.test.ts | 59 +++++++++ ...-issue-4-same-route-pending-blank.test.tsx | 119 ++++++++++++++++++ 14 files changed, 934 insertions(+) create mode 100644 e2e/react-start/basic/src/routes/hydration-capped-assets/child.tsx create mode 100644 e2e/react-start/basic/src/routes/hydration-capped-assets/route.tsx create mode 100644 e2e/react-start/basic/tests/hydration-capped-assets.spec.ts create mode 100644 packages/router-core/tests/repro-issue-1-route-chunk-boundary.test.ts create mode 100644 packages/router-core/tests/repro-issue-10-preload-pending-publication.test.ts create mode 100644 packages/router-core/tests/repro-issue-11-background-assets-stale.test.ts create mode 100644 packages/router-core/tests/repro-issue-2-chunk-failure-lifecycle.test.ts create mode 100644 packages/router-core/tests/repro-issue-3-superseded-load-await.test.ts create mode 100644 packages/router-core/tests/repro-issue-5-server-redirect-leak.test.ts create mode 100644 packages/router-core/tests/repro-issue-6-view-transition-fallback.test.ts create mode 100644 packages/router-core/tests/repro-issue-8-server-headers-asset-failure.test.ts create mode 100644 packages/router-core/tests/repro-issue-9-redirect-status-overwrite.test.ts create mode 100644 packages/solid-router/tests/repro-issue-4-same-route-pending-blank.test.tsx diff --git a/e2e/react-start/basic/src/routeTree.gen.ts b/e2e/react-start/basic/src/routeTree.gen.ts index 103853f3b8..dbd1d7a754 100644 --- a/e2e/react-start/basic/src/routeTree.gen.ts +++ b/e2e/react-start/basic/src/routeTree.gen.ts @@ -26,6 +26,7 @@ import { Route as LayoutRouteImport } from './routes/_layout' import { Route as SpecialCharsRouteRouteImport } from './routes/specialChars/route' import { Route as SearchParamsRouteRouteImport } from './routes/search-params/route' import { Route as NotFoundRouteRouteImport } from './routes/not-found/route' +import { Route as HydrationCappedAssetsRouteRouteImport } from './routes/hydration-capped-assets/route' import { Route as IndexRouteImport } from './routes/index' import { Route as UsersIndexRouteImport } from './routes/users.index' import { Route as SearchParamsIndexRouteImport } from './routes/search-params/index' @@ -53,6 +54,7 @@ import { Route as NotFoundViaLoaderRouteImport } from './routes/not-found/via-lo import { Route as NotFoundViaBeforeLoadTargetRootRouteImport } from './routes/not-found/via-beforeLoad-target-root' import { Route as NotFoundViaBeforeLoadRouteImport } from './routes/not-found/via-beforeLoad' import { Route as MultiCookieRedirectTargetRouteImport } from './routes/multi-cookie-redirect/target' +import { Route as HydrationCappedAssetsChildRouteImport } from './routes/hydration-capped-assets/child' import { Route as ApiUsersRouteImport } from './routes/api.users' import { Route as LayoutLayout2RouteImport } from './routes/_layout/_layout-2' import { Route as SpecialCharsMalformedRouteRouteImport } from './routes/specialChars/malformed/route' @@ -165,6 +167,12 @@ const NotFoundRouteRoute = NotFoundRouteRouteImport.update({ path: '/not-found', getParentRoute: () => rootRouteImport, } as any) +const HydrationCappedAssetsRouteRoute = + HydrationCappedAssetsRouteRouteImport.update({ + id: '/hydration-capped-assets', + path: '/hydration-capped-assets', + getParentRoute: () => rootRouteImport, + } as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', @@ -305,6 +313,12 @@ const MultiCookieRedirectTargetRoute = path: '/multi-cookie-redirect/target', getParentRoute: () => rootRouteImport, } as any) +const HydrationCappedAssetsChildRoute = + HydrationCappedAssetsChildRouteImport.update({ + id: '/child', + path: '/child', + getParentRoute: () => HydrationCappedAssetsRouteRoute, + } as any) const ApiUsersRoute = ApiUsersRouteImport.update({ id: '/api/users', path: '/api/users', @@ -448,6 +462,7 @@ const NotFoundDeepBCDRoute = NotFoundDeepBCDRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/hydration-capped-assets': typeof HydrationCappedAssetsRouteRouteWithChildren '/not-found': typeof NotFoundRouteRouteWithChildren '/search-params': typeof SearchParamsRouteRouteWithChildren '/specialChars': typeof SpecialCharsRouteRouteWithChildren @@ -468,6 +483,7 @@ export interface FileRoutesByFullPath { '/not-found/parent-boundary': typeof NotFoundParentBoundaryRouteRouteWithChildren '/specialChars/malformed': typeof SpecialCharsMalformedRouteRouteWithChildren '/api/users': typeof ApiUsersRouteWithChildren + '/hydration-capped-assets/child': typeof HydrationCappedAssetsChildRoute '/multi-cookie-redirect/target': typeof MultiCookieRedirectTargetRoute '/not-found/via-beforeLoad': typeof NotFoundViaBeforeLoadRoute '/not-found/via-beforeLoad-target-root': typeof NotFoundViaBeforeLoadTargetRootRoute @@ -518,6 +534,7 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/': typeof IndexRoute + '/hydration-capped-assets': typeof HydrationCappedAssetsRouteRouteWithChildren '/specialChars': typeof SpecialCharsRouteRouteWithChildren '/async-scripts': typeof AsyncScriptsRoute '/client-only': typeof ClientOnlyRoute @@ -531,6 +548,7 @@ export interface FileRoutesByTo { '/type-only-reexport': typeof TypeOnlyReexportRoute '/specialChars/malformed': typeof SpecialCharsMalformedRouteRouteWithChildren '/api/users': typeof ApiUsersRouteWithChildren + '/hydration-capped-assets/child': typeof HydrationCappedAssetsChildRoute '/multi-cookie-redirect/target': typeof MultiCookieRedirectTargetRoute '/not-found/via-beforeLoad': typeof NotFoundViaBeforeLoadRoute '/not-found/via-beforeLoad-target-root': typeof NotFoundViaBeforeLoadTargetRootRoute @@ -580,6 +598,7 @@ export interface FileRoutesByTo { export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute + '/hydration-capped-assets': typeof HydrationCappedAssetsRouteRouteWithChildren '/not-found': typeof NotFoundRouteRouteWithChildren '/search-params': typeof SearchParamsRouteRouteWithChildren '/specialChars': typeof SpecialCharsRouteRouteWithChildren @@ -602,6 +621,7 @@ export interface FileRoutesById { '/specialChars/malformed': typeof SpecialCharsMalformedRouteRouteWithChildren '/_layout/_layout-2': typeof LayoutLayout2RouteWithChildren '/api/users': typeof ApiUsersRouteWithChildren + '/hydration-capped-assets/child': typeof HydrationCappedAssetsChildRoute '/multi-cookie-redirect/target': typeof MultiCookieRedirectTargetRoute '/not-found/via-beforeLoad': typeof NotFoundViaBeforeLoadRoute '/not-found/via-beforeLoad-target-root': typeof NotFoundViaBeforeLoadTargetRootRoute @@ -654,6 +674,7 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' + | '/hydration-capped-assets' | '/not-found' | '/search-params' | '/specialChars' @@ -674,6 +695,7 @@ export interface FileRouteTypes { | '/not-found/parent-boundary' | '/specialChars/malformed' | '/api/users' + | '/hydration-capped-assets/child' | '/multi-cookie-redirect/target' | '/not-found/via-beforeLoad' | '/not-found/via-beforeLoad-target-root' @@ -724,6 +746,7 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/' + | '/hydration-capped-assets' | '/specialChars' | '/async-scripts' | '/client-only' @@ -737,6 +760,7 @@ export interface FileRouteTypes { | '/type-only-reexport' | '/specialChars/malformed' | '/api/users' + | '/hydration-capped-assets/child' | '/multi-cookie-redirect/target' | '/not-found/via-beforeLoad' | '/not-found/via-beforeLoad-target-root' @@ -785,6 +809,7 @@ export interface FileRouteTypes { id: | '__root__' | '/' + | '/hydration-capped-assets' | '/not-found' | '/search-params' | '/specialChars' @@ -807,6 +832,7 @@ export interface FileRouteTypes { | '/specialChars/malformed' | '/_layout/_layout-2' | '/api/users' + | '/hydration-capped-assets/child' | '/multi-cookie-redirect/target' | '/not-found/via-beforeLoad' | '/not-found/via-beforeLoad-target-root' @@ -858,6 +884,7 @@ export interface FileRouteTypes { } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + HydrationCappedAssetsRouteRoute: typeof HydrationCappedAssetsRouteRouteWithChildren NotFoundRouteRoute: typeof NotFoundRouteRouteWithChildren SearchParamsRouteRoute: typeof SearchParamsRouteRouteWithChildren SpecialCharsRouteRoute: typeof SpecialCharsRouteRouteWithChildren @@ -1005,6 +1032,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof NotFoundRouteRouteImport parentRoute: typeof rootRouteImport } + '/hydration-capped-assets': { + id: '/hydration-capped-assets' + path: '/hydration-capped-assets' + fullPath: '/hydration-capped-assets' + preLoaderRoute: typeof HydrationCappedAssetsRouteRouteImport + parentRoute: typeof rootRouteImport + } '/': { id: '/' path: '/' @@ -1194,6 +1228,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof MultiCookieRedirectTargetRouteImport parentRoute: typeof rootRouteImport } + '/hydration-capped-assets/child': { + id: '/hydration-capped-assets/child' + path: '/child' + fullPath: '/hydration-capped-assets/child' + preLoaderRoute: typeof HydrationCappedAssetsChildRouteImport + parentRoute: typeof HydrationCappedAssetsRouteRoute + } '/api/users': { id: '/api/users' path: '/api/users' @@ -1379,6 +1420,20 @@ declare module '@tanstack/react-router' { } } +interface HydrationCappedAssetsRouteRouteChildren { + HydrationCappedAssetsChildRoute: typeof HydrationCappedAssetsChildRoute +} + +const HydrationCappedAssetsRouteRouteChildren: HydrationCappedAssetsRouteRouteChildren = + { + HydrationCappedAssetsChildRoute: HydrationCappedAssetsChildRoute, + } + +const HydrationCappedAssetsRouteRouteWithChildren = + HydrationCappedAssetsRouteRoute._addFileChildren( + HydrationCappedAssetsRouteRouteChildren, + ) + interface NotFoundDeepBCRouteRouteChildren { NotFoundDeepBCDRoute: typeof NotFoundDeepBCDRoute } @@ -1630,6 +1685,7 @@ const FooBarQuxHereRouteWithChildren = FooBarQuxHereRoute._addFileChildren( const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + HydrationCappedAssetsRouteRoute: HydrationCappedAssetsRouteRouteWithChildren, NotFoundRouteRoute: NotFoundRouteRouteWithChildren, SearchParamsRouteRoute: SearchParamsRouteRouteWithChildren, SpecialCharsRouteRoute: SpecialCharsRouteRouteWithChildren, diff --git a/e2e/react-start/basic/src/routes/hydration-capped-assets/child.tsx b/e2e/react-start/basic/src/routes/hydration-capped-assets/child.tsx new file mode 100644 index 0000000000..c18083a45d --- /dev/null +++ b/e2e/react-start/basic/src/routes/hydration-capped-assets/child.tsx @@ -0,0 +1,25 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/hydration-capped-assets/child')({ + loader: () => ({ + title: 'child loader data', + }), + head: ({ loaderData }) => ({ + meta: [ + { + name: 'issue-7-child-head', + content: loaderData?.title ?? 'missing-loader-data', + }, + ], + }), + scripts: ({ loaderData }) => [ + { + children: `window.__ISSUE_7_CHILD_SCRIPT = ${JSON.stringify( + loaderData?.title ?? 'missing-loader-data', + )}`, + }, + ], + component: () => ( +
Child route
+ ), +}) diff --git a/e2e/react-start/basic/src/routes/hydration-capped-assets/route.tsx b/e2e/react-start/basic/src/routes/hydration-capped-assets/route.tsx new file mode 100644 index 0000000000..0b75775bc0 --- /dev/null +++ b/e2e/react-start/basic/src/routes/hydration-capped-assets/route.tsx @@ -0,0 +1,11 @@ +import { Outlet, createFileRoute, notFound } from '@tanstack/react-router' + +export const Route = createFileRoute('/hydration-capped-assets')({ + beforeLoad: () => { + throw notFound() + }, + notFoundComponent: () => ( +
Parent not found
+ ), + component: () => , +}) diff --git a/e2e/react-start/basic/tests/hydration-capped-assets.spec.ts b/e2e/react-start/basic/tests/hydration-capped-assets.spec.ts new file mode 100644 index 0000000000..cbd39e235a --- /dev/null +++ b/e2e/react-start/basic/tests/hydration-capped-assets.spec.ts @@ -0,0 +1,49 @@ +import { expect } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' +import { isPrerender } from './utils/isPrerender' +import { isSpaMode } from './utils/isSpaMode' + +/** + * Issue 7: on a direct SSR hard load, the server can intentionally cap the + * committed match list at a parent notFound/error boundary. Hydration should not + * project head/scripts for the child route that was omitted from the server + * match list, because those assets can be added with missing or stale loader + * data before the follow-up client load enforces the same boundary. + * + * This Playwright repro uses the real React Start app, a real browser page load, + * the real SSR response, and normal client hydration. The parent route throws + * notFound from beforeLoad, while its child route defines a meta tag and inline + * script; neither child asset should appear after loading the capped parent + * boundary response. + */ + +test.use({ + whitelistErrors: [ + 'Failed to load resource: the server responded with a status of 404', + ], +}) + +test.describe('SSR hydration capped route assets', () => { + test.skip(isSpaMode || isPrerender, 'SSR hydration repro only') + + test('does not project assets for a child route omitted by the server boundary', async ({ + page, + }) => { + await page.goto('/hydration-capped-assets/child') + await page.waitForLoadState('networkidle') + + await expect( + page.getByTestId('issue-7-parent-not-found'), + ).toBeInViewport() + await expect( + page.getByTestId('issue-7-child-route-component'), + ).not.toBeInViewport() + + await expect(page.locator('meta[name="issue-7-child-head"]')).toHaveCount( + 0, + ) + expect( + await page.evaluate(() => Boolean((window as any).__ISSUE_7_CHILD_SCRIPT)), + ).toBe(false) + }) +}) diff --git a/packages/router-core/tests/repro-issue-1-route-chunk-boundary.test.ts b/packages/router-core/tests/repro-issue-1-route-chunk-boundary.test.ts new file mode 100644 index 0000000000..28954ebac6 --- /dev/null +++ b/packages/router-core/tests/repro-issue-1-route-chunk-boundary.test.ts @@ -0,0 +1,78 @@ +import { afterEach, describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute, createControlledPromise } from '../src' +import { createTestRouter } from './routerTestUtils' + +/** + * Issue 1: boundary component preloads should not be blocked by an unrelated + * normal route component preload. A route load starts the normal component + * preload before the loader settles; if that loader throws, the error boundary + * asks to load only the route's errorComponent. + * + * This test uses a real client router load. It keeps the normal route component + * preload pending, resolves the errorComponent preload, and expects the route + * error state to be committed without waiting for the normal component chunk. + */ + +const pendingGates: Array>> = [] +const pendingLoads: Array> = [] + +afterEach(async () => { + for (const gate of pendingGates) { + gate.resolve() + } + + await Promise.allSettled(pendingLoads) + + pendingGates.length = 0 + pendingLoads.length = 0 +}) + +describe('route boundary component preloads', () => { + test('errorComponent preload resolves without waiting for a pending route component preload', async () => { + const componentGate = createControlledPromise() + const errorComponentGate = createControlledPromise() + const routeError = new Error('loader failed') + let errorComponentPreloadCalls = 0 + pendingGates.push(componentGate, errorComponentGate) + + const SlowRouteComponent = Object.assign(() => null, { + preload: () => componentGate, + }) + const ErrorBoundary = Object.assign(() => null, { + preload: () => { + errorComponentPreloadCalls++ + return errorComponentGate + }, + }) + + const rootRoute = new BaseRootRoute({}) + const route = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/chunked', + loader: () => { + throw routeError + }, + component: SlowRouteComponent as any, + errorComponent: ErrorBoundary as any, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([route]), + history: createMemoryHistory({ initialEntries: ['/chunked'] }), + }) + + const loadPromise = router.load() + pendingLoads.push(loadPromise) + + await vi.waitFor(() => expect(errorComponentPreloadCalls).toBe(1)) + errorComponentGate.resolve() + await Promise.resolve() + + const match = router.state.matches.find((item) => item.routeId === route.id) + expect(match?.status).toBe('error') + expect(match?.error).toBe(routeError) + + componentGate.resolve() + await loadPromise + }) +}) diff --git a/packages/router-core/tests/repro-issue-10-preload-pending-publication.test.ts b/packages/router-core/tests/repro-issue-10-preload-pending-publication.test.ts new file mode 100644 index 0000000000..b96a8d3329 --- /dev/null +++ b/packages/router-core/tests/repro-issue-10-preload-pending-publication.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute, createControlledPromise } from '../src' +import { createTestRouter } from './routerTestUtils' + +/** + * Issue 10: a preload started after foreground pending publication should not + * be dropped just because it borrows an active match that is still pending. + * After commitReady moves pending matches into active stores, preload joining + * must still wait for the foreground load instead of abandoning descendants. + * + * This test performs a real foreground navigation to /parent, publishes the + * pending parent match, then preloads /parent/child while final commit is held + * open by async head projection. The descendant preload loader should still run + * and cache the child match. + */ + +describe('issue 10: preload during foreground pending publication', () => { + test('preload started after pending publication still loads and caches descendant routes', async () => { + const parentLoaderGate = createControlledPromise<{ parent: string }>() + const parentHeadGate = createControlledPromise<{ + meta: Array<{ title: string }> + }>() + const parentLoader = vi.fn(() => parentLoaderGate) + const parentHead = vi.fn(() => parentHeadGate) + const childLoader = vi.fn(() => ({ child: 'preloaded' })) + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: parentLoader, + head: parentHead, + pendingMs: 0, + pendingComponent: {}, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: childLoader, + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + + const navigation = router.navigate({ to: '/parent' }) + await vi.waitFor(() => expect(parentLoader).toHaveBeenCalledTimes(1)) + + await vi.waitFor(() => { + const activeParent = router.state.matches.find( + (match) => match.routeId === parentRoute.id, + ) + expect(activeParent?.status).toBe('pending') + expect(router.stores.pendingIds.get()).toEqual([]) + }) + + const preload = router.preloadRoute({ to: '/parent/child' }) + await Promise.resolve() + expect(childLoader).not.toHaveBeenCalled() + + parentLoaderGate.resolve({ parent: 'loaded' }) + await vi.waitFor(() => expect(parentHead).toHaveBeenCalledTimes(1)) + + expect( + router.state.matches.find((match) => match.routeId === parentRoute.id) + ?.status, + ).toBe('pending') + + parentHeadGate.resolve({ meta: [{ title: 'Parent' }] }) + const [, preloadMatches] = await Promise.all([navigation, preload]) + + expect(childLoader).toHaveBeenCalledTimes(1) + expect( + preloadMatches?.some((match) => match.routeId === childRoute.id), + ).toBe(true) + expect( + router.stores.cachedMatches + .get() + .find((match) => match.routeId === childRoute.id)?.loaderData, + ).toEqual({ child: 'preloaded' }) + }) +}) diff --git a/packages/router-core/tests/repro-issue-11-background-assets-stale.test.ts b/packages/router-core/tests/repro-issue-11-background-assets-stale.test.ts new file mode 100644 index 0000000000..e5756808a4 --- /dev/null +++ b/packages/router-core/tests/repro-issue-11-background-assets-stale.test.ts @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter } from './routerTestUtils' + +/** + * Issue 11: background reloads should not commit fresh loaderData when route + * asset projection for that fresh data fails. Otherwise the router can expose + * new data while meta/scripts still describe the previous data snapshot. + * + * This test performs a real same-location background reload after the route is + * stale. The second loader returns fresh data and head throws for that data; + * the active match should keep the old loaderData and old projected assets. + */ + +describe('issue 11: background asset projection failure', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(0) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + test('does not commit fresh loaderData when background asset projection fails', async () => { + let resolveStaleReload!: (data: { title: string }) => void + let loaderCalls = 0 + let headCalls = 0 + const loader = () => { + loaderCalls += 1 + if (loaderCalls === 1) { + return { title: 'old' } + } + + return new Promise<{ title: string }>((resolve) => { + resolveStaleReload = resolve + }) + } + + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loader, + head: ({ loaderData }) => { + headCalls += 1 + if (loaderData.title === 'fresh') { + throw new Error('head projection failed') + } + + return { + meta: [{ title: loaderData.title }], + } + }, + staleTime: 0, + gcTime: 60_000, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([fooRoute]), + history: createMemoryHistory(), + }) + + await router.navigate({ to: '/foo' }) + const matchId = router.state.matches.find( + (match) => match.routeId === fooRoute.id, + )!.id + const getMatch = () => + router.state.matches.find((match) => match.id === matchId) + + expect(getMatch()?.loaderData).toEqual({ title: 'old' }) + expect(getMatch()?.meta).toEqual([{ title: 'old' }]) + + await vi.advanceTimersByTimeAsync(1) + await router.load() + await vi.waitFor(() => expect(loaderCalls).toBe(2)) + expect(getMatch()?.isFetching).toBe('loader') + + resolveStaleReload({ title: 'fresh' }) + await vi.waitFor(() => expect(getMatch()?.isFetching).toBe(false)) + + expect(headCalls).toBe(2) + expect(getMatch()?.loaderData).toEqual({ title: 'old' }) + expect(getMatch()?.meta).toEqual([{ title: 'old' }]) + }) +}) diff --git a/packages/router-core/tests/repro-issue-2-chunk-failure-lifecycle.test.ts b/packages/router-core/tests/repro-issue-2-chunk-failure-lifecycle.test.ts new file mode 100644 index 0000000000..a64e3202dc --- /dev/null +++ b/packages/router-core/tests/repro-issue-2-chunk-failure-lifecycle.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter } from './routerTestUtils' + +/** + * Issue 2: primary route chunk failures should go through the same route-error + * lifecycle as loader and beforeLoad failures. Skipping normalization means + * route onError callbacks, redirects, and notFound values from lazy/chunk + * loading can behave differently from other route failures. + * + * This test uses a real router navigation to a lazy route whose chunk rejects. + * The match reaches error state with the chunk error, but the route's onError + * callback should also receive that error as part of normal route failure + * handling. + */ + +describe('route chunk failure lifecycle', () => { + test('calls route onError when lazy route chunk rejects during navigation', async () => { + const chunkError = new Error('lazy chunk failed') + let capturedError: unknown + + const rootRoute = new BaseRootRoute({}) + const lazyRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/lazy', + onError: (error) => { + capturedError = error + }, + }).lazy(() => Promise.reject(chunkError)) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([lazyRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.navigate({ to: '/lazy' }) + + const lazyMatch = router.state.matches.find( + (match) => match.routeId === lazyRoute.id, + ) + expect(lazyMatch?.status).toBe('error') + expect(lazyMatch?.error).toBe(chunkError) + expect(capturedError).toBe(chunkError) + }) +}) diff --git a/packages/router-core/tests/repro-issue-3-superseded-load-await.test.ts b/packages/router-core/tests/repro-issue-3-superseded-load-await.test.ts new file mode 100644 index 0000000000..ce78c02874 --- /dev/null +++ b/packages/router-core/tests/repro-issue-3-superseded-load-await.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute, createControlledPromise } from '../src' +import { createTestRouter } from './routerTestUtils' + +/** + * Issue 3: awaiting router.load() or router.invalidate() should remain a + * reliable synchronization point even when that load is superseded. If the + * abandoned load resolves immediately, callers can observe router state before + * the newer load has settled. + * + * This test starts a real public router.load() for /one, supersedes it with a + * real navigation to /two, and resolves only the abandoned /one loader. The + * original await should not settle until the superseding /two loader settles. + */ + +const waitForMacrotask = () => + new Promise((resolve) => { + setTimeout(resolve, 0) + }) + +describe('superseded load await', () => { + test('router.load waits for a superseding navigation load to settle', async () => { + const firstLoaderGate = createControlledPromise() + const secondLoaderGate = createControlledPromise() + let firstLoaderCalls = 0 + let secondLoaderCalls = 0 + let secondLoaderSettled = false + let supersededLoadSettled = false + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const firstRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/one', + loader: async () => { + firstLoaderCalls++ + await firstLoaderGate + return 'one' + }, + }) + const secondRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/two', + loader: async () => { + secondLoaderCalls++ + await secondLoaderGate + secondLoaderSettled = true + return 'two' + }, + }) + + const history = createMemoryHistory({ initialEntries: ['/'] }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, firstRoute, secondRoute]), + history, + }) + + await router.load() + + history.push('/one') + const supersededLoad = router.load().then(() => { + supersededLoadSettled = true + }) + await vi.waitFor(() => expect(firstLoaderCalls).toBe(1)) + + const latestNavigation = router.navigate({ to: '/two' }) + await vi.waitFor(() => expect(secondLoaderCalls).toBe(1)) + + firstLoaderGate.resolve() + await waitForMacrotask() + + expect(secondLoaderSettled).toBe(false) + expect(supersededLoadSettled).toBe(false) + + secondLoaderGate.resolve() + await Promise.all([supersededLoad, latestNavigation]) + + expect(secondLoaderSettled).toBe(true) + expect(supersededLoadSettled).toBe(true) + }) +}) diff --git a/packages/router-core/tests/repro-issue-5-server-redirect-leak.test.ts b/packages/router-core/tests/repro-issue-5-server-redirect-leak.test.ts new file mode 100644 index 0000000000..f31c46a710 --- /dev/null +++ b/packages/router-core/tests/repro-issue-5-server-redirect-leak.test.ts @@ -0,0 +1,59 @@ +import { createMemoryHistory } from '@tanstack/history' +import { describe, expect, test } from 'vitest' +import { BaseRootRoute, BaseRoute, redirect } from '../src' +import { createTestRouter } from './routerTestUtils' + +/** + * Issue 5: server routers can be reused across requests, so request-scoped + * redirect state must be cleared before each server load. Leaving router.redirect + * set after a previous redirect lets a later successful request return the old + * redirect response. + * + * This test reuses one real server router. The first load redirects from /from + * to /target, then the same router is updated with a fresh /ok history and + * loaded successfully. The second request should not retain the first redirect. + */ + +describe('server router redirect reuse', () => { + test('clears redirect on a later successful server load when reusing a router', async () => { + const rootRoute = new BaseRootRoute({}) + const fromRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/from', + beforeLoad: () => { + throw redirect({ to: '/target', statusCode: 307 }) + }, + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + }) + const okRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/ok', + loader: () => 'ok', + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([fromRoute, targetRoute, okRoute]), + history: createMemoryHistory({ initialEntries: ['/from'] }), + isServer: true, + }) + + await router.load() + + expect(router.redirect?.options.href).toBe('/target') + expect(router.redirect?.headers.get('Location')).toBe('/target') + + router.update({ + history: createMemoryHistory({ initialEntries: ['/ok'] }), + }) + + await router.load() + + expect(router.statusCode).toBe(200) + expect(router.state.location.pathname).toBe('/ok') + expect(router.state.matches.at(-1)?.loaderData).toBe('ok') + expect(router.redirect).toBeUndefined() + }) +}) diff --git a/packages/router-core/tests/repro-issue-6-view-transition-fallback.test.ts b/packages/router-core/tests/repro-issue-6-view-transition-fallback.test.ts new file mode 100644 index 0000000000..8da874e482 --- /dev/null +++ b/packages/router-core/tests/repro-issue-6-view-transition-fallback.test.ts @@ -0,0 +1,105 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter } from './routerTestUtils' + +/** + * Issue 6: object-form view transitions should fall back to the normal browser + * startViewTransition API when typed transition selectors are unsupported. + * Returning fn() directly disables the transition even though the browser can + * still perform an untyped view transition. + * + * This test navigates through the public router API with object-form + * viewTransition options. The mocked browser reports no typed-transition + * support, but still provides document.startViewTransition, so navigation should + * call startViewTransition with a regular update callback. + */ + +type ViewTransitionInput = + | (() => Promise) + | { update: () => Promise; types?: Array } + +type DocumentWithViewTransition = Document & { + startViewTransition?: (input: ViewTransitionInput) => { + ready: Promise + finished: Promise + updateCallbackDone: Promise + skipTransition: () => void + } +} + +let startViewTransitionDescriptor: + | PropertyDescriptor + | undefined +let startViewTransitionMock: ReturnType + +beforeEach(() => { + startViewTransitionDescriptor = Object.getOwnPropertyDescriptor( + document, + 'startViewTransition', + ) + startViewTransitionMock = vi.fn((input: ViewTransitionInput) => { + const update = typeof input === 'function' ? input : input.update + const updateCallbackDone = Promise.resolve() + .then(update) + .then(() => undefined) + + return { + ready: Promise.resolve(), + finished: updateCallbackDone, + updateCallbackDone, + skipTransition: vi.fn(), + } + }) + + vi.stubGlobal('CSS', { + supports: vi.fn(() => false), + }) + Object.defineProperty(document, 'startViewTransition', { + configurable: true, + value: startViewTransitionMock, + }) +}) + +afterEach(() => { + if (startViewTransitionDescriptor) { + Object.defineProperty( + document, + 'startViewTransition', + startViewTransitionDescriptor, + ) + } else { + delete (document as DocumentWithViewTransition).startViewTransition + } + + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +describe('view transition fallback', () => { + test('uses document.startViewTransition for object-form transitions when typed transitions are unsupported', async () => { + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const nextRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/next', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, nextRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + await router.navigate({ + to: '/next', + viewTransition: { types: ['route-change'] }, + }) + + expect(startViewTransitionMock).toHaveBeenCalledTimes(1) + expect(startViewTransitionMock).toHaveBeenCalledWith(expect.any(Function)) + expect(router.state.location.pathname).toBe('/next') + }) +}) diff --git a/packages/router-core/tests/repro-issue-8-server-headers-asset-failure.test.ts b/packages/router-core/tests/repro-issue-8-server-headers-asset-failure.test.ts new file mode 100644 index 0000000000..d76d436f1b --- /dev/null +++ b/packages/router-core/tests/repro-issue-8-server-headers-asset-failure.test.ts @@ -0,0 +1,63 @@ +import { createMemoryHistory } from '@tanstack/history' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { BaseRootRoute, BaseRoute } from '../src' +import { createTestRouter } from './routerTestUtils' + +/** + * Issue 8: route headers are response behavior and should not be dropped just + * because decorative server assets fail. Server route asset projection currently + * commits head, scripts, and headers as one unit. + * + * This test uses a real server router load. The route's headers option succeeds + * while head fails, and the loaded route match should still expose the header + * that Start later merges into the HTTP response. + */ + +const expectedHeaders = { 'x-route-header': 'kept' } + +beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => {}) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +async function loadTargetRoute(options: { asyncHead: boolean }) { + const rootRoute = new BaseRootRoute({}) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + head: options.asyncHead + ? async () => { + throw new Error('head failed') + } + : () => { + throw new Error('head failed') + }, + headers: () => expectedHeaders, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([targetRoute]), + history: createMemoryHistory({ initialEntries: ['/target'] }), + isServer: true, + }) + + await router.load() + + return router.state.matches.find((match) => match.routeId === targetRoute.id) +} + +describe('server asset projection route headers', () => { + test('keeps route headers when head throws synchronously', async () => { + const targetMatch = await loadTargetRoute({ asyncHead: false }) + expect(targetMatch).toBeDefined() + expect(targetMatch!.headers).toEqual(expectedHeaders) + }) + + test('keeps route headers when head rejects asynchronously', async () => { + const targetMatch = await loadTargetRoute({ asyncHead: true }) + expect(targetMatch).toBeDefined() + expect(targetMatch!.headers).toEqual(expectedHeaders) + }) +}) diff --git a/packages/router-core/tests/repro-issue-9-redirect-status-overwrite.test.ts b/packages/router-core/tests/repro-issue-9-redirect-status-overwrite.test.ts new file mode 100644 index 0000000000..9747e79ffd --- /dev/null +++ b/packages/router-core/tests/repro-issue-9-redirect-status-overwrite.test.ts @@ -0,0 +1,59 @@ +import { createMemoryHistory } from '@tanstack/history' +import { describe, expect, test } from 'vitest' +import { BaseRootRoute, BaseRoute, notFound, redirect } from '../src' +import { createTestRouter } from './routerTestUtils' + +/** + * Issue 9: a redirect response status should not be overwritten by stale match + * state on a reused server router. Once loadServerRouter catches a redirect, + * the redirect status is the response status that should be preserved. + * + * This test first commits a notFound match on a real server router, then reuses + * that router for a redirecting route. The redirect Location is present, but + * stale notFound state must not rewrite the status from 302 to 404. + */ + +describe('issue 9 redirect status overwrite', () => { + test('keeps redirect status when a reused server router has stale notFound matches', async () => { + const rootRoute = new BaseRootRoute({}) + const staleNotFoundRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/stale-not-found', + beforeLoad: () => { + throw notFound() + }, + notFoundComponent: () => null, + }) + const redirectRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/redirect', + beforeLoad: () => { + throw redirect({ to: '/target', statusCode: 302 }) + }, + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + staleNotFoundRoute, + redirectRoute, + targetRoute, + ]), + history: createMemoryHistory({ initialEntries: ['/stale-not-found'] }), + isServer: true, + }) + + await router.load() + + expect(router.statusCode).toBe(404) + expect(router.state.matches.some((match) => match.status === 'notFound')) + .toBe(true) + + await router.navigate({ to: '/redirect' }) + + expect(router.redirect?.headers.get('Location')).toBe('/target') + expect(router.statusCode).toBe(302) + }) +}) diff --git a/packages/solid-router/tests/repro-issue-4-same-route-pending-blank.test.tsx b/packages/solid-router/tests/repro-issue-4-same-route-pending-blank.test.tsx new file mode 100644 index 0000000000..adb5f81a3d --- /dev/null +++ b/packages/solid-router/tests/repro-issue-4-same-route-pending-blank.test.tsx @@ -0,0 +1,119 @@ +import { cleanup, render, screen, waitFor } from '@solidjs/testing-library' +import { afterEach, expect, test } from 'vitest' +import { + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +/** + * Issue 4: Solid same-route pending replacement should not blank stale content + * before pending UI is ready. Core can expose a replacement pending match while + * the route has no pending fallback and a long pendingMs delay. + * + * This test renders a real Solid RouterProvider, navigates from page 1 to page + * 2 on the same route, and leaves the page 2 loader unresolved. Until pendingMs + * elapses, the previously committed page 1 content should remain visible. + */ + +let resolvePendingPage2: (() => void) | undefined +let pendingNavigation: Promise | undefined + +afterEach(async () => { + resolvePendingPage2?.() + if (pendingNavigation) { + await Promise.allSettled([pendingNavigation]) + } + resolvePendingPage2 = undefined + pendingNavigation = undefined + cleanup() +}) + +function deferred() { + let resolve!: (value: T | PromiseLike) => void + const promise = new Promise((resolver) => { + resolve = resolver + }) + + return { promise, resolve } +} + +test('same-route pending replacement without fallback keeps stale content until pendingMs', async () => { + const pendingMs = 10_000 + const page2Gate = deferred() + 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) { + await page2Gate.promise + } + + return `Page ${deps.page}` + }, + component: Posts, + }) + const router = createRouter({ + routeTree: root.addChildren([postsRoute]), + history, + defaultPendingMs: pendingMs, + }) + + render(() => ) + expect(await screen.findByText('Page 1')).toBeInTheDocument() + + const currentMatch = () => + router.stores.matches + .get() + .find((match) => match.routeId === postsRoute.id) + const navigation = router.navigate({ + to: '/posts', + search: { page: 2 }, + }) + pendingNavigation = navigation + + await waitFor( + () => { + const current = currentMatch() + expect(current).toBeDefined() + expect( + router.stores.pendingMatches.get().some( + (match) => + match.routeId === postsRoute.id && + match.status === 'pending' && + match.id !== current?.id, + ), + ).toBe(true) + }, + { timeout: 1000 }, + ) + + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(screen.getByTestId('post-content')).toHaveTextContent('Page 1') + + page2Gate.resolve() + await navigation + pendingNavigation = undefined + + expect(await screen.findByText('Page 2')).toBeInTheDocument() +}) From 5a59655cf7eed15fff22b5106fb9682eb0507ab1 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 28 Jun 2026 21:22:37 +0000 Subject: [PATCH 2/3] ci: apply automated fixes --- .../tests/hydration-capped-assets.spec.ts | 12 +++++------- ...ro-issue-6-view-transition-fallback.test.ts | 4 +--- ...o-issue-9-redirect-status-overwrite.test.ts | 5 +++-- ...o-issue-4-same-route-pending-blank.test.tsx | 18 +++++++++--------- 4 files changed, 18 insertions(+), 21 deletions(-) diff --git a/e2e/react-start/basic/tests/hydration-capped-assets.spec.ts b/e2e/react-start/basic/tests/hydration-capped-assets.spec.ts index cbd39e235a..fa40b16348 100644 --- a/e2e/react-start/basic/tests/hydration-capped-assets.spec.ts +++ b/e2e/react-start/basic/tests/hydration-capped-assets.spec.ts @@ -32,18 +32,16 @@ test.describe('SSR hydration capped route assets', () => { await page.goto('/hydration-capped-assets/child') await page.waitForLoadState('networkidle') - await expect( - page.getByTestId('issue-7-parent-not-found'), - ).toBeInViewport() + await expect(page.getByTestId('issue-7-parent-not-found')).toBeInViewport() await expect( page.getByTestId('issue-7-child-route-component'), ).not.toBeInViewport() - await expect(page.locator('meta[name="issue-7-child-head"]')).toHaveCount( - 0, - ) + await expect(page.locator('meta[name="issue-7-child-head"]')).toHaveCount(0) expect( - await page.evaluate(() => Boolean((window as any).__ISSUE_7_CHILD_SCRIPT)), + await page.evaluate(() => + Boolean((window as any).__ISSUE_7_CHILD_SCRIPT), + ), ).toBe(false) }) }) diff --git a/packages/router-core/tests/repro-issue-6-view-transition-fallback.test.ts b/packages/router-core/tests/repro-issue-6-view-transition-fallback.test.ts index 8da874e482..85268f12cf 100644 --- a/packages/router-core/tests/repro-issue-6-view-transition-fallback.test.ts +++ b/packages/router-core/tests/repro-issue-6-view-transition-fallback.test.ts @@ -28,9 +28,7 @@ type DocumentWithViewTransition = Document & { } } -let startViewTransitionDescriptor: - | PropertyDescriptor - | undefined +let startViewTransitionDescriptor: PropertyDescriptor | undefined let startViewTransitionMock: ReturnType beforeEach(() => { diff --git a/packages/router-core/tests/repro-issue-9-redirect-status-overwrite.test.ts b/packages/router-core/tests/repro-issue-9-redirect-status-overwrite.test.ts index 9747e79ffd..dc031f7444 100644 --- a/packages/router-core/tests/repro-issue-9-redirect-status-overwrite.test.ts +++ b/packages/router-core/tests/repro-issue-9-redirect-status-overwrite.test.ts @@ -48,8 +48,9 @@ describe('issue 9 redirect status overwrite', () => { await router.load() expect(router.statusCode).toBe(404) - expect(router.state.matches.some((match) => match.status === 'notFound')) - .toBe(true) + expect( + router.state.matches.some((match) => match.status === 'notFound'), + ).toBe(true) await router.navigate({ to: '/redirect' }) diff --git a/packages/solid-router/tests/repro-issue-4-same-route-pending-blank.test.tsx b/packages/solid-router/tests/repro-issue-4-same-route-pending-blank.test.tsx index adb5f81a3d..04374e3be6 100644 --- a/packages/solid-router/tests/repro-issue-4-same-route-pending-blank.test.tsx +++ b/packages/solid-router/tests/repro-issue-4-same-route-pending-blank.test.tsx @@ -82,9 +82,7 @@ test('same-route pending replacement without fallback keeps stale content until expect(await screen.findByText('Page 1')).toBeInTheDocument() const currentMatch = () => - router.stores.matches - .get() - .find((match) => match.routeId === postsRoute.id) + router.stores.matches.get().find((match) => match.routeId === postsRoute.id) const navigation = router.navigate({ to: '/posts', search: { page: 2 }, @@ -96,12 +94,14 @@ test('same-route pending replacement without fallback keeps stale content until const current = currentMatch() expect(current).toBeDefined() expect( - router.stores.pendingMatches.get().some( - (match) => - match.routeId === postsRoute.id && - match.status === 'pending' && - match.id !== current?.id, - ), + router.stores.pendingMatches + .get() + .some( + (match) => + match.routeId === postsRoute.id && + match.status === 'pending' && + match.id !== current?.id, + ), ).toBe(true) }, { timeout: 1000 }, From 4d218c3c0711e739de1509192232ca83d93a1ffd Mon Sep 17 00:00:00 2001 From: Sheraff Date: Mon, 29 Jun 2026 19:23:57 +0200 Subject: [PATCH 3/3] remove some repros --- ...repro-issue-5-server-redirect-leak.test.ts | 59 ------------------ ...-issue-9-redirect-status-overwrite.test.ts | 60 ------------------- 2 files changed, 119 deletions(-) delete mode 100644 packages/router-core/tests/repro-issue-5-server-redirect-leak.test.ts delete mode 100644 packages/router-core/tests/repro-issue-9-redirect-status-overwrite.test.ts diff --git a/packages/router-core/tests/repro-issue-5-server-redirect-leak.test.ts b/packages/router-core/tests/repro-issue-5-server-redirect-leak.test.ts deleted file mode 100644 index f31c46a710..0000000000 --- a/packages/router-core/tests/repro-issue-5-server-redirect-leak.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { createMemoryHistory } from '@tanstack/history' -import { describe, expect, test } from 'vitest' -import { BaseRootRoute, BaseRoute, redirect } from '../src' -import { createTestRouter } from './routerTestUtils' - -/** - * Issue 5: server routers can be reused across requests, so request-scoped - * redirect state must be cleared before each server load. Leaving router.redirect - * set after a previous redirect lets a later successful request return the old - * redirect response. - * - * This test reuses one real server router. The first load redirects from /from - * to /target, then the same router is updated with a fresh /ok history and - * loaded successfully. The second request should not retain the first redirect. - */ - -describe('server router redirect reuse', () => { - test('clears redirect on a later successful server load when reusing a router', async () => { - const rootRoute = new BaseRootRoute({}) - const fromRoute = new BaseRoute({ - getParentRoute: () => rootRoute, - path: '/from', - beforeLoad: () => { - throw redirect({ to: '/target', statusCode: 307 }) - }, - }) - const targetRoute = new BaseRoute({ - getParentRoute: () => rootRoute, - path: '/target', - }) - const okRoute = new BaseRoute({ - getParentRoute: () => rootRoute, - path: '/ok', - loader: () => 'ok', - }) - - const router = createTestRouter({ - routeTree: rootRoute.addChildren([fromRoute, targetRoute, okRoute]), - history: createMemoryHistory({ initialEntries: ['/from'] }), - isServer: true, - }) - - await router.load() - - expect(router.redirect?.options.href).toBe('/target') - expect(router.redirect?.headers.get('Location')).toBe('/target') - - router.update({ - history: createMemoryHistory({ initialEntries: ['/ok'] }), - }) - - await router.load() - - expect(router.statusCode).toBe(200) - expect(router.state.location.pathname).toBe('/ok') - expect(router.state.matches.at(-1)?.loaderData).toBe('ok') - expect(router.redirect).toBeUndefined() - }) -}) diff --git a/packages/router-core/tests/repro-issue-9-redirect-status-overwrite.test.ts b/packages/router-core/tests/repro-issue-9-redirect-status-overwrite.test.ts deleted file mode 100644 index dc031f7444..0000000000 --- a/packages/router-core/tests/repro-issue-9-redirect-status-overwrite.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { createMemoryHistory } from '@tanstack/history' -import { describe, expect, test } from 'vitest' -import { BaseRootRoute, BaseRoute, notFound, redirect } from '../src' -import { createTestRouter } from './routerTestUtils' - -/** - * Issue 9: a redirect response status should not be overwritten by stale match - * state on a reused server router. Once loadServerRouter catches a redirect, - * the redirect status is the response status that should be preserved. - * - * This test first commits a notFound match on a real server router, then reuses - * that router for a redirecting route. The redirect Location is present, but - * stale notFound state must not rewrite the status from 302 to 404. - */ - -describe('issue 9 redirect status overwrite', () => { - test('keeps redirect status when a reused server router has stale notFound matches', async () => { - const rootRoute = new BaseRootRoute({}) - const staleNotFoundRoute = new BaseRoute({ - getParentRoute: () => rootRoute, - path: '/stale-not-found', - beforeLoad: () => { - throw notFound() - }, - notFoundComponent: () => null, - }) - const redirectRoute = new BaseRoute({ - getParentRoute: () => rootRoute, - path: '/redirect', - beforeLoad: () => { - throw redirect({ to: '/target', statusCode: 302 }) - }, - }) - const targetRoute = new BaseRoute({ - getParentRoute: () => rootRoute, - path: '/target', - }) - const router = createTestRouter({ - routeTree: rootRoute.addChildren([ - staleNotFoundRoute, - redirectRoute, - targetRoute, - ]), - history: createMemoryHistory({ initialEntries: ['/stale-not-found'] }), - isServer: true, - }) - - await router.load() - - expect(router.statusCode).toBe(404) - expect( - router.state.matches.some((match) => match.status === 'notFound'), - ).toBe(true) - - await router.navigate({ to: '/redirect' }) - - expect(router.redirect?.headers.get('Location')).toBe('/target') - expect(router.statusCode).toBe(302) - }) -})