From c1ba06ab3ffeb637036751840e2c7d80fe7dc4a6 Mon Sep 17 00:00:00 2001 From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:45:31 +0200 Subject: [PATCH 1/3] failing regression test for #4614 --- .../selective-ssr/src/routes/__root.tsx | 37 +++++------- .../selective-ssr/src/routes/issue-4614.tsx | 7 +-- .../selective-ssr/tests/app.spec.ts | 57 ++++--------------- 3 files changed, 29 insertions(+), 72 deletions(-) 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..e926f57354 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(() => @@ -54,10 +20,10 @@ test.describe('selective ssr', () => { ) .toEqual([ { - cause: 'preload', - preload: true, - root: 'client', - issue4614Context: 'client:reload', + cause: 'enter', + preload: false, + 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, }) }) From 185fcccbf0c712c5b456f6acc6f6f7d325cfa7bc Mon Sep 17 00:00:00 2001 From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com> Date: Fri, 24 Jul 2026 00:04:38 +0200 Subject: [PATCH 2/3] failing regressiontest for #6221 --- e2e/react-start/basic/src/routeTree.gen.ts | 42 +++++ .../src/routes/issue-6221.article.$id.tsx | 42 +++++ .../basic/src/routes/issue-6221.dashboard.tsx | 6 + .../basic/tests/issue-6221-head.spec.ts | 41 +++++ .../issue-6221-head-waits-for-loader.test.ts | 162 ------------------ 5 files changed, 131 insertions(+), 162 deletions(-) create mode 100644 e2e/react-start/basic/src/routes/issue-6221.article.$id.tsx create mode 100644 e2e/react-start/basic/src/routes/issue-6221.dashboard.tsx create mode 100644 e2e/react-start/basic/tests/issue-6221-head.spec.ts delete mode 100644 packages/router-core/tests/issue-6221-head-waits-for-loader.test.ts 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/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' }) - } - }) -}) From aba4ddc4bed7adb5908307e98f0fc6a75dfcdefc Mon Sep 17 00:00:00 2001 From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com> Date: Fri, 24 Jul 2026 00:13:01 +0200 Subject: [PATCH 3/3] tests --- .../selective-ssr/tests/app.spec.ts | 4 +- .../tests/transitioner-render-ack.test.tsx | 94 ++++++++++++-- .../src/tests/hydrateStart.test.ts | 18 ++- .../public-preload-lane-contract.test.ts | 118 +++++++++++++----- .../src/tests/hydrateStart.test.ts | 18 ++- 5 files changed, 207 insertions(+), 45 deletions(-) diff --git a/e2e/react-start/selective-ssr/tests/app.spec.ts b/e2e/react-start/selective-ssr/tests/app.spec.ts index e926f57354..ad85bf3e22 100644 --- a/e2e/react-start/selective-ssr/tests/app.spec.ts +++ b/e2e/react-start/selective-ssr/tests/app.spec.ts @@ -20,8 +20,8 @@ test.describe('selective ssr', () => { ) .toEqual([ { - cause: 'enter', - preload: false, + cause: 'preload', + preload: true, 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/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) })