diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts index 6759c0e22e..6a29f05b9d 100644 --- a/packages/router-core/src/index.ts +++ b/packages/router-core/src/index.ts @@ -116,7 +116,12 @@ export { encode, decode } from './qss' export { rootRouteId } from './root' export type { RootRouteId } from './root' -export { BaseRoute, BaseRouteApi, BaseRootRoute } from './route' +export { + BaseRoute, + BaseRouteApi, + BaseRootRoute, + routeOptionsHeadUnexpectedKeysWarning, +} from './route' export type { AnyPathParams, SearchSchemaInput, diff --git a/packages/router-core/src/route.ts b/packages/router-core/src/route.ts index 9c1aeb05ea..4535d34f9b 100644 --- a/packages/router-core/src/route.ts +++ b/packages/router-core/src/route.ts @@ -1114,10 +1114,12 @@ export interface UpdatableRouteOptions< TBeforeLoadFn, TLoaderDeps >, - ) => { - links?: AnyRouteMatch['links'] - scripts?: AnyRouteMatch['headScripts'] - meta?: AnyRouteMatch['meta'] + ) => HeadResult & { + // prevent likely typos or accidental overrides since not able to force the shape of the return type using TypeScript + script?: never + link?: never + metas?: never + styles?: never } scripts?: ( ctx: AssetFnContextOptions< @@ -1673,4 +1675,38 @@ export class BaseRootRoute< } } -// +/** + * Warns if the result of a route head option is not a valid RouteHeadOptionResult + * @param result The result of a route head option + * @returns void + */ +export function routeOptionsHeadUnexpectedKeysWarning(result: unknown): void { + if (process.env.NODE_ENV === 'development') { + const keys = Object.keys(result as HeadResult) + const unexpectedKeys = keys.filter( + (key) => !headExpectedKeys.includes(key as HeadExpectedKey), + ) + + if (unexpectedKeys.length === 0) { + return + } + + console.warn( + `Route head option result has unexpected keys: "${unexpectedKeys.join('", "')}".`, + 'Only "links", "scripts", and "meta" are allowed', + ) + } +} + +type HeadExpectedKey = keyof Required +const headExpectedKeys = [ + 'links', + 'scripts', + 'meta', +] satisfies Array + +type HeadResult = { + links?: AnyRouteMatch['links'] + scripts?: AnyRouteMatch['headScripts'] + meta?: AnyRouteMatch['meta'] +} diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 3899ce384c..665d625a38 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -26,6 +26,7 @@ import { } from './path' import { isNotFound } from './not-found' import { setupScrollRestoration } from './scroll-restoration' +import { routeOptionsHeadUnexpectedKeysWarning } from './route' import { defaultParseSearch, defaultStringifySearch } from './searchParams' import { rootRouteId } from './root' import { isRedirect, isResolvedRedirect } from './redirect' @@ -2602,6 +2603,8 @@ export class RouterCore< loaderData: match.loaderData, } const headFnContent = route.options.head?.(assetContext) + routeOptionsHeadUnexpectedKeysWarning(headFnContent) + const meta = headFnContent?.meta const links = headFnContent?.links const headScripts = headFnContent?.scripts diff --git a/packages/router-core/tests/route.test.ts b/packages/router-core/tests/route.test.ts new file mode 100644 index 0000000000..ee4e656789 --- /dev/null +++ b/packages/router-core/tests/route.test.ts @@ -0,0 +1,82 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { routeOptionsHeadUnexpectedKeysWarning } from '../src/route' + +describe('routeOptionsHeadUnexpectedKeysWarning', () => { + const originalEnv = process.env.NODE_ENV + const originalWarn = console.warn + + beforeEach(() => { + console.warn = vi.fn() + }) + + afterEach(() => { + console.warn = originalWarn + process.env.NODE_ENV = originalEnv + }) + + it('should not warn when all keys are valid', () => { + process.env.NODE_ENV = 'development' + const validResult = { + links: [], + scripts: [], + meta: [], + } + + routeOptionsHeadUnexpectedKeysWarning(validResult) + expect(console.warn).not.toHaveBeenCalled() + }) + + it('should warn when there are unexpected keys', () => { + process.env.NODE_ENV = 'development' + const invalidResult = { + links: [], + scripts: [], + meta: [], + unexpectedKey: 'value', + } + + routeOptionsHeadUnexpectedKeysWarning(invalidResult) + expect(console.warn).toHaveBeenCalledWith( + 'Route head option result has unexpected keys: "unexpectedKey".', + 'Only "links", "scripts", and "meta" are allowed', + ) + }) + + it('should not warn in production environment', () => { + process.env.NODE_ENV = 'production' + const invalidResult = { + links: [], + scripts: [], + meta: [], + unexpectedKey: 'value', + } + + routeOptionsHeadUnexpectedKeysWarning(invalidResult) + expect(console.warn).not.toHaveBeenCalled() + }) + + it('should not warn when missing expected keys', () => { + process.env.NODE_ENV = 'development' + const partialResult = { + links: [], + // missing scripts and meta + } + + routeOptionsHeadUnexpectedKeysWarning(partialResult) + expect(console.warn).not.toHaveBeenCalled() + }) + + it('should warn when all keys are unexpected', () => { + process.env.NODE_ENV = 'development' + const allInvalidResult = { + invalidKey1: 'value1', + invalidKey2: 'value2', + } as any + + routeOptionsHeadUnexpectedKeysWarning(allInvalidResult) + expect(console.warn).toHaveBeenCalledWith( + 'Route head option result has unexpected keys: "invalidKey1", "invalidKey2".', + 'Only "links", "scripts", and "meta" are allowed', + ) + }) +}) diff --git a/packages/start-client-core/src/ssr-client.tsx b/packages/start-client-core/src/ssr-client.tsx index 9eddf971db..54d4cfced2 100644 --- a/packages/start-client-core/src/ssr-client.tsx +++ b/packages/start-client-core/src/ssr-client.tsx @@ -10,6 +10,7 @@ import type { MakeRouteMatch, Manifest, RouteContextOptions, + routeOptionsHeadUnexpectedKeysWarning, } from '@tanstack/router-core' declare global { @@ -215,6 +216,7 @@ export function hydrate(router: AnyRouter) { loaderData: match.loaderData, } const headFnContent = route.options.head?.(assetContext) + routeOptionsHeadUnexpectedKeysWarning(headFnContent) const scripts = route.options.scripts?.(assetContext)