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/5] 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/5] 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/5] 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) }) From 8aa727f415d6c1d49f7ecb126c6f70be152cb065 Mon Sep 17 00:00:00 2001 From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:00:49 +0200 Subject: [PATCH 4/5] fixes --- .changeset/lane-match-loader-rewrite.md | 8 +- docs/router/api/router/RouterType.md | 9 +- docs/router/guide/preloading.md | 9 +- .../src/routeTree.gen.ts | 21 + .../src/routes/__root.tsx | 1 + .../src/routes/preload-disabled.tsx | 6 + .../tests/preload.spec.ts | 25 + .../basic-file-based/src/main.tsx | 4 + .../basic-file-based/tests/mask.spec.ts | 65 + .../issue-7120/src/routeTree.gen.ts | 26 +- .../issue-7457/src/routeTree.gen.ts | 26 +- e2e/react-start/basic/src/routeTree.gen.ts | 21 + .../src/routes/client-context-failure.tsx | 23 + .../tests/client-context-failure.spec.ts | 29 + .../selective-ssr/src/routeTree.gen.ts | 62 +- packages/react-router/src/Matches.tsx | 27 +- packages/react-router/src/Transitioner.tsx | 221 ++- .../react-router/src/headContentUtils.tsx | 129 +- .../react-router/src/lazyRouteComponent.tsx | 39 +- .../tests/component-preload-retry.test.tsx | 66 + .../tests/errorComponent.test.tsx | 46 +- .../issue-4467-lazy-route-pending.test.tsx | 29 +- .../react-render-owner-contract.test.tsx | 140 ++ .../router-client-context-retry.test.tsx | 88 ++ packages/react-router/tests/router.test.tsx | 8 +- .../tests/transitioner-remount.test.tsx | 52 +- .../tests/transitioner-render-ack.test.tsx | 180 ++- packages/router-core/INTERNALS.md | 412 +++--- packages/router-core/src/load-client.ts | 1250 ++++++++--------- packages/router-core/src/router.ts | 186 ++- .../router-core/src/ssr/handlerCallback.ts | 153 +- packages/router-core/src/ssr/server.ts | 1 + .../src/ssr/transformStreamWithRouter.ts | 5 +- packages/router-core/src/stores.ts | 6 +- packages/router-core/src/utils.ts | 9 - .../router-core/tests/build-location.test.ts | 8 +- packages/router-core/tests/callbacks.test.ts | 99 +- .../tests/hmr-refresh-lifecycle.test.ts | 48 +- ...search-default-normalization-abort.test.ts | 109 -- packages/router-core/tests/load.test.ts | 23 +- .../loader-architecture-regressions.test.ts | 577 +++++++- .../masked-location-state-commit.test.ts | 2 +- .../tests/pending-terminal-boundary.test.ts | 103 ++ .../tests/preload-adoption.test.ts | 133 +- .../tests/preload-beforeload-reuse.test.ts | 78 +- .../tests/preload-navigation-adoption.test.ts | 66 +- .../preload-public-cache-behavior.test.ts | 226 ++- .../preload-public-signal-lifetime.test.ts | 20 +- .../tests/public-hydration-contract.test.ts | 167 ++- .../public-preload-lane-contract.test.ts | 1177 ++++++++++------ .../tests/ssr-server-cleanup.test.ts | 270 +++- .../solid-router/src/lazyRouteComponent.tsx | 77 +- .../tests/component-preload-retry.test.tsx | 66 + .../src/client-rpc/frame-decoder.ts | 462 +++--- .../src/client-rpc/serverFnFetcher.ts | 157 +-- packages/start-client-core/src/constants.ts | 8 +- packages/start-client-core/src/index.tsx | 2 + .../tests/frame-decoder.test.ts | 354 ++++- .../tests/serverFnFetcher.test.ts | 395 ++++++ .../src/createStartHandler.ts | 286 ++-- .../start-server-core/src/frame-protocol.ts | 290 ++-- .../src/server-functions-handler.ts | 256 ++-- .../tests/createStartHandler.test.ts | 569 +++++++- .../tests/frame-protocol.test.ts | 456 +++--- .../tests/server-functions-handler.test.ts | 247 ++++ packages/vue-router/src/CatchBoundary.tsx | 45 +- packages/vue-router/src/headContentUtils.tsx | 58 +- .../vue-router/src/lazyRouteComponent.tsx | 118 +- .../vue-router/tests/CatchBoundary.test.tsx | 144 ++ .../tests/component-preload-retry.test.tsx | 234 ++- 70 files changed, 7402 insertions(+), 3310 deletions(-) create mode 100644 e2e/react-router/basic-file-based-code-splitting/src/routes/preload-disabled.tsx create mode 100644 e2e/react-start/basic/src/routes/client-context-failure.tsx create mode 100644 e2e/react-start/basic/tests/client-context-failure.spec.ts create mode 100644 packages/react-router/tests/react-render-owner-contract.test.tsx create mode 100644 packages/react-router/tests/router-client-context-retry.test.tsx delete mode 100644 packages/router-core/tests/issue-6371-search-default-normalization-abort.test.ts create mode 100644 packages/router-core/tests/pending-terminal-boundary.test.ts create mode 100644 packages/start-client-core/tests/serverFnFetcher.test.ts create mode 100644 packages/start-server-core/tests/server-functions-handler.test.ts create mode 100644 packages/vue-router/tests/CatchBoundary.test.tsx diff --git a/.changeset/lane-match-loader-rewrite.md b/.changeset/lane-match-loader-rewrite.md index ddd71fa4c5..3833ca60bc 100644 --- a/.changeset/lane-match-loader-rewrite.md +++ b/.changeset/lane-match-loader-rewrite.md @@ -3,12 +3,18 @@ '@tanstack/react-router': patch '@tanstack/solid-router': patch '@tanstack/vue-router': patch +'@tanstack/router-devtools-core': patch +'@tanstack/react-router-devtools': patch +'@tanstack/solid-router-devtools': patch +'@tanstack/vue-router-devtools': patch +'@tanstack/router-devtools': patch --- Rewrite match loading around a lane-based scheduler that tracks each navigation, preload, and background reload as an ordered unit of work. This fixes pending/redirect/retry state leaking between overlapping navigations, restores correct SSR status codes for redirects, errors, and not-found responses, and closes hydration gaps where the client re-ran work the server had already completed. - Route `headers()` now only runs on the server, matching the documented behavior — it is no longer invoked during client-side asset projection. - Default `gcTime` and `preloadGcTime` are reduced from 30 minutes (`1_800_000`) to 5 minutes (`300_000`). +- Devtools packages are released alongside the router packages so they consume the rewritten router store interface. **Removed / changed public API** @@ -26,4 +32,4 @@ Rewrite match loading around a lane-based scheduler that tracks each navigation, - Removed the exported `GetMatchFn` and `UpdateMatchFn` types, along with the methods they typed. - Removed the standalone `getMatchedRoutes()` export from `@tanstack/router-core` — use the `router.getMatchedRoutes()` instance method instead. - `MatchRoutesOpts.preload` and `MatchRoutesOpts.dest` have been removed. -- `StartTransitionFn` is now `(fn, expected, urgent?) => Promise` (previously `(fn) => void`). This only affects custom framework adapters that implement `startTransition`. +- `StartTransitionFn` is now `(fn, expected) => Promise` (previously `(fn) => void`). This only affects custom framework adapters that implement `startTransition`. diff --git a/docs/router/api/router/RouterType.md b/docs/router/api/router/RouterType.md index 0252eb5b80..9c659fa02c 100644 --- a/docs/router/api/router/RouterType.md +++ b/docs/router/api/router/RouterType.md @@ -134,8 +134,8 @@ protocol. - Type: `(opts?: {filter?: (d: MakeRouteMatchUnion) => boolean, sync?: boolean, forcePending?: boolean }) => Promise` - This is useful any time your loader data might be out of date or stale. For example, if you have a route that displays a list of posts, and you have a loader function that fetches the list of posts from an API, you might want to invalidate the route matches for that route any time a new post is created so that the list of posts is always up-to-date. -- If `filter` is not supplied, all committed and cached match generations are invalidated. -- If `filter` is supplied, it is evaluated against committed and cached matches. Selecting one generation invalidates every committed or cached generation with the same match ID. +- If `filter` is not supplied, all committed and cached match generations are invalidated and all active preloads are canceled. +- If `filter` is supplied, it is evaluated against the available committed, cached, loading, and preloading matches. Selecting one match invalidates every generation with the same match ID. An active preload is canceled when its lane contains a selected match ID. - Invalidation reruns `beforeLoad`; reusable loader data is marked stale and reloads through the normal loading protocol. Route-level `context` remains reusable while the match ID is unchanged. - If `sync` is `true`, stale loader work is blocking and the returned promise resolves after it finishes instead of leaving a background refresh detached. - If `forcePending` is `true`, selected routes that need loading enter the normal pending protocol even when successful data was already available. @@ -169,9 +169,8 @@ presentation. Successful loader data can enter the normal in-memory route cache and remain reusable according to `preloadStaleTime` and `preloadGcTime`. Completed preload `beforeLoad` context is not cached. A later navigation reruns -`beforeLoad` unless it adopts an identical whole-route preload that is still -active. Adoption also requires unchanged router context, additional context, -and user-supplied location state. +`beforeLoad` with navigation semantics. It can still join a pending loader for +the same match ID or reuse completed cached loader data. - Type: `(opts: NavigateOptions) => Promise` - Properties diff --git a/docs/router/guide/preloading.md b/docs/router/guide/preloading.md index 669a11175d..1b13b438b6 100644 --- a/docs/router/guide/preloading.md +++ b/docs/router/guide/preloading.md @@ -26,9 +26,8 @@ two independent lifetimes: - **Unused retention defaults to 5 minutes.** Configure it with `defaultPreloadGcTime` or a route's `preloadGcTime`. - **The speculative lane is never promoted into router state.** Navigation - creates its own presentation and can reuse cached loader data. It reruns - `beforeLoad` unless it adopts the same whole lane while that preload is still - active. + creates its own presentation and reruns `beforeLoad`, but it can reuse cached + loader data or join a loader for the same match that is still pending. If you need more control over preloading, caching and/or garbage collection of preloaded data, you should use an external caching library like [TanStack Query](https://tanstack.com/query). @@ -145,9 +144,7 @@ export const Route = createFileRoute('/posts/$postId')({ Client-side preloading also runs each new route's `beforeLoad` with `preload: true`. A completed preload never caches the context returned by `beforeLoad`; a later navigation calls `beforeLoad` again with `preload: false`, even when it reuses the preload's cached loader data. -There is one exception: if navigation starts while an identical whole-route preload is still running, it can adopt that active work. Compatibility includes the complete ordered route lane, params, search, router context, additional context, user-supplied location state, redirect depth, and any explicit mask's href, search, state, and `unmaskOnReload` value. Router-managed history and temporary-mask keys are ignored. In that case navigation can use the active preload's `beforeLoad` result, or follow its redirect, without calling `beforeLoad` again. A completed, failed, not-found, or canceled preload does not donate `beforeLoad` context to a later navigation. The `shouldReload` option remains loader-only. - -If any route in the lane has `preload: false`, navigation does not adopt the active preload. It reruns the `beforeLoad` chain and performs the loader work that speculation skipped. +A navigation never adopts speculative `beforeLoad` context or control flow. If a loader for the same match ID is already pending, navigation can join that loader promise after running its own `beforeLoad`. The joined promise shares its loader outcome, including an error, not-found result, or redirect. Cancellation remains lane-local: canceling one waiter releases its lease without canceling a generation that another lane still owns. Only successful loader data persists in the loader cache; after a failed preload loader has settled, a later navigation runs a new generation. Invalidation or a `shouldReload` decision can also require a new generation. A route with `preload: false` skips its speculative loader work and loads normally during navigation. ## Preloading with External Libraries diff --git a/e2e/react-router/basic-file-based-code-splitting/src/routeTree.gen.ts b/e2e/react-router/basic-file-based-code-splitting/src/routeTree.gen.ts index c7a15cd84e..613c5a7dae 100644 --- a/e2e/react-router/basic-file-based-code-splitting/src/routeTree.gen.ts +++ b/e2e/react-router/basic-file-based-code-splitting/src/routeTree.gen.ts @@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as IndexRouteImport } from './routes/index' import { Route as LayoutRouteImport } from './routes/_layout' import { Route as PostsRouteImport } from './routes/posts' +import { Route as PreloadDisabledRouteImport } from './routes/preload-disabled' import { Route as SharedSingletonRouteImport } from './routes/shared-singleton' import { Route as ViewportTestRouteImport } from './routes/viewport-test' import { Route as WithoutLoaderRouteImport } from './routes/without-loader' @@ -35,6 +36,11 @@ const PostsRoute = PostsRouteImport.update({ path: '/posts', getParentRoute: () => rootRouteImport, } as any) +const PreloadDisabledRoute = PreloadDisabledRouteImport.update({ + id: '/preload-disabled', + path: '/preload-disabled', + getParentRoute: () => rootRouteImport, +} as any) const SharedSingletonRoute = SharedSingletonRouteImport.update({ id: '/shared-singleton', path: '/shared-singleton', @@ -78,6 +84,7 @@ const LayoutLayout2LayoutBRoute = LayoutLayout2LayoutBRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/posts': typeof PostsRouteWithChildren + '/preload-disabled': typeof PreloadDisabledRoute '/shared-singleton': typeof SharedSingletonRoute '/viewport-test': typeof ViewportTestRoute '/without-loader': typeof WithoutLoaderRoute @@ -88,6 +95,7 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/': typeof IndexRoute + '/preload-disabled': typeof PreloadDisabledRoute '/shared-singleton': typeof SharedSingletonRoute '/viewport-test': typeof ViewportTestRoute '/without-loader': typeof WithoutLoaderRoute @@ -101,6 +109,7 @@ export interface FileRoutesById { '/': typeof IndexRoute '/_layout': typeof LayoutRouteWithChildren '/posts': typeof PostsRouteWithChildren + '/preload-disabled': typeof PreloadDisabledRoute '/shared-singleton': typeof SharedSingletonRoute '/viewport-test': typeof ViewportTestRoute '/without-loader': typeof WithoutLoaderRoute @@ -115,6 +124,7 @@ export interface FileRouteTypes { fullPaths: | '/' | '/posts' + | '/preload-disabled' | '/shared-singleton' | '/viewport-test' | '/without-loader' @@ -125,6 +135,7 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/' + | '/preload-disabled' | '/shared-singleton' | '/viewport-test' | '/without-loader' @@ -137,6 +148,7 @@ export interface FileRouteTypes { | '/' | '/_layout' | '/posts' + | '/preload-disabled' | '/shared-singleton' | '/viewport-test' | '/without-loader' @@ -151,6 +163,7 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute LayoutRoute: typeof LayoutRouteWithChildren PostsRoute: typeof PostsRouteWithChildren + PreloadDisabledRoute: typeof PreloadDisabledRoute SharedSingletonRoute: typeof SharedSingletonRoute ViewportTestRoute: typeof ViewportTestRoute WithoutLoaderRoute: typeof WithoutLoaderRoute @@ -179,6 +192,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PostsRouteImport parentRoute: typeof rootRouteImport } + '/preload-disabled': { + id: '/preload-disabled' + path: '/preload-disabled' + fullPath: '/preload-disabled' + preLoaderRoute: typeof PreloadDisabledRouteImport + parentRoute: typeof rootRouteImport + } '/shared-singleton': { id: '/shared-singleton' path: '/shared-singleton' @@ -279,6 +299,7 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, LayoutRoute: LayoutRouteWithChildren, PostsRoute: PostsRouteWithChildren, + PreloadDisabledRoute: PreloadDisabledRoute, SharedSingletonRoute: SharedSingletonRoute, ViewportTestRoute: ViewportTestRoute, WithoutLoaderRoute: WithoutLoaderRoute, diff --git a/e2e/react-router/basic-file-based-code-splitting/src/routes/__root.tsx b/e2e/react-router/basic-file-based-code-splitting/src/routes/__root.tsx index cb2a94ee19..f42053dad0 100644 --- a/e2e/react-router/basic-file-based-code-splitting/src/routes/__root.tsx +++ b/e2e/react-router/basic-file-based-code-splitting/src/routes/__root.tsx @@ -51,6 +51,7 @@ function RootComponent() { > without-loader {' '} + preload-disabled{' '}
preload disabled
, +}) diff --git a/e2e/react-router/basic-file-based-code-splitting/tests/preload.spec.ts b/e2e/react-router/basic-file-based-code-splitting/tests/preload.spec.ts index 0bc3265760..96f4d834bf 100644 --- a/e2e/react-router/basic-file-based-code-splitting/tests/preload.spec.ts +++ b/e2e/react-router/basic-file-based-code-splitting/tests/preload.spec.ts @@ -40,3 +40,28 @@ test('scrolling into viewport a link with preload=viewport to a route should pre : '/assets/viewport-test' expect(request.url()).toEqual(expect.stringContaining(expectedString)) }) + +test('preload: false skips an auto-code-split component chunk until navigation', async ({ + page, +}) => { + await page.waitForLoadState('networkidle') + const expectedString = + process.env.NODE_ENV === 'development' + ? 'preload-disabled.tsx?tsr-split' + : '/assets/preload-disabled' + const matchingRequests: Array = [] + page.on('request', (request) => { + if (request.url().includes(expectedString)) { + matchingRequests.push(request.url()) + } + }) + + const link = page.getByRole('link', { name: 'preload-disabled' }) + await link.hover() + await page.waitForTimeout(300) + expect(matchingRequests).toEqual([]) + + await link.click() + await expect(page.getByTestId('preload-disabled')).toBeVisible() + expect(matchingRequests.length).toBeGreaterThan(0) +}) diff --git a/e2e/react-router/basic-file-based/src/main.tsx b/e2e/react-router/basic-file-based/src/main.tsx index 12e202dae4..a35a10673e 100644 --- a/e2e/react-router/basic-file-based/src/main.tsx +++ b/e2e/react-router/basic-file-based/src/main.tsx @@ -24,8 +24,12 @@ const router = createRouter({ defaultStaleTime: 5000, scrollRestoration: true, routeMasks: [mask], + unmaskOnReload: true, }) +// Test hook for browser-history integration tests in this fixture. +;(window as any).__TSR_TEST_ROUTER__ = router + // Register things for typesafety declare module '@tanstack/react-router' { interface Register { diff --git a/e2e/react-router/basic-file-based/tests/mask.spec.ts b/e2e/react-router/basic-file-based/tests/mask.spec.ts index 1b77760de6..2af13beed2 100644 --- a/e2e/react-router/basic-file-based/tests/mask.spec.ts +++ b/e2e/react-router/basic-file-based/tests/mask.spec.ts @@ -1,4 +1,17 @@ import { expect, test } from '@playwright/test' +import type { Page } from '@playwright/test' + +async function readLocation(page: Page) { + return page.evaluate(() => { + const location = (window as any).__TSR_TEST_ROUTER__.state.location + return { + pathname: location.pathname, + maskedPathname: location.maskedLocation?.pathname, + maskedSearch: location.maskedLocation?.search, + maskedStateSource: location.maskedLocation?.state?.source, + } + }) +} test('route masks transform params and expose masked pathname in the browser (react)', async ({ page, @@ -24,3 +37,55 @@ test('route masks transform params and expose masked pathname in the browser (re '/masks/public/user-42', ) }) + +test('native history restores a reversible mask across traversal and reload', async ({ + page, +}) => { + await page.goto('/masks') + + await page.evaluate(async () => { + await (window as any).__TSR_TEST_ROUTER__.navigate({ + to: '/masks/admin/$userId', + params: { userId: '42' }, + mask: { + to: '/masks/public/$username', + params: { username: 'user-42' }, + search: { source: 'reversible' }, + state: { source: 'state' }, + unmaskOnReload: false, + }, + }) + }) + + await page.waitForURL('/masks/public/user-42?source=reversible') + await expect(page.getByTestId('admin-user-id')).toHaveText('42') + expect(await readLocation(page)).toEqual({ + pathname: '/masks/admin/42', + maskedPathname: '/masks/public/user-42', + maskedSearch: { source: 'reversible' }, + maskedStateSource: 'state', + }) + + await page.evaluate(async () => { + await (window as any).__TSR_TEST_ROUTER__.navigate({ to: '/masks' }) + }) + await page.waitForURL('/masks') + await page.goBack() + await expect(page.getByTestId('admin-user-id')).toHaveText('42') + expect(await readLocation(page)).toMatchObject({ + pathname: '/masks/admin/42', + maskedPathname: '/masks/public/user-42', + maskedSearch: { source: 'reversible' }, + maskedStateSource: 'state', + }) + + await page.reload() + await expect(page).toHaveURL('/masks/public/user-42?source=reversible') + await expect(page.getByTestId('admin-user-id')).toHaveText('42') + expect(await readLocation(page)).toMatchObject({ + pathname: '/masks/admin/42', + maskedPathname: '/masks/public/user-42', + maskedSearch: { source: 'reversible' }, + maskedStateSource: 'state', + }) +}) diff --git a/e2e/react-router/issue-7120/src/routeTree.gen.ts b/e2e/react-router/issue-7120/src/routeTree.gen.ts index fc671589c7..e8c6d92110 100644 --- a/e2e/react-router/issue-7120/src/routeTree.gen.ts +++ b/e2e/react-router/issue-7120/src/routeTree.gen.ts @@ -9,19 +9,19 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' -import { Route as PostsRouteImport } from './routes/posts' import { Route as IndexRouteImport } from './routes/index' +import { Route as PostsRouteImport } from './routes/posts' -const PostsRoute = PostsRouteImport.update({ - id: '/posts', - path: '/posts', - getParentRoute: () => rootRouteImport, -} as any).lazy(() => import('./routes/posts.lazy').then((d) => d.Route)) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', getParentRoute: () => rootRouteImport, } as any) +const PostsRoute = PostsRouteImport.update({ + id: '/posts', + path: '/posts', + getParentRoute: () => rootRouteImport, +} as any).lazy(() => import('./routes/posts.lazy').then((d) => d.Route)) export interface FileRoutesByFullPath { '/': typeof IndexRoute @@ -51,13 +51,6 @@ export interface RootRouteChildren { declare module '@tanstack/react-router' { interface FileRoutesByPath { - '/posts': { - id: '/posts' - path: '/posts' - fullPath: '/posts' - preLoaderRoute: typeof PostsRouteImport - parentRoute: typeof rootRouteImport - } '/': { id: '/' path: '/' @@ -65,6 +58,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/posts': { + id: '/posts' + path: '/posts' + fullPath: '/posts' + preLoaderRoute: typeof PostsRouteImport + parentRoute: typeof rootRouteImport + } } } diff --git a/e2e/react-router/issue-7457/src/routeTree.gen.ts b/e2e/react-router/issue-7457/src/routeTree.gen.ts index 72a8f23384..04a2c94ce8 100644 --- a/e2e/react-router/issue-7457/src/routeTree.gen.ts +++ b/e2e/react-router/issue-7457/src/routeTree.gen.ts @@ -9,19 +9,19 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' -import { Route as AnotherRouteImport } from './routes/another' import { Route as IndexRouteImport } from './routes/index' +import { Route as AnotherRouteImport } from './routes/another' -const AnotherRoute = AnotherRouteImport.update({ - id: '/another', - path: '/another', - getParentRoute: () => rootRouteImport, -} as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', getParentRoute: () => rootRouteImport, } as any) +const AnotherRoute = AnotherRouteImport.update({ + id: '/another', + path: '/another', + getParentRoute: () => rootRouteImport, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute @@ -51,13 +51,6 @@ export interface RootRouteChildren { declare module '@tanstack/react-router' { interface FileRoutesByPath { - '/another': { - id: '/another' - path: '/another' - fullPath: '/another' - preLoaderRoute: typeof AnotherRouteImport - parentRoute: typeof rootRouteImport - } '/': { id: '/' path: '/' @@ -65,6 +58,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/another': { + id: '/another' + path: '/another' + fullPath: '/another' + preLoaderRoute: typeof AnotherRouteImport + parentRoute: typeof rootRouteImport + } } } diff --git a/e2e/react-start/basic/src/routeTree.gen.ts b/e2e/react-start/basic/src/routeTree.gen.ts index ea09864d8e..27b10415d7 100644 --- a/e2e/react-start/basic/src/routeTree.gen.ts +++ b/e2e/react-start/basic/src/routeTree.gen.ts @@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as IndexRouteImport } from './routes/index' import { Route as LayoutRouteImport } from './routes/_layout' import { Route as AsyncScriptsRouteImport } from './routes/async-scripts' +import { Route as ClientContextFailureRouteImport } from './routes/client-context-failure' import { Route as ClientOnlyRouteImport } from './routes/client-only' import { Route as DeferredRouteImport } from './routes/deferred' import { Route as InlineScriptsRouteImport } from './routes/inline-scripts' @@ -96,6 +97,11 @@ const AsyncScriptsRoute = AsyncScriptsRouteImport.update({ path: '/async-scripts', getParentRoute: () => rootRouteImport, } as any) +const ClientContextFailureRoute = ClientContextFailureRouteImport.update({ + id: '/client-context-failure', + path: '/client-context-failure', + getParentRoute: () => rootRouteImport, +} as any) const ClientOnlyRoute = ClientOnlyRouteImport.update({ id: '/client-only', path: '/client-only', @@ -464,6 +470,7 @@ export interface FileRoutesByFullPath { '/search-params': typeof SearchParamsRouteRouteWithChildren '/specialChars': typeof SpecialCharsRouteRouteWithChildren '/async-scripts': typeof AsyncScriptsRoute + '/client-context-failure': typeof ClientContextFailureRoute '/client-only': typeof ClientOnlyRoute '/deferred': typeof DeferredRoute '/inline-scripts': typeof InlineScriptsRoute @@ -534,6 +541,7 @@ export interface FileRoutesByTo { '/': typeof IndexRoute '/specialChars': typeof SpecialCharsRouteRouteWithChildren '/async-scripts': typeof AsyncScriptsRoute + '/client-context-failure': typeof ClientContextFailureRoute '/client-only': typeof ClientOnlyRoute '/deferred': typeof DeferredRoute '/inline-scripts': typeof InlineScriptsRoute @@ -601,6 +609,7 @@ export interface FileRoutesById { '/specialChars': typeof SpecialCharsRouteRouteWithChildren '/_layout': typeof LayoutRouteWithChildren '/async-scripts': typeof AsyncScriptsRoute + '/client-context-failure': typeof ClientContextFailureRoute '/client-only': typeof ClientOnlyRoute '/deferred': typeof DeferredRoute '/inline-scripts': typeof InlineScriptsRoute @@ -676,6 +685,7 @@ export interface FileRouteTypes { | '/search-params' | '/specialChars' | '/async-scripts' + | '/client-context-failure' | '/client-only' | '/deferred' | '/inline-scripts' @@ -746,6 +756,7 @@ export interface FileRouteTypes { | '/' | '/specialChars' | '/async-scripts' + | '/client-context-failure' | '/client-only' | '/deferred' | '/inline-scripts' @@ -812,6 +823,7 @@ export interface FileRouteTypes { | '/specialChars' | '/_layout' | '/async-scripts' + | '/client-context-failure' | '/client-only' | '/deferred' | '/inline-scripts' @@ -887,6 +899,7 @@ export interface RootRouteChildren { SpecialCharsRouteRoute: typeof SpecialCharsRouteRouteWithChildren LayoutRoute: typeof LayoutRouteWithChildren AsyncScriptsRoute: typeof AsyncScriptsRoute + ClientContextFailureRoute: typeof ClientContextFailureRoute ClientOnlyRoute: typeof ClientOnlyRoute DeferredRoute: typeof DeferredRoute InlineScriptsRoute: typeof InlineScriptsRoute @@ -933,6 +946,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AsyncScriptsRouteImport parentRoute: typeof rootRouteImport } + '/client-context-failure': { + id: '/client-context-failure' + path: '/client-context-failure' + fullPath: '/client-context-failure' + preLoaderRoute: typeof ClientContextFailureRouteImport + parentRoute: typeof rootRouteImport + } '/client-only': { id: '/client-only' path: '/client-only' @@ -1675,6 +1695,7 @@ const rootRouteChildren: RootRouteChildren = { SpecialCharsRouteRoute: SpecialCharsRouteRouteWithChildren, LayoutRoute: LayoutRouteWithChildren, AsyncScriptsRoute: AsyncScriptsRoute, + ClientContextFailureRoute: ClientContextFailureRoute, ClientOnlyRoute: ClientOnlyRoute, DeferredRoute: DeferredRoute, InlineScriptsRoute: InlineScriptsRoute, diff --git a/e2e/react-start/basic/src/routes/client-context-failure.tsx b/e2e/react-start/basic/src/routes/client-context-failure.tsx new file mode 100644 index 0000000000..412cc00e48 --- /dev/null +++ b/e2e/react-start/basic/src/routes/client-context-failure.tsx @@ -0,0 +1,23 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/client-context-failure')({ + context: () => { + if (typeof window !== 'undefined') { + throw new Error('Client context reconstruction failed') + } + return { serverContext: true } + }, + component: ClientContextSuccess, + errorComponent: ({ error }) => ( +
{error.message}
+ ), +}) + +function ClientContextSuccess() { + if (typeof window !== 'undefined') { + const testWindow = window as any + testWindow.__clientContextSuccessRenders = + (testWindow.__clientContextSuccessRenders ?? 0) + 1 + } + return
server success
+} diff --git a/e2e/react-start/basic/tests/client-context-failure.spec.ts b/e2e/react-start/basic/tests/client-context-failure.spec.ts new file mode 100644 index 0000000000..feb36ed1bd --- /dev/null +++ b/e2e/react-start/basic/tests/client-context-failure.spec.ts @@ -0,0 +1,29 @@ +import { expect, test } from '@playwright/test' + +test('client context failure never hydrates transported success', async ({ + page, + request, + baseURL, +}) => { + const response = await request.get(`${baseURL}/client-context-failure`) + expect(response.ok()).toBe(true) + const serverHtml = await response.text() + expect(serverHtml).toContain('data-testid="client-context-success"') + expect(serverHtml).toContain('>server success') + + const pageErrors: Array = [] + page.on('pageerror', (error) => pageErrors.push(error)) + await page.addInitScript(() => { + ;(window as any).__clientContextSuccessRenders = 0 + }) + await page.goto('/client-context-failure') + + await expect(page.getByTestId('client-context-error')).toHaveText( + 'Client context reconstruction failed', + ) + await expect(page.getByTestId('client-context-success')).toHaveCount(0) + expect( + await page.evaluate(() => (window as any).__clientContextSuccessRenders), + ).toBe(0) + expect(pageErrors).toEqual([]) +}) diff --git a/e2e/solid-start/selective-ssr/src/routeTree.gen.ts b/e2e/solid-start/selective-ssr/src/routeTree.gen.ts index 8fb128de38..d0ea8db3a2 100644 --- a/e2e/solid-start/selective-ssr/src/routeTree.gen.ts +++ b/e2e/solid-start/selective-ssr/src/routeTree.gen.ts @@ -9,20 +9,15 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' -import { Route as SsrFalsePendingMinRouteImport } from './routes/ssr-false-pending-min' -import { Route as PostsRouteImport } from './routes/posts' -import { Route as DataOnlyPendingComponentRouteImport } from './routes/data-only-pending-component' import { Route as IndexRouteImport } from './routes/index' +import { Route as DataOnlyPendingComponentRouteImport } from './routes/data-only-pending-component' +import { Route as PostsRouteImport } from './routes/posts' +import { Route as SsrFalsePendingMinRouteImport } from './routes/ssr-false-pending-min' import { Route as PostsPostIdRouteImport } from './routes/posts.$postId' -const SsrFalsePendingMinRoute = SsrFalsePendingMinRouteImport.update({ - id: '/ssr-false-pending-min', - path: '/ssr-false-pending-min', - getParentRoute: () => rootRouteImport, -} as any) -const PostsRoute = PostsRouteImport.update({ - id: '/posts', - path: '/posts', +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', getParentRoute: () => rootRouteImport, } as any) const DataOnlyPendingComponentRoute = @@ -31,9 +26,14 @@ const DataOnlyPendingComponentRoute = path: '/data-only-pending-component', getParentRoute: () => rootRouteImport, } as any) -const IndexRoute = IndexRouteImport.update({ - id: '/', - path: '/', +const PostsRoute = PostsRouteImport.update({ + id: '/posts', + path: '/posts', + getParentRoute: () => rootRouteImport, +} as any) +const SsrFalsePendingMinRoute = SsrFalsePendingMinRouteImport.update({ + id: '/ssr-false-pending-min', + path: '/ssr-false-pending-min', getParentRoute: () => rootRouteImport, } as any) const PostsPostIdRoute = PostsPostIdRouteImport.update({ @@ -97,18 +97,11 @@ export interface RootRouteChildren { declare module '@tanstack/solid-router' { interface FileRoutesByPath { - '/ssr-false-pending-min': { - id: '/ssr-false-pending-min' - path: '/ssr-false-pending-min' - fullPath: '/ssr-false-pending-min' - preLoaderRoute: typeof SsrFalsePendingMinRouteImport - parentRoute: typeof rootRouteImport - } - '/posts': { - id: '/posts' - path: '/posts' - fullPath: '/posts' - preLoaderRoute: typeof PostsRouteImport + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } '/data-only-pending-component': { @@ -118,11 +111,18 @@ declare module '@tanstack/solid-router' { preLoaderRoute: typeof DataOnlyPendingComponentRouteImport parentRoute: typeof rootRouteImport } - '/': { - id: '/' - path: '/' - fullPath: '/' - preLoaderRoute: typeof IndexRouteImport + '/posts': { + id: '/posts' + path: '/posts' + fullPath: '/posts' + preLoaderRoute: typeof PostsRouteImport + parentRoute: typeof rootRouteImport + } + '/ssr-false-pending-min': { + id: '/ssr-false-pending-min' + path: '/ssr-false-pending-min' + fullPath: '/ssr-false-pending-min' + preLoaderRoute: typeof SsrFalsePendingMinRouteImport parentRoute: typeof rootRouteImport } '/posts/$postId': { diff --git a/packages/react-router/src/Matches.tsx b/packages/react-router/src/Matches.tsx index d9977db272..a555848535 100644 --- a/packages/react-router/src/Matches.tsx +++ b/packages/react-router/src/Matches.tsx @@ -8,10 +8,11 @@ import { CatchBoundary } from './CatchBoundary' import { useRouter } from './useRouter' import { useStructuralSharing } from './useMatch' import { useLayoutEffect } from './utils' -import { Transitioner } from './Transitioner' +import { Transitioner, clearOwner } from './Transitioner' import { matchContext } from './matchContext' import { Match, renderPending } from './Match' import { SafeFragment } from './SafeFragment' +import type { ReactRenderOwner } from './Transitioner' import type { StructuralSharingOption, ValidateSelected, @@ -47,6 +48,9 @@ declare module '@tanstack/router-core' { */ export function Matches() { const router = useRouter() + // Stable render receipt: Transitioner offers a generation and MatchesInner + // acknowledges that exact array after it commits. + const [owner] = React.useState([]) const rootRoute: AnyRoute = router.routesById[rootRouteId] const pendingElement = renderPending(router, rootRoute) @@ -57,9 +61,9 @@ export function Matches() { const inner = ( <> - {!(isServer ?? router.isServer) && } + {!(isServer ?? router.isServer) && } - + ) @@ -71,19 +75,24 @@ export function Matches() { ) } -function MatchesInner() { +function MatchesInner({ owner }: { owner: ReactRenderOwner }) { const router = useRouter() - const matches = + const presentation = (isServer ?? router.isServer) ? router.stores.matches.get() : // eslint-disable-next-line react-hooks/rules-of-hooks - useStore(router.stores.matches, (value) => value) - const match = matches[0] + useStore( + router.stores.ids, + () => owner[0] ?? router.stores.matches.get(), + ) + const match = presentation[0] const routeId = match?.routeId useLayoutEffect(() => { - router._rendered!(matches) - }, [matches, router]) + if (owner[0] === presentation) { + clearOwner(owner)?.(true) + } + }, [owner, presentation]) const matchComponent = routeId ? : null diff --git a/packages/react-router/src/Transitioner.tsx b/packages/react-router/src/Transitioner.tsx index acbb60b5bf..e65d76012e 100644 --- a/packages/react-router/src/Transitioner.tsx +++ b/packages/react-router/src/Transitioner.tsx @@ -6,137 +6,130 @@ import { useLayoutEffect } from './utils' import { useRouter } from './useRouter' import type { AnyRouteMatch } from '@tanstack/router-core' -export function Transitioner() { - const router = useRouter() - const acknowledgement = React.useRef< - [Array, (rendered: boolean) => void] | undefined - >(undefined) - const mounted = - process.env.NODE_ENV !== 'production' - ? // eslint-disable-next-line react-hooks/rules-of-hooks - React.useRef(false) - : undefined +export type ReactRenderOwner = [ + offered?: Array, + settle?: (rendered: boolean) => void, + active?: boolean, +] - // `` precedes ``, so install the render - // acknowledgement before the latter can publish a rendered lane. - router._rendered = (matches) => { - const current = acknowledgement.current - if ( - current?.[0].length === matches.length && - current[0].every( - (match, index) => - match.id === matches[index]!.id && - match.abortController === matches[index]!.abortController && - match.status === matches[index]!.status, - ) - ) { - acknowledgement.current = undefined - current[1](true) - } +export function clearOwner( + owner: ReactRenderOwner, + current = owner[1], +): typeof current { + if (owner[1] === current) { + owner[0] = owner[1] = undefined + return current } - if (process.env.NODE_ENV === 'production') { - router.startTransition = (fn, expected, urgent) => - new Promise((resolve) => { - acknowledgement.current?.[1](false) - acknowledgement.current = [expected, resolve] - if (urgent) { - fn() - } else { + return undefined +} + +export function Transitioner({ owner }: { owner: ReactRenderOwner }) { + const router = useRouter() + + // Subscribe before canonicalizing so the initial URL has exactly one load. + useLayoutEffect(() => { + // Suspense and Activity can reconnect effects without rendering again. + const initialized = owner[2] !== undefined + owner[2] = true + router.startTransition = async (fn, expected) => { + if (!owner[2]) { + fn() + return false + } + + return new Promise((resolve, reject) => { + clearOwner(owner)?.(false) + owner[0] = expected + owner[1] = resolve + if (process.env.NODE_ENV === 'production') { React.startTransition(fn) + } else { + // React captures transition-action errors, so HMR must catch inside it. + React.startTransition(() => { + try { + fn() + } catch (cause) { + clearOwner(owner, resolve) + reject(cause) + } + }) } }) - } else { - router.startTransition = (fn, expected, urgent) => - new Promise((resolve, reject) => { - acknowledgement.current?.[1](false) - const next: NonNullable = [ - expected, - resolve, - ] - acknowledgement.current = next - try { - if (urgent) { - fn() - } else { - React.startTransition(fn) - } - } catch (cause) { - if (acknowledgement.current === next) { - acknowledgement.current = undefined - } - reject(cause) - } - }) + } ;( router as typeof router & { _cancelTransition?: () => void } - )._cancelTransition = () => { - const current = acknowledgement.current - acknowledgement.current = undefined - current?.[1](false) - } - } - - // Subscribe before canonicalizing so the initial URL has exactly one load. - useLayoutEffect(() => { + )._cancelTransition = () => clearOwner(owner)?.(false) const unsub = router.history.subscribe(router.load) - if (mounted?.current) { - return unsub - } - if (mounted) { - mounted.current = true - } - - router.updateLatestLocation() - const location = router.latestLocation - const nextLocation = router.buildLocation({ - to: location.pathname, - search: true, - params: true, - hash: true, - state: true, - _includeValidateSearch: true, - }) - - // Check if the current URL matches the canonical form. - // Compare publicHref (browser-facing URL) consistently with server - // canonicalization. if ( - trimPathRight(location.publicHref) !== - trimPathRight(nextLocation.publicHref) + !initialized || + router.history.location.state.__TSR_key !== + router.latestLocation.state.__TSR_key ) { - router.commitLocation({ - ...nextLocation, - replace: true, - ignoreBlocker: true, + router.updateLatestLocation() + const location = router.latestLocation + const nextLocation = router.buildLocation({ + to: location.pathname, + search: true, + params: true, + hash: true, + state: true, + _includeValidateSearch: true, }) - return unsub - } - const resolvedLocation = router.stores.resolvedLocation.get() - if ( - resolvedLocation?.href === location.href && - resolvedLocation.state.__TSR_key === location.state.__TSR_key - ) { - acknowledgement.current = [ - router.stores.matches.get(), - (rendered) => { - if (rendered) { - router.emit({ - type: 'onRendered', - ...getLocationChangeInfo(resolvedLocation, resolvedLocation), - }) + // Check if the current URL matches the canonical form. + // Compare publicHref (browser-facing URL) consistently with server + // canonicalization. + if ( + trimPathRight(location.publicHref) !== + trimPathRight(nextLocation.publicHref) + ) { + router.commitLocation({ + ...nextLocation, + replace: true, + ignoreBlocker: true, + }) + } else { + const resolvedLocation = router.stores.resolvedLocation.get() + if ( + resolvedLocation?.href === location.href && + resolvedLocation.state.__TSR_key === location.state.__TSR_key + ) { + if (!owner[1]) { + owner[0] = router.stores.matches.get() + owner[1] = (rendered) => { + if (rendered) { + router.emit({ + type: 'onRendered', + ...getLocationChangeInfo(resolvedLocation, resolvedLocation), + }) + } + } } - }, - ] - } else if (!router._tx) { - router.load().catch(console.error) + } else if (initialized || !router._tx) { + router.load().catch(console.error) + } + } } - return unsub - // `mounted` exists only in development and is a stable ref when present. + return () => { + unsub() + owner[2] = false + if (process.env.NODE_ENV === 'production') { + clearOwner(owner)?.(false) + } else { + const current = owner[1] + // StrictMode replays effects without another render. Its second setup + // reactivates this owner before the microtask; a real unmount does not. + queueMicrotask(() => { + if (!owner[2]) { + clearOwner(owner, current)?.(false) + } + }) + } + } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [router, router.history]) + }, [owner, router, router.history]) return null } diff --git a/packages/react-router/src/headContentUtils.tsx b/packages/react-router/src/headContentUtils.tsx index d61ca829db..2c91ba7e62 100644 --- a/packages/react-router/src/headContentUtils.tsx +++ b/packages/react-router/src/headContentUtils.tsx @@ -24,25 +24,24 @@ function buildTagsFromMatches( assetCrossOrigin?: AssetCrossOriginConfig, ): Array { matches = _getAssetMatches(matches) - const routeMeta = matches - .map((match) => match.meta) - .filter((meta) => meta !== undefined) - const resultMeta: Array = [] const metaByAttribute: Record = {} let title: RouterManagedTag | undefined - for (let i = routeMeta.length - 1; i >= 0; i--) { - const metas = routeMeta[i]! + for (let i = matches.length - 1; i >= 0; i--) { + const metas = matches[i]!.meta + if (!metas) { + continue + } for (let j = metas.length - 1; j >= 0; j--) { const m = metas[j] - if (!m) continue + if (!m) { + continue + } if (m.title) { - if (!title) { - title = { - tag: 'title', - children: m.title, - } + title ??= { + tag: 'title', + children: m.title, } } else if ('script:ld+json' in m) { try { @@ -62,9 +61,8 @@ function buildTagsFromMatches( if (attribute) { if (metaByAttribute[attribute]) { continue - } else { - metaByAttribute[attribute] = true } + metaByAttribute[attribute] = true } resultMeta.push({ @@ -93,23 +91,24 @@ function buildTagsFromMatches( } resultMeta.reverse() - const constructedLinks = matches - .flatMap((match) => match.links ?? []) - .filter((link) => link !== undefined) - .map((link) => ({ - tag: 'link', - attrs: { - ...link, - nonce, - }, - })) satisfies Array - const manifest = router.ssr?.manifest + const constructedLinks: Array = [] const manifestCssTags: Array = [] - if (manifest) { - matches.forEach((match) => { - const css = manifest.routes[match.routeId]?.css - css?.forEach((link) => { + const preloadLinks: Array = [] + const styles: Array = [] + const headScripts: Array = [] + for (const match of matches) { + for (const link of match.links ?? []) { + if (link) { + constructedLinks.push({ + tag: 'link', + attrs: { ...link, nonce }, + }) + } + } + const manifestRoute = manifest?.routes[match.routeId] + if (manifestRoute) { + for (const link of manifestRoute.css ?? []) { const resolvedLink = resolveManifestCssLink(link) manifestCssTags.push({ tag: 'link', @@ -123,26 +122,8 @@ function buildTagsFromMatches( nonce, }, }) - }) - }) - - if (manifest.inlineStyle) { - manifestCssTags.push({ - tag: 'style', - attrs: { - ...manifest.inlineStyle.attrs, - nonce, - }, - children: manifest.inlineStyle.children, - inlineCss: true, - }) - } - } - - const preloadLinks: Array = [] - if (manifest) { - matches.forEach((match) => { - manifest.routes[match.routeId]?.preloads?.forEach((preload) => { + } + for (const preload of manifestRoute.preloads ?? []) { preloadLinks.push({ tag: 'link', attrs: { @@ -150,33 +131,41 @@ function buildTagsFromMatches( nonce, }, }) - }) - }) + } + } + for (const style of match.styles ?? []) { + if (style) { + const { children, ...attrs } = style + styles.push({ + tag: 'style', + attrs: { ...attrs, nonce }, + children: children as string | undefined, + }) + } + } + for (const script of match.headScripts ?? []) { + if (script) { + const { children, ...attrs } = script + headScripts.push({ + tag: 'script', + attrs: { ...attrs, nonce }, + children: children as string | undefined, + }) + } + } } - const styles = matches - .flatMap((match) => match.styles ?? []) - .filter((style) => style !== undefined) - .map(({ children, ...attrs }) => ({ + if (manifest?.inlineStyle) { + manifestCssTags.push({ tag: 'style', attrs: { - ...attrs, - nonce, - }, - children: children as string | undefined, - })) satisfies Array - - const headScripts = matches - .flatMap((match) => match.headScripts ?? []) - .filter((script) => script !== undefined) - .map(({ children, ...script }) => ({ - tag: 'script', - attrs: { - ...script, + ...manifest.inlineStyle.attrs, nonce, }, - children: children as string | undefined, - })) satisfies Array + children: manifest.inlineStyle.children, + inlineCss: true, + }) + } const tags: Array = [] appendUniqueUserTags(tags, resultMeta) diff --git a/packages/react-router/src/lazyRouteComponent.tsx b/packages/react-router/src/lazyRouteComponent.tsx index 7c387e0e31..d9352ce763 100644 --- a/packages/react-router/src/lazyRouteComponent.tsx +++ b/packages/react-router/src/lazyRouteComponent.tsx @@ -22,31 +22,32 @@ export function lazyRouteComponent< ): T[TKey] extends (props: infer TProps) => any ? AsyncRouteComponent : never { - let loadPromise: Promise | undefined + let loadPromise: Promise | undefined let comp: T[TKey] | T['default'] let error: any const load = () => { - if (!loadPromise) { - error = undefined - loadPromise = importer() - .then((res) => { - // Keep browser preload behavior unchanged; SSR can reuse the import. - if (!(isServer ?? typeof window === 'undefined')) { - loadPromise = undefined - } - comp = res[exportName ?? 'default'] - }) - .catch((err) => { - loadPromise = undefined - // We don't want an error thrown from preload in this case, because - // there's nothing we want to do about module not found during preload. - // Record the error, the rest is handled during the render path. - error = err - }) + if (loadPromise) { + return loadPromise } - return loadPromise + error = undefined + return (loadPromise = importer() + .then((res) => { + // Keep browser preload behavior unchanged; SSR can reuse the import. + if (!(isServer ?? typeof window === 'undefined')) { + loadPromise = undefined + } + comp = res[exportName ?? 'default'] + }) + .catch((err) => { + loadPromise = undefined + error = err + + if (!isModuleNotFoundError(err)) { + throw err + } + })) } const lazyComp = function Lazy(props: any) { diff --git a/packages/react-router/tests/component-preload-retry.test.tsx b/packages/react-router/tests/component-preload-retry.test.tsx index 2ce819f1bb..26cf472602 100644 --- a/packages/react-router/tests/component-preload-retry.test.tsx +++ b/packages/react-router/tests/component-preload-retry.test.tsx @@ -3,6 +3,7 @@ import { afterEach, expect, test, vi } from 'vitest' import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { RouterProvider, + createControlledPromise, createMemoryHistory, createRootRoute, createRoute, @@ -31,6 +32,21 @@ test('a successful server component download is reused', async () => { expect(importer).toHaveBeenCalledTimes(1) }) +test('concurrent component preloads share the import', async () => { + const componentImport = createControlledPromise<{ + default: () => null + }>() + const importer = vi.fn(() => componentImport) + const Page = lazyRouteComponent(importer) + + const first = Page.preload?.() + expect(Page.preload?.()).toBe(first) + expect(importer).toHaveBeenCalledOnce() + + componentImport.resolve({ default: () => null }) + await first +}) + test('a failed component download is retried from the route error UI', async () => { vi.spyOn(console, 'error').mockImplementation(() => {}) @@ -112,3 +128,53 @@ test('renders after retrying a module download that failed during preload', asyn expect(await screen.findByText('Page content')).toBeInTheDocument() expect(importer).toHaveBeenCalledTimes(2) }) + +test('retries a module download from the error UI after reloading once', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + + const failure = new TypeError( + 'Failed to fetch dynamically imported module: /assets/page.js', + ) + sessionStorage.setItem(`tanstack_router_reload:${failure.message}`, '1') + + const PageContent = () =>
Page content
+ const importer = vi + .fn<() => Promise<{ default: typeof PageContent }>>() + .mockRejectedValueOnce(failure) + .mockResolvedValue({ default: PageContent }) + const Page = lazyRouteComponent(importer) + + function RouteError({ reset }: ErrorComponentProps) { + const router = useRouter() + return ( + + ) + } + + const rootRoute = createRootRoute() + const pageRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/page', + component: Page, + errorComponent: RouteError, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + }) + + render() + + fireEvent.click(await screen.findByRole('button', { name: 'Retry' })) + + expect(await screen.findByText('Page content')).toBeInTheDocument() + expect(importer).toHaveBeenCalledTimes(2) +}) diff --git a/packages/react-router/tests/errorComponent.test.tsx b/packages/react-router/tests/errorComponent.test.tsx index b5ee4b6709..25e52259a4 100644 --- a/packages/react-router/tests/errorComponent.test.tsx +++ b/packages/react-router/tests/errorComponent.test.tsx @@ -774,12 +774,25 @@ test('#4684: SSR renders head content when beforeLoad throws', async () => { }) describe('notFoundComponent is rendered when an error is thrown in params.parse', () => { - test('displays notFoundComponent when error is thrown in params.parse', async () => { + test('renders a params.parse notFound without joining a pending intent-preload ancestor loader', async () => { const history = createMemoryHistory({ initialEntries: ['/'] }) - const rootLoader = vi.fn(async () => { - await new Promise((resolve) => setTimeout(resolve, 1)) - return { ok: true } - }) + const preloadGate = createControlledPromise() + let preloadController: AbortController | undefined + const rootLoader = vi.fn( + async ({ + preload, + abortController, + }: { + preload: boolean + abortController: AbortController + }) => { + if (preload) { + preloadController = abortController + await preloadGate + } + return { ok: true, preload } + }, + ) const rootRoute = createRootRoute({ component: function Root() { @@ -804,7 +817,7 @@ describe('notFoundComponent is rendered when an error is thrown in params.parse' component: function Home() { return (
- + link to rotten pizza
@@ -859,12 +872,31 @@ describe('notFoundComponent is rendered when an error is thrown in params.parse' expect(rootLoader).toHaveBeenCalledTimes(1) expect(linkToRottenPizza).toBeInTheDocument() await act(() => fireEvent.mouseOver(linkToRottenPizza)) + await vi.waitFor(() => expect(rootLoader).toHaveBeenCalledTimes(2)) await act(() => fireEvent.click(linkToRottenPizza)) + await vi.waitFor(() => expect(router.state.status).toBe('pending')) + + expect(preloadGate.status).toBe('pending') + expect(preloadController?.signal.aborted).toBe(false) + + await vi.waitFor(() => expect(rootLoader).toHaveBeenCalledTimes(3)) + expect(rootLoader.mock.calls.map(([context]) => context.preload)).toEqual([ + false, + true, + false, + ]) const notFoundComponent = await screen.findByText('No pizza', undefined, { timeout: 750, }) - expect(rootLoader).toHaveBeenCalledTimes(2) + expect(router.state.status).toBe('idle') + expect(preloadGate.status).toBe('pending') + expect(preloadController?.signal.aborted).toBe(false) expect(notFoundComponent).toBeInTheDocument() + + await act(async () => { + preloadGate.resolve() + await Promise.resolve() + }) }) }) diff --git a/packages/react-router/tests/issue-4467-lazy-route-pending.test.tsx b/packages/react-router/tests/issue-4467-lazy-route-pending.test.tsx index e05d2ee792..074816153f 100644 --- a/packages/react-router/tests/issue-4467-lazy-route-pending.test.tsx +++ b/packages/react-router/tests/issue-4467-lazy-route-pending.test.tsx @@ -87,9 +87,19 @@ test('a lazy pending component is offered while the eager loader is still pendin component: () =>

Index page

, }) const loader = createControlledPromise() + const componentPreload = createControlledPromise() + const pendingComponentPreload = createControlledPromise() + const preloadComponent = vi.fn(() => componentPreload) + const preloadPendingComponent = vi.fn(() => pendingComponentPreload) + const Page = Object.assign(() =>

Page

, { + preload: preloadComponent, + }) + const Pending = Object.assign(() =>

Loading lazy page

, { + preload: preloadPendingComponent, + }) const lazyPageOptions = createLazyRoute('/page')({ - pendingComponent: () =>

Loading lazy page

, - component: () =>

Page

, + pendingComponent: Pending, + component: Page, }) const lazyOptions = createControlledPromise() const pageRoute = createRoute({ @@ -121,6 +131,18 @@ test('a lazy pending component is offered while the eager loader is still pendin await act(async () => { lazyOptions.resolve(lazyPageOptions) + await Promise.resolve() + }) + + expect(preloadComponent).toHaveBeenCalledOnce() + expect(preloadPendingComponent).toHaveBeenCalledOnce() + expect(componentPreload.status).toBe('pending') + expect(pendingComponentPreload.status).toBe('pending') + expect(screen.getByRole('status')).toHaveTextContent('Loading default') + + await act(async () => { + pendingComponentPreload.resolve() + await Promise.resolve() }) expect(await screen.findByRole('status')).toHaveTextContent( @@ -131,6 +153,7 @@ test('a lazy pending component is offered while the eager loader is still pendin ).not.toBeInTheDocument() await act(async () => { + componentPreload.resolve() loader.resolve() await navigation }) @@ -139,6 +162,8 @@ test('a lazy pending component is offered while the eager loader is still pendin } finally { await act(async () => { lazyOptions.resolve(lazyPageOptions) + componentPreload.resolve() + pendingComponentPreload.resolve() loader.resolve() await navigation }) diff --git a/packages/react-router/tests/react-render-owner-contract.test.tsx b/packages/react-router/tests/react-render-owner-contract.test.tsx new file mode 100644 index 0000000000..0324ae38c3 --- /dev/null +++ b/packages/react-router/tests/react-render-owner-contract.test.tsx @@ -0,0 +1,140 @@ +import { act } from 'react' +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import { afterEach, expect, test, vi } from 'vitest' +import { + Outlet, + RouterProvider, + createControlledPromise, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +const testCleanups: Array<() => void> = [] + +afterEach(() => { + while (testCleanups.length) { + testCleanups.pop()!() + } + cleanup() +}) + +test('a suspended same-membership publication cannot acknowledge its successor', async () => { + const firstRenderStarted = createControlledPromise() + const firstRenderGate = createControlledPromise() + let signaledFirstRender = false + + const rootRoute = createRootRoute({ + validateSearch: (search: Record) => ({ + revision: Number(search.revision), + }), + component: () => { + const revision = rootRoute.useSearch().revision + if (revision === 1 && firstRenderGate.status === 'pending') { + if (!signaledFirstRender) { + signaledFirstRender = true + firstRenderStarted.resolve() + } + throw firstRenderGate + } + return
Root revision {revision}
+ }, + }) + const router = createRouter({ + routeTree: rootRoute, + history: createMemoryHistory({ initialEntries: ['/?revision=0'] }), + }) + + render() + expect(await screen.findByText('Root revision 0')).toBeInTheDocument() + await waitFor(() => expect(router.state.status).toBe('idle')) + const initialIds = router.state.matches.map((match) => match.routeId) + + const renderedRevisions: Array = [] + testCleanups.push( + router.subscribe('onRendered', (event) => { + renderedRevisions.push( + Number((event.toLocation.search as Record).revision), + ) + }), + ) + + let firstNavigation!: Promise + await act(async () => { + firstNavigation = router.navigate({ + to: '/', + search: { revision: 1 }, + }) + await firstRenderStarted + }) + + const firstSettled = vi.fn() + void firstNavigation.then(firstSettled) + expect(router.state.matches.map((match) => match.routeId)).toEqual(initialIds) + expect(router.state.matches[0]?.search.revision).toBe(1) + expect(screen.getByText('Root revision 0')).toBeInTheDocument() + expect(firstSettled).not.toHaveBeenCalled() + expect(renderedRevisions).toEqual([]) + + try { + await act(() => + router.navigate({ + to: '/', + search: { revision: 2 }, + }), + ) + await firstNavigation + + expect(screen.getByText('Root revision 2')).toBeInTheDocument() + expect(router.state.matches.map((match) => match.routeId)).toEqual( + initialIds, + ) + expect(renderedRevisions).toEqual([2]) + expect(firstSettled).toHaveBeenCalledOnce() + } finally { + await act(async () => { + firstRenderGate.resolve() + await Promise.resolve() + }) + } + + expect(screen.getByText('Root revision 2')).toBeInTheDocument() + expect(renderedRevisions).toEqual([2]) +}) + +test('a development HMR lifecycle failure rolls back before recovery', async () => { + let generation = 1 + const rootRoute = createRootRoute({ component: Outlet }) + const pageRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/page', + loader: () => generation, + component: () =>
Page generation {pageRoute.useLoaderData()}
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + }) + + render() + expect(await screen.findByText('Page generation 1')).toBeInTheDocument() + await waitFor(() => expect(router.state.status).toBe('idle')) + + generation = 2 + pageRoute.options.onStay = () => { + throw new Error('hot route failed') + } + await act(() => router._refreshRoute!()) + + expect(screen.getByText('Page generation 1')).toBeInTheDocument() + expect(router.state.matches.at(-1)?.loaderData).toBe(1) + expect(router.state.status).toBe('idle') + + pageRoute.options.onStay = undefined + generation = 3 + await act(() => router._refreshRoute!()) + + expect(screen.getByText('Page generation 3')).toBeInTheDocument() + expect(router.state.matches.at(-1)?.loaderData).toBe(3) +}) diff --git a/packages/react-router/tests/router-client-context-retry.test.tsx b/packages/react-router/tests/router-client-context-retry.test.tsx new file mode 100644 index 0000000000..580d529a6d --- /dev/null +++ b/packages/react-router/tests/router-client-context-retry.test.tsx @@ -0,0 +1,88 @@ +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { hydrate } from '@tanstack/router-core/ssr/client' +import { dehydrateSsrMatchId } from '../../router-core/src/ssr/ssr-match-id' +import { + Outlet, + RouterProvider, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +afterEach(() => { + cleanup() + delete window.$_TSR +}) + +test('a RouterProvider mounted after router hydration never renders transported success after client context reconstruction fails', async () => { + const serverRootRoute = createRootRoute({ component: Outlet }) + const serverPageRoute = createRoute({ + getParentRoute: () => serverRootRoute, + path: '/page', + context: () => ({ clientReady: true }), + component: () =>
Server page
, + }) + const serverRouter = createRouter({ + routeTree: serverRootRoute.addChildren([serverPageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + }) + serverRouter.isServer = true + await serverRouter.load() + const serverMatches = serverRouter.stores.matches.get() + expect(serverMatches.at(-1)).toMatchObject({ status: 'success' }) + + const contextError = new Error('Client context failed') + const pageRenders = vi.fn() + const clientRootRoute = createRootRoute({ component: Outlet }) + const clientPageRoute = createRoute({ + getParentRoute: () => clientRootRoute, + path: '/page', + context: () => { + throw contextError + }, + component: () => { + const context = clientRouter.state.matches.at(-1)?.context + pageRenders(context) + return context?.clientReady ? ( +
Client page
+ ) : ( +
Missing client context
+ ) + }, + errorComponent: ({ error }) =>
{error.message}
, + }) + const clientRouter = createRouter({ + routeTree: clientRootRoute.addChildren([clientPageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + }) + window.$_TSR = { + router: { + manifest: { routes: {} }, + dehydratedData: {}, + matches: serverMatches.map((match) => ({ + i: dehydrateSsrMatchId(match.id), + u: match.updatedAt, + s: match.status, + l: match.loaderData, + e: match.error, + ssr: match.ssr, + })), + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + initialized: false, + } + + await hydrate(clientRouter) + render() + + expect(await screen.findByText(contextError.message)).toBeInTheDocument() + expect(screen.queryByText('Missing client context')).not.toBeInTheDocument() + expect(screen.queryByText('Client page')).not.toBeInTheDocument() + expect(pageRenders).not.toHaveBeenCalled() +}) diff --git a/packages/react-router/tests/router.test.tsx b/packages/react-router/tests/router.test.tsx index 43c4d47423..bfa89a73c1 100644 --- a/packages/react-router/tests/router.test.tsx +++ b/packages/react-router/tests/router.test.tsx @@ -2701,10 +2701,16 @@ describe('notFound in beforeLoad with pendingComponent', () => { } } finally { try { + // Navigation resolves from the final React layout acknowledgement. + // Do not await it inside the act callback that first makes that + // render possible, or fake timers and the concurrent render deadlock. + vi.useRealTimers() await act(async () => { beforeLoad.resolve() - await navigation + await Promise.resolve() }) + await act(async () => {}) + await navigation } finally { vi.useRealTimers() } diff --git a/packages/react-router/tests/transitioner-remount.test.tsx b/packages/react-router/tests/transitioner-remount.test.tsx index 76cc6393ce..e0b6122ec6 100644 --- a/packages/react-router/tests/transitioner-remount.test.tsx +++ b/packages/react-router/tests/transitioner-remount.test.tsx @@ -1,5 +1,7 @@ +import { useState } from 'react' +import { flushSync } from 'react-dom' import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, render, screen, waitFor } from '@testing-library/react' import { Outlet, RouterProvider, @@ -11,6 +13,7 @@ import { afterEach(() => { cleanup() + vi.unstubAllEnvs() }) function setup() { @@ -88,4 +91,51 @@ describe('Transitioner remount', () => { loadSpy.mockRestore() }) + + it.each(['development', 'production'] as const)( + 'settles an outstanding render when the provider unmounts in %s', + async (nodeEnv) => { + vi.stubEnv('NODE_ENV', nodeEnv) + + const { router } = setup() + let setMounted!: (mounted: boolean) => void + function Harness() { + const [mounted, set] = useState(true) + setMounted = set + return mounted ? : null + } + + render() + expect(await screen.findByText('Index')).toBeInTheDocument() + await waitFor(() => expect(router.state.status).toBe('idle')) + + const rendered = vi.fn() + const navigationSettled = vi.fn() + const unsubscribers = [ + router.subscribe('onRendered', (event) => { + if (event.toLocation.pathname === '/next') { + rendered() + } + }), + router.subscribe('onBeforeRouteMount', (event) => { + if (event.toLocation.pathname === '/next') { + expect(navigationSettled).not.toHaveBeenCalled() + flushSync(() => setMounted(false)) + } + }), + ] + + void router.navigate({ to: '/next' }).then(navigationSettled) + await waitFor(() => expect(navigationSettled).toHaveBeenCalledOnce()) + expect(rendered).not.toHaveBeenCalled() + + await act(() => setMounted(true)) + expect(await screen.findByText('Next')).toBeInTheDocument() + await waitFor(() => expect(rendered).toHaveBeenCalledOnce()) + + for (const unsubscribe of unsubscribers) { + unsubscribe() + } + }, + ) }) diff --git a/packages/react-router/tests/transitioner-render-ack.test.tsx b/packages/react-router/tests/transitioner-render-ack.test.tsx index b533c225b3..29c3b05554 100644 --- a/packages/react-router/tests/transitioner-render-ack.test.tsx +++ b/packages/react-router/tests/transitioner-render-ack.test.tsx @@ -1,4 +1,4 @@ -import { StrictMode, act } from 'react' +import { Activity, StrictMode, act, lazy } from 'react' import { cleanup, render, screen, waitFor } from '@testing-library/react' import { afterEach, expect, test, vi } from 'vitest' import { @@ -19,6 +19,7 @@ afterEach(() => { } cleanup() vi.useRealTimers() + vi.unstubAllEnvs() }) test('same-location invalidation emits onResolved after its refreshed DOM commits', async () => { @@ -74,6 +75,7 @@ test('a late background refresh is discarded after foreground navigation commits const itemRoute = createRoute({ getParentRoute: () => rootRoute, path: '/items/$itemId', + staleTime: Infinity, loader: { staleReloadMode: 'background', handler: ({ params }) => itemLoader(params.itemId), @@ -94,7 +96,6 @@ test('a late background refresh is discarded after foreground navigation commits 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 = [ @@ -130,12 +131,6 @@ test('a late background refresh is discarded after foreground navigation commits 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( screen.getByText('Item target: foreground target data'), @@ -154,6 +149,18 @@ test('a late background refresh is discarded after foreground navigation commits .slice(eventCountAfterForegroundCommit) .every((event) => event.endsWith('/items/target')), ).toBe(true) + + await act(() => + router.navigate({ + to: '/items/$itemId', + params: { itemId: 'source' }, + }), + ) + expect( + screen.getByText('Item source: initial source data'), + ).toBeInTheDocument() + expect(screen.queryByText('Item source: obsolete source data')).toBeNull() + expect(itemLoader).toHaveBeenCalledTimes(3) }) test('a navigation started by route lifecycle keeps the pending minimum of its own render', async () => { @@ -190,6 +197,7 @@ test('a navigation started by route lifecycle keeps the pending minimum of its o render() expect(await screen.findByText('Index')).toBeInTheDocument() await waitFor(() => expect(router.state.status).toBe('idle')) + vi.useFakeTimers() let firstNavigation!: Promise @@ -266,3 +274,159 @@ test('StrictMode effect replay preserves renderer commit sequencing', async () = expect(screen.getByText('Next')).toBeInTheDocument() expect(screen.queryByText('Index')).not.toBeInTheDocument() }) + +test('Activity reconnects render acknowledgement before the next navigation', async () => { + vi.stubEnv('NODE_ENV', 'production') + + const rootRoute = createRootRoute({ component: Outlet }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Index
, + }) + const nextRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/next', + component: () =>
Next
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, nextRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + const provider = + const view = render({provider}) + + expect(await screen.findByText('Index')).toBeInTheDocument() + await waitFor(() => expect(router.state.status).toBe('idle')) + + const rendered = vi.fn() + const unsubscribe = router.subscribe('onRendered', (event) => { + if (event.toLocation.pathname === '/next') { + rendered(screen.queryByText('Next') !== null) + } + }) + testCleanups.push(unsubscribe) + + view.rerender({provider}) + expect(screen.getByText('Index')).not.toBeVisible() + view.rerender({provider}) + expect(screen.getByText('Index')).toBeVisible() + + await act(() => router.navigate({ to: '/next' })) + + expect(screen.getByText('Next')).toBeVisible() + expect(rendered).toHaveBeenCalledOnce() + expect(rendered).toHaveBeenCalledWith(true) + + view.unmount() + vi.unstubAllEnvs() +}) + +test('Activity catches up with history changes made while hidden', async () => { + const rootRoute = createRootRoute({ component: Outlet }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Index
, + }) + const nextRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/next', + component: () =>
Next
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, nextRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + const provider = + const view = render({provider}) + + expect(await screen.findByText('Index')).toBeInTheDocument() + await waitFor(() => expect(router.state.status).toBe('idle')) + + view.rerender({provider}) + expect(screen.getByText('Index')).not.toBeVisible() + + await act(() => router.history.push('/next')) + expect(router.history.location.href).toBe('/next') + expect(router.latestLocation.pathname).toBe('/') + + view.rerender({provider}) + + expect(await screen.findByText('Next')).toBeVisible() + expect(router.latestLocation.pathname).toBe('/next') +}) + +test('Activity does not restart a navigation observed before it was hidden', async () => { + const next = createControlledPromise() + const loader = vi.fn(() => next) + const rootRoute = createRootRoute({ component: Outlet }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Index
, + }) + const nextRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/next', + loader, + component: () =>
Next
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, nextRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + const provider = + const view = render({provider}) + + expect(await screen.findByText('Index')).toBeInTheDocument() + await waitFor(() => expect(router.state.status).toBe('idle')) + + let navigation!: Promise + act(() => { + navigation = router.navigate({ to: '/next' }) + }) + await waitFor(() => expect(loader).toHaveBeenCalledOnce()) + + view.rerender({provider}) + view.rerender({provider}) + expect(loader).toHaveBeenCalledOnce() + + await act(async () => { + next.resolve() + await navigation + }) + expect(screen.getByText('Next')).toBeVisible() +}) + +test('an initial canceled pending publication restores the empty presentation', async () => { + let offeredRouteId: string | undefined + const offeredRoute = { readId: (): string | undefined => undefined } + const suspendedPending = createControlledPromise() + const Pending = lazy(async () => { + await suspendedPending + return { default: () =>
Initial pending
} + }) + const rootRoute = createRootRoute({ + pendingMs: 0, + pendingMinMs: 0, + pendingComponent: Pending, + beforeLoad: ({ abortController }) => { + offeredRouteId = offeredRoute.readId() + abortController.abort() + }, + component: () =>
Ready
, + }) + const router = createRouter({ + routeTree: rootRoute, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + offeredRoute.readId = () => router.state.matches[0]?.routeId + + render() + + await waitFor(() => expect(offeredRouteId).toBe(rootRoute.id)) + await waitFor(() => expect(router.state.status).toBe('idle')) + expect(router.state.matches).toEqual([]) + expect(screen.queryByText('Something went wrong!')).not.toBeInTheDocument() +}) diff --git a/packages/router-core/INTERNALS.md b/packages/router-core/INTERNALS.md index d8efe633cf..5a8dc15597 100644 --- a/packages/router-core/INTERNALS.md +++ b/packages/router-core/INTERNALS.md @@ -34,31 +34,29 @@ The main authorities are: | `_committed` | The accepted current lane and lifecycle/background CAS base | | `stores.matches` | The current match presentation exposed to renderers and users | | `router._cache` | Off-screen loader generations and first same-ID planning seeds | -| Active preload entry | One still-running speculative whole lane | -| Loader flight registry entry | The latest same-ID loader generation available to new consumers | -| Match flight lease | Ownership keeping that loader work alive | +| `router._flights` | The discoverable loader generation currently producing a cache key | +| Active preload entry | One running speculative lane registered for cancellation | +| Match flight lease | Ownership keeping one match's loader generation alive | | Pending session | One reveal/minimum-visible deadline and its current owner | | React acknowledgement slot | The one requested publication whose render may settle a transition | | Request signal | Lifetime of one server request and any accepted SSR stream | | Accepted SSR stream response | Cleanup ownership transferred from the handler to the response body | These authorities are related, but none is a substitute for another. In -particular, presentation is not semantic authority, registry membership is not -resource ownership, and a promise settling is not permission to publish. +particular, presentation is not semantic authority, a cached generation is not +permission to skip a requested reload, and a promise settling is not permission +to publish. ## Code map - `src/router.ts` matches locations, creates match objects, owns public router state, history, cache operations, and entry points into loading. - `src/stores.ts` reconciles route-keyed presentation stores and their ordered - aggregate. + aggregate. It owns no React acknowledgement pointer. - `src/load-client.ts` owns client planning, transactions, preloads, loader - flights, reduction, pending presentation, background reloads, and commits. + flights, route/component readiness, reduction, pending presentation, + background reloads, hydration reconstruction, and commits. - `src/load-server.ts` runs the request-local server lane. -- `src/route-chunks.ts` owns lazy route-option installation and component - readiness without adding a JavaScript-module cache. -- `src/hydrate.ts` reconstructs the server-resolved prefix and the initial - selective-SSR presentation. - `src/ssr/createRequestHandler.ts` connects request lifetime, server loading, dehydration, redirects, rendering, and cleanup. - `src/ssr/handlerCallback.ts`, `ssr-server.ts`, and @@ -121,8 +119,8 @@ across match generations and A-to-B-to-A membership changes. `ids` is published before departure tombstones so framework trees stop reading a leaving route before its atom is cleared. The pool retains atoms for route IDs encountered during the router's lifetime; it is not an LRU or a cache of match generations. -Semantic caches and loader flights remain keyed by match ID and must not use the -presentation pool as their authority. +Semantic cache entries remain keyed by match ID; loader flights belong to exact +match generations. Neither may use the presentation pool as its authority. This distinction matters for user code. Once the destination is presented, a selector can inspect every destination match and observe `isFetching` while the @@ -202,8 +200,7 @@ reuse an older merged context or `beforeLoad` contribution. Server requests have fresh matches and execute route context normally. Hydration executes each accepted route context locally, stores its `_ctx`, and -then merges transported `beforeLoad` output. An active identical preload donates -the whole lane it is still contextualizing. +then merges transported `beforeLoad` output. ### Reduced @@ -282,7 +279,7 @@ Every asynchronous client publication checks `_tx` immediately before the write. Background publication additionally checks the exact committed base array from which it was derived. -## `beforeLoad`: execution, active adoption, and hydration +## `beforeLoad`: execution and hydration `beforeLoad` context is not a cache. @@ -292,59 +289,16 @@ same-ID route-local `_ctx` may remain reusable. A later navigation rebuilds the merged context from the current parent and `_ctx`, then reruns `beforeLoad`, even when it reuses completed loader data. -There are two deliberate exceptions. - -### Identical active whole-lane adoption - -A navigation may latch onto an entry in `router._preloads` that is still running -only when the whole lane is identical. Admission is decided before rematching -from the preload's href and redirect depth, route-tree identity, semantic owner, -parsed search, router root context, additional context, user-supplied location -state, and any explicit mask's href, search, user state, and -`unmaskOnReload`. Router-managed history and temporary-mask keys do not -participate. With deterministic route planning, those inputs imply the same -ordered routes, params, validated search, and global-path-miss meaning. They may -be observed by `beforeLoad` even when the route lane is unchanged. -Adoption inputs are treated as immutable values: replace a context/state object -to express a generation change; in-place mutation is not detected. -Adoption is all-or-nothing. There is no prefix donation and no -completed-preload freshness window for `beforeLoad`. Matching derives -global-path-miss semantics for every lane, including preloads, so an otherwise -identical candidate cannot erase that terminal meaning during adoption. - -The active preload also captures the identity of `_committed` from which -it was planned. Whole-lane adoption requires that semantic owner to remain -current. Invalidation replaces the committed generation, so an older preload -may still donate an identical loader flight but cannot donate its pre-invalidation -`beforeLoad` context. - -The adopting navigation takes ownership of the active lane and its controller. -The original public preload call must then stop releasing that lane. Individual -successful loader tasks may still publish cache entries through their original -per-match compare-and-swap; that publication belongs to the transferred lane, -not to a second whole-lane owner. - -If the adopted speculative lane succeeds, the navigation may use the -`beforeLoad` calls that ran with `preload: true`. A redirect from that same -active lane is also accepted as navigation control flow. An ordinary failure, -not-found, or cancellation is not authoritative for navigation: the real -navigation replans and reruns `beforeLoad` with `preload: false`. Successful -loader work anywhere in the settled lane remains eligible for reuse, including -a descendant that completed after an ancestor loader failed. Each successful -loader generation has already attempted its own cache publication at settlement, -so retry does not scan the terminal lane or create another cache handoff. It -discards the failed speculative semantic lane and replans cache-first. The retry -rebuilds the serial context chain and reruns `beforeLoad`; only independently -successful loader generations cross that boundary through the normal -cache/flight rules. - -Routes with `preload: false` do not permit active whole-lane adoption. Their -speculative `beforeLoad` may run with `preload: true`, but navigation reruns the -serial chain and performs the skipped loader work. - -Rejecting whole-lane adoption does not require discarding loader work before -the real lane reaches its reload decisions. The rejected preload may remain a -resource-only donor until that lane settles. +Navigation always builds its own context chain and calls `beforeLoad` with +`preload: false`, even while an identical preload is still running. This keeps +guard context and control flow owned by the operation that will publish them. +A same-ID loader generation is the narrower sharing boundary: navigation may +join its pending promise or reuse its accepted cache result without inheriting +the preload lane's `beforeLoad` context or control outcome. Joining a loader +promise does observe that loader generation's complete normalized outcome, +including error, not-found, or redirect. Cancellation is lane-local: a canceled +waiter releases its lease without canceling a generation that another lane +still owns. ### Hydration @@ -358,8 +312,8 @@ Hydration is not a general `beforeLoad` cache. Its temporary handoff is valid only for the initial client load of the same document entry and exact committed owner; rejection, invalidation, or any later load returns to normal serial execution. The claim also requires the same route tree, root/additional context, -search, user history state, and exact browser history generation. That initial -load may transfer the accepted hydration prefix and +internal href, browser-facing public href, and parsed history-state object. That +initial load may transfer the accepted hydration prefix and keep its transported context while it completes a selective-SSR suffix. A preload never claims this prefix. Frameworks must start the initial client load before descendant route code can preload; invoking a preload in the gap after @@ -391,7 +345,7 @@ generations when its error or not-found came from `beforeLoad`, validation, or another route. Each preload loader success attempts cache admission immediately, before whole-lane reduction. The cache receives a non-terminal copy and an additional flight lease; the speculative lane keeps its own lease and terminal -meaning until it is adopted or discarded. This works even below the eventual +meaning until it is discarded. This works even below the eventual render boundary and does not preserve the speculative parent chain: merged context and `beforeLoad` output are cleared. Same-ID `_ctx`, loader identity, and successful loader data remain reusable by design. @@ -417,36 +371,31 @@ been removed; the merged chain is rebuilt from current parents and same-ID route-local context before `beforeLoad` reruns. Committing a lane removes cache entries for its IDs. -### Same-id in-flight work - -`router._flights` is the only registry from which a new consumer discovers -same-ID loader work. Every new loader generation registers there, whether it -was started by navigation, preload, or background refresh. A newer generation -replaces the registry entry synchronously. A flight has its own abort -controller; it does not use one consumer transaction's controller as its -lifetime. - -Two facts must remain separate: - -- registry membership means new consumers may join the flight; -- a match lease means an existing consumer keeps the flight alive. - -Registry membership itself owns no lease. A settled generation remains -discoverable while at least one semantic or cached match owns it. Releasing the -last lease removes that generation only if it is still the current registry -entry, then aborts its controller. Every copied semantic match that retains a -flight must acquire a lease, and every discarded match must release one exactly -once. Consequently, a flight referenced by a match or discoverable in the -registry always has a positive lease count. Acquisition code must not recover -from a referenced zero-lease flight; that would hide an ownership bug. +### Match-owned loader work + +Every loader execution creates a flight containing its outcome promise, +controller, lease count, and discovery registry. The originating match owns the +first lease. A semantic or cached copy that keeps the same generation alive +acquires another; every discarded copy releases exactly once. The last release +removes that exact generation from discovery and aborts its controller. + +`router._flights` is the pending analogue of `router._cache`. Both are keyed by +match ID: a distinct lane rebuilds context and reruns `beforeLoad`, then may +lease the pending same-ID loader generation if its reload policy would accept +that work. A lease joins the complete pending loader outcome, including error, +not-found, or redirect; the recipient does not rerun that loader generation. +Lane cancellation only releases that lane's lease. Only success persists as +reusable cache data. Once a terminal generation has settled and left discovery, +a later lane runs its own loader. +Public invalidation and cache clearing revoke affected discovery entries before +old owners are detached, so pre-boundary work cannot supply a later generation. Releasing a set of matches is deliberately two-phase. First every outgoing -match drops its `_flight` lease and every zero-owner generation is removed from -the discovery registry. Only after the entire outgoing set is detached are the -collected flight controllers aborted. Do not interleave one flight abort with -detaching later matches in the same replacement: an abort listener can -synchronously reenter, and that load must observe every logically removed lease -and registry entry as already gone. +match drops its `_flight` lease. Only after the entire outgoing set is detached +are the collected flight controllers aborted. Do not interleave one flight +abort with detaching later matches in the same replacement: an abort listener +can synchronously reenter, and that load must observe every logically removed +lease as already gone. A successful accepted match may keep its lease after the loader promise has settled. This keeps the loader's public `AbortSignal` alive for that semantic @@ -460,26 +409,17 @@ rejection must not call user error hooks. This is an ownership check, not a separate cancellation flag. A loader that aborts its own flight controller while its match remains owned can still fulfill or reject normally. -A planned match holds only the lease for the accepted generation copied from -cache or committed state. After `beforeLoad` and `shouldReload` decide that a -loader will run, it may synchronously acquire the registry's latest different -same-ID generation. It never reacquires its own accepted generation merely to -avoid a requested reload. A blocking reload replaces the accepted lease with -the donor; a background reload keeps accepted data visible and gives the donor -lease to its private candidate. This one lookup covers work started by active -preloads, navigation, and background refresh without scanning those owners. - -When a different lane supersedes an active preload at the same href, the old -lane stops being adoptable but retains its resources until the successor lane -settles. Its leased flights therefore remain discoverable for the successor's -late reload decisions without giving every planned match a second reservation -lease. The successor then releases the old lane; any borrowed flight remains -alive through the successor match's ordinary `_flight` lease. - -A joining navigation accepts successful shared loader work. A failure, -not-found, or cancellation produced under another speculative generation is not -allowed to govern it; the navigation releases that flight and registers its own -generation. Redirect remains control flow and is never cached as loader data. +A planned match may hold a lease for an accepted generation copied from cache or +committed state. Once `beforeLoad` and `shouldReload` decide that a loader must +run, the lane releases that accepted lease and creates its own flight. A +blocking reload owns the new flight on the planned match; a background reload +keeps accepted data visible and owns the new flight on its private candidate. + +Each `preloadRoute` call owns its context and `beforeLoad` lane. Active entries +exist only so invalidation and cache clearing can retire those lanes. They do +not deduplicate by href. Pending same-ID loader work is shared independently +through `_flights`, including a loader redirect. `beforeLoad` control flow stays +lane-local, and redirects are never cached as loader data. ### Semantic parent chain @@ -658,11 +598,12 @@ Each reentrant callback can start another navigation. A currentness check after each publication boundary suppresses stale later events. In particular, an `onResolved` navigation suppresses the old transaction's `onRendered`. -Route lifecycle callbacks are invoked directly and are expected not to throw. -The coordinator does not carry a second error-handling path for callback -failures. All `onLeave` callbacks run before callbacks for retained or newly -entered routes; the relative ordering of `onEnter` and `onStay` is not a public -contract. +Ordinary route lifecycle callback failures are logged and isolated per callback, +so one user callback cannot interrupt the remaining callbacks or leave a commit +half-resolved. HMR refresh deliberately propagates lifecycle failures so its +publication checkpoint can roll the generation back. All `onLeave` callbacks +run before callbacks for retained or newly entered routes; the relative ordering +of `onEnter` and `onStay` is not a public contract. Server render results are published request-locally and run the documented route `onLeave`/`onEnter`/`onStay` callbacks against the previous server @@ -734,10 +675,17 @@ the superseded navigation. Changing the boundary discards the old session. - settlement means the framework transition can no longer block navigation completion; -- `true` means the exact requested match publication rendered; +- `true` means the exact requested match publication selected by React reached + the `MatchesInner` layout-commit phase; - `false` means core must finish without emitting `onRendered` or starting a pending minimum based on that publication. +This is publication-shell exactness, not proof that every nested Suspense +boundary revealed new child content. A route-owned Suspense boundary can retain +its previous child DOM while the surrounding `MatchesInner` publication commits +and acknowledges. Review code that uses `onRendered` for descendant DOM work +with that distinction in mind. + React cannot await `React.startTransition` directly. Its adapter has one current acknowledgement slot, and `Matches` settles that slot from a layout effect. A new expected publication first settles the previous slot as `false`, then installs @@ -745,36 +693,55 @@ itself before the transition callback runs. This ordering matters because publication can invoke a route lifecycle callback that synchronously starts and publishes a successor navigation. Installing the expectation after the callback would let the superseded publication replace the successor's slot. -The lifetime-owned Transitioner installs these callbacks during render, before -the sibling match tree can register its acknowledgement effect. +The mounted-tree-owned Transitioner precedes the sibling match tree, so its +layout effect installs these callbacks before the match tree can acknowledge. + +In development HMR, the transition callback is wrapped by a `try`/`catch` inside +the function passed to `React.startTransition`. React 19 captures callback +throws instead of rethrowing them from the outer `React.startTransition` call. +The development-only wrapper therefore clears only its exact owner and rejects +the adapter promise so the HMR transaction can restore `_committed`. Ordinary +production lifecycle and router-event callbacks are already isolated, so the +production adapter calls `React.startTransition` directly. An already-settled router mounted into React uses the same acknowledgement slot -for its initial `onRendered` event. It therefore emits only after the exact match -tree and its descendant layout effects have committed, rather than from the -earlier history-subscription effect. - -A generation is its array length plus the ordered sequence of match ID, -execution-controller identity, and render status. These are existing semantic -facts rather than a separate generation counter. A new transaction or -background candidate changes controller identity; pending-to-terminal -publication changes status. Object reference equality is deliberately not -required because `isFetching` and per-match store reconciliation can replace -objects without changing the logical publication. An exact signature settles -the current slot as `true`. A mismatched render is ignored; only installing a -successor settles the current slot as `false`. +for its initial `onRendered` event. It therefore emits from the matching +`MatchesInner` layout commit rather than from the earlier history-subscription +effect. Descendant layout effects in that commit run first, subject to the +nested-Suspense qualification above. + +`stores.matches` is the public presented match view, while `stores.ids` is the +ordered route-membership and publication notification used by framework +renderers. Every publication writes its already-created route-ID array, even +when membership is unchanged. Per-route stores can still replace live match +objects for `isFetching` without acknowledging another semantic publication. + +React keeps the exact offered match-array pointer in a small owner local to the +mounted `Matches` tree. It captures that pointer during render and compares it +by identity in a layout effect. An exact render settles the current slot as +`true`; a mismatched render is ignored. This pointer is neither public match +state nor semantic authority, and it does not exist in router-core. + +Direct core restoration clears an outstanding React offer before publishing +the restored stores. This matters on a canceled first load: the accepted lane +is empty, so leaving the offered root armed while tombstoning its route atom +would make React try to render a route that no longer has a presented match. Solid awaits its transition, and Vue awaits its render tick. For an already-settled Solid mount, history subscription remains before the route tree while a post-match notifier emits the initial `onRendered` event after -descendant mount effects. The architecture assumes exactly one Transitioner is -mounted per router and remains mounted for the lifetime of the application. Its -history subscription and acknowledgement machinery are therefore -lifetime-owned; there is deliberately no second unmount-time completion -authority. - -Every core write passed to `startTransition` must notify the aggregate matches -store even if much of the lane is structurally reused. Suppressing the write can -strand the acknowledgement promise. +descendant mount effects. The architecture supports one committed `Matches` +owner per router at a time. Unmounting it unsubscribes from history and retires +any outstanding acknowledgement as `false`; a later mount creates a new owner +and synchronizes with the router's current presented matches. Development +defers only that retirement through the StrictMode effect replay turn, while +production cleanup is synchronous. Effect setup always reactivates the owner: +React Suspense and Activity can disconnect and reconnect layout effects without +rendering the preserved child tree again. + +Every core publication passed to `startTransition` must publish the route-ID +generation signal even if route membership is unchanged. Suppressing that +notification can strand React's acknowledgement promise. ## `isFetching` and background reloads @@ -790,8 +757,8 @@ presented lane and therefore expose their phase immediately. A background reload keeps successful loader data visible while a private candidate runs. The full presented lane remains installed and the affected match reports the active phase. Completion clears fetching state whether the -candidate publishes, fails, or is superseded. A successor may join its loader -flight, but never adopts the private candidate lane. +candidate publishes, fails, or is superseded. A successor never adopts or joins +the private candidate lane. Background loader and chunk work may begin and settle while the foreground publication renders. Background reduction, projection, and publication do not @@ -850,12 +817,19 @@ generation from escaping invalidation merely because a different generation was the one passed to the filter. Route context needs no invalidation marker; every new lane rebuilds it regardless. +Invalidation also retires active preloads planned under the old generation. +Unfiltered invalidation retires every active preload; filtered invalidation +retires a preload when any match in its lane passes the filter. The retained +preload collection is installed before discarded match leases are released and +their lane controllers are aborted, so late speculative work cannot regain +cache publication authority after invalidation. + Invalidated successful data may remain visible until pending or terminal publication, depending on reload mode. Error/not-found generations reset through the same loading protocol rather than becoming cache successes. -Cache clearing derives the retained active-preload and cache maps, installs both -replacement authorities, bulk-detaches removed leases, lets that operation +Cache clearing derives the retained active-preload collection and cache map, +installs both replacement authorities, bulk-detaches removed leases, lets that operation abort zero-owner flight controllers, and only then aborts selected preload lane controllers. A public loader signal can synchronously reenter from its abort listener; that reentrant load must observe the cleared authorities and every @@ -864,11 +838,12 @@ independent, and every later cache publication must still pass the per-match cache-entry identity check captured during planning. Development refresh is deliberately aggressive: it aborts flights, discards -active preloads and caches, drops the committed semantic lane, and rematches -from the current route definitions. This prevents same-ID reuse from retaining -obsolete params, context, loader data, or projected assets. Correct ownership -and eventual usable state matter more than preserving speculative HMR work. -Flight discovery entries are removed before their controllers are aborted. +active preloads and caches, and prevents the committed lane from being reused +while rematching current route definitions. The old committed lane remains +installed for rendering and rollback until the replacement publishes. This +prevents same-ID reuse from retaining obsolete params, context, loader data, or +projected assets. Correct ownership and eventual usable state matter more than +preserving speculative HMR work. ## Speculative preloading @@ -877,9 +852,8 @@ navigation, but it never becomes `_tx` and never publishes match presentation. Its match/cache ownership effects are limited to: -- an active identical whole lane that a navigation may adopt while it is still - running; -- joinable same-id loader flights; and +- each active lane being registered so invalidation and cache clearing can + cancel it; - individual successful preload loader generations entering the loader cache as they settle. @@ -887,12 +861,14 @@ A preload can also install durable lazy route options through the separate route-chunk owner described above. That is route definition readiness, not authority over a completed match lane or `beforeLoad` result. -A completed preload's failure, not-found, redirect, cancellation, and route -context do not become authority for a later navigation. An adopted still-active -identical lane is the exception described above, including its redirect control -flow. Standalone preload redirects may be followed only while the preload -remains current and within its bounded redirect policy. A preload never performs -a document reload. +A preload's route context and `beforeLoad` control outcome do not become +authority for a navigation, and terminal outcomes do not become reusable cache +entries. A navigation that joins the preload's still-pending same-ID loader does +share that loader generation's complete outcome. A later navigation starts a +new loader after a terminal generation leaves discovery. A still-active +standalone preload follows redirects within its bounded policy even if an +unrelated navigation runs; this only starts more speculative work and cannot +publish location or presentation. A preload never performs a document reload. The public `preloadRoute` result describes the speculative lane, not merely its cacheable subset. An ordinary error or not-found therefore resolves with the @@ -905,8 +881,7 @@ the entry captured when that task was planned. It cannot overwrite a cache generation installed since that plan. Admission happens at loader settlement, without waiting for whole-lane success. A distinct successful generation may coexist with an older committed same-ID generation; this changes future -planning precedence, not current presentation. A duplicate sharing the already -accepted flight is discarded. +planning precedence, not current presentation. `preloadRoute` also works on a server router. It runs the same speculative protocol with `preload: true`, can return matches and populate that router's @@ -957,18 +932,26 @@ swallowed. Until a response is accepted, the request handler owns router SSR cleanup. A non-stream response, redirect, or failure leaves cleanup with the handler. An accepted SSR stream transfers that ownership to the response and is immediately -bound to the request signal, so an abort after handoff still disposes it. - -Disposal is idempotent and severs router SSR ownership before best-effort body -cancellation. This order matters because a custom or framework stream may ignore -or indefinitely delay cancellation. Replacing a stream response, resolving a -redirect from it, or stripping a HEAD body disposes the old stream under the -same request signal before accepting the replacement. - -Framework renderers connect request abort to their upstream renderer as well as -the router stream transform. Normal completion, downstream cancellation, -request abort, renderer failure, and stream lifetime timeout all converge on the -same cleanup authority; none creates a second response owner. +bound to the request signal, so an abort after handoff still disposes it. An +ordinary response body has no router cleanup ownership, but is handed off +through an abortable stream pipe so a later request abort still cancels its +upstream body. + +Disposal is idempotent and severs router SSR ownership before body cancellation. +Start's internal response-retirement and HEAD paths detach cancellation so a +custom or framework stream cannot delay request settlement. The public +router-core replacement and body-stripping helpers instead await a custom tagged +response disposer, so callers know that the old owner has finished cleanup. + +Framework renderers connect request abort to their upstream renderer when the +renderer exposes cancellation ownership, as well as to the router stream +transform. The current Solid and Vue renderer APIs expose no disposer for a +pending render: abort still releases router state and stops outbound bytes, but +cannot prove that the hidden framework render graph was retired. Hosting must +therefore enforce a request/render deadline until those upstream APIs expose +that ownership. Normal completion, downstream cancellation, request abort, +renderer failure, and stream lifetime timeout otherwise converge on the same +cleanup authority; none creates a second response owner. Once a server lane is accepted for rendering, its match controllers are registered with that same SSR cleanup authority. They remain live through @@ -1126,8 +1109,8 @@ The two-phase transfer proceeds as follows: 2. It installs its own `_preflight`, emits the events, matches a private lane, and asks the capability to finish the transfer. 3. Finish revalidates capability identity, transaction absence, hydration - controller liveness, captured location identity, and the exact committed - owner. + controller liveness, captured semantic location inputs, and the exact + committed owner. 4. On rejection, the capability clears itself before aborting its controller, so abort listeners cannot reenter and claim a rejected handoff. 5. On acceptance, the prefix replaces the planner's private copies. A terminal @@ -1176,22 +1159,20 @@ When changing this architecture, verify all of the following: 6. Each successful preload loader generation may enter the cache as it settles, even if its whole lane later becomes terminal or is canceled. It may retain same-ID route-local `_ctx`, but never merged context or `beforeLoad` output. -7. Active preload adoption is identical-whole-lane only, including router - context, additional context, and user-supplied location state. It also - requires the semantic committed owner captured during planning to remain - current; rejected lanes may donate loader flights, never `beforeLoad` - context. Retrying a terminal adopted lane may retain every independently - successful loader generation, but rebuilds merged context and reruns the - serial `beforeLoad` chain. +7. Every preload and navigation rebuilds merged context and reruns the serial + `beforeLoad` chain. Same-ID loader promises and accepted loader data remain + reusable independently through `_flights` and `_cache`. 8. Hydration is the only completed-work exception for `beforeLoad` reuse. It verifies each transported match identity, reconstructs the accepted ordered prefix, reruns route context locally, preserves terminal/selective-SSR authority, and presents the full local branch while executing only the accepted prefix. Its two-phase handoff is claimable only by the supported - initial document load and revalidates the live controller, captured location, - and exact committed owner. The framework's exact-document contract remains - the URL authority; the captured location rejects supported reentrant - successors, while compact serialized match IDs guard prefix compatibility. + initial document load and revalidates the live controller, captured route + tree and contexts, both internal and browser-facing hrefs, the parsed state + object, and the exact committed owner. The framework's exact-document + contract remains the URL authority; those semantic inputs reject supported + reentrant successors, while compact serialized match IDs guard prefix + compatibility. It clears before abort on rejection and remains reclaimable until `_tx` exists. Framework adapters start that load before descendant route code can create unsupported speculative work in the handoff gap. @@ -1199,15 +1180,16 @@ When changing this architecture, verify all of the following: 10. Cache publication retains only the generation allowed by its planning CAS; same-ID committed and cached generations invalidate together, and accepted recipients are installed before replaced resources are released. -11. The registry is the sole discovery authority for the latest same-ID flight - started by navigation, preload, or background work. Registry joinability - and lease ownership remain separate; donor selection happens synchronously - after the reload decision, and accepted signal lifetime may outlive promise - settlement. +11. Loader flights are match-owned. Same-ID lanes may lease the currently + discoverable generation after independently deciding loader policy, but + a lease observes the complete pending outcome. Only successful data persists + in the cache. Explicit invalidation and requested `shouldReload` generations + do not borrow older work. Lease ownership may outlive promise settlement so + the accepted public signal retains the semantic generation's lifetime. 12. Child `parentMatchPromise` follows the fresh semantic parent generation. 13. The first parallel ordinary failure is chronological unless a required ancestor failure has no accepted loader data or its required chunk fails; - any started descendant redirect can still win as control flow. + any started descendant redirect in that lane can still win as control flow. 14. Projection errors are logged and swallowed, and global path misses retain their `_notFound` representation, which may be a successful fuzzy/root match without an attached error. @@ -1216,30 +1198,31 @@ When changing this architecture, verify all of the following: exclusion derive the same cutoff, while SSR transport may cap its payload at the terminal prefix. 16. Every discarded semantic match and background candidate releases resources - exactly once; bulk release detaches every lease and registry entry before - aborting any collected flight controller. -17. A background write checks both writer and exact base identity. A successor - may join its flight but never adopts its candidate lane. + exactly once; bulk release detaches every lease before aborting any + collected flight controller. +17. A background write checks both writer and exact base identity. Its private + candidate flight is not discoverable by a successor lane. 18. Background publication waits for the foreground acknowledgement, then remains detached from foreground completion. Its eager settlement witness is retained across that wait so chronological failure/control selection is not recomputed later. 19. `isFetching` clears for success, failure, cancellation, and supersession. 20. Transition acknowledgement settlement gates resolved/idle completion; React - identifies the exact generation by length plus ordered ID, controller, and - status, and installs it in one superseding slot before running the possibly - reentrant transition callback. Only `true` gates `onRendered` and pending - minimum timing. -21. The single Transitioner and its acknowledgement machinery live for the - router's entire application lifetime. + identifies the exact generation by presentation-array identity and installs + it in one superseding slot before running the possibly reentrant transition + callback. Only `true` gates `onRendered` and pending minimum timing. +21. Exactly one mounted `Matches` tree owns a router's React acknowledgement at + a time. Final unmount retires it, and a later mount creates a new owner from + the router's current presentation. 22. Reentrant callbacks suppress every stale later event or publication. 23. Canonicalization compares browser-facing `publicHref`; semantic `href` is not a symmetric canonical key across input and output rewrites. 24. Request abort can settle every awaited server phase; late work cannot regain response or injection authority. 25. Exactly one of the request handler or an accepted stream owns SSR cleanup. - Accepted streams remain bound to request abort, and disposal releases router - state before best-effort body cancellation. + Every handed-off response body remains bound to request abort. Disposal + releases router state before best-effort body cancellation, and response + replacement never waits for that cancellation to settle. 26. Cleanup prevents a still-pending custom dehydration from beginning serialization. @@ -1260,7 +1243,8 @@ Useful assertions include: events, without asserting a relative `onEnter`/`onStay` order; - preload/cache reuse observable through user loader calls; - HTTP status, headers, redirects, and absence of renderer invocation; -- request-abort settlement, late-stream disposal, and single stream cleanup; +- request-abort settlement after body handoff, detached replacement disposal, + and single stream cleanup; - hydration output, completion on rejection, and absence of client reruns below server terminal boundaries; - absence of stale data/assets after supersession; and diff --git a/packages/router-core/src/load-client.ts b/packages/router-core/src/load-client.ts index 897d479e09..f0c7432128 100644 --- a/packages/router-core/src/load-client.ts +++ b/packages/router-core/src/load-client.ts @@ -2,12 +2,7 @@ // can rewrite relative imports for both ESM and CJS. import { isNotFound } from './not-found' import { isRedirect } from './redirect' -import { - _getUserHistoryState, - getLocationChangeInfo, - runRouteLifecycle, -} from './router' -import { deepEqual } from './utils' +import { getLocationChangeInfo, runRouteLifecycle } from './router' import { hydrateSsrMatchId } from './ssr/ssr-match-id' import type { GLOBAL_SEROVAL, GLOBAL_TSR } from './ssr/constants' import type { AnySerializationAdapter } from './ssr/serializer/transformer' @@ -25,12 +20,6 @@ import type { import type { AnyRedirect } from './redirect' import type { AnyRouter } from './router' -type RouteComponentType = - | 'component' - | 'pendingComponent' - | 'errorComponent' - | 'notFoundComponent' - export function replaceRouteChunk( route: AnyRoute, lazyFn: AnyRoute['lazyFn'], @@ -39,33 +28,29 @@ export function replaceRouteChunk( route._lazy = undefined } -function preloadComponent( - route: AnyRoute, - type: RouteComponentType, -): Promise | undefined { - return (route.options[type] as any)?.preload?.() -} - -function loadComponents(route: AnyRoute): Promise | undefined { - const component = preloadComponent(route, 'component') - const pending = preloadComponent(route, 'pendingComponent') - if (component && pending) { - return Promise.all([component, pending]).then(() => {}) - } - return component ?? pending -} - export function loadRouteChunk( route: AnyRoute, // `false` waits only for lazy route options, before a boundary is selected. componentType?: 'errorComponent' | 'notFoundComponent' | false, + onPendingReady?: () => void, ): Promise | undefined { - const afterLazy = () => - componentType === false - ? undefined - : componentType - ? preloadComponent(route, componentType) - : loadComponents(route) + const afterLazy = () => { + if (componentType === false) { + return + } + if (componentType) { + return (route.options[componentType] as any)?.preload?.() + } + const component = (route.options.component as any)?.preload?.() + const pending = (route.options.pendingComponent as any)?.preload?.() + if (onPendingReady) { + pending ? pending.then(onPendingReady, () => {}) : onPendingReady() + } + if (component && pending) { + return Promise.all([component, pending]).then(() => {}) + } + return component ?? pending + } const current = route._lazy if (current) { return current === true ? afterLazy() : current.then(afterLazy) @@ -114,7 +99,8 @@ export function _getAssetMatches( const match = matches[index]! // `_assetEnd` is only ever set on hydration presentation clones that are // `status: 'pending'`, `ssr: 'data-only'`, error-free, and not not-found - // (see hydrate.ts), and commits clear it — so its presence alone is the guard. + // (see hydration reconstruction below), and commits clear it — so its + // presence alone is the guard. if (match._assetEnd !== undefined) { end = Math.min(end, Math.max(index + 1, match._assetEnd)) continue @@ -138,7 +124,7 @@ type LanePhase = 'matched' | 'contextualized' | 'reduced' | 'projected' * matches). The brand is phantom — it never exists at runtime. */ type LaneMatches = Array & { - readonly [lanePhase]?: TPhase + readonly [lanePhase]: TPhase } type Lane = [ @@ -146,7 +132,7 @@ type Lane = [ matches: LaneMatches, background?: Array, backgroundSettlement?: Promise, -] & { readonly [lanePhase]?: TPhase } +] & { readonly [lanePhase]: TPhase } type MatchedLane = Lane<'matched'> type ContextualizedLane = Lane<'contextualized'> @@ -160,61 +146,51 @@ const NOT_FOUND = 2 const REDIRECTED = 3 const CANCELED = 4 -type LoaderOutcome = - | [typeof SUCCESS, data: unknown] +type SharedLoaderFailure = | [typeof ERROR, error: unknown] | [typeof NOT_FOUND, error: NotFoundError] | [typeof REDIRECTED, redirect: AnyRedirect] - | [typeof CANCELED] -type IndexedOutcome = [index: number, outcome: LoaderOutcome, boundary?: number] +type LoaderFailure = SharedLoaderFailure | [typeof CANCELED] + +type LoaderOutcome = + | [ + typeof SUCCESS, + data: unknown, + updatedAt?: number, + ] + | SharedLoaderFailure + +type LoaderSuccess = [ + typeof SUCCESS, + data: unknown, + updatedAt: number, +] + +type LoaderResult = LoaderSuccess | LoaderFailure +type LoaderFlightResult = LoaderSuccess | SharedLoaderFailure + +type IndexedOutcome = [index: number, outcome: LoaderFailure, boundary?: number] + +declare const flightGeneration: unique symbol export type LoaderFlight = [ - outcome: Promise, + outcome: Promise, controller: AbortController, leases: number, -] + registry: Map, +] & { readonly [flightGeneration]: true } type WorkMatch = AnyRouteMatch & { _flight?: LoaderFlight + _claimedFlight?: LoaderFlight } declare const matchPhase: unique symbol -/** - * A match whose loader outcome has been applied by `settleInto`, which is the - * sole granter of this brand (phantom, zero-runtime). Consumers that require - * it — e.g. `cacheLoaderMatch` — can only be reached after settlement, so the - * compiler enforces the loader→settle→cache ordering. Sources that arrive - * already settled (dehydrated server data) must cast at a named boundary. - */ +/** A match whose loader outcome has been applied by `settle`. */ type SettledMatch = WorkMatch & { readonly [matchPhase]: 'settled' } -export type LaneInputs = [ - routeTree: AnyRoute, - context: unknown, - additionalContext: unknown, - state: object, - search: object, - maskedLocation: - | [ - href: string, - state: object, - search: object, - unmaskOnReload: boolean | undefined, - ] - | undefined, -] - -export type ActivePreload = [ - matches: Array, - controller: AbortController, - result: Promise, - semanticOwner: Array, - inputs: LaneInputs, - redirects: number, -] - export type LoadTransaction = [ controller: AbortController, redirects: number, @@ -244,8 +220,6 @@ export type PendingSession = [ ] type CoordinatorRouter = AnyRouter & { - /** Whole speculative lanes that a matching navigation may adopt. */ - _preloads?: Map _refreshNextLoad?: boolean _rollbackRefresh?: () => void _cancelTransition?: () => void @@ -261,14 +235,14 @@ type PublicationCheckpoint = { type LoaderTask = [ index: number, - outcome: Promise, + outcome: Promise, ready: Promise, candidate?: WorkMatch, ] type BackgroundLoaderTask = [ index: number, - outcome: Promise, + outcome: Promise, ready: Promise, candidate: WorkMatch, ] @@ -278,7 +252,7 @@ type ExecuteLaneOptions = [ redirects: number, isCurrent: () => boolean, base: Array, - preload?: boolean, + preload: boolean, sync?: boolean, forceStaleReload?: boolean, resolvedPrefix?: number, @@ -317,6 +291,16 @@ export function getRoute(router: AnyRouter, match: WorkMatch): AnyRoute { return (router.routesById as Record)[match.routeId]! } +function normalize( + value: unknown, + rejected: true, + routeId?: string, +): SharedLoaderFailure +function normalize( + value: unknown, + rejected: false, + routeId?: string, +): LoaderOutcome function normalize( value: unknown, rejected: boolean, @@ -335,7 +319,7 @@ function normalize( return rejected ? [ERROR, value] : [SUCCESS, value] } -function normalizeError(route: AnyRoute, cause: unknown): LoaderOutcome { +function normalizeError(route: AnyRoute, cause: unknown): SharedLoaderFailure { let outcome = normalize(cause, true, route.id) if (outcome[0] !== ERROR) { return outcome @@ -352,7 +336,7 @@ function normalizeLaneError( route: AnyRoute, cause: unknown, options: ExecuteLaneOptions, -): LoaderOutcome { +): LoaderFailure { if (options[0].signal.aborted || !options[2]()) { options[0].abort() return [CANCELED] @@ -376,7 +360,7 @@ async function contextualize( ): Promise { const [location, matches] = lane const signal = options[0].signal - const preload = !!options[4] + const preload = options[4] for (let index = options[7] ?? 0; index < end; index++) { const match = matches[index]! const route = getRoute(router, match) @@ -414,7 +398,7 @@ async function contextualize( } match.context = context } catch (cause) { - releaseFlight(router, match) + releaseFlight(match) return [index, normalizeLaneError(route, cause, options)] } if (signal.aborted || !options[2]()) { @@ -423,7 +407,7 @@ async function contextualize( } const validationError = match.paramsError ?? match.searchError if (validationError !== undefined) { - releaseFlight(router, match) + releaseFlight(match) return [index, normalizeLaneError(route, validationError, options)] } const beforeLoad = route.options.beforeLoad @@ -462,7 +446,7 @@ async function contextualize( } const outcome = normalize(result, false, route.id) if (outcome[0] !== SUCCESS) { - releaseFlight(router, match) + releaseFlight(match) return [index, outcome] } match.context = { @@ -470,7 +454,7 @@ async function contextualize( ...result, } } catch (cause) { - releaseFlight(router, match) + releaseFlight(match) return [index, normalizeLaneError(route, cause, options)] } finally { if (previousStatus === 'success' && match.status === 'pending') { @@ -483,79 +467,57 @@ async function contextualize( return } -function releaseOwnedFlight( - router: AnyRouter, - id: string, - flight?: LoaderFlight, +function detachOwnedFlight( + matchId: string, + flight: LoaderFlight | undefined, ): AbortController | undefined { - if (!flight || --flight[2]) { - return - } - if (router._flights?.get(id) === flight) { - router._flights.delete(id) + if (flight && !--flight[2]) { + if (flight[3].get(matchId) === flight) { + flight[3].delete(matchId) + } + return flight[1] } - return flight[1] + return } -function releaseFlight(router: AnyRouter, match: WorkMatch): void { +function detachFlight(match: WorkMatch): AbortController | undefined { const flight = match._flight match._flight = undefined - releaseOwnedFlight(router, match.id, flight)?.abort() + return detachOwnedFlight(match.id, flight) } -export function laneInputs( - router: AnyRouter, - location: ParsedLocation, -): LaneInputs { - const masked = location.maskedLocation - return [ - router.routeTree, - router.options.context ?? {}, - router.options.additionalContext, - _getUserHistoryState(location.state), - location.search, - masked && [ - masked.href, - _getUserHistoryState(masked.state), - masked.search, - masked.unmaskOnReload, - ], - ] + +function detachClaim(match: WorkMatch): AbortController | undefined { + const flight = match._claimedFlight + match._claimedFlight = undefined + return detachOwnedFlight(match.id, flight) } -function samePreloadLane( - preload: ActivePreload, - router: AnyRouter, - location: ParsedLocation, - redirects: number, -): boolean { - return ( - preload[3] === router._committed && - preload[5] === redirects && - deepEqual(preload[4], laneInputs(router, location)) && - !preload[0].some( - (match) => getRoute(router, match as WorkMatch).options.preload === false, - ) - ) +function releaseFlight(match: WorkMatch): void { + const claimed = detachClaim(match) + const accepted = detachFlight(match) + claimed?.abort() + accepted?.abort() } -/** - * Not passing in a `next` ownership recipient - * is equivalent to discarding the match resources - */ +function retireFlight(matchId: string, flight: LoaderFlight): void { + if (flight[3].get(matchId) === flight) { + flight[3].delete(matchId) + } +} + +/** Release every loader-flight lease owned by a discarded match set. */ export function transferMatchResources( - router: AnyRouter, previous: Array, - next?: Array, ): void { const abort: Array = [] for (const match of previous as Array) { - if (!next?.includes(match)) { - const flight = match._flight - match._flight = undefined - const controller = releaseOwnedFlight(router, match.id, flight) - if (controller) { - abort.push(controller) - } + const claimed = detachClaim(match) + const accepted = detachFlight(match) + if (claimed) { + abort.push(claimed) + } + if (accepted) { + abort.push(accepted) } } for (const controller of abort) { @@ -563,16 +525,15 @@ export function transferMatchResources( } } -function discardPreload(router: AnyRouter, preload: ActivePreload): void { - preload[1].abort() - transferMatchResources(router, preload[0]) -} - -function acquireMatchResources(matches: Array): void { +function claimPendingFlights( + router: AnyRouter, + matches: Array, +): void { for (const match of matches as Array) { - const flight = match._flight - if (flight) { + const flight = router._flights.get(match.id) + if (flight && flight !== match._flight) { flight[2]++ + match._claimedFlight = flight } } } @@ -624,97 +585,96 @@ async function loadResource( lane: ContextualizedLane, match: WorkMatch, route: AnyRoute, - loader: RouteLoaderFn | undefined, + loader: RouteLoaderFn, parentMatchPromise: Promise | undefined, preload: boolean, owner: AbortController, -): Promise { +): Promise { const signal = owner.signal if (signal.aborted) { return [CANCELED] } - if (!loader) { - return [SUCCESS, undefined] - } - let flight = match._flight - let joined = !!flight setFetching(router, match, 'loader', owner) + const registry = router._flights try { - for (;;) { - if (!flight) { - const controller = new AbortController() - const outcome = Promise.resolve() - .then(() => - loader( - getLoaderContext( - router, - lane, - match, - route, - controller, - parentMatchPromise, - preload, - ), - ), - ) - .then( - (value) => normalize(value, false, route.id), - (cause) => normalize(cause, true, route.id), - ) - .then((result): LoaderOutcome => { - return result[0] === ERROR && match._flight === flight - ? normalizeError(route, result[1]) - : result - }) - flight = [outcome, controller, 1] - ;(router._flights ??= new Map()).set(match.id, flight) + let flight = match._claimedFlight + match._claimedFlight = undefined + if (!flight) { + flight = registry.get(match.id) + if (flight) { + flight[2]++ } - match._flight = flight - match.abortController = flight[1] - try { - const outcome = await waitFor(flight[0], signal) - if (!joined || outcome[0] === SUCCESS || outcome[0] === REDIRECTED) { - return outcome - } - } catch (cause) { - if (cause === signal) { - releaseFlight(router, match) - return [CANCELED] - } + } + if (!flight) { + const controller = new AbortController() + const outcome = Promise.resolve() + .then(() => + loader( + getLoaderContext( + router, + lane, + match, + route, + controller, + parentMatchPromise, + preload, + ), + ), + ) + .then( + (value): LoaderFlightResult => { + const result = normalize(value, false, route.id) + if (result[0] === SUCCESS) { + result[2] = Date.now() + } else { + retireFlight(match.id, flight!) + } + return result as LoaderFlightResult + }, + (cause) => { + retireFlight(match.id, flight!) + return flight![2] + ? normalizeError(route, cause) + : normalize(cause, true, route.id) + }, + ) + flight = [outcome, controller, 1, registry] as LoaderFlight + registry.set(match.id, flight) + } + match._flight = flight + match.abortController = flight[1] + try { + return await waitFor(flight[0], signal) + } catch (cause) { + if (cause !== signal) { throw cause } - releaseFlight(router, match) - if (signal.aborted) { - return [CANCELED] - } - flight = undefined - joined = false + releaseFlight(match) + return [CANCELED] } } finally { setFetching(router, match, false, owner) } } -function settleInto( +function settle( match: WorkMatch, - result: LoaderOutcome, + result: LoaderResult, preload: boolean, -): asserts match is SettledMatch { - if (result[0] === SUCCESS) { - match.loaderData = result[1] +): match is SettledMatch { + const success = result[0] === SUCCESS + if (result[0] < REDIRECTED) { match.error = undefined match.status = 'success' - match.invalid = false - match.updatedAt = Date.now() - match.preload = preload - } else if (result[0] !== REDIRECTED) { - // Reduction installs only the selected terminal failure. Every other - // settled attempt remains a renderable, stale match in that lane. - match.status = 'success' - match.error = undefined - match.invalid = true + match.invalid = !success + if (success) { + match.loaderData = result[1] + match.updatedAt = result[2] + match.preload = preload + } } + return success } export function cacheLoaderMatch( @@ -723,13 +683,16 @@ export function cacheLoaderMatch( planned: AnyRouteMatch | undefined, ): void { const current = router._cache.get(match.id) as WorkMatch | undefined + const flight = match._flight if ( current !== planned || - router._committed.some( - (candidate) => - candidate.id === match.id && - (candidate as WorkMatch)._flight === match._flight, - ) + (flight && + (flight[3].get(match.id) !== flight || + ( + router._committed.find((candidate) => candidate.id === match.id) as + | WorkMatch + | undefined + )?._flight === flight)) ) { return } @@ -743,13 +706,13 @@ export function cacheLoaderMatch( } router._cache.set(match.id, cached) if (current) { - releaseFlight(router, current) + releaseFlight(current) } } function getParentSnapshot( match: WorkMatch, - outcome: LoaderOutcome, + outcome: LoaderResult, ): WorkMatch { if (outcome[0] === ERROR || outcome[0] === NOT_FOUND) { return { @@ -772,12 +735,16 @@ function createLoaderTask( ): Promise { const match = lane[1][index]! const route = getRoute(router, match) - const preload = !!options[4] + const preload = options[4] + if (preload && route.options.preload === false) { + match.isFetching = false + return Promise.resolve(match) + } const plannedCacheMatch = preload ? router._cache.get(match.id) : undefined + let configured let reload = false - let reloadFailure: LoaderOutcome | undefined + let reloadFailure: LoaderFailure | undefined try { - let configured if (match.status === 'success') { configured = route.options.shouldReload if (typeof configured === 'function') { @@ -803,11 +770,13 @@ function createLoaderTask( reload = true } else { const staleAge = - options[4] || match.preload + preload || match.preload ? (route.options.preloadStaleTime ?? router.options.defaultPreloadStaleTime ?? 30_000) - : (route.options.staleTime ?? router.options.defaultStaleTime ?? 0) + : (route.options.staleTime ?? + router.options.defaultStaleTime ?? + 0) reload = !!( match.invalid || configured || @@ -825,14 +794,23 @@ function createLoaderTask( } } catch (cause) { match.invalid = true - releaseFlight(router, match) + releaseFlight(match) reloadFailure = normalizeLaneError(route, cause, options) } const routeLoader = route.options.loader const loader = typeof routeLoader === 'function' ? routeLoader : routeLoader?.handler + let claimed = match._claimedFlight + if (claimed) { + if (configured === undefined && !reloadFailure && loader) { + reload = true + } else { + detachClaim(match)?.abort() + claimed = undefined + } + } const background = !!( - routeLoader && + loader && reload && match.status === 'success' && !preload && @@ -842,23 +820,29 @@ function createLoaderTask( : routeLoader?.staleReloadMode) ?? router.options.defaultStaleReloadMode) !== 'blocking' ) - const loaded = reload && (!preload || route.options.preload !== false) const blocking = - loaded && !background && (match.status !== 'success' || !!routeLoader) - if (loaded && !routeLoader) { + reload && !background && (match.status !== 'success' || !!loader) + if (!reload) { + match.isFetching = false + } else if (!loader) { match.invalid = false match.updatedAt = Date.now() - } - let donor = loaded && routeLoader ? router._flights?.get(match.id) : undefined - if (donor === match._flight) { - donor = undefined - } else if (donor) { - donor[2]++ + } else if (!claimed) { + if (configured === undefined) { + if (match._flight) { + retireFlight(match.id, match._flight) + } + } else { + router._flights.delete(match.id) + } } if (blocking) { - const acceptedFlight = match._flight - match._flight = donor - releaseOwnedFlight(router, match.id, acceptedFlight)?.abort() + if (claimed) { + const accepted = detachFlight(match) + accepted?.abort() + } else { + releaseFlight(match) + } // A successful route without a loader has no blocking work to present. It // still gets a task so its chunk and derived assets participate in the // lane, but putting it back into pending would hide an already-rendered @@ -868,13 +852,14 @@ function createLoaderTask( } options[8]?.() } - if (!loaded) { - match.isFetching = false - } - const rawOutcome = reloadFailure + const rawOutcome: Promise = reloadFailure ? Promise.resolve(reloadFailure) - : !blocking - ? Promise.resolve([SUCCESS, match.loaderData]) + : !blocking || !loader + ? Promise.resolve([ + SUCCESS, + match.loaderData, + match.updatedAt, + ]) : loadResource( router, lane, @@ -886,33 +871,28 @@ function createLoaderTask( options[0], ) const outcome = rawOutcome.then((result) => { - if (blocking) { - settleInto(match, result, preload) - if (result[0] === SUCCESS) { - if (preload && routeLoader) { - cacheLoaderMatch(router, match, plannedCacheMatch) - } - // A route is renderable only after both its data and normal component - // chunk are ready. Its loader data is already available to descendants. - match.status = 'pending' + if (blocking && settle(match, result, preload)) { + if (preload && loader) { + cacheLoaderMatch(router, match, plannedCacheMatch) } + // A route is renderable only after both its data and normal component + // chunk are ready. Its loader data is already available to descendants. + match.status = 'pending' } return result }) const rawChunkFailure = waitFor( - Promise.resolve().then(() => loadRouteChunk(route)), + Promise.resolve().then(() => + loadRouteChunk(route, undefined, options[8]), + ), options[0].signal, - ).then( - () => { - options[8]?.() - return undefined - }, + ).catch( (cause): IndexedOutcome => [ index, normalizeLaneError(route, cause, options), ], - ) + ) as Promise const chunkFailure = rawChunkFailure.then((failure) => outcome.then((result) => { if ( @@ -936,8 +916,10 @@ function createLoaderTask( ...match, status: 'pending', preload: false, - _flight: donor, + _flight: undefined, + _claimedFlight: claimed, } + match._claimedFlight = undefined match.invalid = false match.isFetching = 'loader' const backgroundOutcome = loadResource( @@ -951,7 +933,10 @@ function createLoaderTask( options[0], ).then((result) => { match.isFetching = false - settleInto(candidate, result, false) + if (result[0] === SUCCESS) { + retireFlight(match.id, candidate._flight!) + } + settle(candidate, result, false) return result }) ;(lane[2] ??= []).push([index, backgroundOutcome, chunkFailure, candidate]) @@ -966,6 +951,7 @@ async function getNotFoundBoundary( indexed: IndexedOutcome | undefined, signal: AbortSignal, fallback = 0, + preload?: boolean, ): Promise { const cause = indexed?.[1][1] as NotFoundError | undefined let index = cause?.routeId @@ -976,13 +962,15 @@ async function getNotFoundBoundary( } for (let i = index; i >= 0; i--) { const route = getRoute(router, matches[i]!) - const loading = loadRouteChunk(route, false) - if (loading) { - try { - await waitFor(loading, signal) - } catch (cause) { - if (cause === signal) { - throw cause + if (!preload || route.options.preload !== false) { + const loading = loadRouteChunk(route, false) + if (loading) { + try { + await waitFor(loading, signal) + } catch (cause) { + if (cause === signal) { + throw cause + } } } } @@ -993,16 +981,30 @@ async function getNotFoundBoundary( return cause?.routeId ? index : fallback } -function discardBackground(router: AnyRouter, lane: Lane): void { +function discardBackground(lane: Lane): void { if (lane[2]) { - transferMatchResources( - router, - lane[2].map((task) => task[3]), - ) + transferMatchResources(lane[2].map((task) => task[3])) lane[2] = undefined } } +function waitForDescendantRedirects( + tasks: Array | undefined, + boundary: number, +): Promise { + return Promise.all( + (tasks ?? []).map( + (task) => + task[0] > boundary && + task[1].then((outcome) => { + if (outcome[0] === REDIRECTED) { + throw [task[0], outcome] as IndexedOutcome + } + }), + ), + ) +} + async function settleTasks( tasks: Array, serialFailure?: IndexedOutcome, @@ -1022,22 +1024,11 @@ async function settleTasks( if (outcome[0] >= REDIRECTED) { throw [taskIndex, outcome] as IndexedOutcome } - if (!loaderFailure && outcome[0] !== SUCCESS) { - loaderFailure = [taskIndex, outcome] + if (outcome[0] !== SUCCESS) { + loaderFailure ||= [taskIndex, outcome] // Every started descendant must settle before an ordinary failure // wins because a redirect from any of them remains control flow. - await Promise.all( - (redirectTasks ?? []).map((nextTask) => { - if (nextTask[0] <= taskIndex) { - return - } - return nextTask[1].then((nextOutcome) => { - if (nextOutcome[0] === REDIRECTED) { - throw [nextTask[0], nextOutcome] as IndexedOutcome - } - }) - }), - ) + await waitForDescendantRedirects(redirectTasks, taskIndex) } }), ), @@ -1056,6 +1047,7 @@ async function reduceLane( redirects: number, settlement: Promise, onReady?: () => void, + preload?: boolean, ): Promise { const matches = lane[1] let failure = await settlement @@ -1063,7 +1055,14 @@ async function reduceLane( const plannedBoundary = matches.findIndex((match) => match._notFound) const boundaryOf = (found: IndexedOutcome) => found[1][0] === NOT_FOUND - ? getNotFoundBoundary(router, matches, found, controller.signal) + ? getNotFoundBoundary( + router, + matches, + found, + controller.signal, + 0, + preload, + ) : found[0] let readinessEnd = plannedBoundary < 0 ? matches.length : plannedBoundary @@ -1101,6 +1100,17 @@ async function reduceLane( break } + if (failure && failure[1][0] < REDIRECTED) { + try { + await waitForDescendantRedirects( + lane[2], + (failure[2] ??= await boundaryOf(failure)), + ) + } catch (cause) { + failure = cause as IndexedOutcome + } + } + if ((failure?.[1][0] ?? 0) >= REDIRECTED) { const outcome = failure![1] if ( @@ -1108,7 +1118,7 @@ async function reduceLane( outcome[1].options.reloadDocument || redirects < 20 ) { - discardBackground(router, lane) + discardBackground(lane) return outcome as ControlOutcome } redirectLimitExceeded = true @@ -1123,44 +1133,44 @@ async function reduceLane( const kind = outcome?.[0] const match = matches[boundary]! const cause = outcome?.[1] - const install = () => { - if (outcome) { - match._notFound = undefined - if (kind === ERROR) { - match.status = 'error' + const route = getRoute(router, match) + if (outcome) { + match._notFound = undefined + if (kind === ERROR) { + match.status = 'error' + } else { + ;(cause as NotFoundError).routeId = match.routeId + if (match.routeId === router.routeTree.id) { + match.status = 'success' + match._notFound = true } else { - ;(cause as NotFoundError).routeId = match.routeId - if (match.routeId === router.routeTree.id) { - match.status = 'success' - match._notFound = true - } else { - match.status = 'notFound' - } + match.status = 'notFound' } - match.error = cause - match.isFetching = false } + match.error = cause + match.isFetching = false } - install() - try { - await waitFor( - outcome - ? Promise.resolve().then(() => - loadRouteChunk( - getRoute(router, match), - kind === ERROR ? 'errorComponent' : 'notFoundComponent', - ), - ) - : Promise.all([ - loadRouteChunk(getRoute(router, match)), - loadRouteChunk(getRoute(router, match), 'notFoundComponent'), - ]), - controller.signal, - ) - } catch (cause) { - if (cause === controller.signal) { - discardBackground(router, lane) - return [CANCELED] + if (!preload || route.options.preload !== false) { + try { + await waitFor( + outcome + ? Promise.resolve().then(() => + loadRouteChunk( + route, + kind === ERROR ? 'errorComponent' : 'notFoundComponent', + ), + ) + : Promise.all([ + loadRouteChunk(route), + loadRouteChunk(route, 'notFoundComponent'), + ]), + controller.signal, + ) + } catch (cause) { + if (cause === controller.signal) { + discardBackground(lane) + return [CANCELED] + } } } if (!outcome) { @@ -1173,13 +1183,12 @@ async function reduceLane( ...tasks.map((task) => task[2]), ...(lane[2] ?? []).map((task) => task[1]), ]) - discardBackground(router, lane) - transferMatchResources(router, matches) - install() + discardBackground(lane) + transferMatchResources(matches) } } - return lane as ReducedLane + return lane as unknown as ReducedLane } export async function projectLane( @@ -1225,7 +1234,7 @@ export async function projectLane( break } } - return lane as ProjectedLane + return lane as unknown as ProjectedLane } async function executeClientLane( @@ -1243,6 +1252,7 @@ async function executeClientLane( undefined, options[0].signal, plannedBoundary, + options[4], ) if (boundary !== plannedBoundary) { matches[plannedBoundary]!._notFound = undefined @@ -1270,6 +1280,8 @@ async function executeClientLane( matched[1], failure, options[0].signal, + 0, + options[4], ) end = Math.min(end, failure[2] + 1) } else if ((failure?.[1][0] ?? 0) >= REDIRECTED) { @@ -1281,23 +1293,27 @@ async function executeClientLane( } semanticParent = createLoaderTask( router, - matched as ContextualizedLane, + matched as unknown as ContextualizedLane, index, tasks, semanticParent, options, ) } + for (const match of matched[1]) { + detachClaim(match)?.abort() + } let reduced: ReducedLane | ControlOutcome try { const reduction = reduceLane( router, - matched as ContextualizedLane, + matched as unknown as ContextualizedLane, tasks, options[0], options[1], settleTasks(tasks, failure, matched[2]), options[8], + options[4], ) if (matched[2]?.length) { matched[3] = settleTasks( @@ -1315,7 +1331,7 @@ async function executeClientLane( } reduced = await reduction } catch (cause) { - discardBackground(router, matched) + discardBackground(matched) throw cause } if (isControl(reduced)) { @@ -1329,49 +1345,6 @@ async function executeClientLane( ) } -/** - * Finds the first route that should show pending UI and its two timing values. - * A fallback already on screen remains selected after its route loads, so we - * do not jump to a child fallback. Matches put back into pending by invalidation - * skip pendingMs, and a route without a usable fallback blocks pending UI for deeper routes. - */ -function pendingConfig( - router: AnyRouter, - matches: Array, -): - | [delay: number, boundary: number, min: number, component: unknown] - | undefined - | void { - const presented = router.stores.matches.get() - for (let index = 0; index < matches.length; index++) { - const match = matches[index]! - const success = match.status === 'success' - const visible = - success && - presented[index]?.id === match.id && - presented[index]?.status === 'pending' - if (success && !visible) { - continue - } - const route = getRoute(router, match as WorkMatch) - const delay = - visible || match.invalid - ? 0 - : (route.options.pendingMs ?? router.options.defaultPendingMs) - const component = - route.options.pendingComponent ?? - (router.options as any).defaultPendingComponent - return component && typeof delay === 'number' && delay !== Infinity - ? [ - delay, - index, - route.options.pendingMinMs ?? router.options.defaultPendingMinMs ?? 0, - component, - ] - : undefined - } -} - /** * Waits for `pendingMs`, then presents the complete lane. Rendering applies the * selected boundary cutoff while retaining every match's structural state. @@ -1394,18 +1367,49 @@ function offerPending(router: CoordinatorRouter, tx: LoadTransaction): void { router._pending = session = undefined } } - const config = pendingConfig(router, tx[3]) - if (!config) { + // The first unresolved match chooses the fallback. A visible fallback stays + // selected after its route resolves, and a route without one blocks children. + const presented = router.stores.matches.get() + let boundary = 0 + let delay: number | undefined + let min = 0 + let component: unknown + for (; boundary < tx[3].length; boundary++) { + const match = tx[3][boundary]! + const success = match.status === 'success' + const visible = + success && + presented[boundary]?.id === match.id && + presented[boundary]?.status === 'pending' + if (success && !visible) { + continue + } + const route = getRoute(router, match as WorkMatch) + delay = + visible || match.invalid + ? 0 + : (route.options.pendingMs ?? router.options.defaultPendingMs) + component = + route.options.pendingComponent ?? + (router.options as any).defaultPendingComponent + if (!component || typeof delay !== 'number' || delay === Infinity) { + return + } + min = + route.options.pendingMinMs ?? router.options.defaultPendingMinMs ?? 0 + break + } + if (delay === undefined) { return } - const [delay, boundary, min, component] = config const matchId = tx[3][boundary]!.id if (!session || session[1] !== boundary || sessionMatchId !== matchId) { // Hydration and redirects can preserve pending presentation without a session. // Do not delay it again; conservatively start pendingMinMs from now. clearTimeout(session?.[3]) - const presented = router.stores.matches.get()[boundary] - const visible = presented?.id === matchId && presented.status === 'pending' + const presentedMatch = presented[boundary] + const visible = + presentedMatch?.id === matchId && presentedMatch.status === 'pending' router._pending = session = [ tx, boundary, @@ -1436,7 +1440,7 @@ function offerPending(router: CoordinatorRouter, tx: LoadTransaction): void { })) offered[boundary]!.status = 'pending' const ack = router - .startTransition(() => router.stores.setMatches(offered), offered, true) + .startTransition(() => router.stores.setMatches(offered), offered) .then((rendered) => { if ( rendered && @@ -1471,9 +1475,9 @@ function publishMatches( router.stores.setMatches(matches) } -function discardLane(router: AnyRouter, lane: ProjectedLane): void { - transferMatchResources(router, lane[1]) - discardBackground(router, lane) +function discardLane(lane: ProjectedLane): void { + transferMatchResources(lane[1]) + discardBackground(lane) } function commitMatches( @@ -1492,6 +1496,7 @@ function commitMatches( } const cut = _getRenderedMatches(matches).length const cached = new Map() + const discarded: Array = [] const now = Date.now() for (const match of [...previous, ...previousCached.values()]) { // Rendered-prefix ids and settled successes anywhere in the lane are @@ -1506,6 +1511,7 @@ function commitMatches( (index < cut || candidate.status === 'success'), ) ) { + discarded.push(match) continue } const work = match as WorkMatch @@ -1519,29 +1525,26 @@ function commitMatches( 300_000) : (route.options.gcTime ?? router.options.defaultGcTime ?? 300_000)) ) { + discarded.push(match) continue } - cached.set( - match.id, - previousCached.get(match.id) === match - ? match - : ({ - ...match, - _flight: undefined, - isFetching: false, - context: {}, - } as WorkMatch), - ) + if (previousCached.get(match.id) === match) { + cached.set(match.id, match) + } else { + cached.set(match.id, { + ...match, + _flight: undefined, + isFetching: false, + context: {}, + } as WorkMatch) + discarded.push(match) + } } // The lane becomes committed before publication can synchronously reenter. tx[3] = [] router._cache = cached publishMatches(router, matches) - transferMatchResources( - router, - [...previousCached.values(), ...previous], - [...matches, ...cached.values()], - ) + transferMatchResources(discarded) runRouteLifecycle(router, previous, matches, () => router._tx === tx) } @@ -1568,7 +1571,7 @@ function commitRefreshMatches( if (!checkpoint.published || router._tx !== tx) { return } - runRouteLifecycle(router, previous, matches, () => router._tx === tx) + runRouteLifecycle(router, previous, matches, () => router._tx === tx, true) } function settlePublication( @@ -1579,10 +1582,11 @@ function settlePublication( return } checkpoint.published = false + const retained = [...router._cache.values(), ...router._committed] transferMatchResources( - router, - [...checkpoint.previousCache.values(), ...checkpoint.previousMatches], - [...router._cache.values(), ...router._committed], + [...checkpoint.previousCache.values(), ...checkpoint.previousMatches].filter( + (match) => !retained.includes(match), + ), ) } @@ -1601,6 +1605,7 @@ function rollbackPublication( return false } + router._cancelTransition?.() const discarded = [...router._cache.values(), ...router._committed] const restored = [ ...checkpoint.previousCache.values(), @@ -1610,24 +1615,16 @@ function rollbackPublication( router._committed = checkpoint.previousMatches checkpoint.published = false - for (const match of discarded as Array) { - if ( - !restored.includes(match) && - match._flight && - router._flights?.get(match.id) === match._flight - ) { - router._flights.delete(match.id) - } - } - finishPending(router, tx) router.batch(() => { router.stores.status.set('idle') router.stores.setMatches(checkpoint.previousPresentation) }) tx[0].abort() - transferMatchResources(router, discarded, restored) - discardBackground(router, lane) + transferMatchResources( + discarded.filter((match) => !restored.includes(match)), + ) + discardBackground(lane) if (router._tx === tx && router._commitPromise === checkpoint.commitPromise) { router._commitPromise?.resolve() router._commitPromise = undefined @@ -1664,9 +1661,7 @@ async function transitionRefresh( if (router._rollbackRefresh === rollback) { router._rollbackRefresh = undefined } - const restored = rollbackPublication(router, tx, lane, checkpoint) - router._cancelTransition?.() - return restored + return rollbackPublication(router, tx, lane, checkpoint) } try { const rendered = await router.startTransition(commit, lane[1]) @@ -1725,11 +1720,12 @@ function restoreCommitted( ): void { finishPending(router, tx) tx[0].abort() - transferMatchResources(router, tx[3]) + transferMatchResources(tx[3]) tx[3] = [] if (router._tx !== tx) { return } + router._cancelTransition?.() router.batch(() => { router.stores.status.set('idle') router.stores.setMatches(router._committed) @@ -1747,12 +1743,21 @@ async function runBackground( tasks: Array, settlement: Promise, ): Promise { - const next = base.map((match) => ({ ...match })) - acquireMatchResources(next) - for (const task of tasks) { - releaseFlight(router, next[task[0]]!) - next[task[0]] = task[3] - } + // Tasks are appended in route order, so candidates can transfer ownership + // while untouched matches are cloned in the same pass. + let taskIndex = 0 + const next = base.map((match, index) => { + const task = tasks[taskIndex] + if (task?.[0] === index) { + taskIndex++ + return task[3] + } + const clone = { ...match } as WorkMatch + if (clone._flight) { + clone._flight[2]++ + } + return clone + }) // Phase jump: the clones inherit beforeLoad context from the committed // foreground lane, which already ran `contextualize` for these matches. const lane = [tx[2], next] as ContextualizedLane @@ -1760,11 +1765,11 @@ async function runBackground( try { reduced = await reduceLane(router, lane, tasks, tx[0], tx[1], settlement) } catch (cause) { - transferMatchResources(router, next) + transferMatchResources(next) throw cause } if (isControl(reduced)) { - transferMatchResources(router, next) + transferMatchResources(next) if ( reduced[0] === REDIRECTED && router._tx === tx && @@ -1776,18 +1781,19 @@ async function runBackground( } const projected = await projectLane(router, reduced, tx[0].signal) if (router._tx !== tx || router._committed !== base) { - transferMatchResources(router, projected[1]) + transferMatchResources(projected[1]) return } - for (const match of projected[1] as Array) { + for (const task of tasks) { + const match = task[3] const cached = router._cache.get(match.id) as WorkMatch | undefined if (cached?._flight && cached._flight === match._flight) { router._cache.delete(match.id) - releaseFlight(router, cached) + releaseFlight(cached) } } publishMatches(router, projected[1]) - transferMatchResources(router, base, projected[1]) + transferMatchResources(base) } async function runClientTransaction( @@ -1797,60 +1803,24 @@ async function runClientTransaction( onReady?: () => void, sync?: boolean, resolvedPrefix?: number, - adopted?: ActivePreload, - retained?: ActivePreload, ): Promise { const options: ExecuteLaneOptions = [ tx[0], tx[1], () => router._tx === tx && !!tx[3].length, router._committed, - undefined, + false, sync, forceStaleReload, resolvedPrefix, onReady, ] - let result: LaneResult - try { - result = adopted - ? await adopted[2] - : await executeClientLane(router, tx[2], tx[3], options) - } finally { - if (retained) { - discardPreload(router, retained) - } - } - if ( - adopted && - router._tx === tx && - ((isControl(result) && result[0] === CANCELED) || - (!isControl(result) && - result[1].some( - (match) => match.status !== 'success' || match._notFound, - ))) - ) { - // Successful loaders already seeded the cache; retry only the guard lane. - const donors = tx[3] as Array - tx[3] = [] - transferMatchResources(router, donors) - tx[0].abort() - if (router._tx !== tx) { - return - } - const controller = new AbortController() - tx[0] = options[0] = controller - tx[3] = router.matchRoutes(tx[2], { - _controller: controller, - }) - acquireMatchResources(tx[3]) - result = await executeClientLane(router, tx[2], tx[3], options) - } + const result = await executeClientLane(router, tx[2], tx[3], options) if (isControl(result)) { if (result[0] === REDIRECTED && router._tx === tx) { finishPending(router, tx) - transferMatchResources(router, tx[3]) + transferMatchResources(tx[3]) tx[3] = [] if (router._tx === tx) { if (process.env.NODE_ENV !== 'production' && tx[6]) { @@ -1900,7 +1870,7 @@ async function runClientTransaction( } if (router._tx !== tx) { finishPending(router, tx) - discardLane(router, result) + discardLane(result) return } const toLocation = tx[2] @@ -1911,7 +1881,7 @@ async function runClientTransaction( const background = result[2] await router.startViewTransition(async () => { if (router._tx !== tx) { - discardLane(router, result) + discardLane(result) return } const commit = () => { @@ -1937,7 +1907,7 @@ async function runClientTransaction( return } if (router._tx !== tx) { - discardBackground(router, result) + discardBackground(result) return } if (background?.length) { @@ -2028,87 +1998,52 @@ export async function loadClientRoute( return } const sameHref = previousLocation.href === location.href - let adopted = router._preloads?.get(location.href) - let retained: ActivePreload | undefined - if (rematerialize && adopted) { - router._preloads!.delete(location.href) - discardPreload(router, adopted) - adopted = undefined - if (preflight.signal.aborted || router._tx !== previousOwner) { - preflight.abort() - await awaitCurrent(router, previousOwner) - return - } - } - if ( - adopted && - (hydrationController || - !samePreloadLane( - adopted, - router, - pendingLocation?.href === location.href ? pendingLocation : location, - redirects, - )) - ) { - router._preloads!.delete(location.href) - // Keep incompatible loader flights alive through the real lane's reload - // decisions so matching generations can still donate their work. - retained = adopted - adopted = undefined - } let matches: Array let controller = preflight let resolvedPrefix: number | undefined - if (adopted) { - controller = adopted[1] - matches = adopted[0] - router._preloads!.delete(location.href) - } else { - try { - matches = - process.env.NODE_ENV !== 'production' && rematerialize - ? router.matchRoutes(location, { - _controller: preflight, - _rematerialize: true, - }) - : router.matchRoutes(location, { _controller: preflight }) - acquireMatchResources(matches) - } catch (cause) { - preflight.abort() - if (retained) { - discardPreload(router, retained) - } - if (!isRedirect(cause)) { - if (process.env.NODE_ENV !== 'production' && rematerialize) { - router._refreshNextLoad = undefined - } - await awaitCurrent(router) - router._commitPromise?.resolve() - router._commitPromise = undefined - return + try { + matches = + process.env.NODE_ENV !== 'production' && rematerialize + ? router.matchRoutes(location, { + _controller: preflight, + _rematerialize: true, + }) + : router.matchRoutes(location, { _controller: preflight }) + } catch (cause) { + preflight.abort() + if (!isRedirect(cause)) { + if (process.env.NODE_ENV !== 'production' && rematerialize) { + router._refreshNextLoad = undefined } - await router.navigate({ - ...cause.options, - replace: true, - ignoreBlocker: true, - }) - await awaitCurrent(router, previousOwner) + await awaitCurrent(router) + router._commitPromise?.resolve() + router._commitPromise = undefined return } - resolvedPrefix = hydrationController ? handoff![1](matches) : undefined - if (resolvedPrefix) { - controller = hydrationController! - } else { - hydrationController?.abort() - } + await router.navigate({ + ...cause.options, + replace: true, + ignoreBlocker: true, + }) + await awaitCurrent(router, previousOwner) + return + } + resolvedPrefix = hydrationController ? handoff![1](matches) : undefined + if (resolvedPrefix) { + controller = hydrationController! + } else { + hydrationController?.abort() } if (router._preflight !== preflight || router._tx !== previousOwner) { preflight.abort() - transferMatchResources(router, matches) + transferMatchResources(matches) await awaitCurrent(router, previousOwner) return } router._preflight = undefined + if (!rematerialize) { + claimPendingFlights(router, matches) + } const tx: LoadTransaction = [ controller, @@ -2125,8 +2060,6 @@ export async function loadClientRoute( () => offerPending(router, tx), opts?.sync, resolvedPrefix, - adopted, - retained, ), ) .catch(() => { @@ -2154,10 +2087,10 @@ export async function loadClientRoute( } } previousOwner[0].abort() - transferMatchResources(router, previousOwner[3]) + transferMatchResources(previousOwner[3]) } if (router._tx !== tx) { - transferMatchResources(router, tx[3]) + transferMatchResources(tx[3]) tx[3] = [] await awaitCurrent(router, tx) return @@ -2178,6 +2111,7 @@ export async function loadClientRoute( export async function refreshClientRoute( router: CoordinatorRouter, ): Promise { + router._refreshNextLoad = true router._rollbackRefresh?.() const pending = router._tx if (pending && !pending[6] && router.stores.status.get() === 'pending') { @@ -2186,129 +2120,74 @@ export async function refreshClientRoute( await awaitCurrent(router, pending) } } - // Existing owners remain alive for rollback but cannot donate stale work. - router._flights?.clear() + if (process.env.NODE_ENV !== 'production') { + router._flights.clear() + } router.clearCache() - router._refreshNextLoad = true await loadClientRoute(router, { sync: true }) } -function followPreloadRedirect( - router: CoordinatorRouter, - result: ControlOutcome, - location: ParsedLocation, - owner: LoadTransaction | undefined, - redirects: number, -): Promise | undefined> | undefined { - if ( - result[0] === REDIRECTED && - !result[1].options.reloadDocument && - router._tx === owner - ) { - return preloadClientRoute( - router, - { - ...result[1].options, - _fromLocation: location, - }, - redirects + 1, - ) - } - return -} - export async function preloadClientRoute( router: CoordinatorRouter, opts: any, redirects = 0, ): Promise | undefined> { - if (redirects > 20) { - return - } - const owner = router._tx if ( process.env.NODE_ENV !== 'production' && - (router._refreshNextLoad || owner?.[6]) + (router._refreshNextLoad || router._tx?.[6]) ) { return } const location = opts._builtLocation ?? router.buildLocation(opts) const base = router._committed const controller = new AbortController() - let matches: Array | undefined - let preload: ActivePreload | undefined - let replaced: ActivePreload | undefined + let matches: Array try { - const pending = router._preloads?.get(location.href) - if (pending) { - if (samePreloadLane(pending, router, location, redirects)) { - const result = await pending[2] - return isControl(result) - ? followPreloadRedirect(router, result, location, owner, redirects) - : result[1] - } - router._preloads!.delete(location.href) - // Keep the superseded lane alive until this lane has made its reload - // decisions. Its active flights are the synchronous donor authority. - replaced = pending - } matches = router.matchRoutes(location, { _controller: controller, }) - acquireMatchResources(matches) - const promise = Promise.resolve() - .then(() => - executeClientLane(router, location, matches!, [ - controller, - redirects, - // Preload lanes run to completion even when unrelated navigations - // commit: finished work seeds the cache, and adoption safety is - // enforced independently by samePreloadLane's base identity check. - () => true, - base, - true, - ]), - ) - .finally(() => { - if (replaced) { - discardPreload(router, replaced) - } - }) - preload = [ - matches, + } catch (cause) { + controller.abort() + if (!isNotFound(cause)) { + console.error(cause) + } + return + } + router._preloads.set(controller, matches) + try { + const result = await executeClientLane(router, location, matches, [ controller, - promise, - base, - laneInputs(router, location), redirects, - ] - ;(router._preloads ??= new Map()).set(location.href, preload) - const result = await promise - if (router._preloads?.get(location.href) !== preload) { - return isControl(result) ? undefined : result[1] - } - router._preloads.delete(location.href) - if (isControl(result)) { - controller.abort() - transferMatchResources(router, matches) - return followPreloadRedirect(router, result, location, owner, redirects) + // Preload lanes run to completion even when unrelated navigations + // commit: finished work seeds the cache. + () => true, + base, + true, + ]) + const control = isControl(result) + if (!router._preloads.delete(controller)) { + return control ? undefined : matches } - - transferMatchResources(router, result[1]) + transferMatchResources(matches) controller.abort() - return result[1] + if (!control) { + return matches + } + if (result[0] === REDIRECTED && !result[1].options.reloadDocument) { + return preloadClientRoute( + router, + { + ...result[1].options, + _fromLocation: location, + }, + redirects + 1, + ) + } + return } catch (cause) { - if (!preload || router._preloads?.get(location.href) === preload) { - if (preload) { - router._preloads!.delete(location.href) - } + if (router._preloads.delete(controller)) { + transferMatchResources(matches) controller.abort() - if (matches) { - transferMatchResources(router, matches) - } - } - if (router._tx !== owner) { - return } if (!isNotFound(cause)) { console.error(cause) @@ -2380,7 +2259,8 @@ export async function hydrate(router: AnyRouter): Promise { let location!: AnyRouter['latestLocation'] let candidates!: Array - let handoffInputs!: ReturnType + let handoffHistoryHref!: string + let handoffHistoryState: unknown try { await waitFor( router.options.hydrate?.(dehydratedRouter!.dehydratedData), @@ -2389,10 +2269,12 @@ export async function hydrate(router: AnyRouter): Promise { if (!isCurrent()) { return } + const historyLocation = router.history.location + handoffHistoryHref = historyLocation.href + handoffHistoryState = historyLocation.state router.updateLatestLocation() location = router.latestLocation router.stores.location.set(location) - handoffInputs = laneInputs(router, location) candidates = router.matchRoutes(location, { _controller: controller, }) @@ -2405,13 +2287,16 @@ export async function hydrate(router: AnyRouter): Promise { if (!isCurrent()) { return } - const committed: Array = [] + let committedEnd = 0 let pendingBoundary: number | undefined let verifiedAssetEnd = 0 - const retryFrom = (index: number) => { - // The failing route's identity is still verified, but no descendant is. - verifiedAssetEnd = Math.min(verifiedAssetEnd, index + 1) - const removed = committed.splice(index) + const retryFrom = (index: number, assetEnd = index + 1) => { + // The failing route's identity is still verified, but its client context + // and assets are not. Present it as the boundary for the client retry. + pendingBoundary = Math.min(pendingBoundary ?? index, index) + verifiedAssetEnd = Math.min(verifiedAssetEnd, assetEnd) + const removed = candidates.slice(index, committedEnd) + committedEnd = index for (const match of removed) { if ( getRoute(router, match).options.loader && @@ -2433,7 +2318,7 @@ export async function hydrate(router: AnyRouter): Promise { ) } } - transferMatchResources(router, removed) + transferMatchResources(removed) } // A longer server lane is valid only when the local match already caps the @@ -2476,7 +2361,7 @@ export async function hydrate(router: AnyRouter): Promise { candidate._notFound if (terminal) { isTerminal = true - committed.push(candidate) + committedEnd = index + 1 if (candidate.ssr === false || candidate.ssr === 'data-only') { pendingBoundary ??= index } @@ -2487,24 +2372,20 @@ export async function hydrate(router: AnyRouter): Promise { break } - committed.push(candidate) + committedEnd = index + 1 if (candidate.ssr === 'data-only') { pendingBoundary ??= index } } let verifiedContextEnd = verifiedAssetEnd - if ( - !isTerminal && - committed.length === shared && - shared < candidates.length - ) { + if (!isTerminal && committedEnd === shared && shared < candidates.length) { pendingBoundary = shared } // Hooks observe structural membership. Execution remains limited to - // `committed`, the accepted server prefix. - const chunks = committed.map(async (match) => { + // the accepted server prefix. + const chunks = candidates.slice(0, committedEnd).map(async (match) => { try { const route = getRoute(router, match) if (match._notFound) { @@ -2542,7 +2423,7 @@ export async function hydrate(router: AnyRouter): Promise { if (!isCurrent()) { return } - if (chunkFailure < committed.length) { + if (chunkFailure < committedEnd) { verifiedContextEnd = Math.min(verifiedContextEnd, chunkFailure) retryFrom(chunkFailure) } @@ -2550,9 +2431,7 @@ export async function hydrate(router: AnyRouter): Promise { // The first pending match is already visible, so prepare its route context // without granting its beforeLoad or loader any hydration authority. const contextEnd = Math.max( - pendingBoundary === committed.length - ? committed.length + 1 - : committed.length, + pendingBoundary === committedEnd ? committedEnd + 1 : committedEnd, verifiedContextEnd, ) for (let index = 0; index < contextEnd; index++) { @@ -2586,7 +2465,7 @@ export async function hydrate(router: AnyRouter): Promise { match.status !== 'notFound' && !match._notFound ) { - retryFrom(index) + retryFrom(index, index) break } } @@ -2597,7 +2476,7 @@ export async function hydrate(router: AnyRouter): Promise { match.context = { ...parentContext, ...routeContext, - ...(committed[index] && dehydratedMatches[index]!.b), + ...(index < committedEnd && dehydratedMatches[index]!.b), } } @@ -2611,10 +2490,11 @@ export async function hydrate(router: AnyRouter): Promise { if (!isCurrent()) { return } - const needsClientLoad = - pendingBoundary !== undefined || committed.length < shared + const needsClientLoad = pendingBoundary !== undefined || committedEnd < shared const committedMatches = - isTerminal && committed.length === shared ? candidates : committed + isTerminal && committedEnd === shared + ? candidates + : candidates.slice(0, committedEnd) let presented = needsClientLoad ? candidates : committedMatches let dataOnlyAssetEnd: number | undefined if (needsClientLoad && pendingBoundary !== undefined) { @@ -2636,16 +2516,18 @@ export async function hydrate(router: AnyRouter): Promise { } } - const claim = () => - needsClientLoad && - !router._tx && - router.latestLocation.state === location.state && - deepEqual(handoffInputs, laneInputs(router, router.latestLocation)) && - router._committed === committedMatches && - committedMatches.length && - !controller.signal.aborted + const claim = () => { + const historyLocation = router.history.location + return needsClientLoad && + !router._tx && + historyLocation.href === handoffHistoryHref && + historyLocation.state === handoffHistoryState && + router._committed === committedMatches && + committedMatches.length && + !controller.signal.aborted ? controller : undefined + } const handoff: NonNullable = [ claim, (matches) => { @@ -2676,7 +2558,7 @@ export async function hydrate(router: AnyRouter): Promise { if (handoffAssetEnd !== undefined) { clones[pendingBoundary!]!._assetEnd = handoffAssetEnd } - transferMatchResources(router, matches.splice(0, prefix, ...clones)) + transferMatchResources(matches.splice(0, prefix, ...clones)) for (let index = prefix; index < matches.length; index++) { const match = matches[index]! const hydrated = candidates[index] diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index e2c9da3d25..e18269802a 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -58,7 +58,6 @@ import type { import type { SearchParser, SearchSerializer } from './searchParams' import type { AnyRedirect, ResolvedRedirect } from './redirect' import type { - ActivePreload, LoadTransaction, LoaderFlight, PendingSession, @@ -778,7 +777,6 @@ export type CommitLocationFn = ({ export type StartTransitionFn = ( fn: () => void, expected: Array, - urgent?: boolean, ) => Promise export interface MatchRoutesFn { @@ -936,21 +934,41 @@ export function _getUserHistoryState({ return state } +function runCallback( + callback: ((arg: T) => void) | undefined, + arg: T, + throwOnError?: boolean, +): void { + if (throwOnError && process.env.NODE_ENV !== 'production') { + callback?.(arg) + return + } + try { + callback?.(arg) + } catch (error) { + console.error(error) + } +} + /** Run route lifecycle callbacks in leave/enter/stay phases. */ export function runRouteLifecycle( router: AnyRouter, previous: Array, matches: Array, isCurrent?: () => boolean, + throwOnError?: boolean, ): void { for (const match of previous) { if (isCurrent?.() === false) { return } if (!matches.some((candidate) => candidate.routeId === match.routeId)) { - ;(router.routesById as Record)[ - match.routeId - ]!.options.onLeave?.(match) + runCallback( + (router.routesById as Record)[match.routeId]!.options + .onLeave, + match, + throwOnError, + ) } } for (const match of matches) { @@ -960,11 +978,15 @@ export function runRouteLifecycle( const route = (router.routesById as Record)[ match.routeId ]! - route.options[ - previous.some((candidate) => candidate.routeId === match.routeId) - ? 'onStay' - : 'onEnter' - ]?.(match) + runCallback( + route.options[ + previous.some((candidate) => candidate.routeId === match.routeId) + ? 'onStay' + : 'onEnter' + ], + match, + throwOnError, + ) } } @@ -1025,10 +1047,10 @@ export interface RouterCore< shouldViewTransition?: boolean | ViewTransitionOptions /** Current client load transaction and owner of navigation writes. */ _tx?: LoadTransaction - /** Joinable in-flight loader generations keyed by match ID. */ - _flights?: Map - /** Whole speculative lanes that an identical navigation may adopt. */ - _preloads?: Map + /** Joinable loader generations keyed by match ID. */ + _flights: Map + /** Active speculative lane matches keyed by their cancellation owner. */ + _preloads: Map> /** Owns cancellable work before a client transaction publishes. */ _preflight?: AbortController /** Transfers one reconstructed SSR prefix into its initial client load. */ @@ -1037,8 +1059,6 @@ export interface RouterCore< _pending?: PendingSession /** Result of the latest server load, used to render or redirect. */ _serverResult?: ServerLoadResult - /** Framework callback that acknowledges an exact matches publication. */ - _rendered?: (matches: Array) => void /** Development-only HMR reload for a route and its descendants. */ _refreshRoute: (() => Promise) | undefined /** Development-only replacement for a route's lazy chunk owner. */ @@ -1083,9 +1103,12 @@ export class RouterCore< subscribers = new Set>() /** Accepted off-screen loader generations keyed by match ID. */ _cache = new Map() + /** Joinable loader generations keyed by match ID. */ + _flights = new Map() + /** Active speculative lane matches keyed by their cancellation owner. */ + _preloads = new Map>() /** Accepted semantic lane, excluding temporary pending presentation. */ _committed: Array = [] - // Must build in constructor stores!: RouterStores private getStoreConfig!: GetStoreConfig @@ -1377,11 +1400,7 @@ export class RouterCore< emit: EmitFn = (routerEvent) => { for (const listener of this.subscribers) { if (listener.eventType === routerEvent.type) { - try { - listener.fn(routerEvent) - } catch (e) { - console.error(e) - } + runCallback(listener.fn, routerEvent) } } } @@ -1463,7 +1482,6 @@ export class RouterCore< parsedTempLocation.state.__TSR_key = location.state.__TSR_key delete parsedTempLocation.state.__tempLocation - return { ...parsedTempLocation, maskedLocation: location, @@ -1541,14 +1559,7 @@ export class RouterCore< const matches = new Array(matchedRoutes.length) const committed = this._committed - const previousAt = (route: AnyRoute, index: number) => { - const match = committed[index] - return match?.routeId === route.id - ? match - : route === this.options.notFoundRoute - ? committed.find((candidate) => candidate.routeId === route.id) - : undefined - } + const controller = opts?._controller for (let index = 0; index < matchedRoutes.length; index++) { const route = matchedRoutes[index]! @@ -1633,7 +1644,13 @@ export class RouterCore< // explicit deps loaderDepsHash - const previousMatch = previousAt(route, index) + let previousMatch = committed[index] + if (previousMatch?.routeId !== route.id) { + previousMatch = + route === this.options.notFoundRoute + ? committed.find((candidate) => candidate.routeId === route.id) + : undefined + } const existingMatch = process.env.NODE_ENV !== 'production' && opts?._rematerialize ? undefined @@ -1702,7 +1719,7 @@ export class RouterCore< error: undefined, paramsError, context: {}, - abortController: opts?._controller ?? new AbortController(), + abortController: controller ?? new AbortController(), cause, loaderDeps: previousMatch ? replaceEqualDeep(previousMatch.loaderDeps, loaderDeps) @@ -1730,8 +1747,13 @@ export class RouterCore< match.cause === 'stay' ? nullReplaceEqualDeep(match.params, routeParams) : routeParams - if (opts?._controller) { + if (controller) { match.context = {} + const flight = (match as AnyRouteMatch & { _flight?: LoaderFlight }) + ._flight + if (flight) { + flight[2]++ + } } } @@ -2469,13 +2491,37 @@ export class RouterCore< > = (opts) => { const committedMatches = this._committed const filter = opts?.filter - const invalidIds = filter - ? new Set( - [...committedMatches, ...this._cache.values()] - .filter((match) => filter(match as MakeRouteMatchUnion)) - .map((match) => match.id), - ) - : undefined + let invalidIds: Set | undefined + if (filter) { + invalidIds = new Set() + const collect = (matches: Iterable) => { + for (const match of matches) { + if (filter(match as MakeRouteMatchUnion)) { + invalidIds!.add(match.id) + } + } + } + collect(committedMatches) + collect(this._cache.values()) + for (const matches of this._preloads.values()) { + collect(matches) + } + if (this._tx) { + collect(this._tx[3]) + } + } + const discardedPreloads: Array = [] + const preloadControllers: Array = [] + for (const [controller, matches] of this._preloads) { + if ( + !invalidIds || + matches.some((match) => invalidIds.has(match.id)) + ) { + this._preloads.delete(controller) + discardedPreloads.push(...matches) + preloadControllers.push(controller) + } + } const invalidate = (d: MakeRouteMatch) => { if (!invalidIds || invalidIds.has(d.id)) { const route = this.routesById[d.routeId] as AnyRoute @@ -2498,11 +2544,20 @@ export class RouterCore< const committed = committedMatches.map(invalidate) this._committed = committed - const cache = new Map() for (const [id, match] of this._cache) { - cache.set(id, invalidate(match)) + this._cache.set(id, invalidate(match)) + } + if (invalidIds) { + for (const id of invalidIds) { + this._flights.delete(id) + } + } else { + this._flights.clear() + } + transferMatchResources(discardedPreloads) + for (const controller of preloadControllers) { + controller.abort() } - this._cache = cache this.shouldViewTransition = false return this.load({ sync: opts?.sync }) @@ -2551,36 +2606,33 @@ export class RouterCore< } clearCache: ClearCacheFn = (opts) => { - const cached = this._cache - const preloads = this._preloads const filter = opts?.filter - const retained = new Map() const discarded: Array = [] - for (const [id, match] of cached) { - if (filter && !filter(match as MakeRouteMatchUnion)) { - retained.set(id, match) - } else { + const cacheIds: Array = [] + for (const [id, match] of this._cache) { + if (!filter || filter(match as MakeRouteMatchUnion)) { + cacheIds.push(id) discarded.push(match) } } - const retainedPreloads = new Map() - const discardedPreloads: Array = [] - for (const [href, preload] of preloads ?? []) { - if (!filter || preload[0].some(filter as any)) { - discardedPreloads.push(preload) - discarded.push(...preload[0]) - } else { - retainedPreloads.set(href, preload) + const preloadControllers: Array = [] + for (const [controller, matches] of this._preloads) { + if (!filter || matches.some(filter as any)) { + discarded.push(...matches) + preloadControllers.push(controller) } } - - // Install both replacement authorities before releasing a public loader - // signal, whose abort listeners can synchronously reenter the router. - this._cache = retained - this._preloads = retainedPreloads - transferMatchResources(this, discarded) - for (const preload of discardedPreloads) { - preload[1].abort() + // Update both authorities before releasing a public loader signal, whose + // abort listeners can synchronously reenter the router. + for (const id of cacheIds) { + this._cache.delete(id) + } + for (const controller of preloadControllers) { + this._preloads.delete(controller) + } + transferMatchResources(discarded) + for (const controller of preloadControllers) { + controller.abort() } } diff --git a/packages/router-core/src/ssr/handlerCallback.ts b/packages/router-core/src/ssr/handlerCallback.ts index 368c8a2a61..ac06c45f3b 100644 --- a/packages/router-core/src/ssr/handlerCallback.ts +++ b/packages/router-core/src/ssr/handlerCallback.ts @@ -30,17 +30,12 @@ export function normalizeSsrResponse( : { response: result, serverSsrCleanup: 'none' } } -export function disposeSsrResponse( +export async function disposeSsrResponse( response: SsrResponse, reason?: unknown, ): Promise { - if (response.serverSsrCleanup !== 'stream') { - return Promise.resolve() - } - try { - return Promise.resolve(response.dispose(reason)) - } catch (error) { - return Promise.reject(error) + if (response.serverSsrCleanup === 'stream') { + await response.dispose(reason) } } @@ -55,12 +50,78 @@ export function disposeSsrResponseDetached( return } - if (ssrResponse.response.body) { - try { - void ssrResponse.response.body.cancel(reason).catch(onError) - } catch (error) { - onError(error) + const body = ssrResponse.response.body + if (body && !body.locked) { + void body.cancel(reason).catch(onError) + } +} + +function ownResponseBody( + response: Response, + onTerminal: (reason?: unknown) => void, +): [Response, (reason?: unknown) => void] { + const reader = response.body!.getReader() + let controller!: ReadableStreamDefaultController + let terminal = false + const finish = (reason?: unknown) => { + terminal = true + onTerminal(reason) + } + const cancel = (reason?: unknown): Promise | undefined => { + if (!terminal) { + finish(reason) + return reader.cancel(reason) } + return + } + const body = new ReadableStream({ + start(ctrl) { + controller = ctrl + }, + async pull(ctrl) { + try { + const { done, value } = await reader.read() + if (terminal) { + return + } + if (done) { + finish() + ctrl.close() + } else { + ctrl.enqueue(value) + } + } catch (error) { + if (!terminal) { + finish(error) + ctrl.error(error) + } + } + }, + cancel, + }) + return [ + new Response(body, response), + (reason) => { + if (!terminal) { + controller.error(reason) + finish(reason) + void reader.cancel(reason).catch(console.error) + } + }, + ] +} + +function ownSsrResponse( + response: Response, + onTerminal: (reason?: unknown) => void, +): Extract { + const [ownedResponse, dispose] = ownResponseBody(response, onTerminal) + return { + response: ownedResponse, + serverSsrCleanup: 'stream', + async dispose(reason?: unknown) { + dispose(reason) + }, } } @@ -72,27 +133,16 @@ export function createSsrStreamResponse( throw new Error('Invariant failed: SSR stream response requires a body') } - let disposed = false - return { - response, - serverSsrCleanup: 'stream', - async dispose(reason?: unknown) { - if (disposed) { - return - } - disposed = true - - // Sever router ownership before asking user/renderer stream machinery to - // cancel. A custom stream is allowed to ignore cancellation forever. - router.serverSsr?.cleanup() + return ownSsrResponse(response, () => router.serverSsr?.cleanup()) +} - try { - await response.body!.cancel(reason) - } catch { - // Cleanup above already released router SSR state. - } - }, - } +export function _transferSsrResponse( + owner: Extract, + response: Response & { body: ReadableStream }, +): SsrResponse { + return ownSsrResponse(response, (reason) => { + disposeSsrResponseDetached(owner, reason) + }) } export function bindSsrResponseToRequest( @@ -104,14 +154,28 @@ export function bindSsrResponseToRequest( if (ssrResponse.serverSsrCleanup !== 'stream') { if (signal.aborted) { disposeSsrResponseDetached(result, signal.reason) + return ssrResponse } - return ssrResponse + const body = ssrResponse.response.body + if (!body) { + return ssrResponse + } + let abort = () => {} + const [response, dispose] = ownResponseBody(ssrResponse.response, () => { + signal.removeEventListener('abort', abort) + }) + abort = () => dispose(signal.reason) + signal.addEventListener('abort', abort, { once: true }) + return { response, serverSsrCleanup: 'none' } } - const failed = (error: unknown) => { router?.serverSsr?.cleanup() console.error(error) } + if (!ssrResponse.response.body) { + disposeSsrResponseDetached(ssrResponse, signal.reason, failed) + return { response: ssrResponse.response, serverSsrCleanup: 'none' } + } const abort = () => { disposeSsrResponseDetached(ssrResponse, signal.reason, failed) } @@ -127,13 +191,25 @@ export function bindSsrResponseToRequest( return ssrResponse } +async function disposeReplacedSsrResponse( + result: HandlerCallbackResult, + reason?: unknown, +): Promise { + const ssrResponse = normalizeSsrResponse(result) + if (ssrResponse.serverSsrCleanup === 'stream') { + await ssrResponse.dispose(reason) + } else { + disposeSsrResponseDetached(ssrResponse, reason) + } + return ssrResponse +} + export async function replaceSsrResponse( result: HandlerCallbackResult, response: Response, reason?: unknown, ): Promise { - const ssrResponse = normalizeSsrResponse(result) - await disposeSsrResponse(ssrResponse, reason) + await disposeReplacedSsrResponse(result, reason) return { response, serverSsrCleanup: 'none' } } @@ -141,8 +217,7 @@ export async function stripSsrResponseBody( result: HandlerCallbackResult, reason?: unknown, ): Promise { - const ssrResponse = normalizeSsrResponse(result) - await disposeSsrResponse(ssrResponse, reason) + const ssrResponse = await disposeReplacedSsrResponse(result, reason) return { response: new Response(null, ssrResponse.response), serverSsrCleanup: 'none', diff --git a/packages/router-core/src/ssr/server.ts b/packages/router-core/src/ssr/server.ts index 93973999cb..415cead0ef 100644 --- a/packages/router-core/src/ssr/server.ts +++ b/packages/router-core/src/ssr/server.ts @@ -10,6 +10,7 @@ export { normalizeSsrResponse, replaceSsrResponse, stripSsrResponseBody, + _transferSsrResponse, } from './handlerCallback' export type { HandlerCallback, diff --git a/packages/router-core/src/ssr/transformStreamWithRouter.ts b/packages/router-core/src/ssr/transformStreamWithRouter.ts index 03c6ecf2e1..68a9f3a00b 100644 --- a/packages/router-core/src/ssr/transformStreamWithRouter.ts +++ b/packages/router-core/src/ssr/transformStreamWithRouter.ts @@ -796,10 +796,11 @@ function makeMainStream( safeError(reason) cleanup(reason) }) - if (cleanedUp || isDone()) + if (cleanedUp || isDone()) { return stream + } - // Transform the appStream + // Transform the appStream ;(async () => { try { while (true) { diff --git a/packages/router-core/src/stores.ts b/packages/router-core/src/stores.ts index 8c2beeb2e7..c3c5770d07 100644 --- a/packages/router-core/src/stores.ts +++ b/packages/router-core/src/stores.ts @@ -1,4 +1,4 @@ -import { arraysEqual, functionalUpdate } from './utils' +import { functionalUpdate } from './utils' import type { AnyRoute } from './route' import type { RouterState } from './router' @@ -157,9 +157,7 @@ export function createRouterStores( batch(() => { // Publish lane membership first so framework trees reconcile departures // before observers of a leaving route receive its tombstone. - if (!arraysEqual(previousIds, nextIds)) { - ids.set(nextIds) - } + ids.set(nextIds) for (const id of previousIds) { if (!nextIds.includes(id)) { diff --git a/packages/router-core/src/utils.ts b/packages/router-core/src/utils.ts index 91011bfa7f..5d15d9a839 100644 --- a/packages/router-core/src/utils.ts +++ b/packages/router-core/src/utils.ts @@ -716,12 +716,3 @@ export function buildDevStylesUrl( const normalizedBasepath = trimmedBasepath === '' ? '' : `/${trimmedBasepath}` return `${normalizedBasepath}/@tanstack-start/styles.css?routes=${encodeURIComponent(routeIds.join(','))}` } - -export function arraysEqual(a: Array, b: Array) { - if (a === b) return true - if (a.length !== b.length) return false - for (let i = 0; i < a.length; i++) { - if (a[i] !== b[i]) return false - } - return true -} diff --git a/packages/router-core/tests/build-location.test.ts b/packages/router-core/tests/build-location.test.ts index ce0d5c3c98..dabfa42629 100644 --- a/packages/router-core/tests/build-location.test.ts +++ b/packages/router-core/tests/build-location.test.ts @@ -9,7 +9,7 @@ import { import { _getUserHistoryState } from '../src/router' import { createTestRouter } from './routerTestUtils' -test('_getUserHistoryState removes volatile router bookkeeping but keeps mask payloads', () => { +test('_getUserHistoryState removes volatile router bookkeeping', () => { expect( _getUserHistoryState({ key: 'legacy-key', @@ -20,7 +20,11 @@ test('_getUserHistoryState removes volatile router bookkeeping but keeps mask pa __tempKey: 'temp-key', user: 'state', } as any), - ).toEqual({ user: 'state', __tempLocation: {}, __tempKey: 'temp-key' }) + ).toEqual({ + __tempLocation: {}, + __tempKey: 'temp-key', + user: 'state', + }) }) describe('buildLocation - params function receives parsed params', () => { diff --git a/packages/router-core/tests/callbacks.test.ts b/packages/router-core/tests/callbacks.test.ts index 7a1b062d4f..c320dc7bd7 100644 --- a/packages/router-core/tests/callbacks.test.ts +++ b/packages/router-core/tests/callbacks.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, test, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' import { BaseRootRoute, BaseRoute } from '../src' -import { createTestRouter } from './routerTestUtils' +import { createTestRouter, loadServerResponse } from './routerTestUtils' describe('callbacks', () => { const setup = ({ @@ -159,6 +159,103 @@ describe('callbacks', () => { }) }) + test('a throwing lifecycle callback cannot interrupt later callbacks or leave a client commit half-resolved', async () => { + const lifecycleError = new Error('root onStay failed') + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + const rootOnStay = vi.fn(() => { + throw lifecycleError + }) + const targetOnEnter = vi.fn() + const sourceOnLeave = vi.fn() + const rootRoute = new BaseRootRoute({ onStay: rootOnStay }) + const sourceRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/source', + onLeave: sourceOnLeave, + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + onEnter: targetOnEnter, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([sourceRoute, targetRoute]), + history: createMemoryHistory(), + }) + + // Establish the source lane through the same public navigation path used + // by the lifecycle contract tests above. + await router.navigate({ to: '/source' }) + rootOnStay.mockClear() + const onLoad = vi.fn() + const onResolved = vi.fn() + const unsubscribers = [ + router.subscribe('onLoad', onLoad), + router.subscribe('onResolved', onResolved), + ] + + try { + await router.navigate({ to: '/target' }) + + // Prove the throwing callback was reached before checking the work that + // must remain isolated from it. + expect(sourceOnLeave).toHaveBeenCalledOnce() + expect(rootOnStay).toHaveBeenCalledOnce() + expect(targetOnEnter).toHaveBeenCalledOnce() + expect(onLoad).toHaveBeenCalledOnce() + expect(onResolved).toHaveBeenCalledOnce() + expect(router.state).toMatchObject({ + status: 'idle', + location: { pathname: '/target' }, + resolvedLocation: { pathname: '/target' }, + }) + expect(router.state.matches.at(-1)?.routeId).toBe(targetRoute.id) + expect(log).toHaveBeenCalledWith(lifecycleError) + } finally { + for (const unsubscribe of unsubscribers) { + unsubscribe() + } + log.mockRestore() + } + }) + + test('a throwing lifecycle callback cannot reject an otherwise committed server load', async () => { + const lifecycleError = new Error('root onEnter failed') + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + const childOnEnter = vi.fn() + const rootRoute = new BaseRootRoute({ + onEnter: () => { + throw lifecycleError + }, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/child', + onEnter: childOnEnter, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([childRoute]), + history: createMemoryHistory({ initialEntries: ['/child'] }), + isServer: true, + }) + + try { + const response = await loadServerResponse(router, '/child') + + expect(response.status).toBe(200) + expect(log).toHaveBeenCalledWith(lifecycleError) + expect(childOnEnter).toHaveBeenCalledOnce() + expect(router.state).toMatchObject({ + status: 'idle', + location: { pathname: '/child' }, + resolvedLocation: { pathname: '/child' }, + }) + expect(router.state.matches.at(-1)?.routeId).toBe(childRoute.id) + } finally { + log.mockRestore() + } + }) + // Regression tests: switching lifecycle hooks to use routeId must NOT break // match-level caching, which still relies on match.id (routeId + params + loaderDeps). describe('same-route match caching', () => { diff --git a/packages/router-core/tests/hmr-refresh-lifecycle.test.ts b/packages/router-core/tests/hmr-refresh-lifecycle.test.ts index 2807c05951..cac5e942d1 100644 --- a/packages/router-core/tests/hmr-refresh-lifecycle.test.ts +++ b/packages/router-core/tests/hmr-refresh-lifecycle.test.ts @@ -433,7 +433,7 @@ describe('HMR route refresh', () => { expect(router.state.matches.at(-1)?.loaderData).toBe(3) }) - test('does not adopt a preload created by HMR preflight hooks', async () => { + test('ignores preloads triggered by HMR preflight hooks', async () => { let generation = 1 const rootRoute = new BaseRootRoute({}) const pageRoute = new BaseRoute({ @@ -459,4 +459,50 @@ describe('HMR route refresh', () => { expect(router.state.matches.at(-1)?.loaderData).toBe(2) }) + + test('ignores a preload reentered from cache cleanup during HMR', async () => { + let reentrantPreload: Promise | undefined + let router!: ReturnType + const reentrantLoader = vi.fn(() => 'reentrant data') + const rootRoute = new BaseRootRoute({}) + const pageRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/page', + }) + const cachedRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/cached', + loader: ({ abortController }) => { + abortController.signal.addEventListener( + 'abort', + () => { + reentrantPreload = router.preloadRoute({ to: '/reentrant' }) + }, + { once: true }, + ) + return 'cached data' + }, + }) + const reentrantRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/reentrant', + loader: reentrantLoader, + }) + router = createTestRouter({ + routeTree: rootRoute.addChildren([ + pageRoute, + cachedRoute, + reentrantRoute, + ]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + }) + + await router.load() + await router.preloadRoute({ to: '/cached' }) + await router._refreshRoute!() + expect(reentrantPreload).toBeDefined() + await reentrantPreload + + expect(reentrantLoader).not.toHaveBeenCalled() + }) }) diff --git a/packages/router-core/tests/issue-6371-search-default-normalization-abort.test.ts b/packages/router-core/tests/issue-6371-search-default-normalization-abort.test.ts deleted file mode 100644 index a52a7f183d..0000000000 --- a/packages/router-core/tests/issue-6371-search-default-normalization-abort.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { describe, expect, test, vi } from 'vitest' -import { createMemoryHistory } from '@tanstack/history' -import { BaseRootRoute, BaseRoute, trimPathRight } from '../src' -import { createTestRouter } from './routerTestUtils' - -/** - * Repro for https://github.com/TanStack/router/issues/6371 - * - * Entering a route directly via URL without search params, while - * validateSearch supplies defaults, makes the framework Transitioner commit a - * canonical replace-navigation (URL with the defaulted search) right after - * the mount load already started. That replace supersedes the first load - * pass and aborts its match's abortController. A loader that forwarded that - * signal to fetch() re-throws an AbortError ("signal is aborted without - * reason"): it belongs to the abandoned pass and must not surface as a route - * error; the follow-up canonical load must complete normally. - */ - -const waitForMacrotask = () => - new Promise((resolve) => { - setTimeout(resolve, 0) - }) - -describe('issue #6371: search default normalization aborts the mount load silently', () => { - test('canonical replace after validateSearch defaults does not surface AbortError', async () => { - const invocations: Array<{ resolve: () => void; signal: AbortSignal }> = [] - - const rootRoute = new BaseRootRoute({}) - const aboutRoute = new BaseRoute({ - getParentRoute: () => rootRoute, - path: '/about', - validateSearch: (search: Record) => ({ - page: typeof search.page === 'number' ? search.page : 1, - }), - loader: ({ abortController }: { abortController: AbortController }) => - new Promise((resolve, reject) => { - invocations.push({ - resolve: () => resolve('about data'), - signal: abortController.signal, - }) - abortController.signal.addEventListener('abort', () => { - const abortError = new Error('signal is aborted without reason') - abortError.name = 'AbortError' - reject(abortError) - }) - }), - }) - - const router = createTestRouter({ - routeTree: rootRoute.addChildren([aboutRoute]), - history: createMemoryHistory({ initialEntries: ['/about'] }), - }) - - // Mount load for the raw URL (no search params in the href yet). - const initialLoad = router.load() - await vi.waitFor(() => expect(invocations.length).toBe(1)) - - // What the framework Transitioner does on mount: rebuild the canonical - // location with validated search and replace if the public href differs. - const nextLocation = router.buildLocation({ - to: router.latestLocation.pathname, - search: true, - params: true, - hash: true, - state: true, - _includeValidateSearch: true, - } as any) - - expect(nextLocation.publicHref).toContain('page=1') - expect(trimPathRight(router.latestLocation.publicHref)).not.toBe( - trimPathRight(nextLocation.publicHref), - ) - - // In core (no history subscribers) commitLocation starts the second load. - void router.commitLocation({ ...nextLocation, replace: true } as any) - - await vi.waitFor(() => expect(invocations.length).toBe(2)) - - // The superseded mount-load generation was aborted (its fetch canceled). - expect(invocations[0]!.signal.aborted).toBe(true) - - // Give the aborted rejection time to (incorrectly) commit as an error. - await waitForMacrotask() - await waitForMacrotask() - - for (const match of router.state.matches) { - expect(match.status).not.toBe('error') - expect((match.error as Error | undefined)?.name).not.toBe('AbortError') - } - - invocations[1]!.resolve() - // Awaiting the superseded mount load also awaits the superseding chain. - await initialLoad - - // No runaway normalization loop: exactly one aborted + one canonical run. - expect(invocations.length).toBe(2) - - const aboutMatch = router.state.matches.find( - (match) => match.routeId === aboutRoute.id, - )! - expect(aboutMatch.status).toBe('success') - expect(aboutMatch.error).toBeUndefined() - expect(aboutMatch.loaderData).toBe('about data') - expect(aboutMatch.search).toEqual({ page: 1 }) - - expect(router.state.location.search).toEqual({ page: 1 }) - expect(router.latestLocation.publicHref).toContain('page=1') - }) -}) diff --git a/packages/router-core/tests/load.test.ts b/packages/router-core/tests/load.test.ts index 7e465c20cb..62d919f1bd 100644 --- a/packages/router-core/tests/load.test.ts +++ b/packages/router-core/tests/load.test.ts @@ -215,8 +215,7 @@ describe('beforeLoad skip or exec', () => { await router.navigate({ to: '/foo' }) expect(router.state.location.pathname).toBe('/foo') - // An identical navigation may latch onto the still-active whole lane. - expect(beforeLoad).toHaveBeenCalledTimes(1) + expect(beforeLoad).toHaveBeenCalledTimes(2) }) test('exec if rejected preload (notFound)', async () => { @@ -278,9 +277,9 @@ describe('beforeLoad skip or exec', () => { await Promise.resolve() await router.navigate({ to: '/foo' }) - expect(router.state.location.pathname).toBe('/bar') + expect(router.state.location.pathname).toBe('/foo') expect(router.state.matches.at(-1)?.status).toBe('success') - expect(beforeLoad).toHaveBeenCalledTimes(1) + expect(beforeLoad).toHaveBeenCalledTimes(2) }) test('exec if rejected preload (error)', async () => { @@ -458,7 +457,7 @@ describe('loader skip or exec', () => { expect(loader).toHaveBeenCalledTimes(2) }) - test('exec if pending preload returns notFound', async () => { + test('shares a pending preload notFound with navigation', async () => { const loader: Loader = vi.fn(async ({ preload }) => { await sleep(100) if (preload) throw notFound() @@ -471,8 +470,12 @@ describe('loader skip or exec', () => { await router.navigate({ to: '/foo' }) expect(router.state.location.pathname).toBe('/foo') - expect(router.state.matches.at(-1)?.status).toBe('success') - expect(loader).toHaveBeenCalledTimes(2) + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: '/foo', + status: 'notFound', + error: { isNotFound: true, routeId: '/foo' }, + }) + expect(loader).toHaveBeenCalledTimes(1) }) test('exec if rejected preload (redirect)', async () => { @@ -524,7 +527,7 @@ describe('loader skip or exec', () => { expect(loader).toHaveBeenCalledTimes(2) }) - test('exec if pending preload errors', async () => { + test('shares a pending preload error with navigation', async () => { const loader: Loader = vi.fn(async ({ preload }) => { await sleep(100) if (preload) throw new Error('error') @@ -537,8 +540,8 @@ describe('loader skip or exec', () => { await router.navigate({ to: '/foo' }) expect(router.state.location.pathname).toBe('/foo') - expect(router.state.matches.at(-1)?.status).toBe('success') - expect(loader).toHaveBeenCalledTimes(2) + expect(router.state.matches.at(-1)?.status).toBe('error') + expect(loader).toHaveBeenCalledTimes(1) }) }) diff --git a/packages/router-core/tests/loader-architecture-regressions.test.ts b/packages/router-core/tests/loader-architecture-regressions.test.ts index 1efc42cc73..823b760311 100644 --- a/packages/router-core/tests/loader-architecture-regressions.test.ts +++ b/packages/router-core/tests/loader-architecture-regressions.test.ts @@ -279,37 +279,6 @@ test('invalidation reruns the loader with same-id route context', async () => { }) }) -test('a preload cannot abort its controller after navigation adopts its lane', async () => { - const suspended = createControlledPromise() - let adoptedSignal: AbortSignal | undefined - const rootRoute = new BaseRootRoute({}) - const indexRoute = new BaseRoute({ - getParentRoute: () => rootRoute, - path: '/', - }) - const guardedRoute = new BaseRoute({ - getParentRoute: () => rootRoute, - path: '/guarded', - beforeLoad: ({ abortController }) => { - adoptedSignal = abortController.signal - throw suspended - }, - }) - const router = createTestRouter({ - routeTree: rootRoute.addChildren([indexRoute, guardedRoute]), - history: createMemoryHistory({ initialEntries: ['/'] }), - }) - - await router.load() - const preload = router.preloadRoute({ to: '/guarded' }) - const navigation = router.navigate({ to: '/guarded' }) - - await preload - expect(adoptedSignal?.aborted).toBe(false) - - await navigation -}) - test('a fast background candidate remains private when a reentrant navigation wins', async () => { let parentLoads = 0 let childLoads = 0 @@ -387,6 +356,464 @@ test('a fast background candidate remains private when a reentrant navigation wi expect(replacementSignal?.aborted).toBe(true) }) +test('a successor reload does not join a settled background candidate awaiting render acknowledgement', async () => { + let generation = 0 + const signals: Array = [] + const rootRoute = new BaseRootRoute({}) + const route = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/page', + validateSearch: (search: Record) => ({ + reload: Number(search.reload ?? 0), + }), + shouldReload: ({ location }) => { + return (location.search as { reload: number }).reload > 0 + }, + loader: ({ abortController }) => { + signals.push(abortController.signal) + return ++generation + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([route]), + history: createMemoryHistory({ initialEntries: ['/page?reload=0'] }), + }) + + await router.load() + + let transitionCount = 0 + let acknowledgeFirst!: () => void + router.startTransition = (commit) => { + commit() + transitionCount++ + if (transitionCount === 1) { + return new Promise((resolve) => { + acknowledgeFirst = () => resolve(false) + }) + } + return Promise.resolve(true) + } + + const first = router.navigate({ + to: '/page', + search: { reload: 1 }, + }) + await vi.waitFor(() => { + expect(generation).toBe(2) + expect(transitionCount).toBe(1) + }) + + await router.navigate({ + to: '/page', + search: { reload: 2 }, + }) + + await vi.waitFor(() => { + expect(generation).toBe(3) + expect(router.state.matches.at(-1)?.loaderData).toBe(3) + }) + + acknowledgeFirst() + await first + + expect(signals[1]?.aborted).toBe(true) + expect(signals[2]?.aborted).toBe(false) +}) + +test('a superseding navigation adopts a pending background loader before async beforeLoad', async () => { + const backgroundResult = createControlledPromise() + const beforeLoadStarted = createControlledPromise() + const beforeLoadGate = createControlledPromise() + const signals: Array = [] + let generation = 0 + + const rootRoute = new BaseRootRoute({}) + const route = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/page', + validateSearch: (search: Record) => ({ + reload: Number(search.reload ?? 0), + }), + shouldReload: ({ location }) => { + return (location.search as { reload: number }).reload === 1 + ? true + : undefined + }, + beforeLoad: async ({ search }) => { + if (search.reload === 2) { + beforeLoadStarted.resolve() + await beforeLoadGate + } + }, + loader: { + staleReloadMode: 'background', + handler: ({ abortController }) => { + signals.push(abortController.signal) + generation++ + return generation === 2 ? backgroundResult : generation + }, + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([route]), + history: createMemoryHistory({ initialEntries: ['/page?reload=0'] }), + }) + + await router.load() + await router.navigate({ + to: '/page', + search: { reload: 1 }, + }) + await vi.waitFor(() => expect(generation).toBe(2)) + + const successor = router.navigate({ + to: '/page', + search: { reload: 2 }, + }) + await beforeLoadStarted + + backgroundResult.resolve(2) + beforeLoadGate.resolve() + await successor + + await vi.waitFor(() => { + expect(router.state.matches.at(-1)?.loaderData).toBe(2) + }) + expect(generation).toBe(2) + expect(signals[1]?.aborted).toBe(false) + expect(router.state.matches.at(-1)?.preload).toBe(false) +}) + +test('rejecting a pending background loader preserves the active loader owner', async () => { + const backgroundResult = createControlledPromise() + const signals: Array = [] + let generation = 0 + + const rootRoute = new BaseRootRoute({}) + const pageRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/page', + validateSearch: (search: Record) => ({ + reload: Number(search.reload ?? 0), + }), + shouldReload: ({ location }) => { + return (location.search as { reload: number }).reload === 1 + }, + loader: { + staleReloadMode: 'background', + handler: ({ abortController }) => { + signals.push(abortController.signal) + generation++ + return generation === 2 ? backgroundResult : generation + }, + }, + }) + const otherRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/other', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([pageRoute, otherRoute]), + history: createMemoryHistory({ initialEntries: ['/page?reload=0'] }), + }) + + await router.load() + await router.navigate({ to: '/page', search: { reload: 1 } }) + await vi.waitFor(() => expect(generation).toBe(2)) + + await router.navigate({ to: '/page', search: { reload: 2 } }) + + expect(generation).toBe(2) + expect(signals[0]?.aborted).toBe(false) + expect(signals[1]?.aborted).toBe(true) + expect(router.state.matches.at(-1)?.loaderData).toBe(1) + + await router.navigate({ to: '/other' }) + expect(signals[0]?.aborted).toBe(true) +}) + +test('an ancestor beforeLoad failure releases an unconsumed background claim', async () => { + const backgroundResult = createControlledPromise() + const signals: Array = [] + let generation = 0 + + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + validateSearch: (search: Record) => ({ + phase: String(search.phase ?? 'initial'), + }), + beforeLoad: ({ search }) => { + if (search.phase === 'fail') { + throw new Error('parent failed') + } + }, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + shouldReload: ({ location }) => { + return (location.search as { phase: string }).phase === 'refresh' + ? true + : undefined + }, + staleTime: Infinity, + loader: { + staleReloadMode: 'background', + handler: ({ abortController }) => { + signals.push(abortController.signal) + generation++ + return generation === 2 ? backgroundResult : generation + }, + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory({ + initialEntries: ['/parent/child?phase=initial'], + }), + }) + + await router.load() + await router.navigate({ + to: '/parent/child', + search: { phase: 'refresh' }, + }) + await vi.waitFor(() => expect(generation).toBe(2)) + + await router.navigate({ + to: '/parent/child', + search: { phase: 'fail' }, + }) + + expect( + router.state.matches.find((match) => match.routeId === parentRoute.id), + ).toMatchObject({ status: 'error' }) + expect(signals[1]?.aborted).toBe(true) + + backgroundResult.resolve(2) + await router.navigate({ + to: '/parent/child', + search: { phase: 'recovered' }, + }) + + expect(generation).toBe(2) + expect(router.state.matches.at(-1)?.loaderData).toBe(1) +}) + +test('a background redirect beats a slower ancestor failure promoted over a child failure', async () => { + const parentFailureGate = createControlledPromise() + const middleRedirectGate = createControlledPromise() + const middleStarted = createControlledPromise() + const childFailureStarted = createControlledPromise() + + const rootRoute = new BaseRootRoute({ + validateSearch: (search: Record) => ({ + revision: Number(search.revision ?? 0), + }), + }) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loaderDeps: ({ search }) => ({ revision: search.revision }), + loader: async ({ location }) => { + if ((location.search as { revision: number }).revision === 1) { + await parentFailureGate + throw new Error('parent failed') + } + return 'parent data' + }, + }) + const middleRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/middle', + shouldReload: ({ location }) => + (location.search as { revision: number }).revision === 1, + loader: { + staleReloadMode: 'background', + handler: async ({ location }) => { + if ((location.search as { revision: number }).revision === 0) { + return 'middle data' + } + middleStarted.resolve() + await middleRedirectGate + throw redirect({ to: '/target' }) + }, + }, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => middleRoute, + path: '/child', + loaderDeps: ({ search }) => ({ revision: search.revision }), + loader: ({ location }) => { + if ((location.search as { revision: number }).revision === 1) { + childFailureStarted.resolve() + throw new Error('child failed') + } + return 'child data' + }, + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + parentRoute.addChildren([middleRoute.addChildren([childRoute])]), + targetRoute, + ]), + history: createMemoryHistory({ + initialEntries: ['/parent/middle/child?revision=0'], + }), + }) + + await router.load() + const navigation = router.navigate({ + to: '/parent/middle/child', + search: { revision: 1 }, + }) + await Promise.all([middleStarted, childFailureStarted]) + + middleRedirectGate.resolve() + parentFailureGate.resolve() + await navigation + + expect(router.state.location.pathname).toBe('/target') +}) + +test('navigation retries a failed preload loader while its error boundary is still loading', async () => { + const boundaryGate = createControlledPromise() + const failure = new Error('preload failed') + const loader = vi.fn(() => { + if (loader.mock.calls.length === 1) { + throw failure + } + return 'navigation data' + }) + const errorComponent = Object.assign(() => null, { + preload: vi.fn(() => boundaryGate), + }) + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + loader, + errorComponent, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, targetRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + const preload = router.preloadRoute({ to: '/target' }) + await vi.waitFor(() => { + expect(errorComponent.preload).toHaveBeenCalledOnce() + }) + + const navigation = router.navigate({ to: '/target' }) + await vi.waitFor(() => { + expect(loader).toHaveBeenCalledTimes(2) + }) + + boundaryGate.resolve() + await Promise.all([preload, navigation]) + + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: targetRoute.id, + status: 'success', + loaderData: 'navigation data', + }) +}) + +test('a preload started from onError retries the rejected loader flight', async () => { + const failure = new Error('first preload failed') + const loader = vi.fn(() => { + if (loader.mock.calls.length === 1) { + throw failure + } + return 'retry data' + }) + let retry: ReturnType | undefined + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + loader, + onError: () => { + retry ??= router.preloadRoute({ to: '/target' }) + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, targetRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + await router.preloadRoute({ to: '/target' }) + const retried = await retry + + expect(loader).toHaveBeenCalledTimes(2) + expect(retried?.at(-1)?.loaderData).toBe('retry data') +}) + +test('a preload started after a returned terminal result gets a fresh loader flight', async () => { + const firstResult = createControlledPromise>() + const loader = vi.fn(() => { + if (loader.mock.calls.length === 1) { + return firstResult + } + return 'retry data' + }) + let retry: ReturnType | undefined + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + loader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, targetRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + const preload = router.preloadRoute({ to: '/target' }) + await vi.waitFor(() => expect(loader).toHaveBeenCalledOnce()) + + // A real application can react to the same request promise in a later + // microtask, after the router has normalized its result but before a loader + // lane waiter resumes. + firstResult.then(() => { + queueMicrotask(() => { + retry = router.preloadRoute({ to: '/target' }) + }) + }) + firstResult.resolve(notFound()) + + await preload + await vi.waitFor(() => expect(retry).toBeDefined()) + const retried = await retry + + expect(loader).toHaveBeenCalledTimes(2) + expect(retried?.at(-1)?.loaderData).toBe('retry data') +}) + test('reentrant navigation from onError cancels stale serial work', async () => { const failure = new Error('stale context failed') const parentLoader = vi.fn(() => 'parent data') @@ -437,7 +864,7 @@ test('reentrant navigation from onError cancels stale serial work', async () => expect(parentLoader).not.toHaveBeenCalled() }) -test('a joined descendant loader redirect still wins after an ancestor loader fails', async () => { +test('an unrelated preload redirect does not override a navigation error', async () => { const childStarted = createControlledPromise() const childRedirect = createControlledPromise() const ancestorFailure = new Error('navigation parent failed') @@ -466,6 +893,7 @@ test('a joined descendant loader redirect still wins after an ancestor loader fa const childRoute = new BaseRoute({ getParentRoute: () => parentRoute, path: '/child', + loaderDeps: ({ search }) => ({ mode: search.mode }), loader: async () => { childLoads++ if (childLoads === 1) { @@ -503,12 +931,15 @@ test('a joined descendant loader redirect still wins after an ancestor loader fa childRedirect.resolve() await Promise.all([preload, navigation]) - expect(router.state.location.pathname).toBe('/target') - expect(router.state.matches.at(-1)).toMatchObject({ - routeId: targetRoute.id, - status: 'success', + expect(router.state.location.pathname).toBe('/parent/child') + expect(router.state.location.search).toEqual({ mode: 'navigation' }) + expect( + router.state.matches.find((match) => match.routeId === parentRoute.id), + ).toMatchObject({ + status: 'error', + error: ancestorFailure, }) - expect(childLoads).toBe(1) + expect(childLoads).toBe(2) }) test('a loaderDeps error is handled by the route error boundary', async () => { @@ -906,3 +1337,75 @@ test('an onLeave navigation to the in-flight destination joins it and enters onc expect(firstEnter).toHaveBeenCalledOnce() expect(firstStay).not.toHaveBeenCalled() }) + +test('a delayed legacy notFound preload cannot replace committed cache policy at another match index', async () => { + const preloadBeforeLoadStarted = createControlledPromise() + const releasePreloadBeforeLoad = createControlledPromise() + let generation = 0 + const loader = vi.fn(({ preload }: { preload: boolean }) => ({ + generation: ++generation, + preload, + })) + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + }) + const legacyNotFoundRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/404', + staleTime: Infinity, + gcTime: Infinity, + preloadStaleTime: 0, + preloadGcTime: Infinity, + beforeLoad: async ({ preload }) => { + if (preload) { + preloadBeforeLoadStarted.resolve() + await releasePreloadBeforeLoad + } + }, + loader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, parentRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + notFoundRoute: legacyNotFoundRoute, + }) + + await router.load() + + const delayedPreload = router.preloadRoute({ + to: '/parent/missing', + } as any) + try { + await preloadBeforeLoadStarted + await router.navigate({ to: '/missing' } as any) + + expect(loader).toHaveBeenCalledOnce() + expect(router.state.matches.at(-1)?.loaderData).toEqual({ + generation: 1, + preload: false, + }) + + releasePreloadBeforeLoad.resolve() + await delayedPreload + + expect(loader).toHaveBeenCalledOnce() + + await router.navigate({ to: '/' }) + await router.navigate({ to: '/missing' } as any) + + expect(loader).toHaveBeenCalledOnce() + expect(router.state.matches.at(-1)?.loaderData).toEqual({ + generation: 1, + preload: false, + }) + } finally { + releasePreloadBeforeLoad.resolve() + await Promise.allSettled([delayedPreload]) + } +}) diff --git a/packages/router-core/tests/masked-location-state-commit.test.ts b/packages/router-core/tests/masked-location-state-commit.test.ts index 9738b42261..fae2f275b2 100644 --- a/packages/router-core/tests/masked-location-state-commit.test.ts +++ b/packages/router-core/tests/masked-location-state-commit.test.ts @@ -8,7 +8,7 @@ afterEach(() => { }) describe('masked location remnants in history state', () => { - test('a same-href navigation clears an expired mask from history state', async () => { + test('a same-href navigation clears an expired mask payload from history state', async () => { const makeRoutes = () => { const rootRoute = new BaseRootRoute({}) const indexRoute = new BaseRoute({ diff --git a/packages/router-core/tests/pending-terminal-boundary.test.ts b/packages/router-core/tests/pending-terminal-boundary.test.ts new file mode 100644 index 0000000000..3d7959998d --- /dev/null +++ b/packages/router-core/tests/pending-terminal-boundary.test.ts @@ -0,0 +1,103 @@ +import { expect, test } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { + BaseRootRoute, + BaseRoute, + createControlledPromise, +} from '../src' +import { createTestRouter } from './routerTestUtils' + +test('a known parent error does not offer a descendant pending boundary while its error component loads', async () => { + const failure = new Error('parent failed') + const errorComponentStarted = createControlledPromise() + const errorComponentGate = createControlledPromise() + const childPendingStarted = createControlledPromise() + const childPendingGate = createControlledPromise() + const childComponentGate = createControlledPromise() + + const errorComponent = Object.assign(() => null, { + preload: () => { + errorComponentStarted.resolve() + return errorComponentGate + }, + }) + const childPendingComponent = Object.assign(() => null, { + preload: () => { + childPendingStarted.resolve() + return childPendingGate + }, + }) + const childComponent = Object.assign(() => null, { + preload: () => childComponentGate, + }) + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: () => { + throw failure + }, + errorComponent, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + loader: () => 'child data', + component: childComponent, + pendingComponent: childPendingComponent, + pendingMs: 0, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + + const transitions: Array< + Array<{ routeId: string; status: string; error: unknown }> + > = [] + const startTransition = router.startTransition + router.startTransition = (commit, expected) => { + transitions.push( + expected.map((match) => ({ + routeId: match.routeId, + status: match.status, + error: match.error, + })), + ) + return startTransition(commit, expected) + } + + const navigation = router.navigate({ to: '/parent/child' }) + await Promise.all([errorComponentStarted, childPendingStarted]) + + childPendingGate.resolve() + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect( + transitions.some((matches) => { + const parent = matches.find( + (match) => match.routeId === parentRoute.id, + ) + const child = matches.find((match) => match.routeId === childRoute.id) + return parent?.status === 'success' && child?.status === 'pending' + }), + ).toBe(false) + + errorComponentGate.resolve() + childComponentGate.resolve() + await navigation + + expect( + router.state.matches.find((match) => match.routeId === parentRoute.id), + ).toMatchObject({ status: 'error', error: failure }) +}) diff --git a/packages/router-core/tests/preload-adoption.test.ts b/packages/router-core/tests/preload-adoption.test.ts index 18ec7385d5..dcc7548225 100644 --- a/packages/router-core/tests/preload-adoption.test.ts +++ b/packages/router-core/tests/preload-adoption.test.ts @@ -8,16 +8,65 @@ afterEach(() => { }) /** - * Preload adoption edge cases. The happy path (navigation adopts an - * in-flight preload's successful loader run) and the control-flow - * non-leakage path are pinned in load.test.ts; this file pins the - * adoption boundary: a donor must have its loader genuinely in flight. A - * preload still in its serial phase can itself be waiting on the navigation - * through the borrow protocol, while a stale successful donor can still have - * fresh loader work pending behind its cached snapshot. + * Preload concurrency edge cases. A navigation owns a fresh serial context + * lane while same-ID loader work can still be shared with an active preload. + * Concurrent public preloads own independent context lanes while sharing + * same-ID loader work. */ -describe('preload adoption', () => { +describe('preload concurrency', () => { + test('a superseded preload cannot cache over a newer navigation generation', async () => { + const oldPreload = createControlledPromise() + const newerNavigation = createControlledPromise() + let reload = true + let loaderCalls = 0 + + const rootRoute = new BaseRootRoute({}) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + shouldReload: () => reload, + loader: { + staleReloadMode: 'blocking', + handler: () => { + loaderCalls++ + if (loaderCalls === 1) { + return 'initial' + } + return loaderCalls === 2 ? oldPreload : newerNavigation + }, + }, + }) + const otherRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/other', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([targetRoute, otherRoute]), + history: createMemoryHistory({ initialEntries: ['/target'] }), + }) + + await router.load() + + const preload = router.preloadRoute({ to: '/target' }) + await vi.waitFor(() => expect(loaderCalls).toBe(2)) + + const navigation = router.load() + await vi.waitFor(() => expect(loaderCalls).toBe(3)) + newerNavigation.resolve('newer navigation') + await navigation + + oldPreload.resolve('older preload') + await preload + + reload = false + await router.navigate({ to: '/other' }) + await router.navigate({ to: '/target' }) + + expect(loaderCalls).toBe(3) + expect(router.state.matches.at(-1)?.loaderData).toBe('newer navigation') + }) + test('navigation waits for fresh data from an in-flight stale preload revalidation', async () => { vi.useFakeTimers() vi.setSystemTime(0) @@ -78,17 +127,15 @@ describe('preload adoption', () => { }) await Promise.resolve() - // The navigation may share the revalidation, but it must not treat the - // stale cached snapshot as the completed result while fresh work runs. + // The navigation must share the pending revalidation rather than treating + // the stale cached snapshot as complete or starting a third generation. expect(revalidationGate.status).toBe('pending') expect(navigationSettled).toBe(false) revalidationGate.resolve({ notifications: ['fresh'] }) await Promise.all([revalidation, navigation]) - // A fix may either share the fresh revalidation or run a navigation - // loader of its own; correctness only requires publishing fresh data. - expect(loaderCalls).toBeGreaterThanOrEqual(2) + expect(loaderCalls).toBe(2) expect( router.state.matches.find( (match) => match.routeId === notificationsRoute.id, @@ -96,7 +143,7 @@ describe('preload adoption', () => { ).toEqual({ notifications: ['fresh'] }) }) - test('navigation adopts an identical preload still in its serial phase', async () => { + test('navigation does not wait for a pending preload beforeLoad and shares its loader', async () => { const beforeLoadGate = createControlledPromise() const preloadSerialStarted = createControlledPromise() let beforeLoadCalls = 0 @@ -110,13 +157,14 @@ describe('preload adoption', () => { const fooRoute = new BaseRoute({ getParentRoute: () => rootRoute, path: '/foo', - beforeLoad: async () => { + beforeLoad: async ({ preload }) => { beforeLoadCalls++ if (beforeLoadCalls === 1) { // Keep the shared lane's serial phase in flight. preloadSerialStarted.resolve() await beforeLoadGate } + return { source: preload ? 'preload' : 'navigation' } }, loader, }) @@ -134,20 +182,23 @@ describe('preload adoption', () => { expect(beforeLoadCalls).toBe(1) const navigation = router.navigate({ to: '/foo' }) - await Promise.resolve() - expect(loader).not.toHaveBeenCalled() + await vi.waitFor(() => expect(beforeLoadCalls).toBe(2)) + expect(loader).toHaveBeenCalledTimes(1) beforeLoadGate.resolve() await Promise.all([navigation, preload]) - expect(beforeLoadCalls).toBe(1) + expect(beforeLoadCalls).toBe(2) expect(loader).toHaveBeenCalledTimes(1) expect( - router.state.matches.find((match) => match.routeId === fooRoute.id) - ?.status, - ).toBe('success') + router.state.matches.find((match) => match.routeId === fooRoute.id), + ).toMatchObject({ + status: 'success', + context: { source: 'navigation' }, + loaderData: 'data', + }) }) - test("a sibling preload adopts another preload lane's in-flight loader", async () => { + test('identical preloads rerun beforeLoad while sharing the active loader', async () => { const loaderGate = createControlledPromise() const loaderStarted = createControlledPromise() let preloadBeforeLoadCalls = 0 @@ -185,7 +236,7 @@ describe('preload adoption', () => { const second = router.preloadRoute({ to: '/foo' } as any) await Promise.resolve() - expect(preloadBeforeLoadCalls).toBe(1) + expect(preloadBeforeLoadCalls).toBe(2) expect(loader).toHaveBeenCalledTimes(1) loaderGate.resolve('once') @@ -201,7 +252,7 @@ describe('preload adoption', () => { ).toBe('once') }) - test('a fulfilled undefined loader result is adopted as success', async () => { + test('a fulfilled undefined loader result is shared as success', async () => { const loaderGate = createControlledPromise() const loaderStarted = createControlledPromise() const loader = vi.fn(() => { @@ -246,13 +297,13 @@ describe('preload adoption', () => { expect(match?.loaderData).toBeUndefined() }) - test('navigation retries a failed joined preload without starving sibling work', async () => { + test('navigation shares a pending preload loader failure without starving sibling work', async () => { const preloadParentStarted = createControlledPromise() const preloadFailureGate = createControlledPromise() const navigationStarted = createControlledPromise() - const navigationParentRetryStarted = createControlledPromise() const childStarted = createControlledPromise() const childGate = createControlledPromise() + const sharedFailure = new Error('shared loader failed') let parentLoads = 0 let childLoads = 0 const rootRoute = new BaseRootRoute({}) @@ -268,15 +319,11 @@ describe('preload adoption', () => { navigationStarted.resolve() } }, - loader: async ({ preload }) => { + loader: async () => { parentLoads++ - if (preload) { - preloadParentStarted.resolve() - await preloadFailureGate - throw new Error('preload failed') - } - navigationParentRetryStarted.resolve() - return 'navigation data' + preloadParentStarted.resolve() + await preloadFailureGate + throw sharedFailure }, }) const childRoute = new BaseRoute({ @@ -303,33 +350,27 @@ describe('preload adoption', () => { expect(childLoads).toBe(1) const navigation = router.navigate({ to: '/parent/child' }) - await Promise.resolve() + await navigationStarted expect(preloadFailureGate.status).toBe('pending') expect(parentLoads).toBe(1) expect(childLoads).toBe(1) preloadFailureGate.resolve() // An ordinary ancestor failure waits for every started descendant so a - // later redirect can still win before navigation retries. + // later redirect can still win, while both lanes keep sharing the failed + // parent generation. childGate.resolve('child data') - await navigationStarted - await navigationParentRetryStarted - expect(parentLoads).toBe(2) - expect(childLoads).toBe(1) - await Promise.all([navigation, preload]) - expect(parentLoads).toBe(2) + expect(parentLoads).toBe(1) expect(childLoads).toBe(1) expect(router.state.location.pathname).toBe('/parent/child') expect( router.state.matches.find((match) => match.routeId === parentRoute.id) - ?.loaderData, - ).toBe('navigation data') + ).toMatchObject({ status: 'error', error: sharedFailure }) expect( router.state.matches.find((match) => match.routeId === childRoute.id) - ?.loaderData, - ).toBe('child data') + ).toMatchObject({ status: 'success', loaderData: 'child data' }) }) test('a route with preload disabled does not discard its preloaded ancestor', async () => { diff --git a/packages/router-core/tests/preload-beforeload-reuse.test.ts b/packages/router-core/tests/preload-beforeload-reuse.test.ts index 84cdb5bfde..b061233fb9 100644 --- a/packages/router-core/tests/preload-beforeload-reuse.test.ts +++ b/packages/router-core/tests/preload-beforeload-reuse.test.ts @@ -90,56 +90,9 @@ describe('preloaded loader reuse with fresh beforeLoad context', () => { }) }) - test('adopts beforeLoad and loader from an identical active preload', async () => { - const loaderGate = createControlledPromise() - const beforeLoad = vi.fn(({ preload }: { preload: boolean }) => ({ - guard: preload ? 'preloaded' : 'loaded', - })) - const loader = vi.fn(() => loaderGate) - const rootRoute = new BaseRootRoute({}) - const indexRoute = new BaseRoute({ - getParentRoute: () => rootRoute, - path: '/', - }) - const guardedRoute = new BaseRoute({ - getParentRoute: () => rootRoute, - path: '/guarded', - preloadStaleTime: Infinity, - beforeLoad, - loader, - }) - const router = createTestRouter({ - routeTree: rootRoute.addChildren([indexRoute, guardedRoute]), - history: createMemoryHistory({ initialEntries: ['/'] }), - }) - - await router.load() - const preload = router.preloadRoute({ to: '/guarded' }) - await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(1)) - - const navigation = router.navigate({ to: '/guarded' }) - await vi.waitFor(() => expect(beforeLoad).toHaveBeenCalledTimes(1)) - expect(beforeLoad.mock.calls.map(([context]) => context.preload)).toEqual([ - true, - ]) - expect(loader).toHaveBeenCalledTimes(1) - - loaderGate.resolve('shared loader data') - await Promise.all([preload, navigation]) - - expect(loader).toHaveBeenCalledTimes(1) - expect(router.state.matches.at(-1)?.context).toEqual({ guard: 'preloaded' }) - expect(router.state.matches.at(-1)?.loaderData).toBe('shared loader data') - }) - - test.each([ - { age: 50, expected: [false, true, false], guard: 'loaded' }, - { age: 100, expected: [false, true, false], guard: 'loaded' }, - ])( - 'reruns completed beforeLoad while retaining navigation-owned loader data at age $age', - async ({ age, expected, guard }) => { - vi.useFakeTimers() - vi.setSystemTime(1_000) + test( + 'reruns completed beforeLoad while retaining navigation-owned loader data', + async () => { const beforeLoad = vi.fn(({ preload }: { preload: boolean }) => ({ guard: preload ? 'preloaded' : 'loaded', })) @@ -152,8 +105,6 @@ describe('preloaded loader reuse with fresh beforeLoad context', () => { const guardedRoute = new BaseRoute({ getParentRoute: () => rootRoute, path: '/guarded', - staleTime: Infinity, - preloadStaleTime: 100, beforeLoad, shouldReload: false, loader, @@ -166,18 +117,16 @@ describe('preloaded loader reuse with fresh beforeLoad context', () => { await router.load() await router.navigate({ to: '/guarded' }) await router.navigate({ to: '/' }) - vi.setSystemTime(5_000) await router.preloadRoute({ to: '/guarded' }) - vi.setSystemTime(5_000 + age) await router.navigate({ to: '/guarded' }) expect(beforeLoad.mock.calls.map(([context]) => context.preload)).toEqual( - expected, + [false, true, false], ) expect(loader.mock.calls.map(([context]) => context.preload)).toEqual([ false, ]) - expect(router.state.matches.at(-1)?.context).toEqual({ guard }) + expect(router.state.matches.at(-1)?.context).toEqual({ guard: 'loaded' }) }, ) @@ -399,14 +348,18 @@ describe('preloaded loader reuse with fresh beforeLoad context', () => { expect(seen).toEqual([true, false]) }) - test('invalidation prevents adoption of context from an active preload', async () => { - const loaderGate = createControlledPromise() + test('invalidation prevents joining an active preload loader generation', async () => { + const preloadLoaderGate = createControlledPromise() let generation = 1 const beforeLoad = vi.fn(({ preload }: { preload: boolean }) => ({ generation, preload, })) - const loader = vi.fn(() => loaderGate) + const loader = vi.fn(({ context }: { context: { generation: number } }) => + context.generation === 1 + ? preloadLoaderGate + : `generation ${context.generation} data`, + ) const rootRoute = new BaseRootRoute({}) const indexRoute = new BaseRoute({ getParentRoute: () => rootRoute, @@ -433,19 +386,20 @@ describe('preloaded loader reuse with fresh beforeLoad context', () => { await router.invalidate() const navigation = router.navigate({ to: '/guarded' }) await vi.waitFor(() => expect(beforeLoad).toHaveBeenCalledTimes(2)) - loaderGate.resolve('shared loader data') + await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(2)) + preloadLoaderGate.resolve('generation 1 data') await Promise.all([preload, navigation]) expect(beforeLoad.mock.calls.map(([context]) => context.preload)).toEqual([ true, false, ]) - expect(loader).toHaveBeenCalledOnce() + expect(loader).toHaveBeenCalledTimes(2) expect(router.state.matches.at(-1)?.context).toEqual({ generation: 2, preload: false, }) - expect(router.state.matches.at(-1)?.loaderData).toBe('shared loader data') + expect(router.state.matches.at(-1)?.loaderData).toBe('generation 2 data') }) test('preload false does not cache beforeLoad context for navigation', async () => { diff --git a/packages/router-core/tests/preload-navigation-adoption.test.ts b/packages/router-core/tests/preload-navigation-adoption.test.ts index 675a69b037..589ea36496 100644 --- a/packages/router-core/tests/preload-navigation-adoption.test.ts +++ b/packages/router-core/tests/preload-navigation-adoption.test.ts @@ -3,12 +3,11 @@ import { createMemoryHistory } from '@tanstack/history' import { BaseRootRoute, BaseRoute, createControlledPromise } from '../src' import { createTestRouter } from './routerTestUtils' -// A navigation that adopts an in-flight preload's loader run must reuse that -// run: it must not abort the preload's loader signal or rerun the loader. This -// is generic router adoption coverage; issue #4476 is covered separately with -// the React Query observer-unmount sequence from its reproduction. -describe('navigation adopting an in-flight preload', () => { - test('adopted preload loader runs once and its signal is not aborted', async () => { +// Navigation owns its beforeLoad context, but a matching pending loader remains +// shared with speculation. Issue #4476 is covered separately with the React +// Query observer-unmount sequence from its reproduction. +describe('navigation joining an in-flight preload loader', () => { + test('reruns beforeLoad, reuses the loader, and keeps its signal alive', async () => { const loaderGate = createControlledPromise() const loaderStarted = createControlledPromise() const beforeLoad = vi.fn() @@ -50,12 +49,13 @@ describe('navigation adopting an in-flight preload', () => { expect(preloadSignal).toBeDefined() expect(preloadSignal?.aborted).toBe(false) - // The identical navigation adopts the whole active lane, including its - // already-completed beforeLoad context. + // The click is a fresh semantic lane even when its speculative loader is + // already pending. const navigation = router.navigate({ to: '/foo' }) - await Promise.resolve() + await vi.waitFor(() => expect(beforeLoad).toHaveBeenCalledTimes(2)) expect(beforeLoad.mock.calls.map(([context]) => context.preload)).toEqual([ true, + false, ]) expect(loaderGate.status).toBe('pending') expect(loader).toHaveBeenCalledTimes(1) @@ -65,18 +65,18 @@ describe('navigation adopting an in-flight preload', () => { loaderGate.resolve('adopted') await Promise.all([navigation, preload]) - // Loader ran exactly once; the adopted run's signal was never aborted. + // Loader ran exactly once; the joined run's signal was never aborted. expect(loader).toHaveBeenCalledTimes(1) expect(preloadSignal?.aborted).toBe(false) - // Navigation committed with the adopted loaderData. + // Navigation committed with the shared loaderData. const committed = router.state.matches.find( (match) => match.routeId === fooRoute.id, ) expect(committed?.status).toBe('success') expect(committed?.loaderData).toBe('adopted') - // Adoption transfers loader-data lifetime ownership to the active match. + // Publication transfers loader-data lifetime ownership to the active match. // The shared request remains alive while rendered, then aborts on unload. await router.navigate({ to: '/' }) expect(preloadSignal?.aborted).toBe(true) @@ -125,12 +125,13 @@ describe('navigation adopting an in-flight preload', () => { ]) expect(loader).toHaveBeenCalledTimes(1) - loaderGate.resolve('shared') + loaderGate.resolve('shared data') await Promise.all([preload, navigation]) expect(router.state.matches.at(-1)?.context).toEqual({ auth: true, authorization: 'true:false', }) + expect(router.state.matches.at(-1)?.loaderData).toBe('shared data') }) test('reruns beforeLoad when user location state changes at the same href', async () => { @@ -176,46 +177,11 @@ describe('navigation adopting an in-flight preload', () => { ]) expect(loader).toHaveBeenCalledTimes(1) - loaderGate.resolve('shared') + loaderGate.resolve('shared data') await Promise.all([preload, navigation]) expect(router.state.matches.at(-1)?.context).toMatchObject({ authorization: 'new:false', }) - }) - - test('reruns beforeLoad when the route tree changes during an active preload', async () => { - const loaderGate = createControlledPromise() - const beforeLoad = vi.fn() - const loader = vi.fn(() => loaderGate) - const createRouteTree = () => { - const rootRoute = new BaseRootRoute({}) - const indexRoute = new BaseRoute({ - getParentRoute: () => rootRoute, - path: '/', - }) - const guardedRoute = new BaseRoute({ - getParentRoute: () => rootRoute, - path: '/guarded', - beforeLoad, - loader, - }) - return rootRoute.addChildren([indexRoute, guardedRoute]) - } - const router = createTestRouter({ - routeTree: createRouteTree(), - history: createMemoryHistory({ initialEntries: ['/'] }), - }) - - await router.load() - const preload = router.preloadRoute({ to: '/guarded' }) - await vi.waitFor(() => expect(loader).toHaveBeenCalledOnce()) - - router.update({ ...router.options, routeTree: createRouteTree() }) - const navigation = router.navigate({ to: '/guarded' }) - await vi.waitFor(() => expect(beforeLoad).toHaveBeenCalledTimes(2)) - - loaderGate.resolve('shared') - await Promise.all([preload, navigation]) - expect(loader).toHaveBeenCalledOnce() + expect(router.state.matches.at(-1)?.loaderData).toBe('shared data') }) }) diff --git a/packages/router-core/tests/preload-public-cache-behavior.test.ts b/packages/router-core/tests/preload-public-cache-behavior.test.ts index 5a7b6b2b98..54acdca68c 100644 --- a/packages/router-core/tests/preload-public-cache-behavior.test.ts +++ b/packages/router-core/tests/preload-public-cache-behavior.test.ts @@ -9,8 +9,8 @@ afterEach(() => { }) // https://github.com/TanStack/router/issues/2980 -// Repeated child preloads must borrow a stale active parent instead of rerunning -// its loader. +// Repeated child preloads must reuse data from the accepted active parent +// generation instead of rerunning its loader. test('#2980: repeated child preloads do not rerun a stale active parent loader', async () => { vi.useFakeTimers() vi.setSystemTime(1_000) @@ -115,7 +115,7 @@ test('fresh navigation data keeps its gc policy when a preload reuses it', async // Public contract: clearCache must remain authoritative even if an older // preload finishes unrelated asset work after the clear. -test('clearCache during an in-flight preload cannot resurrect its borrowed data', async () => { +test('clearCache during an in-flight preload cannot resurrect reused accepted data', async () => { const headGate = createControlledPromise<{ meta: Array<{ title: string }> }>() @@ -321,6 +321,128 @@ test('clearCache detaches every discarded flight before abort listeners reenter' expect(router.state.matches.at(-1)?.loaderData).toBe('generation 2') }) +test('a throwing clearCache filter leaves every preload owned', async () => { + const firstGate = createControlledPromise() + const secondGate = createControlledPromise() + let firstSignal: AbortSignal | undefined + let secondSignal: AbortSignal | undefined + const rootRoute = new BaseRootRoute({}) + const homeRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const firstRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/first', + loader: ({ abortController }) => { + firstSignal = abortController.signal + return firstGate + }, + }) + const secondRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/second', + loader: ({ abortController }) => { + secondSignal = abortController.signal + return secondGate + }, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([homeRoute, firstRoute, secondRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + const firstPreload = router.preloadRoute({ to: '/first' }) + const secondPreload = router.preloadRoute({ to: '/second' }) + await vi.waitFor(() => { + expect(firstSignal).toBeDefined() + expect(secondSignal).toBeDefined() + }) + + const failure = new Error('filter failed') + expect(() => + router.clearCache({ + filter: (match) => { + if (match.routeId === firstRoute.id) { + return true + } + if (match.routeId === secondRoute.id) { + throw failure + } + return false + }, + }), + ).toThrow(failure) + + router.clearCache() + expect(firstSignal?.aborted).toBe(true) + expect(secondSignal?.aborted).toBe(true) + + firstGate.resolve('first') + secondGate.resolve('second') + await Promise.all([firstPreload, secondPreload]) +}) + +test('filtered clearCache preserves a parent flight owned by another preload', async () => { + const parentGate = createControlledPromise() + const parentError = new Error('shared parent failed') + const onError = vi.fn() + const parentLoader = vi.fn(() => parentGate) + const secondLoader = vi.fn(() => 'second') + const thirdLoader = vi.fn(() => 'third') + const rootRoute = new BaseRootRoute({}) + const homeRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: parentLoader, + onError, + }) + const firstRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/first', + }) + const secondRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/second', + loader: secondLoader, + }) + const thirdRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/third', + loader: thirdLoader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + homeRoute, + parentRoute.addChildren([firstRoute, secondRoute, thirdRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + const firstPreload = router.preloadRoute({ to: '/parent/first' }) + await vi.waitFor(() => expect(parentLoader).toHaveBeenCalledOnce()) + const secondPreload = router.preloadRoute({ to: '/parent/second' }) + await vi.waitFor(() => expect(secondLoader).toHaveBeenCalledOnce()) + + router.clearCache({ + filter: (match) => match.routeId === firstRoute.id, + }) + const thirdPreload = router.preloadRoute({ to: '/parent/third' }) + await vi.waitFor(() => expect(thirdLoader).toHaveBeenCalledOnce()) + + expect(parentLoader).toHaveBeenCalledOnce() + + parentGate.reject(parentError) + await Promise.all([firstPreload, secondPreload, thirdPreload]) + expect(onError).toHaveBeenCalledExactlyOnceWith(parentError) +}) + test('independent concurrent preloads both populate the cache', async () => { const firstGate = createControlledPromise() const secondGate = createControlledPromise() @@ -367,46 +489,64 @@ test('independent concurrent preloads both populate the cache', async () => { expect(secondLoader).toHaveBeenCalledOnce() }) -// Probe, not a required cancellation policy: invalidating the accepted route -// need not cancel unrelated private preload work, but the public operations -// must both settle and leave navigation usable. -test('invalidate and an unrelated in-flight preload both settle cleanly', async () => { - const loaderGate = createControlledPromise() - const loader = vi.fn(() => loaderGate) - const rootRoute = new BaseRootRoute({}) - const homeRoute = new BaseRoute({ - getParentRoute: () => rootRoute, - path: '/', - }) - const targetRoute = new BaseRoute({ - getParentRoute: () => rootRoute, - path: '/target', - preloadStaleTime: Infinity, - staleTime: Infinity, - loader, - }) - const router = createTestRouter({ - routeTree: rootRoute.addChildren([homeRoute, targetRoute]), - history: createMemoryHistory({ initialEntries: ['/'] }), - }) - - await router.load() - const preload = router.preloadRoute({ to: '/target' }) - await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(1)) - expect(loaderGate.status).toBe('pending') - await router.invalidate() - expect(loaderGate.status).toBe('pending') - loaderGate.resolve('target data') - await preload - - await router.navigate({ to: '/target' }) - expect(router.state.location.pathname).toBe('/target') - expect( - router.state.matches.find((match) => match.routeId === targetRoute.id) - ?.loaderData, - ).toBe('target data') - expect(loader).toHaveBeenCalledTimes(1) -}) +test.each([ + ['unfiltered', undefined, true], + ['filtered affected', 'target', true], + ['filtered unaffected', 'home', false], +] as const)( + '%s invalidation handles an active preload according to its filter scope', + async (_mode, selected, shouldRetire) => { + const oldLoaderGate = createControlledPromise() + const signals: Array = [] + const loader = vi.fn( + ({ abortController }: { abortController: AbortController }) => { + signals.push(abortController.signal) + return loader.mock.calls.length === 1 + ? oldLoaderGate + : 'new-generation' + }, + ) + const rootRoute = new BaseRootRoute({}) + const homeRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + preloadStaleTime: Infinity, + staleTime: Infinity, + loader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([homeRoute, targetRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + const preload = router.preloadRoute({ to: '/target' }) + await vi.waitFor(() => expect(loader).toHaveBeenCalledOnce()) + + await (selected + ? router.invalidate({ + filter: (match) => + match.routeId === + (selected === 'target' ? targetRoute.id : homeRoute.id), + }) + : router.invalidate()) + const retired = signals[0]!.aborted + oldLoaderGate.resolve('old-generation') + await preload + await router.navigate({ to: '/target' }) + + expect(retired).toBe(shouldRetire) + expect(loader).toHaveBeenCalledTimes(shouldRetire ? 2 : 1) + expect(router.state.matches.at(-1)).toMatchObject({ + invalid: false, + loaderData: shouldRetire ? 'new-generation' : 'old-generation', + }) + }, +) // Preload lanes are not cancelled by unrelated navigations: an in-flight // async beforeLoad runs to completion, its loader still executes and seeds diff --git a/packages/router-core/tests/preload-public-signal-lifetime.test.ts b/packages/router-core/tests/preload-public-signal-lifetime.test.ts index b1bc87e65c..c83b6a54be 100644 --- a/packages/router-core/tests/preload-public-signal-lifetime.test.ts +++ b/packages/router-core/tests/preload-public-signal-lifetime.test.ts @@ -4,18 +4,18 @@ import { BaseRootRoute, BaseRoute, createControlledPromise } from '../src' import { createTestRouter } from './routerTestUtils' // Public contract: the AbortSignal supplied to loader code belongs to the -// accepted data generation. Adopting preloaded data keeps it alive; accepting +// accepted data generation. Reusing preloaded data keeps it alive; accepting // replacement data aborts it; unloading aborts the replacement generation. -test('an adopted loader signal lives until public replacement and unload', async () => { +test('a reused preload loader signal lives until public replacement and unload', async () => { const replacementGate = createControlledPromise<{ generation: number }>() let generation = 0 - let adoptedSignal: AbortSignal | undefined + let preloadSignal: AbortSignal | undefined let replacementSignal: AbortSignal | undefined const loader = vi.fn( ({ abortController }: { abortController: AbortController }) => { generation++ if (generation === 1) { - adoptedSignal = abortController.signal + preloadSignal = abortController.signal return { generation } } @@ -46,7 +46,7 @@ test('an adopted loader signal lives until public replacement and unload', async await router.load() await router.preloadRoute({ to: '/reports' }) - expect(adoptedSignal?.aborted).toBe(false) + expect(preloadSignal?.aborted).toBe(false) await router.navigate({ to: '/reports' }) expect(loader).toHaveBeenCalledTimes(1) @@ -54,15 +54,15 @@ test('an adopted loader signal lives until public replacement and unload', async router.state.matches.find((match) => match.routeId === reportsRoute.id) ?.loaderData, ).toEqual({ generation: 1 }) - expect(adoptedSignal?.aborted).toBe(false) + expect(preloadSignal?.aborted).toBe(false) const replacement = router.invalidate({ forcePending: true }) await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(2)) expect(replacementGate.status).toBe('pending') // Until replacement data is accepted, the currently rendered data still - // owns the adopted signal. - expect(adoptedSignal?.aborted).toBe(false) + // owns the preload generation's signal. + expect(preloadSignal?.aborted).toBe(false) expect(replacementSignal?.aborted).toBe(false) replacementGate.resolve({ generation: 2 }) @@ -71,14 +71,14 @@ test('an adopted loader signal lives until public replacement and unload', async router.state.matches.find((match) => match.routeId === reportsRoute.id) ?.loaderData, ).toEqual({ generation: 2 }) - expect(adoptedSignal?.aborted).toBe(true) + expect(preloadSignal?.aborted).toBe(true) expect(replacementSignal?.aborted).toBe(false) await router.navigate({ to: '/' }) expect(replacementSignal?.aborted).toBe(true) }) -test('a superseded preload releases its borrowed loader signal lease', async () => { +test('a superseded preload releases its lease on a reused loader generation', async () => { let parentSignal: AbortSignal | undefined let navigation!: Promise diff --git a/packages/router-core/tests/public-hydration-contract.test.ts b/packages/router-core/tests/public-hydration-contract.test.ts index b721135e2f..6d053b7cb9 100644 --- a/packages/router-core/tests/public-hydration-contract.test.ts +++ b/packages/router-core/tests/public-hydration-contract.test.ts @@ -1,6 +1,6 @@ import { runInNewContext } from 'node:vm' import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' -import { createMemoryHistory } from '@tanstack/history' +import { createBrowserHistory, createMemoryHistory } from '@tanstack/history' import { BaseRootRoute, BaseRoute, @@ -11,11 +11,12 @@ import { hydrate } from '../src/ssr/client' import { attachRouterServerSsrUtils } from '../src/ssr/ssr-server' import { dehydrateSsrMatchId } from '../src/ssr/ssr-match-id' import { createTestRouter } from './routerTestUtils' -import type { AnyRouteMatch, AnyRouter } from '../src' +import type { AnyRouteMatch, AnyRouter, LocationRewrite } from '../src' import type { DehydratedRouter, TsrSsrGlobal } from '../src/ssr/types' import type { ServerManifest } from '../src/manifest' const testManifest: ServerManifest = { routes: {} } +const browserWindow = window async function dehydrateToBootstrap(router: AnyRouter): Promise { attachRouterServerSsrUtils({ router, manifest: testManifest }) @@ -452,7 +453,7 @@ describe('public hydration contracts', () => { childRoute.id, ]) expect(router.state.matches[1]).toMatchObject({ - status: 'error', + status: 'pending', error: serverError, }) @@ -986,6 +987,77 @@ describe('public hydration contracts', () => { expect(router.state.matches.at(-1)?.loaderData).toBe('client new') }) + test('does not hand transported context to a replaced public URL generation', async () => { + vi.spyOn(browserWindow, 'scrollTo').mockImplementation(() => undefined) + browserWindow.history.replaceState({}, '', '/public-a') + const history = createBrowserHistory({ window: browserWindow }) + const rewrite: LocationRewrite = { + input: ({ url }) => { + if (url.pathname === '/public-a' || url.pathname === '/public-b') { + url.pathname = '/internal' + } + return url + }, + output: ({ url }) => url, + } + const beforeLoad = vi.fn(({ location }: { location: any }) => ({ + publicHref: location.publicHref, + })) + const loader = vi.fn( + ({ context: routeContext }: { context: { publicHref: string } }) => + routeContext.publicHref, + ) + const rootRoute = new BaseRootRoute({ beforeLoad }) + const internalRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/internal', + ssr: false, + loader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([internalRoute]), + history, + rewrite, + isServer: false, + }) + const matches = router.matchRoutes(router.stores.location.get()) + installHydrationPayload(mockWindow, [ + { + i: dehydrateSsrMatchId(matches[0]!.id), + s: 'success', + b: { publicHref: '/public-a' }, + ssr: true, + u: Date.now(), + }, + { + i: dehydrateSsrMatchId(matches[1]!.id), + s: 'pending', + ssr: false, + u: Date.now(), + }, + ]) + + try { + await hydrate(router) + browserWindow.history.replaceState( + browserWindow.history.state, + '', + '/public-b', + ) + + await router.load() + + expect( + beforeLoad.mock.calls.map(([options]) => options.location.publicHref), + ).toEqual(['/public-b']) + expect(loader).toHaveBeenCalledTimes(1) + expect(router.state.matches.at(-1)?.loaderData).toBe('/public-b') + } finally { + history.destroy() + browserWindow.history.replaceState({}, '', '/') + } + }) + test('keeps a hydration handoff when a provider initializes an empty context', async () => { const rootBeforeLoad = vi.fn(() => ({ source: 'client' })) const rootLoader = vi.fn(() => 'client root') @@ -1042,7 +1114,7 @@ describe('public hydration contracts', () => { expect(router.state.matches[1]).toMatchObject({ loaderData: 'server' }) }) - test('does not hand transported context across a router-context generation change', async () => { + test('does not hand transported context across an invalidation', async () => { const rootBeforeLoad = vi.fn(({ context }: { context: any }) => ({ auth: context.auth, })) @@ -1084,7 +1156,7 @@ describe('public hydration contracts', () => { ...router.options, context: { auth: 'client new' }, }) - await router.load() + await router.invalidate() expect(rootBeforeLoad).toHaveBeenCalledTimes(1) expect(pageLoader).toHaveBeenCalledTimes(1) @@ -1178,7 +1250,7 @@ describe('public hydration contracts', () => { expect(router.state.matches.at(-1)).toMatchObject({ routeId: pageRoute.id, - status: 'success', + status: 'pending', error: undefined, loaderData: 'server data', }) @@ -1233,7 +1305,7 @@ describe('public hydration contracts', () => { expect(router.state.location.pathname).toBe('/source') expect(router.state.matches.at(-1)).toMatchObject({ routeId: sourceRoute.id, - status: 'success', + status: 'pending', }) expect(router.state.resolvedLocation).toBeUndefined() @@ -1316,12 +1388,18 @@ describe('public hydration contracts', () => { }) }) - test('turns a hydrated normal-component chunk failure into a route error without rerunning loader data', async () => { + test('turns a hydrated normal-component chunk failure into a route error while retaining verified assets and loader data', async () => { const chunkError = new Error('page component failed to load') const componentPreload = vi.fn(() => Promise.reject(chunkError)) const Page = Object.assign(() => 'Page', { preload: componentPreload }) const loader = vi.fn(() => 'client data') const onError = vi.fn() + const head = vi.fn(({ loaderData }) => ({ + meta: [{ title: `Page: ${loaderData}` }], + })) + const scripts = vi.fn(({ loaderData }) => [ + { children: `window.pageData = ${JSON.stringify(loaderData)}` }, + ]) const rootRoute = new BaseRootRoute({}) const pageRoute = new BaseRoute({ getParentRoute: () => rootRoute, @@ -1330,6 +1408,8 @@ describe('public hydration contracts', () => { errorComponent: () => 'Page error', loader, onError, + head, + scripts, }) const router = createTestRouter({ routeTree: rootRoute.addChildren([pageRoute]), @@ -1352,9 +1432,11 @@ describe('public hydration contracts', () => { expect(router.state.matches.at(-1)).toMatchObject({ routeId: pageRoute.id, - status: 'success', + status: 'pending', error: undefined, loaderData: 'server data', + meta: [{ title: 'Page: server data' }], + scripts: [{ children: 'window.pageData = "server data"' }], }) expect(router.state.resolvedLocation).toBeUndefined() expect(onError).not.toHaveBeenCalled() @@ -1367,12 +1449,63 @@ describe('public hydration contracts', () => { status: 'error', error: chunkError, loaderData: 'server data', + meta: [{ title: 'Page: server data' }], + scripts: [{ children: 'window.pageData = "server data"' }], }) expect(onError).toHaveBeenCalledTimes(1) expect(onError).toHaveBeenCalledWith(chunkError) expect(loader).not.toHaveBeenCalled() }) + test('a context failure does not project assets for the failed hydrated route', async () => { + const contextError = new Error('root context failed') + const rootHead = vi.fn(() => ({ + meta: [{ title: 'Server-only root title' }], + })) + const rootScripts = vi.fn(() => [ + { children: 'window.serverOnlyRoot = true' }, + ]) + const rootRoute = new BaseRootRoute({ + context: () => { + throw contextError + }, + head: rootHead, + scripts: rootScripts, + }) + const pageRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/page', + component: () => 'Page', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + isServer: false, + }) + const matches = router.matchRoutes(router.stores.location.get()) + installHydrationPayload( + mockWindow, + matches.map((match) => ({ + i: dehydrateSsrMatchId(match.id), + s: 'success' as const, + ssr: true, + u: Date.now(), + })), + ) + + await hydrate(router) + + expect(rootHead).not.toHaveBeenCalled() + expect(rootScripts).not.toHaveBeenCalled() + expect(router.state.matches[0]).toMatchObject({ + routeId: rootRoute.id, + status: 'pending', + }) + expect(router.state.matches[0]).not.toHaveProperty('meta') + expect(router.state.matches[0]).not.toHaveProperty('scripts') + expect(router.state.resolvedLocation).toBeUndefined() + }) + test('retries from the earliest failed hydration chunk regardless of rejection order', async () => { const rootChunkError = new Error('root component failed to load') const childChunkError = new Error('child component failed to load') @@ -1388,10 +1521,9 @@ describe('public hydration contracts', () => { const ChildComponent = Object.assign(() => 'Child', { preload: childComponentPreload, }) - const rootContext = vi.fn(() => ({})) const rootRoute = new BaseRootRoute({ component: RootComponent, - context: rootContext, + context: () => ({}), }) const childRoute = new BaseRoute({ getParentRoute: () => rootRoute, @@ -1422,7 +1554,10 @@ describe('public hydration contracts', () => { rootChunkGate.reject(rootChunkError) await expect(hydration).resolves.toBeUndefined() - expect(rootContext).not.toHaveBeenCalled() + expect(router.state.matches[0]).toMatchObject({ + routeId: rootRoute.id, + status: 'pending', + }) expect(router.state.resolvedLocation).toBeUndefined() }) @@ -1439,10 +1574,9 @@ describe('public hydration contracts', () => { return childChunkGate }, }) - const rootContext = vi.fn(() => ({})) const rootRoute = new BaseRootRoute({ component: RootComponent, - context: rootContext, + context: () => ({}), }) const childRoute = new BaseRoute({ getParentRoute: () => rootRoute, @@ -1479,7 +1613,10 @@ describe('public hydration contracts', () => { await hydration } - expect(rootContext).not.toHaveBeenCalled() + expect(router.state.matches[0]).toMatchObject({ + routeId: rootRoute.id, + status: 'pending', + }) expect(router.state.resolvedLocation).toBeUndefined() }) }) 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 7b13e6a0c9..4c8f7f0b56 100644 --- a/packages/router-core/tests/public-preload-lane-contract.test.ts +++ b/packages/router-core/tests/public-preload-lane-contract.test.ts @@ -13,10 +13,12 @@ afterEach(() => { vi.useRealTimers() }) -describe('public preload lane contracts', () => { - test('completed preload matches retain context without caching beforeLoad context', async () => { +describe('client loading contracts', () => { + test('completed preloads cache loader data without caching beforeLoad context', async () => { let beforeLoadGeneration = 0 - const loader = vi.fn(() => 'loader data') + const loader = vi.fn( + ({ context }: { context: { generation: number } }) => context.generation, + ) const rootRoute = new BaseRootRoute({}) const indexRoute = new BaseRoute({ getParentRoute: () => rootRoute, @@ -49,73 +51,26 @@ describe('public preload lane contracts', () => { generation: 2, }) expect(loader).toHaveBeenCalledTimes(1) + expect(router.state.matches.at(-1)?.loaderData).toBe(1) }) - test('active preloads share only an identical full lane', async () => { - const gates = new Map< - number, - ReturnType> - >() - const beforeLoad = vi.fn(({ search }: { search: { version: number } }) => { - const gate = createControlledPromise() - gates.set(search.version, gate) - return gate - }) - const rootRoute = new BaseRootRoute({}) - const indexRoute = new BaseRoute({ - getParentRoute: () => rootRoute, - path: '/', - }) - const targetRoute = new BaseRoute({ - getParentRoute: () => rootRoute, - path: '/target', - validateSearch: (search: Record) => ({ - version: Number(search.version), - }), - beforeLoad, - }) - const router = createTestRouter({ - routeTree: rootRoute.addChildren([indexRoute, targetRoute]), - history: createMemoryHistory({ initialEntries: ['/'] }), - stringifySearch: () => '', - }) - - await router.load() - const first = router.preloadRoute({ - to: '/target', - search: { version: 1 }, - }) - await vi.waitFor(() => expect(beforeLoad).toHaveBeenCalledTimes(1)) - const second = router.preloadRoute({ - to: '/target', - search: { version: 2 }, - }) - await vi.waitFor(() => expect(beforeLoad).toHaveBeenCalledTimes(2)) - gates.get(2)!.resolve(undefined) - await Promise.all([first, second]) - - const third = router.preloadRoute({ - to: '/target', - search: { version: 3 }, - }) - await vi.waitFor(() => expect(beforeLoad).toHaveBeenCalledTimes(3)) - const identical = router.preloadRoute({ - to: '/target', - search: { version: 3 }, - }) - await Promise.resolve() - expect(beforeLoad).toHaveBeenCalledTimes(3) - gates.get(3)!.resolve(undefined) - await Promise.all([third, identical]) - }) - - test('navigation adopts beforeLoad from an identical active preload lane', async () => { + test('navigation owns beforeLoad for equivalent Date search', async () => { const beforeLoadGate = createControlledPromise() const beforeLoad = vi.fn(async ({ preload }: { preload: boolean }) => { await beforeLoadGate return { source: preload ? 'preload' : 'navigation' } }) const loader = vi.fn(() => 'loader data') + const parseSearch = (searchStr: string) => { + const day = new URLSearchParams(searchStr).get('day') + return day ? { day: new Date(day) } : {} + } + const stringifySearch = (search: Record) => { + const day = search.day + return day instanceof Date + ? `?day=${encodeURIComponent(day.toISOString())}` + : '' + } const rootRoute = new BaseRootRoute({}) const indexRoute = new BaseRoute({ getParentRoute: () => rootRoute, @@ -124,301 +79,115 @@ describe('public preload lane contracts', () => { const targetRoute = new BaseRoute({ getParentRoute: () => rootRoute, path: '/target', + validateSearch: (search: Record) => ({ + day: + search.day instanceof Date + ? search.day + : new Date(String(search.day)), + }), beforeLoad, loader, }) const router = createTestRouter({ routeTree: rootRoute.addChildren([indexRoute, targetRoute]), history: createMemoryHistory({ initialEntries: ['/'] }), + parseSearch, + stringifySearch, + }) + const iso = '2026-07-24T10:00:00.000Z' + const options = () => ({ + to: '/target' as const, + search: { day: new Date(iso) }, }) + const preloadOptions = options() + const navigationOptions = options() + + expect(preloadOptions.search.day).not.toBe(navigationOptions.search.day) await router.load() - const preload = router.preloadRoute({ to: '/target' }) - await vi.waitFor(() => expect(beforeLoad).toHaveBeenCalledTimes(1)) + const preload = router.preloadRoute(preloadOptions) + let navigation: ReturnType | undefined - const navigation = router.navigate({ to: '/target' }) - await Promise.resolve() - expect(beforeLoad).toHaveBeenCalledTimes(1) + try { + await vi.waitFor(() => expect(beforeLoad).toHaveBeenCalledOnce()) - beforeLoadGate.resolve() - await Promise.all([preload, navigation]) + navigation = router.navigate(navigationOptions) + await vi.waitFor(() => expect(beforeLoad).toHaveBeenCalledTimes(2)) - expect(beforeLoad.mock.calls.map(([context]) => context.preload)).toEqual([ - true, - ]) - expect(loader).toHaveBeenCalledTimes(1) - expect(router.state.matches.at(-1)).toMatchObject({ - context: { source: 'preload' }, - loaderData: 'loader data', - }) + beforeLoadGate.resolve() + await Promise.all([preload, navigation]) + + expect(beforeLoad.mock.calls.map(([context]) => context.preload)).toEqual( + [true, false], + ) + expect(loader).toHaveBeenCalledOnce() + expect(router.state.location.search).toEqual({ day: new Date(iso) }) + expect(router.state.matches.at(-1)).toMatchObject({ + context: { source: 'navigation' }, + loaderData: 'loader data', + }) + } finally { + beforeLoadGate.resolve() + await Promise.allSettled([preload, navigation]) + } }) - test('a depth-20 navigation does not adopt a depth-0 active preload redirect', async () => { + test('identical preloads rerun beforeLoad and share the redirected loader', async () => { const redirectGate = createControlledPromise() - const sharedBeforeLoad = vi.fn(async (_context: { preload: boolean }) => { + const loaderGate = createControlledPromise() + const sourceBeforeLoad = vi.fn(async () => { await redirectGate throw redirect({ to: '/target' }) }) + const redirectedLoader = vi.fn(() => loaderGate) const rootRoute = new BaseRootRoute({}) const indexRoute = new BaseRoute({ getParentRoute: () => rootRoute, path: '/', }) - const hopRoute = new BaseRoute({ - getParentRoute: () => rootRoute, - path: '/hop/$hop', - beforeLoad: ({ params }) => { - const hop = Number(params.hop) - if (hop < 19) { - throw redirect({ - to: '/hop/$hop', - params: { hop: String(hop + 1) }, - } as any) - } - throw redirect({ to: '/shared' }) - }, - }) - const sharedRoute = new BaseRoute({ + const sourceRoute = new BaseRoute({ getParentRoute: () => rootRoute, - path: '/shared', - beforeLoad: sharedBeforeLoad, + path: '/source', + beforeLoad: sourceBeforeLoad, }) const targetRoute = new BaseRoute({ getParentRoute: () => rootRoute, path: '/target', + loader: redirectedLoader, }) const router = createTestRouter({ - routeTree: rootRoute.addChildren([ - indexRoute, - hopRoute, - sharedRoute, - targetRoute, - ]), + routeTree: rootRoute.addChildren([indexRoute, sourceRoute, targetRoute]), history: createMemoryHistory({ initialEntries: ['/'] }), }) - let preload: Promise | undefined - let navigation: Promise | undefined - try { - await router.load() - preload = router.preloadRoute({ to: '/shared' }) - await vi.waitFor(() => expect(sharedBeforeLoad).toHaveBeenCalledOnce()) - - navigation = router.navigate({ - to: '/hop/$hop', - params: { hop: '0' }, - } as any) - await vi.waitFor(() => expect(sharedBeforeLoad).toHaveBeenCalledTimes(2)) + await router.load() + const first = router.preloadRoute({ to: '/source' }) + const second = router.preloadRoute({ to: '/source' }) + await vi.waitFor(() => expect(sourceBeforeLoad).toHaveBeenCalledTimes(2)) + let secondSettled = false + void second.then(() => { + secondSettled = true + }) - redirectGate.resolve() - await Promise.all([preload, navigation]) + redirectGate.resolve() + await vi.waitFor(() => expect(redirectedLoader).toHaveBeenCalledOnce()) + expect(secondSettled).toBe(false) - expect( - sharedBeforeLoad.mock.calls.map(([context]) => context.preload), - ).toEqual([true, false]) - expect(router.state.location.pathname).toBe('/shared') - expect( - router.state.matches.find((match) => match.status !== 'success'), - ).toMatchObject({ - routeId: rootRoute.id, - status: 'error', - error: expect.objectContaining({ message: 'Too many redirects' }), - }) - expect(router.state.status).toBe('idle') - } finally { - redirectGate.resolve() - const activeWork: Array> = [] - if (preload) { - activeWork.push(preload) - } - if (navigation) { - activeWork.push(navigation) - } - await Promise.allSettled(activeWork) - } + loaderGate.resolve() + await Promise.all([first, second]) + expect(secondSettled).toBe(true) }) - test.each([ - { - difference: 'pathname', - preloadMask: { to: '/mask-a' }, - navigationMask: { to: '/mask-b' }, - preloadPathname: '/mask-a', - navigationPathname: '/mask-b', - preloadUnmaskOnReload: undefined, - navigationUnmaskOnReload: undefined, - preloadSearchSource: undefined, - navigationSearchSource: undefined, - preloadStateSource: undefined, - navigationStateSource: undefined, - }, - { - difference: 'unmaskOnReload', - preloadMask: { to: '/mask-a' }, - 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', - preloadMask: { to: '/mask-a', search: { source: 'preload' } }, - 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', - preloadMask: { to: '/mask-a', state: { source: 'preload' } }, - 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', - async ({ - preloadMask, - navigationMask, - preloadPathname, - navigationPathname, - preloadUnmaskOnReload, - navigationUnmaskOnReload, - preloadSearchSource, - navigationSearchSource, - preloadStateSource, - navigationStateSource, - }) => { - const beforeLoadGate = createControlledPromise() - const loaderGate = createControlledPromise() - const beforeLoadCalls = vi.fn() - const loader = vi.fn(() => loaderGate) - const rootRoute = new BaseRootRoute({}) - const indexRoute = new BaseRoute({ - getParentRoute: () => rootRoute, - path: '/', - }) - const targetRoute = new BaseRoute({ - getParentRoute: () => rootRoute, - path: '/target', - 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({ - getParentRoute: () => rootRoute, - path: '/mask-a', - }) - const maskBRoute = new BaseRoute({ - getParentRoute: () => rootRoute, - path: '/mask-b', - }) - const router = createTestRouter({ - routeTree: rootRoute.addChildren([ - indexRoute, - targetRoute, - maskARoute, - maskBRoute, - ]), - ...('search' in preloadMask ? { stringifySearch: () => '' } : {}), - history: createMemoryHistory({ initialEntries: ['/'] }), - }) - - let preload: Promise | undefined - let navigation: Promise | undefined - try { - await router.load() - preload = router.preloadRoute({ - to: '/target', - mask: preloadMask as any, - }) - await vi.waitFor(() => expect(beforeLoadCalls).toHaveBeenCalledTimes(1)) - - beforeLoadGate.resolve() - await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(1)) - - navigation = router.navigate({ - to: '/target', - mask: navigationMask as any, - }) - await vi.waitFor(() => - 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, - }, - ]), - ) - - loaderGate.resolve('shared loader data') - await Promise.all([preload, navigation]) - - expect(router.state.matches.at(-1)?.context).toEqual({ - source: 'navigation', - }) - expect(router.history.location.pathname).toBe(navigationPathname) - expect(loader).toHaveBeenCalledTimes(1) - } finally { - beforeLoadGate.resolve() - loaderGate.resolve('shared loader data') - const activeWork: Array> = [] - if (preload) { - activeWork.push(preload) - } - if (navigation) { - activeWork.push(navigation) - } - await Promise.allSettled(activeWork) - } - }, - ) - - test('identical active preloads await the same redirect chain', async () => { + test('an unrelated navigation does not stop a preload redirect chain', async () => { const redirectGate = createControlledPromise() - const loaderGate = createControlledPromise() - const redirectedLoader = vi.fn(() => loaderGate) + const navigationGate = createControlledPromise() + const targetGate = createControlledPromise() + const sourceBeforeLoad = vi.fn(async () => { + await redirectGate + throw redirect({ to: '/target' }) + }) + const navigationBeforeLoad = vi.fn(() => navigationGate) + const targetLoader = vi.fn(() => targetGate) const rootRoute = new BaseRootRoute({}) const indexRoute = new BaseRoute({ getParentRoute: () => rootRoute, @@ -427,36 +196,46 @@ describe('public preload lane contracts', () => { const sourceRoute = new BaseRoute({ getParentRoute: () => rootRoute, path: '/source', - beforeLoad: async () => { - await redirectGate - throw redirect({ to: '/target' }) - }, + beforeLoad: sourceBeforeLoad, + }) + const navigationRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/navigation', + beforeLoad: navigationBeforeLoad, }) const targetRoute = new BaseRoute({ getParentRoute: () => rootRoute, path: '/target', - loader: redirectedLoader, + loader: targetLoader, }) const router = createTestRouter({ - routeTree: rootRoute.addChildren([indexRoute, sourceRoute, targetRoute]), + routeTree: rootRoute.addChildren([ + indexRoute, + sourceRoute, + navigationRoute, + targetRoute, + ]), history: createMemoryHistory({ initialEntries: ['/'] }), }) await router.load() const first = router.preloadRoute({ to: '/source' }) - const second = router.preloadRoute({ to: '/source' }) - let secondSettled = false - void second.then(() => { - secondSettled = true - }) + await vi.waitFor(() => expect(sourceBeforeLoad).toHaveBeenCalledOnce()) + + const navigation = router.navigate({ to: '/navigation' }) + await vi.waitFor(() => expect(navigationBeforeLoad).toHaveBeenCalledOnce()) + + expect(sourceBeforeLoad).toHaveBeenCalledOnce() redirectGate.resolve() - await vi.waitFor(() => expect(redirectedLoader).toHaveBeenCalledOnce()) - expect(secondSettled).toBe(false) + await vi.waitFor(() => expect(targetLoader).toHaveBeenCalledOnce()) - loaderGate.resolve() - await Promise.all([first, second]) - expect(secondSettled).toBe(true) + targetGate.resolve('target') + const firstResult = await first + expect(firstResult?.at(-1)?.routeId).toBe(targetRoute.id) + + navigationGate.resolve() + await navigation }) test.each([ @@ -488,7 +267,7 @@ describe('public preload lane contracts', () => { search: { view: 'detail' }, }, ])( - 'navigation does not adopt beforeLoad from an active preload with different $name', + 'navigation owns beforeLoad with a different $name than an active preload', async ({ preload: preloadOptions, navigation: navigationOptions, @@ -545,12 +324,56 @@ describe('public preload lane contracts', () => { }, ) - test('a different full navigation lane reruns beforeLoad but reuses active same-id loader work', async () => { + test('equivalent serialized Date loader deps reuse stale-infinite data', async () => { + const loader = vi.fn(({ deps }: { deps: { day: Date } }) => + deps.day.toISOString(), + ) + const rootRoute = new BaseRootRoute({}) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + staleTime: Infinity, + validateSearch: (search: Record) => ({ + day: String(search.day), + layout: search.layout === 'grid' ? 'grid' : 'list', + }), + loaderDeps: ({ search }) => ({ + day: new Date(`${search.day}T00:00:00.000Z`), + }), + loader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([targetRoute]), + history: createMemoryHistory({ + initialEntries: ['/target?day=2026-07-24&layout=list'], + }), + }) + + await router.load() + await router.navigate({ + to: '/target', + search: { day: '2026-07-24', layout: 'grid' }, + }) + + expect(loader).toHaveBeenCalledTimes(1) + expect(router.state.location.search).toMatchObject({ layout: 'grid' }) + expect(router.state.matches.at(-1)?.loaderData).toBe( + '2026-07-24T00:00:00.000Z', + ) + }) + + test('a sibling navigation reruns beforeLoad but reuses the pending parent loader', async () => { const loaderGate = createControlledPromise() + let loaderSignal: AbortSignal | undefined const beforeLoad = vi.fn(({ preload }: { preload: boolean }) => ({ source: preload ? 'preload' : 'navigation', })) - const loader = vi.fn(() => loaderGate) + const loader = vi.fn( + ({ abortController }: { abortController: AbortController }) => { + loaderSignal = abortController.signal + return loaderGate + }, + ) const rootRoute = new BaseRootRoute({}) const indexRoute = new BaseRoute({ getParentRoute: () => rootRoute, @@ -601,9 +424,13 @@ describe('public preload lane contracts', () => { context: { source: 'navigation' }, loaderData: 'shared parent data', }) + expect(loaderSignal?.aborted).toBe(false) + + await router.navigate({ to: '/' }) + expect(loaderSignal?.aborted).toBe(true) }) - test('reserved preload loader work survives preload completion while navigation beforeLoad is pending', async () => { + test('a sibling navigation reuses parent data that settles while its beforeLoad is pending', async () => { const loaderGate = createControlledPromise() const navigationBeforeLoadGate = createControlledPromise() const beforeLoad = vi.fn(async ({ preload }: { preload: boolean }) => { @@ -647,7 +474,7 @@ describe('public preload lane contracts', () => { const navigation = router.navigate({ to: '/parent/second' }) await vi.waitFor(() => expect(beforeLoad).toHaveBeenCalledTimes(2)) - loaderGate.resolve('reserved parent data') + loaderGate.resolve('shared parent data') await preload expect(loader).toHaveBeenCalledTimes(1) @@ -664,12 +491,13 @@ describe('public preload lane contracts', () => { router.state.matches.find((match) => match.routeId === parentRoute.id), ).toMatchObject({ context: { source: 'navigation' }, - loaderData: 'reserved parent data', + loaderData: 'shared parent data', }) }) - test('a background refresh consumes reserved preload work after that preload completes', async () => { - const refreshGate = createControlledPromise() + test('a background shouldReload navigation starts fresh instead of joining a pending preload loader', async () => { + const preloadRefreshGate = createControlledPromise() + const navigationRefreshGate = createControlledPromise() const navigationBeforeLoadGate = createControlledPromise() const beforeLoad = vi.fn( async ({ @@ -685,9 +513,14 @@ describe('public preload lane contracts', () => { return { source: preload ? 'preload' : 'navigation' } }, ) - const loader = vi.fn(() => - loader.mock.calls.length === 1 ? 'initial data' : refreshGate, - ) + const loader = vi.fn(({ preload }: { preload: boolean }) => { + if (preload) { + return preloadRefreshGate + } + return loader.mock.calls.length === 1 + ? 'initial data' + : navigationRefreshGate + }) const rootRoute = new BaseRootRoute({}) const parentRoute = new BaseRoute({ getParentRoute: () => rootRoute, @@ -738,32 +571,45 @@ describe('public preload lane contracts', () => { }) await vi.waitFor(() => expect(beforeLoad).toHaveBeenCalledTimes(3)) - refreshGate.resolve('refreshed data') + preloadRefreshGate.resolve('preload refreshed data') await preload expect(loader).toHaveBeenCalledTimes(2) expect(navigationSettled).toBe(false) navigationBeforeLoadGate.resolve() + await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(3)) + navigationRefreshGate.resolve('navigation refreshed data') await navigation + await vi.waitFor(() => { + expect( + router.state.matches.find( + (match) => match.routeId === parentRoute.id, + )?.loaderData, + ).toBe('navigation refreshed data') + }) expect(beforeLoad.mock.calls.map(([context]) => context.preload)).toEqual([ false, true, false, ]) - expect(loader).toHaveBeenCalledTimes(2) + expect(loader.mock.calls.map(([context]) => context.preload)).toEqual([ + false, + true, + false, + ]) expect(router.state.matches.at(-1)?.routeId).toBe(secondRoute.id) expect( router.state.matches.find((match) => match.routeId === parentRoute.id), ).toMatchObject({ context: { source: 'navigation' }, - loaderData: 'refreshed data', + loaderData: 'navigation refreshed data', preload: false, isFetching: false, }) }) - test('different same-href preload lanes rerun beforeLoad but share loader work by match id', async () => { + test('a sibling preload reruns beforeLoad but reuses the pending parent loader', async () => { const loaderGate = createControlledPromise() const beforeLoad = vi.fn() const loader = vi.fn(() => loaderGate) @@ -775,9 +621,6 @@ describe('public preload lane contracts', () => { const parentRoute = new BaseRoute({ getParentRoute: () => rootRoute, path: '/parent', - validateSearch: (search: Record) => ({ - version: Number(search.version), - }), beforeLoad, loader, }) @@ -795,67 +638,198 @@ describe('public preload lane contracts', () => { parentRoute.addChildren([firstRoute, secondRoute]), ]), history: createMemoryHistory({ initialEntries: ['/'] }), - stringifySearch: () => '', }) await router.load() - const first = router.preloadRoute({ - to: '/parent/first', - search: { version: 1 }, - }) + const first = router.preloadRoute({ to: '/parent/first' }) await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(1)) - const second = router.preloadRoute({ - to: '/parent/first', - search: { version: 2 }, - }) + const second = router.preloadRoute({ to: '/parent/second' }) await vi.waitFor(() => expect(beforeLoad).toHaveBeenCalledTimes(2)) expect(loader).toHaveBeenCalledTimes(1) - loaderGate.resolve('parent data') - await Promise.all([first, second]) + loaderGate.resolve('shared parent data') + const [firstMatches, secondMatches] = await Promise.all([first, second]) expect(beforeLoad).toHaveBeenCalledTimes(2) expect(loader).toHaveBeenCalledTimes(1) + expect( + firstMatches?.find((match) => match.routeId === parentRoute.id) + ?.loaderData, + ).toBe('shared parent data') + expect( + secondMatches?.find((match) => match.routeId === parentRoute.id) + ?.loaderData, + ).toBe('shared parent data') }) - test('completed preloads cache loader data but not beforeLoad context', async () => { - const beforeLoad = vi.fn(({ preload }: { preload: boolean }) => ({ - source: preload ? 'preload' : 'navigation', - })) - const loader = vi.fn( - ({ context }: { context: { source: string } }) => context.source, + test('sibling lanes reuse a pending stale parent refresh', async () => { + const refreshGate = createControlledPromise() + const beforeLoad = vi.fn() + const loader = vi.fn(() => + loader.mock.calls.length === 1 ? 'initial data' : refreshGate, ) const rootRoute = new BaseRootRoute({}) const indexRoute = new BaseRoute({ getParentRoute: () => rootRoute, path: '/', }) - const targetRoute = new BaseRoute({ + const parentRoute = new BaseRoute({ getParentRoute: () => rootRoute, - path: '/target', - staleTime: Infinity, - preloadStaleTime: Infinity, + path: '/parent', + preloadStaleTime: 0, + beforeLoad, + loader: { + handler: loader, + staleReloadMode: 'blocking', + }, + }) + const firstRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/first', + }) + const secondRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/second', + }) + const thirdRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/third', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([firstRoute, secondRoute, thirdRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + await router.preloadRoute({ to: '/parent/first' }) + const refresh = router.preloadRoute({ to: '/parent/second' }) + await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(2)) + + const navigation = router.navigate({ to: '/parent/third' }) + await vi.waitFor(() => expect(beforeLoad).toHaveBeenCalledTimes(3)) + expect(loader).toHaveBeenCalledTimes(2) + + refreshGate.resolve('refreshed data') + await Promise.all([refresh, navigation]) + + expect(loader).toHaveBeenCalledTimes(2) + expect( + router.state.matches.find((match) => match.routeId === parentRoute.id) + ?.loaderData, + ).toBe('refreshed data') + }) + + test('a sibling navigation shares a pending parent loader failure', async () => { + const loaderGate = createControlledPromise() + const beforeLoad = vi.fn() + const loader = vi.fn(() => loaderGate) + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', beforeLoad, loader, }) + const firstRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/first', + }) + const secondRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/second', + }) const router = createTestRouter({ - routeTree: rootRoute.addChildren([indexRoute, targetRoute]), + routeTree: rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([firstRoute, secondRoute]), + ]), history: createMemoryHistory({ initialEntries: ['/'] }), }) await router.load() - await router.preloadRoute({ to: '/target' }) - await router.navigate({ to: '/target' }) + const preload = router.preloadRoute({ to: '/parent/first' }) + await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(1)) - expect(beforeLoad.mock.calls.map(([context]) => context.preload)).toEqual([ - true, - false, - ]) + const navigation = router.navigate({ to: '/parent/second' }) + await vi.waitFor(() => expect(beforeLoad).toHaveBeenCalledTimes(2)) expect(loader).toHaveBeenCalledTimes(1) - expect(router.state.matches.at(-1)).toMatchObject({ - context: { source: 'navigation' }, - loaderData: 'preload', + + loaderGate.reject(new Error('preload failed')) + await Promise.all([preload, navigation]) + + expect(loader).toHaveBeenCalledTimes(1) + expect( + router.state.matches.find((match) => match.routeId === parentRoute.id), + ).toMatchObject({ + status: 'error', + error: expect.objectContaining({ message: 'preload failed' }), + }) + }) + + test('sibling lanes share one pending parent loader failure', async () => { + const firstLoaderGate = createControlledPromise() + const loader = vi.fn(() => firstLoaderGate) + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader, + }) + const firstRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/first', + }) + const secondRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/second', + }) + const thirdRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/third', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([firstRoute, secondRoute, thirdRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/'] }), }) + + await router.load() + const first = router.preloadRoute({ to: '/parent/first' }) + await vi.waitFor(() => expect(loader).toHaveBeenCalledOnce()) + const second = router.preloadRoute({ to: '/parent/second' }) + const third = router.preloadRoute({ to: '/parent/third' }) + await Promise.resolve() + expect(loader).toHaveBeenCalledOnce() + + firstLoaderGate.reject(new Error('first generation failed')) + const [, secondMatches, thirdMatches] = await Promise.all([ + first, + second, + third, + ]) + + expect(loader).toHaveBeenCalledOnce() + for (const matches of [secondMatches, thirdMatches]) { + expect( + matches?.find((match) => match.routeId === parentRoute.id), + ).toMatchObject({ + status: 'error', + error: expect.objectContaining({ message: 'first generation failed' }), + }) + } }) test('completed preloads reuse same-id route context and loader data', async () => { @@ -894,7 +868,7 @@ describe('public preload lane contracts', () => { }) }) - test('navigation retries an adopted preload lane that failed', async () => { + test('navigation succeeds independently of a failed preload beforeLoad', async () => { const preloadGate = createControlledPromise() const beforeLoad = vi.fn(async ({ preload }: { preload: boolean }) => { if (preload) { @@ -941,7 +915,7 @@ describe('public preload lane contracts', () => { }) }) - test('a retry reuses successful loader data from the failed preload lane', async () => { + test('a later retry reuses successful loader data from a failed preload lane', async () => { const childGate = createControlledPromise() const beforeLoad = vi.fn(({ preload }: { preload: boolean }) => ({ source: preload ? 'preload' : 'navigation', @@ -985,10 +959,10 @@ describe('public preload lane contracts', () => { await router.load() const preload = router.preloadRoute({ to: '/parent/child' }) await vi.waitFor(() => expect(childLoader).toHaveBeenCalledTimes(1)) - const navigation = router.navigate({ to: '/parent/child' }) childGate.resolve() - await Promise.all([preload, navigation]) + await preload + await router.navigate({ to: '/parent/child' }) expect(beforeLoad.mock.calls.map(([context]) => context.preload)).toEqual([ true, @@ -1071,11 +1045,15 @@ describe('public preload lane contracts', () => { }) }) - test('a terminal preload started during navigation borrows its loader generation', async () => { - const loaderGate = createControlledPromise() - const loader = vi.fn(() => - loader.mock.calls.length === 1 ? 'initial data' : loaderGate, - ) + test('a terminal preload with shouldReload starts fresh instead of joining a pending navigation loader', async () => { + const navigationLoaderGate = createControlledPromise() + const preloadLoaderGate = createControlledPromise() + const loader = vi.fn(({ preload }: { preload: boolean }) => { + if (loader.mock.calls.length === 1) { + return 'initial data' + } + return preload ? preloadLoaderGate : navigationLoaderGate + }) const rootRoute = new BaseRootRoute({ shouldReload: true, loader, @@ -1102,13 +1080,30 @@ describe('public preload lane contracts', () => { await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(2)) const preload = router.preloadRoute({ to: '/target' }) await vi.waitFor(() => expect(beforeLoad).toHaveBeenCalledTimes(2)) + await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(3)) - expect(loader).toHaveBeenCalledTimes(2) - loaderGate.resolve('navigation data') - const [, terminal] = await Promise.all([navigation, preload]) + navigationLoaderGate.resolve('navigation data') + await navigation + expect(router.state.matches[0]).toMatchObject({ + routeId: rootRoute.id, + loaderData: 'navigation data', + }) - expect(loader).toHaveBeenCalledTimes(2) + preloadLoaderGate.resolve('preload data') + const terminal = await preload + + expect(loader.mock.calls.map(([context]) => context.preload)).toEqual([ + false, + false, + true, + ]) expect(terminal?.[0]).toMatchObject({ + routeId: rootRoute.id, + _notFound: true, + loaderData: 'preload data', + }) + + expect(router.state.matches[0]).toMatchObject({ routeId: rootRoute.id, _notFound: true, loaderData: 'navigation data', @@ -1195,7 +1190,7 @@ describe('public preload lane contracts', () => { expect(router.state.matches.at(-1)?.loaderData).toBe('loader data 1') }) - test('filtered invalidation invalidates active and cached generations of the selected route', async () => { + test('filtered invalidation invalidates committed 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({ @@ -1319,9 +1314,21 @@ describe('public preload lane contracts', () => { }) }) - test('preload false skips speculation but performs a blocking navigation load', async () => { + test('preload false skips only that match while navigation loads it normally', async () => { const loaderGate = createControlledPromise() const loader = vi.fn(() => loaderGate) + const componentPreload = vi.fn(async () => {}) + const pendingComponentPreload = vi.fn(async () => {}) + const component = Object.assign(() => null, { + preload: componentPreload, + }) + const pendingComponent = Object.assign(() => null, { + preload: pendingComponentPreload, + }) + const lazyFn = vi.fn(async () => ({ options: {} }) as any) + const childLoader = vi.fn(() => 'child data') + const childComponentPreload = vi.fn(async () => {}) + const childPendingComponentPreload = vi.fn(async () => {}) const rootRoute = new BaseRootRoute({}) const indexRoute = new BaseRoute({ getParentRoute: () => rootRoute, @@ -1331,34 +1338,394 @@ describe('public preload lane contracts', () => { getParentRoute: () => rootRoute, path: '/target', preload: false, + component, + pendingComponent, loader, + }).lazy(lazyFn) + const childRoute = new BaseRoute({ + getParentRoute: () => targetRoute, + path: '/child', + preloadStaleTime: Infinity, + component: Object.assign(() => null, { + preload: childComponentPreload, + }), + pendingComponent: Object.assign(() => null, { + preload: childPendingComponentPreload, + }), + loader: childLoader, }) const router = createTestRouter({ - routeTree: rootRoute.addChildren([indexRoute, targetRoute]), + routeTree: rootRoute.addChildren([ + indexRoute, + targetRoute.addChildren([childRoute]), + ]), history: createMemoryHistory({ initialEntries: ['/'] }), }) await router.load() - await router.preloadRoute({ to: '/target' }) + await router.preloadRoute({ to: '/target/child' }) + expect(loader).not.toHaveBeenCalled() + expect(lazyFn).not.toHaveBeenCalled() + expect(componentPreload).not.toHaveBeenCalled() + expect(pendingComponentPreload).not.toHaveBeenCalled() + expect(childLoader).toHaveBeenCalledOnce() + expect(childComponentPreload).toHaveBeenCalledOnce() + expect(childPendingComponentPreload).toHaveBeenCalledOnce() let settled = false - const navigation = router.navigate({ to: '/target' }).then(() => { + const navigation = router.navigate({ to: '/target/child' }).then(() => { settled = true }) - await vi.waitFor(() => expect(loader).toHaveBeenCalledTimes(1)) + await vi.waitFor(() => { + expect(loader).toHaveBeenCalledOnce() + expect(lazyFn).toHaveBeenCalledOnce() + expect(componentPreload).toHaveBeenCalledOnce() + expect(pendingComponentPreload).toHaveBeenCalledOnce() + }) expect(settled).toBe(false) loaderGate.resolve('target data') await navigation - expect(router.state.matches.at(-1)).toMatchObject({ + expect(router.state.matches.at(-2)).toMatchObject({ routeId: targetRoute.id, status: 'success', loaderData: 'target data', }) + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: childRoute.id, + loaderData: 'child data', + }) + expect(childLoader).toHaveBeenCalledOnce() + }) + + test('a descendant background redirect beats an ancestor chunk failure', async () => { + const componentGate = createControlledPromise() + const componentStarted = createControlledPromise() + const redirectGate = createControlledPromise() + const redirectStarted = createControlledPromise() + const chunkError = new Error('parent component failed') + let childLoads = 0 + + const ParentComponent = Object.assign(() => null, { + preload: () => { + componentStarted.resolve() + return componentGate + }, + }) + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + preload: false, + component: ParentComponent, + errorComponent: () => null, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + shouldReload: true, + loader: { + staleReloadMode: 'background', + handler: async () => { + childLoads++ + if (childLoads === 1) { + return 'preloaded child data' + } + redirectStarted.resolve() + await redirectGate + throw redirect({ to: '/target' }) + }, + }, + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([childRoute]), + targetRoute, + ]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + await router.preloadRoute({ to: '/parent/child' }) + expect(componentStarted.status).toBe('pending') + expect(childLoads).toBe(1) + + const navigation = router.navigate({ to: '/parent/child' }) + await Promise.all([componentStarted, redirectStarted]) + redirectGate.resolve() + componentGate.reject(chunkError) + await navigation + + expect(router.state.location.pathname).toBe('/target') + }) + + test('a background redirect beats a descendant notFound targeted to its ancestor', async () => { + const redirectGate = createControlledPromise() + const redirectStarted = createControlledPromise() + const notFoundGate = createControlledPromise() + const notFoundStarted = createControlledPromise() + let middleLoads = 0 + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + notFoundComponent: () => null, + }) + const middleRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/middle', + shouldReload: true, + loader: { + staleReloadMode: 'background', + handler: async () => { + middleLoads++ + if (middleLoads === 1) { + return 'preloaded middle data' + } + redirectStarted.resolve() + await redirectGate + throw redirect({ to: '/target' }) + }, + }, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => middleRoute, + path: '/child', + preload: false, + loader: async () => { + notFoundStarted.resolve() + await notFoundGate + throw notFound({ routeId: parentRoute.id as never }) + }, + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([ + middleRoute.addChildren([childRoute]), + ]), + targetRoute, + ]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + await router.preloadRoute({ to: '/parent/middle/child' }) + expect(middleLoads).toBe(1) + expect(notFoundStarted.status).toBe('pending') + + const navigation = router.navigate({ to: '/parent/middle/child' }) + await Promise.all([redirectStarted, notFoundStarted]) + redirectGate.resolve() + notFoundGate.resolve() + await navigation + + expect(router.state.location.pathname).toBe('/target') + }) + + test('preload false skips a serial error boundary chunk until navigation', async () => { + const error = new Error('target failed') + const context = vi.fn(() => ({ targetContext: true })) + const beforeLoad = vi.fn(() => { + throw error + }) + const lazyFn = vi.fn(async () => ({ options: {} }) as any) + const errorComponentPreload = vi.fn(async () => {}) + const errorComponent = Object.assign(() => null, { + preload: errorComponentPreload, + }) + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + preload: false, + context, + beforeLoad, + errorComponent, + }).lazy(lazyFn) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, targetRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + const preloaded = await router.preloadRoute({ to: '/target' }) + + expect(context).toHaveBeenCalledOnce() + expect(beforeLoad).toHaveBeenCalledOnce() + expect(lazyFn).not.toHaveBeenCalled() + expect(errorComponentPreload).not.toHaveBeenCalled() + expect(preloaded?.at(-1)).toMatchObject({ + routeId: targetRoute.id, + status: 'error', + error, + }) + + await router.navigate({ to: '/target' }) + + expect(context).toHaveBeenCalledTimes(2) + expect(beforeLoad).toHaveBeenCalledTimes(2) + expect(lazyFn).toHaveBeenCalledOnce() + expect(errorComponentPreload).toHaveBeenCalledOnce() + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: targetRoute.id, + status: 'error', + error, + }) + }) + + test('preload false skips its serial not-found boundary chunks', async () => { + const context = vi.fn(() => ({ targetContext: true })) + const lazyFn = vi.fn(async () => ({ options: {} }) as any) + const notFoundComponentPreload = vi.fn(async () => {}) + const notFoundComponent = Object.assign(() => null, { + preload: notFoundComponentPreload, + }) + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + preload: false, + context, + beforeLoad: () => { + throw notFound({ routeId: targetRoute.id as never }) + }, + notFoundComponent, + }).lazy(lazyFn) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, targetRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + const preloaded = await router.preloadRoute({ to: '/target' }) + + expect(context).toHaveBeenCalledOnce() + expect(lazyFn).not.toHaveBeenCalled() + expect(notFoundComponentPreload).not.toHaveBeenCalled() + expect(preloaded?.at(-1)).toMatchObject({ + routeId: targetRoute.id, + status: 'notFound', + error: { isNotFound: true }, + }) + + await router.navigate({ to: '/target' }) + + expect(context).toHaveBeenCalledTimes(2) + expect(lazyFn).toHaveBeenCalledOnce() + expect(notFoundComponentPreload).toHaveBeenCalledOnce() + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: targetRoute.id, + status: 'notFound', + error: { isNotFound: true }, + }) + }) + + test('preload false still permits an enabled ancestor not-found boundary', async () => { + const targetLazyFn = vi.fn(async () => ({ options: {} }) as any) + const parentNotFoundPreload = vi.fn(async () => {}) + const ParentNotFound = Object.assign(() => null, { + preload: parentNotFoundPreload, + }) + const parentLazyFn = vi.fn( + async () => ({ options: { notFoundComponent: ParentNotFound } }) as any, + ) + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + }).lazy(parentLazyFn) + const targetRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/target', + preload: false, + beforeLoad: () => { + throw notFound() + }, + }).lazy(targetLazyFn) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([targetRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await router.load() + const preloaded = await router.preloadRoute({ to: '/parent/target' }) + + expect(targetLazyFn).not.toHaveBeenCalled() + expect(parentLazyFn).toHaveBeenCalledOnce() + expect(parentNotFoundPreload).toHaveBeenCalledOnce() + expect(preloaded?.[1]).toMatchObject({ + routeId: parentRoute.id, + status: 'notFound', + error: { isNotFound: true }, + }) }) + test.each(['errorComponent', 'notFoundComponent'] as const)( + 'retries a failed on-demand %s chunk', + async (componentType) => { + const preload = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error('boundary download failed')) + .mockResolvedValue(undefined) + const boundary = Object.assign(() => null, { preload }) + const rootRoute = new BaseRootRoute({}) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + [componentType]: boundary, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([targetRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + await expect( + router.loadRouteChunk(targetRoute, componentType), + ).rejects.toThrow('boundary download failed') + await expect( + router.loadRouteChunk(targetRoute, componentType), + ).resolves.toBeUndefined() + + expect(preload).toHaveBeenCalledTimes(2) + }, + ) + test('preloads the target of a twenty-hop redirect chain', async () => { const beforeLoad = vi.fn(({ params }) => { const hop = Number(params.hop) @@ -1429,7 +1796,7 @@ describe('public preload lane contracts', () => { }) }) - test('forwards a document redirect at the redirect limit', async () => { + test('returns no preload matches for a document redirect at the limit', async () => { const beforeLoad = vi.fn(({ params }) => { const hop = Number(params.hop) throw redirect({ diff --git a/packages/router-core/tests/ssr-server-cleanup.test.ts b/packages/router-core/tests/ssr-server-cleanup.test.ts index d137fb3ae4..06e11c8119 100644 --- a/packages/router-core/tests/ssr-server-cleanup.test.ts +++ b/packages/router-core/tests/ssr-server-cleanup.test.ts @@ -1,10 +1,13 @@ import { createMemoryHistory } from '@tanstack/history' -import { afterEach, describe, expect, test, vi } from 'vitest' +import { afterEach, describe, expect, expectTypeOf, test, vi } from 'vitest' import { BaseRootRoute, BaseRoute } from '../src' import { createRequestHandler } from '../src/ssr/createRequestHandler' import { bindSsrResponseToRequest, createSsrStreamResponse, + replaceSsrResponse, + stripSsrResponseBody, + type SsrResponse, } from '../src/ssr/handlerCallback' import { attachRouterServerSsrUtils } from '../src/ssr/ssr-server' import { transformStreamWithRouter } from '../src/ssr/transformStreamWithRouter' @@ -391,10 +394,8 @@ describe('serverSsr.cleanup', () => { expect(router.serverSsr).toBeUndefined() renderResult.resolve(lateStreamResponse) - await Promise.resolve() - await Promise.resolve() + await vi.waitFor(() => expect(cancelCalls).toBe(1)) expect(cleanupCalls).toBe(1) - expect(cancelCalls).toBe(1) expect(router.serverSsr).toBeUndefined() }) @@ -479,11 +480,12 @@ describe('serverSsr.cleanup', () => { }, ) - test('request abort disposes a stream after response handoff', async () => { + test('request abort cancels a locked tagged stream after response handoff', async () => { const router = buildRouter() const requestController = new AbortController() + const cancellation = new Error('request disconnected') let cleanupCalls = 0 - let cancelCalls = 0 + const cancel = vi.fn(() => new Promise(() => {})) const handler = createRequestHandler({ createRouter: () => router, request: new Request('http://localhost/', { @@ -502,25 +504,223 @@ describe('serverSsr.cleanup', () => { requestRouter, new Response( new ReadableStream({ - cancel() { - cancelCalls++ - return new Promise(() => {}) - }, + cancel, }), ), ) }) - expect(response.body).not.toBeNull() + const reader = response.body!.getReader() + const read = expect(reader.read()).rejects.toBe(cancellation) expect(cleanupCalls).toBe(0) - requestController.abort(new Error('request disconnected')) + requestController.abort(cancellation) + + try { + await vi.waitFor(() => { + expect(cancel).toHaveBeenCalledWith(cancellation) + }) + await read + expect(cleanupCalls).toBe(1) + expect(router.serverSsr).toBeUndefined() + } finally { + reader.releaseLock() + } + }) + + test('request abort cancels a pre-enqueued plain stream after response handoff', async () => { + const router = buildRouter() + const requestController = new AbortController() + const cancellation = new Error('request disconnected') + const cancel = vi.fn() + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + const handler = createRequestHandler({ + createRouter: () => router, + request: new Request('http://localhost/', { + signal: requestController.signal, + }), + }) + + const response = await handler(() => + Promise.resolve( + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([1])) + }, + cancel, + }), + ), + ), + ) + const reader = response.body!.getReader() + requestController.abort(cancellation) + + try { + await vi.waitFor(() => { + expect(cancel).toHaveBeenCalledWith(cancellation) + }) + await expect(reader.read()).rejects.toBe(cancellation) + expect(consoleError).not.toHaveBeenCalled() + } finally { + reader.releaseLock() + consoleError.mockRestore() + } + }) + + test.each(['replace', 'strip'] as const)( + '%s remains Promise-returning without awaiting body cancellation', + async (operation) => { + const router = buildRouter() + attachRouterServerSsrUtils({ router, manifest: undefined }) + const cancel = vi.fn(() => new Promise(() => {})) + const result = createSsrStreamResponse( + router, + new Response(new ReadableStream({ cancel })), + ) + + const replacementPromise = + operation === 'replace' + ? replaceSsrResponse(result, new Response('replacement'), 'replaced') + : stripSsrResponseBody(result, 'stripped') + + expectTypeOf(replacementPromise).toEqualTypeOf>() + expect(replacementPromise).toBeInstanceOf(Promise) + const replacement = await replacementPromise + expect(replacement.serverSsrCleanup).toBe('none') + expect(router.serverSsr).toBeUndefined() + await vi.waitFor(() => expect(cancel).toHaveBeenCalledOnce()) + }, + ) + + test('stream response disposal remains Promise-returning', async () => { + const router = buildRouter() + attachRouterServerSsrUtils({ router, manifest: undefined }) + const result = createSsrStreamResponse( + router, + new Response(new ReadableStream()), + ) + + if (result.serverSsrCleanup !== 'stream') { + throw new Error('Expected a stream response') + } + + const disposal = result.dispose() + + expectTypeOf(disposal).toEqualTypeOf>() + expect(disposal).toBeInstanceOf(Promise) + await disposal + }) + + test('response body cancellation awaits upstream cancellation', async () => { + const router = buildRouter() + attachRouterServerSsrUtils({ router, manifest: undefined }) + const cancellation = deferred() + const cancel = vi.fn(() => cancellation.promise) + const result = createSsrStreamResponse( + router, + new Response(new ReadableStream({ cancel })), + ) + + const cancelled = result.response.body!.cancel('consumer cancelled') + expect(cancel).toHaveBeenCalledWith('consumer cancelled') + let settled = false + void cancelled.then(() => { + settled = true + }) await Promise.resolve() + expect(settled).toBe(false) - expect(cleanupCalls).toBe(1) - expect(cancelCalls).toBe(1) - expect(router.serverSsr).toBeUndefined() + cancellation.resolve() + await cancelled + }) + + test('an SSR source error propagates and cleans router state', async () => { + const router = buildRouter() + attachRouterServerSsrUtils({ router, manifest: undefined }) + const cleanup = router.serverSsr!.cleanup + const cleanupSpy = vi.fn(() => cleanup()) + router.serverSsr!.cleanup = cleanupSpy + let source!: ReadableStreamDefaultController + const failure = new Error('renderer stream failed') + const result = createSsrStreamResponse( + router, + new Response( + new ReadableStream({ + start(controller) { + source = controller + }, + }), + ), + ) + const reader = result.response.body!.getReader() + const read = reader.read() + + source.error(failure) + + try { + await expect(read).rejects.toBe(failure) + expect(cleanupSpy).toHaveBeenCalledOnce() + expect(router.serverSsr).toBeUndefined() + } finally { + reader.releaseLock() + } }) + test.each(['replace', 'strip'] as const)( + '%s awaits custom asynchronous disposal', + async (operation) => { + const disposal = deferred() + const result = { + response: new Response('stream'), + serverSsrCleanup: 'stream' as const, + dispose: vi.fn(() => disposal.promise), + } + const replacementPromise = + operation === 'replace' + ? replaceSsrResponse(result, new Response('replacement'), 'replaced') + : stripSsrResponseBody(result, 'stripped') + + expect(replacementPromise).toBeInstanceOf(Promise) + await Promise.resolve() + expect(result.dispose).toHaveBeenCalledWith( + operation === 'replace' ? 'replaced' : 'stripped', + ) + + let settled = false + void replacementPromise.then(() => { + settled = true + }) + await Promise.resolve() + expect(settled).toBe(false) + + disposal.resolve() + const replacement = await replacementPromise + expect(replacement.serverSsrCleanup).toBe('none') + }, + ) + + test.each(['replace', 'strip'] as const)( + '%s cancels an abandoned plain response body', + async (operation) => { + const cancel = vi.fn() + const result = new Response(new ReadableStream({ cancel })) + + if (operation === 'replace') { + await replaceSsrResponse( + result, + new Response('replacement'), + 'replaced', + ) + } else { + await stripSsrResponseBody(result, 'stripped') + } + + expect(cancel).toHaveBeenCalledOnce() + }, + ) + test.each(['throw', 'reject'] as const)( 'reports a custom stream disposal %s after request abort', async (failureMode) => { @@ -564,6 +764,46 @@ describe('serverSsr.cleanup', () => { }, ) + test('immediately disposes a bodyless custom stream response', async () => { + const dispose = vi.fn(() => Promise.resolve()) + const result = bindSsrResponseToRequest( + undefined, + { + response: new Response(null, { status: 204 }), + serverSsrCleanup: 'stream', + dispose, + }, + new AbortController().signal, + ) + + expect(result).toMatchObject({ serverSsrCleanup: 'none' }) + expect(result.response.status).toBe(204) + await vi.waitFor(() => { + expect(dispose).toHaveBeenCalledWith(undefined) + }) + }) + + test('passes an existing request abort reason to a bodyless custom stream response', async () => { + const dispose = vi.fn(() => Promise.resolve()) + const request = new AbortController() + const reason = new Error('request disconnected') + request.abort(reason) + + bindSsrResponseToRequest( + undefined, + { + response: new Response(null, { status: 204 }), + serverSsrCleanup: 'stream', + dispose, + }, + request.signal, + ) + + await vi.waitFor(() => { + expect(dispose).toHaveBeenCalledWith(reason) + }) + }) + test('request handler defers cleanup for stream response metadata', async () => { const router = buildRouter() let cleanupCalls = 0 diff --git a/packages/solid-router/src/lazyRouteComponent.tsx b/packages/solid-router/src/lazyRouteComponent.tsx index 64bf129337..267548d2ab 100644 --- a/packages/solid-router/src/lazyRouteComponent.tsx +++ b/packages/solid-router/src/lazyRouteComponent.tsx @@ -13,29 +13,34 @@ export function lazyRouteComponent< ): T[TKey] extends (props: infer TProps) => any ? AsyncRouteComponent : never { - let loadPromise: Promise | undefined + let loadPromise: Promise | undefined let comp: T[TKey] | T['default'] let error: any const load = () => { - if (!loadPromise) { - error = undefined - loadPromise = importer() - .then((res) => { - // Keep browser preload behavior unchanged; SSR can reuse the import. - if (!(isServer ?? typeof window === 'undefined')) { - loadPromise = undefined - } - comp = res[exportName ?? 'default'] - return comp - }) - .catch((err) => { - loadPromise = undefined - error = err - }) + if (loadPromise) { + return loadPromise } - return loadPromise + error = undefined + return (loadPromise = importer() + .then((res) => { + // Keep browser preload behavior unchanged; SSR can reuse the import. + if ( + !((isServer as boolean | undefined) ?? typeof window === 'undefined') + ) { + loadPromise = undefined + } + comp = res[exportName ?? 'default'] + }) + .catch((err) => { + loadPromise = undefined + error = err + + if (!isModuleNotFoundError(err)) { + throw err + } + })) } const lazyComp = function Lazy(props: any) { @@ -47,30 +52,18 @@ export function lazyRouteComponent< // If this happens, the old version in the user's browser would have an outdated // URL to the lazy module. // In that case, we want to attempt one window refresh to get the latest. - if (isModuleNotFoundError(error)) { - // We don't want an error thrown from preload in this case, because - // there's nothing we want to do about module not found during preload. - // Record the error, recover the promise with a null return, - // and we will attempt module not found resolution during the render path. - - if ( - error instanceof Error && - typeof window !== 'undefined' && - typeof sessionStorage !== 'undefined' - ) { - // Again, we want to reload one time on module not found error and not enter - // a reload loop if there is some other issue besides an old deploy. - // That's why we store our reload attempt in sessionStorage. - // Use error.message as key because it contains the module path that failed. - const storageKey = `tanstack_router_reload:${error.message}` - if (!sessionStorage.getItem(storageKey)) { - sessionStorage.setItem(storageKey, '1') - window.location.reload() - - // Return empty component while we wait for window to reload - return { - default: () => null, - } + if ( + isModuleNotFoundError(error) && + error instanceof Error && + !((isServer as boolean | undefined) ?? typeof window === 'undefined') && + typeof sessionStorage !== 'undefined' + ) { + const storageKey = `tanstack_router_reload:${error.message}` + if (!sessionStorage.getItem(storageKey)) { + sessionStorage.setItem(storageKey, '1') + window.location.reload() + return { + default: () => null, } } } @@ -80,7 +73,7 @@ export function lazyRouteComponent< } if (!comp) { - const [compResource] = createResource(load, { + const [compResource] = createResource(() => load().then(() => comp), { initialValue: comp, ssrLoadFrom: 'initial', }) diff --git a/packages/solid-router/tests/component-preload-retry.test.tsx b/packages/solid-router/tests/component-preload-retry.test.tsx index 0a26f0af26..2ac1538d51 100644 --- a/packages/solid-router/tests/component-preload-retry.test.tsx +++ b/packages/solid-router/tests/component-preload-retry.test.tsx @@ -2,6 +2,7 @@ import { afterEach, expect, test, vi } from 'vitest' import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library' import { RouterProvider, + createControlledPromise, createMemoryHistory, createRootRoute, createRoute, @@ -15,6 +16,7 @@ afterEach(() => { cleanup() vi.restoreAllMocks() vi.unstubAllGlobals() + sessionStorage.clear() }) test('a successful server component download is reused', async () => { @@ -29,6 +31,21 @@ test('a successful server component download is reused', async () => { expect(importer).toHaveBeenCalledTimes(1) }) +test('concurrent component preloads share the import', async () => { + const componentImport = createControlledPromise<{ + default: () => null + }>() + const importer = vi.fn(() => componentImport) + const Page = lazyRouteComponent(importer) + + const first = Page.preload?.() + expect(Page.preload?.()).toBe(first) + expect(importer).toHaveBeenCalledOnce() + + componentImport.resolve({ default: () => null }) + await first +}) + test('a component loads when rendered before preload', async () => { const importer = vi.fn().mockResolvedValue({ default: () =>
Page content
, @@ -84,3 +101,52 @@ test('a failed component download is retried from the route error UI', async () expect(await screen.findByText('Page content')).toBeInTheDocument() }) + +test('retries a module download from the error UI after reloading once', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + + const failure = new TypeError( + 'Failed to fetch dynamically imported module: /assets/page.js', + ) + sessionStorage.setItem(`tanstack_router_reload:${failure.message}`, '1') + + const PageContent = () =>
Page content
+ const importer = vi + .fn<() => Promise<{ default: typeof PageContent }>>() + .mockRejectedValueOnce(failure) + .mockResolvedValue({ default: PageContent }) + const Page = lazyRouteComponent(importer) + + function RouteError(props: ErrorComponentProps) { + const router = useRouter() + return ( + + ) + } + + const rootRoute = createRootRoute() + const pageRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/page', + component: Page, + errorComponent: RouteError, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + }) + + render(() => ) + fireEvent.click(await screen.findByRole('button', { name: 'Retry' })) + + expect(await screen.findByText('Page content')).toBeInTheDocument() + expect(importer).toHaveBeenCalledTimes(2) +}) diff --git a/packages/start-client-core/src/client-rpc/frame-decoder.ts b/packages/start-client-core/src/client-rpc/frame-decoder.ts index 9834b10a1b..263739fa13 100644 --- a/packages/start-client-core/src/client-rpc/frame-decoder.ts +++ b/packages/start-client-core/src/client-rpc/frame-decoder.ts @@ -2,11 +2,16 @@ * Client-side frame decoder for multiplexed responses. * * Decodes binary frame protocol and reconstructs: - * - JSON stream (NDJSON lines for seroval) + * - JSON stream (one complete Seroval value per frame) * - Raw streams (binary data as ReadableStream) */ -import { FRAME_HEADER_SIZE, FrameType } from '../constants' +import { + FRAME_HEADER_SIZE, + FrameType, + MAX_FRAMED_STREAMS, + MAX_FRAME_PAYLOAD_SIZE, +} from '../constants' /** Cached TextDecoder for frame decoding */ const textDecoder = new TextDecoder() @@ -14,11 +19,15 @@ const textDecoder = new TextDecoder() /** Shared empty buffer for empty buffer case - avoids allocation */ const EMPTY_BUFFER = new Uint8Array(0) -/** Hardening limits to prevent memory/CPU DoS */ -const MAX_FRAME_PAYLOAD_SIZE = 16 * 1024 * 1024 // 16MiB -const MAX_BUFFERED_BYTES = 32 * 1024 * 1024 // 32MiB -const MAX_STREAMS = 1024 -const MAX_FRAMES = 100_000 // Limit total frames to prevent CPU DoS +const MAX_QUEUED_BYTES = + FRAME_HEADER_SIZE + MAX_FRAME_PAYLOAD_SIZE * 2 + +type RawStreamState = [ + stream: ReadableStream, + controller: ReadableStreamDefaultController, + terminal: boolean | undefined, + queuedBytes: number, +] /** * Result of frame decoding. @@ -26,7 +35,7 @@ const MAX_FRAMES = 100_000 // Limit total frames to prevent CPU DoS export interface FrameDecoderResult { /** Gets or creates a raw stream by ID (for use by deserialize plugin) */ getOrCreateStream: (id: number) => ReadableStream - /** Stream of JSON strings (NDJSON lines) */ + /** Stream of complete JSON strings */ jsonChunks: ReadableStream } @@ -39,167 +48,138 @@ export interface FrameDecoderResult { export function createFrameDecoder( input: ReadableStream, ): FrameDecoderResult { - const streamControllers = new Map< - number, - ReadableStreamDefaultController - >() - const streams = new Map>() - const cancelledStreamIds = new Set() + const reader = input.getReader() + const streams = new Map() + + let stopped: [unknown] | undefined + let queuedBytes = 0 + + const updateQueuedBytes = ( + controller: ReadableStreamDefaultController, + previous: number, + ): number => { + const next = MAX_QUEUED_BYTES - controller.desiredSize! + queuedBytes += next - previous + return next + } - let cancelled = false as boolean - let inputReader: ReadableStreamReader | null = null - let frameCount = 0 + const assertQueueBudget = (): void => { + if (queuedBytes > MAX_QUEUED_BYTES) { + throw new Error( + `Framed response queue exceeded ${MAX_QUEUED_BYTES} bytes`, + ) + } + } - let jsonController!: ReadableStreamDefaultController - const jsonChunks = new ReadableStream({ - start(controller) { - jsonController = controller - }, - cancel() { - cancelled = true - try { - inputReader?.cancel() - } catch { - // Ignore - } + const errorStream = (state: RawStreamState, error: unknown): void => { + queuedBytes -= state[3] + state[3] = 0 + state[2] = true + state[1].error(error) + } - streamControllers.forEach((ctrl) => { - try { - ctrl.error(new Error('Framed response cancelled')) - } catch { - // Ignore - } - }) - streamControllers.clear() - streams.clear() - cancelledStreamIds.clear() - }, - }) + const errorActiveStreams = (error: unknown): void => { + for (const state of streams.values()) { + if (!state[2]) { + errorStream(state, error) + } + } + } - /** - * Gets or creates a stream for a given stream ID. - * Called by deserialize plugin when it encounters a RawStream reference. - */ - function getOrCreateStream(id: number): ReadableStream { + function getOrCreateState(id: number): RawStreamState { const existing = streams.get(id) if (existing) { return existing } - // If we already received an END/ERROR for this streamId, returning a fresh stream - // would hang consumers. Return an already-closed stream instead. - if (cancelledStreamIds.has(id)) { - return new ReadableStream({ - start(controller) { - controller.close() - }, - }) - } - - if (streams.size >= MAX_STREAMS) { + if (streams.size >= MAX_FRAMED_STREAMS) { throw new Error( - `Too many raw streams in framed response (max ${MAX_STREAMS})`, + `Too many raw streams in framed response (max ${MAX_FRAMED_STREAMS})`, ) } - const stream = new ReadableStream({ - start(ctrl) { - streamControllers.set(id, ctrl) + const terminal = stopped + // Assigned after stream construction so its callbacks can close over it. + // eslint-disable-next-line prefer-const + let state!: RawStreamState + let controller!: ReadableStreamDefaultController + const stream = new ReadableStream( + { + start(ctrl) { + controller = ctrl + if (terminal) { + ctrl.error(terminal[0]) + } + }, + cancel() { + queuedBytes -= state[3] + state[3] = 0 + state[2] = true + }, + pull() { + state[3] = updateQueuedBytes(controller, state[3]) + if (state[2] && !state[3]) { + controller.close() + } + }, }, - cancel() { - cancelledStreamIds.add(id) - streamControllers.delete(id) - streams.delete(id) + { + highWaterMark: MAX_QUEUED_BYTES, + size(chunk) { + return FRAME_HEADER_SIZE + chunk.byteLength + }, }, - }) - streams.set(id, stream) - return stream + ) + state = [stream, controller, !!terminal, 0] + streams.set(id, state) + return state } - /** - * Ensures stream exists and returns its controller for enqueuing data. - * Used for CHUNK frames where we need to ensure stream is created. - */ - function ensureController( - id: number, - ): ReadableStreamDefaultController | undefined { - getOrCreateStream(id) - return streamControllers.get(id) + function getOrCreateStream(id: number): ReadableStream { + return getOrCreateState(id)[0] } + let jsonController!: ReadableStreamDefaultController + let queuedJSONBytes = 0 + const jsonChunks = new ReadableStream( + { + start(controller) { + jsonController = controller + }, + cancel(reason) { + const error = reason ?? new Error('Framed response cancelled') + stopped = [error] + errorActiveStreams(error) + streams.clear() + return reader.cancel(reason).catch(() => {}) + }, + pull() { + queuedJSONBytes = updateQueuedBytes(jsonController, queuedJSONBytes) + }, + }, + { + highWaterMark: MAX_QUEUED_BYTES, + size(value) { + return FRAME_HEADER_SIZE + value.length * 2 + }, + }, + ) + // Process frames asynchronously ;(async () => { - const reader = input.getReader() - inputReader = reader - const bufferList: Array = [] let totalLength = 0 - - /** - * Reads header bytes from buffer chunks without flattening. - * Returns header data or null if not enough bytes available. - */ - function readHeader(): { - type: number - streamId: number - length: number - } | null { - if (totalLength < FRAME_HEADER_SIZE) return null - - const first = bufferList[0]! - - // Fast path: header fits entirely in first chunk (common case) - if (first.length >= FRAME_HEADER_SIZE) { - const type = first[0]! - const streamId = - ((first[1]! << 24) | - (first[2]! << 16) | - (first[3]! << 8) | - first[4]!) >>> - 0 - const length = - ((first[5]! << 24) | - (first[6]! << 16) | - (first[7]! << 8) | - first[8]!) >>> - 0 - return { type, streamId, length } - } - - // Slow path: header spans multiple chunks - flatten header bytes only - const headerBytes = new Uint8Array(FRAME_HEADER_SIZE) - let offset = 0 - let remaining = FRAME_HEADER_SIZE - for (let i = 0; i < bufferList.length && remaining > 0; i++) { - const chunk = bufferList[i]! - const toCopy = Math.min(chunk.length, remaining) - headerBytes.set(chunk.subarray(0, toCopy), offset) - offset += toCopy - remaining -= toCopy - } - - const type = headerBytes[0]! - const streamId = - ((headerBytes[1]! << 24) | - (headerBytes[2]! << 16) | - (headerBytes[3]! << 8) | - headerBytes[4]!) >>> - 0 - const length = - ((headerBytes[5]! << 24) | - (headerBytes[6]! << 16) | - (headerBytes[7]! << 8) | - headerBytes[8]!) >>> - 0 - - return { type, streamId, length } - } + let pendingType = -1 + let pendingStreamId = 0 + let pendingLength = 0 /** * Flattens buffer list into single Uint8Array and removes from list. */ function extractFlattened(count: number): Uint8Array { - if (count === 0) return EMPTY_BUFFER + if (count === 0) { + return EMPTY_BUFFER + } // Fast path: the requested bytes are fully contained in the first buffered // chunk (the common case — most frames arrive within a single network @@ -207,8 +187,8 @@ export function createFrameDecoder( // copying `count` bytes. The view shares the chunk's backing ArrayBuffer, // which is safe because buffered chunks are never mutated in place after // being read from the network. - const first = bufferList[0] - if (first && first.length >= count) { + const first = bufferList[0]! + if (first.length >= count) { const result = first.subarray(0, count) if (first.length === count) { bufferList.shift() @@ -224,9 +204,8 @@ export function createFrameDecoder( let offset = 0 let remaining = count - while (remaining > 0 && bufferList.length > 0) { - const chunk = bufferList[0] - if (!chunk) break + while (remaining > 0) { + const chunk = bufferList[0]! const toCopy = Math.min(chunk.length, remaining) result.set(chunk.subarray(0, toCopy), offset) @@ -248,110 +227,111 @@ export function createFrameDecoder( // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition while (true) { const { done, value } = await reader.read() - if (cancelled) break - if (done) break - - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (!value) continue + if (stopped) { + break + } + if (done) { + break + } // Append incoming chunk to buffer list - if (totalLength + value.length > MAX_BUFFERED_BYTES) { - throw new Error( - `Framed response buffer exceeded ${MAX_BUFFERED_BYTES} bytes`, - ) - } bufferList.push(value) totalLength += value.length // Parse complete frames from buffer // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition while (true) { - const header = readHeader() - if (!header) break // Not enough bytes for header - - const { type, streamId, length } = header - - if ( - type !== FrameType.JSON && - type !== FrameType.CHUNK && - type !== FrameType.END && - type !== FrameType.ERROR - ) { - throw new Error(`Unknown frame type: ${type}`) - } + if (pendingType < 0) { + if (totalLength < FRAME_HEADER_SIZE) { + break + } - // Enforce stream id conventions: JSON uses streamId 0, raw streams use non-zero ids - if (type === FrameType.JSON) { - if (streamId !== 0) { - throw new Error('Invalid JSON frame streamId (expected 0)') + const header = extractFlattened(FRAME_HEADER_SIZE) + pendingType = header[0]! + pendingStreamId = + ((header[1]! << 24) | + (header[2]! << 16) | + (header[3]! << 8) | + header[4]!) >>> + 0 + pendingLength = + ((header[5]! << 24) | + (header[6]! << 16) | + (header[7]! << 8) | + header[8]!) >>> + 0 + + if (pendingType > FrameType.ERROR) { + throw new Error(`Unknown frame type: ${pendingType}`) } - } else { - if (streamId === 0) { - throw new Error('Invalid raw frame streamId (expected non-zero)') + + // JSON uses stream ID 0; raw streams use non-zero IDs. + if ( + pendingType === FrameType.JSON + ? pendingStreamId !== 0 + : pendingStreamId === 0 + ) { + throw new Error( + pendingType === FrameType.JSON + ? 'Invalid JSON frame streamId (expected 0)' + : 'Invalid raw frame streamId (expected non-zero)', + ) } - } - if (length > MAX_FRAME_PAYLOAD_SIZE) { - throw new Error( - `Frame payload too large: ${length} bytes (max ${MAX_FRAME_PAYLOAD_SIZE})`, - ) + if (pendingLength > MAX_FRAME_PAYLOAD_SIZE) { + throw new Error( + `Frame payload too large: ${pendingLength} bytes (max ${MAX_FRAME_PAYLOAD_SIZE})`, + ) + } } - const frameSize = FRAME_HEADER_SIZE + length - if (totalLength < frameSize) break // Wait for more data - - if (++frameCount > MAX_FRAMES) { - throw new Error( - `Too many frames in framed response (max ${MAX_FRAMES})`, - ) + if (totalLength < pendingLength) { + break } - // Extract and consume header bytes - extractFlattened(FRAME_HEADER_SIZE) - - // Extract payload - const payload = extractFlattened(length) + const type = pendingType + const streamId = pendingStreamId + pendingType = -1 + const payload = extractFlattened(pendingLength) // Process frame by type switch (type) { case FrameType.JSON: { - try { - jsonController.enqueue(textDecoder.decode(payload)) - } catch { - // JSON stream may be cancelled/closed - } + const value = textDecoder.decode(payload) + jsonController.enqueue(value) + queuedJSONBytes = updateQueuedBytes( + jsonController, + queuedJSONBytes, + ) + assertQueueBudget() break } case FrameType.CHUNK: { - const ctrl = ensureController(streamId) - if (ctrl) { - ctrl.enqueue(payload) + const state = getOrCreateState(streamId) + if (!state[2]) { + state[1].enqueue(payload.slice()) + state[3] = updateQueuedBytes(state[1], state[3]) + assertQueueBudget() } break } case FrameType.END: { - const ctrl = ensureController(streamId) - cancelledStreamIds.add(streamId) - if (ctrl) { - try { - ctrl.close() - } catch { - // Already closed + const state = getOrCreateState(streamId) + if (!state[2]) { + state[2] = true + if (!state[3]) { + state[1].close() } - streamControllers.delete(streamId) } break } case FrameType.ERROR: { - const ctrl = ensureController(streamId) - cancelledStreamIds.add(streamId) - if (ctrl) { - const message = textDecoder.decode(payload) - ctrl.error(new Error(message)) - streamControllers.delete(streamId) + const state = getOrCreateState(streamId) + if (!state[2]) { + errorStream(state, new Error(textDecoder.decode(payload))) } break } @@ -359,48 +339,30 @@ export function createFrameDecoder( } } - if (totalLength !== 0) { - throw new Error('Incomplete frame at end of framed response') + if (stopped) { + return } - // Close JSON stream when done - try { - jsonController.close() - } catch { - // JSON stream may be cancelled/closed + if (pendingType >= 0 || totalLength) { + throw new Error('Incomplete frame at end of framed response') } - // Close any remaining streams (shouldn't happen in normal operation) - streamControllers.forEach((ctrl) => { - try { - ctrl.close() - } catch { - // Already closed - } - }) - streamControllers.clear() + const missingEnd = new Error( + 'Framed response ended before raw stream END', + ) + stopped = [missingEnd] + errorActiveStreams(missingEnd) + + // Close JSON stream when done + jsonController.close() } catch (error) { + stopped = [error] // Error reading - propagate to all streams - try { - jsonController.error(error) - } catch { - // Already errored/closed - } - streamControllers.forEach((ctrl) => { - try { - ctrl.error(error) - } catch { - // Already errored/closed - } - }) - streamControllers.clear() + jsonController.error(error) + errorActiveStreams(error) + void reader.cancel(error).catch(() => {}) } finally { - try { - reader.releaseLock() - } catch { - // Ignore - } - inputReader = null + reader.releaseLock() } })() diff --git a/packages/start-client-core/src/client-rpc/serverFnFetcher.ts b/packages/start-client-core/src/client-rpc/serverFnFetcher.ts index 2e993205bc..9b7bfbcf86 100644 --- a/packages/start-client-core/src/client-rpc/serverFnFetcher.ts +++ b/packages/start-client-core/src/client-rpc/serverFnFetcher.ts @@ -72,18 +72,6 @@ export function trackPostProcessPromise(promise: Promise): void { } } -/** - * Helper to await all post-processing promises. - * Uses Promise.allSettled to ensure all promises complete even if some reject. - */ -async function awaitPostProcessPromises( - promises: Array>, -): Promise { - if (promises.length > 0) { - await Promise.allSettled(promises) - } -} - /** * Checks if an object has at least one own enumerable property. * More efficient than Object.keys(obj).length > 0 as it short-circuits on first property. @@ -164,7 +152,7 @@ export async function serverFnFetcher( body = fetchBody?.body } - return await getResponse(async () => + return getResponse(async () => fetchImpl(url, { method: first.method, headers, @@ -197,9 +185,7 @@ async function serializePayload( } async function serialize(data: any) { - return JSON.stringify( - await Promise.resolve(toJSONAsync(data, { plugins: serovalPlugins! })), - ) + return JSON.stringify(await toJSONAsync(data, { plugins: serovalPlugins! })) } async function getFetchBody( @@ -268,7 +254,12 @@ async function getResponse(fn: () => Promise) { // If it's a framed response (contains RawStream), use frame decoder if (contentType.includes(TSS_CONTENT_TYPE_FRAMED)) { // Validate protocol version compatibility - validateFramedProtocolVersion(contentType) + try { + validateFramedProtocolVersion(contentType) + } catch (error) { + void response.body?.cancel(error).catch(() => {}) + throw error + } if (!response.body) { throw new Error('No response body for framed response') @@ -281,16 +272,9 @@ async function getResponse(fn: () => Promise) { // Create deserialize plugin that wires up the raw streams const rawStreamPlugin = createRawStreamDeserializePlugin(getOrCreateStream) - const plugins = [rawStreamPlugin, ...(serovalPlugins || [])] - - const refs = new Map() - result = await processFramedResponse({ - jsonStream: jsonChunks, - onMessage: (msg: any) => fromCrossJSON(msg, { refs, plugins }), - onError(msg, error) { - console.error(msg, error) - }, - }) + const plugins = [rawStreamPlugin, ...serovalPlugins!] + + result = await processFramedResponse(jsonChunks, plugins) } // If it's a JSON response, it can be simpler else if (contentType.includes('application/json')) { @@ -304,7 +288,9 @@ async function getResponse(fn: () => Promise) { setPostProcessContext(null) } // Await any async post-processing before returning - await awaitPostProcessPromises(postProcessPromises) + if (postProcessPromises.length) { + await Promise.allSettled(postProcessPromises) + } } if (!result) { @@ -352,94 +338,77 @@ async function getResponse(fn: () => Promise) { * completes before the next chunk is processed. This prevents issues when * streaming values require async post-processing (e.g., RSC decoding). */ -async function processFramedResponse({ - jsonStream, - onMessage, - onError, -}: { - jsonStream: ReadableStream - onMessage: (msg: any) => any - onError?: (msg: string, error?: any) => void -}) { +async function processFramedResponse( + jsonStream: ReadableStream, + plugins: Array>, +) { const reader = jsonStream.getReader() + const refs = new Map() + const transportFailure = new Promise((_, reject) => { + void reader.closed.catch(reject) + }) + void transportFailure.catch(() => {}) - // Read first JSON frame - this is the main result - const { value: firstValue, done: firstDone } = await reader.read() - if (firstDone || !firstValue) { - throw new Error('Stream ended before first object') + const deserialize = (json: string) => { + const postProcessPromises: Array> = [] + setPostProcessContext(postProcessPromises) + let result + try { + result = fromCrossJSON(JSON.parse(json), { refs, plugins }) + } finally { + setPostProcessContext(null) + } + return postProcessPromises.length + ? Promise.race([ + Promise.allSettled(postProcessPromises), + transportFailure, + ]).then(() => result) + : result } - // Each frame is a complete JSON string - const firstObject = JSON.parse(firstValue) + // Read and deserialize the first frame before starting the detached drain so + // its refs exist before the drain can observe a terminal transport error. + let initial: Promise + try { + const { value, done } = await reader.read() + if (done || !value) { + throw new Error('Stream ended before first object') + } + initial = Promise.resolve(deserialize(value)) + } catch (error) { + reader.cancel(error).catch(() => {}) + reader.releaseLock() + throw error + } // Process remaining frames for streaming refs like RawStream. // Keep draining until the server closes the stream. // Each chunk gets its own post-processing context to properly scope async work. - let drainCancelled = false as boolean const drain = (async () => { try { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition while (true) { const { value, done } = await reader.read() - if (done) break + if (done) { + break + } if (value) { - try { - // Set up post-processing context for this chunk - const chunkPostProcessPromises: Array> = [] - setPostProcessContext(chunkPostProcessPromises) - try { - onMessage(JSON.parse(value)) - } finally { - setPostProcessContext(null) - } - // Await any async post-processing from this chunk before processing next. - // This ensures values requiring async work are ready before their - // containing Promise/Stream resolves/emits to consumers. - await awaitPostProcessPromises(chunkPostProcessPromises) - } catch (e) { - onError?.(`Invalid JSON: ${value}`, e) - } + await deserialize(value) } } } catch (err) { - if (!drainCancelled) { - onError?.('Stream processing error:', err) - } + reader.cancel(err).catch(() => {}) + throw err + } finally { + reader.releaseLock() } })() - // Process first object with its own post-processing context - let result: any - const initialPostProcessPromises: Array> = [] - setPostProcessContext(initialPostProcessPromises) try { - result = onMessage(firstObject) + await Promise.race([initial, drain]) + return await initial } catch (err) { - setPostProcessContext(null) - drainCancelled = true - reader.cancel().catch(() => {}) + reader.cancel(err).catch(() => {}) throw err } - setPostProcessContext(null) - - // Await initial post-processing promises before returning result - await awaitPostProcessPromises(initialPostProcessPromises) - - // If the initial decode fails async, stop draining to avoid holding - // onto the response body and raw stream buffers unnecessarily. - Promise.resolve(result).catch(() => { - drainCancelled = true - reader.cancel().catch(() => {}) - }) - - // Detach reader once draining completes. - drain.finally(() => { - try { - reader.releaseLock() - } catch { - // Ignore - } - }) - - return result } diff --git a/packages/start-client-core/src/constants.ts b/packages/start-client-core/src/constants.ts index 3df983dfe7..01c71ca96f 100644 --- a/packages/start-client-core/src/constants.ts +++ b/packages/start-client-core/src/constants.ts @@ -15,7 +15,7 @@ export const TSS_CONTENT_TYPE_FRAMED = 'application/x-tss-framed' * Frame types for binary multiplexing protocol. */ export const FrameType = { - /** Seroval JSON chunk (NDJSON line) */ + /** Seroval JSON value */ JSON: 0, /** Raw stream data chunk */ CHUNK: 1, @@ -30,6 +30,12 @@ export type FrameType = (typeof FrameType)[keyof typeof FrameType] /** Header size in bytes: type(1) + streamId(4) + length(4) */ export const FRAME_HEADER_SIZE = 9 +/** Maximum payload size accepted by the framed response decoder (16 MiB). */ +export const MAX_FRAME_PAYLOAD_SIZE = 16 * 1024 * 1024 + +/** Maximum number of raw streams accepted in one framed response. */ +export const MAX_FRAMED_STREAMS = 1024 + /** Current protocol version for framed responses */ export const TSS_FRAMED_PROTOCOL_VERSION = 1 diff --git a/packages/start-client-core/src/index.tsx b/packages/start-client-core/src/index.tsx index 5bf09c4440..6c79b366ab 100644 --- a/packages/start-client-core/src/index.tsx +++ b/packages/start-client-core/src/index.tsx @@ -104,6 +104,8 @@ export { TSS_FRAMED_PROTOCOL_VERSION, FrameType, FRAME_HEADER_SIZE, + MAX_FRAME_PAYLOAD_SIZE, + MAX_FRAMED_STREAMS, X_TSS_SERIALIZED, X_TSS_RAW_RESPONSE, X_TSS_CONTEXT, diff --git a/packages/start-client-core/tests/frame-decoder.test.ts b/packages/start-client-core/tests/frame-decoder.test.ts index 3a05611392..ab9cdbe364 100644 --- a/packages/start-client-core/tests/frame-decoder.test.ts +++ b/packages/start-client-core/tests/frame-decoder.test.ts @@ -1,6 +1,11 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { createFrameDecoder } from '../src/client-rpc/frame-decoder' -import { FRAME_HEADER_SIZE, FrameType } from '../src/constants' +import { + FRAME_HEADER_SIZE, + MAX_FRAME_PAYLOAD_SIZE, + MAX_FRAMED_STREAMS, + FrameType, +} from '../src/constants' /** * Helper to encode a frame for testing @@ -96,7 +101,7 @@ describe('frame-decoder', () => { const view = new DataView(headerOnly.buffer) view.setUint8(0, FrameType.JSON) view.setUint32(1, 0, false) - view.setUint32(5, 16 * 1024 * 1024 + 1, false) + view.setUint32(5, MAX_FRAME_PAYLOAD_SIZE + 1, false) const input = new ReadableStream({ start(controller) { @@ -131,26 +136,150 @@ describe('frame-decoder', () => { await expect(reader.read()).rejects.toThrow('Incomplete frame') }) - it('should cancel input when jsonChunks cancelled', async () => { - let cancelled = false + it('should error a raw stream that reaches transport EOF without END', async () => { + const chunk = encodeChunkFrame(1, new Uint8Array([1, 2, 3])) + const input = new ReadableStream({ + start(controller) { + controller.enqueue(chunk) + controller.close() + }, + }) + const { getOrCreateStream, jsonChunks } = createFrameDecoder(input) + const rawReader = getOrCreateStream(1).getReader() + + await expect(jsonChunks.getReader().read()).resolves.toEqual({ + done: true, + value: undefined, + }) + await expect(rawReader.read()).rejects.toThrow( + 'ended before raw stream END', + ) + }) + + it('should error an unknown raw stream requested after transport EOF', async () => { + const { getOrCreateStream, jsonChunks } = createFrameDecoder( + new ReadableStream({ + start(controller) { + controller.close() + }, + }), + ) + await jsonChunks.getReader().read() + + const read = getOrCreateStream(1).getReader().read() + const result = await Promise.race([ + read.then( + () => undefined, + (error: unknown) => error, + ), + new Promise<'pending'>((resolve) => { + setTimeout(() => resolve('pending'), 0) + }), + ]) + + expect(result).toBeInstanceOf(Error) + expect((result as Error).message).toContain('ended before raw stream END') + }) + + it('should deliver a raw chunk to an already waiting reader', async () => { + let inputController!: ReadableStreamDefaultController + const input = new ReadableStream({ + start(controller) { + inputController = controller + }, + }) + const { getOrCreateStream } = createFrameDecoder(input) + const reader = getOrCreateStream(1).getReader() + const firstRead = reader.read() + + await new Promise((resolve) => setTimeout(resolve, 0)) + inputController.enqueue(encodeChunkFrame(1, new Uint8Array([1, 2, 3]))) + inputController.enqueue(encodeEndFrame(1)) + inputController.close() + + await expect(firstRead).resolves.toEqual({ + done: false, + value: new Uint8Array([1, 2, 3]), + }) + await expect(reader.read()).resolves.toEqual({ + done: true, + value: undefined, + }) + }) + + it('should preserve a queued empty raw chunk before END', async () => { + const input = new ReadableStream({ + start(controller) { + controller.enqueue(encodeChunkFrame(1, new Uint8Array())) + controller.enqueue(encodeEndFrame(1)) + controller.close() + }, + }) + const { getOrCreateStream, jsonChunks } = createFrameDecoder(input) + + await expect(jsonChunks.getReader().read()).resolves.toEqual({ + done: true, + value: undefined, + }) + const reader = getOrCreateStream(1).getReader() + await expect(reader.read()).resolves.toEqual({ + done: false, + value: new Uint8Array(), + }) + await expect(reader.read()).resolves.toEqual({ + done: true, + value: undefined, + }) + }) + + it('should forward cancellation and swallow input cleanup failures', async () => { + let cancelReason: unknown const input = new ReadableStream({ pull() {}, - cancel() { - cancelled = true + cancel(reason) { + cancelReason = reason + return Promise.reject(new Error('input cleanup failed')) }, }) const { jsonChunks } = createFrameDecoder(input) - const reader = jsonChunks.getReader() + const reason = new Error('consumer cancelled') + + await expect(jsonChunks.cancel(reason)).resolves.toBeUndefined() + expect(cancelReason).toBe(reason) + }) + + it('should cancel the input after a protocol error', async () => { + const badFrame = encodeFrame(99, 0, new Uint8Array(0)) + let cancelReason: unknown + const input = new ReadableStream({ + start(controller) { + controller.enqueue(badFrame) + }, + cancel(reason) { + cancelReason = reason + }, + }) + const { jsonChunks } = createFrameDecoder(input) - await reader.cancel() - expect(cancelled).toBe(true) + const error = await jsonChunks + .getReader() + .read() + .then( + () => undefined, + (cause: unknown) => cause, + ) + + expect(error).toBeInstanceOf(Error) + await vi.waitFor(() => { + expect(cancelReason).toBe(error) + }) }) it('should reject too many raw streams', async () => { // END frames create streams via ensureController, even with no CHUNKs. const frames: Array = [] - for (let i = 1; i <= 1025; i++) { + for (let i = 1; i <= MAX_FRAMED_STREAMS + 1; i++) { frames.push(encodeEndFrame(i)) } @@ -175,21 +304,212 @@ describe('frame-decoder', () => { await expect(reader.read()).rejects.toThrow('Too many raw streams') }) - it('should reject when buffered bytes exceed limit', async () => { - // No valid frame can be parsed from this; we just want to exceed MAX_BUFFERED_BYTES. - const tooLarge = new Uint8Array(32 * 1024 * 1024 + 1) + it('should count cancelled raw stream IDs toward the response limit', async () => { + const { getOrCreateStream, jsonChunks } = createFrameDecoder( + new ReadableStream(), + ) + + for (let id = 1; id <= MAX_FRAMED_STREAMS; id++) { + await getOrCreateStream(id).cancel() + } + + expect(() => getOrCreateStream(MAX_FRAMED_STREAMS + 1)).toThrow( + `Too many raw streams in framed response (max ${MAX_FRAMED_STREAMS})`, + ) + await jsonChunks.cancel() + }) + + it('should allow a consumed raw stream to exceed 100,000 frames', async () => { + const frameCount = 100_001 + const frame = encodeChunkFrame(1, new Uint8Array([1])) + const end = encodeEndFrame(1) + const combined = new Uint8Array(frame.length * frameCount + end.length) + for (let index = 0; index < frameCount; index++) { + combined.set(frame, index * frame.length) + } + combined.set(end, frame.length * frameCount) + const input = new ReadableStream({ + start(controller) { + controller.enqueue(combined) + controller.close() + }, + }) + const { getOrCreateStream, jsonChunks } = createFrameDecoder(input) + + const received = await new Response( + getOrCreateStream(1), + ).arrayBuffer() + expect(received.byteLength).toBe(frameCount) + await expect(jsonChunks.getReader().read()).resolves.toEqual({ + done: true, + value: undefined, + }) + }) + + it('should decode valid frames coalesced into one large transport chunk', async () => { + const frame = encodeChunkFrame(1, new Uint8Array(MAX_FRAME_PAYLOAD_SIZE)) + const tail = encodeChunkFrame( + 1, + new Uint8Array(MAX_FRAME_PAYLOAD_SIZE - FRAME_HEADER_SIZE), + ) + const end = encodeEndFrame(1) + const combined = new Uint8Array(frame.length + tail.length + end.length) + combined.set(frame) + combined.set(tail, frame.length) + combined.set(end, frame.length + tail.length) + const input = new ReadableStream({ + start(controller) { + controller.enqueue(combined) + controller.close() + }, + }) + const { getOrCreateStream, jsonChunks } = createFrameDecoder(input) + const rawReader = getOrCreateStream(1).getReader() + + await expect(jsonChunks.getReader().read()).resolves.toEqual({ + done: true, + value: undefined, + }) + await expect(rawReader.read()).resolves.toMatchObject({ + done: false, + value: { byteLength: MAX_FRAME_PAYLOAD_SIZE }, + }) + await expect(rawReader.read()).resolves.toMatchObject({ + done: false, + value: { + byteLength: MAX_FRAME_PAYLOAD_SIZE - FRAME_HEADER_SIZE, + }, + }) + await expect(rawReader.read()).resolves.toEqual({ + done: true, + value: undefined, + }) + }) + + it('accepts one maximum-size JSON frame while its consumer is stalled', async () => { + const json = 'x'.repeat(MAX_FRAME_PAYLOAD_SIZE) + const input = new ReadableStream({ + start(controller) { + controller.enqueue(encodeJSONFrame(json)) + controller.close() + }, + }) + const { jsonChunks } = createFrameDecoder(input) + + await new Promise((resolve) => setTimeout(resolve, 0)) + const result = await jsonChunks.getReader().read() + expect(result.done).toBe(false) + expect(result.value?.length).toBe(MAX_FRAME_PAYLOAD_SIZE) + }) + it('delivers later JSON before an unconsumed large raw stream is read', async () => { + const payload = new Uint8Array(MAX_FRAME_PAYLOAD_SIZE) const input = new ReadableStream({ start(controller) { - controller.enqueue(tooLarge) + controller.enqueue(encodeJSONFrame('{"ref":1}')) + controller.enqueue(encodeChunkFrame(1, payload)) + controller.enqueue(encodeChunkFrame(1, new Uint8Array([1]))) + controller.enqueue(encodeJSONFrame('{"later":true}')) + controller.enqueue(encodeEndFrame(1)) controller.close() }, }) + const { getOrCreateStream, jsonChunks } = createFrameDecoder(input) + const rawReader = getOrCreateStream(1).getReader() + const jsonReader = jsonChunks.getReader() + + await expect(jsonReader.read()).resolves.toMatchObject({ + value: '{"ref":1}', + }) + const laterJSON = jsonReader.read() + await expect(laterJSON).resolves.toMatchObject({ + value: '{"later":true}', + }) + await expect(rawReader.read()).resolves.toMatchObject({ + value: { byteLength: payload.byteLength }, + }) + await expect(rawReader.read()).resolves.toMatchObject({ + value: new Uint8Array([1]), + }) + await expect(rawReader.read()).resolves.toMatchObject({ done: true }) + }) + + it('rejects when unread decoded output queues exceed the byte budget', async () => { + const cancel = vi.fn() + let frame = 0 + const input = new ReadableStream({ + pull(controller) { + if (frame++ === 0) { + controller.enqueue(encodeJSONFrame('{"ref":1}')) + } else if (frame < 4) { + controller.enqueue( + encodeChunkFrame(1, new Uint8Array(MAX_FRAME_PAYLOAD_SIZE)), + ) + } else { + controller.enqueue(encodeEndFrame(1)) + controller.close() + } + }, + cancel, + }) + const { jsonChunks } = createFrameDecoder(input) + + await vi.waitFor(() => { + expect(cancel).toHaveBeenCalledOnce() + }) + await expect(jsonChunks.getReader().read()).rejects.toThrow( + 'Framed response queue exceeded', + ) + }) + + it('allows consumed traffic to exceed the cumulative queue budget', async () => { + let frame = 0 + const input = new ReadableStream({ + pull(controller) { + if (frame++ < 3) { + controller.enqueue( + encodeChunkFrame(1, new Uint8Array(MAX_FRAME_PAYLOAD_SIZE)), + ) + } else { + controller.enqueue(encodeEndFrame(1)) + controller.close() + } + }, + }) + const { getOrCreateStream, jsonChunks } = createFrameDecoder(input) + const reader = getOrCreateStream(1).getReader() + let received = 0 + + while (true) { + const chunk = await reader.read() + if (chunk.done) { + break + } + received += chunk.value.byteLength + } + + expect(received).toBe(MAX_FRAME_PAYLOAD_SIZE * 3) + await expect(jsonChunks.getReader().read()).resolves.toMatchObject({ + done: true, + }) + }) + it('surfaces a transport failure after JSON was queued', async () => { + const sourceError = new Error('transport failed') + let inputController!: ReadableStreamDefaultController + const input = new ReadableStream({ + start(controller) { + inputController = controller + controller.enqueue(encodeJSONFrame('{"queued":true}')) + }, + }) const { jsonChunks } = createFrameDecoder(input) - const reader = jsonChunks.getReader() - await expect(reader.read()).rejects.toThrow('buffer exceeded') + await new Promise((resolve) => setTimeout(resolve, 0)) + inputController.error(sourceError) + await new Promise((resolve) => setTimeout(resolve, 0)) + + await expect(jsonChunks.getReader().read()).rejects.toBe(sourceError) }) it('should decode JSON frames', async () => { diff --git a/packages/start-client-core/tests/serverFnFetcher.test.ts b/packages/start-client-core/tests/serverFnFetcher.test.ts new file mode 100644 index 0000000000..72df1804da --- /dev/null +++ b/packages/start-client-core/tests/serverFnFetcher.test.ts @@ -0,0 +1,395 @@ +import { describe, expect, it, vi } from 'vitest' +import { toCrossJSONStream } from 'seroval' +import { + RawStream, + createRawStreamRPCPlugin, + createSerializationAdapter, + makeSerovalPlugin, +} from '@tanstack/router-core' +import { + serverFnFetcher, + trackPostProcessPromise, +} from '../src/client-rpc/serverFnFetcher' +import { + FRAME_HEADER_SIZE, + FrameType, + MAX_FRAME_PAYLOAD_SIZE, + TSS_CONTENT_TYPE_FRAMED, + TSS_CONTENT_TYPE_FRAMED_VERSIONED, + X_TSS_SERIALIZED, +} from '../src/constants' +import type { SerovalNode } from 'seroval' +import type { Plugin as SerovalPlugin } from 'seroval' + +const mocks = vi.hoisted(() => ({ plugins: [] as Array })) +vi.mock('../src/getDefaultSerovalPlugins', () => ({ + getDefaultSerovalPlugins: () => mocks.plugins, +})) + +const textEncoder = new TextEncoder() + +function encodeFrame( + type: FrameType, + streamId: number, + payload: Uint8Array, +): Uint8Array { + const frame = new Uint8Array(FRAME_HEADER_SIZE + payload.length) + const view = new DataView(frame.buffer) + view.setUint8(0, type) + view.setUint32(1, streamId, false) + view.setUint32(5, payload.length, false) + frame.set(payload, FRAME_HEADER_SIZE) + return frame +} + +function encodeJSONPayload(json: string): Uint8Array { + return encodeFrame(FrameType.JSON, 0, textEncoder.encode(json)) +} + +function encodeJSONFrame(value: SerovalNode): Uint8Array { + return encodeJSONPayload(JSON.stringify(value)) +} + +function serializeInitial(value: unknown): SerovalNode { + let initial!: SerovalNode + const destroy = toCrossJSONStream(value, { + refs: new Map(), + plugins: mocks.plugins as Array>, + onParse(value) { + initial ??= value + }, + onDone() {}, + onError(error) { + throw error + }, + }) + destroy() + return initial +} + +function createDeferred() { + let resolve!: () => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} + +function createFramedResponse(value: unknown) { + let controller!: ReadableStreamDefaultController + const body = new ReadableStream({ + start(ctrl) { + controller = ctrl + controller.enqueue(encodeJSONFrame(serializeInitial(value))) + }, + }) + const response = new Response(body, { + headers: { + 'content-type': TSS_CONTENT_TYPE_FRAMED_VERSIONED, + [X_TSS_SERIALIZED]: 'true', + }, + }) + return { controller, response } +} + +function createStreamingFramedResponse(value: unknown) { + let controller!: ReadableStreamDefaultController + let destroy!: () => void + const body = new ReadableStream({ + start(ctrl) { + controller = ctrl + destroy = toCrossJSONStream(value, { + refs: new Map(), + plugins: mocks.plugins as Array>, + onParse(value) { + controller.enqueue(encodeJSONFrame(value)) + }, + onDone() {}, + onError(error) { + controller.error(error) + }, + }) + }, + cancel() { + destroy() + }, + }) + const response = new Response(body, { + headers: { + 'content-type': TSS_CONTENT_TYPE_FRAMED_VERSIONED, + [X_TSS_SERIALIZED]: 'true', + }, + }) + return { controller, destroy, response } +} + +function createRawPromiseFramedResponse() { + let destroy!: () => void + const body = new ReadableStream({ + start(controller) { + let resolveLater!: (value: string) => void + const later = new Promise((resolve) => { + resolveLater = resolve + }) + let rawId!: number + let initial = true + destroy = toCrossJSONStream( + { + raw: new RawStream(new ReadableStream()), + later, + }, + { + refs: new Map(), + plugins: [ + createRawStreamRPCPlugin((id) => { + rawId = id + }), + ], + onParse(value) { + controller.enqueue(encodeJSONFrame(value)) + if (initial) { + initial = false + const chunk = new Uint8Array(MAX_FRAME_PAYLOAD_SIZE) + controller.enqueue(encodeFrame(FrameType.CHUNK, rawId, chunk)) + setTimeout(() => resolveLater('resolved'), 0) + } + }, + onDone() { + controller.enqueue( + encodeFrame(FrameType.END, rawId, new Uint8Array()), + ) + controller.close() + }, + onError(error) { + controller.error(error) + }, + }, + ) + }, + cancel() { + destroy() + }, + }) + return new Response(body, { + headers: { + 'content-type': TSS_CONTENT_TYPE_FRAMED_VERSIONED, + [X_TSS_SERIALIZED]: 'true', + }, + }) +} + +async function observe(promise: Promise) { + return Promise.race([ + promise.then( + (value) => ({ status: 'resolved' as const, value }), + (error: unknown) => ({ status: 'rejected' as const, error }), + ), + new Promise<{ status: 'pending' }>((resolve) => { + setTimeout(() => resolve({ status: 'pending' }), 50) + }), + ]) +} + +describe('serverFnFetcher framed responses', () => { + it('cancels a response body rejected for an incompatible protocol version', async () => { + let cancelReason: unknown + const body = new ReadableStream({ + cancel(reason) { + cancelReason = reason + }, + }) + const response = new Response(body, { + headers: { + 'content-type': `${TSS_CONTENT_TYPE_FRAMED}; v=999`, + [X_TSS_SERIALIZED]: 'true', + }, + }) + + await expect( + serverFnFetcher( + '/server-fn', + [{ method: 'GET', context: {} }], + async () => response, + ), + ).rejects.toThrow('Incompatible framed protocol version') + await vi.waitFor(() => { + expect(cancelReason).toBeInstanceOf(Error) + expect(body.locked).toBe(false) + }) + }) + + it('cancels and unlocks the body after malformed initial JSON', async () => { + let cancelReason: unknown + const body = new ReadableStream({ + start(controller) { + controller.enqueue(encodeJSONPayload('{')) + }, + cancel(reason) { + cancelReason = reason + }, + }) + const response = new Response(body, { + headers: { + 'content-type': TSS_CONTENT_TYPE_FRAMED_VERSIONED, + [X_TSS_SERIALIZED]: 'true', + }, + }) + + await expect( + serverFnFetcher( + '/server-fn', + [{ method: 'GET', context: {} }], + async () => response, + ), + ).rejects.toBeInstanceOf(SyntaxError) + await vi.waitFor(() => { + expect(cancelReason).toBeInstanceOf(SyntaxError) + expect(body.locked).toBe(false) + }) + }) + + it('cancels and unlocks the body when initial deserialization throws', async () => { + class ThrowingValue {} + class LaterValue {} + const failure = new Error('deserialization failed') + let laterDeserializations = 0 + const plugin = makeSerovalPlugin( + createSerializationAdapter({ + key: 'throwing-deserializer-test', + test: (value): value is ThrowingValue | LaterValue => + value instanceof ThrowingValue || value instanceof LaterValue, + toSerializable: (value) => value instanceof LaterValue, + fromSerializable: (later) => { + if (later) { + laterDeserializations++ + return new LaterValue() + } + throw failure + }, + }), + ) + mocks.plugins.push(plugin) + let cancelReason: unknown + const body = new ReadableStream({ + start(controller) { + controller.enqueue( + encodeJSONFrame(serializeInitial(new ThrowingValue())), + ) + controller.enqueue(encodeJSONFrame(serializeInitial(new LaterValue()))) + }, + cancel(reason) { + cancelReason = reason + }, + }) + const response = new Response(body, { + headers: { + 'content-type': TSS_CONTENT_TYPE_FRAMED_VERSIONED, + [X_TSS_SERIALIZED]: 'true', + }, + }) + + try { + await expect( + serverFnFetcher( + '/server-fn', + [{ method: 'GET', context: {} }], + async () => response, + ), + ).rejects.toMatchObject({ cause: failure }) + await vi.waitFor(() => { + expect(cancelReason).toBeInstanceOf(Error) + expect(body.locked).toBe(false) + }) + expect(laterDeserializations).toBe(0) + } finally { + mocks.plugins.pop() + } + }) + + it('forwards a server-side Promise rejection', async () => { + let rejectLater!: (error: unknown) => void + const later = new Promise((_, reject) => { + rejectLater = reject + }) + const { controller, destroy, response } = createStreamingFramedResponse({ + later, + }) + + try { + const result = (await serverFnFetcher( + '/server-fn', + [{ method: 'GET', context: {} }], + async () => response, + )) as { later: Promise } + const error = new Error('server rejected') + + rejectLater(error) + + await expect(result.later).rejects.toMatchObject({ + message: 'server rejected', + }) + } finally { + destroy() + controller.close() + } + }) + + it('rejects when transport fails during initial post-processing', async () => { + class PostProcessedValue {} + const started = createDeferred() + const postProcess = createDeferred() + const plugin = makeSerovalPlugin( + createSerializationAdapter({ + key: 'pending-post-process-test', + test: (value): value is PostProcessedValue => + value instanceof PostProcessedValue, + toSerializable: () => null, + fromSerializable: () => { + trackPostProcessPromise(postProcess.promise) + started.resolve() + return 'decoded' + }, + }), + ) + mocks.plugins.push(plugin) + const { controller, response } = createFramedResponse( + new PostProcessedValue(), + ) + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + + try { + const result = serverFnFetcher( + '/server-fn', + [{ method: 'GET', context: {} }], + async () => response, + ) + await started.promise + const transportError = new Error('transport failed') + + controller.error(transportError) + + await expect(observe(result)).resolves.toEqual({ + status: 'rejected', + error: transportError, + }) + } finally { + postProcess.resolve() + mocks.plugins.pop() + consoleError.mockRestore() + } + }) + + it('resolves a sibling Promise without consuming an eager RawStream', async () => { + const result = (await serverFnFetcher( + '/server-fn', + [{ method: 'GET', context: {} }], + async () => createRawPromiseFramedResponse(), + )) as { raw: ReadableStream; later: Promise } + + try { + await expect(result.later).resolves.toBe('resolved') + } finally { + await result.raw.cancel() + } + }) +}) diff --git a/packages/start-server-core/src/createStartHandler.ts b/packages/start-server-core/src/createStartHandler.ts index 20c4763137..ff67afd432 100644 --- a/packages/start-server-core/src/createStartHandler.ts +++ b/packages/start-server-core/src/createStartHandler.ts @@ -21,8 +21,7 @@ import { getOrigin, isSsrResponse, normalizeSsrResponse, - replaceSsrResponse, - stripSsrResponseBody, + _transferSsrResponse, waitForRequest, } from '@tanstack/router-core/ssr/server' import { @@ -199,7 +198,7 @@ function throwIfMayNotDefer(): never { * Check if a value is a special response (Response or Redirect) */ function isSpecialResponse(value: unknown): value is Response { - return value instanceof Response || isRedirect(value) + return value instanceof Response } /** @@ -219,10 +218,6 @@ function disposeLateResponse(result: TODO, signal: AbortSignal): void { } } -function isSignalAborted(signal: AbortSignal): boolean { - return signal.aborted -} - /** * Execute a middleware chain */ @@ -232,10 +227,16 @@ async function executeMiddleware( signal: AbortSignal, ): Promise<{ ctx: TODO; response: HandlerCallbackResult }> { let index = -1 - let streamResponse: - | Extract - | undefined - let retiredStreamIdentities: WeakSet | undefined + let responseOwner: HandlerCallbackResult | undefined + let responseOwnerBody: ReadableStream | null | undefined + let retiredResponseIdentities: WeakSet | undefined + + const getResponse = (value: unknown) => + isSsrResponse(value) + ? value.response + : value instanceof Response + ? value + : undefined const isResponseAlias = (candidate: unknown, response: Response) => candidate === response || @@ -243,49 +244,77 @@ async function executeMiddleware( response.body !== null && candidate.body === response.body) - const setResponse = (response: TODO) => { - if (isSsrResponse(response)) { - if (response.serverSsrCleanup === 'stream') { - streamResponse = response - } - ctx.response = response.response + const retireResponse = (reason: unknown) => { + const owner = responseOwner + if (!owner) { return } - ctx.response = response - } - - const disposeStreamResponse = async (reason: unknown) => { - const response = streamResponse - if (!response) { - return + responseOwner = undefined + responseOwnerBody = undefined + const response = getResponse(owner)! + retiredResponseIdentities ??= new WeakSet() + retiredResponseIdentities.add(response) + if (response.body) { + retiredResponseIdentities.add(response.body) } + if (isResponseAlias(ctx.response, response)) { + ctx.response = undefined + } + disposeSsrResponseDetached(owner, reason) + } - streamResponse = undefined - retiredStreamIdentities ??= new WeakSet() - retiredStreamIdentities.add(response.response) - if (response.response.body) { - retiredStreamIdentities.add(response.response.body) + const setResponse = (value: TODO) => { + const owned = getResponse(value) + let response = owned ?? value + const owner = responseOwner + const ownedResponse = getResponse(responseOwner) + if (ownedResponse && !isResponseAlias(response, ownedResponse)) { + if ( + response instanceof Response && + response.body && + isSsrResponse(owner) && + owner.serverSsrCleanup === 'stream' && + responseOwnerBody && + (responseOwnerBody.locked || ownedResponse.body !== responseOwnerBody) + ) { + if (ownedResponse.body !== responseOwnerBody) { + disposeSsrResponseDetached( + ownedResponse, + 'middleware response transferred', + ) + } + responseOwner = _transferSsrResponse( + owner, + response as Response & { body: ReadableStream }, + ) + response = responseOwner.response + responseOwnerBody = response.body + } else { + retireResponse('middleware response replaced') + } } - const currentResponse = ctx.response - if (isResponseAlias(currentResponse, response.response)) { - ctx.response = undefined + if (!responseOwner && owned) { + responseOwner = value + responseOwnerBody = owned.body } - await response.dispose(reason) + ctx.response = response } const disposeAbandonedResult = (result: TODO) => { const exposed = handleCtxResult(result)?.response - const response = isSsrResponse(exposed) ? exposed.response : exposed - if (streamResponse && isResponseAlias(response, streamResponse.response)) { - void disposeStreamResponse(signal.reason).catch(console.error) + const response = getResponse(exposed) + const ownedResponse = getResponse(responseOwner) + if (ownedResponse && isResponseAlias(response, ownedResponse)) { + retireResponse(signal.reason) return } if ( - response instanceof Response && - retiredStreamIdentities && - (retiredStreamIdentities.has(response) || - (response.body !== null && retiredStreamIdentities.has(response.body))) + response && + retiredResponseIdentities && + (retiredResponseIdentities.has(response) || + (response.body !== null && + retiredResponseIdentities.has(response.body))) ) { return } @@ -293,39 +322,29 @@ async function executeMiddleware( disposeLateResponse(result, signal) } - const getFinalResponse = async (): Promise => { + const getFinalResponse = (): HandlerCallbackResult => { const response = ctx.response if (!response) { throwRouteHandlerError() } - if (!streamResponse) { + const owner = responseOwner + const ownedResponse = getResponse(owner) + if (!ownedResponse) { return response } - if (response === streamResponse.response) { - return streamResponse - } - - if ( - streamResponse.response.body !== null && - response.body === streamResponse.response.body - ) { - return { ...streamResponse, response } + if (isResponseAlias(response, ownedResponse)) { + if (isSsrResponse(owner) && owner.serverSsrCleanup === 'stream') { + return response === ownedResponse ? owner : { ...owner, response } + } + return response } - await disposeStreamResponse('middleware response replaced') + retireResponse('middleware response replaced') return response } - let nextPromise: Promise | undefined - - function next(nextCtx?: TODO): Promise { - const result = runNext(nextCtx) - nextPromise = result - return result - } - async function runNext(nextCtx?: TODO): Promise { if (signal.aborted) { throw signal.reason @@ -352,20 +371,13 @@ async function executeMiddleware( let result: TODO try { - const pending = middleware({ ...ctx, next }) - // A directly returned next() promise already propagates request aborts. - if (pending === nextPromise) { - nextPromise = undefined - result = await pending - if (isSignalAborted(signal)) { - disposeAbandonedResult(result) - throw signal.reason - } - } else { - result = await waitForRequest(pending, signal, disposeAbandonedResult) - } + result = await waitForRequest( + middleware({ ...ctx, next: runNext }), + signal, + disposeAbandonedResult, + ) } catch (err) { - if (isSignalAborted(signal)) { + if (signal.aborted) { throw signal.reason } if (isSpecialResponse(err)) { @@ -390,23 +402,14 @@ async function executeMiddleware( try { await runNext() - const response = await waitForRequest( - getFinalResponse(), - signal, - disposeAbandonedResult, - ) + const response = getFinalResponse() if (signal.aborted) { disposeAbandonedResult(response) throw signal.reason } return { ctx, response } } catch (err) { - const disposal = disposeStreamResponse(signal.aborted ? signal.reason : err) - if (signal.aborted) { - void disposal.catch(console.error) - } else { - await disposal - } + retireResponse(signal.aborted ? signal.reason : err) throw err } } @@ -624,16 +627,19 @@ export function createStartHandler( request.signal, ) - const result = await handleRedirectResponse( + const result = await finalizeResponse( middlewareResponse, request, getRouter, + ) + const bound = bindSsrResponseToRequest( + router ?? undefined, + result, request.signal, ) - bindSsrResponseToRequest(router ?? undefined, result, request.signal) request.signal.throwIfAborted() - responseOwnsCleanup = result.serverSsrCleanup === 'stream' - return result.response + responseOwnsCleanup = bound.serverSsrCleanup === 'stream' + return bound.response } // Router execution function @@ -769,16 +775,19 @@ export function createStartHandler( request.signal, ) - const response = await handleRedirectResponse( + const response = await finalizeResponse( middlewareResponse, request, getRouter, + ) + const bound = bindSsrResponseToRequest( + router ?? undefined, + response, request.signal, ) - bindSsrResponseToRequest(router ?? undefined, response, request.signal) request.signal.throwIfAborted() - responseOwnsCleanup = response.serverSsrCleanup === 'stream' - return response.response + responseOwnsCleanup = bound.serverSsrCleanup === 'stream' + return bound.response } finally { if (router?.serverSsr && !responseOwnsCleanup) { // Clean up router SSR state if it was set up but won't be cleaned up by the callback @@ -793,6 +802,37 @@ export function createStartHandler( return requestHandler(startRequestResolver) } +async function finalizeResponse( + response: HandlerCallbackResult, + request: Request, + getRouter: () => Promise, +): Promise { + const signal = request.signal + // Resolve redirects before stripping so HEAD preserves the final Location. + const resolved = await handleRedirectResponse( + response, + request, + getRouter, + signal, + ) + return request.method === 'HEAD' + ? replaceSsrResponseDetached( + resolved, + new Response(null, resolved.response), + 'HEAD body stripped', + ) + : resolved +} + +function replaceSsrResponseDetached( + result: SsrResponse, + response: Response, + reason: unknown, +): SsrResponse { + disposeSsrResponseDetached(result, reason) + return { response, serverSsrCleanup: 'none' } +} + async function handleRedirectResponse( response: HandlerCallbackResult, request: Request, @@ -807,16 +847,13 @@ async function handleRedirectResponse( if (isResolvedRedirect(ssrResponse.response)) { if (request.headers.get('x-tsr-serverFn') === 'true') { - return waitForRequest( - replaceSsrResponse( - ssrResponse, - Response.json( - { ...ssrResponse.response.options, isSerializedRedirect: true }, - { headers: ssrResponse.response.headers }, - ), - 'redirect response replaced', + return replaceSsrResponseDetached( + ssrResponse, + Response.json( + { ...ssrResponse.response.options, isSerializedRedirect: true }, + { headers: ssrResponse.response.headers }, ), - signal, + 'redirect response replaced', ) } return ssrResponse @@ -850,22 +887,20 @@ async function handleRedirectResponse( const redirect = router.resolveRedirect(ssrResponse.response) if (request.headers.get('x-tsr-serverFn') === 'true') { - return waitForRequest( - replaceSsrResponse( - ssrResponse, - Response.json( - { ...ssrResponse.response.options, isSerializedRedirect: true }, - { headers: ssrResponse.response.headers }, - ), - 'redirect response replaced', + return replaceSsrResponseDetached( + ssrResponse, + Response.json( + { ...ssrResponse.response.options, isSerializedRedirect: true }, + { headers: ssrResponse.response.headers }, ), - signal, + 'redirect response replaced', ) } - return waitForRequest( - replaceSsrResponse(ssrResponse, redirect, 'redirect response replaced'), - signal, + return replaceSsrResponseDetached( + ssrResponse, + redirect, + 'redirect response replaced', ) } @@ -919,7 +954,6 @@ async function handleServerRoutes({ // Add handler middleware if exact match const server = foundRoute?.options.server - let isHeadFallback = false if (server?.handlers && isExactMatch) { const handlers = typeof server.handlers === 'function' @@ -933,9 +967,6 @@ async function handleServerRoutes({ requestMethod === 'HEAD' ? (handlers['HEAD'] ?? handlers['GET'] ?? handlers['ANY']) : (handlers[requestMethod] ?? handlers['ANY']) - isHeadFallback = - requestMethod === 'HEAD' && handler !== undefined && !handlers['HEAD'] - if (handler) { const mayDefer = !!foundRoute.options.component @@ -959,7 +990,7 @@ async function handleServerRoutes({ routeMiddlewares.push(((ctx: TODO) => executeRouter(ctx.context, matchedRoutes)) as TODO) - const { ctx, response } = await executeMiddleware( + const { response } = await executeMiddleware( routeMiddlewares, { request, @@ -971,24 +1002,5 @@ async function handleServerRoutes({ request.signal, ) - // RFC 9110 §9.3.2: HEAD must carry the same header fields as GET but no body. - // Resolve any redirect before stripping so the Location header survives. - if (isHeadFallback) { - if (!ctx.response) { - throwRouteHandlerError() - } - - const resolved = await handleRedirectResponse( - response, - request, - getRouter, - request.signal, - ) - return waitForRequest( - stripSsrResponseBody(resolved, 'HEAD body stripped'), - request.signal, - ) - } - return normalizeSsrResponse(response) } diff --git a/packages/start-server-core/src/frame-protocol.ts b/packages/start-server-core/src/frame-protocol.ts index e96275c0bb..1d4c8e63be 100644 --- a/packages/start-server-core/src/frame-protocol.ts +++ b/packages/start-server-core/src/frame-protocol.ts @@ -9,10 +9,16 @@ */ // Re-export constants from shared location -import { FRAME_HEADER_SIZE, FrameType } from '@tanstack/start-client-core' +import { + FRAME_HEADER_SIZE, + FrameType, + MAX_FRAME_PAYLOAD_SIZE, +} from '@tanstack/start-client-core' export { FRAME_HEADER_SIZE, + MAX_FRAME_PAYLOAD_SIZE, + MAX_FRAMED_STREAMS, FrameType, TSS_CONTENT_TYPE_FRAMED, TSS_CONTENT_TYPE_FRAMED_VERSIONED, @@ -22,9 +28,6 @@ export { /** Cached TextEncoder for frame encoding */ const textEncoder = new TextEncoder() -/** Shared empty payload for END frames - avoids allocation per call */ -const EMPTY_PAYLOAD = new Uint8Array(0) - /** * Encodes a single frame with header and payload. */ @@ -37,14 +40,14 @@ export function encodeFrame( // Write header bytes directly to avoid DataView allocation per frame // Frame format: [type:1][streamId:4 BE][length:4 BE] frame[0] = type - frame[1] = (streamId >>> 24) & 0xff - frame[2] = (streamId >>> 16) & 0xff - frame[3] = (streamId >>> 8) & 0xff - frame[4] = streamId & 0xff - frame[5] = (payload.length >>> 24) & 0xff - frame[6] = (payload.length >>> 16) & 0xff - frame[7] = (payload.length >>> 8) & 0xff - frame[8] = payload.length & 0xff + frame[1] = streamId >>> 24 + frame[2] = streamId >>> 16 + frame[3] = streamId >>> 8 + frame[4] = streamId + frame[5] = payload.length >>> 24 + frame[6] = payload.length >>> 16 + frame[7] = payload.length >>> 8 + frame[8] = payload.length frame.set(payload, FRAME_HEADER_SIZE) return frame } @@ -53,24 +56,13 @@ export function encodeFrame( * Encodes a JSON frame (type 0, streamId 0). */ export function encodeJSONFrame(json: string): Uint8Array { - return encodeFrame(FrameType.JSON, 0, textEncoder.encode(json)) -} - -/** - * Encodes a raw stream chunk frame. - */ -export function encodeChunkFrame( - streamId: number, - chunk: Uint8Array, -): Uint8Array { - return encodeFrame(FrameType.CHUNK, streamId, chunk) -} - -/** - * Encodes a raw stream end frame. - */ -export function encodeEndFrame(streamId: number): Uint8Array { - return encodeFrame(FrameType.END, streamId, EMPTY_PAYLOAD) + const payload = textEncoder.encode(json) + if (payload.length > MAX_FRAME_PAYLOAD_SIZE) { + throw new RangeError( + `Frame payload too large: ${payload.length} bytes (max ${MAX_FRAME_PAYLOAD_SIZE})`, + ) + } + return encodeFrame(FrameType.JSON, 0, payload) } /** @@ -79,65 +71,72 @@ export function encodeEndFrame(streamId: number): Uint8Array { export function encodeErrorFrame(streamId: number, error: unknown): Uint8Array { const message = error instanceof Error ? error.message : String(error ?? 'Unknown error') - return encodeFrame(FrameType.ERROR, streamId, textEncoder.encode(message)) + const payload = new Uint8Array( + Math.min(MAX_FRAME_PAYLOAD_SIZE, message.length * 3), + ) + const { written } = textEncoder.encodeInto(message, payload) + return encodeFrame(FrameType.ERROR, streamId, payload.subarray(0, written)) } -/** - * Late stream registration for RawStreams discovered after serialization starts. - * Used when Promise resolves after the initial synchronous pass. - */ -export interface LateStreamRegistration { - id: number - stream: ReadableStream +export function cancelReadableStream( + stream: ReadableStream, + reason?: unknown, +): void { + void stream.cancel(reason).catch(() => {}) } /** * Creates a multiplexed ReadableStream from JSON stream and raw streams. * - * The JSON stream emits NDJSON lines (from seroval's toCrossJSONStream). + * The JSON stream emits pre-encoded JSON frames. * Raw streams are pumped concurrently, interleaved with JSON frames. - * - * Supports late stream registration for RawStreams discovered after initial - * serialization (e.g., from resolved Promises). - * - * @param jsonStream Stream of JSON strings (each string is one NDJSON line) - * @param rawStreams Map of stream IDs to raw binary streams (known at start) - * @param lateStreamSource Optional stream of late registrations for streams discovered later */ export function createMultiplexedStream( - jsonStream: ReadableStream, + jsonStream: ReadableStream, rawStreams: Map>, - lateStreamSource?: ReadableStream, -): ReadableStream { - // Shared state for the multiplexed stream - let controller: ReadableStreamDefaultController - let cancelled = false - const readers: Array> = [] - - // Helper to enqueue a frame, ignoring errors if stream is closed/cancelled - const enqueue = (frame: Uint8Array): boolean => { - if (cancelled) return false - try { - controller.enqueue(frame) - return true - } catch { - return false +): [ + stream: ReadableStream, + registerRaw: (id: number, stream: ReadableStream) => void, +] { + const transform = new TransformStream() + const writer = transform.writable.getWriter() + let terminal = false + const readers = new Set>() + let active = 0 + + const stop = (error: unknown): void => { + if (terminal) { + return } - } - // Helper to error the output stream (for fatal errors like JSON stream failure) - const errorOutput = (error: unknown): void => { - if (cancelled) return - cancelled = true - try { - controller.error(error) - } catch { - // Already errored - } - // Cancel all readers to stop other pumps + terminal = true for (const reader of readers) { - reader.cancel().catch(() => {}) + void reader.cancel(error).catch(() => {}) } + readers.clear() + void writer.abort(error).catch(() => {}) + } + + void writer.closed.catch(stop) + + const write = (frame: Uint8Array): Promise => { + return writer.write(frame).then( + () => true, + (error) => { + stop(error) + return false + }, + ) + } + + const track = (pump: Promise): void => { + active++ + void pump.then(() => { + if (!--active && !terminal) { + terminal = true + void writer.close().catch(() => {}) + } + }, stop) } // Pumps a raw stream, sending CHUNK frames and END/ERROR on completion @@ -145,22 +144,47 @@ export function createMultiplexedStream( streamId: number, stream: ReadableStream, ): Promise { - const reader = stream.getReader() - readers.push(reader) + let reader: ReadableStreamDefaultReader | undefined try { - while (!cancelled) { + reader = stream.getReader() + readers.add(reader) + + while (!terminal) { const { done, value } = await reader.read() + // Cancellation can happen while the read is pending. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (terminal) { + return + } if (done) { - enqueue(encodeEndFrame(streamId)) + await write( + encodeFrame(FrameType.END, streamId, new Uint8Array()), + ) return } - if (!enqueue(encodeChunkFrame(streamId, value))) return + + let offset = 0 + do { + const chunk = value.subarray( + offset, + Math.min(offset + MAX_FRAME_PAYLOAD_SIZE, value.length), + ) + if (!(await write(encodeFrame(FrameType.CHUNK, streamId, chunk)))) { + return + } + offset += MAX_FRAME_PAYLOAD_SIZE + } while (offset < value.length) } } catch (error) { // Raw stream error - send ERROR frame, don't fail entire response - enqueue(encodeErrorFrame(streamId, error)) + if (!terminal) { + await write(encodeErrorFrame(streamId, error)) + } } finally { - reader.releaseLock() + if (reader) { + readers.delete(reader) + reader.releaseLock() + } } } @@ -168,91 +192,43 @@ export function createMultiplexedStream( // JSON stream errors are fatal - they error the entire output async function pumpJSON(): Promise { const reader = jsonStream.getReader() - readers.push(reader) + readers.add(reader) try { - while (!cancelled) { + while (!terminal) { const { done, value } = await reader.read() - if (done) return - if (!enqueue(encodeJSONFrame(value))) return - } - } catch (error) { - // JSON stream error is fatal - error the entire output - errorOutput(error) - throw error // Re-throw to signal failure to Promise.all - } finally { - reader.releaseLock() - } - } - - // Pumps late stream registrations, spawning raw stream pumps as they arrive - async function pumpLateStreams(): Promise>> { - if (!lateStreamSource) return [] + // Cancellation can happen while the read is pending. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (terminal) { + return + } + if (done) { + return + } - const lateStreamPumps: Array> = [] - const reader = lateStreamSource.getReader() - readers.push(reader) - try { - while (!cancelled) { - const { done, value } = await reader.read() - if (done) break - // Start pumping this late stream and track it - lateStreamPumps.push(pumpRawStream(value.id, value.stream)) + if (!(await write(value))) { + return + } } } finally { + readers.delete(reader) reader.releaseLock() } - return lateStreamPumps } - return new ReadableStream({ - async start(ctrl) { - controller = ctrl - - // Collect all pump promises - const pumps: Array>>> = [pumpJSON()] - - for (const [streamId, stream] of rawStreams) { - pumps.push(pumpRawStream(streamId, stream)) - } - - // Add late stream pump (returns array of spawned pump promises) - if (lateStreamSource) { - pumps.push(pumpLateStreams()) - } - - try { - // Wait for initial pumps to complete - const results = await Promise.all(pumps) - - // Wait for any late stream pumps that were spawned - const latePumps = results.find(Array.isArray) as - | Array> - | undefined - if (latePumps && latePumps.length > 0) { - await Promise.all(latePumps) - } + const registerRaw = ( + id: number, + stream: ReadableStream, + ): void => { + // Start immediately so output cancellation owns the raw reader. Raw and + // JSON frame order is intentionally independent. + track(pumpRawStream(id, stream)) + } - // All pumps done - close the output stream - if (!cancelled) { - try { - controller.close() - } catch { - // Already closed - } - } - } catch { - // Error already handled by errorOutput in pumpJSON - // or was a raw stream error (non-fatal, already sent ERROR frame) - } - }, + track(pumpJSON()) + for (const [id, stream] of rawStreams) { + track(pumpRawStream(id, stream)) + } + rawStreams.clear() - cancel() { - cancelled = true - // Cancel all readers to stop pumps quickly - for (const reader of readers) { - reader.cancel().catch(() => {}) - } - readers.length = 0 - }, - }) + return [transform.readable, registerRaw] } diff --git a/packages/start-server-core/src/server-functions-handler.ts b/packages/start-server-core/src/server-functions-handler.ts index e7d961decf..e054b45391 100644 --- a/packages/start-server-core/src/server-functions-handler.ts +++ b/packages/start-server-core/src/server-functions-handler.ts @@ -15,10 +15,14 @@ import { fromJSON, toCrossJSONAsync, toCrossJSONStream } from 'seroval' import { getResponse } from './request-response' import { getServerFnById } from './getServerFnById' import { + FRAME_HEADER_SIZE, + MAX_FRAME_PAYLOAD_SIZE, + MAX_FRAMED_STREAMS, TSS_CONTENT_TYPE_FRAMED_VERSIONED, + cancelReadableStream, createMultiplexedStream, + encodeJSONFrame, } from './frame-protocol' -import type { LateStreamRegistration } from './frame-protocol' import type { Plugin as SerovalPlugin } from 'seroval' // Cache serovalPlugins at module level to avoid repeated calls @@ -33,6 +37,8 @@ const FORM_DATA_CONTENT_TYPES = [ // Maximum payload size for GET requests (1MB) const MAX_PAYLOAD_SIZE = 1_000_000 +const MAX_JSON_FRAME_SIZE = FRAME_HEADER_SIZE + MAX_FRAME_PAYLOAD_SIZE + export const handleServerAction = async ({ request, context, @@ -180,87 +186,79 @@ export const handleServerAction = async ({ return serializeResult(res) function serializeResult(res: unknown): Response { - let nonStreamingBody: any = undefined + let nonStreamingBody: any const alsResponse = getResponse() if (res !== undefined) { // Collect raw streams encountered during initial synchronous serialization const rawStreams = new Map>() - // Track whether we're still in the initial synchronous phase - // After initial phase, new RawStreams go to lateStreamWriter - let initialPhase = true - - // Late stream registration for RawStreams discovered after initial pass - // (e.g., from resolved Promises) - let lateStreamWriter: - | WritableStreamDefaultWriter + let registerRaw: + | ((id: number, stream: ReadableStream) => void) | undefined - let lateStreamReadable: - | ReadableStream - | undefined = undefined - const pendingLateStreams: Array = [] const rawStreamPlugin = createRawStreamRPCPlugin( (id: number, stream: ReadableStream) => { - if (initialPhase) { - rawStreams.set(id, stream) - return + if (id > MAX_FRAMED_STREAMS) { + const error = new Error( + `Too many raw streams in framed response (max ${MAX_FRAMED_STREAMS})`, + ) + cancelReadableStream(stream, error) + throw error } - - if (lateStreamWriter) { - // Late stream - write to the late stream channel - lateStreamWriter.write({ id, stream }).catch(() => { - // Ignore write errors - stream may be closed - }) + if (registerRaw) { + registerRaw(id, stream) return } - - // Discovered after initial phase but before writer exists. - pendingLateStreams.push({ id, stream }) + rawStreams.set(id, stream) }, ) // Build plugins with RawStreamRPCPlugin first (before default SSR plugin) - const plugins = [rawStreamPlugin, ...(serovalPlugins || [])] + const plugins = [rawStreamPlugin, ...serovalPlugins!] // first run without the stream in case `result` does not need streaming - let done = false as boolean - const callbacks: { - onParse: (value: any) => void - onDone: () => void - onError: (error: any) => void - } = { - onParse: (value) => { - nonStreamingBody = value - }, - onDone: () => { - done = true - }, - onError: (error) => { - throw error - }, + let done = false + let initialError: [unknown] | undefined + let onParse = (value: any): void => { + nonStreamingBody = value + } + let onDone = (): void => { + done = true + } + let onError = (error: unknown): void => { + initialError ??= [error] + } + let destroySerialization: (() => void) | undefined + const stopSerialization = (): void => { + const destroy = destroySerialization + destroySerialization = undefined + destroy?.() + } + try { + destroySerialization = toCrossJSONStream(res, { + refs: new Map(), + plugins, + onParse(value) { + onParse(value) + }, + onDone() { + onDone() + }, + onError(error) { + onError(error) + }, + }) + if (initialError) { + throw initialError[0] + } + } catch (error) { + stopSerialization() + for (const stream of rawStreams.values()) { + cancelReadableStream(stream, error) + } + throw error } - toCrossJSONStream(res, { - refs: new Map(), - plugins, - onParse(value) { - callbacks.onParse(value) - }, - onDone() { - callbacks.onDone() - }, - onError: (error) => { - callbacks.onError(error) - }, - }) - - // End of initial synchronous phase - any new RawStreams are "late" - initialPhase = false - - // If any RawStreams are discovered after this point but before the - // late-stream writer exists, we buffer them and flush once the writer - // is ready. This avoids an occasional missed-stream race. // If no raw streams and done synchronously, return simple JSON if (done && rawStreams.size === 0) { @@ -277,83 +275,65 @@ export const handleServerAction = async ({ ) } - // Not done synchronously or has raw streams - use framed protocol - // This supports late RawStreams from resolved Promises - const { readable, writable } = - new TransformStream() - lateStreamReadable = readable - lateStreamWriter = writable.getWriter() - - // Flush any late streams that were discovered in the small window - // between end of initial serialization and writer setup. - for (const registration of pendingLateStreams) { - lateStreamWriter.write(registration).catch(() => { - // Ignore write errors - stream may be closed - }) - } - pendingLateStreams.length = 0 + let jsonController!: ReadableStreamDefaultController + const jsonStream = new ReadableStream( + { + start(controller) { + jsonController = controller + }, + cancel() { + stopSerialization() + }, + }, + { + highWaterMark: MAX_JSON_FRAME_SIZE, + size(frame) { + return frame!.byteLength + }, + }, + ) - // Create a stream of JSON chunks - const jsonStream = new ReadableStream({ - start(controller) { - callbacks.onParse = (value) => { - controller.enqueue(JSON.stringify(value) + '\n') - } - callbacks.onDone = () => { - try { - controller.close() - } catch { - // Already closed - } - // Close late stream writer when JSON serialization is done - // Any RawStreams not yet discovered won't be sent - lateStreamWriter - ?.close() - .catch(() => { - // Ignore close errors - }) - .finally(() => { - lateStreamWriter = undefined - }) - } + const multiplexed = createMultiplexedStream(jsonStream, rawStreams) + registerRaw = multiplexed[1] - callbacks.onError = (error) => { - controller.error(error) - lateStreamWriter - ?.abort(error) - .catch(() => { - // Ignore abort errors - }) - .finally(() => { - lateStreamWriter = undefined - }) - } + const failSerialization = (error: unknown): void => { + if (!destroySerialization) { + return + } + stopSerialization() + jsonController.error(error) + } - // Emit initial body if we have one - if (nonStreamingBody !== undefined) { - callbacks.onParse(nonStreamingBody) - } - // If serialization already completed synchronously, close now - // This handles the case where onDone was called during toCrossJSONStream - // before we overwrote callbacks.onDone - if (done) { - callbacks.onDone() + onParse = (value) => { + try { + const frame = encodeJSONFrame(JSON.stringify(value)) + if (jsonController.desiredSize! < frame.byteLength) { + throw new Error( + `Framed JSON queue exceeded ${MAX_JSON_FRAME_SIZE} bytes`, + ) } - }, - cancel() { - lateStreamWriter?.abort().catch(() => {}) - lateStreamWriter = undefined - }, - }) + jsonController.enqueue(frame) + } catch (error) { + failSerialization(error) + } + } + onDone = () => { + if (!destroySerialization) { + return + } + destroySerialization = undefined + jsonController.close() + } + onError = failSerialization - // Create multiplexed stream with JSON, initial raw streams, and late streams - const multiplexedStream = createMultiplexedStream( - jsonStream, - rawStreams, - lateStreamReadable, - ) + if (nonStreamingBody !== undefined) { + onParse(nonStreamingBody) + } + if (done) { + onDone() + } - return new Response(multiplexedStream, { + return new Response(multiplexed[0], { status: alsResponse.status, statusText: alsResponse.statusText, headers: { @@ -396,12 +376,10 @@ export const handleServerAction = async ({ console.info() const serializedError = JSON.stringify( - await Promise.resolve( - toCrossJSONAsync(error, { - refs: new Map(), - plugins: serovalPlugins, - }), - ), + await toCrossJSONAsync(error, { + refs: new Map(), + plugins: serovalPlugins, + }), ) const response = getResponse() return new Response(serializedError, { diff --git a/packages/start-server-core/tests/createStartHandler.test.ts b/packages/start-server-core/tests/createStartHandler.test.ts index a4c8dc16f6..447af79885 100644 --- a/packages/start-server-core/tests/createStartHandler.test.ts +++ b/packages/start-server-core/tests/createStartHandler.test.ts @@ -7,6 +7,7 @@ import { BaseRootRoute, BaseRoute, RouterCore, + redirect, type AnyRouter, } from '@tanstack/router-core' import { @@ -107,13 +108,17 @@ function waitForAbortOrRelease(signal: AbortSignal) { }) } -function makeStreamResponse(router: ReturnType) { +function makeStreamResponse( + router: ReturnType, + onCancel?: (reason: unknown) => void, +) { attachRouterServerSsrUtils({ router: router as any, manifest: undefined }) const stream = new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode('stream')) }, - cancel() { + cancel(reason) { + onCancel?.(reason) router.serverSsr?.cleanup() }, }) @@ -182,11 +187,71 @@ describe('createStartHandler SSR cleanup ownership', () => { {}, ) - expect(response).toBe(replacement) + expect(await response.text()).toBe('replacement') expect(dispose).toHaveBeenCalledOnce() expect(router.serverSsr).toBeUndefined() }) + it('cancels a plain response body replaced by middleware', async () => { + const router = makeRouter() + startMocks.router = router + const cancel = vi.fn() + startMocks.serverFnResult = new Response( + new ReadableStream({ cancel }), + ) + startMocks.requestMiddleware = [ + createMiddleware().server(async ({ next }) => { + await next() + return new Response('replacement') + }), + ] + + const handler = createStartHandler(() => new Response('unused')) + const response = await handler( + new Request('http://localhost/_serverFn/test', { + headers: { 'x-tsr-serverFn': 'true' }, + }), + {}, + ) + + expect(await response.text()).toBe('replacement') + expect(cancel).toHaveBeenCalledOnce() + }) + + it('does not await a replaced stream owner', async () => { + const router = makeRouter() + startMocks.router = router + const dispose = vi.fn(() => new Promise(() => {})) + startMocks.serverFnResult = { + response: new Response('stream'), + serverSsrCleanup: 'stream', + dispose, + } + startMocks.requestMiddleware = [ + createMiddleware().server(async ({ next }) => { + await next() + return new Response('replacement') + }), + ] + + const handler = createStartHandler(() => new Response('unused')) + const response = handler( + new Request('http://localhost/_serverFn/test', { + headers: { 'x-tsr-serverFn': 'true' }, + }), + {}, + ) + const outcome = await Promise.race([ + Promise.resolve(response).then(() => 'settled'), + new Promise<'pending'>((resolve) => { + setTimeout(() => resolve('pending'), 20) + }), + ]) + + expect(outcome).toBe('settled') + expect(dispose).toHaveBeenCalledOnce() + }) + it('exposes Response to middleware while preserving stream ownership', async () => { startMocks.requestMiddleware = [] const router = makeRouter() @@ -282,6 +347,227 @@ describe('createStartHandler SSR cleanup ownership', () => { expect(router.serverSsr).toBeUndefined() }) + it.each(['pipeThrough', 'clone'] as const)( + 'preserves stream ownership when middleware returns a %s response', + async (operation) => { + const router = makeRouter() + startMocks.router = router + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + attachRouterServerSsrUtils({ router: router as any, manifest: undefined }) + const ssrResponse = createSsrStreamResponse( + router as any, + new Response( + new ReadableStream({ + async pull(controller) { + await gate + controller.enqueue(new TextEncoder().encode('stream')) + controller.close() + }, + }), + ), + ) + const dispose = vi.spyOn(ssrResponse as any, 'dispose') + startMocks.serverFnResult = ssrResponse + startMocks.requestMiddleware = [ + createMiddleware().server(async ({ next }) => { + const result = await next() + return operation === 'clone' + ? result.response.clone() + : new Response( + result.response.body!.pipeThrough(new TransformStream()), + result.response, + ) + }), + ] + + const handler = createStartHandler(() => new Response('unused')) + const response = await handler( + new Request('http://localhost/_serverFn/test', { + headers: { 'x-tsr-serverFn': 'true' }, + }), + {}, + ) + release() + + expect(dispose).not.toHaveBeenCalled() + await expect(response.text()).resolves.toBe('stream') + expect(router.serverSsr).toBeUndefined() + }, + ) + + it.each(['pipeThrough', 'clone'] as const)( + 'request abort cancels a locked %s response and its SSR source', + async (operation) => { + const router = makeRouter() + startMocks.router = router + attachRouterServerSsrUtils({ router: router as any, manifest: undefined }) + const cleanup = router.serverSsr!.cleanup + const cleanupSpy = vi.fn(() => cleanup()) + router.serverSsr!.cleanup = cleanupSpy + const sourceCancel = vi.fn() + startMocks.serverFnResult = createSsrStreamResponse( + router as any, + new Response(new ReadableStream({ cancel: sourceCancel })), + ) + startMocks.requestMiddleware = [ + createMiddleware().server(async ({ next }) => { + const result = await next() + return operation === 'clone' + ? result.response.clone() + : new Response( + result.response.body!.pipeThrough(new TransformStream()), + result.response, + ) + }), + ] + const requestController = new AbortController() + const cancellation = new Error('request disconnected') + const response = await createStartHandler(() => + new Response('unused'), + )( + new Request('http://localhost/_serverFn/test', { + headers: { 'x-tsr-serverFn': 'true' }, + signal: requestController.signal, + }), + {}, + ) + const reader = response.body!.getReader() + const read = reader.read() + + expect(response.body!.locked).toBe(true) + requestController.abort(cancellation) + + try { + await expect(read).rejects.toBe(cancellation) + await vi.waitFor(() => expect(sourceCancel).toHaveBeenCalledOnce()) + expect(cleanupSpy).toHaveBeenCalledOnce() + expect(router.serverSsr).toBeUndefined() + } finally { + reader.releaseLock() + } + }, + ) + + it('cancels a replacement stream after middleware buffers the SSR response', async () => { + const router = makeRouter() + startMocks.router = router + attachRouterServerSsrUtils({ router: router as any, manifest: undefined }) + startMocks.serverFnResult = createSsrStreamResponse( + router as any, + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('stream')) + controller.close() + }, + }), + ), + ) + const requestController = new AbortController() + const cancellation = new Error('request disconnected') + const cancel = vi.fn() + startMocks.requestMiddleware = [ + createMiddleware().server(async ({ next }) => { + const result = await next() + await expect(result.response.text()).resolves.toBe('stream') + return new Response(new ReadableStream({ cancel })) + }), + ] + + const response = await createStartHandler(() => new Response('unused'))( + new Request('http://localhost/_serverFn/test', { + headers: { 'x-tsr-serverFn': 'true' }, + signal: requestController.signal, + }), + {}, + ) + try { + requestController.abort(cancellation) + await vi.waitFor(() => { + expect(cancel).toHaveBeenCalledOnce() + }) + } finally { + void response.body!.cancel('test cleanup').catch(() => {}) + } + }) + + it('does not report a cleanup error for a transformed plain response', async () => { + const router = makeRouter() + startMocks.router = router + startMocks.serverFnResult = new Response('plain') + startMocks.requestMiddleware = [ + createMiddleware().server(async ({ next }) => { + const result = await next() + return new Response( + result.response.body!.pipeThrough(new TransformStream()), + result.response, + ) + }), + ] + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + + try { + const response = await createStartHandler(() => new Response('unused'))( + new Request('http://localhost/_serverFn/test', { + headers: { 'x-tsr-serverFn': 'true' }, + }), + {}, + ) + + await expect(response.text()).resolves.toBe('plain') + expect(consoleError).not.toHaveBeenCalled() + } finally { + consoleError.mockRestore() + } + }) + + it('retires a locked stream owner replaced by a bodyless response', async () => { + const router = makeRouter() + startMocks.router = router + const cancel = vi.fn() + attachRouterServerSsrUtils({ router: router as any, manifest: undefined }) + const ssrResponse = createSsrStreamResponse( + router as any, + new Response(new ReadableStream({ cancel })), + ) + const dispose = vi.spyOn(ssrResponse as any, 'dispose') + startMocks.serverFnResult = ssrResponse + let lockedReader: ReadableStreamDefaultReader | undefined + startMocks.requestMiddleware = [ + createMiddleware().server(async ({ next }) => { + const result = await next() + lockedReader = result.response.body!.getReader() + void lockedReader.closed.catch(() => {}) + return new Response(null, { status: 204 }) + }), + ] + + try { + const response = await createStartHandler(() => new Response('unused'))( + new Request('http://localhost/_serverFn/test', { + headers: { 'x-tsr-serverFn': 'true' }, + }), + {}, + ) + + expect(response.status).toBe(204) + expect(response.body).toBeNull() + expect(dispose).toHaveBeenCalledOnce() + expect(cancel).toHaveBeenCalledOnce() + expect(router.serverSsr).toBeUndefined() + } finally { + lockedReader?.releaseLock() + if (router.serverSsr) { + await ssrResponse.dispose('test cleanup') + } + } + }) + it('disposes stream response on middleware error after next', async () => { const router = makeRouter() startMocks.router = router @@ -330,7 +616,8 @@ describe('createStartHandler SSR cleanup ownership', () => { {}, ) - expect(response).toBe(replacement) + expect(response.status).toBe(418) + expect(await response.text()).toBe('handled') expect(dispose).toHaveBeenCalledOnce() expect(router.serverSsr).toBeUndefined() }) @@ -358,12 +645,161 @@ describe('createStartHandler SSR cleanup ownership', () => { {}, ) - expect(response).toBe(replacement) + expect(await response.text()).toBe('replacement') expect(dispose).toHaveBeenCalledOnce() expect(router.serverSsr).toBeUndefined() }) }) +describe('createStartHandler HEAD responses', () => { + it('strips and cancels an application document body', async () => { + const router = makeRouter() + startMocks.router = router + const cancel = vi.fn() + const render = vi.fn( + () => + new Response(new ReadableStream({ cancel }), { + headers: { 'x-rendered': 'true' }, + }), + ) + const handler = createStartHandler(render) + + const response = await handler( + new Request('http://localhost/', { method: 'HEAD' }), + {}, + ) + + expect(render).toHaveBeenCalledOnce() + expect(response.headers.get('x-rendered')).toBe('true') + expect(response.body).toBeNull() + expect(cancel).toHaveBeenCalledOnce() + }) + + it('strips and cancels a body returned by an explicit HEAD handler', async () => { + const rootRoute = new BaseRootRoute({}) + const cancel = vi.fn() + const headHandler = vi.fn( + () => + new Response(new ReadableStream({ cancel }), { + status: 202, + headers: { 'x-handler': 'HEAD' }, + }), + ) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + server: { handlers: { HEAD: headHandler } }, + }) + const router = new RouterCore( + { + history: createMemoryHistory({ initialEntries: ['/target'] }), + routeTree: rootRoute.addChildren([targetRoute]), + }, + getStoreConfig, + ) + router.isServer = true + startMocks.router = router + const render = vi.fn(() => new Response('must not render')) + const handler = createStartHandler(render) + + const response = await handler( + new Request('http://localhost/target', { method: 'HEAD' }), + {}, + ) + + expect(headHandler).toHaveBeenCalledOnce() + expect(render).not.toHaveBeenCalled() + expect(response.status).toBe(202) + expect(response.headers.get('x-handler')).toBe('HEAD') + expect(response.body).toBeNull() + expect(cancel).toHaveBeenCalledOnce() + }) + + it('resolves a redirect before stripping its HEAD body', async () => { + const rootRoute = new BaseRootRoute({}) + const sourceRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/source', + server: { + handlers: { + HEAD: () => redirect({ to: '/target' }), + }, + }, + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + }) + const router = new RouterCore( + { + history: createMemoryHistory({ initialEntries: ['/source'] }), + routeTree: rootRoute.addChildren([sourceRoute, targetRoute]), + }, + getStoreConfig, + ) + router.isServer = true + startMocks.router = router + + const response = await createStartHandler(() => + new Response('must not render'), + )(new Request('http://localhost/source', { method: 'HEAD' }), {}) + + expect(response.status).toBe(307) + expect(response.headers.get('Location')).toBe('/target') + expect(response.body).toBeNull() + }) + + it('strips and cancels a body returned early by request middleware', async () => { + const router = makeRouter() + startMocks.router = router + const cancel = vi.fn() + startMocks.requestMiddleware = [ + createMiddleware().server( + () => + new Response(new ReadableStream({ cancel }), { + status: 202, + headers: { 'x-middleware': 'true' }, + }), + ), + ] + const render = vi.fn(() => new Response('must not render')) + + const response = await createStartHandler(render)( + new Request('http://localhost/', { method: 'HEAD' }), + {}, + ) + + expect(render).not.toHaveBeenCalled() + expect(response.status).toBe(202) + expect(response.headers.get('x-middleware')).toBe('true') + expect(response.body).toBeNull() + expect(cancel).toHaveBeenCalledOnce() + }) + + it('strips a server-function HEAD error body', async () => { + const router = makeRouter() + startMocks.router = router + startMocks.serverFnResult = new Response('method not allowed', { + status: 405, + headers: { Allow: 'POST' }, + }) + + const response = await createStartHandler(() => + new Response('must not render'), + )( + new Request('http://localhost/_serverFn/test', { + method: 'HEAD', + headers: { 'x-tsr-serverFn': 'true' }, + }), + {}, + ) + + expect(response.status).toBe(405) + expect(response.headers.get('Allow')).toBe('POST') + expect(response.body).toBeNull() + }) +}) + describe('createStartHandler request cancellation', () => { it.each(['beforeLoad', 'loader'] as const)( 'aborts route %s work and does not render HTML', @@ -460,10 +896,8 @@ describe('createStartHandler request cancellation', () => { expect(router.serverSsr).toBeUndefined() resolveRender(lateStreamResponse) - await Promise.resolve() - await Promise.resolve() + await vi.waitFor(() => expect(cancelCalls).toBe(1)) expect(cleanupCalls).toBe(1) - expect(cancelCalls).toBe(1) expect(router.serverSsr).toBeUndefined() }) @@ -655,20 +1089,18 @@ describe('createStartHandler request cancellation', () => { }, ) - it('disposes a stream when the request aborts after response handoff', async () => { + it('cancels a locked tagged stream when the request aborts after handoff', async () => { const router = makeRouter() startMocks.router = router const requestController = new AbortController() - let cancelCalls = 0 + const cancellation = new Error('request disconnected') + const cancel = vi.fn(() => new Promise(() => {})) const handler = createStartHandler(({ router: requestRouter }) => createSsrStreamResponse( requestRouter, new Response( new ReadableStream({ - cancel() { - cancelCalls++ - return new Promise(() => {}) - }, + cancel, }), ), ), @@ -680,16 +1112,66 @@ describe('createStartHandler request cancellation', () => { }), {}, ) - expect(response.body).not.toBeNull() + const reader = response.body!.getReader() + const read = expect(reader.read()).rejects.toBe(cancellation) expect(router.serverSsr).toBeDefined() - requestController.abort(new Error('request disconnected')) - await Promise.resolve() + requestController.abort(cancellation) - expect(cancelCalls).toBe(1) - expect(router.serverSsr).toBeUndefined() + try { + await vi.waitFor(() => { + expect(cancel).toHaveBeenCalledWith(cancellation) + }) + await read + expect(router.serverSsr).toBeUndefined() + } finally { + reader.releaseLock() + } }) + it.each(['router', 'serverFn'] as const)( + 'cancels a locked plain %s response after handoff', + async (handlerType) => { + const router = makeRouter() + startMocks.router = router + const requestController = new AbortController() + const cancellation = new Error('request disconnected') + const cancel = vi.fn() + const plainResponse = new Response(new ReadableStream({ cancel })) + if (handlerType === 'serverFn') { + startMocks.serverFnResult = plainResponse + } + const handler = createStartHandler(() => plainResponse) + const response = await handler( + new Request( + handlerType === 'serverFn' + ? 'http://localhost/_serverFn/test' + : 'http://localhost/', + { + headers: + handlerType === 'serverFn' + ? { 'x-tsr-serverFn': 'true' } + : undefined, + signal: requestController.signal, + }, + ), + {}, + ) + const reader = response.body!.getReader() + + requestController.abort(cancellation) + + try { + await vi.waitFor(() => { + expect(cancel).toHaveBeenCalledWith(cancellation) + }) + await expect(reader.read()).rejects.toBe(cancellation) + } finally { + reader.releaseLock() + } + }, + ) + it('settles when request middleware ignores cancellation', async () => { const router = makeRouter() startMocks.router = router @@ -839,9 +1321,9 @@ describe('createStartHandler request cancellation', () => { const reason = new Error('request disconnected') const observedErrors: Array = [] const afterNext = vi.fn() - const ssrResponse = makeStreamResponse(router) + const cancel = vi.fn() + const ssrResponse = makeStreamResponse(router, cancel) const dispose = vi.spyOn(ssrResponse as any, 'dispose') - const cancel = vi.spyOn(ssrResponse.response.body!, 'cancel') startMocks.requestMiddleware = [ createMiddleware().server(async ({ next }) => { try { @@ -860,9 +1342,8 @@ describe('createStartHandler request cancellation', () => { ) return pending }), - createMiddleware().server(() => ssrResponse as any), ] - const render = vi.fn(() => new Response('must not render')) + const render = vi.fn(() => ssrResponse) const handler = createStartHandler(render) const response = await handler( @@ -881,44 +1362,7 @@ describe('createStartHandler request cancellation', () => { expect(cancel).toHaveBeenCalledWith(reason) }) expect(afterNext).not.toHaveBeenCalled() - expect(render).not.toHaveBeenCalled() - }) - - it('disposes a tagged final response once when abort wins handoff', async () => { - const router = makeRouter() - startMocks.router = router - const requestController = new AbortController() - const reason = new Error('request disconnected') - const ssrResponse = makeStreamResponse(router) - const dispose = vi.spyOn(ssrResponse as any, 'dispose') - const cancel = vi.spyOn(ssrResponse.response.body!, 'cancel') - startMocks.requestMiddleware = [ - createMiddleware().server(() => { - queueMicrotask(() => { - queueMicrotask(() => requestController.abort(reason)) - }) - return ssrResponse as any - }), - ] - const render = vi.fn(() => new Response('must not render')) - const handler = createStartHandler(render) - - const response = await handler( - new Request('http://localhost/', { - signal: requestController.signal, - }), - {}, - ) - - expect(response.status).toBe(500) - await vi.waitFor(() => { - expect(dispose).toHaveBeenCalledOnce() - expect(dispose).toHaveBeenCalledWith(reason) - expect(cancel).toHaveBeenCalledOnce() - expect(cancel).toHaveBeenCalledWith(reason) - }) - expect(router.serverSsr).toBeUndefined() - expect(render).not.toHaveBeenCalled() + expect(render).toHaveBeenCalledOnce() }) it('ignores a late same-body alias after catch disposes its owner', async () => { @@ -950,14 +1394,15 @@ describe('createStartHandler request cancellation', () => { return wrapped }), ] + const cancel = vi.fn() const response = new Response( new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode('stream')) }, + cancel, }), ) - const cancel = vi.spyOn(response.body!, 'cancel') let ssrResponse!: ReturnType const render = vi.fn(({ router: requestRouter }) => { ssrResponse = createSsrStreamResponse(requestRouter, response) diff --git a/packages/start-server-core/tests/frame-protocol.test.ts b/packages/start-server-core/tests/frame-protocol.test.ts index 380f3a8351..4d5a13f301 100644 --- a/packages/start-server-core/tests/frame-protocol.test.ts +++ b/packages/start-server-core/tests/frame-protocol.test.ts @@ -1,15 +1,19 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { FRAME_HEADER_SIZE, + MAX_FRAME_PAYLOAD_SIZE, FrameType, createMultiplexedStream, - encodeChunkFrame, - encodeEndFrame, encodeErrorFrame, encodeFrame, encodeJSONFrame, } from '../src/frame-protocol' +const encodeChunkFrame = (streamId: number, chunk: Uint8Array) => + encodeFrame(FrameType.CHUNK, streamId, chunk) +const encodeEndFrame = (streamId: number) => + encodeFrame(FrameType.END, streamId, new Uint8Array()) + describe('frame-protocol', () => { describe('encodeFrame', () => { it('should encode frame with header and payload', () => { @@ -55,32 +59,13 @@ describe('frame-protocol', () => { const payload = frame.slice(FRAME_HEADER_SIZE) expect(new TextDecoder().decode(payload)).toBe(json) }) - }) - - describe('encodeChunkFrame', () => { - it('should encode binary chunk with frame type CHUNK', () => { - const chunk = new Uint8Array([0xff, 0xfe, 0xfd]) - const frame = encodeChunkFrame(123, chunk) - - const view = new DataView(frame.buffer) - expect(view.getUint8(0)).toBe(FrameType.CHUNK) - expect(view.getUint32(1, false)).toBe(123) - expect(view.getUint32(5, false)).toBe(3) - - expect(frame.slice(FRAME_HEADER_SIZE)).toEqual(chunk) - }) - }) - - describe('encodeEndFrame', () => { - it('should encode end frame with empty payload', () => { - const frame = encodeEndFrame(456) - expect(frame.length).toBe(FRAME_HEADER_SIZE) + it('should reject payloads larger than the client decoder limit', () => { + const json = 'x'.repeat(MAX_FRAME_PAYLOAD_SIZE + 1) - const view = new DataView(frame.buffer) - expect(view.getUint8(0)).toBe(FrameType.END) - expect(view.getUint32(1, false)).toBe(456) - expect(view.getUint32(5, false)).toBe(0) + expect(() => encodeJSONFrame(json)).toThrow( + `Frame payload too large: ${json.length} bytes (max ${MAX_FRAME_PAYLOAD_SIZE})`, + ) }) }) @@ -109,19 +94,30 @@ describe('frame-protocol', () => { const payload = frame.slice(FRAME_HEADER_SIZE) expect(new TextDecoder().decode(payload)).toBe('Unknown error') }) + + it('should truncate error payloads to the client decoder limit', () => { + const frame = encodeErrorFrame( + 1, + new Error('x'.repeat(MAX_FRAME_PAYLOAD_SIZE + 1)), + ) + const view = new DataView(frame.buffer, frame.byteOffset) + + expect(view.getUint32(5, false)).toBe(MAX_FRAME_PAYLOAD_SIZE) + expect(frame.length).toBe(FRAME_HEADER_SIZE + MAX_FRAME_PAYLOAD_SIZE) + }) }) describe('createMultiplexedStream', () => { it('should multiplex JSON stream only', async () => { - const jsonStream = new ReadableStream({ + const jsonStream = new ReadableStream({ start(controller) { - controller.enqueue('{"data":1}') - controller.enqueue('{"data":2}') + controller.enqueue(encodeJSONFrame('{"data":1}')) + controller.enqueue(encodeJSONFrame('{"data":2}')) controller.close() }, }) - const multiplexed = createMultiplexedStream( + const [multiplexed] = createMultiplexedStream( jsonStream, new Map(), // no raw streams ) @@ -145,9 +141,9 @@ describe('frame-protocol', () => { }) it('should multiplex JSON and raw streams', async () => { - const jsonStream = new ReadableStream({ + const jsonStream = new ReadableStream({ start(controller) { - controller.enqueue('{"result":"ok"}') + controller.enqueue(encodeJSONFrame('{"result":"ok"}')) controller.close() }, }) @@ -162,7 +158,7 @@ describe('frame-protocol', () => { const rawStreams = new Map>() rawStreams.set(5, rawStream) - const multiplexed = createMultiplexedStream(jsonStream, rawStreams) + const [multiplexed] = createMultiplexedStream(jsonStream, rawStreams) const reader = multiplexed.getReader() const chunks: Array = [] @@ -191,10 +187,10 @@ describe('frame-protocol', () => { let jsonCancelled = false let rawCancelled = false - const jsonStream = new ReadableStream({ + const jsonStream = new ReadableStream({ async start(controller) { await new Promise((r) => setTimeout(r, 100)) - controller.enqueue('{}\n') + controller.enqueue(encodeJSONFrame('{}\n')) controller.close() }, cancel() { @@ -216,7 +212,7 @@ describe('frame-protocol', () => { const rawStreams = new Map>() rawStreams.set(1, rawStream) - const multiplexed = createMultiplexedStream(jsonStream, rawStreams) + const [multiplexed] = createMultiplexedStream(jsonStream, rawStreams) const reader = multiplexed.getReader() // Cancel immediately before streams complete @@ -224,8 +220,186 @@ describe('frame-protocol', () => { await reader.cancel() // Underlying reader.cancel should propagate to sources - expect(jsonCancelled).toBe(true) - expect(rawCancelled).toBe(true) + await vi.waitFor(() => { + expect(jsonCancelled).toBe(true) + expect(rawCancelled).toBe(true) + }) + }) + + it('should cancel a registered raw stream with the output cancellation reason', async () => { + let rawCancelReason: unknown + const rawStream = new ReadableStream({ + cancel(reason) { + rawCancelReason = reason + }, + }) + const [output, registerRaw] = createMultiplexedStream( + new ReadableStream(), + new Map(), + ) + const reason = new Error('output cancelled') + + registerRaw(1, rawStream) + await output.cancel(reason) + + await vi.waitFor(() => { + expect(rawCancelReason).toBe(reason) + }) + }) + + it('should isolate a reused raw stream failure from healthy frames', async () => { + const reusedStream = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([1])) + controller.close() + }, + }) + const healthyStream = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([3])) + controller.close() + }, + }) + const jsonStream = new ReadableStream({ + start(controller) { + controller.enqueue(encodeJSONFrame('{}')) + controller.close() + }, + }) + const reader = createMultiplexedStream( + jsonStream, + new Map([ + [1, reusedStream], + [2, reusedStream], + [3, healthyStream], + ]), + )[0].getReader() + const frames: Array = [] + + while (true) { + const { done, value } = await reader.read() + if (done) { + break + } + frames.push(value) + } + + const frameMetadata = frames.map((frame) => { + const view = new DataView(frame.buffer, frame.byteOffset) + return { + type: view.getUint8(0), + streamId: view.getUint32(1, false), + } + }) + + expect(frameMetadata).toContainEqual({ + type: FrameType.JSON, + streamId: 0, + }) + expect(frameMetadata).toContainEqual({ + type: FrameType.CHUNK, + streamId: 1, + }) + expect(frameMetadata).toContainEqual({ + type: FrameType.END, + streamId: 1, + }) + expect(frameMetadata).toContainEqual({ + type: FrameType.ERROR, + streamId: 2, + }) + expect(frameMetadata).toContainEqual({ + type: FrameType.CHUNK, + streamId: 3, + }) + expect(frameMetadata).toContainEqual({ + type: FrameType.END, + streamId: 3, + }) + }) + + it('should split oversized raw chunks into client-decodable frames', async () => { + const oversizedChunk = new Uint8Array(MAX_FRAME_PAYLOAD_SIZE + 1) + oversizedChunk[0] = 1 + oversizedChunk[MAX_FRAME_PAYLOAD_SIZE] = 2 + const rawStream = new ReadableStream({ + start(controller) { + controller.enqueue(oversizedChunk) + controller.close() + }, + }) + const jsonStream = new ReadableStream({ + start(controller) { + controller.close() + }, + }) + const reader = createMultiplexedStream( + jsonStream, + new Map([[1, rawStream]]), + )[0].getReader() + const frames: Array = [] + + while (true) { + const { done, value } = await reader.read() + if (done) { + break + } + frames.push(value) + } + + const chunkFrames = frames.filter((frame) => { + return frame[0] === FrameType.CHUNK + }) + expect(chunkFrames).toHaveLength(2) + expect( + chunkFrames.map((frame) => { + return new DataView(frame.buffer, frame.byteOffset).getUint32( + 5, + false, + ) + }), + ).toEqual([MAX_FRAME_PAYLOAD_SIZE, 1]) + expect(chunkFrames[0]![FRAME_HEADER_SIZE]).toBe(1) + expect(chunkFrames[1]![FRAME_HEADER_SIZE]).toBe(2) + expect(frames.at(-1)?.[0]).toBe(FrameType.END) + }) + + it('should stop pulling inputs while the output is backpressured', async () => { + let pulls = 0 + let rawCancelled = false + const rawStream = new ReadableStream({ + pull(controller) { + pulls++ + controller.enqueue(new Uint8Array(1024)) + if (pulls === 100) { + controller.close() + } + }, + cancel() { + rawCancelled = true + }, + }) + + const jsonStream = new ReadableStream({ + start(controller) { + controller.close() + }, + }) + const [multiplexed] = createMultiplexedStream( + jsonStream, + new Map([[1, rawStream]]), + ) + + await new Promise((resolve) => setTimeout(resolve, 0)) + + // One frame may be queued, one held by the pump, and one prefetched by + // the input stream. The remaining chunks must wait for output demand. + expect(pulls).toBeLessThanOrEqual(3) + + await multiplexed.cancel() + await vi.waitFor(() => { + expect(rawCancelled).toBe(true) + }) }) it('should interleave multiple raw streams correctly', async () => { @@ -235,9 +409,9 @@ describe('frame-protocol', () => { const gate1 = new Promise((r) => (resolve1 = r)) const gate2 = new Promise((r) => (resolve2 = r)) - const jsonStream = new ReadableStream({ + const jsonStream = new ReadableStream({ start(controller) { - controller.enqueue('{"streams":[1,2]}') + controller.enqueue(encodeJSONFrame('{"streams":[1,2]}')) controller.close() }, }) @@ -264,7 +438,7 @@ describe('frame-protocol', () => { rawStreams.set(1, rawStream1) rawStreams.set(2, rawStream2) - const multiplexed = createMultiplexedStream(jsonStream, rawStreams) + const [multiplexed] = createMultiplexedStream(jsonStream, rawStreams) const reader = multiplexed.getReader() const chunks: Array = [] @@ -301,42 +475,32 @@ describe('frame-protocol', () => { }) it('should handle late stream registration', async () => { - const jsonStream = new ReadableStream({ + let jsonController!: ReadableStreamDefaultController + const jsonStream = new ReadableStream({ start(controller) { - controller.enqueue('{"ref":99}') - controller.close() + jsonController = controller + controller.enqueue(encodeJSONFrame('{"ref":99}')) }, }) - // Late stream source that emits a registration after a delay - // (ensures framed protocol doesn't miss late-stream messages) let resolveGate: () => void const gate = new Promise((r) => (resolveGate = r)) - - const lateStreamSource = new ReadableStream<{ - id: number - stream: ReadableStream - }>({ - async start(controller) { - await gate - controller.enqueue({ - id: 99, - stream: new ReadableStream({ - start(c) { - c.enqueue(new Uint8Array([0xaa, 0xbb])) - c.close() - }, - }), - }) - controller.close() - }, - }) - - const multiplexed = createMultiplexedStream( + const [multiplexed, registerRaw] = createMultiplexedStream( jsonStream, new Map(), - lateStreamSource, ) + const register = gate.then(() => { + registerRaw( + 99, + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([0xaa, 0xbb])) + controller.close() + }, + }), + ) + jsonController.close() + }) const reader = multiplexed.getReader() const chunks: Array = [] @@ -347,6 +511,7 @@ describe('frame-protocol', () => { // Release gate to let late stream arrive resolveGate!() + await register // Read remaining frames while (true) { @@ -378,37 +543,26 @@ describe('frame-protocol', () => { let startJson: () => void const jsonGate = new Promise((r) => (startJson = r)) - const jsonStream = new ReadableStream({ + const jsonStream = new ReadableStream({ async start(controller) { await jsonGate - controller.enqueue('{"ref":1}') - controller.close() - }, - }) - - // Late stream registers immediately (before JSON starts) - const lateStreamSource = new ReadableStream<{ - id: number - stream: ReadableStream - }>({ - start(controller) { - controller.enqueue({ - id: 1, - stream: new ReadableStream({ - start(c) { - c.enqueue(new Uint8Array([0x01])) - c.close() - }, - }), - }) + controller.enqueue(encodeJSONFrame('{"ref":1}')) controller.close() }, }) - const multiplexed = createMultiplexedStream( + const [multiplexed, registerRaw] = createMultiplexedStream( jsonStream, new Map(), - lateStreamSource, + ) + registerRaw( + 1, + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([0x01])) + controller.close() + }, + }), ) const reader = multiplexed.getReader() @@ -436,46 +590,37 @@ describe('frame-protocol', () => { }) it('should handle multiple late stream registrations', async () => { - const jsonStream = new ReadableStream({ + let jsonController!: ReadableStreamDefaultController + const jsonStream = new ReadableStream({ start(controller) { - controller.enqueue('{}') - controller.close() - }, - }) - - const lateStreamSource = new ReadableStream<{ - id: number - stream: ReadableStream - }>({ - start(controller) { - // Register two streams - controller.enqueue({ - id: 10, - stream: new ReadableStream({ - start(c) { - c.enqueue(new Uint8Array([10])) - c.close() - }, - }), - }) - controller.enqueue({ - id: 20, - stream: new ReadableStream({ - start(c) { - c.enqueue(new Uint8Array([20])) - c.close() - }, - }), - }) - controller.close() + jsonController = controller + controller.enqueue(encodeJSONFrame('{}')) }, }) - const multiplexed = createMultiplexedStream( + const [multiplexed, registerRaw] = createMultiplexedStream( jsonStream, new Map(), - lateStreamSource, ) + registerRaw( + 10, + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([10])) + controller.close() + }, + }), + ) + registerRaw( + 20, + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([20])) + controller.close() + }, + }), + ) + jsonController.close() const reader = multiplexed.getReader() const chunks: Array = [] @@ -504,11 +649,11 @@ describe('frame-protocol', () => { let resolveJson: () => void const jsonGate = new Promise((r) => (resolveJson = r)) - const jsonStream = new ReadableStream({ + const jsonStream = new ReadableStream({ async start(controller) { - controller.enqueue('{"first":true}') + controller.enqueue(encodeJSONFrame('{"first":true}')) await jsonGate - controller.enqueue('{"second":true}') + controller.enqueue(encodeJSONFrame('{"second":true}')) controller.close() }, }) @@ -524,29 +669,18 @@ describe('frame-protocol', () => { const rawStreams = new Map>() rawStreams.set(1, initialRaw) - // Late stream arrives after first JSON - const lateStreamSource = new ReadableStream<{ - id: number - stream: ReadableStream - }>({ - start(controller) { - controller.enqueue({ - id: 2, - stream: new ReadableStream({ - start(c) { - c.enqueue(new Uint8Array([2])) - c.close() - }, - }), - }) - controller.close() - }, - }) - - const multiplexed = createMultiplexedStream( + const [multiplexed, registerRaw] = createMultiplexedStream( jsonStream, rawStreams, - lateStreamSource, + ) + registerRaw( + 2, + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([2])) + controller.close() + }, + }), ) const reader = multiplexed.getReader() @@ -583,9 +717,9 @@ describe('frame-protocol', () => { }) it('should handle raw stream error', async () => { - const jsonStream = new ReadableStream({ + const jsonStream = new ReadableStream({ start(controller) { - controller.enqueue('{}') + controller.enqueue(encodeJSONFrame('{}')) controller.close() }, }) @@ -599,7 +733,7 @@ describe('frame-protocol', () => { const rawStreams = new Map>() rawStreams.set(10, errorStream) - const multiplexed = createMultiplexedStream(jsonStream, rawStreams) + const [multiplexed] = createMultiplexedStream(jsonStream, rawStreams) const reader = multiplexed.getReader() const chunks: Array = [] @@ -631,11 +765,11 @@ describe('frame-protocol', () => { }) it('should propagate JSON stream error to output (fatal)', async () => { - let errorController: ReadableStreamDefaultController - const jsonStream = new ReadableStream({ + let errorController: ReadableStreamDefaultController + const jsonStream = new ReadableStream({ start(controller) { errorController = controller - controller.enqueue('{"first":true}') + controller.enqueue(encodeJSONFrame('{"first":true}')) }, }) @@ -651,7 +785,7 @@ describe('frame-protocol', () => { const rawStreams = new Map>() rawStreams.set(1, rawStream) - const multiplexed = createMultiplexedStream(jsonStream, rawStreams) + const [multiplexed] = createMultiplexedStream(jsonStream, rawStreams) const reader = multiplexed.getReader() // Should be able to read first JSON frame @@ -669,9 +803,9 @@ describe('frame-protocol', () => { it('should not hang when raw stream never ends', async () => { // This tests the fix for hanging requests - const jsonStream = new ReadableStream({ + const jsonStream = new ReadableStream({ start(controller) { - controller.enqueue('{}') + controller.enqueue(encodeJSONFrame('{}')) controller.close() }, }) @@ -690,7 +824,7 @@ describe('frame-protocol', () => { const rawStreams = new Map>() rawStreams.set(1, neverEndingStream) - const multiplexed = createMultiplexedStream(jsonStream, rawStreams) + const [multiplexed] = createMultiplexedStream(jsonStream, rawStreams) const reader = multiplexed.getReader() // Read first two frames (JSON and CHUNK) @@ -704,7 +838,9 @@ describe('frame-protocol', () => { await reader.cancel() // The underlying raw stream should be cancelled - expect(rawStreamCancelled).toBe(true) + await vi.waitFor(() => { + expect(rawStreamCancelled).toBe(true) + }) }) }) }) diff --git a/packages/start-server-core/tests/server-functions-handler.test.ts b/packages/start-server-core/tests/server-functions-handler.test.ts new file mode 100644 index 0000000000..593f9284e0 --- /dev/null +++ b/packages/start-server-core/tests/server-functions-handler.test.ts @@ -0,0 +1,247 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + RawStream, + createSerializationAdapter, + defaultSerovalPlugins, + makeSerovalPlugin, +} from '@tanstack/router-core' +import { + FRAME_HEADER_SIZE, + MAX_FRAME_PAYLOAD_SIZE, + MAX_FRAMED_STREAMS, + TSS_CONTENT_TYPE_FRAMED_VERSIONED, +} from '@tanstack/start-client-core' +import { handleServerAction } from '../src/server-functions-handler' + +const handlerMocks = vi.hoisted(() => ({ + action: vi.fn(), + serializationDestroyed: vi.fn(), + serializationPlugins: [] as Array, +})) + +vi.mock('../src/getServerFnById', () => ({ + getServerFnById: async () => handlerMocks.action, +})) + +vi.mock('../src/request-response', () => ({ + getResponse: () => ({ status: 200, statusText: 'OK' }), +})) + +vi.mock('@tanstack/start-client-core', async (importOriginal) => { + const actual = + await importOriginal() + return { + ...actual, + getDefaultSerovalPlugins: () => [ + ...handlerMocks.serializationPlugins, + ...defaultSerovalPlugins, + ], + } +}) + +vi.mock('seroval', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + toCrossJSONStream( + ...args: Parameters + ): ReturnType { + const destroy = actual.toCrossJSONStream(...args) + return (() => { + handlerMocks.serializationDestroyed() + destroy() + }) as ReturnType + }, + } +}) + +function createDeferred() { + let resolve!: (value: T) => void + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve + }) + return { promise, resolve } +} + +class ThrowDuringSerialization {} + +const serializationFailure = new Error('custom serialization failed') +handlerMocks.serializationPlugins.push( + makeSerovalPlugin( + createSerializationAdapter({ + key: 'throw-during-serialization-test', + test: (value): value is ThrowDuringSerialization => { + return value instanceof ThrowDuringSerialization + }, + toSerializable: (): unknown => { + throw serializationFailure + }, + fromSerializable: () => new ThrowDuringSerialization(), + }), + ), +) + +async function createServerFnResponse(result: unknown): Promise { + handlerMocks.action.mockResolvedValue({ result }) + const response = await handleServerAction({ + request: new Request('http://localhost/_serverFn/test', { + method: 'POST', + headers: { 'x-tsr-serverFn': 'true' }, + }), + context: {}, + serverFnId: 'test', + }) + + expect(response.headers.get('Content-Type')).toBe( + TSS_CONTENT_TYPE_FRAMED_VERSIONED, + ) + return response +} + +describe('server-functions-handler framed responses', () => { + beforeEach(() => { + handlerMocks.action.mockReset() + handlerMocks.serializationDestroyed.mockClear() + }) + + it('destroys pending Seroval work when the response is cancelled', async () => { + const pendingResult = createDeferred() + const response = await createServerFnResponse(pendingResult.promise) + const reason = new Error('consumer disconnected') + + await response.body!.cancel(reason) + + await vi.waitFor(() => { + expect(handlerMocks.serializationDestroyed).toHaveBeenCalledTimes(1) + }) + }) + + it('destroys pending work and cancels raw streams when initial serialization fails', async () => { + const cancel = vi.fn() + const pending = new Promise(() => {}) + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + handlerMocks.action.mockResolvedValue({ + result: [ + pending, + new RawStream(new ReadableStream({ cancel })), + new ThrowDuringSerialization(), + ], + }) + try { + const response = await handleServerAction({ + request: new Request('http://localhost/_serverFn/test', { + method: 'POST', + headers: { 'x-tsr-serverFn': 'true' }, + }), + context: {}, + serverFnId: 'test', + }) + + expect(response.headers.get('Content-Type')).toBe('application/json') + expect(handlerMocks.serializationDestroyed).toHaveBeenCalledOnce() + expect(cancel).toHaveBeenCalledOnce() + expect(cancel).toHaveBeenCalledWith(serializationFailure) + } finally { + consoleError.mockRestore() + } + }) + + it('cancels every late raw stream when the output is cancelled', async () => { + const pendingResult = createDeferred>() + const cancelReasons: Array> = [[], [], []] + let resolveFirstPull!: () => void + const firstPull = new Promise((resolve) => { + resolveFirstPull = resolve + }) + let firstStream!: ReadableStream + firstStream = new ReadableStream({ + pull() { + if (firstStream.locked) { + resolveFirstPull() + } + }, + cancel(reason) { + cancelReasons[0]!.push(reason) + }, + }) + const streams = [ + firstStream, + ...cancelReasons.slice(1).map((reasons) => { + return new ReadableStream({ + cancel(reason) { + reasons.push(reason) + }, + }) + }), + ] + const reason = new Error('consumer disconnected') + + const response = await createServerFnResponse(pendingResult.promise) + pendingResult.resolve( + streams.map((stream) => { + return new RawStream(stream) + }), + ) + + await firstPull + await response.body!.cancel(reason) + + await vi.waitFor(() => { + expect(cancelReasons).toEqual([[reason], [reason], [reason]]) + }) + }) + + it('rejects excess raw streams discovered after the response starts', async () => { + const pendingResult = createDeferred>() + const response = await createServerFnResponse(pendingResult.promise) + let cancellations = 0 + pendingResult.resolve( + Array.from({ length: MAX_FRAMED_STREAMS + 1 }, () => { + return new RawStream( + new ReadableStream({ + cancel() { + cancellations++ + }, + }), + ) + }), + ) + + await expect(response.text()).rejects.toThrow( + `Too many raw streams in framed response (max ${MAX_FRAMED_STREAMS})`, + ) + await vi.waitFor(() => { + expect(cancellations).toBe(MAX_FRAMED_STREAMS + 1) + }) + }) + + it('bounds queued JSON while the response consumer is stalled', async () => { + const pendingValues = [ + createDeferred(), + createDeferred(), + createDeferred(), + ] + const response = await createServerFnResponse( + pendingValues.map(({ promise }) => promise), + ) + const largeValue = 'x'.repeat( + Math.floor(MAX_FRAME_PAYLOAD_SIZE / 2) + 1024, + ) + + for (const pendingValue of pendingValues) { + pendingValue.resolve(largeValue) + await new Promise((resolve) => { + setTimeout(resolve, 0) + }) + } + + await expect(response.text()).rejects.toThrow( + `Framed JSON queue exceeded ${FRAME_HEADER_SIZE + MAX_FRAME_PAYLOAD_SIZE} bytes`, + ) + expect(handlerMocks.serializationDestroyed).toHaveBeenCalledOnce() + }) +}) diff --git a/packages/vue-router/src/CatchBoundary.tsx b/packages/vue-router/src/CatchBoundary.tsx index e912445158..96ff495228 100644 --- a/packages/vue-router/src/CatchBoundary.tsx +++ b/packages/vue-router/src/CatchBoundary.tsx @@ -8,6 +8,25 @@ type CatchBoundaryProps = { onCatch?: (error: Error) => void } +const ErrorCatcher = Vue.defineComponent({ + props: { + onError: Function, + children: null, + }, + setup(props) { + Vue.onErrorCaptured((err: Error) => { + if (typeof (err as any).then === 'function') { + return false + } + + props.onError?.(err) + return false + }) + + return () => props.children as Vue.VNode + }, +}) + const VueErrorBoundary = Vue.defineComponent({ name: 'VueErrorBoundary', props: { @@ -23,6 +42,13 @@ const VueErrorBoundary = Vue.defineComponent({ error.value = null } + const onError = (err: Error) => { + if (!error.value) { + error.value = err + props.onError?.(err) + } + } + Vue.watch( () => props.resetKey, (newKey, oldKey) => { @@ -32,30 +58,13 @@ const VueErrorBoundary = Vue.defineComponent({ }, ) - Vue.onErrorCaptured((err: Error) => { - if ( - err instanceof Promise || - (err && typeof (err as any).then === 'function') - ) { - return false - } - - error.value = err - - if (props.onError) { - props.onError(err) - } - - return false - }) - return () => error.value ? Vue.h(props.errorComponent ?? ErrorComponent, { error: error.value, reset, }) - : (props.children as Vue.VNode) + : Vue.h(ErrorCatcher, { onError, children: props.children }) }, }) diff --git a/packages/vue-router/src/headContentUtils.tsx b/packages/vue-router/src/headContentUtils.tsx index adfc156c72..9ec5453b10 100644 --- a/packages/vue-router/src/headContentUtils.tsx +++ b/packages/vue-router/src/headContentUtils.tsx @@ -22,30 +22,6 @@ export const useTags = (assetCrossOrigin?: AssetCrossOriginConfig) => { const currentMatches = matches.value const tags: Array = [] const manifest = router.ssr?.manifest - for (const match of currentMatches) { - for (const link of manifest?.routes[match.routeId]?.css ?? []) { - const resolvedLink = resolveManifestCssLink(link) - tags.push({ - tag: 'link', - attrs: { - rel: 'stylesheet', - ...resolvedLink, - crossOrigin: - getAssetCrossOrigin(assetCrossOrigin, 'stylesheet') ?? - resolvedLink.crossOrigin, - }, - }) - } - } - if (manifest?.inlineStyle) { - tags.push({ - tag: 'style', - attrs: manifest.inlineStyle.attrs, - children: manifest.inlineStyle.children, - inlineCss: true, - }) - } - const resultMeta: Array = [] const metaByAttribute: Record = {} let title: RouterManagedTag | undefined @@ -101,21 +77,36 @@ export const useTags = (assetCrossOrigin?: AssetCrossOriginConfig) => { resultMeta.push(title) } resultMeta.reverse() - appendUniqueUserTags(tags, resultMeta) const preloads: Array = [] const links: Array = [] const headScripts: Array = [] for (const match of currentMatches) { - for (const preload of manifest?.routes[match.routeId]?.preloads ?? []) { - if (preload) { - preloads.push({ + const manifestRoute = manifest?.routes[match.routeId] + if (manifestRoute) { + for (const link of manifestRoute.css ?? []) { + const resolvedLink = resolveManifestCssLink(link) + tags.push({ tag: 'link', attrs: { - ...getScriptPreloadAttrs(manifest, preload, assetCrossOrigin), + rel: 'stylesheet', + ...resolvedLink, + crossOrigin: + getAssetCrossOrigin(assetCrossOrigin, 'stylesheet') ?? + resolvedLink.crossOrigin, }, }) } + for (const preload of manifestRoute.preloads ?? []) { + if (preload) { + preloads.push({ + tag: 'link', + attrs: { + ...getScriptPreloadAttrs(manifest, preload, assetCrossOrigin), + }, + }) + } + } } for (const link of match.links ?? []) { if (link) { @@ -133,6 +124,15 @@ export const useTags = (assetCrossOrigin?: AssetCrossOriginConfig) => { } } } + if (manifest?.inlineStyle) { + tags.push({ + tag: 'style', + attrs: manifest.inlineStyle.attrs, + children: manifest.inlineStyle.children, + inlineCss: true, + }) + } + appendUniqueUserTags(tags, resultMeta) tags.push(...preloads) appendUniqueUserTags(tags, links) appendUniqueUserTags(tags, headScripts) diff --git a/packages/vue-router/src/lazyRouteComponent.tsx b/packages/vue-router/src/lazyRouteComponent.tsx index 527f3d15ad..a9d2b8bec1 100644 --- a/packages/vue-router/src/lazyRouteComponent.tsx +++ b/packages/vue-router/src/lazyRouteComponent.tsx @@ -1,25 +1,10 @@ import * as Vue from 'vue' +import { isModuleNotFoundError } from '@tanstack/router-core' +import { isServer } from '@tanstack/router-core/isServer' import { Outlet } from './Match' import { ClientOnly } from './ClientOnly' import type { AsyncRouteComponent } from './route' -// If the load fails due to module not found, it may mean a new version of -// the build was deployed and the user's browser is still using an old version. -// If this happens, the old version in the user's browser would have an outdated -// URL to the lazy module. -// In that case, we want to attempt one window refresh to get the latest. -function isModuleNotFoundError(error: any): boolean { - // chrome: "Failed to fetch dynamically imported module: http://localhost:5173/src/routes/posts.index.tsx?tsr-split" - // firefox: "error loading dynamically imported module: http://localhost:5173/src/routes/posts.index.tsx?tsr-split" - // safari: "Importing a module script failed." - if (typeof error?.message !== 'string') return false - return ( - error.message.startsWith('Failed to fetch dynamically imported module') || - error.message.startsWith('error loading dynamically imported module') || - error.message.startsWith('Importing a module script failed') - ) -} - export function lazyRouteComponent< T extends Record, TKey extends keyof T = 'default', @@ -30,41 +15,36 @@ export function lazyRouteComponent< ): T[TKey] extends (props: infer TProps) => any ? AsyncRouteComponent : never { - let loadPromise: Promise | undefined + let loadPromise: Promise | undefined let comp: T[TKey] | T['default'] | null = null let error: any = null - let attemptedReload = false const load = () => { // If we're on the server and SSR is disabled for this component - if (typeof document === 'undefined' && ssr?.() === false) { + if ((isServer ?? typeof document === 'undefined') && ssr?.() === false) { comp = (() => null) as any - return Promise.resolve(comp) + return Promise.resolve() } - // Use existing promise or create new one - if (!loadPromise) { - error = undefined - loadPromise = importer() - .then((res) => { - loadPromise = undefined - comp = res[exportName ?? 'default'] - return comp - }) - .catch((err) => { - error = err - loadPromise = undefined + if (loadPromise) { + return loadPromise + } - // If it's a module not found error, we'll try to handle it in the component - if (isModuleNotFoundError(error)) { - return null - } + error = null + return (loadPromise = importer() + .then((res) => { + loadPromise = undefined + comp = res[exportName ?? 'default'] + }) + .catch((err) => { + error = err + loadPromise = undefined + // Missing modules are handled when the component renders. + if (!isModuleNotFoundError(err)) { throw err - }) - } - - return loadPromise + } + })) } // Create a lazy component wrapper using defineComponent so it works in Vue SFC templates @@ -72,59 +52,43 @@ export function lazyRouteComponent< name: 'LazyRouteComponent', setup(props: any) { // Create refs to track component state - // Use shallowRef for component to avoid making it reactive (Vue warning) - const component = Vue.shallowRef(comp ? Vue.markRaw(comp) : comp) + const component = Vue.shallowRef(comp) const errorState = Vue.ref(error) - const loading = Vue.ref(!component.value && !errorState.value) // Setup effect to load the component when this component is used Vue.onMounted(() => { if (!component.value && !errorState.value) { - loading.value = true - load() - .then((result) => { - // Use markRaw to prevent Vue from making the component reactive - component.value = result ? Vue.markRaw(result) : result - loading.value = false + .then(() => { + errorState.value = error + component.value = comp }) .catch((err) => { errorState.value = err - loading.value = false }) } }) - // Handle module not found error with reload attempt - if ( - errorState.value && - isModuleNotFoundError(errorState.value) && - !attemptedReload - ) { - if ( - typeof window !== 'undefined' && - typeof sessionStorage !== 'undefined' - ) { - // Try to reload once on module not found error - const storageKey = `tanstack_router_reload:${errorState.value.message}` - if (!sessionStorage.getItem(storageKey)) { - sessionStorage.setItem(storageKey, '1') - attemptedReload = true - window.location.reload() - return () => null // Return empty while reloading + // Return a render function + return () => { + if (errorState.value) { + if ( + isModuleNotFoundError(errorState.value) && + !(isServer ?? typeof window === 'undefined') && + typeof sessionStorage !== 'undefined' + ) { + const storageKey = `tanstack_router_reload:${errorState.value.message}` + if (!sessionStorage.getItem(storageKey)) { + sessionStorage.setItem(storageKey, '1') + window.location.reload() + return null + } } + throw errorState.value } - } - // If we have a non-module-not-found error, throw it - if (errorState.value && !isModuleNotFoundError(errorState.value)) { - throw errorState.value - } - - // Return a render function - return () => { // If we're still loading or don't have a component yet, use a suspense pattern - if (loading.value || !component.value) { + if (!component.value) { return Vue.h('div', null) // Empty div while loading } diff --git a/packages/vue-router/tests/CatchBoundary.test.tsx b/packages/vue-router/tests/CatchBoundary.test.tsx new file mode 100644 index 0000000000..a792ec7684 --- /dev/null +++ b/packages/vue-router/tests/CatchBoundary.test.tsx @@ -0,0 +1,144 @@ +import { afterEach, expect, test, vi } from 'vitest' +import * as Vue from 'vue' +import { cleanup, fireEvent, render, screen } from '@testing-library/vue' +import { CatchBoundary } from '../src' +import type { ErrorComponentProps } from '../src' + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +const unmountHooks = [ + ['before unmount', Vue.onBeforeUnmount], + ['after unmount', Vue.onUnmounted], +] as const + +test.each(unmountHooks)( + 'keeps ownership of errors thrown by the failed child tree %s hook', + async (_, onUnmount) => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + + const shouldThrow = Vue.ref(false) + const cleanupChild = vi.fn(() => { + throw new Error('child cleanup failed') + }) + const innerCatch = vi.fn() + const Child = Vue.defineComponent({ + setup() { + onUnmount(cleanupChild) + return () => { + if (shouldThrow.value) { + throw new Error('child render failed') + } + return Vue.h('p', null, 'child content') + } + }, + }) + const InnerError = (props: ErrorComponentProps) => ( +

inner: {props.error.message}

+ ) + const OuterError = (props: ErrorComponentProps) => ( +

outer: {props.error.message}

+ ) + const App = Vue.defineComponent({ + setup() { + return () => + CatchBoundary({ + getResetKey: () => 0, + errorComponent: OuterError, + children: CatchBoundary({ + getResetKey: () => 0, + errorComponent: InnerError, + onCatch: innerCatch, + children: Vue.h(Child), + }), + }) + }, + }) + + render() + expect(await screen.findByText('child content')).toBeInTheDocument() + + shouldThrow.value = true + + expect( + await screen.findByText('inner: child render failed'), + ).toBeInTheDocument() + expect(cleanupChild).toHaveBeenCalledOnce() + expect(innerCatch).toHaveBeenCalledOnce() + expect(innerCatch.mock.calls[0]![0]).toMatchObject({ + message: 'child render failed', + }) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }, +) + +test.each(unmountHooks)( + 'propagates errors thrown by the fallback %s hook during reset', + async (_, onUnmount) => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + + const shouldThrow = Vue.ref(true) + const cleanupFallback = vi.fn(() => { + throw new Error('fallback cleanup failed') + }) + const Child = Vue.defineComponent({ + setup() { + return () => { + if (shouldThrow.value) { + throw new Error('child render failed') + } + return Vue.h('p', null, 'child content') + } + }, + }) + const InnerError = Vue.defineComponent({ + props: { + error: { type: Error, required: true }, + reset: { type: Function, required: true }, + }, + setup(props) { + onUnmount(cleanupFallback) + return () => + Vue.h( + 'button', + { + onClick: () => { + shouldThrow.value = false + props.reset() + }, + }, + `inner: ${props.error.message}`, + ) + }, + }) + const OuterError = (props: ErrorComponentProps) => ( +

outer: {props.error.message}

+ ) + const App = Vue.defineComponent({ + setup() { + return () => + CatchBoundary({ + getResetKey: () => 0, + errorComponent: OuterError, + children: CatchBoundary({ + getResetKey: () => 0, + errorComponent: InnerError, + children: Vue.h(Child), + }), + }) + }, + }) + + render() + await fireEvent.click( + await screen.findByRole('button', { name: 'inner: child render failed' }), + ) + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'outer: fallback cleanup failed', + ) + expect(cleanupFallback).toHaveBeenCalledOnce() + }, +) diff --git a/packages/vue-router/tests/component-preload-retry.test.tsx b/packages/vue-router/tests/component-preload-retry.test.tsx index 9fb8ad5f5a..c2c7526459 100644 --- a/packages/vue-router/tests/component-preload-retry.test.tsx +++ b/packages/vue-router/tests/component-preload-retry.test.tsx @@ -1,7 +1,16 @@ import { afterEach, expect, test, vi } from 'vitest' -import { cleanup, fireEvent, render, screen } from '@testing-library/vue' import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/vue' +import { + Outlet, RouterProvider, + createControlledPromise, + createLazyRoute, createMemoryHistory, createRootRoute, createRoute, @@ -14,6 +23,48 @@ import type { ErrorComponentProps } from '../src' afterEach(() => { cleanup() vi.restoreAllMocks() + vi.unstubAllGlobals() + sessionStorage.clear() +}) + +test('concurrent component preloads share the import', async () => { + const componentImport = createControlledPromise<{ + default: () => null + }>() + const importer = vi.fn(() => componentImport) + const Page = lazyRouteComponent(importer) + + const first = Page.preload?.() + expect(Page.preload?.()).toBe(first) + expect(importer).toHaveBeenCalledOnce() + + componentImport.resolve({ default: () => null }) + await first +}) + +test('a component loads when rendered before preload', async () => { + const importer = vi.fn().mockResolvedValue({ + default: () =>
Page content
, + }) + const Page = lazyRouteComponent(importer) + + render() + + expect(await screen.findByText('Page content')).toBeInTheDocument() + expect(importer).toHaveBeenCalledOnce() +}) + +test('a preloaded component renders without another import', async () => { + const importer = vi.fn().mockResolvedValue({ + default: () =>
Page content
, + }) + const Page = lazyRouteComponent(importer) + + await Page.preload?.() + render() + + expect(await screen.findByText('Page content')).toBeInTheDocument() + expect(importer).toHaveBeenCalledOnce() }) test('a failed component download is retried from the route error UI', async () => { @@ -63,3 +114,184 @@ test('a failed component download is retried from the route error UI', async () expect(await screen.findByText('Page content')).toBeInTheDocument() expect(importer).toHaveBeenCalledTimes(2) }) + +test('a lazy error component failure after reload reaches the global error boundary', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const importFailure = new TypeError( + 'Failed to fetch dynamically imported module: /assets/error.js', + ) + sessionStorage.setItem(`tanstack_router_reload:${importFailure.message}`, '1') + const ErrorPage = () =>

Unreachable error page

+ const importer = vi + .fn<() => Promise<{ default: typeof ErrorPage }>>() + .mockRejectedValue(importFailure) + const LazyErrorPage = lazyRouteComponent(importer) + const rootRoute = createRootRoute() + const pageRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/page', + component: () => { + throw new Error('page render failed') + }, + errorComponent: LazyErrorPage, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + }) + + render() + + expect(await screen.findByText(importFailure.message)).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Error' })).toBeInTheDocument() + expect(importer).toHaveBeenCalledOnce() +}) + +test('reloads once when a route component preload reports a missing module', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + + const failure = new TypeError( + 'Failed to fetch dynamically imported module: /assets/page.js', + ) + const storageKey = `tanstack_router_reload:${failure.message}` + const importer = vi.fn().mockRejectedValue(failure) + const Page = lazyRouteComponent(importer) + const rootRoute = createRootRoute() + const pageRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/page', + component: Page, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + }) + const reload = vi.fn() + vi.stubGlobal('window', { document, location: { reload } }) + + render() + + await waitFor(() => { + expect(sessionStorage.getItem(storageKey)).toBe('1') + }) + expect(importer).toHaveBeenCalledOnce() + expect(reload).toHaveBeenCalledOnce() +}) + +test('retries a module download from the error UI after reloading once', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + + const failure = new TypeError( + 'Failed to fetch dynamically imported module: /assets/page.js', + ) + sessionStorage.setItem(`tanstack_router_reload:${failure.message}`, '1') + + const PageContent = () =>
Page content
+ const importer = vi + .fn<() => Promise<{ default: typeof PageContent }>>() + .mockRejectedValueOnce(failure) + .mockResolvedValue({ default: PageContent }) + const Page = lazyRouteComponent(importer) + + function RouteError(props: ErrorComponentProps) { + const router = useRouter() + return ( + + ) + } + + const rootRoute = createRootRoute() + const pageRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/page', + component: Page, + errorComponent: RouteError, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + }) + const reload = vi.fn() + vi.stubGlobal('window', { document, location: { reload } }) + + render() + await fireEvent.click(await screen.findByRole('button', { name: 'Retry' })) + + expect(await screen.findByText('Page content')).toBeInTheDocument() + expect(importer).toHaveBeenCalledTimes(2) + expect(reload).not.toHaveBeenCalled() +}) + +test('delayed lazy options replace default pending UI before the page component loads', async () => { + const loader = createControlledPromise() + const componentPreload = createControlledPromise() + const preloadComponent = vi.fn(() => componentPreload) + const Page = Object.assign(() =>

Page

, { + preload: preloadComponent, + }) + const lazyPageOptions = createLazyRoute('/page')({ + pendingComponent: () =>

Loading lazy page

, + component: Page, + }) + const lazyOptions = createControlledPromise() + const rootRoute = createRootRoute({ component: Outlet }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>

Index page

, + }) + const pageRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/page', + loader: () => loader, + }).lazy(() => lazyOptions) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, pageRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + defaultPendingMs: 0, + defaultPendingMinMs: 0, + defaultPendingComponent: () =>

Loading default

, + }) + let navigation: Promise | undefined + + try { + render() + expect(await screen.findByText('Index page')).toBeInTheDocument() + + navigation = router.navigate({ to: '/page' }) + expect(await screen.findByRole('status')).toHaveTextContent( + 'Loading default', + ) + + lazyOptions.resolve(lazyPageOptions) + await waitFor(() => { + expect(screen.getByRole('status')).toHaveTextContent('Loading lazy page') + }) + expect(preloadComponent).toHaveBeenCalledOnce() + expect(componentPreload.status).toBe('pending') + expect(screen.queryByText('Page')).not.toBeInTheDocument() + + componentPreload.resolve() + loader.resolve() + await navigation + + expect(await screen.findByText('Page')).toBeInTheDocument() + } finally { + lazyOptions.resolve(lazyPageOptions) + componentPreload.resolve() + loader.resolve() + if (navigation) { + await Promise.allSettled([navigation]) + } + } +}) From a6ce40af2cb87a5d14b9c4e10e9bbd5f85599022 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:04:07 +0000 Subject: [PATCH 5/5] ci: apply automated fixes --- packages/router-core/src/load-client.ts | 51 ++++--------- packages/router-core/src/router.ts | 5 +- .../tests/pending-terminal-boundary.test.ts | 10 +-- .../tests/preload-adoption.test.ts | 4 +- .../tests/preload-beforeload-reuse.test.ts | 75 +++++++++---------- .../preload-public-cache-behavior.test.ts | 4 +- .../public-preload-lane-contract.test.ts | 9 +-- .../src/client-rpc/frame-decoder.ts | 3 +- .../tests/frame-decoder.test.ts | 4 +- .../start-server-core/src/frame-protocol.ts | 4 +- .../tests/createStartHandler.test.ts | 16 ++-- .../tests/server-functions-handler.test.ts | 4 +- 12 files changed, 70 insertions(+), 119 deletions(-) diff --git a/packages/router-core/src/load-client.ts b/packages/router-core/src/load-client.ts index f0c7432128..86830d1bb6 100644 --- a/packages/router-core/src/load-client.ts +++ b/packages/router-core/src/load-client.ts @@ -154,18 +154,10 @@ type SharedLoaderFailure = type LoaderFailure = SharedLoaderFailure | [typeof CANCELED] type LoaderOutcome = - | [ - typeof SUCCESS, - data: unknown, - updatedAt?: number, - ] + | [typeof SUCCESS, data: unknown, updatedAt?: number] | SharedLoaderFailure -type LoaderSuccess = [ - typeof SUCCESS, - data: unknown, - updatedAt: number, -] +type LoaderSuccess = [typeof SUCCESS, data: unknown, updatedAt: number] type LoaderResult = LoaderSuccess | LoaderFailure type LoaderFlightResult = LoaderSuccess | SharedLoaderFailure @@ -506,9 +498,7 @@ function retireFlight(matchId: string, flight: LoaderFlight): void { } /** Release every loader-flight lease owned by a discarded match set. */ -export function transferMatchResources( - previous: Array, -): void { +export function transferMatchResources(previous: Array): void { const abort: Array = [] for (const match of previous as Array) { const claimed = detachClaim(match) @@ -638,7 +628,7 @@ async function loadResource( ? normalizeError(route, cause) : normalize(cause, true, route.id) }, - ) + ) flight = [outcome, controller, 1, registry] as LoaderFlight registry.set(match.id, flight) } @@ -710,10 +700,7 @@ export function cacheLoaderMatch( } } -function getParentSnapshot( - match: WorkMatch, - outcome: LoaderResult, -): WorkMatch { +function getParentSnapshot(match: WorkMatch, outcome: LoaderResult): WorkMatch { if (outcome[0] === ERROR || outcome[0] === NOT_FOUND) { return { ...match, @@ -774,9 +761,7 @@ function createLoaderTask( ? (route.options.preloadStaleTime ?? router.options.defaultPreloadStaleTime ?? 30_000) - : (route.options.staleTime ?? - router.options.defaultStaleTime ?? - 0) + : (route.options.staleTime ?? router.options.defaultStaleTime ?? 0) reload = !!( match.invalid || configured || @@ -855,11 +840,7 @@ function createLoaderTask( const rawOutcome: Promise = reloadFailure ? Promise.resolve(reloadFailure) : !blocking || !loader - ? Promise.resolve([ - SUCCESS, - match.loaderData, - match.updatedAt, - ]) + ? Promise.resolve([SUCCESS, match.loaderData, match.updatedAt]) : loadResource( router, lane, @@ -883,9 +864,7 @@ function createLoaderTask( }) const rawChunkFailure = waitFor( - Promise.resolve().then(() => - loadRouteChunk(route, undefined, options[8]), - ), + Promise.resolve().then(() => loadRouteChunk(route, undefined, options[8])), options[0].signal, ).catch( (cause): IndexedOutcome => [ @@ -1395,8 +1374,7 @@ function offerPending(router: CoordinatorRouter, tx: LoadTransaction): void { if (!component || typeof delay !== 'number' || delay === Infinity) { return } - min = - route.options.pendingMinMs ?? router.options.defaultPendingMinMs ?? 0 + min = route.options.pendingMinMs ?? router.options.defaultPendingMinMs ?? 0 break } if (delay === undefined) { @@ -1584,9 +1562,10 @@ function settlePublication( checkpoint.published = false const retained = [...router._cache.values(), ...router._committed] transferMatchResources( - [...checkpoint.previousCache.values(), ...checkpoint.previousMatches].filter( - (match) => !retained.includes(match), - ), + [ + ...checkpoint.previousCache.values(), + ...checkpoint.previousMatches, + ].filter((match) => !retained.includes(match)), ) } @@ -1621,9 +1600,7 @@ function rollbackPublication( router.stores.setMatches(checkpoint.previousPresentation) }) tx[0].abort() - transferMatchResources( - discarded.filter((match) => !restored.includes(match)), - ) + transferMatchResources(discarded.filter((match) => !restored.includes(match))) discardBackground(lane) if (router._tx === tx && router._commitPromise === checkpoint.commitPromise) { router._commitPromise?.resolve() diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index e18269802a..875117b87a 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -2513,10 +2513,7 @@ export class RouterCore< const discardedPreloads: Array = [] const preloadControllers: Array = [] for (const [controller, matches] of this._preloads) { - if ( - !invalidIds || - matches.some((match) => invalidIds.has(match.id)) - ) { + if (!invalidIds || matches.some((match) => invalidIds.has(match.id))) { this._preloads.delete(controller) discardedPreloads.push(...matches) preloadControllers.push(controller) diff --git a/packages/router-core/tests/pending-terminal-boundary.test.ts b/packages/router-core/tests/pending-terminal-boundary.test.ts index 3d7959998d..e7ecde162e 100644 --- a/packages/router-core/tests/pending-terminal-boundary.test.ts +++ b/packages/router-core/tests/pending-terminal-boundary.test.ts @@ -1,10 +1,6 @@ import { expect, test } from 'vitest' import { createMemoryHistory } from '@tanstack/history' -import { - BaseRootRoute, - BaseRoute, - createControlledPromise, -} from '../src' +import { BaseRootRoute, BaseRoute, createControlledPromise } from '../src' import { createTestRouter } from './routerTestUtils' test('a known parent error does not offer a descendant pending boundary while its error component loads', async () => { @@ -85,9 +81,7 @@ test('a known parent error does not offer a descendant pending boundary while it expect( transitions.some((matches) => { - const parent = matches.find( - (match) => match.routeId === parentRoute.id, - ) + const parent = matches.find((match) => match.routeId === parentRoute.id) const child = matches.find((match) => match.routeId === childRoute.id) return parent?.status === 'success' && child?.status === 'pending' }), diff --git a/packages/router-core/tests/preload-adoption.test.ts b/packages/router-core/tests/preload-adoption.test.ts index dcc7548225..efc114cf5f 100644 --- a/packages/router-core/tests/preload-adoption.test.ts +++ b/packages/router-core/tests/preload-adoption.test.ts @@ -366,10 +366,10 @@ describe('preload concurrency', () => { expect(childLoads).toBe(1) expect(router.state.location.pathname).toBe('/parent/child') expect( - router.state.matches.find((match) => match.routeId === parentRoute.id) + router.state.matches.find((match) => match.routeId === parentRoute.id), ).toMatchObject({ status: 'error', error: sharedFailure }) expect( - router.state.matches.find((match) => match.routeId === childRoute.id) + router.state.matches.find((match) => match.routeId === childRoute.id), ).toMatchObject({ status: 'success', loaderData: 'child data' }) }) diff --git a/packages/router-core/tests/preload-beforeload-reuse.test.ts b/packages/router-core/tests/preload-beforeload-reuse.test.ts index b061233fb9..6d046476d6 100644 --- a/packages/router-core/tests/preload-beforeload-reuse.test.ts +++ b/packages/router-core/tests/preload-beforeload-reuse.test.ts @@ -90,45 +90,44 @@ describe('preloaded loader reuse with fresh beforeLoad context', () => { }) }) - test( - 'reruns completed beforeLoad while retaining navigation-owned loader data', - async () => { - const beforeLoad = vi.fn(({ preload }: { preload: boolean }) => ({ - guard: preload ? 'preloaded' : 'loaded', - })) - const loader = vi.fn(({ preload }: { preload: boolean }) => preload) - const rootRoute = new BaseRootRoute({}) - const indexRoute = new BaseRoute({ - getParentRoute: () => rootRoute, - path: '/', - }) - const guardedRoute = new BaseRoute({ - getParentRoute: () => rootRoute, - path: '/guarded', - beforeLoad, - shouldReload: false, - loader, - }) - const router = createTestRouter({ - routeTree: rootRoute.addChildren([indexRoute, guardedRoute]), - history: createMemoryHistory({ initialEntries: ['/'] }), - }) + test('reruns completed beforeLoad while retaining navigation-owned loader data', async () => { + const beforeLoad = vi.fn(({ preload }: { preload: boolean }) => ({ + guard: preload ? 'preloaded' : 'loaded', + })) + const loader = vi.fn(({ preload }: { preload: boolean }) => preload) + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const guardedRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/guarded', + beforeLoad, + shouldReload: false, + loader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, guardedRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) - await router.load() - await router.navigate({ to: '/guarded' }) - await router.navigate({ to: '/' }) - await router.preloadRoute({ to: '/guarded' }) - await router.navigate({ to: '/guarded' }) - - expect(beforeLoad.mock.calls.map(([context]) => context.preload)).toEqual( - [false, true, false], - ) - expect(loader.mock.calls.map(([context]) => context.preload)).toEqual([ - false, - ]) - expect(router.state.matches.at(-1)?.context).toEqual({ guard: 'loaded' }) - }, - ) + await router.load() + await router.navigate({ to: '/guarded' }) + await router.navigate({ to: '/' }) + await router.preloadRoute({ to: '/guarded' }) + await router.navigate({ to: '/guarded' }) + + expect(beforeLoad.mock.calls.map(([context]) => context.preload)).toEqual([ + false, + true, + false, + ]) + expect(loader.mock.calls.map(([context]) => context.preload)).toEqual([ + false, + ]) + expect(router.state.matches.at(-1)?.context).toEqual({ guard: 'loaded' }) + }) test('does not cache beforeLoad-only context across an unrelated navigation', async () => { const beforeLoad = vi.fn(({ preload }: { preload: boolean }) => ({ diff --git a/packages/router-core/tests/preload-public-cache-behavior.test.ts b/packages/router-core/tests/preload-public-cache-behavior.test.ts index 54acdca68c..d1eeb29a55 100644 --- a/packages/router-core/tests/preload-public-cache-behavior.test.ts +++ b/packages/router-core/tests/preload-public-cache-behavior.test.ts @@ -501,9 +501,7 @@ test.each([ const loader = vi.fn( ({ abortController }: { abortController: AbortController }) => { signals.push(abortController.signal) - return loader.mock.calls.length === 1 - ? oldLoaderGate - : 'new-generation' + return loader.mock.calls.length === 1 ? oldLoaderGate : 'new-generation' }, ) const rootRoute = new BaseRootRoute({}) 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 4c8f7f0b56..c43e57e61d 100644 --- a/packages/router-core/tests/public-preload-lane-contract.test.ts +++ b/packages/router-core/tests/public-preload-lane-contract.test.ts @@ -582,9 +582,8 @@ describe('client loading contracts', () => { await navigation await vi.waitFor(() => { expect( - router.state.matches.find( - (match) => match.routeId === parentRoute.id, - )?.loaderData, + router.state.matches.find((match) => match.routeId === parentRoute.id) + ?.loaderData, ).toBe('navigation refreshed data') }) @@ -1521,9 +1520,7 @@ describe('client loading contracts', () => { const router = createTestRouter({ routeTree: rootRoute.addChildren([ indexRoute, - parentRoute.addChildren([ - middleRoute.addChildren([childRoute]), - ]), + parentRoute.addChildren([middleRoute.addChildren([childRoute])]), targetRoute, ]), history: createMemoryHistory({ initialEntries: ['/'] }), diff --git a/packages/start-client-core/src/client-rpc/frame-decoder.ts b/packages/start-client-core/src/client-rpc/frame-decoder.ts index 263739fa13..2e7127e885 100644 --- a/packages/start-client-core/src/client-rpc/frame-decoder.ts +++ b/packages/start-client-core/src/client-rpc/frame-decoder.ts @@ -19,8 +19,7 @@ const textDecoder = new TextDecoder() /** Shared empty buffer for empty buffer case - avoids allocation */ const EMPTY_BUFFER = new Uint8Array(0) -const MAX_QUEUED_BYTES = - FRAME_HEADER_SIZE + MAX_FRAME_PAYLOAD_SIZE * 2 +const MAX_QUEUED_BYTES = FRAME_HEADER_SIZE + MAX_FRAME_PAYLOAD_SIZE * 2 type RawStreamState = [ stream: ReadableStream, diff --git a/packages/start-client-core/tests/frame-decoder.test.ts b/packages/start-client-core/tests/frame-decoder.test.ts index ab9cdbe364..a6a6940b8d 100644 --- a/packages/start-client-core/tests/frame-decoder.test.ts +++ b/packages/start-client-core/tests/frame-decoder.test.ts @@ -336,9 +336,7 @@ describe('frame-decoder', () => { }) const { getOrCreateStream, jsonChunks } = createFrameDecoder(input) - const received = await new Response( - getOrCreateStream(1), - ).arrayBuffer() + const received = await new Response(getOrCreateStream(1)).arrayBuffer() expect(received.byteLength).toBe(frameCount) await expect(jsonChunks.getReader().read()).resolves.toEqual({ done: true, diff --git a/packages/start-server-core/src/frame-protocol.ts b/packages/start-server-core/src/frame-protocol.ts index 1d4c8e63be..5d70ef6443 100644 --- a/packages/start-server-core/src/frame-protocol.ts +++ b/packages/start-server-core/src/frame-protocol.ts @@ -157,9 +157,7 @@ export function createMultiplexedStream( return } if (done) { - await write( - encodeFrame(FrameType.END, streamId, new Uint8Array()), - ) + await write(encodeFrame(FrameType.END, streamId, new Uint8Array())) return } diff --git a/packages/start-server-core/tests/createStartHandler.test.ts b/packages/start-server-core/tests/createStartHandler.test.ts index 447af79885..3ea3be756a 100644 --- a/packages/start-server-core/tests/createStartHandler.test.ts +++ b/packages/start-server-core/tests/createStartHandler.test.ts @@ -196,9 +196,7 @@ describe('createStartHandler SSR cleanup ownership', () => { const router = makeRouter() startMocks.router = router const cancel = vi.fn() - startMocks.serverFnResult = new Response( - new ReadableStream({ cancel }), - ) + startMocks.serverFnResult = new Response(new ReadableStream({ cancel })) startMocks.requestMiddleware = [ createMiddleware().server(async ({ next }) => { await next() @@ -425,9 +423,7 @@ describe('createStartHandler SSR cleanup ownership', () => { ] const requestController = new AbortController() const cancellation = new Error('request disconnected') - const response = await createStartHandler(() => - new Response('unused'), - )( + const response = await createStartHandler(() => new Response('unused'))( new Request('http://localhost/_serverFn/test', { headers: { 'x-tsr-serverFn': 'true' }, signal: requestController.signal, @@ -740,8 +736,8 @@ describe('createStartHandler HEAD responses', () => { router.isServer = true startMocks.router = router - const response = await createStartHandler(() => - new Response('must not render'), + const response = await createStartHandler( + () => new Response('must not render'), )(new Request('http://localhost/source', { method: 'HEAD' }), {}) expect(response.status).toBe(307) @@ -784,8 +780,8 @@ describe('createStartHandler HEAD responses', () => { headers: { Allow: 'POST' }, }) - const response = await createStartHandler(() => - new Response('must not render'), + const response = await createStartHandler( + () => new Response('must not render'), )( new Request('http://localhost/_serverFn/test', { method: 'HEAD', diff --git a/packages/start-server-core/tests/server-functions-handler.test.ts b/packages/start-server-core/tests/server-functions-handler.test.ts index 593f9284e0..ce79793a40 100644 --- a/packages/start-server-core/tests/server-functions-handler.test.ts +++ b/packages/start-server-core/tests/server-functions-handler.test.ts @@ -228,9 +228,7 @@ describe('server-functions-handler framed responses', () => { const response = await createServerFnResponse( pendingValues.map(({ promise }) => promise), ) - const largeValue = 'x'.repeat( - Math.floor(MAX_FRAME_PAYLOAD_SIZE / 2) + 1024, - ) + const largeValue = 'x'.repeat(Math.floor(MAX_FRAME_PAYLOAD_SIZE / 2) + 1024) for (const pendingValue of pendingValues) { pendingValue.resolve(largeValue)