diff --git a/packages/angular/ssr/src/routes/ng-routes.ts b/packages/angular/ssr/src/routes/ng-routes.ts index 915d7c2b9a47..816155c4df01 100644 --- a/packages/angular/ssr/src/routes/ng-routes.ts +++ b/packages/angular/ssr/src/routes/ng-routes.ts @@ -31,7 +31,7 @@ import { AngularAppManifest, getAngularAppManifest } from '../manifest'; import { AngularBootstrap, isNgModule } from '../utils/ng'; import { promiseWithAbort } from '../utils/promise'; import { VALID_REDIRECT_RESPONSE_CODES, isValidRedirectResponseCode } from '../utils/redirect'; -import { addTrailingSlash, joinUrlParts, stripLeadingSlash } from '../utils/url'; +import { joinUrlParts, stripLeadingSlash } from '../utils/url'; import { PrerenderFallback, RenderMode, @@ -170,12 +170,14 @@ async function* handleRoute(options: { } = options; const { redirectTo, loadChildren, loadComponent, children, ɵentryName } = route; - if (ɵentryName && loadComponent) { + if (ɵentryName && (loadComponent || loadChildren)) { appendPreloadToMetadata(ɵentryName, entryPointToBrowserMapping, metadata); } + const parentRoutes = new Map(); + if (metadata.renderMode === RenderMode.Prerender) { - yield* handleSSGRoute( + for await (const result of handleSSGRoute( serverConfigRouteTree, typeof redirectTo === 'string' ? redirectTo : undefined, parentRoute, @@ -183,7 +185,13 @@ async function* handleRoute(options: { parentInjector, invokeGetPrerenderParams, includePrerenderFallbackRoutes, - ); + )) { + if ('error' in result) { + yield result; + } else { + parentRoutes.set(result.route, result); + } + } } else if (redirectTo !== undefined) { if (metadata.status && !isValidRedirectResponseCode(metadata.status)) { yield { @@ -192,34 +200,23 @@ async function* handleRoute(options: { `Please use one of the following redirect response codes: ${[...VALID_REDIRECT_RESPONSE_CODES.values()].join(', ')}.`, }; } else if (typeof redirectTo === 'string') { - yield { + parentRoutes.set(metadata.route, { ...metadata, redirectTo: resolveRedirectTo(parentRoute, redirectTo), relativeRedirect: redirectTo[0] !== '/' ? true : undefined, - }; + }); } else { - yield metadata; + parentRoutes.set(metadata.route, metadata); } } else { - yield metadata; + parentRoutes.set(metadata.route, 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) { - appendPreloadToMetadata(ɵentryName, entryPointToBrowserMapping, metadata); - } + // Recursively process child and lazy-loaded routes + let childRoutes: Route[] | undefined = children; + let childInjector = parentInjector; + if (!childRoutes?.length && loadChildren) { const routeInjector = route.providers ? createEnvironmentInjector( route.providers, @@ -230,16 +227,48 @@ async function* handleRoute(options: { const loadedChildRoutes = await loadChildrenHelper(route, compiler, routeInjector); if (loadedChildRoutes) { - const { routes: childRoutes, injector = routeInjector } = loadedChildRoutes; - yield* traverseRoutesConfig({ - ...options, - routes: childRoutes, - parentInjector: injector, - parentRoute: currentRoutePath, - parentPreloads: metadata.preload, - }); + childRoutes = loadedChildRoutes.routes; + childInjector = loadedChildRoutes.injector ?? routeInjector; } } + + if (!childRoutes?.length) { + yield* parentRoutes.values(); + + return; + } + + const childResults: (RouteTreeNodeMetadata | { error: string })[] = []; + const overriddenParentRoutes = new Set(); + const traverseChildRoutes = traverseRoutesConfig({ + ...options, + routes: childRoutes, + parentInjector: childInjector, + parentRoute: currentRoutePath, + parentPreloads: metadata.preload, + }); + + for await (const result of traverseChildRoutes) { + if ('error' in result) { + childResults.push(result); + continue; + } + + // If a child route resolves to the exact same path as the parent (i.e., an empty-path child), + // let the first matching child override the parent's metadata so accumulated preloads and + // redirects from the activated descendant chain are preserved. + if (parentRoutes.has(result.route)) { + if (!overriddenParentRoutes.has(result.route)) { + overriddenParentRoutes.add(result.route); + parentRoutes.set(result.route, result); + } + } else { + childResults.push(result); + } + } + + yield* parentRoutes.values(); + yield* childResults; } catch (error) { yield { error: `Error in handleRoute for '${options.currentRoutePath}': ${(error as Error).message}`, @@ -344,10 +373,7 @@ async function* traverseRoutesConfig(options: { renderMode: RenderMode.Prerender, ...matchedMetaData, preload: parentPreloads, - // Match Angular router behavior - // ['one', 'two', ''] -> 'one/two/' - // ['one', 'two', 'three'] -> 'one/two/three' - route: path === '' ? addTrailingSlash(currentRoutePath) : currentRoutePath, + route: currentRoutePath, presentInClientRouter: undefined, }, currentRoutePath, diff --git a/packages/angular/ssr/test/routes/ng-routes_spec.ts b/packages/angular/ssr/test/routes/ng-routes_spec.ts index 1af823f8f2ba..93b8e446ccb4 100644 --- a/packages/angular/ssr/test/routes/ng-routes_spec.ts +++ b/packages/angular/ssr/test/routes/ng-routes_spec.ts @@ -1044,4 +1044,95 @@ describe('extractRoutesAndCreateRouteTree', () => { expect(isDiscoveringRoutes).toBeTrue(); }); + + it('should preserve preload metadata for routes under nested empty-path routes', async () => { + setAngularAppTestingManifest( + [ + { + path: '', + children: [ + { + path: '', + pathMatch: 'full', + loadChildren: () => [ + { + path: '', + loadComponent: () => DummyComponent, + ɵentryName: 'home-page', + } as Route, + ], + ɵentryName: 'home-routes', + } as Route, + { + path: '', + children: [ + { + path: 'pricing', + loadChildren: () => [ + { + path: '', + loadComponent: () => DummyComponent, + ɵentryName: 'pricing-page', + } as Route, + ], + ɵentryName: 'pricing-routes', + } as Route, + ], + }, + ], + }, + { + path: 'es', + children: [ + { + path: '', + pathMatch: 'full', + loadChildren: () => [ + { + path: '', + loadComponent: () => DummyComponent, + ɵentryName: 'home-page', + } as Route, + ], + ɵentryName: 'home-routes', + } as Route, + ], + }, + ], + [{ path: '**', renderMode: RenderMode.Prerender }], + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + { + 'home-routes': ['chunk-home-routes.js'], + 'home-page': ['chunk-home-page.js'], + 'pricing-routes': ['chunk-pricing-routes.js'], + 'pricing-page': ['chunk-pricing-page.js'], + }, + ); + + const { routeTree, errors } = await extractRoutesAndCreateRouteTree({ url }); + expect(errors).toHaveSize(0); + expect(routeTree.toObject()).toEqual([ + { + route: '/', + renderMode: RenderMode.Prerender, + preload: ['chunk-home-routes.js', 'chunk-home-page.js'], + }, + { + route: '/pricing', + renderMode: RenderMode.Prerender, + preload: ['chunk-pricing-routes.js', 'chunk-pricing-page.js'], + }, + { + route: '/es', + renderMode: RenderMode.Prerender, + preload: ['chunk-home-routes.js', 'chunk-home-page.js'], + }, + ]); + }); }); diff --git a/packages/angular/ssr/test/testing-utils.ts b/packages/angular/ssr/test/testing-utils.ts index 6f825507c7a3..dad622d2e802 100644 --- a/packages/angular/ssr/test/testing-utils.ts +++ b/packages/angular/ssr/test/testing-utils.ts @@ -17,7 +17,7 @@ import { bootstrapApplication } from '@angular/platform-browser'; import { RouterOutlet, Routes, provideRouter } from '@angular/router'; import { type CompactPlan, compileSheet, encodePlan } from 'beasties/compiler'; import { destroyAngularServerApp } from '../src/app'; -import { ServerAsset, setAngularAppManifest } from '../src/manifest'; +import { AngularAppManifest, ServerAsset, setAngularAppManifest } from '../src/manifest'; import { ServerRoute, provideServerRendering, withRoutes } from '../src/routes/route-config'; @Component({ @@ -44,6 +44,7 @@ class AppComponent {} * root component and all its children. * @param inlineCriticalCss - An optional record of stylesheet paths to CSS content to be compiled into critical CSS plans. * @param nonce - An optional Content Security Policy (CSP) nonce to be used for inlined critical CSS. + * @param entryPointToBrowserMapping - An optional mapping of entry-point names to their associated browser bundles. */ export function setAngularAppTestingManifest( routes: Routes, @@ -55,6 +56,7 @@ export function setAngularAppTestingManifest( extraProviders: Array = [], inlineCriticalCss?: Record, nonce?: string, + entryPointToBrowserMapping?: AngularAppManifest['entryPointToBrowserMapping'], ): void { destroyAngularServerApp(); @@ -70,6 +72,7 @@ export function setAngularAppTestingManifest( setAngularAppManifest({ criticalCssPlans, nonce, + entryPointToBrowserMapping, baseHref, locale, assets: {