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() + }) +})