From be9385006c1fc3163b89162f2664787641bf8f3e Mon Sep 17 00:00:00 2001 From: WolfieLeader Date: Tue, 17 Mar 2026 13:37:07 +0200 Subject: [PATCH 1/4] feat: allow head route option to accept a static object Routes with static head content (e.g. favicon, charset, viewport meta) no longer need a function wrapper. The head option now accepts either a function or a plain object. Closes #6949 --- packages/router-core/src/load-matches.ts | 4 +- packages/router-core/src/route.ts | 45 ++++++++++------- packages/router-core/src/ssr/ssr-client.ts | 5 +- packages/router-core/tests/load.test.ts | 59 ++++++++++++++++++++++ 4 files changed, 92 insertions(+), 21 deletions(-) diff --git a/packages/router-core/src/load-matches.ts b/packages/router-core/src/load-matches.ts index 6b400833fae..b463875a32c 100644 --- a/packages/router-core/src/load-matches.ts +++ b/packages/router-core/src/load-matches.ts @@ -581,7 +581,9 @@ const executeHead = ( } return Promise.all([ - route.options.head?.(assetContext), + typeof route.options.head === 'function' + ? route.options.head(assetContext) + : route.options.head, route.options.scripts?.(assetContext), route.options.headers?.(assetContext), ]).then(([headFnContent, scripts, headers]) => { diff --git a/packages/router-core/src/route.ts b/packages/router-core/src/route.ts index ef80228c8bc..45344a4a50e 100644 --- a/packages/router-core/src/route.ts +++ b/packages/router-core/src/route.ts @@ -1383,25 +1383,32 @@ export interface UpdatableRouteOptions< TLoaderDeps >, ) => Awaitable | undefined> - head?: ( - ctx: AssetFnContextOptions< - TRouteId, - TFullPath, - TParentRoute, - TParams, - TSearchValidator, - TLoaderFn, - TRouterContext, - TRouteContextFn, - TBeforeLoadFn, - TLoaderDeps - >, - ) => Awaitable<{ - links?: AnyRouteMatch['links'] - scripts?: AnyRouteMatch['headScripts'] - meta?: AnyRouteMatch['meta'] - styles?: AnyRouteMatch['styles'] - }> + head?: + | (( + ctx: AssetFnContextOptions< + TRouteId, + TFullPath, + TParentRoute, + TParams, + TSearchValidator, + TLoaderFn, + TRouterContext, + TRouteContextFn, + TBeforeLoadFn, + TLoaderDeps + >, + ) => Awaitable<{ + links?: AnyRouteMatch['links'] + scripts?: AnyRouteMatch['headScripts'] + meta?: AnyRouteMatch['meta'] + styles?: AnyRouteMatch['styles'] + }>) + | { + links?: AnyRouteMatch['links'] + scripts?: AnyRouteMatch['headScripts'] + meta?: AnyRouteMatch['meta'] + styles?: AnyRouteMatch['styles'] + } scripts?: ( ctx: AssetFnContextOptions< TRouteId, diff --git a/packages/router-core/src/ssr/ssr-client.ts b/packages/router-core/src/ssr/ssr-client.ts index 000c4106e37..7d773824c39 100644 --- a/packages/router-core/src/ssr/ssr-client.ts +++ b/packages/router-core/src/ssr/ssr-client.ts @@ -210,7 +210,10 @@ export async function hydrate(router: AnyRouter): Promise { params: match.params, loaderData: match.loaderData, } - const headFnContent = await route.options.head?.(assetContext) + const headFnContent = + typeof route.options.head === 'function' + ? await route.options.head(assetContext) + : route.options.head const scripts = await route.options.scripts?.(assetContext) diff --git a/packages/router-core/tests/load.test.ts b/packages/router-core/tests/load.test.ts index 62b8107c5ab..7e9ca09bfe1 100644 --- a/packages/router-core/tests/load.test.ts +++ b/packages/router-core/tests/load.test.ts @@ -1851,6 +1851,65 @@ describe('head execution', () => { expect(rootMatch?.error).toBeUndefined() }) }) + + test('accepts a static object for head instead of a function', async () => { + const staticHead = { + meta: [{ title: 'Static Title' }], + links: [{ rel: 'icon', href: '/favicon.ico' }], + } + + const rootRoute = new BaseRootRoute({ + head: staticHead, + }) + const testRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/test', + }) + const routeTree = rootRoute.addChildren([testRoute]) + const router = new RouterCore({ + routeTree, + history: createMemoryHistory({ initialEntries: ['/test'] }), + }) + + await router.load() + + const match = router.state.matches.find( + (m) => m.routeId === rootRoute.id, + ) + expect(match?.meta).toEqual([{ title: 'Static Title' }]) + expect(match?.links).toEqual([{ rel: 'icon', href: '/favicon.ico' }]) + }) + + test('static head object and function head work together in route hierarchy', async () => { + const rootRoute = new BaseRootRoute({ + head: { + meta: [{ charSet: 'UTF-8' }], + }, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/child', + head: () => ({ + meta: [{ title: 'Child Page' }], + }), + }) + const routeTree = rootRoute.addChildren([childRoute]) + const router = new RouterCore({ + routeTree, + history: createMemoryHistory({ initialEntries: ['/child'] }), + }) + + await router.load() + + const rootMatch = router.state.matches.find( + (m) => m.routeId === rootRoute.id, + ) + const childMatch = router.state.matches.find( + (m) => m.routeId === childRoute.id, + ) + expect(rootMatch?.meta).toEqual([{ charSet: 'UTF-8' }]) + expect(childMatch?.meta).toEqual([{ title: 'Child Page' }]) + }) }) describe('params.parse notFound', () => { From eadd81b2007d2e0186a33833d39f0d0605354184 Mon Sep 17 00:00:00 2001 From: WolfieLeader Date: Tue, 17 Mar 2026 14:01:29 +0200 Subject: [PATCH 2/4] refactor: extract HeadContent type to deduplicate head option shape --- packages/router-core/src/index.ts | 1 + packages/router-core/src/route.ts | 21 +++++++++------------ 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts index e428852be4c..7f09c6b465e 100644 --- a/packages/router-core/src/index.ts +++ b/packages/router-core/src/index.ts @@ -164,6 +164,7 @@ export type { FileBaseRouteOptions, BaseRouteOptions, UpdatableRouteOptions, + HeadContent, LoaderStaleReloadMode, RouteLoaderFn, RouteLoaderEntry, diff --git a/packages/router-core/src/route.ts b/packages/router-core/src/route.ts index 45344a4a50e..9646f7552b0 100644 --- a/packages/router-core/src/route.ts +++ b/packages/router-core/src/route.ts @@ -1177,6 +1177,13 @@ export interface BeforeLoadContextOptions< > } +export type HeadContent = { + links?: AnyRouteMatch['links'] + scripts?: AnyRouteMatch['headScripts'] + meta?: AnyRouteMatch['meta'] + styles?: AnyRouteMatch['styles'] +} + type AssetFnContextOptions< in out TRouteId, in out TFullPath, @@ -1397,18 +1404,8 @@ export interface UpdatableRouteOptions< TBeforeLoadFn, TLoaderDeps >, - ) => Awaitable<{ - links?: AnyRouteMatch['links'] - scripts?: AnyRouteMatch['headScripts'] - meta?: AnyRouteMatch['meta'] - styles?: AnyRouteMatch['styles'] - }>) - | { - links?: AnyRouteMatch['links'] - scripts?: AnyRouteMatch['headScripts'] - meta?: AnyRouteMatch['meta'] - styles?: AnyRouteMatch['styles'] - } + ) => Awaitable) + | HeadContent scripts?: ( ctx: AssetFnContextOptions< TRouteId, From e6c27acb0a9f5f550a7d0aefae29d43b84b9b6ea Mon Sep 17 00:00:00 2001 From: WolfieLeader Date: Tue, 17 Mar 2026 14:01:40 +0200 Subject: [PATCH 3/4] ci: add changeset for head static object feature --- .changeset/static-head-object.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/static-head-object.md diff --git a/.changeset/static-head-object.md b/.changeset/static-head-object.md new file mode 100644 index 00000000000..e9090d4094a --- /dev/null +++ b/.changeset/static-head-object.md @@ -0,0 +1,5 @@ +--- +'@tanstack/router-core': patch +--- + +feat: allow head route option to accept a static object From 89d8cf1da89323e19b1ff4b5b6faf86829b24c33 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 17 Mar 2026 14:58:50 +0000 Subject: [PATCH 4/4] ci: apply automated fixes --- packages/router-core/tests/load.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/router-core/tests/load.test.ts b/packages/router-core/tests/load.test.ts index 7e9ca09bfe1..ce564d59a77 100644 --- a/packages/router-core/tests/load.test.ts +++ b/packages/router-core/tests/load.test.ts @@ -1873,9 +1873,7 @@ describe('head execution', () => { await router.load() - const match = router.state.matches.find( - (m) => m.routeId === rootRoute.id, - ) + const match = router.state.matches.find((m) => m.routeId === rootRoute.id) expect(match?.meta).toEqual([{ title: 'Static Title' }]) expect(match?.links).toEqual([{ rel: 'icon', href: '/favicon.ico' }]) })