diff --git a/e2e/react-start/basic/src/routeTree.gen.ts b/e2e/react-start/basic/src/routeTree.gen.ts
index e29bb26d3ce..1f798d17fa5 100644
--- a/e2e/react-start/basic/src/routeTree.gen.ts
+++ b/e2e/react-start/basic/src/routeTree.gen.ts
@@ -34,6 +34,7 @@ import { Route as SearchParamsDefaultRouteImport } from './routes/search-params/
import { Route as RedirectTargetRouteImport } from './routes/redirect/$target'
import { Route as PostsPostIdRouteImport } from './routes/posts.$postId'
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'
@@ -169,6 +170,11 @@ const NotFoundViaLoaderRoute = NotFoundViaLoaderRouteImport.update({
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,6 +278,7 @@ 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
'/posts/$postId': typeof PostsPostIdRoute
'/redirect/$target': typeof RedirectTargetRouteWithChildren
@@ -307,6 +314,7 @@ 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
'/posts/$postId': typeof PostsPostIdRoute
'/search-params/default': typeof SearchParamsDefaultRoute
@@ -347,6 +355,7 @@ 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
'/posts/$postId': typeof PostsPostIdRoute
'/redirect/$target': typeof RedirectTargetRouteWithChildren
@@ -389,6 +398,7 @@ export interface FileRouteTypes {
| '/대한민국'
| '/api/users'
| '/not-found/via-beforeLoad'
+ | '/not-found/via-head'
| '/not-found/via-loader'
| '/posts/$postId'
| '/redirect/$target'
@@ -424,6 +434,7 @@ export interface FileRouteTypes {
| '/대한민국'
| '/api/users'
| '/not-found/via-beforeLoad'
+ | '/not-found/via-head'
| '/not-found/via-loader'
| '/posts/$postId'
| '/search-params/default'
@@ -463,6 +474,7 @@ export interface FileRouteTypes {
| '/_layout/_layout-2'
| '/api/users'
| '/not-found/via-beforeLoad'
+ | '/not-found/via-head'
| '/not-found/via-loader'
| '/posts/$postId'
| '/redirect/$target'
@@ -673,6 +685,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,12 +816,14 @@ declare module '@tanstack/react-router' {
interface NotFoundRouteRouteChildren {
NotFoundViaBeforeLoadRoute: typeof NotFoundViaBeforeLoadRoute
+ NotFoundViaHeadRoute: typeof NotFoundViaHeadRoute
NotFoundViaLoaderRoute: typeof NotFoundViaLoaderRoute
NotFoundIndexRoute: typeof NotFoundIndexRoute
}
const NotFoundRouteRouteChildren: NotFoundRouteRouteChildren = {
NotFoundViaBeforeLoadRoute: NotFoundViaBeforeLoadRoute,
+ NotFoundViaHeadRoute: NotFoundViaHeadRoute,
NotFoundViaLoaderRoute: NotFoundViaLoaderRoute,
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..722813fca36 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,16 @@ export const Route = createFileRoute('/not-found/')({
via-loader
+
+
+ via-head
+
+
)
},
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/tests/not-found.spec.ts b/e2e/react-start/basic/tests/not-found.spec.ts
index bde0a1a02a3..0b5acc3b782 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/,
+ 'NotFound error during hydration for routeId',
],
})
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'] as const,
preload: [false, true] as const,
})
@@ -57,7 +58,7 @@ test.describe('not-found', () => {
const directVisitTestMatrix = combinate({
// TODO beforeLoad!
- thrower: [/* 'beforeLoad',*/ 'loader'] as const,
+ thrower: [/* 'beforeLoad',*/ 'head', 'loader'] as const,
})
directVisitTestMatrix.forEach(({ thrower }) => {
diff --git a/e2e/react-start/basic/vite.config.ts b/e2e/react-start/basic/vite.config.ts
index f9249150d92..ecab96f2d8b 100644
--- a/e2e/react-start/basic/vite.config.ts
+++ b/e2e/react-start/basic/vite.config.ts
@@ -20,6 +20,7 @@ const prerenderConfiguration = {
'/redirect',
'/i-do-not-exist',
'/not-found/via-beforeLoad',
+ '/not-found/via-head',
'/not-found/via-loader',
'/users',
].some((p) => page.path.includes(p)),
diff --git a/e2e/solid-start/basic/src/routeTree.gen.ts b/e2e/solid-start/basic/src/routeTree.gen.ts
index 1fb482f3af0..f1ecc1d1499 100644
--- a/e2e/solid-start/basic/src/routeTree.gen.ts
+++ b/e2e/solid-start/basic/src/routeTree.gen.ts
@@ -32,6 +32,7 @@ import { Route as SearchParamsDefaultRouteImport } from './routes/search-params/
import { Route as RedirectTargetRouteImport } from './routes/redirect/$target'
import { Route as PostsPostIdRouteImport } from './routes/posts.$postId'
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'
@@ -165,6 +166,11 @@ const NotFoundViaLoaderRoute = NotFoundViaLoaderRouteImport.update({
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',
@@ -266,6 +272,7 @@ 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
'/posts/$postId': typeof PostsPostIdRoute
'/redirect/$target': typeof RedirectTargetRouteWithChildren
@@ -301,6 +308,7 @@ 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
'/posts/$postId': typeof PostsPostIdRoute
'/search-params/default': typeof SearchParamsDefaultRoute
@@ -342,6 +350,7 @@ 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
'/posts/$postId': typeof PostsPostIdRoute
'/redirect/$target': typeof RedirectTargetRouteWithChildren
@@ -383,6 +392,7 @@ export interface FileRouteTypes {
| '/대한민국'
| '/api/users'
| '/not-found/via-beforeLoad'
+ | '/not-found/via-head'
| '/not-found/via-loader'
| '/posts/$postId'
| '/redirect/$target'
@@ -418,6 +428,7 @@ export interface FileRouteTypes {
| '/대한민국'
| '/api/users'
| '/not-found/via-beforeLoad'
+ | '/not-found/via-head'
| '/not-found/via-loader'
| '/posts/$postId'
| '/search-params/default'
@@ -458,6 +469,7 @@ export interface FileRouteTypes {
| '/_layout/_layout-2'
| '/api/users'
| '/not-found/via-beforeLoad'
+ | '/not-found/via-head'
| '/not-found/via-loader'
| '/posts/$postId'
| '/redirect/$target'
@@ -668,6 +680,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'
@@ -785,12 +804,14 @@ declare module '@tanstack/solid-router' {
interface NotFoundRouteRouteChildren {
NotFoundViaBeforeLoadRoute: typeof NotFoundViaBeforeLoadRoute
+ NotFoundViaHeadRoute: typeof NotFoundViaHeadRoute
NotFoundViaLoaderRoute: typeof NotFoundViaLoaderRoute
NotFoundIndexRoute: typeof NotFoundIndexRoute
}
const NotFoundRouteRouteChildren: NotFoundRouteRouteChildren = {
NotFoundViaBeforeLoadRoute: NotFoundViaBeforeLoadRoute,
+ NotFoundViaHeadRoute: NotFoundViaHeadRoute,
NotFoundViaLoaderRoute: NotFoundViaLoaderRoute,
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..54eff41247c 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,16 @@ export const Route = createFileRoute('/not-found/')({
via-loader
+
+
+ via-head
+
+
)
},
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/tests/not-found.spec.ts b/e2e/solid-start/basic/tests/not-found.spec.ts
index e1a69eefc65..1bed8e991c9 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/,
+ 'NotFound error during hydration for routeId',
],
})
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'] as const,
preload: [false, true] as const,
})
@@ -56,7 +57,7 @@ test.describe('not-found', () => {
const directVisitTestMatrix = combinate({
// TODO beforeLoad!
- thrower: [/* 'beforeLoad',*/ 'loader'] as const,
+ thrower: [/* 'beforeLoad',*/ 'head', 'loader'] as const,
})
directVisitTestMatrix.forEach(({ thrower }) => {
diff --git a/e2e/solid-start/basic/vite.config.ts b/e2e/solid-start/basic/vite.config.ts
index 6af116d625b..7e8d607c532 100644
--- a/e2e/solid-start/basic/vite.config.ts
+++ b/e2e/solid-start/basic/vite.config.ts
@@ -20,6 +20,7 @@ const prerenderConfiguration = {
'/redirect',
'/i-do-not-exist',
'/not-found/via-beforeLoad',
+ '/not-found/via-head',
'/not-found/via-loader',
'/search-params/default',
'/transition',
diff --git a/packages/react-router/tests/hydrate.test.ts b/packages/react-router/tests/hydrate.test.ts
new file mode 100644
index 00000000000..b0d91530554
--- /dev/null
+++ b/packages/react-router/tests/hydrate.test.ts
@@ -0,0 +1,265 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { hydrate } from '@tanstack/router-core/ssr/client'
+import {
+ createMemoryHistory,
+ createRootRoute,
+ createRoute,
+ createRouter,
+ notFound,
+} from '../src'
+import type { TsrSsrGlobal } from '@tanstack/router-core/ssr/client'
+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)
+
+ const match = mockRouter.state.matches[0] as AnyRouteMatch
+ expect(match.error).toEqual({ isNotFound: true })
+
+ expect(consoleSpy).toHaveBeenCalledWith(
+ 'NotFound error during hydration for routeId: /',
+ expect.objectContaining({
+ isNotFound: true,
+ }),
+ )
+
+ consoleSpy.mockRestore()
+ })
+})
diff --git a/packages/router-core/src/ssr/ssr-client.ts b/packages/router-core/src/ssr/ssr-client.ts
index 18e9f394bbb..4f52ca66a24 100644
--- a/packages/router-core/src/ssr/ssr-client.ts
+++ b/packages/router-core/src/ssr/ssr-client.ts
@@ -1,5 +1,6 @@
import invariant from 'tiny-invariant'
import { batch } from '@tanstack/store'
+import { isNotFound } from '../not-found'
import { createControlledPromise } from '../utils'
import type { AnyRouteMatch, MakeRouteMatch } from '../Matches'
import type { AnyRouter } from '../router'
@@ -179,52 +180,72 @@ export async function hydrate(router: AnyRouter): Promise {
// 2) execute `head()` and `scripts()` for each match
await Promise.all(
router.state.matches.map(async (match) => {
- const route = router.looseRoutesById[match.routeId]!
+ try {
+ const route = router.looseRoutesById[match.routeId]!
+
+ const parentMatch = router.state.matches[match.index - 1]
+ const parentContext = parentMatch?.context ?? router.options.context
+
+ // `context()` was already executed by `matchRoutes`, however route context was not yet fully reconstructed
+ // so run it again and merge route context
+ if (route.options.context) {
+ const contextFnContext: RouteContextOptions = {
+ deps: match.loaderDeps,
+ params: match.params,
+ context: parentContext ?? {},
+ location: router.state.location,
+ navigate: (opts: any) =>
+ router.navigate({
+ ...opts,
+ _fromLocation: router.state.location,
+ }),
+ buildLocation: router.buildLocation,
+ cause: match.cause,
+ abortController: match.abortController,
+ preload: false,
+ matches,
+ }
+ match.__routeContext =
+ route.options.context(contextFnContext) ?? undefined
+ }
- const parentMatch = router.state.matches[match.index - 1]
- const parentContext = parentMatch?.context ?? router.options.context
+ match.context = {
+ ...parentContext,
+ ...match.__routeContext,
+ ...match.__beforeLoadContext,
+ }
- // `context()` was already executed by `matchRoutes`, however route context was not yet fully reconstructed
- // so run it again and merge route context
- if (route.options.context) {
- const contextFnContext: RouteContextOptions = {
- deps: match.loaderDeps,
+ const assetContext = {
+ matches: router.state.matches,
+ match,
params: match.params,
- context: parentContext ?? {},
- location: router.state.location,
- navigate: (opts: any) =>
- router.navigate({ ...opts, _fromLocation: router.state.location }),
- buildLocation: router.buildLocation,
- cause: match.cause,
- abortController: match.abortController,
- preload: false,
- matches,
+ loaderData: match.loaderData,
+ }
+ const headFnContent = await route.options.head?.(assetContext)
+
+ const scripts = await route.options.scripts?.(assetContext)
+
+ match.meta = headFnContent?.meta
+ match.links = headFnContent?.links
+ match.headScripts = headFnContent?.scripts
+ match.styles = headFnContent?.styles
+ match.scripts = scripts
+ } catch (err) {
+ if (isNotFound(err)) {
+ match.error = { isNotFound: true }
+ console.error(
+ `NotFound error during hydration for routeId: ${match.routeId}`,
+ err,
+ )
+ } else {
+ match.error = err as any
+ console.error(
+ `Error during hydration for route ${match.routeId}:`,
+ err,
+ )
+ throw err
}
- match.__routeContext =
- route.options.context(contextFnContext) ?? undefined
- }
-
- match.context = {
- ...parentContext,
- ...match.__routeContext,
- ...match.__beforeLoadContext,
- }
-
- const assetContext = {
- matches: router.state.matches,
- match,
- params: match.params,
- loaderData: match.loaderData,
}
- const headFnContent = await route.options.head?.(assetContext)
-
- const scripts = await route.options.scripts?.(assetContext)
-
- match.meta = headFnContent?.meta
- match.links = headFnContent?.links
- match.headScripts = headFnContent?.scripts
- match.styles = headFnContent?.styles
- match.scripts = scripts
}),
)
diff --git a/packages/solid-router/tests/hydrate.test.ts b/packages/solid-router/tests/hydrate.test.ts
new file mode 100644
index 00000000000..d71e29d7e98
--- /dev/null
+++ b/packages/solid-router/tests/hydrate.test.ts
@@ -0,0 +1,265 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { hydrate } from '@tanstack/router-core/ssr/client'
+import {
+ createMemoryHistory,
+ createRootRoute,
+ createRoute,
+ createRouter,
+ notFound,
+} from '../src'
+import type { AnyRouteMatch } from '../src'
+import type { TsrSsrGlobal } from '@tanstack/router-core/ssr/client'
+
+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)
+
+ const match = mockRouter.state.matches[0] as AnyRouteMatch
+ expect(match.error).toEqual({ isNotFound: true })
+
+ expect(consoleSpy).toHaveBeenCalledWith(
+ 'NotFound error during hydration for routeId: /',
+ expect.objectContaining({
+ isNotFound: true,
+ }),
+ )
+
+ consoleSpy.mockRestore()
+ })
+})