Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 61 additions & 35 deletions packages/angular/ssr/src/routes/ng-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -170,20 +170,28 @@ 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<string, RouteTreeNodeMetadata>();

if (metadata.renderMode === RenderMode.Prerender) {
yield* handleSSGRoute(
for await (const result of handleSSGRoute(
serverConfigRouteTree,
typeof redirectTo === 'string' ? redirectTo : undefined,
parentRoute,
metadata,
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 {
Expand All @@ -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,
Expand All @@ -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<string>();
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}`,
Expand Down Expand Up @@ -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,
Expand Down
91 changes: 91 additions & 0 deletions packages/angular/ssr/test/routes/ng-routes_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
},
]);
});
});
5 changes: 4 additions & 1 deletion packages/angular/ssr/test/testing-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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,
Expand All @@ -55,6 +56,7 @@ export function setAngularAppTestingManifest(
extraProviders: Array<Provider | EnvironmentProviders> = [],
inlineCriticalCss?: Record<string, string>,
nonce?: string,
entryPointToBrowserMapping?: AngularAppManifest['entryPointToBrowserMapping'],
): void {
destroyAngularServerApp();

Expand All @@ -70,6 +72,7 @@ export function setAngularAppTestingManifest(
setAngularAppManifest({
criticalCssPlans,
nonce,
entryPointToBrowserMapping,
baseHref,
locale,
assets: {
Expand Down
Loading