Skip to content

Commit 9726cd0

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 #29284
1 parent 02d87bc commit 9726cd0

File tree

2 files changed

+228
-116
lines changed

2 files changed

+228
-116
lines changed

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

+162-116
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,108 @@ 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+
}): AsyncIterableIterator<RouteTreeNodeMetadata | { error: string }> {
130+
try {
131+
const {
132+
metadata,
133+
currentRoutePath,
134+
route,
135+
compiler,
136+
parentInjector,
137+
serverConfigRouteTree,
138+
entryPointToBrowserMapping,
139+
invokeGetPrerenderParams,
140+
includePrerenderFallbackRoutes,
141+
} = options;
142+
143+
const { redirectTo, loadChildren, loadComponent, children, ɵentryName } = route;
144+
if (ɵentryName && loadComponent) {
145+
appendPreloadToMetadata(ɵentryName, entryPointToBrowserMapping, metadata, true);
146+
}
147+
148+
if (metadata.renderMode === RenderMode.Prerender) {
149+
yield* handleSSGRoute(
150+
serverConfigRouteTree,
151+
typeof redirectTo === 'string' ? redirectTo : undefined,
152+
metadata,
153+
parentInjector,
154+
invokeGetPrerenderParams,
155+
includePrerenderFallbackRoutes,
156+
);
157+
} else if (typeof redirectTo === 'string') {
158+
if (metadata.status && !VALID_REDIRECT_RESPONSE_CODES.has(metadata.status)) {
159+
yield {
160+
error:
161+
`The '${metadata.status}' status code is not a valid redirect response code. ` +
162+
`Please use one of the following redirect response codes: ${[...VALID_REDIRECT_RESPONSE_CODES.values()].join(', ')}.`,
163+
};
164+
} else {
165+
yield { ...metadata, redirectTo: resolveRedirectTo(metadata.route, redirectTo) };
166+
}
167+
} else {
168+
yield metadata;
169+
}
170+
171+
// Recursively process child routes
172+
if (children?.length) {
173+
yield* traverseRoutesConfig({
174+
...options,
175+
routes: children,
176+
parentRoute: currentRoutePath,
177+
parentPreloads: metadata.preload,
178+
});
179+
}
180+
181+
// Load and process lazy-loaded child routes
182+
if (loadChildren) {
183+
if (ɵentryName) {
184+
// When using `loadChildren`, the entire feature area (including multiple routes) is loaded.
185+
// As a result, we do not want all dynamic-import dependencies to be preload, because it involves multiple dependencies
186+
// across different child routes. In contrast, `loadComponent` only loads a single component, which allows
187+
// for precise control over preloading, ensuring that the files preloaded are exactly those required for that specific route.
188+
appendPreloadToMetadata(ɵentryName, entryPointToBrowserMapping, metadata, false);
189+
}
190+
191+
const loadedChildRoutes = await loadChildrenHelper(
192+
route,
193+
compiler,
194+
parentInjector,
195+
).toPromise();
196+
197+
if (loadedChildRoutes) {
198+
const { routes: childRoutes, injector = parentInjector } = loadedChildRoutes;
199+
yield* traverseRoutesConfig({
200+
...options,
201+
routes: childRoutes,
202+
parentInjector: injector,
203+
parentRoute: currentRoutePath,
204+
parentPreloads: metadata.preload,
205+
});
206+
}
207+
}
208+
} catch (error) {
209+
yield {
210+
error: `Error in handleRoute for '${options.currentRoutePath}': ${(error as Error).message}`,
211+
};
212+
}
213+
}
214+
113215
/**
114216
* Traverses an array of route configurations to generate route tree node metadata.
115217
*
@@ -124,64 +226,79 @@ async function* traverseRoutesConfig(options: {
124226
compiler: Compiler;
125227
parentInjector: Injector;
126228
parentRoute: string;
127-
serverConfigRouteTree: RouteTree<ServerConfigRouteTreeAdditionalMetadata> | undefined;
229+
serverConfigRouteTree?: RouteTree<ServerConfigRouteTreeAdditionalMetadata>;
128230
invokeGetPrerenderParams: boolean;
129231
includePrerenderFallbackRoutes: boolean;
130-
entryPointToBrowserMapping: EntryPointToBrowserMapping | undefined;
232+
entryPointToBrowserMapping?: EntryPointToBrowserMapping;
131233
parentPreloads?: readonly string[];
132234
}): 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;
235+
const { routes: routeConfigs, parentPreloads, parentRoute, serverConfigRouteTree } = options;
144236

145-
for (const route of routes) {
146-
try {
147-
const {
148-
path = '',
149-
matcher,
150-
redirectTo,
151-
loadChildren,
152-
loadComponent,
153-
children,
154-
ɵentryName,
155-
} = route;
156-
const currentRoutePath = joinUrlParts(parentRoute, path);
157-
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.`,
165-
};
237+
for (const route of routeConfigs) {
238+
const { matcher, path = matcher ? '**' : '' } = route;
239+
const currentRoutePath = joinUrlParts(parentRoute, path);
166240

241+
if (matcher && serverConfigRouteTree) {
242+
let foundMatch = false;
243+
for (const matchedMetaData of serverConfigRouteTree.traverse()) {
244+
if (!matchedMetaData.route.startsWith(currentRoutePath)) {
167245
continue;
168246
}
169247

170-
matchedMetaData = serverConfigRouteTree.match(currentRoutePath);
171-
if (!matchedMetaData) {
248+
foundMatch = true;
249+
matchedMetaData.presentInClientRouter = true;
250+
251+
if (matchedMetaData.renderMode === RenderMode.Prerender) {
172252
yield {
173253
error:
174-
`The '${stripLeadingSlash(currentRoutePath)}' route does not match any route defined in the server routing configuration. ` +
175-
'Please ensure this route is added to the server routing configuration.',
254+
`The route '${stripLeadingSlash(currentRoutePath)}' is set for prerendering but has a defined matcher. ` +
255+
`Routes with matchers cannot use prerendering. Please specify a different 'renderMode'.`,
176256
};
177-
178257
continue;
179258
}
180259

181-
matchedMetaData.presentInClientRouter = true;
260+
yield* handleRoute({
261+
...options,
262+
currentRoutePath,
263+
route,
264+
metadata: {
265+
...matchedMetaData,
266+
preload: parentPreloads,
267+
route: matchedMetaData.route,
268+
presentInClientRouter: undefined,
269+
},
270+
});
271+
}
272+
273+
if (!foundMatch) {
274+
yield {
275+
error:
276+
`The route '${stripLeadingSlash(currentRoutePath)}' has a defined matcher but does not ` +
277+
'match any route in the server routing configuration. Please ensure this route is added to the server routing configuration.',
278+
};
279+
}
280+
281+
continue;
282+
}
283+
284+
let matchedMetaData: ServerConfigRouteTreeNodeMetadata | undefined;
285+
if (serverConfigRouteTree) {
286+
matchedMetaData = serverConfigRouteTree.match(currentRoutePath);
287+
if (!matchedMetaData) {
288+
yield {
289+
error:
290+
`The '${stripLeadingSlash(currentRoutePath)}' route does not match any route defined in the server routing configuration. ` +
291+
'Please ensure this route is added to the server routing configuration.',
292+
};
293+
continue;
182294
}
183295

184-
const metadata: ServerConfigRouteTreeNodeMetadata = {
296+
matchedMetaData.presentInClientRouter = true;
297+
}
298+
299+
yield* handleRoute({
300+
...options,
301+
metadata: {
185302
renderMode: RenderMode.Prerender,
186303
...matchedMetaData,
187304
preload: parentPreloads,
@@ -190,81 +307,10 @@ async function* traverseRoutesConfig(options: {
190307
// ['one', 'two', 'three'] -> 'one/two/three'
191308
route: path === '' ? addTrailingSlash(currentRoutePath) : currentRoutePath,
192309
presentInClientRouter: undefined,
193-
};
194-
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-
};
217-
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) {
228-
yield* traverseRoutesConfig({
229-
...options,
230-
routes: children,
231-
parentRoute: currentRoutePath,
232-
parentPreloads: metadata.preload,
233-
});
234-
}
235-
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-
246-
const loadedChildRoutes = await loadChildrenHelper(
247-
route,
248-
compiler,
249-
parentInjector,
250-
).toPromise();
251-
252-
if (loadedChildRoutes) {
253-
const { routes: childRoutes, injector = parentInjector } = loadedChildRoutes;
254-
yield* traverseRoutesConfig({
255-
...options,
256-
routes: childRoutes,
257-
parentInjector: injector,
258-
parentRoute: currentRoutePath,
259-
parentPreloads: metadata.preload,
260-
});
261-
}
262-
}
263-
} catch (error) {
264-
yield {
265-
error: `Error processing route '${stripLeadingSlash(route.path ?? '')}': ${(error as Error).message}`,
266-
};
267-
}
310+
},
311+
currentRoutePath,
312+
route,
313+
});
268314
}
269315
}
270316

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

+66
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)