Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions e2e/react-start/basic/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { Route as LayoutRouteImport } from './routes/_layout'
import { Route as SpecialCharsRouteRouteImport } from './routes/specialChars/route'
import { Route as SearchParamsRouteRouteImport } from './routes/search-params/route'
import { Route as NotFoundRouteRouteImport } from './routes/not-found/route'
import { Route as HydrationCappedAssetsRouteRouteImport } from './routes/hydration-capped-assets/route'
import { Route as IndexRouteImport } from './routes/index'
import { Route as UsersIndexRouteImport } from './routes/users.index'
import { Route as SearchParamsIndexRouteImport } from './routes/search-params/index'
Expand Down Expand Up @@ -53,6 +54,7 @@ import { Route as NotFoundViaLoaderRouteImport } from './routes/not-found/via-lo
import { Route as NotFoundViaBeforeLoadTargetRootRouteImport } from './routes/not-found/via-beforeLoad-target-root'
import { Route as NotFoundViaBeforeLoadRouteImport } from './routes/not-found/via-beforeLoad'
import { Route as MultiCookieRedirectTargetRouteImport } from './routes/multi-cookie-redirect/target'
import { Route as HydrationCappedAssetsChildRouteImport } from './routes/hydration-capped-assets/child'
import { Route as ApiUsersRouteImport } from './routes/api.users'
import { Route as LayoutLayout2RouteImport } from './routes/_layout/_layout-2'
import { Route as SpecialCharsMalformedRouteRouteImport } from './routes/specialChars/malformed/route'
Expand Down Expand Up @@ -165,6 +167,12 @@ const NotFoundRouteRoute = NotFoundRouteRouteImport.update({
path: '/not-found',
getParentRoute: () => rootRouteImport,
} as any)
const HydrationCappedAssetsRouteRoute =
HydrationCappedAssetsRouteRouteImport.update({
id: '/hydration-capped-assets',
path: '/hydration-capped-assets',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
Expand Down Expand Up @@ -305,6 +313,12 @@ const MultiCookieRedirectTargetRoute =
path: '/multi-cookie-redirect/target',
getParentRoute: () => rootRouteImport,
} as any)
const HydrationCappedAssetsChildRoute =
HydrationCappedAssetsChildRouteImport.update({
id: '/child',
path: '/child',
getParentRoute: () => HydrationCappedAssetsRouteRoute,
} as any)
const ApiUsersRoute = ApiUsersRouteImport.update({
id: '/api/users',
path: '/api/users',
Expand Down Expand Up @@ -448,6 +462,7 @@ const NotFoundDeepBCDRoute = NotFoundDeepBCDRouteImport.update({

export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/hydration-capped-assets': typeof HydrationCappedAssetsRouteRouteWithChildren
'/not-found': typeof NotFoundRouteRouteWithChildren
'/search-params': typeof SearchParamsRouteRouteWithChildren
'/specialChars': typeof SpecialCharsRouteRouteWithChildren
Expand All @@ -468,6 +483,7 @@ export interface FileRoutesByFullPath {
'/not-found/parent-boundary': typeof NotFoundParentBoundaryRouteRouteWithChildren
'/specialChars/malformed': typeof SpecialCharsMalformedRouteRouteWithChildren
'/api/users': typeof ApiUsersRouteWithChildren
'/hydration-capped-assets/child': typeof HydrationCappedAssetsChildRoute
'/multi-cookie-redirect/target': typeof MultiCookieRedirectTargetRoute
'/not-found/via-beforeLoad': typeof NotFoundViaBeforeLoadRoute
'/not-found/via-beforeLoad-target-root': typeof NotFoundViaBeforeLoadTargetRootRoute
Expand Down Expand Up @@ -518,6 +534,7 @@ export interface FileRoutesByFullPath {
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/hydration-capped-assets': typeof HydrationCappedAssetsRouteRouteWithChildren
'/specialChars': typeof SpecialCharsRouteRouteWithChildren
'/async-scripts': typeof AsyncScriptsRoute
'/client-only': typeof ClientOnlyRoute
Expand All @@ -531,6 +548,7 @@ export interface FileRoutesByTo {
'/type-only-reexport': typeof TypeOnlyReexportRoute
'/specialChars/malformed': typeof SpecialCharsMalformedRouteRouteWithChildren
'/api/users': typeof ApiUsersRouteWithChildren
'/hydration-capped-assets/child': typeof HydrationCappedAssetsChildRoute
'/multi-cookie-redirect/target': typeof MultiCookieRedirectTargetRoute
'/not-found/via-beforeLoad': typeof NotFoundViaBeforeLoadRoute
'/not-found/via-beforeLoad-target-root': typeof NotFoundViaBeforeLoadTargetRootRoute
Expand Down Expand Up @@ -580,6 +598,7 @@ export interface FileRoutesByTo {
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/hydration-capped-assets': typeof HydrationCappedAssetsRouteRouteWithChildren
'/not-found': typeof NotFoundRouteRouteWithChildren
'/search-params': typeof SearchParamsRouteRouteWithChildren
'/specialChars': typeof SpecialCharsRouteRouteWithChildren
Expand All @@ -602,6 +621,7 @@ export interface FileRoutesById {
'/specialChars/malformed': typeof SpecialCharsMalformedRouteRouteWithChildren
'/_layout/_layout-2': typeof LayoutLayout2RouteWithChildren
'/api/users': typeof ApiUsersRouteWithChildren
'/hydration-capped-assets/child': typeof HydrationCappedAssetsChildRoute
'/multi-cookie-redirect/target': typeof MultiCookieRedirectTargetRoute
'/not-found/via-beforeLoad': typeof NotFoundViaBeforeLoadRoute
'/not-found/via-beforeLoad-target-root': typeof NotFoundViaBeforeLoadTargetRootRoute
Expand Down Expand Up @@ -654,6 +674,7 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/hydration-capped-assets'
| '/not-found'
| '/search-params'
| '/specialChars'
Expand All @@ -674,6 +695,7 @@ export interface FileRouteTypes {
| '/not-found/parent-boundary'
| '/specialChars/malformed'
| '/api/users'
| '/hydration-capped-assets/child'
| '/multi-cookie-redirect/target'
| '/not-found/via-beforeLoad'
| '/not-found/via-beforeLoad-target-root'
Expand Down Expand Up @@ -724,6 +746,7 @@ export interface FileRouteTypes {
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/hydration-capped-assets'
| '/specialChars'
| '/async-scripts'
| '/client-only'
Expand All @@ -737,6 +760,7 @@ export interface FileRouteTypes {
| '/type-only-reexport'
| '/specialChars/malformed'
| '/api/users'
| '/hydration-capped-assets/child'
| '/multi-cookie-redirect/target'
| '/not-found/via-beforeLoad'
| '/not-found/via-beforeLoad-target-root'
Expand Down Expand Up @@ -785,6 +809,7 @@ export interface FileRouteTypes {
id:
| '__root__'
| '/'
| '/hydration-capped-assets'
| '/not-found'
| '/search-params'
| '/specialChars'
Expand All @@ -807,6 +832,7 @@ export interface FileRouteTypes {
| '/specialChars/malformed'
| '/_layout/_layout-2'
| '/api/users'
| '/hydration-capped-assets/child'
| '/multi-cookie-redirect/target'
| '/not-found/via-beforeLoad'
| '/not-found/via-beforeLoad-target-root'
Expand Down Expand Up @@ -858,6 +884,7 @@ export interface FileRouteTypes {
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
HydrationCappedAssetsRouteRoute: typeof HydrationCappedAssetsRouteRouteWithChildren
NotFoundRouteRoute: typeof NotFoundRouteRouteWithChildren
SearchParamsRouteRoute: typeof SearchParamsRouteRouteWithChildren
SpecialCharsRouteRoute: typeof SpecialCharsRouteRouteWithChildren
Expand Down Expand Up @@ -1005,6 +1032,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof NotFoundRouteRouteImport
parentRoute: typeof rootRouteImport
}
'/hydration-capped-assets': {
id: '/hydration-capped-assets'
path: '/hydration-capped-assets'
fullPath: '/hydration-capped-assets'
preLoaderRoute: typeof HydrationCappedAssetsRouteRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
Expand Down Expand Up @@ -1194,6 +1228,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof MultiCookieRedirectTargetRouteImport
parentRoute: typeof rootRouteImport
}
'/hydration-capped-assets/child': {
id: '/hydration-capped-assets/child'
path: '/child'
fullPath: '/hydration-capped-assets/child'
preLoaderRoute: typeof HydrationCappedAssetsChildRouteImport
parentRoute: typeof HydrationCappedAssetsRouteRoute
}
'/api/users': {
id: '/api/users'
path: '/api/users'
Expand Down Expand Up @@ -1379,6 +1420,20 @@ declare module '@tanstack/react-router' {
}
}

interface HydrationCappedAssetsRouteRouteChildren {
HydrationCappedAssetsChildRoute: typeof HydrationCappedAssetsChildRoute
}

const HydrationCappedAssetsRouteRouteChildren: HydrationCappedAssetsRouteRouteChildren =
{
HydrationCappedAssetsChildRoute: HydrationCappedAssetsChildRoute,
}

const HydrationCappedAssetsRouteRouteWithChildren =
HydrationCappedAssetsRouteRoute._addFileChildren(
HydrationCappedAssetsRouteRouteChildren,
)

interface NotFoundDeepBCRouteRouteChildren {
NotFoundDeepBCDRoute: typeof NotFoundDeepBCDRoute
}
Expand Down Expand Up @@ -1630,6 +1685,7 @@ const FooBarQuxHereRouteWithChildren = FooBarQuxHereRoute._addFileChildren(

const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
HydrationCappedAssetsRouteRoute: HydrationCappedAssetsRouteRouteWithChildren,
NotFoundRouteRoute: NotFoundRouteRouteWithChildren,
SearchParamsRouteRoute: SearchParamsRouteRouteWithChildren,
SpecialCharsRouteRoute: SpecialCharsRouteRouteWithChildren,
Expand Down
25 changes: 25 additions & 0 deletions e2e/react-start/basic/src/routes/hydration-capped-assets/child.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/hydration-capped-assets/child')({
loader: () => ({
title: 'child loader data',
}),
head: ({ loaderData }) => ({
meta: [
{
name: 'issue-7-child-head',
content: loaderData?.title ?? 'missing-loader-data',
},
],
}),
scripts: ({ loaderData }) => [
{
children: `window.__ISSUE_7_CHILD_SCRIPT = ${JSON.stringify(
loaderData?.title ?? 'missing-loader-data',
)}`,
},
],
component: () => (
<div data-testid="issue-7-child-route-component">Child route</div>
),
})
11 changes: 11 additions & 0 deletions e2e/react-start/basic/src/routes/hydration-capped-assets/route.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Outlet, createFileRoute, notFound } from '@tanstack/react-router'

export const Route = createFileRoute('/hydration-capped-assets')({
beforeLoad: () => {
throw notFound()
},
notFoundComponent: () => (
<div data-testid="issue-7-parent-not-found">Parent not found</div>
),
component: () => <Outlet />,
})
49 changes: 49 additions & 0 deletions e2e/react-start/basic/tests/hydration-capped-assets.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { expect } from '@playwright/test'
import { test } from '@tanstack/router-e2e-utils'
import { isPrerender } from './utils/isPrerender'
import { isSpaMode } from './utils/isSpaMode'

/**
* Issue 7: on a direct SSR hard load, the server can intentionally cap the
* committed match list at a parent notFound/error boundary. Hydration should not
* project head/scripts for the child route that was omitted from the server
* match list, because those assets can be added with missing or stale loader
* data before the follow-up client load enforces the same boundary.
*
* This Playwright repro uses the real React Start app, a real browser page load,
* the real SSR response, and normal client hydration. The parent route throws
* notFound from beforeLoad, while its child route defines a meta tag and inline
* script; neither child asset should appear after loading the capped parent
* boundary response.
*/

test.use({
whitelistErrors: [
'Failed to load resource: the server responded with a status of 404',
],
})

test.describe('SSR hydration capped route assets', () => {
test.skip(isSpaMode || isPrerender, 'SSR hydration repro only')

test('does not project assets for a child route omitted by the server boundary', async ({
page,
}) => {
await page.goto('/hydration-capped-assets/child')
await page.waitForLoadState('networkidle')

await expect(
page.getByTestId('issue-7-parent-not-found'),
).toBeInViewport()
await expect(
page.getByTestId('issue-7-child-route-component'),
).not.toBeInViewport()

await expect(page.locator('meta[name="issue-7-child-head"]')).toHaveCount(
0,
)
expect(
await page.evaluate(() => Boolean((window as any).__ISSUE_7_CHILD_SCRIPT)),
).toBe(false)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { afterEach, describe, expect, test, vi } from 'vitest'
import { createMemoryHistory } from '@tanstack/history'
import { BaseRootRoute, BaseRoute, createControlledPromise } from '../src'
import { createTestRouter } from './routerTestUtils'

/**
* Issue 1: boundary component preloads should not be blocked by an unrelated
* normal route component preload. A route load starts the normal component
* preload before the loader settles; if that loader throws, the error boundary
* asks to load only the route's errorComponent.
*
* This test uses a real client router load. It keeps the normal route component
* preload pending, resolves the errorComponent preload, and expects the route
* error state to be committed without waiting for the normal component chunk.
*/

const pendingGates: Array<ReturnType<typeof createControlledPromise<void>>> = []
const pendingLoads: Array<Promise<unknown>> = []

afterEach(async () => {
for (const gate of pendingGates) {
gate.resolve()
}

await Promise.allSettled(pendingLoads)

pendingGates.length = 0
pendingLoads.length = 0
})

describe('route boundary component preloads', () => {
test('errorComponent preload resolves without waiting for a pending route component preload', async () => {
const componentGate = createControlledPromise<void>()
const errorComponentGate = createControlledPromise<void>()
const routeError = new Error('loader failed')
let errorComponentPreloadCalls = 0
pendingGates.push(componentGate, errorComponentGate)

const SlowRouteComponent = Object.assign(() => null, {
preload: () => componentGate,
})
const ErrorBoundary = Object.assign(() => null, {
preload: () => {
errorComponentPreloadCalls++
return errorComponentGate
},
})

const rootRoute = new BaseRootRoute({})
const route = new BaseRoute({
getParentRoute: () => rootRoute,
path: '/chunked',
loader: () => {
throw routeError
},
component: SlowRouteComponent as any,
errorComponent: ErrorBoundary as any,
})
const router = createTestRouter({
routeTree: rootRoute.addChildren([route]),
history: createMemoryHistory({ initialEntries: ['/chunked'] }),
})

const loadPromise = router.load()
pendingLoads.push(loadPromise)

await vi.waitFor(() => expect(errorComponentPreloadCalls).toBe(1))
errorComponentGate.resolve()
await Promise.resolve()

const match = router.state.matches.find((item) => item.routeId === route.id)
expect(match?.status).toBe('error')
expect(match?.error).toBe(routeError)

componentGate.resolve()
await loadPromise
})
})
Loading
Loading