Skip to content

Commit caf7c9c

Browse files
committed
feat(@angular/ssr): Add support for route matchers with fine-grained render mode control
This commit adds support for custom route matchers in Angular SSR, allowing fine-grained control over the `renderMode` (Server, Client) for individual routes, including those defined with matchers. Routes with custom matchers are **not** supported during prerendering and must explicitly define a `renderMode` of either server or client. The following configuration demonstrates how to use glob patterns (including recursive `**`) to define server-side rendering (SSR) or client-side rendering (CSR) for specific parts of the 'product' route and its child routes. ```typescript // app.routes.ts import { Routes } from '@angular/router'; export const routes: Routes = [ { path: '', component: DummyComponent, }, { path: 'product', component: DummyComponent, children: [ { path: '', component: DummyComponent, }, { path: 'list', component: DummyComponent, }, { matcher: () => null, // Example custom matcher (always returns null) component: DummyComponent, }, ], }, ]; ``` ```typescript // app.routes.server.ts import { RenderMode, ServerRoute } from '@angular/ssr'; export const serverRoutes: ServerRoute[] = [ { path: '**', renderMode: RenderMode.Client }, { path: 'product', renderMode: RenderMode.Prerender }, { path: 'product/list', renderMode: RenderMode.Prerender }, { path: 'product/**/overview/details', renderMode: RenderMode.Server }, ]; ``` Closes angular#29284
1 parent 832bfff commit caf7c9c

File tree

2 files changed

+223
-80
lines changed

2 files changed

+223
-80
lines changed

packages/angular/ssr/src/routes/ng-routes.ts

Lines changed: 157 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,110 @@ interface AngularRouterConfigResult {
110110

111111
type EntryPointToBrowserMapping = AngularAppManifest['entryPointToBrowserMapping'];
112112

113+
/**
114+
* Handles a single route within the route tree and yields metadata or errors.
115+
*
116+
* @param options - Configuration options for handling the route.
117+
* @returns An async iterable iterator yielding `RouteTreeNodeMetadata` or an error object.
118+
*/
119+
async function* handleRoute(options: {
120+
metadata: ServerConfigRouteTreeNodeMetadata;
121+
currentRoutePath: string;
122+
route: Route;
123+
compiler: Compiler;
124+
parentInjector: Injector;
125+
serverConfigRouteTree?: RouteTree<ServerConfigRouteTreeAdditionalMetadata>;
126+
invokeGetPrerenderParams: boolean;
127+
includePrerenderFallbackRoutes: boolean;
128+
entryPointToBrowserMapping?: EntryPointToBrowserMapping;
129+
parentPreloads?: readonly string[];
130+
}): AsyncIterableIterator<RouteTreeNodeMetadata | { error: string }> {
131+
try {
132+
const {
133+
metadata,
134+
currentRoutePath,
135+
route,
136+
compiler,
137+
parentInjector,
138+
serverConfigRouteTree,
139+
entryPointToBrowserMapping,
140+
invokeGetPrerenderParams,
141+
includePrerenderFallbackRoutes,
142+
} = options;
143+
144+
const { redirectTo, loadChildren, loadComponent, children, ɵentryName } = route;
145+
146+
if (ɵentryName && loadComponent) {
147+
appendPreloadToMetadata(ɵentryName, entryPointToBrowserMapping, metadata, true);
148+
}
149+
150+
if (metadata.renderMode === RenderMode.Prerender) {
151+
yield* handleSSGRoute(
152+
serverConfigRouteTree,
153+
typeof redirectTo === 'string' ? redirectTo : undefined,
154+
metadata,
155+
parentInjector,
156+
invokeGetPrerenderParams,
157+
includePrerenderFallbackRoutes,
158+
);
159+
} else if (typeof redirectTo === 'string') {
160+
if (metadata.status && !VALID_REDIRECT_RESPONSE_CODES.has(metadata.status)) {
161+
yield {
162+
error:
163+
`The '${metadata.status}' status code is not a valid redirect response code. ` +
164+
`Please use one of the following redirect response codes: ${[...VALID_REDIRECT_RESPONSE_CODES.values()].join(', ')}.`,
165+
};
166+
} else {
167+
yield { ...metadata, redirectTo: resolveRedirectTo(metadata.route, redirectTo) };
168+
}
169+
} else {
170+
yield metadata;
171+
}
172+
173+
// Recursively process child routes
174+
if (children?.length) {
175+
yield* traverseRoutesConfig({
176+
...options,
177+
routes: children,
178+
parentRoute: currentRoutePath,
179+
parentPreloads: metadata.preload,
180+
});
181+
}
182+
183+
// Load and process lazy-loaded child routes
184+
if (loadChildren) {
185+
if (ɵentryName) {
186+
// When using `loadChildren`, the entire feature area (including multiple routes) is loaded.
187+
// As a result, we do not want all dynamic-import dependencies to be preload, because it involves multiple dependencies
188+
// across different child routes. In contrast, `loadComponent` only loads a single component, which allows
189+
// for precise control over preloading, ensuring that the files preloaded are exactly those required for that specific route.
190+
appendPreloadToMetadata(ɵentryName, entryPointToBrowserMapping, metadata, false);
191+
}
192+
193+
const loadedChildRoutes = await loadChildrenHelper(
194+
route,
195+
compiler,
196+
parentInjector,
197+
).toPromise();
198+
199+
if (loadedChildRoutes) {
200+
const { routes: childRoutes, injector = parentInjector } = loadedChildRoutes;
201+
yield* traverseRoutesConfig({
202+
...options,
203+
routes: childRoutes,
204+
parentInjector: injector,
205+
parentRoute: currentRoutePath,
206+
parentPreloads: metadata.preload,
207+
});
208+
}
209+
}
210+
} catch (error) {
211+
yield {
212+
error: `Error in handleRoute for '${options.currentRoutePath}': ${(error as Error).message}`,
213+
};
214+
}
215+
}
216+
113217
/**
114218
* Traverses an array of route configurations to generate route tree node metadata.
115219
*
@@ -124,57 +228,69 @@ async function* traverseRoutesConfig(options: {
124228
compiler: Compiler;
125229
parentInjector: Injector;
126230
parentRoute: string;
127-
serverConfigRouteTree: RouteTree<ServerConfigRouteTreeAdditionalMetadata> | undefined;
231+
serverConfigRouteTree?: RouteTree<ServerConfigRouteTreeAdditionalMetadata>;
128232
invokeGetPrerenderParams: boolean;
129233
includePrerenderFallbackRoutes: boolean;
130-
entryPointToBrowserMapping: EntryPointToBrowserMapping | undefined;
234+
entryPointToBrowserMapping?: EntryPointToBrowserMapping;
131235
parentPreloads?: readonly string[];
132236
}): AsyncIterableIterator<RouteTreeNodeMetadata | { error: string }> {
133-
const {
134-
routes,
135-
compiler,
136-
parentInjector,
137-
parentRoute,
138-
serverConfigRouteTree,
139-
entryPointToBrowserMapping,
140-
parentPreloads,
141-
invokeGetPrerenderParams,
142-
includePrerenderFallbackRoutes,
143-
} = options;
237+
const { routes: routeConfigs, parentPreloads, parentRoute, serverConfigRouteTree } = options;
144238

145-
for (const route of routes) {
239+
for (const route of routeConfigs) {
146240
try {
147-
const {
148-
path = '',
149-
matcher,
150-
redirectTo,
151-
loadChildren,
152-
loadComponent,
153-
children,
154-
ɵentryName,
155-
} = route;
241+
const { matcher, path = matcher ? '**' : '' } = route;
156242
const currentRoutePath = joinUrlParts(parentRoute, path);
157243

158-
// Get route metadata from the server config route tree, if available
159-
let matchedMetaData: ServerConfigRouteTreeNodeMetadata | undefined;
160-
if (serverConfigRouteTree) {
161-
if (matcher) {
162-
// Only issue this error when SSR routing is used.
163-
yield {
164-
error: `The route '${stripLeadingSlash(currentRoutePath)}' uses a route matcher that is not supported.`,
244+
if (matcher && serverConfigRouteTree) {
245+
let foundMatch = false;
246+
for (const matchedMetaData of serverConfigRouteTree.traverse()) {
247+
if (!matchedMetaData.route.startsWith(currentRoutePath)) {
248+
continue;
249+
}
250+
251+
foundMatch = true;
252+
matchedMetaData.presentInClientRouter = true;
253+
254+
if (matchedMetaData.renderMode === RenderMode.Prerender) {
255+
yield {
256+
error:
257+
`The route '${stripLeadingSlash(currentRoutePath)}' is set for prerendering but has a defined matcher. ` +
258+
`Routes with matchers cannot use prerendering. Please specify a different 'renderMode'.`,
259+
};
260+
continue;
261+
}
262+
263+
const metadata: ServerConfigRouteTreeNodeMetadata = {
264+
...matchedMetaData,
265+
preload: parentPreloads,
266+
route: matchedMetaData.route,
267+
presentInClientRouter: undefined,
165268
};
166269

167-
continue;
270+
yield* handleRoute({ ...options, metadata, currentRoutePath, route });
168271
}
169272

273+
if (!foundMatch) {
274+
yield {
275+
error:
276+
`The route '${stripLeadingSlash(currentRoutePath)}' has a defined matcher ` +
277+
'but does not match any route in the server routing configuration. ' +
278+
'Please ensure this route is added to the server routing configuration.',
279+
};
280+
}
281+
282+
continue;
283+
}
284+
285+
let matchedMetaData: ServerConfigRouteTreeNodeMetadata | undefined;
286+
if (serverConfigRouteTree) {
170287
matchedMetaData = serverConfigRouteTree.match(currentRoutePath);
171288
if (!matchedMetaData) {
172289
yield {
173290
error:
174291
`The '${stripLeadingSlash(currentRoutePath)}' route does not match any route defined in the server routing configuration. ` +
175292
'Please ensure this route is added to the server routing configuration.',
176293
};
177-
178294
continue;
179295
}
180296

@@ -192,71 +308,32 @@ async function* traverseRoutesConfig(options: {
192308
presentInClientRouter: undefined,
193309
};
194310

195-
if (ɵentryName && loadComponent) {
196-
appendPreloadToMetadata(ɵentryName, entryPointToBrowserMapping, metadata, true);
197-
}
198-
199-
if (metadata.renderMode === RenderMode.Prerender) {
200-
// Handle SSG routes
201-
yield* handleSSGRoute(
202-
serverConfigRouteTree,
203-
typeof redirectTo === 'string' ? redirectTo : undefined,
204-
metadata,
205-
parentInjector,
206-
invokeGetPrerenderParams,
207-
includePrerenderFallbackRoutes,
208-
);
209-
} else if (typeof redirectTo === 'string') {
210-
// Handle redirects
211-
if (metadata.status && !VALID_REDIRECT_RESPONSE_CODES.has(metadata.status)) {
212-
yield {
213-
error:
214-
`The '${metadata.status}' status code is not a valid redirect response code. ` +
215-
`Please use one of the following redirect response codes: ${[...VALID_REDIRECT_RESPONSE_CODES.values()].join(', ')}.`,
216-
};
311+
yield* handleRoute({ ...options, metadata, currentRoutePath, route });
217312

218-
continue;
219-
}
220-
221-
yield { ...metadata, redirectTo: resolveRedirectTo(metadata.route, redirectTo) };
222-
} else {
223-
yield metadata;
224-
}
225-
226-
// Recursively process child routes
227-
if (children?.length) {
313+
if (route.children?.length) {
228314
yield* traverseRoutesConfig({
229315
...options,
230-
routes: children,
316+
routes: route.children,
231317
parentRoute: currentRoutePath,
232-
parentPreloads: metadata.preload,
318+
parentPreloads,
233319
});
234320
}
235321

236-
// Load and process lazy-loaded child routes
237-
if (loadChildren) {
238-
if (ɵentryName) {
239-
// When using `loadChildren`, the entire feature area (including multiple routes) is loaded.
240-
// As a result, we do not want all dynamic-import dependencies to be preload, because it involves multiple dependencies
241-
// across different child routes. In contrast, `loadComponent` only loads a single component, which allows
242-
// for precise control over preloading, ensuring that the files preloaded are exactly those required for that specific route.
243-
appendPreloadToMetadata(ɵentryName, entryPointToBrowserMapping, metadata, false);
244-
}
245-
322+
if (route.loadChildren) {
246323
const loadedChildRoutes = await loadChildrenHelper(
247324
route,
248-
compiler,
249-
parentInjector,
325+
options.compiler,
326+
options.parentInjector,
250327
).toPromise();
251328

252329
if (loadedChildRoutes) {
253-
const { routes: childRoutes, injector = parentInjector } = loadedChildRoutes;
330+
const { routes: childRoutes, injector = options.parentInjector } = loadedChildRoutes;
254331
yield* traverseRoutesConfig({
255332
...options,
256333
routes: childRoutes,
257334
parentInjector: injector,
258335
parentRoute: currentRoutePath,
259-
parentPreloads: metadata.preload,
336+
parentPreloads,
260337
});
261338
}
262339
}

packages/angular/ssr/test/routes/ng-routes_spec.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,24 @@ describe('extractRoutesAndCreateRouteTree', () => {
167167
`The 'invalid' route does not match any route defined in the server routing configuration`,
168168
);
169169
});
170+
171+
it('should error when a route with a matcher when render mode is Prerender.', async () => {
172+
setAngularAppTestingManifest(
173+
[{ matcher: () => null, component: DummyComponent }],
174+
[
175+
{
176+
path: '**',
177+
renderMode: RenderMode.Prerender,
178+
},
179+
],
180+
);
181+
182+
const { errors } = await extractRoutesAndCreateRouteTree({ url });
183+
expect(errors[0]).toContain(
184+
`The route '**' is set for prerendering but has a defined matcher. ` +
185+
`Routes with matchers cannot use prerendering. Please specify a different 'renderMode'.`,
186+
);
187+
});
170188
});
171189

172190
describe('when `invokeGetPrerenderParams` is true', () => {
@@ -330,6 +348,54 @@ describe('extractRoutesAndCreateRouteTree', () => {
330348
});
331349
});
332350

351+
it('should extract routes with a route level matcher', async () => {
352+
setAngularAppTestingManifest(
353+
[
354+
{
355+
path: '',
356+
component: DummyComponent,
357+
},
358+
{
359+
path: 'product',
360+
component: DummyComponent,
361+
children: [
362+
{
363+
path: '',
364+
component: DummyComponent,
365+
},
366+
{
367+
matcher: () => null,
368+
component: DummyComponent,
369+
},
370+
{
371+
path: 'list',
372+
component: DummyComponent,
373+
},
374+
],
375+
},
376+
],
377+
[
378+
{ path: '**', renderMode: RenderMode.Client },
379+
{ path: 'product', renderMode: RenderMode.Client },
380+
{ path: 'product/*', renderMode: RenderMode.Client },
381+
{ path: 'product/**/overview/details', renderMode: RenderMode.Server },
382+
{ path: 'product/**/overview', renderMode: RenderMode.Server },
383+
{ path: 'product/**/overview/about', renderMode: RenderMode.Server },
384+
],
385+
);
386+
387+
const { routeTree, errors } = await extractRoutesAndCreateRouteTree({ url });
388+
expect(errors).toHaveSize(0);
389+
expect(routeTree.toObject()).toEqual([
390+
{ route: '/', renderMode: RenderMode.Client },
391+
{ route: '/product', renderMode: RenderMode.Client },
392+
{ route: '/product/**/overview', renderMode: RenderMode.Server },
393+
{ route: '/product/**/overview/details', renderMode: RenderMode.Server },
394+
{ route: '/product/**/overview/about', renderMode: RenderMode.Server },
395+
{ route: '/product/list', renderMode: RenderMode.Client },
396+
]);
397+
});
398+
333399
it('should extract nested redirects that are not explicitly defined.', async () => {
334400
setAngularAppTestingManifest(
335401
[

0 commit comments

Comments
 (0)