diff --git a/e2e/react-start/basic/src/routeTree.gen.ts b/e2e/react-start/basic/src/routeTree.gen.ts
index 10a40bc044..ea09864d8e 100644
--- a/e2e/react-start/basic/src/routeTree.gen.ts
+++ b/e2e/react-start/basic/src/routeTree.gen.ts
@@ -29,6 +29,7 @@ import { Route as TypeOnlyReexportRouteImport } from './routes/type-only-reexpor
import { Route as UsersRouteImport } from './routes/users'
import { Route as LayoutLayout2RouteImport } from './routes/_layout/_layout-2'
import { Route as ApiUsersRouteImport } from './routes/api.users'
+import { Route as Issue6221DashboardRouteImport } from './routes/issue-6221.dashboard'
import { Route as MultiCookieRedirectIndexRouteImport } from './routes/multi-cookie-redirect/index'
import { Route as MultiCookieRedirectTargetRouteImport } from './routes/multi-cookie-redirect/target'
import { Route as NotFoundIndexRouteImport } from './routes/not-found/index'
@@ -61,6 +62,7 @@ import { Route as UsersUserIdRouteImport } from './routes/users.$userId'
import { Route as LayoutLayout2LayoutARouteImport } from './routes/_layout/_layout-2/layout-a'
import { Route as LayoutLayout2LayoutBRouteImport } from './routes/_layout/_layout-2/layout-b'
import { Route as ApiUsersIdRouteImport } from './routes/api/users.$id'
+import { Route as Issue6221ArticleIdRouteImport } from './routes/issue-6221.article.$id'
import { Route as NotFoundDeepIndexRouteImport } from './routes/not-found/deep/index'
import { Route as NotFoundDeepBRouteRouteImport } from './routes/not-found/deep/b/route'
import { Route as NotFoundParentBoundaryIndexRouteImport } from './routes/not-found/parent-boundary/index'
@@ -179,6 +181,11 @@ const ApiUsersRoute = ApiUsersRouteImport.update({
path: '/api/users',
getParentRoute: () => rootRouteImport,
} as any)
+const Issue6221DashboardRoute = Issue6221DashboardRouteImport.update({
+ id: '/issue-6221/dashboard',
+ path: '/issue-6221/dashboard',
+ getParentRoute: () => rootRouteImport,
+} as any)
const MultiCookieRedirectIndexRoute =
MultiCookieRedirectIndexRouteImport.update({
id: '/multi-cookie-redirect/',
@@ -346,6 +353,11 @@ const ApiUsersIdRoute = ApiUsersIdRouteImport.update({
path: '/$id',
getParentRoute: () => ApiUsersRoute,
} as any)
+const Issue6221ArticleIdRoute = Issue6221ArticleIdRouteImport.update({
+ id: '/issue-6221/article/$id',
+ path: '/issue-6221/article/$id',
+ getParentRoute: () => rootRouteImport,
+} as any)
const NotFoundDeepIndexRoute = NotFoundDeepIndexRouteImport.update({
id: '/',
path: '/',
@@ -468,6 +480,7 @@ export interface FileRoutesByFullPath {
'/not-found/parent-boundary': typeof NotFoundParentBoundaryRouteRouteWithChildren
'/specialChars/malformed': typeof SpecialCharsMalformedRouteRouteWithChildren
'/api/users': typeof ApiUsersRouteWithChildren
+ '/issue-6221/dashboard': typeof Issue6221DashboardRoute
'/multi-cookie-redirect/target': typeof MultiCookieRedirectTargetRoute
'/not-found/via-beforeLoad': typeof NotFoundViaBeforeLoadRoute
'/not-found/via-beforeLoad-target-root': typeof NotFoundViaBeforeLoadTargetRootRoute
@@ -498,6 +511,7 @@ export interface FileRoutesByFullPath {
'/layout-a': typeof LayoutLayout2LayoutARoute
'/layout-b': typeof LayoutLayout2LayoutBRoute
'/api/users/$id': typeof ApiUsersIdRoute
+ '/issue-6221/article/$id': typeof Issue6221ArticleIdRoute
'/not-found/parent-boundary/via-beforeLoad': typeof NotFoundParentBoundaryViaBeforeLoadRoute
'/posts/$postId/deep': typeof PostsPostIdDeepRoute
'/redirect/$target/via-beforeLoad': typeof RedirectTargetViaBeforeLoadRoute
@@ -531,6 +545,7 @@ export interface FileRoutesByTo {
'/type-only-reexport': typeof TypeOnlyReexportRoute
'/specialChars/malformed': typeof SpecialCharsMalformedRouteRouteWithChildren
'/api/users': typeof ApiUsersRouteWithChildren
+ '/issue-6221/dashboard': typeof Issue6221DashboardRoute
'/multi-cookie-redirect/target': typeof MultiCookieRedirectTargetRoute
'/not-found/via-beforeLoad': typeof NotFoundViaBeforeLoadRoute
'/not-found/via-beforeLoad-target-root': typeof NotFoundViaBeforeLoadTargetRootRoute
@@ -560,6 +575,7 @@ export interface FileRoutesByTo {
'/layout-a': typeof LayoutLayout2LayoutARoute
'/layout-b': typeof LayoutLayout2LayoutBRoute
'/api/users/$id': typeof ApiUsersIdRoute
+ '/issue-6221/article/$id': typeof Issue6221ArticleIdRoute
'/not-found/parent-boundary/via-beforeLoad': typeof NotFoundParentBoundaryViaBeforeLoadRoute
'/posts/$postId/deep': typeof PostsPostIdDeepRoute
'/redirect/$target/via-beforeLoad': typeof RedirectTargetViaBeforeLoadRoute
@@ -602,6 +618,7 @@ export interface FileRoutesById {
'/specialChars/malformed': typeof SpecialCharsMalformedRouteRouteWithChildren
'/_layout/_layout-2': typeof LayoutLayout2RouteWithChildren
'/api/users': typeof ApiUsersRouteWithChildren
+ '/issue-6221/dashboard': typeof Issue6221DashboardRoute
'/multi-cookie-redirect/target': typeof MultiCookieRedirectTargetRoute
'/not-found/via-beforeLoad': typeof NotFoundViaBeforeLoadRoute
'/not-found/via-beforeLoad-target-root': typeof NotFoundViaBeforeLoadTargetRootRoute
@@ -632,6 +649,7 @@ export interface FileRoutesById {
'/_layout/_layout-2/layout-a': typeof LayoutLayout2LayoutARoute
'/_layout/_layout-2/layout-b': typeof LayoutLayout2LayoutBRoute
'/api/users/$id': typeof ApiUsersIdRoute
+ '/issue-6221/article/$id': typeof Issue6221ArticleIdRoute
'/not-found/parent-boundary/via-beforeLoad': typeof NotFoundParentBoundaryViaBeforeLoadRoute
'/posts_/$postId/deep': typeof PostsPostIdDeepRoute
'/redirect/$target/via-beforeLoad': typeof RedirectTargetViaBeforeLoadRoute
@@ -674,6 +692,7 @@ export interface FileRouteTypes {
| '/not-found/parent-boundary'
| '/specialChars/malformed'
| '/api/users'
+ | '/issue-6221/dashboard'
| '/multi-cookie-redirect/target'
| '/not-found/via-beforeLoad'
| '/not-found/via-beforeLoad-target-root'
@@ -704,6 +723,7 @@ export interface FileRouteTypes {
| '/layout-a'
| '/layout-b'
| '/api/users/$id'
+ | '/issue-6221/article/$id'
| '/not-found/parent-boundary/via-beforeLoad'
| '/posts/$postId/deep'
| '/redirect/$target/via-beforeLoad'
@@ -737,6 +757,7 @@ export interface FileRouteTypes {
| '/type-only-reexport'
| '/specialChars/malformed'
| '/api/users'
+ | '/issue-6221/dashboard'
| '/multi-cookie-redirect/target'
| '/not-found/via-beforeLoad'
| '/not-found/via-beforeLoad-target-root'
@@ -766,6 +787,7 @@ export interface FileRouteTypes {
| '/layout-a'
| '/layout-b'
| '/api/users/$id'
+ | '/issue-6221/article/$id'
| '/not-found/parent-boundary/via-beforeLoad'
| '/posts/$postId/deep'
| '/redirect/$target/via-beforeLoad'
@@ -807,6 +829,7 @@ export interface FileRouteTypes {
| '/specialChars/malformed'
| '/_layout/_layout-2'
| '/api/users'
+ | '/issue-6221/dashboard'
| '/multi-cookie-redirect/target'
| '/not-found/via-beforeLoad'
| '/not-found/via-beforeLoad-target-root'
@@ -837,6 +860,7 @@ export interface FileRouteTypes {
| '/_layout/_layout-2/layout-a'
| '/_layout/_layout-2/layout-b'
| '/api/users/$id'
+ | '/issue-6221/article/$id'
| '/not-found/parent-boundary/via-beforeLoad'
| '/posts_/$postId/deep'
| '/redirect/$target/via-beforeLoad'
@@ -876,10 +900,12 @@ export interface RootRouteChildren {
TypeOnlyReexportRoute: typeof TypeOnlyReexportRoute
UsersRoute: typeof UsersRouteWithChildren
ApiUsersRoute: typeof ApiUsersRouteWithChildren
+ Issue6221DashboardRoute: typeof Issue6221DashboardRoute
MultiCookieRedirectTargetRoute: typeof MultiCookieRedirectTargetRoute
RedirectTargetRoute: typeof RedirectTargetRouteWithChildren
MultiCookieRedirectIndexRoute: typeof MultiCookieRedirectIndexRoute
RedirectIndexRoute: typeof RedirectIndexRoute
+ Issue6221ArticleIdRoute: typeof Issue6221ArticleIdRoute
PostsPostIdDeepRoute: typeof PostsPostIdDeepRoute
FooBarQuxHereRoute: typeof FooBarQuxHereRouteWithChildren
}
@@ -1026,6 +1052,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiUsersRouteImport
parentRoute: typeof rootRouteImport
}
+ '/issue-6221/dashboard': {
+ id: '/issue-6221/dashboard'
+ path: '/issue-6221/dashboard'
+ fullPath: '/issue-6221/dashboard'
+ preLoaderRoute: typeof Issue6221DashboardRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/multi-cookie-redirect/': {
id: '/multi-cookie-redirect/'
path: '/multi-cookie-redirect'
@@ -1250,6 +1283,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiUsersIdRouteImport
parentRoute: typeof ApiUsersRoute
}
+ '/issue-6221/article/$id': {
+ id: '/issue-6221/article/$id'
+ path: '/issue-6221/article/$id'
+ fullPath: '/issue-6221/article/$id'
+ preLoaderRoute: typeof Issue6221ArticleIdRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/not-found/deep/': {
id: '/not-found/deep/'
path: '/'
@@ -1648,10 +1688,12 @@ const rootRouteChildren: RootRouteChildren = {
TypeOnlyReexportRoute: TypeOnlyReexportRoute,
UsersRoute: UsersRouteWithChildren,
ApiUsersRoute: ApiUsersRouteWithChildren,
+ Issue6221DashboardRoute: Issue6221DashboardRoute,
MultiCookieRedirectTargetRoute: MultiCookieRedirectTargetRoute,
RedirectTargetRoute: RedirectTargetRouteWithChildren,
MultiCookieRedirectIndexRoute: MultiCookieRedirectIndexRoute,
RedirectIndexRoute: RedirectIndexRoute,
+ Issue6221ArticleIdRoute: Issue6221ArticleIdRoute,
PostsPostIdDeepRoute: PostsPostIdDeepRoute,
FooBarQuxHereRoute: FooBarQuxHereRouteWithChildren,
}
diff --git a/e2e/react-start/basic/src/routes/issue-6221.article.$id.tsx b/e2e/react-start/basic/src/routes/issue-6221.article.$id.tsx
new file mode 100644
index 0000000000..c923a5f1cb
--- /dev/null
+++ b/e2e/react-start/basic/src/routes/issue-6221.article.$id.tsx
@@ -0,0 +1,42 @@
+import { Link, createFileRoute } from '@tanstack/react-router'
+import { createClientOnlyFn } from '@tanstack/react-start'
+
+const isAuthed = createClientOnlyFn(
+ () => localStorage.getItem('issue-6221-auth') === 'good',
+)
+
+const fetchArticle = async (id: string) => {
+ await new Promise((resolve) => setTimeout(resolve, 1000))
+ return isAuthed()
+ ? {
+ title: `Article Title for ${id}`,
+ content: `Article content for ${id}`,
+ }
+ : null
+}
+
+export const Route = createFileRoute('/issue-6221/article/$id')({
+ ssr: false,
+ loader: ({ params }) => fetchArticle(params.id),
+ head: ({ loaderData }) => ({
+ meta: [{ title: loaderData?.title ?? 'title n/a' }],
+ }),
+ component: Article,
+})
+
+function Article() {
+ const article = Route.useLoaderData()
+
+ return (
+
+
+ Dashboard
+
+ {article ? (
+
{article.content}
+ ) : (
+
Article Not Accessible.
+ )}
+
+ )
+}
diff --git a/e2e/react-start/basic/src/routes/issue-6221.dashboard.tsx b/e2e/react-start/basic/src/routes/issue-6221.dashboard.tsx
new file mode 100644
index 0000000000..8c93da669c
--- /dev/null
+++ b/e2e/react-start/basic/src/routes/issue-6221.dashboard.tsx
@@ -0,0 +1,6 @@
+import { createFileRoute } from '@tanstack/react-router'
+
+export const Route = createFileRoute('/issue-6221/dashboard')({
+ head: () => ({ meta: [{ title: 'Dashboard' }] }),
+ component: () => Dashboard
,
+})
diff --git a/e2e/react-start/basic/tests/issue-6221-head.spec.ts b/e2e/react-start/basic/tests/issue-6221-head.spec.ts
new file mode 100644
index 0000000000..96274ce0f9
--- /dev/null
+++ b/e2e/react-start/basic/tests/issue-6221-head.spec.ts
@@ -0,0 +1,41 @@
+import { expect } from '@playwright/test'
+import { test } from '@tanstack/router-e2e-utils'
+import { isPreview } from './utils/isPreview'
+import { isSpaMode } from './utils/isSpaMode'
+
+// Ported to React from the regression app in
+// https://github.com/TanStack/router/pull/6222
+test.skip(
+ isSpaMode || isPreview,
+ 'head() coverage for an ssr:false route requires the SSR test mode',
+)
+
+test('browser back uses fresh loaderData for both the body and head (#6221)', async ({
+ page,
+}) => {
+ await page.goto('/')
+ await page.evaluate(() => localStorage.setItem('issue-6221-auth', 'good'))
+
+ await page.goto('/issue-6221/article/1')
+
+ await expect(page.getByTestId('issue-6221-article')).toHaveText(
+ 'Article content for 1',
+ )
+ await expect(page).toHaveTitle('Article Title for 1')
+
+ await page.evaluate(() => localStorage.removeItem('issue-6221-auth'))
+ await page.reload()
+ await expect(page.getByText('Article Not Accessible.')).toBeVisible()
+ await expect(page).toHaveTitle('title n/a')
+
+ await page.evaluate(() => localStorage.setItem('issue-6221-auth', 'good'))
+ await page.getByTestId('issue-6221-dashboard-link').click()
+ await expect(page.getByTestId('issue-6221-dashboard')).toBeVisible()
+ await expect(page).toHaveTitle('Dashboard')
+
+ await page.goBack()
+ await expect(page.getByTestId('issue-6221-article')).toHaveText(
+ 'Article content for 1',
+ )
+ await expect(page).toHaveTitle('Article Title for 1')
+})
diff --git a/e2e/react-start/selective-ssr/src/routes/__root.tsx b/e2e/react-start/selective-ssr/src/routes/__root.tsx
index 3465abf298..d4e96a27e6 100644
--- a/e2e/react-start/selective-ssr/src/routes/__root.tsx
+++ b/e2e/react-start/selective-ssr/src/routes/__root.tsx
@@ -32,9 +32,7 @@ export const Route = createRootRoute({
}),
validateSearch: z.object({
root: ssrSchema,
- issue4614: z.string().optional(),
}),
- loaderDeps: ({ search }) => ({ issue4614: search.issue4614 }),
ssr: ({ search }) => {
if (typeof window !== 'undefined') {
const error = `ssr() for ${Route.id} should not be called on the client`
@@ -57,21 +55,23 @@ export const Route = createRootRoute({
console.error(error)
throw new Error(error)
}
- const root = typeof window === 'undefined' ? 'server' : 'client'
- const issue4614Context = `${root}:${search.issue4614 ?? 'cached'}`
+ const isClient = typeof window !== 'undefined'
+ const isServer = !isClient
+ const root = isServer ? 'server' : 'client'
if (typeof window !== 'undefined' && location.pathname === '/issue-4614') {
const calls = ((globalThis as any).__issue4614RootBeforeLoads ??= [])
calls.push({
cause,
preload,
- root,
- issue4614Context,
+ isClient,
+ isServer,
})
}
return {
root,
search,
- issue4614Context,
+ isClient,
+ isServer,
}
},
loader: ({ context }) => {
@@ -120,6 +120,12 @@ export const Route = createRootRoute({
context:{' '}
{Route.useRouteContext().root}
+
+ issue #4614 context:{' '}
+
+ {`${Route.useRouteContext().isClient}:${Route.useRouteContext().isServer}`}
+
+
@@ -149,21 +155,8 @@ function RootDocument({ children }: { children: React.ReactNode }) {
>
Home
-
- Issue 4614 cached parent
-
-
- Issue 4614 reloaded parent
+
+ Issue 4614
diff --git a/e2e/react-start/selective-ssr/src/routes/issue-4614.tsx b/e2e/react-start/selective-ssr/src/routes/issue-4614.tsx
index 7e59b8d0ac..5bf4b72a5f 100644
--- a/e2e/react-start/selective-ssr/src/routes/issue-4614.tsx
+++ b/e2e/react-start/selective-ssr/src/routes/issue-4614.tsx
@@ -2,13 +2,12 @@ import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/issue-4614')({
ssr: false,
- beforeLoad: ({ context, cause, preload, search }) => {
+ beforeLoad: ({ context, cause, preload }) => {
;(globalThis as any).__issue4614TargetBeforeLoad = {
cause,
preload,
- rootContext: context.root,
- issue4614Context: context.issue4614Context,
- scenario: search.issue4614 ?? 'cached',
+ isClient: context.isClient,
+ isServer: context.isServer,
}
},
component: () => Issue 4614
,
diff --git a/e2e/react-start/selective-ssr/tests/app.spec.ts b/e2e/react-start/selective-ssr/tests/app.spec.ts
index 89eff32cae..ad85bf3e22 100644
--- a/e2e/react-start/selective-ssr/tests/app.spec.ts
+++ b/e2e/react-start/selective-ssr/tests/app.spec.ts
@@ -4,49 +4,15 @@ import { test } from '@tanstack/router-e2e-utils'
const testCount = 7
test.describe('selective ssr', () => {
- test('#4614: cached parent loader data does not cache its beforeLoad context', async ({
+ test('propagates client root context to an ssr:false child during intent preload (#4614)', async ({
page,
}) => {
await page.goto('/')
- await page.getByTestId('issue-4614-cached-link').hover()
+ await expect(page.getByTestId('issue-4614-root-context')).toHaveText(
+ 'false:true',
+ )
- await expect
- .poll(() =>
- page.evaluate(() => (globalThis as any).__issue4614TargetBeforeLoad),
- )
- .not.toBeUndefined()
-
- const { rootBeforeLoads, targetBeforeLoad } = await page.evaluate(() => {
- const rootBeforeLoads =
- (globalThis as any).__issue4614RootBeforeLoads ?? []
- return {
- rootBeforeLoads,
- targetBeforeLoad: (globalThis as any).__issue4614TargetBeforeLoad,
- }
- })
-
- expect(rootBeforeLoads).toEqual([
- {
- cause: 'preload',
- preload: true,
- root: 'client',
- issue4614Context: 'client:cached',
- },
- ])
- expect(targetBeforeLoad).toEqual({
- cause: 'preload',
- preload: true,
- rootContext: 'client',
- issue4614Context: 'client:cached',
- scenario: 'cached',
- })
- })
-
- test('new loaderDeps match generation propagates fresh parent context to child (control)', async ({
- page,
- }) => {
- await page.goto('/')
- await page.getByTestId('issue-4614-reload-link').hover()
+ await page.getByTestId('issue-4614-link').hover()
await expect
.poll(() =>
@@ -56,8 +22,8 @@ test.describe('selective ssr', () => {
{
cause: 'preload',
preload: true,
- root: 'client',
- issue4614Context: 'client:reload',
+ isClient: true,
+ isServer: false,
},
])
await expect
@@ -67,9 +33,8 @@ test.describe('selective ssr', () => {
.toEqual({
cause: 'preload',
preload: true,
- rootContext: 'client',
- issue4614Context: 'client:reload',
- scenario: 'reload',
+ isClient: true,
+ isServer: false,
})
})
diff --git a/packages/react-router/tests/transitioner-render-ack.test.tsx b/packages/react-router/tests/transitioner-render-ack.test.tsx
index 535a1e0e71..b533c225b3 100644
--- a/packages/react-router/tests/transitioner-render-ack.test.tsx
+++ b/packages/react-router/tests/transitioner-render-ack.test.tsx
@@ -21,7 +21,7 @@ afterEach(() => {
vi.useRealTimers()
})
-test('same-location invalidation resolves after its refreshed DOM commits', async () => {
+test('same-location invalidation emits onResolved after its refreshed DOM commits', async () => {
let generation = 0
const rootRoute = createRootRoute({ component: Outlet })
const indexRoute = createRoute({
@@ -57,31 +57,103 @@ test('same-location invalidation resolves after its refreshed DOM commits', asyn
expect(refreshedDomWasVisible).toEqual([true])
})
-test('an immediately completed background refresh cannot replace the acknowledged foreground generation', async () => {
- let generation = 0
+test('a late background refresh is discarded after foreground navigation commits', async () => {
+ const backgroundRefresh = createControlledPromise()
+ let sourceGeneration = 0
+ const itemLoader = vi.fn((itemId: string) => {
+ if (itemId === 'source') {
+ sourceGeneration++
+ if (sourceGeneration === 1) {
+ return 'initial source data'
+ }
+ return backgroundRefresh
+ }
+ return 'foreground target data'
+ })
const rootRoute = createRootRoute({ component: Outlet })
- const indexRoute = createRoute({
+ const itemRoute = createRoute({
getParentRoute: () => rootRoute,
- path: '/',
+ path: '/items/$itemId',
loader: {
staleReloadMode: 'background',
- handler: () => ++generation,
+ handler: ({ params }) => itemLoader(params.itemId),
},
- component: () => Generation {indexRoute.useLoaderData()}
,
+ component: () => (
+
+ Item {itemRoute.useParams().itemId}: {itemRoute.useLoaderData()}
+
+ ),
})
const router = createRouter({
- routeTree: rootRoute.addChildren([indexRoute]),
- history: createMemoryHistory({ initialEntries: ['/'] }),
+ routeTree: rootRoute.addChildren([itemRoute]),
+ history: createMemoryHistory({ initialEntries: ['/items/source'] }),
})
render()
- expect(await screen.findByText('Generation 1')).toBeInTheDocument()
+ expect(
+ await screen.findByText('Item source: initial source data'),
+ ).toBeInTheDocument()
await waitFor(() => expect(router.state.status).toBe('idle'))
+ const sourceMatchId = router.state.matches.at(-1)!.id
+
+ const eventLog: Array = []
+ const unsubscribers = [
+ router.subscribe('onResolved', (event) => {
+ eventLog.push(`onResolved:${event.toLocation.pathname}`)
+ }),
+ router.subscribe('onRendered', (event) => {
+ eventLog.push(`onRendered:${event.toLocation.pathname}`)
+ }),
+ ]
+ testCleanups.push(...unsubscribers)
await act(() => router.invalidate())
+ expect(itemLoader).toHaveBeenCalledTimes(2)
+
+ await act(() =>
+ router.navigate({
+ to: '/items/$itemId',
+ params: { itemId: 'target' },
+ }),
+ )
+ expect(
+ screen.getByText('Item target: foreground target data'),
+ ).toBeInTheDocument()
+ expect(eventLog.filter((event) => event.endsWith('/items/target'))).toEqual([
+ 'onResolved:/items/target',
+ 'onRendered:/items/target',
+ ])
+ expect(router.state.resolvedLocation?.pathname).toBe('/items/target')
+ const eventCountAfterForegroundCommit = eventLog.length
+
+ await act(async () => {
+ backgroundRefresh.resolve('obsolete source data')
+ await Promise.resolve()
+ })
+ await waitFor(() => {
+ expect(router._flights?.has(sourceMatchId) ?? false).toBe(false)
+ const cachedSourceMatch = router._cache.get(sourceMatchId)
+ expect(cachedSourceMatch?.loaderData).toBe('initial source data')
+ expect(cachedSourceMatch?.isFetching).toBe(false)
+ })
- expect(await screen.findByText('Generation 2')).toBeInTheDocument()
+ expect(
+ screen.getByText('Item target: foreground target data'),
+ ).toBeInTheDocument()
+ expect(
+ screen.queryByText('Item source: obsolete source data'),
+ ).not.toBeInTheDocument()
+ expect(router.state.location.pathname).toBe('/items/target')
+ expect(router.state.resolvedLocation?.pathname).toBe('/items/target')
+ expect(router.state.matches.at(-1)?.routeId).toBe(itemRoute.id)
+ expect(router.state.matches.at(-1)?.params).toEqual({ itemId: 'target' })
+ expect(router.state.matches.at(-1)?.loaderData).toBe('foreground target data')
expect(router.state.status).toBe('idle')
+ expect(
+ eventLog
+ .slice(eventCountAfterForegroundCommit)
+ .every((event) => event.endsWith('/items/target')),
+ ).toBe(true)
})
test('a navigation started by route lifecycle keeps the pending minimum of its own render', async () => {
diff --git a/packages/react-start-client/src/tests/hydrateStart.test.ts b/packages/react-start-client/src/tests/hydrateStart.test.ts
index 2d34400009..51de4981ea 100644
--- a/packages/react-start-client/src/tests/hydrateStart.test.ts
+++ b/packages/react-start-client/src/tests/hydrateStart.test.ts
@@ -14,11 +14,23 @@ afterEach(() => {
test('signals streaming cleanup after hydration succeeds', async () => {
const router = {}
- coreHydrateStart.mockResolvedValue(router)
+ let resolveCoreHydration!: (value: object) => void
+ coreHydrateStart.mockReturnValue(
+ new Promise((resolve) => {
+ resolveCoreHydration = resolve
+ }),
+ )
const hydrated = vi.fn()
window.$_TSR = { h: hydrated } as any
- await expect(hydrateStart()).resolves.toBe(router)
+ const hydration = hydrateStart()
+ await Promise.resolve()
+
+ expect(coreHydrateStart).toHaveBeenCalledOnce()
+ expect(hydrated).not.toHaveBeenCalled()
+
+ resolveCoreHydration(router)
+ await expect(hydration).resolves.toBe(router)
expect(hydrated).toHaveBeenCalledTimes(1)
})
@@ -29,5 +41,7 @@ test('signals streaming cleanup without hiding a hydration failure', async () =>
window.$_TSR = { h: hydrated } as any
await expect(hydrateStart()).rejects.toBe(error)
+
+ expect(coreHydrateStart).toHaveBeenCalledOnce()
expect(hydrated).toHaveBeenCalledTimes(1)
})
diff --git a/packages/router-core/tests/issue-6221-head-waits-for-loader.test.ts b/packages/router-core/tests/issue-6221-head-waits-for-loader.test.ts
deleted file mode 100644
index 55cf11c277..0000000000
--- a/packages/router-core/tests/issue-6221-head-waits-for-loader.test.ts
+++ /dev/null
@@ -1,162 +0,0 @@
-import { createMemoryHistory } from '@tanstack/history'
-import { describe, expect, test, vi } from 'vitest'
-import {
- BaseRootRoute,
- BaseRoute,
- createControlledPromise,
- notFound,
-} from '../src'
-import { createTestRouter } from './routerTestUtils'
-
-// https://github.com/TanStack/router/issues/6221
-//
-// head() must never compute from loaderData older than the data the lane
-// commits. Two reported shapes:
-// 1. Revisiting a route whose previous visit ended in notFound — the head
-// must see the fresh successful loaderData, not run before the loader.
-// 2. Revisiting a cached success match that gets a stale reload — the head
-// title must not lag one loaderData run behind.
-describe('issue #6221: head does not run before loader data is ready', () => {
- test('existing-behavior control: head sees fresh loaderData after a notFound visit', async () => {
- let authed = false
- const articleResponse = createControlledPromise<{ title: string }>()
- const successfulLoadStarted = createControlledPromise()
-
- const articleLoader = async () => {
- if (!authed) {
- await Promise.resolve()
- throw notFound()
- }
-
- successfulLoadStarted.resolve()
- return articleResponse
- }
-
- const rootRoute = new BaseRootRoute({})
- const dashboardRoute = new BaseRoute({
- getParentRoute: () => rootRoute,
- path: '/dashboard',
- })
- const articleRoute = new BaseRoute({
- getParentRoute: () => rootRoute,
- path: '/article',
- loader: articleLoader,
- notFoundComponent: () => 'Not found',
- head: ({ loaderData }) => ({
- meta: [{ title: loaderData?.title ?? 'Generic title' }],
- }),
- })
-
- const router = createTestRouter({
- routeTree: rootRoute.addChildren([dashboardRoute, articleRoute]),
- history: createMemoryHistory({ initialEntries: ['/article'] }),
- })
- const backLoadFinished = createControlledPromise()
- let backLoadStarted = false
- let unsubscribe: (() => void) | undefined
-
- try {
- await router.load()
- const notFoundMatch = router.state.matches.find(
- (match) => match.routeId === articleRoute.id,
- )
- expect(notFoundMatch?.status).toBe('notFound')
- expect(notFoundMatch?.meta).toEqual([{ title: 'Generic title' }])
-
- // Model the reported auth redirect and browser Back without relying on a
- // wall-clock loader delay.
- authed = true
- await router.navigate({ to: '/dashboard' })
- unsubscribe = router.history.subscribe(() => {
- backLoadStarted = true
- void router.load().then(
- () => backLoadFinished.resolve(),
- (error) => backLoadFinished.reject(error),
- )
- })
- router.history.back()
- await successfulLoadStarted
- expect(articleResponse.status).toBe('pending')
-
- articleResponse.resolve({ title: 'Article 123' })
- await backLoadFinished
-
- const articleMatch = router.state.matches.find(
- (match) => match.routeId === articleRoute.id,
- )
- expect(articleMatch?.status).toBe('success')
- expect(articleMatch?.loaderData).toEqual({ title: 'Article 123' })
- expect(articleMatch?.meta).toEqual([{ title: 'Article 123' }])
- } finally {
- articleResponse.resolve({ title: 'Article 123' })
- if (backLoadStarted) {
- await backLoadFinished.catch(() => undefined)
- }
- unsubscribe?.()
- }
- })
-
- test('head title does not lag one loaderData run behind on revisits', async () => {
- const secondLoaderStarted = createControlledPromise()
- const secondLoaderResponse = createControlledPromise<{ author: string }>()
- let version = 0
- const quoteLoader = () => {
- version += 1
- if (version === 2) {
- secondLoaderStarted.resolve()
- return secondLoaderResponse
- }
-
- return { author: 'author-1' }
- }
-
- const rootRoute = new BaseRootRoute({})
- const indexRoute = new BaseRoute({
- getParentRoute: () => rootRoute,
- path: '/',
- })
- const quoteRoute = new BaseRoute({
- getParentRoute: () => rootRoute,
- path: '/quote',
- loader: quoteLoader,
- staleTime: 0,
- head: ({ loaderData }) => ({
- meta: [{ title: `Quote by ${loaderData?.author ?? '...'}` }],
- }),
- })
-
- const router = createTestRouter({
- routeTree: rootRoute.addChildren([indexRoute, quoteRoute]),
- history: createMemoryHistory({ initialEntries: ['/'] }),
- })
-
- const getQuoteMatch = () =>
- router.state.matches.find((match) => match.routeId === quoteRoute.id)
- try {
- await router.load()
-
- await router.navigate({ to: '/quote' })
- expect(getQuoteMatch()?.loaderData).toEqual({ author: 'author-1' })
- expect(getQuoteMatch()?.meta).toEqual([{ title: 'Quote by author-1' }])
-
- await router.navigate({ to: '/' })
- const revisit = router.navigate({ to: '/quote' })
- await secondLoaderStarted
- await revisit
-
- // The cached generation remains internally consistent while its stale
- // loader is pending in the background.
- expect(secondLoaderResponse.status).toBe('pending')
- expect(getQuoteMatch()?.loaderData).toEqual({ author: 'author-1' })
- expect(getQuoteMatch()?.meta).toEqual([{ title: 'Quote by author-1' }])
-
- secondLoaderResponse.resolve({ author: 'author-2' })
- await vi.waitFor(() => {
- expect(getQuoteMatch()?.loaderData).toEqual({ author: 'author-2' })
- expect(getQuoteMatch()?.meta).toEqual([{ title: 'Quote by author-2' }])
- })
- } finally {
- secondLoaderResponse.resolve({ author: 'author-2' })
- }
- })
-})
diff --git a/packages/router-core/tests/public-preload-lane-contract.test.ts b/packages/router-core/tests/public-preload-lane-contract.test.ts
index 42170003ad..7b13e6a0c9 100644
--- a/packages/router-core/tests/public-preload-lane-contract.test.ts
+++ b/packages/router-core/tests/public-preload-lane-contract.test.ts
@@ -245,6 +245,12 @@ describe('public preload lane contracts', () => {
navigationMask: { to: '/mask-b' },
preloadPathname: '/mask-a',
navigationPathname: '/mask-b',
+ preloadUnmaskOnReload: undefined,
+ navigationUnmaskOnReload: undefined,
+ preloadSearchSource: undefined,
+ navigationSearchSource: undefined,
+ preloadStateSource: undefined,
+ navigationStateSource: undefined,
},
{
difference: 'unmaskOnReload',
@@ -252,6 +258,12 @@ describe('public preload lane contracts', () => {
navigationMask: { to: '/mask-a', unmaskOnReload: true },
preloadPathname: '/mask-a',
navigationPathname: '/mask-a',
+ preloadUnmaskOnReload: undefined,
+ navigationUnmaskOnReload: true,
+ preloadSearchSource: undefined,
+ navigationSearchSource: undefined,
+ preloadStateSource: undefined,
+ navigationStateSource: undefined,
},
{
difference: 'search',
@@ -259,6 +271,12 @@ describe('public preload lane contracts', () => {
navigationMask: { to: '/mask-a', search: { source: 'navigation' } },
preloadPathname: '/mask-a',
navigationPathname: '/mask-a',
+ preloadUnmaskOnReload: undefined,
+ navigationUnmaskOnReload: undefined,
+ preloadSearchSource: 'preload',
+ navigationSearchSource: 'navigation',
+ preloadStateSource: undefined,
+ navigationStateSource: undefined,
},
{
difference: 'state',
@@ -266,6 +284,12 @@ describe('public preload lane contracts', () => {
navigationMask: { to: '/mask-a', state: { source: 'navigation' } },
preloadPathname: '/mask-a',
navigationPathname: '/mask-a',
+ preloadUnmaskOnReload: undefined,
+ navigationUnmaskOnReload: undefined,
+ preloadSearchSource: undefined,
+ navigationSearchSource: undefined,
+ preloadStateSource: 'preload',
+ navigationStateSource: 'navigation',
},
])(
'navigation reruns beforeLoad for a different explicit mask $difference on the same destination',
@@ -274,20 +298,16 @@ describe('public preload lane contracts', () => {
navigationMask,
preloadPathname,
navigationPathname,
+ preloadUnmaskOnReload,
+ navigationUnmaskOnReload,
+ preloadSearchSource,
+ navigationSearchSource,
+ preloadStateSource,
+ navigationStateSource,
}) => {
const beforeLoadGate = createControlledPromise()
const loaderGate = createControlledPromise()
- const beforeLoad = vi.fn(
- async (context: {
- location: { maskedLocation?: { pathname: string } }
- preload: boolean
- }) => {
- if (context.preload) {
- await beforeLoadGate
- }
- return { source: context.preload ? 'preload' : 'navigation' }
- },
- )
+ const beforeLoadCalls = vi.fn()
const loader = vi.fn(() => loaderGate)
const rootRoute = new BaseRootRoute({})
const indexRoute = new BaseRoute({
@@ -297,7 +317,24 @@ describe('public preload lane contracts', () => {
const targetRoute = new BaseRoute({
getParentRoute: () => rootRoute,
path: '/target',
- beforeLoad,
+ beforeLoad: async (context) => {
+ const maskedLocation = context.location.maskedLocation
+ beforeLoadCalls({
+ preload: context.preload,
+ pathname: maskedLocation?.pathname,
+ unmaskOnReload: maskedLocation?.unmaskOnReload,
+ searchSource: (
+ maskedLocation?.search as { source?: string } | undefined
+ )?.source,
+ stateSource: (
+ maskedLocation?.state as { source?: string } | undefined
+ )?.source,
+ })
+ if (context.preload) {
+ await beforeLoadGate
+ }
+ return { source: context.preload ? 'preload' : 'navigation' }
+ },
loader,
})
const maskARoute = new BaseRoute({
@@ -327,7 +364,7 @@ describe('public preload lane contracts', () => {
to: '/target',
mask: preloadMask as any,
})
- await vi.waitFor(() => expect(beforeLoad).toHaveBeenCalledTimes(1))
+ await vi.waitFor(() => expect(beforeLoadCalls).toHaveBeenCalledTimes(1))
beforeLoadGate.resolve()
await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(1))
@@ -337,14 +374,21 @@ describe('public preload lane contracts', () => {
mask: navigationMask as any,
})
await vi.waitFor(() =>
- expect(
- beforeLoad.mock.calls.map(([callContext]) => ({
- preload: callContext.preload,
- pathname: callContext.location.maskedLocation?.pathname,
- })),
- ).toEqual([
- { preload: true, pathname: preloadPathname },
- { preload: false, pathname: navigationPathname },
+ expect(beforeLoadCalls.mock.calls.map(([call]) => call)).toEqual([
+ {
+ preload: true,
+ pathname: preloadPathname,
+ unmaskOnReload: preloadUnmaskOnReload,
+ searchSource: preloadSearchSource,
+ stateSource: preloadStateSource,
+ },
+ {
+ preload: false,
+ pathname: navigationPathname,
+ unmaskOnReload: navigationUnmaskOnReload,
+ searchSource: navigationSearchSource,
+ stateSource: navigationStateSource,
+ },
]),
)
@@ -1151,20 +1195,24 @@ describe('public preload lane contracts', () => {
expect(router.state.matches.at(-1)?.loaderData).toBe('loader data 1')
})
- test('filtered invalidation reloads every generation of the selected match id', async () => {
+ test('filtered invalidation invalidates active and cached generations of the selected route', async () => {
const loader = vi.fn(() => `loader data ${loader.mock.calls.length}`)
const rootRoute = new BaseRootRoute({})
const targetRoute = new BaseRoute({
getParentRoute: () => rootRoute,
path: '/target',
+ staleTime: Infinity,
+ preloadStaleTime: Infinity,
+ preloadGcTime: Infinity,
+ gcTime: Infinity,
validateSearch: (search: Record) => ({
revision: Number(search.revision ?? 1),
}),
- shouldReload: ({ location }) =>
- (location.search as { revision: number }).revision === 2
- ? true
- : undefined,
- loader,
+ loaderDeps: ({ search }) => ({ revision: search.revision }),
+ loader: {
+ staleReloadMode: 'blocking',
+ handler: loader,
+ },
})
const router = createTestRouter({
routeTree: rootRoute.addChildren([targetRoute]),
@@ -1178,12 +1226,26 @@ describe('public preload lane contracts', () => {
to: '/target',
search: { revision: 2 },
})
+ expect(loader).toHaveBeenCalledTimes(2)
+
await router.invalidate({
- filter: (match) => (match.search as { revision?: number }).revision === 1,
+ filter: (match) => match.routeId === targetRoute.id,
})
expect(loader).toHaveBeenCalledTimes(3)
expect(router.state.matches.at(-1)?.loaderData).toBe('loader data 3')
+
+ await router.navigate({
+ to: '/target',
+ search: { revision: 2 },
+ })
+
+ expect(loader).toHaveBeenCalledTimes(4)
+ expect(router.state.matches.at(-1)).toMatchObject({
+ invalid: false,
+ loaderData: 'loader data 4',
+ search: { revision: 2 },
+ })
})
test('a hidden terminal suffix does not evict a newer same-id preload', async () => {
diff --git a/packages/solid-start-client/src/tests/hydrateStart.test.ts b/packages/solid-start-client/src/tests/hydrateStart.test.ts
index 2d34400009..51de4981ea 100644
--- a/packages/solid-start-client/src/tests/hydrateStart.test.ts
+++ b/packages/solid-start-client/src/tests/hydrateStart.test.ts
@@ -14,11 +14,23 @@ afterEach(() => {
test('signals streaming cleanup after hydration succeeds', async () => {
const router = {}
- coreHydrateStart.mockResolvedValue(router)
+ let resolveCoreHydration!: (value: object) => void
+ coreHydrateStart.mockReturnValue(
+ new Promise((resolve) => {
+ resolveCoreHydration = resolve
+ }),
+ )
const hydrated = vi.fn()
window.$_TSR = { h: hydrated } as any
- await expect(hydrateStart()).resolves.toBe(router)
+ const hydration = hydrateStart()
+ await Promise.resolve()
+
+ expect(coreHydrateStart).toHaveBeenCalledOnce()
+ expect(hydrated).not.toHaveBeenCalled()
+
+ resolveCoreHydration(router)
+ await expect(hydration).resolves.toBe(router)
expect(hydrated).toHaveBeenCalledTimes(1)
})
@@ -29,5 +41,7 @@ test('signals streaming cleanup without hiding a hydration failure', async () =>
window.$_TSR = { h: hydrated } as any
await expect(hydrateStart()).rejects.toBe(error)
+
+ expect(coreHydrateStart).toHaveBeenCalledOnce()
expect(hydrated).toHaveBeenCalledTimes(1)
})