diff --git a/docs/router/guide/static-route-data.md b/docs/router/guide/static-route-data.md index 2df546330b..022bf71aed 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: '/', + }, +}) +``` + +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. + +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' + +// { appBar?: { title: string } } — '/_sidebar/home' matches the '/_sidebar' prefix +type HomeStaticData = StaticDataByRouteId<'/_sidebar/home'> +``` + +# Solid + +```tsx +import type { StaticDataByRouteId } from '@tanstack/solid-router' + +// { appBar?: { title: string } } — '/_sidebar/home' matches the '/_sidebar' prefix +type HomeStaticData = StaticDataByRouteId<'/_sidebar/home'> +``` + + + ## Common Patterns ### Controlling Layout Visibility diff --git a/packages/react-router/src/fileRoute.ts b/packages/react-router/src/fileRoute.ts index e5fdb4993b..7ff6f3d73c 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 6637c7542b..b9123a1f70 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, @@ -197,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 new file mode 100644 index 0000000000..b474c687a6 --- /dev/null +++ b/packages/react-router/tests/staticDataByRoutePrefix.test-d.tsx @@ -0,0 +1,218 @@ +import { expectTypeOf, test } from 'vitest' +import { createFileRoute, createRootRoute, createRoute } from '../src' +import type { + AnyRoute, + StaticDataByRouteId, + StaticDataRouteOption, + UpdatableStaticRouteOptionByRouteId, +} 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() + + 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('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, + ).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'>() +}) + +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 90b47ab643..98dc5c23a6 100644 --- a/packages/router-core/src/fileRoute.ts +++ b/packages/router-core/src/fileRoute.ts @@ -8,6 +8,8 @@ import type { Route, RouteConstraints, UpdatableRouteOptions, + UpdatableRouteOptionsWithoutStaticData, + UpdatableStaticRouteOptionByRouteId, } from './route' import type { AnyValidator } from './validators' @@ -33,7 +35,7 @@ export interface FileRoutesByPath { // } } -export interface FileRouteOptions< +export type FileRouteOptions< TRegister, TFilePath extends string, TParentRoute extends AnyRoute, @@ -49,37 +51,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 +> & + UpdatableRouteOptionsWithoutStaticData< + 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 a296629ffa..12b49bd315 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, @@ -199,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 f254389267..12216a6182 100644 --- a/packages/router-core/src/route.ts +++ b/packages/router-core/src/route.ts @@ -230,6 +230,85 @@ 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. 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 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 + * declare module '@tanstack/react-router' { + * interface StaticDataByRoutePrefix { + * '/_sidebar': SidebarPageConfig + * '/_details': DetailsPageConfig + * } + * } + * ``` + */ +export interface 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 + * `StaticDataByRoutePrefix`, falling back to `StaticDataRouteOption` when + * no prefix matches. Useful for typing utilities that read `staticData` + * for a known route id. + * + * 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, +] extends [never] + ? StaticDataRouteOption + : StaticDataPrefixMatch + +/** + * 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 = [ + keyof StaticDataByRoutePrefix, +] extends [never] + ? UpdatableStaticRouteOption + : string extends TRouteId + ? { staticData?: any } + : [StaticDataPrefixMatch] extends [never] + ? UpdatableStaticRouteOption + : { staticData?: StaticDataPrefixMatch } + export type MetaDescriptor = | { charSet: 'utf-8' } | { title: string } @@ -729,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, @@ -741,8 +826,9 @@ export interface Route< TRouterContext, TRouteContextFn, TBeforeLoadFn - >, - ) => this + > & + UpdatableStaticRouteOptionByRouteId, + ): this lazy: RouteLazyFn< Route< TRegister, @@ -891,7 +977,7 @@ export type RouteOptions< TServerMiddlewares, THandlers > & - UpdatableRouteOptions< + UpdatableRouteOptionsWithoutStaticData< NoInfer, NoInfer, NoInfer, @@ -902,7 +988,11 @@ export type RouteOptions< NoInfer, NoInfer, NoInfer - > + > & + // 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, @@ -1249,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, @@ -1260,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 * @@ -1408,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, @@ -1942,7 +2063,7 @@ export class BaseRoute< } update = ( - options: UpdatableRouteOptions< + options: UpdatableRouteOptionsWithoutStaticData< TParentRoute, TCustomId, TFullPath, @@ -1953,7 +2074,8 @@ export class BaseRoute< TRouterContext, TRouteContextFn, TBeforeLoadFn - >, + > & + UpdatableStaticRouteOptionByRouteId, ): this => { Object.assign(this.options, options) return this diff --git a/packages/solid-router/src/fileRoute.ts b/packages/solid-router/src/fileRoute.ts index 1115ee5414..3db0e9b7ff 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 31be74086f..ffbb2cf51c 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, @@ -149,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 new file mode 100644 index 0000000000..b474c687a6 --- /dev/null +++ b/packages/solid-router/tests/staticDataByRoutePrefix.test-d.tsx @@ -0,0 +1,218 @@ +import { expectTypeOf, test } from 'vitest' +import { createFileRoute, createRootRoute, createRoute } from '../src' +import type { + AnyRoute, + StaticDataByRouteId, + StaticDataRouteOption, + UpdatableStaticRouteOptionByRouteId, +} 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() + + 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('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, + ).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'>() +}) + +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 0e0b6c8368..9637b01039 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 2e14e349f0..7c5ad469ce 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, @@ -192,6 +195,7 @@ export type { FileBaseRouteOptions, BaseRouteOptions, UpdatableRouteOptions, + UpdatableRouteOptionsWithoutStaticData, RouteLoaderFn, LoaderFnContext, LazyRouteOptions,