Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
f35f9c3
test: import edge-case reproductions from #7713
Sheraff Jul 3, 2026
8678c4d
fix: settle superseded loads with the navigation chain and let preloa…
Sheraff Jul 3, 2026
0bc62d0
fix(solid-router): render same-route pending UI from core publication…
Sheraff Jul 3, 2026
6e7c02a
fix(vue-router): tolerate settled loadPromise on pending matches and …
Sheraff Jul 3, 2026
a6cd9ba
fix: only cancel active matches with in-flight work in cancelMatches
Sheraff Jul 3, 2026
85bcb58
fix: keep client load errors observable instead of swallowing them
Sheraff Jul 3, 2026
5f86055
fix: preserve exiting success matches in cache at pending publication
Sheraff Jul 3, 2026
8d7c9f1
fix: make route asset projection failure semantics coherent across se…
Sheraff Jul 3, 2026
1f53606
fix: decouple boundary chunk loading from the whole-route preload and…
Sheraff Jul 3, 2026
2ac3031
feat: cache the successful ancestor prefix of a failed preload
Sheraff Jul 3, 2026
e154e90
refactor: share byte-identical twin helpers and trim dead server-load…
Sheraff Jul 3, 2026
ff13fb2
docs: document behavior changes in the match-loading changeset
Sheraff Jul 3, 2026
00108a4
fix: drop unnecessary type assertion in server asset projection
Sheraff Jul 3, 2026
a9e6ce2
fix: replay dehydrated notFound/error boundaries in the follow-up cli…
Sheraff Jul 3, 2026
142915c
ci: apply automated fixes
autofix-ci[bot] Jul 3, 2026
beed0d2
perf: avoid per-commit Set allocations in match cache reconciliation
Sheraff Jul 3, 2026
1be22be
fix: address self-review findings on the review branch
Sheraff Jul 4, 2026
43ff072
ci: apply automated fixes
autofix-ci[bot] Jul 4, 2026
9f79e98
fix: resolve CI type errors from PR self-review pass
Sheraff Jul 4, 2026
4917900
fix(react-router): repair stuck pending status after unobserved mount…
Sheraff Jul 4, 2026
01dc55e
fix: preserve the dehydrated marker when a rematched match keeps its …
Sheraff Jul 4, 2026
eb9a47f
ci: apply automated fixes
autofix-ci[bot] Jul 4, 2026
60eb597
test: pin 24 community-reported issues fixed by the match-loading wor…
Sheraff Jul 4, 2026
39e8fe5
fix(solid-router): render hydration display pending through the share…
Sheraff Jul 4, 2026
3ebb72e
ci: apply automated fixes
autofix-ci[bot] Jul 4, 2026
3867af1
fix: drop unnecessary type assertions in issue-7638 regression test
Sheraff Jul 4, 2026
69cf2fb
ci: apply automated fixes
autofix-ci[bot] Jul 4, 2026
e1f2a7c
docs: reconcile INTERNALS.md with the final ready-branch semantics
Sheraff Jul 4, 2026
77ec643
docs: cover the remaining behavior changes in the changeset
Sheraff Jul 4, 2026
354d659
fix: address delta-review findings
Sheraff Jul 4, 2026
c9efc46
refactor: single-shot foreground wait in the preload borrow protocol
Sheraff Jul 4, 2026
4d9e0e7
refactor: remove unnecessary mechanism across the match-loading pipeline
Sheraff Jul 4, 2026
d5a5910
ci: apply automated fixes
autofix-ci[bot] Jul 4, 2026
022395e
refactor(react-router): one deterministic emitter for the load lifecycle
Sheraff Jul 4, 2026
e9deed2
refactor: delete hedges against invariants the model already guarante…
Sheraff Jul 4, 2026
a70abdd
refactor: delete hedges against invariants the model already guarante…
Sheraff Jul 4, 2026
ccf92bc
ci: apply automated fixes
autofix-ci[bot] Jul 4, 2026
bcc23d8
feat: navigations adopt in-flight preload loader runs (data-only)
Sheraff Jul 4, 2026
6dcb32c
ci: apply automated fixes
autofix-ci[bot] Jul 4, 2026
f89dc95
refactor: discover preload donors at loader time instead of threading…
Sheraff Jul 4, 2026
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
9 changes: 9 additions & 0 deletions .changeset/violet-poets-wait.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,12 @@ Fix match loading consistency across client navigation, preloading, background r
- SSR hydration now handles dehydrated and data-only matches in a dedicated hydration pass, restoring route context and assets without rerunning client loaders.
- Server loading now has a dedicated path for status codes, redirects, notFound and error responses.
- React, Solid, and Vue match rendering now consume the simplified match readiness model, including aborted loader errors, SSR/data-only pending fallbacks, and stale render snapshots.
- Awaiting `router.load()` or `router.invalidate()` now settles only when the whole navigation chain settles, including superseded and redirected loads; preloads that borrow a pending-published foreground match wait for its commit instead of being dropped.
- A navigation that starts while a hover/intent preload for the same route is in flight adopts the preload's loader run once it succeeds — the loader executes once, like before. Adoption is data-only: the navigation always runs its own `beforeLoad` (with `preload: false` semantics), and a preload that ends in a redirect, notFound, or error is never adopted — the navigation runs its own loader instead, so preload-flavored control flow can no longer leak into navigations (the old implementation could land you on a preload-computed redirect). Concurrent preloads of the same route also share one loader run.
- Preloads now cache the successful ancestor prefix even when a descendant throws an error or `notFound`, so repeated hovers do not re-run expensive ancestor loaders.
- Route chunk (lazy import) failures now run the normal route failure lifecycle: `onError` fires and thrown redirects/notFounds are honored. Error/notFound boundary chunks load independently of the route's other component chunks, and failed chunk preloads are retried on the next load instead of being cached forever.
- Server rendering keeps route `headers()` when a decorative `head()`/`scripts()` hook fails (async `headers()` are always awaited), background reloads never publish fresh `loaderData` whose asset projection failed, and hydration no longer executes `head()`/`scripts()` for matches the server omitted (`ssr: false` or a server-side error/notFound boundary). The follow-up client load after hydration replays a server-committed notFound/error boundary, so routes the server omitted are not loaded or asset-projected client-side.
- With `pendingMs: 0`, the pending fallback is published synchronously on bare initial loads (nothing rendered yet) instead of after a macrotask, eliminating the blank first frame.
- Starting a navigation no longer aborts the `abortController` of settled success matches that stay mounted — deferred/streamed data tied to a stay-match's loader signal survives child navigations and `invalidate()`. Matches with in-flight `beforeLoad`/loader work are still cancelled.
- Fresh data for routes you navigate away from survives pending publication: if a navigation is superseded (e.g. back button) after its pending UI was published, the exited matches are served from cache instead of re-running their loaders.
- Behavior change (Vue): `useMatch({ from })` no longer suppresses its missing-match invariant during a navigation whose destination includes `from` before that navigation's first commit — it throws (or returns `undefined` when `shouldThrow: false`) exactly as it does at idle, matching React.
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: 'capped-assets-child-head',
content: loaderData?.title ?? 'missing-loader-data',
},
],
}),
scripts: ({ loaderData }) => [
{
children: `window.__CAPPED_ASSETS_CHILD_SCRIPT = ${JSON.stringify(
loaderData?.title ?? 'missing-loader-data',
)}`,
},
],
component: () => (
<div data-testid="capped-assets-child-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="capped-assets-parent-not-found">Parent not found</div>
),
component: () => <Outlet />,
})
1 change: 1 addition & 0 deletions e2e/react-start/basic/start-mode-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export function getStartModeConfig() {
'/redirect',
'/i-do-not-exist',
'/not-found',
'/hydration-capped-assets',
'/primitive-beforeload-error',
'/specialChars/search',
'/specialChars/hash',
Expand Down
51 changes: 51 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,51 @@
import { expect } from '@playwright/test'
import { test } from '@tanstack/router-e2e-utils'
import { isPrerender } from './utils/isPrerender'
import { isSpaMode } from './utils/isSpaMode'

/**
* 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('capped-assets-parent-not-found'),
).toBeInViewport()
await expect(
page.getByTestId('capped-assets-child-component'),
).not.toBeInViewport()

await expect(
page.locator('meta[name="capped-assets-child-head"]'),
).toHaveCount(0)
expect(
await page.evaluate(() =>
Boolean((window as any).__CAPPED_ASSETS_CHILD_SCRIPT),
),
).toBe(false)
})
})
Loading
Loading