From a5c021d245d37b82d4ff8899ce7c73598b20c142 Mon Sep 17 00:00:00 2001 From: Zac Rosenbauer Date: Fri, 23 May 2025 14:12:10 -0400 Subject: [PATCH 1/3] fix: better types for root route + add warning if unexpected head result keys --- packages/router-core/src/index.ts | 2 +- packages/router-core/src/route.ts | 37 +++++++-- packages/router-core/src/router.ts | 3 + packages/router-core/tests/route.test.ts | 82 +++++++++++++++++++ packages/start-client-core/src/ssr-client.tsx | 6 +- 5 files changed, 122 insertions(+), 8 deletions(-) create mode 100644 packages/router-core/tests/route.test.ts diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts index 6759c0e22e..a1f57d3783 100644 --- a/packages/router-core/src/index.ts +++ b/packages/router-core/src/index.ts @@ -116,7 +116,7 @@ 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..62abfc54a4 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,29 @@ 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..2f87e5af82 --- /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' + ) + }) +}) \ No newline at end of file diff --git a/packages/start-client-core/src/ssr-client.tsx b/packages/start-client-core/src/ssr-client.tsx index 9eddf971db..db3e9eb99f 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 { @@ -153,7 +154,7 @@ export function hydrate(router: AnyRouter) { } // Handle extracted - ;(match as unknown as SsrMatch).extracted?.forEach((ex) => { + ; (match as unknown as SsrMatch).extracted?.forEach((ex) => { deepMutableSetByPath(match, ['loaderData', ...ex.path], ex.value) }) } else { @@ -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) @@ -230,7 +232,7 @@ export function hydrate(router: AnyRouter) { function deepMutableSetByPath(obj: T, path: Array, value: any) { // mutable set by path retaining array and object references if (path.length === 1) { - ;(obj as any)[path[0]!] = value + ; (obj as any)[path[0]!] = value } const [key, ...rest] = path From a4dfb09e7cace17664dc8dc78a56438cc388198c Mon Sep 17 00:00:00 2001 From: Zac Rosenbauer Date: Fri, 23 May 2025 14:17:30 -0400 Subject: [PATCH 2/3] fix: rm accidental autoformatting --- packages/start-client-core/src/ssr-client.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/start-client-core/src/ssr-client.tsx b/packages/start-client-core/src/ssr-client.tsx index db3e9eb99f..54d4cfced2 100644 --- a/packages/start-client-core/src/ssr-client.tsx +++ b/packages/start-client-core/src/ssr-client.tsx @@ -154,7 +154,7 @@ export function hydrate(router: AnyRouter) { } // Handle extracted - ; (match as unknown as SsrMatch).extracted?.forEach((ex) => { + ;(match as unknown as SsrMatch).extracted?.forEach((ex) => { deepMutableSetByPath(match, ['loaderData', ...ex.path], ex.value) }) } else { @@ -232,7 +232,7 @@ export function hydrate(router: AnyRouter) { function deepMutableSetByPath(obj: T, path: Array, value: any) { // mutable set by path retaining array and object references if (path.length === 1) { - ; (obj as any)[path[0]!] = value + ;(obj as any)[path[0]!] = value } const [key, ...rest] = path From 38ef84b95f7fc4075e987eefbb537188cd9d7466 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 23 May 2025 20:34:32 +0000 Subject: [PATCH 3/3] ci: apply automated fixes --- packages/router-core/src/index.ts | 7 ++++++- packages/router-core/src/route.ts | 15 ++++++++++++--- packages/router-core/tests/route.test.ts | 16 ++++++++-------- 3 files changed, 26 insertions(+), 12 deletions(-) diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts index a1f57d3783..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, routeOptionsHeadUnexpectedKeysWarning } 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 62abfc54a4..4535d34f9b 100644 --- a/packages/router-core/src/route.ts +++ b/packages/router-core/src/route.ts @@ -1683,18 +1683,27 @@ export class BaseRootRoute< 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)) + 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'); + 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 +const headExpectedKeys = [ + 'links', + 'scripts', + 'meta', +] satisfies Array type HeadResult = { links?: AnyRouteMatch['links'] diff --git a/packages/router-core/tests/route.test.ts b/packages/router-core/tests/route.test.ts index 2f87e5af82..ee4e656789 100644 --- a/packages/router-core/tests/route.test.ts +++ b/packages/router-core/tests/route.test.ts @@ -19,7 +19,7 @@ describe('routeOptionsHeadUnexpectedKeysWarning', () => { const validResult = { links: [], scripts: [], - meta: [] + meta: [], } routeOptionsHeadUnexpectedKeysWarning(validResult) @@ -32,13 +32,13 @@ describe('routeOptionsHeadUnexpectedKeysWarning', () => { links: [], scripts: [], meta: [], - unexpectedKey: 'value' + unexpectedKey: 'value', } routeOptionsHeadUnexpectedKeysWarning(invalidResult) expect(console.warn).toHaveBeenCalledWith( 'Route head option result has unexpected keys: "unexpectedKey".', - 'Only "links", "scripts", and "meta" are allowed' + 'Only "links", "scripts", and "meta" are allowed', ) }) @@ -48,7 +48,7 @@ describe('routeOptionsHeadUnexpectedKeysWarning', () => { links: [], scripts: [], meta: [], - unexpectedKey: 'value' + unexpectedKey: 'value', } routeOptionsHeadUnexpectedKeysWarning(invalidResult) @@ -58,7 +58,7 @@ describe('routeOptionsHeadUnexpectedKeysWarning', () => { it('should not warn when missing expected keys', () => { process.env.NODE_ENV = 'development' const partialResult = { - links: [] + links: [], // missing scripts and meta } @@ -70,13 +70,13 @@ describe('routeOptionsHeadUnexpectedKeysWarning', () => { process.env.NODE_ENV = 'development' const allInvalidResult = { invalidKey1: 'value1', - invalidKey2: 'value2' + 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' + 'Only "links", "scripts", and "meta" are allowed', ) }) -}) \ No newline at end of file +})