Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions docs/router/guide/static-route-data.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:

<!-- ::start:framework -->

# React

```tsx
declare module '@tanstack/react-router' {
interface StaticDataByRoutePrefix {
'/_sidebar': { appBar?: { title: string } }
'/_details': { backTo: string }
}
}
```

# Solid
Comment thread
striedinger marked this conversation as resolved.

```tsx
declare module '@tanstack/solid-router' {
interface StaticDataByRoutePrefix {
'/_sidebar': { appBar?: { title: string } }
'/_details': { backTo: string }
}
}
```

<!-- ::end:framework -->

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`:

<!-- ::start:framework -->

# 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'>
```

<!-- ::end:framework -->

## Common Patterns

### Controlling Layout Visibility
Expand Down
8 changes: 5 additions & 3 deletions packages/react-router/src/fileRoute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ import type {
RouteConstraints,
RouteIds,
RouteLoaderEntry,
UpdatableRouteOptions,
UpdatableRouteOptionsWithoutStaticData,
UpdatableStaticRouteOptionByRouteId,
UseNavigateResult,
} from '@tanstack/router-core'
import type { UseLoaderDepsRoute } from './useLoaderDeps'
Expand Down Expand Up @@ -115,7 +116,7 @@ export class FileRoute<
TMiddlewares,
THandlers
> &
UpdatableRouteOptions<
UpdatableRouteOptionsWithoutStaticData<
TParentRoute,
TId,
TFullPath,
Expand All @@ -126,7 +127,8 @@ export class FileRoute<
AnyContext,
TRouteContextFn,
TBeforeLoadFn
>,
> &
UpdatableStaticRouteOptionByRouteId<TId>,
): Route<
TRegister,
TParentRoute,
Expand Down
4 changes: 4 additions & 0 deletions packages/react-router/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ export type {
PreloadableObj,
RoutePathOptions,
StaticDataRouteOption,
StaticDataByRoutePrefix,
StaticDataByRouteId,
UpdatableStaticRouteOptionByRouteId,
RoutePathOptionsIntersection,
UpdatableStaticRouteOption,
MetaDescriptor,
Expand Down Expand Up @@ -197,6 +200,7 @@ export type {
FileBaseRouteOptions,
BaseRouteOptions,
UpdatableRouteOptions,
UpdatableRouteOptionsWithoutStaticData,
RouteLoaderFn,
LoaderFnContext,
LazyRouteOptions,
Expand Down
218 changes: 218 additions & 0 deletions packages/react-router/tests/staticDataByRoutePrefix.test-d.tsx
Original file line number Diff line number Diff line change
@@ -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<PillarStaticData>()
expectTypeOf<
StaticDataByRouteId<'/_staticPillar/dashboard'>
>().toEqualTypeOf<PillarStaticData>()
expectTypeOf<
StaticDataByRouteId<'/_staticDetail/settings/profile'>
>().toEqualTypeOf<DetailStaticData>()
})

test('StaticDataByRouteId only matches whole path segments', () => {
expectTypeOf<
StaticDataByRouteId<'/_staticPillarExtra'>
>().toEqualTypeOf<StaticDataRouteOption>()
})

test('StaticDataByRouteId falls back to StaticDataRouteOption when no prefix matches', () => {
expectTypeOf<
StaticDataByRouteId<'/unrelated'>
>().toEqualTypeOf<StaticDataRouteOption>()
})

test('overlapping registered prefixes union their shapes', () => {
expectTypeOf<StaticDataByRouteId<'/_staticPillar/nested'>>().toEqualTypeOf<
PillarStaticData | NestedStaticData
>()
expectTypeOf<
StaticDataByRouteId<'/_staticPillar/nested/leaf'>
>().toEqualTypeOf<PillarStaticData | NestedStaticData>()

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 & <shape>`
expectTypeOf<
Parameters<typeof pillarRoute.update>[0]['staticData']
>().toEqualTypeOf<PillarStaticData | undefined>()

const legalRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'legal',
})

expectTypeOf<
Parameters<typeof legalRoute.update>[0]['staticData']
>().toEqualTypeOf<StaticDataRouteOption | undefined>()

// 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<UpdatableStaticRouteOptionByRouteId<string>>().toEqualTypeOf<{
staticData?: any
}>()

// ...which keeps prefixed and unprefixed routes assignable to AnyRoute.
const routes: Array<AnyRoute> = [rootRoute, pillarRoute, pillarFileChildRoute]
expectTypeOf(routes).toEqualTypeOf<Array<AnyRoute>>()
})
Loading