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..dd0ca693eee 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'] 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'] as const, }) directVisitTestMatrix.forEach(({ thrower }) => { diff --git a/e2e/solid-start/basic/src/routeTree.gen.ts b/e2e/solid-start/basic/src/routeTree.gen.ts index 9bbb8755f9c..4e16a49a2da 100644 --- a/e2e/solid-start/basic/src/routeTree.gen.ts +++ b/e2e/solid-start/basic/src/routeTree.gen.ts @@ -31,6 +31,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' @@ -156,6 +157,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', @@ -244,6 +250,7 @@ export interface FileRoutesByFullPath { '/users': typeof UsersRouteWithChildren '/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 @@ -276,6 +283,7 @@ export interface FileRoutesByTo { '/stream': typeof StreamRoute '/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 @@ -314,6 +322,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 @@ -352,6 +361,7 @@ export interface FileRouteTypes { | '/users' | '/api/users' | '/not-found/via-beforeLoad' + | '/not-found/via-head' | '/not-found/via-loader' | '/posts/$postId' | '/redirect/$target' @@ -384,6 +394,7 @@ export interface FileRouteTypes { | '/stream' | '/api/users' | '/not-found/via-beforeLoad' + | '/not-found/via-head' | '/not-found/via-loader' | '/posts/$postId' | '/search-params/default' @@ -421,6 +432,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' @@ -619,6 +631,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' @@ -722,12 +741,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..598b34567c0 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', () => { @@ -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'] as const, preload: [false, true] as const, }) @@ -54,9 +54,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/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/load-matches.ts b/packages/router-core/src/load-matches.ts index 6889d886773..839f7740e4f 100644 --- a/packages/router-core/src/load-matches.ts +++ b/packages/router-core/src/load-matches.ts @@ -53,9 +53,18 @@ 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 + } + // Ensure a NotFoundComponent exists on the route if ( !routeCursor.options.notFoundComponent && @@ -84,11 +93,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 = ( @@ -384,7 +388,23 @@ const executeBeforeLoad = ( })) } - const resolve = () => { + const resolve = async () => { + // Execute head during SSR + if (inner.router.isServer) { + try { + const headResult = executeHead(inner, matchId, route) + if (headResult) { + const head = await headResult + inner.updateMatch(matchId, (prev) => ({ + ...prev, + ...head, + })) + } + } catch (err) { + console.error('Error executing head:', err) + } + } + match._nonReactive.beforeLoadPromise?.resolve() match._nonReactive.beforeLoadPromise = undefined inner.updateMatch(matchId, (prev) => ({ @@ -395,9 +415,9 @@ const executeBeforeLoad = ( // if there is no `beforeLoad` option, skip everything, batch update the store, return early if (!route.options.beforeLoad) { - batch(() => { + batch(async () => { pending() - resolve() + await resolve() }) return } @@ -433,11 +453,11 @@ const executeBeforeLoad = ( ...inner.router.options.additionalContext, } - const updateContext = (beforeLoadContext: any) => { + const updateContext = async (beforeLoadContext: any) => { if (beforeLoadContext === undefined) { - batch(() => { + batch(async () => { pending() - resolve() + await resolve() }) return } @@ -446,7 +466,7 @@ const executeBeforeLoad = ( handleSerialError(inner, index, beforeLoadContext, 'BEFORE_LOAD') } - batch(() => { + batch(async () => { pending() inner.updateMatch(matchId, (prev) => ({ ...prev, @@ -456,7 +476,7 @@ const executeBeforeLoad = ( ...beforeLoadContext, }, })) - resolve() + await resolve() }) } @@ -724,14 +744,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 { diff --git a/packages/router-core/src/ssr/ssr-client.ts b/packages/router-core/src/ssr/ssr-client.ts index cff2dc61bee..8ed7ceb9186 100644 --- a/packages/router-core/src/ssr/ssr-client.ts +++ b/packages/router-core/src/ssr/ssr-client.ts @@ -210,7 +210,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() + }) +})