From edb28f52bb402ec600386c1f800c97a58b899a54 Mon Sep 17 00:00:00 2001 From: Hugo Striedinger Date: Fri, 21 Aug 2026 17:36:49 -0400 Subject: [PATCH 1/2] feat(router-core): scope staticData typing by route-id prefix --- docs/router/guide/static-route-data.md | 69 +++++++ packages/react-router/src/index.tsx | 3 + .../tests/staticDataByRoutePrefix.test-d.tsx | 179 ++++++++++++++++++ packages/router-core/src/fileRoute.ts | 64 +++---- packages/router-core/src/index.ts | 3 + packages/router-core/src/route.ts | 69 ++++++- packages/solid-router/src/index.tsx | 3 + .../tests/staticDataByRoutePrefix.test-d.tsx | 179 ++++++++++++++++++ packages/vue-router/src/index.tsx | 3 + 9 files changed, 537 insertions(+), 35 deletions(-) create mode 100644 packages/react-router/tests/staticDataByRoutePrefix.test-d.tsx create mode 100644 packages/solid-router/tests/staticDataByRoutePrefix.test-d.tsx diff --git a/docs/router/guide/static-route-data.md b/docs/router/guide/static-route-data.md index 2df546330bb..7cc5f3f5a34 100644 --- a/docs/router/guide/static-route-data.md +++ b/docs/router/guide/static-route-data.md @@ -161,6 +161,75 @@ declare module '@tanstack/solid-router' { As long as there are any required properties on the `StaticDataRouteOption`, you'll be required to pass in an object. +## Scoping Static Data by Route Prefix + +Augmenting `StaticDataRouteOption` types `staticData` the same way for every route in your app. If different sections of your route tree need different static data shapes — for example, each pathless layout route defines its own page-chrome configuration — you can register a shape per route-id prefix with `StaticDataByRoutePrefix`: + + + +# React + +```tsx +declare module '@tanstack/react-router' { + interface StaticDataByRoutePrefix { + '/_sidebar': { appBar?: { title: string } } + '/_details': { backTo: string } + } +} +``` + +# Solid + +```tsx +declare module '@tanstack/solid-router' { + interface StaticDataByRoutePrefix { + '/_sidebar': { appBar?: { title: string } } + '/_details': { backTo: string } + } +} +``` + + + +Every route whose id is the prefix itself or starts with `` `${prefix}/` `` gets its `staticData` typed as the registered shape. A route under `/_sidebar` now type-errors if it declares `staticData` matching another section's shape: + +```tsx +export const Route = createFileRoute('/_sidebar/home')({ + staticData: { + // Object literal may only specify known properties, and 'backTo' does not exist... + backTo: '/', + }, +}) +``` + +Routes that match no registered prefix fall back to `StaticDataRouteOption`, so the registry composes with the augmentations above. If several registered prefixes match the same route id (for example `/_sidebar` and `/_sidebar/settings`), `staticData` accepts the union of the registered shapes. + +Unlike `StaticDataRouteOption`, prefix-scoped `staticData` is always optional to declare, even when the registered shape has required properties: the registry constrains the shape of `staticData` where it is provided, not its presence. This lets a layout route declare defaults while its children override them selectively. + +You can look up the shape registered for a given route id with `StaticDataByRouteId`, which is useful when typing your own utilities that read `staticData`: + + + +# React + +```tsx +import type { StaticDataByRouteId } from '@tanstack/react-router' + +// SidebarPageConfig — '/_sidebar/home' matches the '/_sidebar' prefix +type HomeStaticData = StaticDataByRouteId<'/_sidebar/home'> +``` + +# Solid + +```tsx +import type { StaticDataByRouteId } from '@tanstack/solid-router' + +// SidebarPageConfig — '/_sidebar/home' matches the '/_sidebar' prefix +type HomeStaticData = StaticDataByRouteId<'/_sidebar/home'> +``` + + + ## Common Patterns ### Controlling Layout Visibility diff --git a/packages/react-router/src/index.tsx b/packages/react-router/src/index.tsx index 6637c7542bc..d80e197ec02 100644 --- a/packages/react-router/src/index.tsx +++ b/packages/react-router/src/index.tsx @@ -46,6 +46,9 @@ export type { PreloadableObj, RoutePathOptions, StaticDataRouteOption, + StaticDataByRoutePrefix, + StaticDataByRouteId, + UpdatableStaticRouteOptionByRouteId, RoutePathOptionsIntersection, UpdatableStaticRouteOption, MetaDescriptor, diff --git a/packages/react-router/tests/staticDataByRoutePrefix.test-d.tsx b/packages/react-router/tests/staticDataByRoutePrefix.test-d.tsx new file mode 100644 index 00000000000..48f98e2eca7 --- /dev/null +++ b/packages/react-router/tests/staticDataByRoutePrefix.test-d.tsx @@ -0,0 +1,179 @@ +import { expectTypeOf, test } from 'vitest' +import { createFileRoute, createRootRoute, createRoute } from '../src' +import type { StaticDataByRouteId, StaticDataRouteOption } from '../src' + +interface PillarStaticData { + layout: 'pillar' + collapsible?: boolean +} + +interface DetailStaticData { + layout: 'detail' + backTo: string +} + +interface NestedStaticData { + layout: 'nested' + depth: number +} + +declare module '@tanstack/router-core' { + interface StaticDataByRoutePrefix { + '/_staticPillar': PillarStaticData + '/_staticDetail': DetailStaticData + '/_staticPillar/nested': NestedStaticData + } +} + +const rootRoute = createRootRoute() + +const pillarRoute = createRoute({ + getParentRoute: () => rootRoute, + id: '_staticPillar', + staticData: { layout: 'pillar' }, +}) + +const pillarFileRoute = createFileRoute('/_staticPillar')() + +const pillarFileChildRoute = createFileRoute('/_staticPillar/dashboard')({ + staticData: { layout: 'pillar', collapsible: true }, +}) + +declare module '@tanstack/router-core' { + interface FileRoutesByPath { + '/_staticPillar': { + preLoaderRoute: typeof pillarFileRoute + parentRoute: typeof rootRoute + id: '/_staticPillar' + fullPath: string + path: string + } + '/_staticPillar/dashboard': { + preLoaderRoute: typeof pillarFileChildRoute + parentRoute: typeof pillarFileRoute + id: '/_staticPillar/dashboard' + fullPath: '/dashboard' + path: '/dashboard' + } + } +} + +test('StaticDataByRouteId maps a route id to its registered prefix shape', () => { + expectTypeOf< + StaticDataByRouteId<'/_staticPillar'> + >().toEqualTypeOf() + expectTypeOf< + StaticDataByRouteId<'/_staticPillar/dashboard'> + >().toEqualTypeOf() + expectTypeOf< + StaticDataByRouteId<'/_staticDetail/settings/profile'> + >().toEqualTypeOf() +}) + +test('StaticDataByRouteId only matches whole path segments', () => { + expectTypeOf< + StaticDataByRouteId<'/_staticPillarExtra'> + >().toEqualTypeOf() +}) + +test('StaticDataByRouteId falls back to StaticDataRouteOption when no prefix matches', () => { + expectTypeOf< + StaticDataByRouteId<'/unrelated'> + >().toEqualTypeOf() +}) + +test('overlapping registered prefixes union their shapes', () => { + expectTypeOf>().toEqualTypeOf< + PillarStaticData | NestedStaticData + >() + expectTypeOf< + StaticDataByRouteId<'/_staticPillar/nested/leaf'> + >().toEqualTypeOf() + + // a route under both prefixes accepts either registered shape + createRoute({ + getParentRoute: () => pillarRoute, + path: 'nested/leaf', + staticData: { layout: 'nested', depth: 1 }, + }) + + createRoute({ + getParentRoute: () => pillarRoute, + path: 'nested/leaf', + staticData: { layout: 'pillar' }, + }) + + createRoute({ + getParentRoute: () => pillarRoute, + path: 'nested/leaf', + // @ts-expect-error neither registered shape allows DetailStaticData + staticData: { layout: 'detail', backTo: '/' }, + }) +}) + +test('code-based routes type staticData by the registered prefix of their id', () => { + expectTypeOf(pillarRoute.id).toEqualTypeOf<'/_staticPillar'>() + + const dashboardRoute = createRoute({ + getParentRoute: () => pillarRoute, + path: 'dashboard', + staticData: { layout: 'pillar', collapsible: true }, + }) + + expectTypeOf(dashboardRoute.id).toEqualTypeOf<'/_staticPillar/dashboard'>() + + createRoute({ + getParentRoute: () => pillarRoute, + path: 'reports', + // @ts-expect-error a route under `/_staticPillar` must use PillarStaticData + staticData: { layout: 'detail', backTo: '/' }, + }) + + // presence stays optional under a prefix, even with required properties + createRoute({ + getParentRoute: () => pillarRoute, + path: 'metrics', + }) +}) + +test('update() types staticData by the registered prefix of the route id', () => { + pillarRoute.update({ staticData: { layout: 'pillar' } }) + + pillarRoute.update({ + // @ts-expect-error a route under `/_staticPillar` must use PillarStaticData + staticData: { layout: 'detail', backTo: '/' }, + }) +}) + +test('update() keeps the StaticDataRouteOption behavior outside registered prefixes', () => { + const legalRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'legal', + }) + + legalRoute.update({ staticData: { anything: 'goes' } }) +}) + +test('file-based routes type staticData by the registered prefix of their id', () => { + expectTypeOf( + pillarFileChildRoute.id, + ).toEqualTypeOf<'/_staticPillar/dashboard'>() + + createFileRoute('/_staticPillar/dashboard')({ + // @ts-expect-error a route under `/_staticPillar` must use PillarStaticData + staticData: { layout: 'detail', backTo: '/' }, + }) + + // presence stays optional under a prefix, even with required properties + createFileRoute('/_staticPillar/dashboard')({}) +}) + +test('routes outside any registered prefix keep the StaticDataRouteOption behavior', () => { + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'about', + staticData: { anything: 'goes' }, + }) + + expectTypeOf(aboutRoute.id).toEqualTypeOf<'/about'>() +}) diff --git a/packages/router-core/src/fileRoute.ts b/packages/router-core/src/fileRoute.ts index 90b47ab6437..87ca8301244 100644 --- a/packages/router-core/src/fileRoute.ts +++ b/packages/router-core/src/fileRoute.ts @@ -8,6 +8,7 @@ import type { Route, RouteConstraints, UpdatableRouteOptions, + UpdatableStaticRouteOptionByRouteId, } from './route' import type { AnyValidator } from './validators' @@ -33,7 +34,7 @@ export interface FileRoutesByPath { // } } -export interface FileRouteOptions< +export type FileRouteOptions< TRegister, TFilePath extends string, TParentRoute extends AnyRoute, @@ -49,37 +50,36 @@ export interface FileRouteOptions< TSSR = unknown, TServerMiddlewares = unknown, THandlers = undefined, -> - extends - FileBaseRouteOptions< - TRegister, - TParentRoute, - TId, - TPath, - TSearchValidator, - TParams, - TLoaderDeps, - TLoaderFn, - AnyContext, - TRouteContextFn, - TBeforeLoadFn, - AnyContext, - TSSR, - TServerMiddlewares, - THandlers - >, - UpdatableRouteOptions< - TParentRoute, - TId, - TFullPath, - TParams, - TSearchValidator, - TLoaderFn, - TLoaderDeps, - AnyContext, - TRouteContextFn, - TBeforeLoadFn - > {} +> = FileBaseRouteOptions< + TRegister, + TParentRoute, + TId, + TPath, + TSearchValidator, + TParams, + TLoaderDeps, + TLoaderFn, + AnyContext, + TRouteContextFn, + TBeforeLoadFn, + AnyContext, + TSSR, + TServerMiddlewares, + THandlers +> & + UpdatableRouteOptions< + TParentRoute, + TId, + TFullPath, + TParams, + TSearchValidator, + TLoaderFn, + TLoaderDeps, + AnyContext, + TRouteContextFn, + TBeforeLoadFn + > & + UpdatableStaticRouteOptionByRouteId export type CreateFileRoute< TFilePath extends string, diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts index a296629ffa3..1d358aa99f0 100644 --- a/packages/router-core/src/index.ts +++ b/packages/router-core/src/index.ts @@ -147,6 +147,9 @@ export type { PreloadableObj, RoutePathOptions, StaticDataRouteOption, + StaticDataByRoutePrefix, + StaticDataByRouteId, + UpdatableStaticRouteOptionByRouteId, RoutePathOptionsIntersection, SearchFilter, SearchMiddlewareContext, diff --git a/packages/router-core/src/route.ts b/packages/router-core/src/route.ts index f2543892677..cf29d9f61e3 100644 --- a/packages/router-core/src/route.ts +++ b/packages/router-core/src/route.ts @@ -230,6 +230,66 @@ export type UpdatableStaticRouteOption = {} extends StaticDataRouteOption ? OptionalStaticDataRouteOption : RequiredStaticDataRouteOption +/** + * Augmentable registry mapping a route-id prefix (typically a pathless + * layout route, e.g. `/_sidebar`) to the `staticData` shape accepted by + * every route whose id starts with that prefix. Routes that match no + * prefix fall back to `StaticDataRouteOption`. + * + * Prefix-scoped `staticData` is always optional to declare, even when the + * registered shape has required properties: the registry constrains the + * shape of `staticData` where it is provided, not its presence. When + * several registered prefixes match the same route id, `staticData` + * accepts the union of their shapes. + * + * @example + * ```ts + * declare module '@tanstack/react-router' { + * interface StaticDataByRoutePrefix { + * '/_sidebar': SidebarPageConfig + * '/_details': DetailsPageConfig + * } + * } + * ``` + */ +export interface StaticDataByRoutePrefix {} + +type StaticDataPrefixMatch = { + [K in keyof StaticDataByRoutePrefix]: TRouteId extends + | K + | `${K & string}/${string}` + ? StaticDataByRoutePrefix[K] + : never +}[keyof StaticDataByRoutePrefix] + +/** + * Maps a route id to the `staticData` shape registered for its prefix in + * `StaticDataByRoutePrefix`, falling back to `StaticDataRouteOption` when + * no prefix matches. Useful for typing utilities that read `staticData` + * for a known route id. + * + * The result is the registered shape only, not the composed constraint: + * routes under a matched prefix additionally accept `StaticDataRouteOption` + * members. When several registered prefixes match, the shapes union. + */ +export type StaticDataByRouteId = [ + StaticDataPrefixMatch, +] extends [never] + ? StaticDataRouteOption + : StaticDataPrefixMatch + +/** + * Narrows `staticData` to the shape registered for the route id's prefix. + * Evaluates to `unknown` (no additional constraint) when no prefix matches, + * preserving the plain `StaticDataRouteOption` behavior, including required + * `staticData` when that interface is augmented with required properties. + */ +export type UpdatableStaticRouteOptionByRouteId = [ + StaticDataPrefixMatch, +] extends [never] + ? unknown + : { staticData?: StaticDataPrefixMatch } + export type MetaDescriptor = | { charSet: 'utf-8' } | { title: string } @@ -741,7 +801,8 @@ export interface Route< TRouterContext, TRouteContextFn, TBeforeLoadFn - >, + > & + UpdatableStaticRouteOptionByRouteId, ) => this lazy: RouteLazyFn< Route< @@ -902,7 +963,8 @@ export type RouteOptions< NoInfer, NoInfer, NoInfer - > + > & + UpdatableStaticRouteOptionByRouteId> export type RouteContextFn< in out TParentRoute extends AnyRoute, @@ -1953,7 +2015,8 @@ export class BaseRoute< TRouterContext, TRouteContextFn, TBeforeLoadFn - >, + > & + UpdatableStaticRouteOptionByRouteId, ): this => { Object.assign(this.options, options) return this diff --git a/packages/solid-router/src/index.tsx b/packages/solid-router/src/index.tsx index 31be74086fd..0cfcac20e26 100644 --- a/packages/solid-router/src/index.tsx +++ b/packages/solid-router/src/index.tsx @@ -43,6 +43,9 @@ export type { PreloadableObj, RoutePathOptions, StaticDataRouteOption, + StaticDataByRoutePrefix, + StaticDataByRouteId, + UpdatableStaticRouteOptionByRouteId, RoutePathOptionsIntersection, UpdatableStaticRouteOption, MetaDescriptor, diff --git a/packages/solid-router/tests/staticDataByRoutePrefix.test-d.tsx b/packages/solid-router/tests/staticDataByRoutePrefix.test-d.tsx new file mode 100644 index 00000000000..48f98e2eca7 --- /dev/null +++ b/packages/solid-router/tests/staticDataByRoutePrefix.test-d.tsx @@ -0,0 +1,179 @@ +import { expectTypeOf, test } from 'vitest' +import { createFileRoute, createRootRoute, createRoute } from '../src' +import type { StaticDataByRouteId, StaticDataRouteOption } from '../src' + +interface PillarStaticData { + layout: 'pillar' + collapsible?: boolean +} + +interface DetailStaticData { + layout: 'detail' + backTo: string +} + +interface NestedStaticData { + layout: 'nested' + depth: number +} + +declare module '@tanstack/router-core' { + interface StaticDataByRoutePrefix { + '/_staticPillar': PillarStaticData + '/_staticDetail': DetailStaticData + '/_staticPillar/nested': NestedStaticData + } +} + +const rootRoute = createRootRoute() + +const pillarRoute = createRoute({ + getParentRoute: () => rootRoute, + id: '_staticPillar', + staticData: { layout: 'pillar' }, +}) + +const pillarFileRoute = createFileRoute('/_staticPillar')() + +const pillarFileChildRoute = createFileRoute('/_staticPillar/dashboard')({ + staticData: { layout: 'pillar', collapsible: true }, +}) + +declare module '@tanstack/router-core' { + interface FileRoutesByPath { + '/_staticPillar': { + preLoaderRoute: typeof pillarFileRoute + parentRoute: typeof rootRoute + id: '/_staticPillar' + fullPath: string + path: string + } + '/_staticPillar/dashboard': { + preLoaderRoute: typeof pillarFileChildRoute + parentRoute: typeof pillarFileRoute + id: '/_staticPillar/dashboard' + fullPath: '/dashboard' + path: '/dashboard' + } + } +} + +test('StaticDataByRouteId maps a route id to its registered prefix shape', () => { + expectTypeOf< + StaticDataByRouteId<'/_staticPillar'> + >().toEqualTypeOf() + expectTypeOf< + StaticDataByRouteId<'/_staticPillar/dashboard'> + >().toEqualTypeOf() + expectTypeOf< + StaticDataByRouteId<'/_staticDetail/settings/profile'> + >().toEqualTypeOf() +}) + +test('StaticDataByRouteId only matches whole path segments', () => { + expectTypeOf< + StaticDataByRouteId<'/_staticPillarExtra'> + >().toEqualTypeOf() +}) + +test('StaticDataByRouteId falls back to StaticDataRouteOption when no prefix matches', () => { + expectTypeOf< + StaticDataByRouteId<'/unrelated'> + >().toEqualTypeOf() +}) + +test('overlapping registered prefixes union their shapes', () => { + expectTypeOf>().toEqualTypeOf< + PillarStaticData | NestedStaticData + >() + expectTypeOf< + StaticDataByRouteId<'/_staticPillar/nested/leaf'> + >().toEqualTypeOf() + + // a route under both prefixes accepts either registered shape + createRoute({ + getParentRoute: () => pillarRoute, + path: 'nested/leaf', + staticData: { layout: 'nested', depth: 1 }, + }) + + createRoute({ + getParentRoute: () => pillarRoute, + path: 'nested/leaf', + staticData: { layout: 'pillar' }, + }) + + createRoute({ + getParentRoute: () => pillarRoute, + path: 'nested/leaf', + // @ts-expect-error neither registered shape allows DetailStaticData + staticData: { layout: 'detail', backTo: '/' }, + }) +}) + +test('code-based routes type staticData by the registered prefix of their id', () => { + expectTypeOf(pillarRoute.id).toEqualTypeOf<'/_staticPillar'>() + + const dashboardRoute = createRoute({ + getParentRoute: () => pillarRoute, + path: 'dashboard', + staticData: { layout: 'pillar', collapsible: true }, + }) + + expectTypeOf(dashboardRoute.id).toEqualTypeOf<'/_staticPillar/dashboard'>() + + createRoute({ + getParentRoute: () => pillarRoute, + path: 'reports', + // @ts-expect-error a route under `/_staticPillar` must use PillarStaticData + staticData: { layout: 'detail', backTo: '/' }, + }) + + // presence stays optional under a prefix, even with required properties + createRoute({ + getParentRoute: () => pillarRoute, + path: 'metrics', + }) +}) + +test('update() types staticData by the registered prefix of the route id', () => { + pillarRoute.update({ staticData: { layout: 'pillar' } }) + + pillarRoute.update({ + // @ts-expect-error a route under `/_staticPillar` must use PillarStaticData + staticData: { layout: 'detail', backTo: '/' }, + }) +}) + +test('update() keeps the StaticDataRouteOption behavior outside registered prefixes', () => { + const legalRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'legal', + }) + + legalRoute.update({ staticData: { anything: 'goes' } }) +}) + +test('file-based routes type staticData by the registered prefix of their id', () => { + expectTypeOf( + pillarFileChildRoute.id, + ).toEqualTypeOf<'/_staticPillar/dashboard'>() + + createFileRoute('/_staticPillar/dashboard')({ + // @ts-expect-error a route under `/_staticPillar` must use PillarStaticData + staticData: { layout: 'detail', backTo: '/' }, + }) + + // presence stays optional under a prefix, even with required properties + createFileRoute('/_staticPillar/dashboard')({}) +}) + +test('routes outside any registered prefix keep the StaticDataRouteOption behavior', () => { + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'about', + staticData: { anything: 'goes' }, + }) + + expectTypeOf(aboutRoute.id).toEqualTypeOf<'/about'>() +}) diff --git a/packages/vue-router/src/index.tsx b/packages/vue-router/src/index.tsx index 2e14e349f06..e76f42242a8 100644 --- a/packages/vue-router/src/index.tsx +++ b/packages/vue-router/src/index.tsx @@ -44,6 +44,9 @@ export type { PreloadableObj, RoutePathOptions, StaticDataRouteOption, + StaticDataByRoutePrefix, + StaticDataByRouteId, + UpdatableStaticRouteOptionByRouteId, RoutePathOptionsIntersection, UpdatableStaticRouteOption, MetaDescriptor, From 504aaf527b69838e57b7397514d93c63db027a9e Mon Sep 17 00:00:00 2001 From: Hugo Striedinger Date: Fri, 21 Aug 2026 21:01:10 -0400 Subject: [PATCH 2/2] fix(router-core): replace the global staticData option on prefixed routes A route id matching a registered StaticDataByRoutePrefix prefix now has its staticData option replaced by the registered shape (optional and exact) instead of intersected with StaticDataRouteOption, so a required global augmentation no longer applies to prefixed routes. Wire createFileRoute in react/solid/vue through the registry, which previously bypassed it, and pin wide route ids to `staticData?: any` so prefixed and unprefixed routes both stay assignable to AnyRoute. --- docs/router/guide/static-route-data.md | 8 +- packages/react-router/src/fileRoute.ts | 8 +- packages/react-router/src/index.tsx | 1 + .../tests/staticDataByRoutePrefix.test-d.tsx | 43 +++++- packages/router-core/src/fileRoute.ts | 3 +- packages/router-core/src/index.ts | 1 + packages/router-core/src/route.ts | 123 +++++++++++++----- packages/solid-router/src/fileRoute.ts | 8 +- packages/solid-router/src/index.tsx | 1 + .../tests/staticDataByRoutePrefix.test-d.tsx | 43 +++++- packages/vue-router/src/fileRoute.ts | 8 +- packages/vue-router/src/index.tsx | 1 + 12 files changed, 198 insertions(+), 50 deletions(-) diff --git a/docs/router/guide/static-route-data.md b/docs/router/guide/static-route-data.md index 7cc5f3f5a34..022bf71aed5 100644 --- a/docs/router/guide/static-route-data.md +++ b/docs/router/guide/static-route-data.md @@ -202,9 +202,9 @@ export const Route = createFileRoute('/_sidebar/home')({ }) ``` -Routes that match no registered prefix fall back to `StaticDataRouteOption`, so the registry composes with the augmentations above. If several registered prefixes match the same route id (for example `/_sidebar` and `/_sidebar/settings`), `staticData` accepts the union of the registered shapes. +A matching prefix fully replaces the global static data shape for those routes: `staticData` is typed as exactly the registered shape, and a `StaticDataRouteOption` augmentation no longer applies there — even one with required properties, as in [Enforcing Static Data](#enforcing-static-data) above. Routes that match no registered prefix keep the plain `StaticDataRouteOption` behavior, unchanged. If several registered prefixes match the same route id (for example `/_sidebar` and `/_sidebar/settings`), `staticData` accepts the union of the registered shapes. -Unlike `StaticDataRouteOption`, prefix-scoped `staticData` is always optional to declare, even when the registered shape has required properties: the registry constrains the shape of `staticData` where it is provided, not its presence. This lets a layout route declare defaults while its children override them selectively. +Prefix-scoped `staticData` is always optional to declare, even when the registered shape has required properties: the registry constrains the shape of `staticData` where it is provided, not its presence. This lets a layout route declare defaults while its children override them selectively. You can look up the shape registered for a given route id with `StaticDataByRouteId`, which is useful when typing your own utilities that read `staticData`: @@ -215,7 +215,7 @@ You can look up the shape registered for a given route id with `StaticDataByRout ```tsx import type { StaticDataByRouteId } from '@tanstack/react-router' -// SidebarPageConfig — '/_sidebar/home' matches the '/_sidebar' prefix +// { appBar?: { title: string } } — '/_sidebar/home' matches the '/_sidebar' prefix type HomeStaticData = StaticDataByRouteId<'/_sidebar/home'> ``` @@ -224,7 +224,7 @@ type HomeStaticData = StaticDataByRouteId<'/_sidebar/home'> ```tsx import type { StaticDataByRouteId } from '@tanstack/solid-router' -// SidebarPageConfig — '/_sidebar/home' matches the '/_sidebar' prefix +// { appBar?: { title: string } } — '/_sidebar/home' matches the '/_sidebar' prefix type HomeStaticData = StaticDataByRouteId<'/_sidebar/home'> ``` diff --git a/packages/react-router/src/fileRoute.ts b/packages/react-router/src/fileRoute.ts index e5fdb4993b9..7ff6f3d73cd 100644 --- a/packages/react-router/src/fileRoute.ts +++ b/packages/react-router/src/fileRoute.ts @@ -28,7 +28,8 @@ import type { RouteConstraints, RouteIds, RouteLoaderEntry, - UpdatableRouteOptions, + UpdatableRouteOptionsWithoutStaticData, + UpdatableStaticRouteOptionByRouteId, UseNavigateResult, } from '@tanstack/router-core' import type { UseLoaderDepsRoute } from './useLoaderDeps' @@ -115,7 +116,7 @@ export class FileRoute< TMiddlewares, THandlers > & - UpdatableRouteOptions< + UpdatableRouteOptionsWithoutStaticData< TParentRoute, TId, TFullPath, @@ -126,7 +127,8 @@ export class FileRoute< AnyContext, TRouteContextFn, TBeforeLoadFn - >, + > & + UpdatableStaticRouteOptionByRouteId, ): Route< TRegister, TParentRoute, diff --git a/packages/react-router/src/index.tsx b/packages/react-router/src/index.tsx index d80e197ec02..b9123a1f70d 100644 --- a/packages/react-router/src/index.tsx +++ b/packages/react-router/src/index.tsx @@ -200,6 +200,7 @@ export type { FileBaseRouteOptions, BaseRouteOptions, UpdatableRouteOptions, + UpdatableRouteOptionsWithoutStaticData, RouteLoaderFn, LoaderFnContext, LazyRouteOptions, diff --git a/packages/react-router/tests/staticDataByRoutePrefix.test-d.tsx b/packages/react-router/tests/staticDataByRoutePrefix.test-d.tsx index 48f98e2eca7..b474c687a64 100644 --- a/packages/react-router/tests/staticDataByRoutePrefix.test-d.tsx +++ b/packages/react-router/tests/staticDataByRoutePrefix.test-d.tsx @@ -1,6 +1,11 @@ import { expectTypeOf, test } from 'vitest' import { createFileRoute, createRootRoute, createRoute } from '../src' -import type { StaticDataByRouteId, StaticDataRouteOption } from '../src' +import type { + AnyRoute, + StaticDataByRouteId, + StaticDataRouteOption, + UpdatableStaticRouteOptionByRouteId, +} from '../src' interface PillarStaticData { layout: 'pillar' @@ -90,7 +95,6 @@ test('overlapping registered prefixes union their shapes', () => { StaticDataByRouteId<'/_staticPillar/nested/leaf'> >().toEqualTypeOf() - // a route under both prefixes accepts either registered shape createRoute({ getParentRoute: () => pillarRoute, path: 'nested/leaf', @@ -154,6 +158,28 @@ test('update() keeps the StaticDataRouteOption behavior outside registered prefi legalRoute.update({ staticData: { anything: 'goes' } }) }) +test('a matching prefix replaces the staticData option instead of intersecting it', () => { + // exactly the registered shape, not `StaticDataRouteOption & ` + expectTypeOf< + Parameters[0]['staticData'] + >().toEqualTypeOf() + + const legalRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'legal', + }) + + expectTypeOf< + Parameters[0]['staticData'] + >().toEqualTypeOf() + + // That a prefix also lifts a *required* `StaticDataRouteOption` + // augmentation (the "Enforcing Static Data" pattern) cannot be asserted + // here: augmenting that interface would leak into every other test of + // this shared tsconfig project, so that case is pinned only by the + // implementation of `UpdatableStaticRouteOptionByRouteId`. +}) + test('file-based routes type staticData by the registered prefix of their id', () => { expectTypeOf( pillarFileChildRoute.id, @@ -177,3 +203,16 @@ test('routes outside any registered prefix keep the StaticDataRouteOption behavi expectTypeOf(aboutRoute.id).toEqualTypeOf<'/about'>() }) + +test('non-literal route ids type staticData permissively once a prefix is registered', () => { + // Wide instantiations such as `AnyRoute` cover prefixed routes (optional, + // prefix-shaped staticData) and unprefixed routes (StaticDataRouteOption) + // at once, so non-literal ids widen to `{ staticData?: any }`. + expectTypeOf>().toEqualTypeOf<{ + staticData?: any + }>() + + // ...which keeps prefixed and unprefixed routes assignable to AnyRoute. + const routes: Array = [rootRoute, pillarRoute, pillarFileChildRoute] + expectTypeOf(routes).toEqualTypeOf>() +}) diff --git a/packages/router-core/src/fileRoute.ts b/packages/router-core/src/fileRoute.ts index 87ca8301244..98dc5c23a6b 100644 --- a/packages/router-core/src/fileRoute.ts +++ b/packages/router-core/src/fileRoute.ts @@ -8,6 +8,7 @@ import type { Route, RouteConstraints, UpdatableRouteOptions, + UpdatableRouteOptionsWithoutStaticData, UpdatableStaticRouteOptionByRouteId, } from './route' import type { AnyValidator } from './validators' @@ -67,7 +68,7 @@ export type FileRouteOptions< TServerMiddlewares, THandlers > & - UpdatableRouteOptions< + UpdatableRouteOptionsWithoutStaticData< TParentRoute, TId, TFullPath, diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts index 1d358aa99f0..12b49bd3153 100644 --- a/packages/router-core/src/index.ts +++ b/packages/router-core/src/index.ts @@ -202,6 +202,7 @@ export type { FileBaseRouteOptions, BaseRouteOptions, UpdatableRouteOptions, + UpdatableRouteOptionsWithoutStaticData, LoaderStaleReloadMode, RouteLoaderFn, RouteLoaderEntry, diff --git a/packages/router-core/src/route.ts b/packages/router-core/src/route.ts index cf29d9f61e3..12216a61820 100644 --- a/packages/router-core/src/route.ts +++ b/packages/router-core/src/route.ts @@ -233,14 +233,16 @@ export type UpdatableStaticRouteOption = {} extends StaticDataRouteOption /** * Augmentable registry mapping a route-id prefix (typically a pathless * layout route, e.g. `/_sidebar`) to the `staticData` shape accepted by - * every route whose id starts with that prefix. Routes that match no - * prefix fall back to `StaticDataRouteOption`. + * every route whose id starts with that prefix. A matching prefix replaces + * `StaticDataRouteOption` for those routes entirely; routes that match no + * prefix keep the plain `StaticDataRouteOption` behavior. * * Prefix-scoped `staticData` is always optional to declare, even when the - * registered shape has required properties: the registry constrains the - * shape of `staticData` where it is provided, not its presence. When - * several registered prefixes match the same route id, `staticData` - * accepts the union of their shapes. + * registered shape has required properties or `StaticDataRouteOption` is + * augmented with required properties: the registry constrains the shape of + * `staticData` where it is provided, not its presence. When several + * registered prefixes match the same route id, `staticData` accepts the + * union of their shapes. * * @example * ```ts @@ -254,13 +256,16 @@ export type UpdatableStaticRouteOption = {} extends StaticDataRouteOption */ export interface StaticDataByRoutePrefix {} -type StaticDataPrefixMatch = { - [K in keyof StaticDataByRoutePrefix]: TRouteId extends - | K - | `${K & string}/${string}` - ? StaticDataByRoutePrefix[K] - : never -}[keyof StaticDataByRoutePrefix] +type StaticDataPrefixMatch = string extends TRouteId + ? // Non-literal ids (`string`, `any`) never match a registered prefix. + never + : { + [K in keyof StaticDataByRoutePrefix]: TRouteId extends + | K + | `${K & string}/${string}` + ? StaticDataByRoutePrefix[K] + : never + }[keyof StaticDataByRoutePrefix] /** * Maps a route id to the `staticData` shape registered for its prefix in @@ -268,9 +273,9 @@ type StaticDataPrefixMatch = { * no prefix matches. Useful for typing utilities that read `staticData` * for a known route id. * - * The result is the registered shape only, not the composed constraint: - * routes under a matched prefix additionally accept `StaticDataRouteOption` - * members. When several registered prefixes match, the shapes union. + * A matched prefix replaces `StaticDataRouteOption`, so the result is + * exactly the shape such routes accept. When several registered prefixes + * match, the shapes union. */ export type StaticDataByRouteId = [ StaticDataPrefixMatch, @@ -279,16 +284,30 @@ export type StaticDataByRouteId = [ : StaticDataPrefixMatch /** - * Narrows `staticData` to the shape registered for the route id's prefix. - * Evaluates to `unknown` (no additional constraint) when no prefix matches, - * preserving the plain `StaticDataRouteOption` behavior, including required - * `staticData` when that interface is augmented with required properties. + * Computes the `staticData` route option for a route id. A prefix + * registered in `StaticDataByRoutePrefix` replaces `StaticDataRouteOption` + * for the routes it matches: `staticData` becomes optional and accepts + * exactly the registered shape (a union when several prefixes match). + * When no prefix matches, evaluates to the plain + * `UpdatableStaticRouteOption`, including required `staticData` when + * `StaticDataRouteOption` is augmented with required properties. + * + * While `StaticDataByRoutePrefix` is empty this is always the plain + * `UpdatableStaticRouteOption`. Once a prefix is registered, non-literal + * route ids (`string`, and `any` as in `AnyRoute` instantiations) widen + * to the permissive `{ staticData?: any }` so that prefixed (optional, + * prefix-shaped) and unprefixed (`StaticDataRouteOption`) routes both + * stay assignable to them. */ export type UpdatableStaticRouteOptionByRouteId = [ - StaticDataPrefixMatch, + keyof StaticDataByRoutePrefix, ] extends [never] - ? unknown - : { staticData?: StaticDataPrefixMatch } + ? UpdatableStaticRouteOption + : string extends TRouteId + ? { staticData?: any } + : [StaticDataPrefixMatch] extends [never] + ? UpdatableStaticRouteOption + : { staticData?: StaticDataPrefixMatch } export type MetaDescriptor = | { charSet: 'utf-8' } @@ -789,8 +808,14 @@ export interface Route< rank: number to: TrimPathRight init: (opts: { originalIndex: number }) => void - update: ( - options: UpdatableRouteOptions< + // Declared method-style (not as a function-typed property) so that + // `update` parameters are related bivariantly: `staticData` varies by + // route id once a prefix is registered, and strict contravariance would + // otherwise make routes with diverging `staticData` (e.g. prefixed + // routes) unassignable to `AnyRoute`. + // eslint-disable-next-line @typescript-eslint/method-signature-style + update( + options: UpdatableRouteOptionsWithoutStaticData< TParentRoute, TCustomId, TFullPath, @@ -803,7 +828,7 @@ export interface Route< TBeforeLoadFn > & UpdatableStaticRouteOptionByRouteId, - ) => this + ): this lazy: RouteLazyFn< Route< TRegister, @@ -952,7 +977,7 @@ export type RouteOptions< TServerMiddlewares, THandlers > & - UpdatableRouteOptions< + UpdatableRouteOptionsWithoutStaticData< NoInfer, NoInfer, NoInfer, @@ -964,7 +989,10 @@ export type RouteOptions< NoInfer, NoInfer > & - UpdatableStaticRouteOptionByRouteId> + // Not wrapped in `NoInfer`: conditional types are never inference + // sources, and a retained `NoInfer` substitution would defeat the + // route-id prefix matching (and its `any` guard) above. + UpdatableStaticRouteOptionByRouteId export type RouteContextFn< in out TParentRoute extends AnyRoute, @@ -1311,7 +1339,12 @@ export interface DefaultUpdatableRouteOptionsExtensions { export interface UpdatableRouteOptionsExtensions extends DefaultUpdatableRouteOptionsExtensions {} -export interface UpdatableRouteOptions< +/** + * Intersect with `UpdatableStaticRouteOptionByRouteId` to type + * `staticData` by route id, or use `UpdatableRouteOptions` for the plain + * `StaticDataRouteOption` behavior. + */ +export interface UpdatableRouteOptionsWithoutStaticData< in out TParentRoute extends AnyRoute, in out TRouteId, in out TFullPath, @@ -1322,8 +1355,7 @@ export interface UpdatableRouteOptions< in out TRouterContext, in out TRouteContextFn, in out TBeforeLoadFn, -> - extends UpdatableStaticRouteOption, UpdatableRouteOptionsExtensions { +> extends UpdatableRouteOptionsExtensions { /** * If true, this route will be matched as case-sensitive * @@ -1470,6 +1502,33 @@ export interface UpdatableRouteOptions< > } +export interface UpdatableRouteOptions< + in out TParentRoute extends AnyRoute, + in out TRouteId, + in out TFullPath, + in out TParams, + in out TSearchValidator, + in out TLoaderFn, + in out TLoaderDeps, + in out TRouterContext, + in out TRouteContextFn, + in out TBeforeLoadFn, +> + extends + UpdatableRouteOptionsWithoutStaticData< + TParentRoute, + TRouteId, + TFullPath, + TParams, + TSearchValidator, + TLoaderFn, + TLoaderDeps, + TRouterContext, + TRouteContextFn, + TBeforeLoadFn + >, + UpdatableStaticRouteOption {} + export type RouteLoaderFn< in out TRegister, in out TParentRoute extends AnyRoute = AnyRoute, @@ -2004,7 +2063,7 @@ export class BaseRoute< } update = ( - options: UpdatableRouteOptions< + options: UpdatableRouteOptionsWithoutStaticData< TParentRoute, TCustomId, TFullPath, diff --git a/packages/solid-router/src/fileRoute.ts b/packages/solid-router/src/fileRoute.ts index 1115ee54144..3db0e9b7ffc 100644 --- a/packages/solid-router/src/fileRoute.ts +++ b/packages/solid-router/src/fileRoute.ts @@ -28,7 +28,8 @@ import type { RouteConstraints, RouteIds, RouteLoaderEntry, - UpdatableRouteOptions, + UpdatableRouteOptionsWithoutStaticData, + UpdatableStaticRouteOptionByRouteId, UseNavigateResult, } from '@tanstack/router-core' import type { UseLoaderDepsRoute } from './useLoaderDeps' @@ -104,7 +105,7 @@ export class FileRoute< TMiddlewares, THandlers > & - UpdatableRouteOptions< + UpdatableRouteOptionsWithoutStaticData< TParentRoute, TId, TFullPath, @@ -115,7 +116,8 @@ export class FileRoute< AnyContext, TRouteContextFn, TBeforeLoadFn - >, + > & + UpdatableStaticRouteOptionByRouteId, ): Route< TRegister, TParentRoute, diff --git a/packages/solid-router/src/index.tsx b/packages/solid-router/src/index.tsx index 0cfcac20e26..ffbb2cf51c9 100644 --- a/packages/solid-router/src/index.tsx +++ b/packages/solid-router/src/index.tsx @@ -152,6 +152,7 @@ export type { FileBaseRouteOptions, BaseRouteOptions, UpdatableRouteOptions, + UpdatableRouteOptionsWithoutStaticData, RouteLoaderFn, LoaderFnContext, MakeRouteMatch, diff --git a/packages/solid-router/tests/staticDataByRoutePrefix.test-d.tsx b/packages/solid-router/tests/staticDataByRoutePrefix.test-d.tsx index 48f98e2eca7..b474c687a64 100644 --- a/packages/solid-router/tests/staticDataByRoutePrefix.test-d.tsx +++ b/packages/solid-router/tests/staticDataByRoutePrefix.test-d.tsx @@ -1,6 +1,11 @@ import { expectTypeOf, test } from 'vitest' import { createFileRoute, createRootRoute, createRoute } from '../src' -import type { StaticDataByRouteId, StaticDataRouteOption } from '../src' +import type { + AnyRoute, + StaticDataByRouteId, + StaticDataRouteOption, + UpdatableStaticRouteOptionByRouteId, +} from '../src' interface PillarStaticData { layout: 'pillar' @@ -90,7 +95,6 @@ test('overlapping registered prefixes union their shapes', () => { StaticDataByRouteId<'/_staticPillar/nested/leaf'> >().toEqualTypeOf() - // a route under both prefixes accepts either registered shape createRoute({ getParentRoute: () => pillarRoute, path: 'nested/leaf', @@ -154,6 +158,28 @@ test('update() keeps the StaticDataRouteOption behavior outside registered prefi legalRoute.update({ staticData: { anything: 'goes' } }) }) +test('a matching prefix replaces the staticData option instead of intersecting it', () => { + // exactly the registered shape, not `StaticDataRouteOption & ` + expectTypeOf< + Parameters[0]['staticData'] + >().toEqualTypeOf() + + const legalRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'legal', + }) + + expectTypeOf< + Parameters[0]['staticData'] + >().toEqualTypeOf() + + // That a prefix also lifts a *required* `StaticDataRouteOption` + // augmentation (the "Enforcing Static Data" pattern) cannot be asserted + // here: augmenting that interface would leak into every other test of + // this shared tsconfig project, so that case is pinned only by the + // implementation of `UpdatableStaticRouteOptionByRouteId`. +}) + test('file-based routes type staticData by the registered prefix of their id', () => { expectTypeOf( pillarFileChildRoute.id, @@ -177,3 +203,16 @@ test('routes outside any registered prefix keep the StaticDataRouteOption behavi expectTypeOf(aboutRoute.id).toEqualTypeOf<'/about'>() }) + +test('non-literal route ids type staticData permissively once a prefix is registered', () => { + // Wide instantiations such as `AnyRoute` cover prefixed routes (optional, + // prefix-shaped staticData) and unprefixed routes (StaticDataRouteOption) + // at once, so non-literal ids widen to `{ staticData?: any }`. + expectTypeOf>().toEqualTypeOf<{ + staticData?: any + }>() + + // ...which keeps prefixed and unprefixed routes assignable to AnyRoute. + const routes: Array = [rootRoute, pillarRoute, pillarFileChildRoute] + expectTypeOf(routes).toEqualTypeOf>() +}) diff --git a/packages/vue-router/src/fileRoute.ts b/packages/vue-router/src/fileRoute.ts index 0e0b6c8368f..9637b010390 100644 --- a/packages/vue-router/src/fileRoute.ts +++ b/packages/vue-router/src/fileRoute.ts @@ -28,7 +28,8 @@ import type { RouteConstraints, RouteIds, RouteLoaderEntry, - UpdatableRouteOptions, + UpdatableRouteOptionsWithoutStaticData, + UpdatableStaticRouteOptionByRouteId, UseNavigateResult, } from '@tanstack/router-core' import type { UseLoaderDepsRoute } from './useLoaderDeps' @@ -104,7 +105,7 @@ export class FileRoute< TMiddlewares, THandlers > & - UpdatableRouteOptions< + UpdatableRouteOptionsWithoutStaticData< TParentRoute, TId, TFullPath, @@ -115,7 +116,8 @@ export class FileRoute< AnyContext, TRouteContextFn, TBeforeLoadFn - >, + > & + UpdatableStaticRouteOptionByRouteId, ): Route< TRegister, TParentRoute, diff --git a/packages/vue-router/src/index.tsx b/packages/vue-router/src/index.tsx index e76f42242a8..7c5ad469cec 100644 --- a/packages/vue-router/src/index.tsx +++ b/packages/vue-router/src/index.tsx @@ -195,6 +195,7 @@ export type { FileBaseRouteOptions, BaseRouteOptions, UpdatableRouteOptions, + UpdatableRouteOptionsWithoutStaticData, RouteLoaderFn, LoaderFnContext, LazyRouteOptions,