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..fa40b16348
--- /dev/null
+++ b/e2e/react-start/basic/tests/hydration-capped-assets.spec.ts
@@ -0,0 +1,47 @@
+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-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..85268f12cf
--- /dev/null
+++ b/packages/router-core/tests/repro-issue-6-view-transition-fallback.test.ts
@@ -0,0 +1,103 @@
+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/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..04374e3be6
--- /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()
+})