Skip to content

Commit e107dfc

Browse files
Merge pull request #2728 from atscott/feat/typed-routing-resource-inheritance
Feat/typed routing resource inheritance
2 parents 17633e4 + 521169c commit e107dfc

11 files changed

Lines changed: 1002 additions & 73 deletions

File tree

‎apps/docs-analog/src/content/features/routing/typed-routes.md‎

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -118,9 +118,15 @@ Type checking comes from the generated table. The `experimental.typedRouting` op
118118

119119
```ts
120120
// src/app/pages/products.page.ts (layout)
121+
import { resource } from '@angular/core';
121122
import type { RouteMeta } from '@analogjs/router';
122123

123-
export const routeMeta = { data: { section: 'catalog' } } satisfies RouteMeta;
124+
export const routeMeta = {
125+
data: { section: 'catalog' },
126+
resources: () => ({
127+
catalog: resource({ loader: async () => fetchCatalog() }),
128+
}),
129+
} satisfies RouteMeta;
124130
```
125131

126132
```ts
@@ -129,8 +135,10 @@ import { Component, inject } from '@angular/core';
129135
import { injectRouteData, type RouteMeta } from '@analogjs/router';
130136

131137
export const routeMeta = {
132-
resolve: { related: () => inject(ProductsService).related() },
133-
} satisfies RouteMeta;
138+
resolve: {
139+
related: (route) => inject(ProductsService).related(route.params.id),
140+
},
141+
} satisfies RouteMeta<'/products/[id]'>;
134142

135143
@Component({ template: `{{ data().section }}` })
136144
export default class ProductPage {
@@ -139,7 +147,7 @@ export default class ProductPage {
139147
}
140148
```
141149

142-
Use `satisfies RouteMeta` instead of a `RouteMeta` annotation. An annotation widens the object, so data values are typed as `unknown`.
150+
Use `satisfies RouteMeta` (or `satisfies RouteMeta<'/products/[id]'>` to also type `route.params`, `route.queryParams`, and inherited layout `route.data` inside resolvers) instead of a `RouteMeta` annotation. An annotation widens the object, so data values are typed as `unknown`.
143151

144152
`injectLoad` also accepts a route path. The result is typed from the page's `.server.ts` `load` function, so the page does not need to import it:
145153

@@ -153,17 +161,33 @@ As with Angular's route data inheritance, a page's keys override the same keys f
153161

154162
## Access route resources
155163

156-
On Angular 22.2+, when using `withRouterResources()`, `injectResources` returns the reactive resources defined on the route via `routeMeta.resources`:
164+
On Angular 22.2+, when using `withRouterResources()`, `injectResources` returns the reactive resources defined on the route via `routeMeta.resources` (and on Angular 22.3+, resources inherited from ancestor [layout routes](/docs/features/routing/overview#layout-routes) as well). As with route data, a child route's resource keys override matching keys from its layouts.
165+
166+
Pass the route path to `RouteMeta` to type `ctx.params`, `ctx.queryParams`, `ctx.data`, and inherited ancestor `ctx.resources` inside `routeMeta.resources`:
157167

158168
```ts
159-
import { injectResources } from '@analogjs/router';
169+
// src/app/pages/products/[id].page.ts
170+
import { resource } from '@angular/core';
171+
import { injectResources, type RouteMeta } from '@analogjs/router';
172+
173+
export const routeMeta = {
174+
resources: (ctx) => ({
175+
product: resource({
176+
params: () => ({
177+
id: ctx.params().id,
178+
catalog: ctx.resources().catalog.value(),
179+
}),
180+
loader: async ({ params }) => fetchProduct(params.id, params.catalog),
181+
}),
182+
}),
183+
} satisfies RouteMeta<'/products/[id]'>;
160184

161185
const resources = injectResources('/products/[id]');
162-
// resources.user is a typed Resource instance
163-
const user = resources.user.value();
186+
// resources.product and inherited resources.catalog are typed Resource instances
187+
const product = resources.product.value();
164188
```
165189

166-
Calling `injectResources()` without a route path returns untyped route resources. On older Angular versions without router resources, it returns an empty object. The other typed-routing helpers do not require router resources.
190+
Calling `injectResources()` without a route path returns untyped route resources. On older Angular versions without router resources, it returns an empty object. Shared URLs have no typed resource keys, matching `injectRouteData`. The other typed-routing helpers do not require router resources.
167191

168192
## Navigate relative to the current route
169193

‎apps/typed-router-app/src/app/pages/orgs/[orgId].page.ts‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
import { injectRouteData, LinkTo, type RouteMeta } from '@analogjs/router';
2-
import { Component } from '@angular/core';
2+
import { Component, resource } from '@angular/core';
33
import { RouterLinkActive, RouterOutlet } from '@angular/router';
44

55
import { findOrg } from '../../data';
66

77
export const routeMeta = {
8-
resolve: { org: (route) => findOrg(route.paramMap.get('orgId')) },
9-
} satisfies RouteMeta;
8+
resolve: { org: (route) => findOrg(route.params.orgId) },
9+
resources: (ctx) => ({
10+
orgResource: resource({
11+
params: ctx.params,
12+
loader: async ({ params }) => findOrg(params.orgId),
13+
}),
14+
}),
15+
} satisfies RouteMeta<'/orgs/[orgId]'>;
1016

1117
@Component({
1218
imports: [LinkTo, RouterLinkActive, RouterOutlet],

‎apps/typed-router-app/src/app/pages/orgs/[orgId]/teams/[teamId].page.ts‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { injectRouteData, LinkTo, type RouteMeta } from '@analogjs/router';
2-
import { Component } from '@angular/core';
2+
import { Component, resource } from '@angular/core';
33
import { RouterOutlet } from '@angular/router';
44
import { of } from 'rxjs';
55

@@ -8,10 +8,15 @@ import { findTeam } from '../../../../data';
88
export const routeMeta = {
99
data: { area: 'team' },
1010
resolve: {
11-
team: (route) =>
12-
of(findTeam(route.paramMap.get('orgId'), route.paramMap.get('teamId'))),
11+
team: (route) => of(findTeam(route.params.orgId, route.params.teamId)),
1312
},
14-
} satisfies RouteMeta;
13+
resources: (ctx) => ({
14+
teamResource: resource({
15+
params: ctx.params,
16+
loader: async ({ params }) => findTeam(params.orgId, params.teamId),
17+
}),
18+
}),
19+
} satisfies RouteMeta<'/orgs/[orgId]/teams/[teamId]'>;
1520

1621
@Component({
1722
imports: [LinkTo, RouterOutlet],

‎apps/typed-router-app/src/app/pages/users.page.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { RouteMeta } from '@analogjs/router';
2-
import { Component } from '@angular/core';
2+
import { Component, resource } from '@angular/core';
33
import { RouterOutlet } from '@angular/router';
44
import { of } from 'rxjs';
55

@@ -10,6 +10,11 @@ export const routeMeta = {
1010
resolve: {
1111
directory: () => of(USERS.map(({ id, name }) => ({ id, name }))),
1212
},
13+
resources: () => ({
14+
directoryResource: resource({
15+
loader: async () => USERS.map(({ id, name }) => ({ id, name })),
16+
}),
17+
}),
1318
} satisfies RouteMeta;
1419

1520
@Component({

‎apps/typed-router-app/src/app/pages/users/[userId].page.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,15 @@ import { findUser } from '../../data';
1515

1616
export const routeMeta = {
1717
resolve: {
18-
profile: async (route) => findUser(route.paramMap.get('userId')),
18+
profile: async (route) => findUser(route.params.userId),
1919
},
2020
resources: (ctx) => ({
2121
userResource: resource({
2222
params: ctx.params,
23-
loader: async ({ params }) => findUser(params['userId']),
23+
loader: async ({ params }) => findUser(params.userId),
2424
}),
2525
}),
26-
} satisfies RouteMeta;
26+
} satisfies RouteMeta<'/users/[userId]'>;
2727

2828
@Component({
2929
imports: [LinkTo],

‎apps/typed-router-app/src/app/route-type-assertions.ts‎

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@ import {
99
toRoute,
1010
type LinkToInput,
1111
type RouteDataOutput,
12+
type RouteMeta,
1213
type RouteResourcesOutput,
1314
} from '@analogjs/router';
14-
import type { ResourceRef } from '@angular/core';
15+
import { resource, type ResourceRef } from '@angular/core';
1516

1617
import type { Org, Team, User } from './data';
1718

@@ -128,21 +129,78 @@ export function routeTypeAssertions() {
128129
const anyData: Record<string, unknown> = injectRouteData()();
129130
const anyResources = injectResources();
130131
const anyRouteResources = injectRouteResources();
132+
const anyResourceValue: unknown = anyResources['user']?.value();
133+
const anyResourceReload: (() => boolean) | undefined =
134+
anyRouteResources['user']?.reload;
135+
void [anyResourceValue, anyResourceReload];
136+
// @ts-expect-error untyped resources entries are Resource instances, not strings
137+
const invalidAnyResource: string = anyResources['user'];
131138
// @ts-expect-error untyped values may be missing
132139
const anyUserId: string = injectParams()()['userId'];
133140

134-
// Route resources
141+
// Route resources (including multi-level layout inheritance and context)
135142
const userResources: RouteResourcesOutput<'/users/[userId]'> =
136143
injectResources('/users/[userId]');
137144
const userResourceRef: ResourceRef<User | undefined> =
138145
userResources.userResource;
146+
const inheritedDirectoryRef: ResourceRef<
147+
{ id: string; name: string }[] | undefined
148+
> = userResources.directoryResource;
139149
const userName: string | undefined = userResources.userResource.value()?.name;
140150
// @ts-expect-error unknown resource name
141151
userResources.missing;
152+
// @ts-expect-error dot notation is not wrapped by users.page.ts
153+
void injectResources('/users/[userId]/posts/[postId]').directoryResource;
154+
// @ts-expect-error users.page.ts and users/index.page.ts share /users
155+
void injectResources('/users').directoryResource;
142156
const aliasedResources = injectRouteResources('/users/[userId]');
143157
const aliasedRef: ResourceRef<User | undefined> =
144158
aliasedResources.userResource;
145159

160+
const memberListResources = injectResources(
161+
'/orgs/[orgId]/teams/[teamId]/members',
162+
);
163+
const listOrgRef: ResourceRef<Org | undefined> =
164+
memberListResources.orgResource;
165+
const listTeamRef: ResourceRef<Team | undefined> =
166+
memberListResources.teamResource;
167+
168+
const memberResources: RouteResourcesOutput<typeof MEMBER> =
169+
injectResources(MEMBER);
170+
const memberOrgRef: ResourceRef<Org | undefined> =
171+
memberResources.orgResource;
172+
const memberTeamRef: ResourceRef<Team | undefined> =
173+
memberResources.teamResource;
174+
175+
const memberMetaCheck = {
176+
resolve: {
177+
check: (route) => ({
178+
memberId: route.params.memberId,
179+
orgName: route.data.org?.name,
180+
teamName: route.data.team?.name,
181+
}),
182+
},
183+
resources: (ctx) => ({
184+
memberSummary: resource({
185+
params: () => ({
186+
memberId: ctx.params().memberId,
187+
org: ctx.resources().orgResource.value(),
188+
team: ctx.resources().teamResource.value(),
189+
projects: ctx.data().load.projects,
190+
}),
191+
loader: async ({ params }) => params,
192+
}),
193+
}),
194+
} satisfies RouteMeta<typeof MEMBER>;
195+
void [
196+
inheritedDirectoryRef,
197+
listOrgRef,
198+
listTeamRef,
199+
memberOrgRef,
200+
memberTeamRef,
201+
memberMetaCheck,
202+
];
203+
146204
// LinkTo inputs
147205
const links: LinkToInput<'/users/[userId]'>[] = [
148206
'..',

0 commit comments

Comments
 (0)