diff --git a/packages/angular/ssr/src/routes/ng-routes.ts b/packages/angular/ssr/src/routes/ng-routes.ts index 2e104054a814..62b202d406f7 100644 --- a/packages/angular/ssr/src/routes/ng-routes.ts +++ b/packages/angular/ssr/src/routes/ng-routes.ts @@ -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; + invokeGetPrerenderParams: boolean; + includePrerenderFallbackRoutes: boolean; + entryPointToBrowserMapping?: EntryPointToBrowserMapping; +}): AsyncIterableIterator { + 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. * @@ -124,64 +226,79 @@ async function* traverseRoutesConfig(options: { compiler: Compiler; parentInjector: Injector; parentRoute: string; - serverConfigRouteTree: RouteTree | undefined; + serverConfigRouteTree?: RouteTree; invokeGetPrerenderParams: boolean; includePrerenderFallbackRoutes: boolean; - entryPointToBrowserMapping: EntryPointToBrowserMapping | undefined; + entryPointToBrowserMapping?: EntryPointToBrowserMapping; parentPreloads?: readonly string[]; }): AsyncIterableIterator { - 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, @@ -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, + }); } } diff --git a/packages/angular/ssr/test/routes/ng-routes_spec.ts b/packages/angular/ssr/test/routes/ng-routes_spec.ts index 44d246e60a2f..37d4bf890a06 100644 --- a/packages/angular/ssr/test/routes/ng-routes_spec.ts +++ b/packages/angular/ssr/test/routes/ng-routes_spec.ts @@ -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', () => { @@ -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( [