Skip to content

Commit

Permalink
feat(@angular/ssr): Add support for route matchers with fine-grained …
Browse files Browse the repository at this point in the history
…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
  • Loading branch information
alan-agius4 committed Jan 31, 2025
1 parent 02d87bc commit 9726cd0
Show file tree
Hide file tree
Showing 2 changed files with 228 additions and 116 deletions.
278 changes: 162 additions & 116 deletions packages/angular/ssr/src/routes/ng-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,108 @@ interface AngularRouterConfigResult {

type EntryPointToBrowserMapping = AngularAppManifest['entryPointToBrowserMapping'];

/**
* Handles a single route within the route tree and yields metadata or errors.
*
* @param options - Configuration options for handling the route.
* @returns An async iterable iterator yielding `RouteTreeNodeMetadata` or an error object.
*/
async function* handleRoute(options: {
metadata: ServerConfigRouteTreeNodeMetadata;
currentRoutePath: string;
route: Route;
compiler: Compiler;
parentInjector: Injector;
serverConfigRouteTree?: RouteTree<ServerConfigRouteTreeAdditionalMetadata>;
invokeGetPrerenderParams: boolean;
includePrerenderFallbackRoutes: boolean;
entryPointToBrowserMapping?: EntryPointToBrowserMapping;
}): AsyncIterableIterator<RouteTreeNodeMetadata | { error: string }> {
try {
const {
metadata,
currentRoutePath,
route,
compiler,
parentInjector,
serverConfigRouteTree,
entryPointToBrowserMapping,
invokeGetPrerenderParams,
includePrerenderFallbackRoutes,
} = options;

const { redirectTo, loadChildren, loadComponent, children, ɵentryName } = route;
if (ɵentryName && loadComponent) {
appendPreloadToMetadata(ɵentryName, entryPointToBrowserMapping, metadata, true);
}

if (metadata.renderMode === RenderMode.Prerender) {
yield* handleSSGRoute(
serverConfigRouteTree,
typeof redirectTo === 'string' ? redirectTo : undefined,
metadata,
parentInjector,
invokeGetPrerenderParams,
includePrerenderFallbackRoutes,
);
} else if (typeof redirectTo === 'string') {
if (metadata.status && !VALID_REDIRECT_RESPONSE_CODES.has(metadata.status)) {
yield {
error:
`The '${metadata.status}' status code is not a valid redirect response code. ` +
`Please use one of the following redirect response codes: ${[...VALID_REDIRECT_RESPONSE_CODES.values()].join(', ')}.`,
};
} else {
yield { ...metadata, redirectTo: resolveRedirectTo(metadata.route, redirectTo) };
}
} else {
yield metadata;
}

// Recursively process child routes
if (children?.length) {
yield* traverseRoutesConfig({
...options,
routes: children,
parentRoute: currentRoutePath,
parentPreloads: metadata.preload,
});
}

// Load and process lazy-loaded child routes
if (loadChildren) {
if (ɵentryName) {
// When using `loadChildren`, the entire feature area (including multiple routes) is loaded.
// As a result, we do not want all dynamic-import dependencies to be preload, because it involves multiple dependencies
// across different child routes. In contrast, `loadComponent` only loads a single component, which allows
// for precise control over preloading, ensuring that the files preloaded are exactly those required for that specific route.
appendPreloadToMetadata(ɵentryName, entryPointToBrowserMapping, metadata, false);
}

const loadedChildRoutes = await loadChildrenHelper(
route,
compiler,
parentInjector,
).toPromise();

if (loadedChildRoutes) {
const { routes: childRoutes, injector = parentInjector } = loadedChildRoutes;
yield* traverseRoutesConfig({
...options,
routes: childRoutes,
parentInjector: injector,
parentRoute: currentRoutePath,
parentPreloads: metadata.preload,
});
}
}
} catch (error) {
yield {
error: `Error in handleRoute for '${options.currentRoutePath}': ${(error as Error).message}`,
};
}
}

/**
* Traverses an array of route configurations to generate route tree node metadata.
*
Expand All @@ -124,64 +226,79 @@ async function* traverseRoutesConfig(options: {
compiler: Compiler;
parentInjector: Injector;
parentRoute: string;
serverConfigRouteTree: RouteTree<ServerConfigRouteTreeAdditionalMetadata> | undefined;
serverConfigRouteTree?: RouteTree<ServerConfigRouteTreeAdditionalMetadata>;
invokeGetPrerenderParams: boolean;
includePrerenderFallbackRoutes: boolean;
entryPointToBrowserMapping: EntryPointToBrowserMapping | undefined;
entryPointToBrowserMapping?: EntryPointToBrowserMapping;
parentPreloads?: readonly string[];
}): AsyncIterableIterator<RouteTreeNodeMetadata | { error: string }> {
const {
routes,
compiler,
parentInjector,
parentRoute,
serverConfigRouteTree,
entryPointToBrowserMapping,
parentPreloads,
invokeGetPrerenderParams,
includePrerenderFallbackRoutes,
} = options;
const { routes: routeConfigs, parentPreloads, parentRoute, serverConfigRouteTree } = options;

for (const route of routes) {
try {
const {
path = '',
matcher,
redirectTo,
loadChildren,
loadComponent,
children,
ɵentryName,
} = route;
const currentRoutePath = joinUrlParts(parentRoute, path);

// Get route metadata from the server config route tree, if available
let matchedMetaData: ServerConfigRouteTreeNodeMetadata | undefined;
if (serverConfigRouteTree) {
if (matcher) {
// Only issue this error when SSR routing is used.
yield {
error: `The route '${stripLeadingSlash(currentRoutePath)}' uses a route matcher that is not supported.`,
};
for (const route of routeConfigs) {
const { matcher, path = matcher ? '**' : '' } = route;
const currentRoutePath = joinUrlParts(parentRoute, path);

if (matcher && serverConfigRouteTree) {
let foundMatch = false;
for (const matchedMetaData of serverConfigRouteTree.traverse()) {
if (!matchedMetaData.route.startsWith(currentRoutePath)) {
continue;
}

matchedMetaData = serverConfigRouteTree.match(currentRoutePath);
if (!matchedMetaData) {
foundMatch = true;
matchedMetaData.presentInClientRouter = true;

if (matchedMetaData.renderMode === RenderMode.Prerender) {
yield {
error:
`The '${stripLeadingSlash(currentRoutePath)}' route does not match any route defined in the server routing configuration. ` +
'Please ensure this route is added to the server routing configuration.',
`The route '${stripLeadingSlash(currentRoutePath)}' is set for prerendering but has a defined matcher. ` +
`Routes with matchers cannot use prerendering. Please specify a different 'renderMode'.`,
};

continue;
}

matchedMetaData.presentInClientRouter = true;
yield* handleRoute({
...options,
currentRoutePath,
route,
metadata: {
...matchedMetaData,
preload: parentPreloads,
route: matchedMetaData.route,
presentInClientRouter: undefined,
},
});
}

if (!foundMatch) {
yield {
error:
`The route '${stripLeadingSlash(currentRoutePath)}' has a defined matcher but does not ` +
'match any route in the server routing configuration. Please ensure this route is added to the server routing configuration.',
};
}

continue;
}

let matchedMetaData: ServerConfigRouteTreeNodeMetadata | undefined;
if (serverConfigRouteTree) {
matchedMetaData = serverConfigRouteTree.match(currentRoutePath);
if (!matchedMetaData) {
yield {
error:
`The '${stripLeadingSlash(currentRoutePath)}' route does not match any route defined in the server routing configuration. ` +
'Please ensure this route is added to the server routing configuration.',
};
continue;
}

const metadata: ServerConfigRouteTreeNodeMetadata = {
matchedMetaData.presentInClientRouter = true;
}

yield* handleRoute({
...options,
metadata: {
renderMode: RenderMode.Prerender,
...matchedMetaData,
preload: parentPreloads,
Expand All @@ -190,81 +307,10 @@ async function* traverseRoutesConfig(options: {
// ['one', 'two', 'three'] -> 'one/two/three'
route: path === '' ? addTrailingSlash(currentRoutePath) : currentRoutePath,
presentInClientRouter: undefined,
};

if (ɵentryName && loadComponent) {
appendPreloadToMetadata(ɵentryName, entryPointToBrowserMapping, metadata, true);
}

if (metadata.renderMode === RenderMode.Prerender) {
// Handle SSG routes
yield* handleSSGRoute(
serverConfigRouteTree,
typeof redirectTo === 'string' ? redirectTo : undefined,
metadata,
parentInjector,
invokeGetPrerenderParams,
includePrerenderFallbackRoutes,
);
} else if (typeof redirectTo === 'string') {
// Handle redirects
if (metadata.status && !VALID_REDIRECT_RESPONSE_CODES.has(metadata.status)) {
yield {
error:
`The '${metadata.status}' status code is not a valid redirect response code. ` +
`Please use one of the following redirect response codes: ${[...VALID_REDIRECT_RESPONSE_CODES.values()].join(', ')}.`,
};

continue;
}

yield { ...metadata, redirectTo: resolveRedirectTo(metadata.route, redirectTo) };
} else {
yield metadata;
}

// Recursively process child routes
if (children?.length) {
yield* traverseRoutesConfig({
...options,
routes: children,
parentRoute: currentRoutePath,
parentPreloads: metadata.preload,
});
}

// Load and process lazy-loaded child routes
if (loadChildren) {
if (ɵentryName) {
// When using `loadChildren`, the entire feature area (including multiple routes) is loaded.
// As a result, we do not want all dynamic-import dependencies to be preload, because it involves multiple dependencies
// across different child routes. In contrast, `loadComponent` only loads a single component, which allows
// for precise control over preloading, ensuring that the files preloaded are exactly those required for that specific route.
appendPreloadToMetadata(ɵentryName, entryPointToBrowserMapping, metadata, false);
}

const loadedChildRoutes = await loadChildrenHelper(
route,
compiler,
parentInjector,
).toPromise();

if (loadedChildRoutes) {
const { routes: childRoutes, injector = parentInjector } = loadedChildRoutes;
yield* traverseRoutesConfig({
...options,
routes: childRoutes,
parentInjector: injector,
parentRoute: currentRoutePath,
parentPreloads: metadata.preload,
});
}
}
} catch (error) {
yield {
error: `Error processing route '${stripLeadingSlash(route.path ?? '')}': ${(error as Error).message}`,
};
}
},
currentRoutePath,
route,
});
}
}

Expand Down
66 changes: 66 additions & 0 deletions packages/angular/ssr/test/routes/ng-routes_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,24 @@ describe('extractRoutesAndCreateRouteTree', () => {
`The 'invalid' route does not match any route defined in the server routing configuration`,
);
});

it('should error when a route with a matcher when render mode is Prerender.', async () => {
setAngularAppTestingManifest(
[{ matcher: () => null, component: DummyComponent }],
[
{
path: '**',
renderMode: RenderMode.Prerender,
},
],
);

const { errors } = await extractRoutesAndCreateRouteTree({ url });
expect(errors[0]).toContain(
`The route '**' is set for prerendering but has a defined matcher. ` +
`Routes with matchers cannot use prerendering. Please specify a different 'renderMode'.`,
);
});
});

describe('when `invokeGetPrerenderParams` is true', () => {
Expand Down Expand Up @@ -330,6 +348,54 @@ describe('extractRoutesAndCreateRouteTree', () => {
});
});

it('should extract routes with a route level matcher', async () => {
setAngularAppTestingManifest(
[
{
path: '',
component: DummyComponent,
},
{
path: 'product',
component: DummyComponent,
children: [
{
path: '',
component: DummyComponent,
},
{
matcher: () => null,
component: DummyComponent,
},
{
path: 'list',
component: DummyComponent,
},
],
},
],
[
{ path: '**', renderMode: RenderMode.Client },
{ path: 'product', renderMode: RenderMode.Client },
{ path: 'product/*', renderMode: RenderMode.Client },
{ path: 'product/**/overview/details', renderMode: RenderMode.Server },
{ path: 'product/**/overview', renderMode: RenderMode.Server },
{ path: 'product/**/overview/about', renderMode: RenderMode.Server },
],
);

const { routeTree, errors } = await extractRoutesAndCreateRouteTree({ url });
expect(errors).toHaveSize(0);
expect(routeTree.toObject()).toEqual([
{ route: '/', renderMode: RenderMode.Client },
{ route: '/product', renderMode: RenderMode.Client },
{ route: '/product/**/overview', renderMode: RenderMode.Server },
{ route: '/product/**/overview/details', renderMode: RenderMode.Server },
{ route: '/product/**/overview/about', renderMode: RenderMode.Server },
{ route: '/product/list', renderMode: RenderMode.Client },
]);
});

it('should extract nested redirects that are not explicitly defined.', async () => {
setAngularAppTestingManifest(
[
Expand Down

0 comments on commit 9726cd0

Please sign in to comment.