diff --git a/e2e/react-start/basic/src/routeTree.gen.ts b/e2e/react-start/basic/src/routeTree.gen.ts
index e29bb26d3ce..5f02b3243c3 100644
--- a/e2e/react-start/basic/src/routeTree.gen.ts
+++ b/e2e/react-start/basic/src/routeTree.gen.ts
@@ -33,7 +33,9 @@ import { Route as SearchParamsLoaderThrowsRedirectRouteImport } from './routes/s
import { Route as SearchParamsDefaultRouteImport } from './routes/search-params/default'
import { Route as RedirectTargetRouteImport } from './routes/redirect/$target'
import { Route as PostsPostIdRouteImport } from './routes/posts.$postId'
+import { Route as NotFoundViaLoaderWithContextRouteImport } from './routes/not-found/via-loader-with-context'
import { Route as NotFoundViaLoaderRouteImport } from './routes/not-found/via-loader'
+import { Route as NotFoundViaHeadRouteImport } from './routes/not-found/via-head'
import { Route as NotFoundViaBeforeLoadRouteImport } from './routes/not-found/via-beforeLoad'
import { Route as ApiUsersRouteImport } from './routes/api.users'
import { Route as LayoutLayout2RouteImport } from './routes/_layout/_layout-2'
@@ -164,11 +166,22 @@ const PostsPostIdRoute = PostsPostIdRouteImport.update({
path: '/$postId',
getParentRoute: () => PostsRoute,
} as any)
+const NotFoundViaLoaderWithContextRoute =
+ NotFoundViaLoaderWithContextRouteImport.update({
+ id: '/via-loader-with-context',
+ path: '/via-loader-with-context',
+ getParentRoute: () => NotFoundRouteRoute,
+ } as any)
const NotFoundViaLoaderRoute = NotFoundViaLoaderRouteImport.update({
id: '/via-loader',
path: '/via-loader',
getParentRoute: () => NotFoundRouteRoute,
} as any)
+const NotFoundViaHeadRoute = NotFoundViaHeadRouteImport.update({
+ id: '/via-head',
+ path: '/via-head',
+ getParentRoute: () => NotFoundRouteRoute,
+} as any)
const NotFoundViaBeforeLoadRoute = NotFoundViaBeforeLoadRouteImport.update({
id: '/via-beforeLoad',
path: '/via-beforeLoad',
@@ -272,7 +285,9 @@ export interface FileRoutesByFullPath {
'/대한민국': typeof Char45824Char54620Char48124Char44397Route
'/api/users': typeof ApiUsersRouteWithChildren
'/not-found/via-beforeLoad': typeof NotFoundViaBeforeLoadRoute
+ '/not-found/via-head': typeof NotFoundViaHeadRoute
'/not-found/via-loader': typeof NotFoundViaLoaderRoute
+ '/not-found/via-loader-with-context': typeof NotFoundViaLoaderWithContextRoute
'/posts/$postId': typeof PostsPostIdRoute
'/redirect/$target': typeof RedirectTargetRouteWithChildren
'/search-params/default': typeof SearchParamsDefaultRoute
@@ -307,7 +322,9 @@ export interface FileRoutesByTo {
'/대한민국': typeof Char45824Char54620Char48124Char44397Route
'/api/users': typeof ApiUsersRouteWithChildren
'/not-found/via-beforeLoad': typeof NotFoundViaBeforeLoadRoute
+ '/not-found/via-head': typeof NotFoundViaHeadRoute
'/not-found/via-loader': typeof NotFoundViaLoaderRoute
+ '/not-found/via-loader-with-context': typeof NotFoundViaLoaderWithContextRoute
'/posts/$postId': typeof PostsPostIdRoute
'/search-params/default': typeof SearchParamsDefaultRoute
'/search-params/loader-throws-redirect': typeof SearchParamsLoaderThrowsRedirectRoute
@@ -347,7 +364,9 @@ export interface FileRoutesById {
'/_layout/_layout-2': typeof LayoutLayout2RouteWithChildren
'/api/users': typeof ApiUsersRouteWithChildren
'/not-found/via-beforeLoad': typeof NotFoundViaBeforeLoadRoute
+ '/not-found/via-head': typeof NotFoundViaHeadRoute
'/not-found/via-loader': typeof NotFoundViaLoaderRoute
+ '/not-found/via-loader-with-context': typeof NotFoundViaLoaderWithContextRoute
'/posts/$postId': typeof PostsPostIdRoute
'/redirect/$target': typeof RedirectTargetRouteWithChildren
'/search-params/default': typeof SearchParamsDefaultRoute
@@ -389,7 +408,9 @@ export interface FileRouteTypes {
| '/대한민국'
| '/api/users'
| '/not-found/via-beforeLoad'
+ | '/not-found/via-head'
| '/not-found/via-loader'
+ | '/not-found/via-loader-with-context'
| '/posts/$postId'
| '/redirect/$target'
| '/search-params/default'
@@ -424,7 +445,9 @@ export interface FileRouteTypes {
| '/대한민국'
| '/api/users'
| '/not-found/via-beforeLoad'
+ | '/not-found/via-head'
| '/not-found/via-loader'
+ | '/not-found/via-loader-with-context'
| '/posts/$postId'
| '/search-params/default'
| '/search-params/loader-throws-redirect'
@@ -463,7 +486,9 @@ export interface FileRouteTypes {
| '/_layout/_layout-2'
| '/api/users'
| '/not-found/via-beforeLoad'
+ | '/not-found/via-head'
| '/not-found/via-loader'
+ | '/not-found/via-loader-with-context'
| '/posts/$postId'
| '/redirect/$target'
| '/search-params/default'
@@ -666,6 +691,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PostsPostIdRouteImport
parentRoute: typeof PostsRoute
}
+ '/not-found/via-loader-with-context': {
+ id: '/not-found/via-loader-with-context'
+ path: '/via-loader-with-context'
+ fullPath: '/not-found/via-loader-with-context'
+ preLoaderRoute: typeof NotFoundViaLoaderWithContextRouteImport
+ parentRoute: typeof NotFoundRouteRoute
+ }
'/not-found/via-loader': {
id: '/not-found/via-loader'
path: '/via-loader'
@@ -673,6 +705,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof NotFoundViaLoaderRouteImport
parentRoute: typeof NotFoundRouteRoute
}
+ '/not-found/via-head': {
+ id: '/not-found/via-head'
+ path: '/via-head'
+ fullPath: '/not-found/via-head'
+ preLoaderRoute: typeof NotFoundViaHeadRouteImport
+ parentRoute: typeof NotFoundRouteRoute
+ }
'/not-found/via-beforeLoad': {
id: '/not-found/via-beforeLoad'
path: '/via-beforeLoad'
@@ -797,13 +836,17 @@ declare module '@tanstack/react-router' {
interface NotFoundRouteRouteChildren {
NotFoundViaBeforeLoadRoute: typeof NotFoundViaBeforeLoadRoute
+ NotFoundViaHeadRoute: typeof NotFoundViaHeadRoute
NotFoundViaLoaderRoute: typeof NotFoundViaLoaderRoute
+ NotFoundViaLoaderWithContextRoute: typeof NotFoundViaLoaderWithContextRoute
NotFoundIndexRoute: typeof NotFoundIndexRoute
}
const NotFoundRouteRouteChildren: NotFoundRouteRouteChildren = {
NotFoundViaBeforeLoadRoute: NotFoundViaBeforeLoadRoute,
+ NotFoundViaHeadRoute: NotFoundViaHeadRoute,
NotFoundViaLoaderRoute: NotFoundViaLoaderRoute,
+ NotFoundViaLoaderWithContextRoute: NotFoundViaLoaderWithContextRoute,
NotFoundIndexRoute: NotFoundIndexRoute,
}
diff --git a/e2e/react-start/basic/src/routes/not-found/index.tsx b/e2e/react-start/basic/src/routes/not-found/index.tsx
index e754f83c74b..48ec1ef90c6 100644
--- a/e2e/react-start/basic/src/routes/not-found/index.tsx
+++ b/e2e/react-start/basic/src/routes/not-found/index.tsx
@@ -25,6 +25,26 @@ export const Route = createFileRoute('/not-found/')({
via-loader
+
+
+ via-head
+
+
+
+
+ via-loader-with-context
+
+
)
},
diff --git a/e2e/react-start/basic/src/routes/not-found/via-head.tsx b/e2e/react-start/basic/src/routes/not-found/via-head.tsx
new file mode 100644
index 00000000000..7cd09f9fa31
--- /dev/null
+++ b/e2e/react-start/basic/src/routes/not-found/via-head.tsx
@@ -0,0 +1,23 @@
+import { createFileRoute, notFound } from '@tanstack/react-router'
+
+export const Route = createFileRoute('/not-found/via-head')({
+ head: () => {
+ throw notFound()
+ },
+ component: RouteComponent,
+ notFoundComponent: () => {
+ return (
+
+ Not Found "/not-found/via-head"!
+
+ )
+ },
+})
+
+function RouteComponent() {
+ return (
+
+ Hello "/not-found/via-head"!
+
+ )
+}
diff --git a/e2e/react-start/basic/src/routes/not-found/via-loader-with-context.tsx b/e2e/react-start/basic/src/routes/not-found/via-loader-with-context.tsx
new file mode 100644
index 00000000000..6921b419909
--- /dev/null
+++ b/e2e/react-start/basic/src/routes/not-found/via-loader-with-context.tsx
@@ -0,0 +1,29 @@
+import { createFileRoute, notFound, useRouteContext } from '@tanstack/react-router'
+
+export const Route = createFileRoute('/not-found/via-loader-with-context')({
+ beforeLoad: () => {
+ return {
+ fool: 'of a Took'
+ }
+ },
+ loader: () => {
+ throw notFound()
+ },
+ component: RouteComponent,
+ notFoundComponent: () => {
+ const context = useRouteContext({ strict: false })
+ return (
+
+ {`Hello you fool ${context.fool}`}
+
+ )
+ },
+})
+
+function RouteComponent() {
+ return (
+
+ Hello "/not-found/via-loader-with-context"!
+
+ )
+}
diff --git a/e2e/react-start/basic/tests/not-found.spec.ts b/e2e/react-start/basic/tests/not-found.spec.ts
index bde0a1a02a3..9282e5dc5e6 100644
--- a/e2e/react-start/basic/tests/not-found.spec.ts
+++ b/e2e/react-start/basic/tests/not-found.spec.ts
@@ -9,6 +9,7 @@ const combinate = (combinateImport as any).default as typeof combinateImport
test.use({
whitelistErrors: [
/Failed to load resource: the server responded with a status of 404/,
+ 'Error during route context hydration: {isNotFound: true}',
],
})
test.describe('not-found', () => {
@@ -24,8 +25,7 @@ test.describe('not-found', () => {
test.describe('throw notFound()', () => {
const navigationTestMatrix = combinate({
- // TODO beforeLoad!
- thrower: [/* 'beforeLoad',*/ 'loader'] as const,
+ thrower: ['beforeLoad', 'head', 'loader', 'loader-with-context'] as const,
preload: [false, true] as const,
})
@@ -55,9 +55,7 @@ test.describe('not-found', () => {
})
})
const directVisitTestMatrix = combinate({
- // TODO beforeLoad!
-
- thrower: [/* 'beforeLoad',*/ 'loader'] as const,
+ thrower: ['beforeLoad', 'head', 'loader', 'loader-with-context'] as const,
})
directVisitTestMatrix.forEach(({ thrower }) => {
diff --git a/e2e/react-start/basic/vite.config.ts b/e2e/react-start/basic/vite.config.ts
index c34468b880f..b24d3461d18 100644
--- a/e2e/react-start/basic/vite.config.ts
+++ b/e2e/react-start/basic/vite.config.ts
@@ -20,7 +20,9 @@ const prerenderConfiguration = {
'/redirect',
'/i-do-not-exist',
'/not-found/via-beforeLoad',
+ '/not-found/via-head',
'/not-found/via-loader',
+ '/not-found/via-loader-with-context',
].some((p) => page.path.includes(p)),
maxRedirects: 100,
}
diff --git a/e2e/solid-start/basic/src/routeTree.gen.ts b/e2e/solid-start/basic/src/routeTree.gen.ts
index aac25901997..6307fee28c3 100644
--- a/e2e/solid-start/basic/src/routeTree.gen.ts
+++ b/e2e/solid-start/basic/src/routeTree.gen.ts
@@ -31,7 +31,9 @@ import { Route as SearchParamsLoaderThrowsRedirectRouteImport } from './routes/s
import { Route as SearchParamsDefaultRouteImport } from './routes/search-params/default'
import { Route as RedirectTargetRouteImport } from './routes/redirect/$target'
import { Route as PostsPostIdRouteImport } from './routes/posts.$postId'
+import { Route as NotFoundViaLoaderWithContextRouteImport } from './routes/not-found/via-loader-with-context'
import { Route as NotFoundViaLoaderRouteImport } from './routes/not-found/via-loader'
+import { Route as NotFoundViaHeadRouteImport } from './routes/not-found/via-head'
import { Route as NotFoundViaBeforeLoadRouteImport } from './routes/not-found/via-beforeLoad'
import { Route as ApiUsersRouteImport } from './routes/api/users'
import { Route as LayoutLayout2RouteImport } from './routes/_layout/_layout-2'
@@ -158,11 +160,22 @@ const PostsPostIdRoute = PostsPostIdRouteImport.update({
path: '/$postId',
getParentRoute: () => PostsRoute,
} as any)
+const NotFoundViaLoaderWithContextRoute =
+ NotFoundViaLoaderWithContextRouteImport.update({
+ id: '/via-loader-with-context',
+ path: '/via-loader-with-context',
+ getParentRoute: () => NotFoundRouteRoute,
+ } as any)
const NotFoundViaLoaderRoute = NotFoundViaLoaderRouteImport.update({
id: '/via-loader',
path: '/via-loader',
getParentRoute: () => NotFoundRouteRoute,
} as any)
+const NotFoundViaHeadRoute = NotFoundViaHeadRouteImport.update({
+ id: '/via-head',
+ path: '/via-head',
+ getParentRoute: () => NotFoundRouteRoute,
+} as any)
const NotFoundViaBeforeLoadRoute = NotFoundViaBeforeLoadRouteImport.update({
id: '/via-beforeLoad',
path: '/via-beforeLoad',
@@ -252,7 +265,9 @@ export interface FileRoutesByFullPath {
'/대한민국': typeof Char45824Char54620Char48124Char44397Route
'/api/users': typeof ApiUsersRouteWithChildren
'/not-found/via-beforeLoad': typeof NotFoundViaBeforeLoadRoute
+ '/not-found/via-head': typeof NotFoundViaHeadRoute
'/not-found/via-loader': typeof NotFoundViaLoaderRoute
+ '/not-found/via-loader-with-context': typeof NotFoundViaLoaderWithContextRoute
'/posts/$postId': typeof PostsPostIdRoute
'/redirect/$target': typeof RedirectTargetRouteWithChildren
'/search-params/default': typeof SearchParamsDefaultRoute
@@ -285,7 +300,9 @@ export interface FileRoutesByTo {
'/대한민국': typeof Char45824Char54620Char48124Char44397Route
'/api/users': typeof ApiUsersRouteWithChildren
'/not-found/via-beforeLoad': typeof NotFoundViaBeforeLoadRoute
+ '/not-found/via-head': typeof NotFoundViaHeadRoute
'/not-found/via-loader': typeof NotFoundViaLoaderRoute
+ '/not-found/via-loader-with-context': typeof NotFoundViaLoaderWithContextRoute
'/posts/$postId': typeof PostsPostIdRoute
'/search-params/default': typeof SearchParamsDefaultRoute
'/search-params/loader-throws-redirect': typeof SearchParamsLoaderThrowsRedirectRoute
@@ -324,7 +341,9 @@ export interface FileRoutesById {
'/_layout/_layout-2': typeof LayoutLayout2RouteWithChildren
'/api/users': typeof ApiUsersRouteWithChildren
'/not-found/via-beforeLoad': typeof NotFoundViaBeforeLoadRoute
+ '/not-found/via-head': typeof NotFoundViaHeadRoute
'/not-found/via-loader': typeof NotFoundViaLoaderRoute
+ '/not-found/via-loader-with-context': typeof NotFoundViaLoaderWithContextRoute
'/posts/$postId': typeof PostsPostIdRoute
'/redirect/$target': typeof RedirectTargetRouteWithChildren
'/search-params/default': typeof SearchParamsDefaultRoute
@@ -363,7 +382,9 @@ export interface FileRouteTypes {
| '/대한민국'
| '/api/users'
| '/not-found/via-beforeLoad'
+ | '/not-found/via-head'
| '/not-found/via-loader'
+ | '/not-found/via-loader-with-context'
| '/posts/$postId'
| '/redirect/$target'
| '/search-params/default'
@@ -396,7 +417,9 @@ export interface FileRouteTypes {
| '/대한민국'
| '/api/users'
| '/not-found/via-beforeLoad'
+ | '/not-found/via-head'
| '/not-found/via-loader'
+ | '/not-found/via-loader-with-context'
| '/posts/$postId'
| '/search-params/default'
| '/search-params/loader-throws-redirect'
@@ -434,7 +457,9 @@ export interface FileRouteTypes {
| '/_layout/_layout-2'
| '/api/users'
| '/not-found/via-beforeLoad'
+ | '/not-found/via-head'
| '/not-found/via-loader'
+ | '/not-found/via-loader-with-context'
| '/posts/$postId'
| '/redirect/$target'
| '/search-params/default'
@@ -633,6 +658,13 @@ declare module '@tanstack/solid-router' {
preLoaderRoute: typeof PostsPostIdRouteImport
parentRoute: typeof PostsRoute
}
+ '/not-found/via-loader-with-context': {
+ id: '/not-found/via-loader-with-context'
+ path: '/via-loader-with-context'
+ fullPath: '/not-found/via-loader-with-context'
+ preLoaderRoute: typeof NotFoundViaLoaderWithContextRouteImport
+ parentRoute: typeof NotFoundRouteRoute
+ }
'/not-found/via-loader': {
id: '/not-found/via-loader'
path: '/via-loader'
@@ -640,6 +672,13 @@ declare module '@tanstack/solid-router' {
preLoaderRoute: typeof NotFoundViaLoaderRouteImport
parentRoute: typeof NotFoundRouteRoute
}
+ '/not-found/via-head': {
+ id: '/not-found/via-head'
+ path: '/via-head'
+ fullPath: '/not-found/via-head'
+ preLoaderRoute: typeof NotFoundViaHeadRouteImport
+ parentRoute: typeof NotFoundRouteRoute
+ }
'/not-found/via-beforeLoad': {
id: '/not-found/via-beforeLoad'
path: '/via-beforeLoad'
@@ -743,13 +782,17 @@ declare module '@tanstack/solid-router' {
interface NotFoundRouteRouteChildren {
NotFoundViaBeforeLoadRoute: typeof NotFoundViaBeforeLoadRoute
+ NotFoundViaHeadRoute: typeof NotFoundViaHeadRoute
NotFoundViaLoaderRoute: typeof NotFoundViaLoaderRoute
+ NotFoundViaLoaderWithContextRoute: typeof NotFoundViaLoaderWithContextRoute
NotFoundIndexRoute: typeof NotFoundIndexRoute
}
const NotFoundRouteRouteChildren: NotFoundRouteRouteChildren = {
NotFoundViaBeforeLoadRoute: NotFoundViaBeforeLoadRoute,
+ NotFoundViaHeadRoute: NotFoundViaHeadRoute,
NotFoundViaLoaderRoute: NotFoundViaLoaderRoute,
+ NotFoundViaLoaderWithContextRoute: NotFoundViaLoaderWithContextRoute,
NotFoundIndexRoute: NotFoundIndexRoute,
}
diff --git a/e2e/solid-start/basic/src/routes/not-found/index.tsx b/e2e/solid-start/basic/src/routes/not-found/index.tsx
index 34c8ef61469..712e5cf44cd 100644
--- a/e2e/solid-start/basic/src/routes/not-found/index.tsx
+++ b/e2e/solid-start/basic/src/routes/not-found/index.tsx
@@ -25,6 +25,26 @@ export const Route = createFileRoute('/not-found/')({
via-loader
+
+
+ via-head
+
+
+
+
+ via-loader-with-context
+
+
)
},
diff --git a/e2e/solid-start/basic/src/routes/not-found/via-head.tsx b/e2e/solid-start/basic/src/routes/not-found/via-head.tsx
new file mode 100644
index 00000000000..51806db45e1
--- /dev/null
+++ b/e2e/solid-start/basic/src/routes/not-found/via-head.tsx
@@ -0,0 +1,23 @@
+import { createFileRoute, notFound } from '@tanstack/solid-router'
+
+export const Route = createFileRoute('/not-found/via-head')({
+ head: () => {
+ throw notFound()
+ },
+ component: RouteComponent,
+ notFoundComponent: () => {
+ return (
+
+ Not Found "/not-found/via-head"!
+
+ )
+ },
+})
+
+function RouteComponent() {
+ return (
+
+ Hello "/not-found/via-head"!
+
+ )
+}
diff --git a/e2e/solid-start/basic/src/routes/not-found/via-loader-with-context.tsx b/e2e/solid-start/basic/src/routes/not-found/via-loader-with-context.tsx
new file mode 100644
index 00000000000..2abe6d2f81c
--- /dev/null
+++ b/e2e/solid-start/basic/src/routes/not-found/via-loader-with-context.tsx
@@ -0,0 +1,29 @@
+import { createFileRoute, notFound, useRouteContext } from '@tanstack/solid-router'
+
+export const Route = createFileRoute('/not-found/via-loader-with-context')({
+ beforeLoad: () => {
+ return {
+ fool: 'of a Took'
+ }
+ },
+ loader: () => {
+ throw notFound()
+ },
+ component: RouteComponent,
+ notFoundComponent: () => {
+ const context = useRouteContext({ strict: false })
+ return (
+
+ {`Hello you fool ${context().fool}`}
+
+ )
+ },
+})
+
+function RouteComponent() {
+ return (
+
+ Hello "/not-found/via-loader-with-context"!
+
+ )
+}
diff --git a/e2e/solid-start/basic/tests/not-found.spec.ts b/e2e/solid-start/basic/tests/not-found.spec.ts
index e1a69eefc65..a0deeed1cc0 100644
--- a/e2e/solid-start/basic/tests/not-found.spec.ts
+++ b/e2e/solid-start/basic/tests/not-found.spec.ts
@@ -9,6 +9,7 @@ const combinate = (combinateImport as any).default as typeof combinateImport
test.use({
whitelistErrors: [
/Failed to load resource: the server responded with a status of 404/,
+ 'Error during route context hydration: {isNotFound: true}',
],
})
test.describe('not-found', () => {
@@ -25,7 +26,7 @@ test.describe('not-found', () => {
test.describe('throw notFound()', () => {
const navigationTestMatrix = combinate({
// TODO beforeLoad!
- thrower: [/* 'beforeLoad',*/ 'loader'] as const,
+ thrower: [/* 'beforeLoad',*/ 'head', 'loader', 'loader-with-context'] as const,
preload: [false, true] as const,
})
@@ -55,8 +56,7 @@ test.describe('not-found', () => {
})
const directVisitTestMatrix = combinate({
// TODO beforeLoad!
-
- thrower: [/* 'beforeLoad',*/ 'loader'] as const,
+ thrower: [/* 'beforeLoad',*/ 'head', 'loader', 'loader-with-context'] as const,
})
directVisitTestMatrix.forEach(({ thrower }) => {
diff --git a/packages/react-router/tests/store-updates-during-navigation.test.tsx b/packages/react-router/tests/store-updates-during-navigation.test.tsx
index d68373ff4ee..0ab5f2812c0 100644
--- a/packages/react-router/tests/store-updates-during-navigation.test.tsx
+++ b/packages/react-router/tests/store-updates-during-navigation.test.tsx
@@ -197,7 +197,7 @@ describe("Store doesn't update *too many* times during navigation", () => {
// This number should be as small as possible to minimize the amount of work
// that needs to be done during a navigation.
// Any change that increases this number should be investigated.
- expect(updates).toBe(7)
+ expect(updates).toBe(6)
})
test('hover preload, then navigate, w/ async loaders', async () => {
diff --git a/packages/router-core/src/Matches.ts b/packages/router-core/src/Matches.ts
index d5e8be52a35..89e7d8cd724 100644
--- a/packages/router-core/src/Matches.ts
+++ b/packages/router-core/src/Matches.ts
@@ -148,6 +148,9 @@ export interface RouteMatch<
displayPendingPromise?: Promise
minPendingPromise?: ControlledPromise
dehydrated?: boolean
+ headExecuted?: boolean
+ lastHeadLoaderData?: TLoaderData
+ lastHeadContext?: TAllContext
}
loaderData?: TLoaderData
/** @internal */
diff --git a/packages/router-core/src/load-matches.ts b/packages/router-core/src/load-matches.ts
index 0ea5068ad0f..4e48c532610 100644
--- a/packages/router-core/src/load-matches.ts
+++ b/packages/router-core/src/load-matches.ts
@@ -53,9 +53,25 @@ const resolvePreload = (inner: InnerLoadContext, matchId: string): boolean => {
const _handleNotFound = (inner: InnerLoadContext, err: NotFoundError) => {
// Find the route that should handle the not found error
// First check if a specific route is requested to show the error
- const routeCursor =
+ let routeCursor =
inner.router.routesById[err.routeId ?? ''] ?? inner.router.routeTree
+ // For BEFORE_LOAD errors, find a parent route with a notFoundComponent that can handle the error
+ if ((err as any).routerCode === 'BEFORE_LOAD' && routeCursor.parentRoute) {
+ while (routeCursor.parentRoute && !routeCursor.options.notFoundComponent) {
+ routeCursor = routeCursor.parentRoute
+ }
+ // Update the error to point to the error handling route
+ err.routeId = routeCursor.id
+
+ if (inner.router.isServer) {
+ const loadPromise = loadRouteChunk(routeCursor)
+ if (loadPromise) {
+ loadPromise.then(() => (routeCursor.options.notFoundComponent)?.preload?.())
+ }
+ }
+ }
+
// Ensure a NotFoundComponent exists on the route
if (
!routeCursor.options.notFoundComponent &&
@@ -75,6 +91,7 @@ const _handleNotFound = (inner: InnerLoadContext, err: NotFoundError) => {
// Find the match for this route
const matchForRoute = inner.matches.find((m) => m.routeId === routeCursor.id)
+
invariant(matchForRoute, 'Could not find match for route: ' + routeCursor.id)
// Assign the error to the match - using non-null assertion since we've checked with invariant
@@ -84,11 +101,6 @@ const _handleNotFound = (inner: InnerLoadContext, err: NotFoundError) => {
error: err,
isFetching: false,
}))
-
- if ((err as any).routerCode === 'BEFORE_LOAD' && routeCursor.parentRoute) {
- err.routeId = routeCursor.parentRoute.id
- _handleNotFound(inner, err)
- }
}
const handleRedirectAndNotFound = (
@@ -522,9 +534,16 @@ const executeHead = (
if (!match) {
return
}
+
+ // Skip if already executed with the same loaderData
+ if (match._nonReactive.headExecuted && match._nonReactive.lastHeadLoaderData === match.loaderData && match._nonReactive.lastHeadContext === match.context) {
+ return
+ }
+
if (!route.options.head && !route.options.scripts && !route.options.headers) {
return
}
+
const assetContext = {
matches: inner.matches,
match,
@@ -542,6 +561,11 @@ const executeHead = (
const headScripts = headFnContent?.scripts
const styles = headFnContent?.styles
+ // Mark as executed with current loaderData
+ match._nonReactive.headExecuted = true
+ match._nonReactive.lastHeadLoaderData = match.loaderData
+ match._nonReactive.lastHeadContext = match.context
+
return {
meta,
links,
@@ -728,14 +752,6 @@ const loadRouteMatch = async (
if (shouldSkipLoader(inner, matchId)) {
if (inner.router.isServer) {
- const headResult = executeHead(inner, matchId, route)
- if (headResult) {
- const head = await headResult
- inner.updateMatch(matchId, (prev) => ({
- ...prev,
- ...head,
- }))
- }
return inner.router.getMatch(matchId)!
}
} else {
@@ -874,6 +890,25 @@ export async function loadMatches(arg: {
try {
// Execute all beforeLoads one by one
for (let i = 0; i < inner.matches.length; i++) {
+ // Execute head before beforeLoad for SSR to ensure head content is available before beforeLoad errors
+ if (inner.router.isServer) {
+ const { id: matchId, routeId } = inner.matches[i]!
+ const route = inner.router.looseRoutesById[routeId]!
+ try {
+ const headResult = executeHead(inner, matchId, route)
+ if (headResult) {
+ const head = await headResult
+ inner.updateMatch(matchId, (prev) => ({
+ ...prev,
+ ...head,
+ }))
+ }
+ } catch (err) {
+ // Don't let head execution errors break beforeLoad execution
+ console.warn('Error executing head before beforeLoad:', err)
+ }
+ }
+
const beforeLoad = handleBeforeLoad(inner, i)
if (isPromise(beforeLoad)) await beforeLoad
}
@@ -885,6 +920,27 @@ export async function loadMatches(arg: {
}
await Promise.all(inner.matchPromises)
+ if (inner.router.isServer) {
+ for (let i = 0; i < inner.matches.length; i++) {
+ const { id: matchId, routeId } = inner.matches[i]!
+ const route = inner.router.looseRoutesById[routeId]!
+
+ try {
+ const headResult = executeHead(inner, matchId, route)
+ if (headResult) {
+ const head = await headResult
+ inner.updateMatch(matchId, (prev) => ({
+ ...prev,
+ ...head,
+ }))
+ }
+ } catch (err) {
+ console.warn('Error executing head during SSR:', err)
+ }
+ }
+ }
+
+
const readyPromise = triggerOnReady(inner)
if (isPromise(readyPromise)) await readyPromise
} catch (err) {
diff --git a/packages/router-core/src/ssr/ssr-client.ts b/packages/router-core/src/ssr/ssr-client.ts
index 736fbeac6ca..dec6056c90d 100644
--- a/packages/router-core/src/ssr/ssr-client.ts
+++ b/packages/router-core/src/ssr/ssr-client.ts
@@ -217,7 +217,9 @@ export async function hydrate(router: AnyRouter): Promise {
match.styles = headFnContent?.styles
match.scripts = scripts
}),
- )
+ ).catch((err) => {
+ console.error('Error during route context hydration:', err)
+ })
const isSpaMode = matches[matches.length - 1]!.id !== lastMatchId
const hasSsrFalseMatches = matches.some((m) => m.ssr === false)
diff --git a/packages/router-core/tests/hydrate.test.ts b/packages/router-core/tests/hydrate.test.ts
new file mode 100644
index 00000000000..3bc5706d44f
--- /dev/null
+++ b/packages/router-core/tests/hydrate.test.ts
@@ -0,0 +1,255 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { hydrate } from '../src/ssr/ssr-client'
+import type { TsrSsrGlobal } from '../src/ssr/ssr-client'
+import {
+ createMemoryHistory,
+ createRootRoute,
+ createRoute,
+ createRouter,
+ notFound,
+} from '../../react-router/dist/esm'
+import type { AnyRouteMatch } from '../src'
+
+describe('hydrate', () => {
+ let mockWindow: { $_TSR?: TsrSsrGlobal }
+ let mockRouter: any
+ let mockHead: any
+
+ beforeEach(() => {
+ // Reset global window mock
+ mockWindow = {}
+ ;(global as any).window = mockWindow
+
+ // Reset mock head function
+ mockHead = vi.fn()
+
+ const history = createMemoryHistory({ initialEntries: ['/'] })
+
+ const rootRoute = createRootRoute({})
+
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => 'Index',
+ notFoundComponent: () => 'Not Found',
+ head: mockHead,
+ })
+
+ const otherRoute = createRoute({
+ getParentRoute: () => indexRoute,
+ path: '/other',
+ component: () => 'Other',
+ })
+
+ const routeTree = rootRoute.addChildren([indexRoute.addChildren([otherRoute])])
+
+ mockRouter = createRouter({ routeTree, history, isServer: true })
+ })
+
+ afterEach(() => {
+ vi.resetAllMocks()
+ delete (global as any).window
+ })
+
+ it('should throw error if window.$_TSR is not available', async () => {
+ await expect(hydrate(mockRouter)).rejects.toThrow(
+ 'Expected to find bootstrap data on window.$_TSR, but we did not. Please file an issue!'
+ )
+ })
+
+ it('should throw error if window.$_TSR.router is not available', async () => {
+ mockWindow.$_TSR = {
+ c: vi.fn(),
+ p: vi.fn(),
+ buffer: [],
+ initialized: false,
+ // router is missing
+ } as any
+
+ await expect(hydrate(mockRouter)).rejects.toThrow(
+ 'Expected to find a dehydrated data on window.$_TSR.router, but we did not. Please file an issue!'
+ )
+ })
+
+ it('should initialize serialization adapters when provided', async () => {
+ const mockSerializer = {
+ key: 'testAdapter',
+ fromSerializable: vi.fn(),
+ toSerializable: vi.fn(),
+ test: vi.fn().mockReturnValue(true),
+ '~types': {
+ input: {},
+ output: {},
+ extends: {},
+ },
+ }
+
+ mockRouter.options.serializationAdapters = [mockSerializer]
+
+ const mockMatches = [
+ { id: '/', routeId: '/', index: 0, _nonReactive: {} },
+ ]
+ mockRouter.matchRoutes = vi.fn().mockReturnValue(mockMatches)
+ mockRouter.state.matches = mockMatches
+
+ const mockBuffer = [vi.fn(), vi.fn()]
+ mockWindow.$_TSR = {
+ router: {
+ manifest: { routes: {} },
+ dehydratedData: {},
+ lastMatchId: '/',
+ matches: [],
+ },
+ c: vi.fn(),
+ p: vi.fn(),
+ buffer: mockBuffer,
+ initialized: false,
+ }
+
+ await hydrate(mockRouter)
+
+ expect(mockWindow.$_TSR!.t).toBeInstanceOf(Map)
+ expect(mockWindow.$_TSR!.t?.get('testAdapter')).toBe(mockSerializer.fromSerializable)
+ expect(mockBuffer[0]).toHaveBeenCalled()
+ expect(mockBuffer[1]).toHaveBeenCalled()
+ expect(mockWindow.$_TSR!.initialized).toBe(true)
+ })
+
+ it('should handle empty serialization adapters', async () => {
+ mockRouter.options.serializationAdapters = []
+
+ mockWindow.$_TSR = {
+ router: {
+ manifest: { routes: {} },
+ dehydratedData: {},
+ lastMatchId: '/',
+ matches: [],
+ },
+ c: vi.fn(),
+ p: vi.fn(),
+ buffer: [],
+ initialized: false,
+ }
+
+ await hydrate(mockRouter)
+
+ expect(mockWindow.$_TSR!.t).toBeUndefined()
+ expect(mockWindow.$_TSR!.initialized).toBe(true)
+ })
+
+ it('should set manifest in router.ssr', async () => {
+ const testManifest = { routes: {} }
+ mockWindow.$_TSR = {
+ router: {
+ manifest: testManifest,
+ dehydratedData: {},
+ lastMatchId: '/',
+ matches: [],
+ },
+ c: vi.fn(),
+ p: vi.fn(),
+ buffer: [],
+ initialized: false,
+ }
+
+ await hydrate(mockRouter)
+
+ expect(mockRouter.ssr).toEqual({
+ manifest: testManifest,
+ })
+ })
+
+ it('should hydrate matches', async () => {
+ const mockMatches = [
+ {
+ id: '/',
+ routeId: '/',
+ index: 0,
+ ssr: undefined,
+ _nonReactive: {},
+ },
+ {
+ id: '/other',
+ routeId: '/other',
+ index: 1,
+ ssr: undefined,
+ _nonReactive: {},
+ },
+ ]
+
+ const dehydratedMatches = [
+ {
+ i: '/',
+ l: { indexData: 'server-data' },
+ s: 'success' as const,
+ ssr: true,
+ u: Date.now(),
+ },
+ ]
+
+ mockRouter.matchRoutes = vi.fn().mockReturnValue(mockMatches)
+ mockRouter.state.matches = mockMatches
+
+ mockWindow.$_TSR = {
+ router: {
+ manifest: { routes: {} },
+ dehydratedData: {},
+ lastMatchId: '/',
+ matches: dehydratedMatches,
+ },
+ c: vi.fn(),
+ p: vi.fn(),
+ buffer: [],
+ initialized: false,
+ }
+
+ await hydrate(mockRouter)
+
+ const { id, loaderData, ssr, status } = mockMatches[0] as AnyRouteMatch
+ expect(id).toBe('/')
+ expect(loaderData).toEqual({ indexData: 'server-data' })
+ expect(status).toBe('success')
+ expect(ssr).toBe(true)
+ })
+
+ it('should handle errors during route context hydration', async () => {
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
+ mockHead.mockImplementation(() => {
+ throw notFound()
+ })
+
+ const mockMatches = [
+ { id: '/', routeId: '/', index: 0, ssr: true, _nonReactive: {} },
+ ]
+
+ mockRouter.matchRoutes = vi.fn().mockReturnValue(mockMatches)
+ mockRouter.state.matches = mockMatches
+
+ mockWindow.$_TSR = {
+ router: {
+ manifest: { routes: {} },
+ dehydratedData: {},
+ lastMatchId: '/',
+ matches: [
+ {
+ i: '/',
+ l: { data: 'test' },
+ s: 'success',
+ ssr: true,
+ u: Date.now(),
+ },
+ ],
+ },
+ c: vi.fn(),
+ p: vi.fn(),
+ buffer: [],
+ initialized: false,
+ }
+
+ await hydrate(mockRouter)
+
+ expect(consoleSpy).toHaveBeenCalledWith('Error during route context hydration:', { 'isNotFound': true })
+
+ consoleSpy.mockRestore()
+ })
+})
diff --git a/packages/solid-router/tests/store-updates-during-navigation.test.tsx b/packages/solid-router/tests/store-updates-during-navigation.test.tsx
index 097fe1fab2b..76440825817 100644
--- a/packages/solid-router/tests/store-updates-during-navigation.test.tsx
+++ b/packages/solid-router/tests/store-updates-during-navigation.test.tsx
@@ -200,7 +200,7 @@ describe("Store doesn't update *too many* times during navigation", () => {
// This number should be as small as possible to minimize the amount of work
// that needs to be done during a navigation.
// Any change that increases this number should be investigated.
- expect(updates).toBe(6)
+ expect(updates).toBe(5)
})
test('hover preload, then navigate, w/ async loaders', async () => {