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 () => {