feat(@angular/ssr): add modulepreload for lazy-loaded routes

Enhance performance when using SSR by adding `modulepreload` links to lazy-loaded routes. This ensures that the required modules are preloaded in the background, improving the user experience and reducing the time to interactive.

Closes #26484
This commit is contained in:
Alan Agius 2024-12-10 10:59:55 +00:00 committed by Alan Agius
parent faa710e32d
commit 8d7a51dfc9
20 changed files with 921 additions and 20 deletions

View File

@ -127,6 +127,7 @@ ts_library(
"@npm//@angular/compiler-cli",
"@npm//@babel/core",
"@npm//prettier",
"@npm//typescript",
],
)

View File

@ -247,12 +247,13 @@ export async function executeBuild(
// Perform i18n translation inlining if enabled
if (i18nOptions.shouldInline) {
const result = await inlineI18n(options, executionResult, initialFiles);
const result = await inlineI18n(metafile, options, executionResult, initialFiles);
executionResult.addErrors(result.errors);
executionResult.addWarnings(result.warnings);
executionResult.addPrerenderedRoutes(result.prerenderedRoutes);
} else {
const result = await executePostBundleSteps(
metafile,
options,
executionResult.outputFiles,
executionResult.assetFiles,

View File

@ -6,6 +6,7 @@
* found in the LICENSE file at https://angular.dev/license
*/
import type { Metafile } from 'esbuild';
import assert from 'node:assert';
import {
BuildOutputFile,
@ -34,6 +35,7 @@ import { OutputMode } from './schema';
/**
* Run additional builds steps including SSG, AppShell, Index HTML file and Service worker generation.
* @param metafile An esbuild metafile object.
* @param options The normalized application builder options used to create the build.
* @param outputFiles The output files of an executed build.
* @param assetFiles The assets of an executed build.
@ -42,6 +44,7 @@ import { OutputMode } from './schema';
*/
// eslint-disable-next-line max-lines-per-function
export async function executePostBundleSteps(
metafile: Metafile,
options: NormalizedApplicationBuildOptions,
outputFiles: BuildOutputFile[],
assetFiles: BuildOutputAsset[],
@ -71,6 +74,7 @@ export async function executePostBundleSteps(
serverEntryPoint,
prerenderOptions,
appShellOptions,
publicPath,
workspaceRoot,
partialSSRBuild,
} = options;
@ -108,6 +112,7 @@ export async function executePostBundleSteps(
}
// Create server manifest
const initialFilesPaths = new Set(initialFiles.keys());
if (serverEntryPoint) {
const { manifestContent, serverAssetsChunks } = generateAngularServerAppManifest(
additionalHtmlOutputFiles,
@ -116,6 +121,9 @@ export async function executePostBundleSteps(
undefined,
locale,
baseHref,
initialFilesPaths,
metafile,
publicPath,
);
additionalOutputFiles.push(
@ -197,6 +205,9 @@ export async function executePostBundleSteps(
serializableRouteTreeNodeForManifest,
locale,
baseHref,
initialFilesPaths,
metafile,
publicPath,
);
for (const chunk of serverAssetsChunks) {

View File

@ -7,6 +7,7 @@
*/
import { BuilderContext } from '@angular-devkit/architect';
import type { Metafile } from 'esbuild';
import { join } from 'node:path';
import { BuildOutputFileType, InitialFileRecord } from '../../tools/esbuild/bundler-context';
import {
@ -23,11 +24,13 @@ import { NormalizedApplicationBuildOptions, getLocaleBaseHref } from './options'
/**
* Inlines all active locales as specified by the application build options into all
* application JavaScript files created during the build.
* @param metafile An esbuild metafile object.
* @param options The normalized application builder options used to create the build.
* @param executionResult The result of an executed build.
* @param initialFiles A map containing initial file information for the executed build.
*/
export async function inlineI18n(
metafile: Metafile,
options: NormalizedApplicationBuildOptions,
executionResult: ExecutionResult,
initialFiles: Map<string, InitialFileRecord>,
@ -79,6 +82,7 @@ export async function inlineI18n(
additionalOutputFiles,
prerenderedRoutes: generatedRoutes,
} = await executePostBundleSteps(
metafile,
{
...options,
baseHref: getLocaleBaseHref(baseHref, i18nOptions, locale) ?? baseHref,

View File

@ -17,6 +17,7 @@ import {
ensureSourceFileVersions,
} from '../angular-host';
import { replaceBootstrap } from '../transformers/jit-bootstrap-transformer';
import { lazyRoutesTransformer } from '../transformers/lazy-routes-transformer';
import { createWorkerTransformer } from '../transformers/web-worker-transformer';
import { AngularCompilation, DiagnosticModes, EmitFileResult } from './angular-compilation';
import { collectHmrCandidates } from './hmr-candidates';
@ -47,6 +48,10 @@ class AngularCompilationState {
export class AotCompilation extends AngularCompilation {
#state?: AngularCompilationState;
constructor(private readonly browserOnlyBuild: boolean) {
super();
}
async initialize(
tsconfig: string,
hostOptions: AngularHostOptions,
@ -314,8 +319,12 @@ export class AotCompilation extends AngularCompilation {
transformers.before ??= [];
transformers.before.push(
replaceBootstrap(() => typeScriptProgram.getProgram().getTypeChecker()),
webWorkerTransform,
);
transformers.before.push(webWorkerTransform);
if (!this.browserOnlyBuild) {
transformers.before.push(lazyRoutesTransformer(compilerOptions, compilerHost));
}
// Emit is handled in write file callback when using TypeScript
if (useTypeScriptTranspilation) {

View File

@ -14,22 +14,26 @@ import type { AngularCompilation } from './angular-compilation';
* compilation either for AOT or JIT mode. By default a parallel compilation is created
* that uses a Node.js worker thread.
* @param jit True, for Angular JIT compilation; False, for Angular AOT compilation.
* @param browserOnlyBuild True, for browser only builds; False, for browser and server builds.
* @returns An instance of an Angular compilation object.
*/
export async function createAngularCompilation(jit: boolean): Promise<AngularCompilation> {
export async function createAngularCompilation(
jit: boolean,
browserOnlyBuild: boolean,
): Promise<AngularCompilation> {
if (useParallelTs) {
const { ParallelCompilation } = await import('./parallel-compilation');
return new ParallelCompilation(jit);
return new ParallelCompilation(jit, browserOnlyBuild);
}
if (jit) {
const { JitCompilation } = await import('./jit-compilation');
return new JitCompilation();
return new JitCompilation(browserOnlyBuild);
} else {
const { AotCompilation } = await import('./aot-compilation');
return new AotCompilation();
return new AotCompilation(browserOnlyBuild);
}
}

View File

@ -13,6 +13,7 @@ import { loadEsmModule } from '../../../utils/load-esm';
import { profileSync } from '../../esbuild/profiling';
import { AngularHostOptions, createAngularCompilerHost } from '../angular-host';
import { createJitResourceTransformer } from '../transformers/jit-resource-transformer';
import { lazyRoutesTransformer } from '../transformers/lazy-routes-transformer';
import { createWorkerTransformer } from '../transformers/web-worker-transformer';
import { AngularCompilation, DiagnosticModes, EmitFileResult } from './angular-compilation';
@ -29,6 +30,10 @@ class JitCompilationState {
export class JitCompilation extends AngularCompilation {
#state?: JitCompilationState;
constructor(private readonly browserOnlyBuild: boolean) {
super();
}
async initialize(
tsconfig: string,
hostOptions: AngularHostOptions,
@ -116,8 +121,8 @@ export class JitCompilation extends AngularCompilation {
replaceResourcesTransform,
webWorkerTransform,
} = this.#state;
const buildInfoFilename =
typeScriptProgram.getCompilerOptions().tsBuildInfoFile ?? '.tsbuildinfo';
const compilerOptions = typeScriptProgram.getCompilerOptions();
const buildInfoFilename = compilerOptions.tsBuildInfoFile ?? '.tsbuildinfo';
const emittedFiles: EmitFileResult[] = [];
const writeFileCallback: ts.WriteFileCallback = (filename, contents, _a, _b, sourceFiles) => {
@ -140,6 +145,10 @@ export class JitCompilation extends AngularCompilation {
],
};
if (!this.browserOnlyBuild) {
transformers.before.push(lazyRoutesTransformer(compilerOptions, compilerHost));
}
// TypeScript will loop until there are no more affected files in the program
while (
typeScriptProgram.emitNextAffectedFile(writeFileCallback, undefined, undefined, transformers)

View File

@ -26,7 +26,10 @@ import { AngularCompilation, DiagnosticModes, EmitFileResult } from './angular-c
export class ParallelCompilation extends AngularCompilation {
readonly #worker: WorkerPool;
constructor(readonly jit: boolean) {
constructor(
private readonly jit: boolean,
private readonly browserOnlyBuild: boolean,
) {
super();
// TODO: Convert to import.meta usage during ESM transition
@ -99,6 +102,7 @@ export class ParallelCompilation extends AngularCompilation {
fileReplacements: hostOptions.fileReplacements,
tsconfig,
jit: this.jit,
browserOnlyBuild: this.browserOnlyBuild,
stylesheetPort: stylesheetChannel.port2,
optionsPort: optionsChannel.port2,
optionsSignal,

View File

@ -17,6 +17,7 @@ import { JitCompilation } from './jit-compilation';
export interface InitRequest {
jit: boolean;
browserOnlyBuild: boolean;
tsconfig: string;
fileReplacements?: Record<string, string>;
stylesheetPort: MessagePort;
@ -31,7 +32,9 @@ let compilation: AngularCompilation | undefined;
const sourceFileCache = new SourceFileCache();
export async function initialize(request: InitRequest) {
compilation ??= request.jit ? new JitCompilation() : new AotCompilation();
compilation ??= request.jit
? new JitCompilation(request.browserOnlyBuild)
: new AotCompilation(request.browserOnlyBuild);
const stylesheetRequests = new Map<string, [(value: string) => void, (reason: Error) => void]>();
request.stylesheetPort.on('message', ({ requestId, value, error }) => {

View File

@ -0,0 +1,225 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import assert from 'node:assert';
import { relative } from 'node:path/posix';
import ts from 'typescript';
/**
* A transformer factory that adds a property to the lazy-loaded route object.
* This property is used to allow for the retrieval of the module path during SSR.
*
* @param compilerOptions The compiler options.
* @param compilerHost The compiler host.
* @returns A transformer factory.
*
* @example
* **Before:**
* ```ts
* const routes: Routes = [
* {
* path: 'lazy',
* loadChildren: () => import('./lazy/lazy.module').then(m => m.LazyModule)
* }
* ];
* ```
*
* **After:**
* ```ts
* const routes: Routes = [
* {
* path: 'lazy',
* loadChildren: () => import('./lazy/lazy.module').then(m => m.LazyModule),
* ...(typeof ngServerMode !== "undefined" && ngServerMode ? { ɵentryName: "./lazy/lazy.module.ts" }: {})
* }
* ];
* ```
*/
export function lazyRoutesTransformer(
compilerOptions: ts.CompilerOptions,
compilerHost: ts.CompilerHost,
): ts.TransformerFactory<ts.SourceFile> {
const moduleResolutionCache = compilerHost.getModuleResolutionCache?.();
assert(
typeof compilerOptions.basePath === 'string',
'compilerOptions.basePath should be a string.',
);
const basePath = compilerOptions.basePath;
return (context: ts.TransformationContext) => {
const factory = context.factory;
const visitor = (node: ts.Node): ts.Node => {
if (!ts.isObjectLiteralExpression(node)) {
// Not an object literal, so skip it.
return ts.visitEachChild(node, visitor, context);
}
const loadFunction = getLoadComponentOrChildrenProperty(node)?.initializer;
// Check if the initializer is an arrow function or a function expression
if (
!loadFunction ||
(!ts.isArrowFunction(loadFunction) && !ts.isFunctionExpression(loadFunction))
) {
return ts.visitEachChild(node, visitor, context);
}
let callExpression: ts.CallExpression | undefined;
if (ts.isArrowFunction(loadFunction)) {
// Handle arrow functions: body can either be a block or a direct call expression
const body = loadFunction.body;
if (ts.isBlock(body)) {
// Arrow function with a block: check the first statement for a return call expression
const firstStatement = body.statements[0];
if (
firstStatement &&
ts.isReturnStatement(firstStatement) &&
firstStatement.expression &&
ts.isCallExpression(firstStatement.expression)
) {
callExpression = firstStatement.expression;
}
} else if (ts.isCallExpression(body)) {
// Arrow function with a direct call expression as its body
callExpression = body;
}
} else if (ts.isFunctionExpression(loadFunction)) {
// Handle function expressions: check for a return statement with a call expression
const returnExpression = loadFunction.body.statements.find(
ts.isReturnStatement,
)?.expression;
if (returnExpression && ts.isCallExpression(returnExpression)) {
callExpression = returnExpression;
}
}
if (!callExpression) {
return ts.visitEachChild(node, visitor, context);
}
// Optionally check for the 'then' property access expression
const expression = callExpression.expression;
if (
!ts.isCallExpression(expression) &&
ts.isPropertyAccessExpression(expression) &&
expression.name.text !== 'then'
) {
return ts.visitEachChild(node, visitor, context);
}
const importExpression = ts.isPropertyAccessExpression(expression)
? expression.expression // Navigate to the underlying expression for 'then'
: callExpression;
// Ensure the underlying expression is an import call
if (
!ts.isCallExpression(importExpression) ||
importExpression.expression.kind !== ts.SyntaxKind.ImportKeyword
) {
return ts.visitEachChild(node, visitor, context);
}
// Check if the argument to the import call is a string literal
const callExpressionArgument = importExpression.arguments[0];
if (!ts.isStringLiteralLike(callExpressionArgument)) {
// Not a string literal, so skip it.
return ts.visitEachChild(node, visitor, context);
}
const resolvedPath = ts.resolveModuleName(
callExpressionArgument.text,
node.getSourceFile().fileName,
compilerOptions,
compilerHost,
moduleResolutionCache,
)?.resolvedModule?.resolvedFileName;
if (!resolvedPath) {
// Could not resolve the module, so skip it.
return ts.visitEachChild(node, visitor, context);
}
const resolvedRelativePath = relative(basePath, resolvedPath);
// Create the new property
// Example: `...(typeof ngServerMode !== "undefined" && ngServerMode ? { ɵentryName: "src/home.ts" }: {})`
const newProperty = factory.createSpreadAssignment(
factory.createParenthesizedExpression(
factory.createConditionalExpression(
factory.createBinaryExpression(
factory.createBinaryExpression(
factory.createTypeOfExpression(factory.createIdentifier('ngServerMode')),
factory.createToken(ts.SyntaxKind.ExclamationEqualsEqualsToken),
factory.createStringLiteral('undefined'),
),
factory.createToken(ts.SyntaxKind.AmpersandAmpersandToken),
factory.createIdentifier('ngServerMode'),
),
factory.createToken(ts.SyntaxKind.QuestionToken),
factory.createObjectLiteralExpression([
factory.createPropertyAssignment(
factory.createIdentifier('ɵentryName'),
factory.createStringLiteral(resolvedRelativePath),
),
]),
factory.createToken(ts.SyntaxKind.ColonToken),
factory.createObjectLiteralExpression([]),
),
),
);
// Add the new property to the object literal.
return factory.updateObjectLiteralExpression(node, [...node.properties, newProperty]);
};
return (sourceFile) => {
const text = sourceFile.text;
if (!text.includes('loadC')) {
// Fast check for 'loadComponent' and 'loadChildren'.
return sourceFile;
}
return ts.visitEachChild(sourceFile, visitor, context);
};
};
}
/**
* Retrieves the property assignment for the `loadComponent` or `loadChildren` property of a route object.
*
* @param node The object literal expression to search.
* @returns The property assignment if found, otherwise `undefined`.
*/
function getLoadComponentOrChildrenProperty(
node: ts.ObjectLiteralExpression,
): ts.PropertyAssignment | undefined {
let hasPathProperty = false;
let loadComponentOrChildrenProperty: ts.PropertyAssignment | undefined;
for (const prop of node.properties) {
if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name)) {
continue;
}
const propertyNameText = prop.name.text;
if (propertyNameText === 'path') {
hasPathProperty = true;
} else if (propertyNameText === 'loadComponent' || propertyNameText === 'loadChildren') {
loadComponentOrChildrenProperty = prop;
}
if (hasPathProperty && loadComponentOrChildrenProperty) {
break;
}
}
return loadComponentOrChildrenProperty;
}

View File

@ -0,0 +1,208 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import ts from 'typescript';
import { lazyRoutesTransformer } from './lazy-routes-transformer';
describe('lazyRoutesTransformer', () => {
let program: ts.Program;
let compilerHost: ts.CompilerHost;
beforeEach(() => {
// Mock a basic TypeScript program and compilerHost
program = ts.createProgram(['/project/src/dummy.ts'], { basePath: '/project/' });
compilerHost = {
getNewLine: () => '\n',
fileExists: () => true,
readFile: () => '',
writeFile: () => undefined,
getCanonicalFileName: (fileName: string) => fileName,
getCurrentDirectory: () => '/project',
getDefaultLibFileName: () => 'lib.d.ts',
getSourceFile: () => undefined,
useCaseSensitiveFileNames: () => true,
resolveModuleNames: (moduleNames, containingFile) =>
moduleNames.map(
(name) =>
({
resolvedFileName: `/project/src/${name}.ts`,
}) as ts.ResolvedModule,
),
};
});
const transformSourceFile = (sourceCode: string): ts.SourceFile => {
const sourceFile = ts.createSourceFile(
'/project/src/dummy.ts',
sourceCode,
ts.ScriptTarget.ESNext,
true,
ts.ScriptKind.TS,
);
const transformer = lazyRoutesTransformer(program.getCompilerOptions(), compilerHost);
const result = ts.transform(sourceFile, [transformer]);
return result.transformed[0];
};
it('should return the same object when the routes array contains an empty object', () => {
const source = `
const routes = [{}];
`;
const transformedSourceFile = transformSourceFile(source);
const transformedCode = ts.createPrinter().printFile(transformedSourceFile);
expect(transformedCode).toContain(`const routes = [{}]`);
});
it('should add ɵentryName property to object with loadComponent and path (Arrow function)', () => {
const source = `
const routes = [
{
path: 'home',
loadComponent: () => import('./home').then(m => m.HomeComponent)
}
];
`;
const transformedSourceFile = transformSourceFile(source);
const transformedCode = ts.createPrinter().printFile(transformedSourceFile);
expect(transformedCode).toContain(
`...(typeof ngServerMode !== "undefined" && ngServerMode ? { ɵentryName: "src/home.ts" } : {})`,
);
});
it('should add ɵentryName property to object with loadComponent and path (Arrow function with return)', () => {
const source = `
const routes = [
{
path: 'home',
loadComponent: () => {
return import('./home').then(m => m.HomeComponent);
}
}
];
`;
const transformedSourceFile = transformSourceFile(source);
const transformedCode = ts.createPrinter().printFile(transformedSourceFile);
expect(transformedCode).toContain(
`...(typeof ngServerMode !== "undefined" && ngServerMode ? { ɵentryName: "src/home.ts" } : {})`,
);
});
it('should add ɵentryName property to object with loadComponent and path (Arrow function without .then)', () => {
const source = `
const routes = [
{
path: 'about',
loadComponent: () => import('./about')
}
];
`;
const transformedSourceFile = transformSourceFile(source);
const transformedCode = ts.createPrinter().printFile(transformedSourceFile);
expect(transformedCode).toContain(
`...(typeof ngServerMode !== "undefined" && ngServerMode ? { ɵentryName: "src/about.ts" } : {})`,
);
});
it('should add ɵentryName property to object with loadComponent using return and .then', () => {
const source = `
const routes = [
{
path: '',
loadComponent: () => {
return import('./home').then((m) => m.HomeComponent);
}
}
];
`;
const transformedSourceFile = transformSourceFile(source);
const transformedCode = ts.createPrinter().printFile(transformedSourceFile);
expect(transformedCode).toContain(
`...(typeof ngServerMode !== "undefined" && ngServerMode ? { ɵentryName: "src/home.ts" } : {})`,
);
});
it('should add ɵentryName property to object with loadComponent and path (Function expression)', () => {
const source = `
const routes = [
{
path: 'home',
loadComponent: function () { return import('./home').then(m => m.HomeComponent) }
}
];
`;
const transformedSourceFile = transformSourceFile(source);
const transformedCode = ts.createPrinter().printFile(transformedSourceFile);
expect(transformedCode).toContain(
`...(typeof ngServerMode !== "undefined" && ngServerMode ? { ɵentryName: "src/home.ts" } : {})`,
);
});
it('should not modify unrelated object literals', () => {
const source = `
const routes = [
{
path: 'home',
component: HomeComponent
}
];
`;
const transformedSourceFile = transformSourceFile(source);
const transformedCode = ts.createPrinter().printFile(transformedSourceFile);
expect(transformedCode).not.toContain(`ɵentryName`);
});
it('should ignore loadComponent without a valid import call', () => {
const source = `
const routes = [
{
path: 'home',
loadComponent: () => someFunction()
}
];
`;
const transformedSourceFile = transformSourceFile(source);
const transformedCode = ts.createPrinter().printFile(transformedSourceFile);
expect(transformedCode).not.toContain(`ɵentryName`);
});
it('should resolve paths relative to basePath', () => {
const source = `
const routes = [
{
path: 'about',
loadChildren: () => import('./features/about').then(m => m.AboutModule)
}
];
`;
const transformedSourceFile = transformSourceFile(source);
const transformedCode = ts.createPrinter().printFile(transformedSourceFile);
expect(transformedCode).toContain(
`...(typeof ngServerMode !== "undefined" && ngServerMode ? { ɵentryName: "src/features/about.ts" } : {})`,
);
});
});

View File

@ -40,6 +40,7 @@ export interface CompilerPluginOptions {
sourcemap: boolean | 'external';
tsconfig: string;
jit?: boolean;
browserOnlyBuild?: boolean;
/** Skip TypeScript compilation setup. This is useful to re-use the TypeScript compilation from another plugin. */
noopTypeScriptCompilation?: boolean;
@ -119,7 +120,7 @@ export function createCompilerPlugin(
// Create new reusable compilation for the appropriate mode based on the `jit` plugin option
const compilation: AngularCompilation = pluginOptions.noopTypeScriptCompilation
? new NoopCompilation()
: await createAngularCompilation(!!pluginOptions.jit);
: await createAngularCompilation(!!pluginOptions.jit, !!pluginOptions.browserOnlyBuild);
// Compilation is initially assumed to have errors until emitted
let hasCompilationErrors = true;

View File

@ -31,6 +31,7 @@ export function createCompilerPluginOptions(
const incremental = !!options.watch;
return {
browserOnlyBuild: !options.serverEntryPoint,
sourcemap: !!sourcemapOptions.scripts && (sourcemapOptions.hidden ? 'external' : true),
thirdPartySourcemaps: sourcemapOptions.vendor,
tsconfig,

View File

@ -6,14 +6,21 @@
* found in the LICENSE file at https://angular.dev/license
*/
import type { Metafile } from 'esbuild';
import { extname } from 'node:path';
import { NormalizedApplicationBuildOptions } from '../../builders/application/options';
import { type BuildOutputFile, BuildOutputFileType } from '../../tools/esbuild/bundler-context';
import { createOutputFile } from '../../tools/esbuild/utils';
import { shouldOptimizeChunks } from '../environment-options';
export const SERVER_APP_MANIFEST_FILENAME = 'angular-app-manifest.mjs';
export const SERVER_APP_ENGINE_MANIFEST_FILENAME = 'angular-app-engine-manifest.mjs';
interface FilesMapping {
path: string;
dynamicImport: boolean;
}
const MAIN_SERVER_OUTPUT_FILENAME = 'main.server.mjs';
/**
@ -97,6 +104,9 @@ export default {
* the application, helping with localization and rendering content specific to the locale.
* @param baseHref - The base HREF for the application. This is used to set the base URL
* for all relative URLs in the application.
* @param initialFiles - A list of initial files that preload tags have already been added for.
* @param metafile - An esbuild metafile object.
* @param publicPath - The configured public path.
*
* @returns An object containing:
* - `manifestContent`: A string of the SSR manifest content.
@ -109,6 +119,9 @@ export function generateAngularServerAppManifest(
routes: readonly unknown[] | undefined,
locale: string | undefined,
baseHref: string,
initialFiles: Set<string>,
metafile: Metafile,
publicPath: string | undefined,
): {
manifestContent: string;
serverAssetsChunks: BuildOutputFile[];
@ -132,6 +145,13 @@ export function generateAngularServerAppManifest(
}
}
// When routes have been extracted, mappings are no longer needed, as preloads will be included in the metadata.
// When shouldOptimizeChunks is enabled the metadata is no longer correct and thus we cannot generate the mappings.
const entryPointToBrowserMapping =
routes?.length || shouldOptimizeChunks
? undefined
: generateLazyLoadedFilesMappings(metafile, initialFiles, publicPath);
const manifestContent = `
export default {
bootstrap: () => import('./main.server.mjs').then(m => m.default),
@ -139,6 +159,7 @@ export default {
baseHref: '${baseHref}',
locale: ${JSON.stringify(locale)},
routes: ${JSON.stringify(routes, undefined, 2)},
entryPointToBrowserMapping: ${JSON.stringify(entryPointToBrowserMapping, undefined, 2)},
assets: {
${Object.entries(serverAssets)
.map(([key, value]) => `'${key}': ${value}`)
@ -149,3 +170,50 @@ export default {
return { manifestContent, serverAssetsChunks };
}
/**
* Maps entry points to their corresponding browser bundles for lazy loading.
*
* This function processes a metafile's outputs to generate a mapping between browser-side entry points
* and the associated JavaScript files that should be loaded in the browser. It includes the entry-point's
* own path and any valid imports while excluding initial files or external resources.
*/
function generateLazyLoadedFilesMappings(
metafile: Metafile,
initialFiles: Set<string>,
publicPath = '',
): Record<string, FilesMapping[]> {
const entryPointToBundles: Record<string, FilesMapping[]> = {};
for (const [fileName, { entryPoint, exports, imports }] of Object.entries(metafile.outputs)) {
// Skip files that don't have an entryPoint, no exports, or are not .js
if (!entryPoint || exports?.length < 1 || !fileName.endsWith('.js')) {
continue;
}
const importedPaths: FilesMapping[] = [
{
path: `${publicPath}${fileName}`,
dynamicImport: false,
},
];
for (const { kind, external, path } of imports) {
if (
external ||
initialFiles.has(path) ||
(kind !== 'dynamic-import' && kind !== 'import-statement')
) {
continue;
}
importedPaths.push({
path: `${publicPath}${path}`,
dynamicImport: kind === 'dynamic-import',
});
}
entryPointToBundles[entryPoint] = importedPaths;
}
return entryPointToBundles;
}

View File

@ -251,7 +251,7 @@ export class AngularServerApp {
matchedRoute: RouteTreeNodeMetadata,
requestContext?: unknown,
): Promise<Response | null> {
const { renderMode, headers, status } = matchedRoute;
const { renderMode, headers, status, preload } = matchedRoute;
if (!this.allowStaticRouteRender && renderMode === RenderMode.Prerender) {
return null;
@ -293,8 +293,8 @@ export class AngularServerApp {
);
} else if (renderMode === RenderMode.Client) {
// Serve the client-side rendered version if the route is configured for CSR.
let html = await assets.getServerAsset('index.csr.html').text();
html = await this.runTransformsOnHtml(html, url);
let html = await this.assets.getServerAsset('index.csr.html').text();
html = await this.runTransformsOnHtml(html, url, preload);
return new Response(html, responseInit);
}
@ -308,7 +308,7 @@ export class AngularServerApp {
this.boostrap ??= await bootstrap();
let html = await assets.getIndexServerHtml().text();
html = await this.runTransformsOnHtml(html, url);
html = await this.runTransformsOnHtml(html, url, preload);
html = await renderAngular(
html,
this.boostrap,
@ -385,13 +385,22 @@ export class AngularServerApp {
*
* @param html - The raw HTML content to be transformed.
* @param url - The URL associated with the HTML content, used for context during transformations.
* @param preload - An array of URLs representing the JavaScript resources to preload.
* @returns A promise that resolves to the transformed HTML string.
*/
private async runTransformsOnHtml(html: string, url: URL): Promise<string> {
private async runTransformsOnHtml(
html: string,
url: URL,
preload: readonly string[] | undefined,
): Promise<string> {
if (this.hooks.has('html:transform:pre')) {
html = await this.hooks.run('html:transform:pre', { html, url });
}
if (preload?.length) {
html = appendPreloadHintsToHtml(html, preload);
}
return html;
}
}
@ -430,3 +439,30 @@ export function destroyAngularServerApp(): void {
angularServerApp = undefined;
}
/**
* Appends module preload hints to an HTML string for specified JavaScript resources.
* This function enhances the HTML by injecting `<link rel="modulepreload">` elements
* for each provided resource, allowing browsers to preload the specified JavaScript
* modules for better performance.
*
* @param html - The original HTML string to which preload hints will be added.
* @param preload - An array of URLs representing the JavaScript resources to preload.
* @returns The modified HTML string with the preload hints injected before the closing `</body>` tag.
* If `</body>` is not found, the links are not added.
*/
function appendPreloadHintsToHtml(html: string, preload: readonly string[]): string {
const bodyCloseIdx = html.lastIndexOf('</body>');
if (bodyCloseIdx === -1) {
return html;
}
// Note: Module preloads should be placed at the end before the closing body tag to avoid a performance penalty.
// Placing them earlier can cause the browser to prioritize downloading these modules
// over other critical page resources like images, CSS, and fonts.
return [
html.slice(0, bodyCloseIdx),
...preload.map((val) => `<link rel="modulepreload" href="${val}">`),
html.slice(bodyCloseIdx),
].join('\n');
}

View File

@ -110,6 +110,26 @@ export interface AngularAppManifest {
* the application, aiding with localization and rendering content specific to the locale.
*/
readonly locale?: string;
/**
* Maps entry-point names to their corresponding browser bundles and loading strategies.
*
* - **Key**: The entry-point name, typically the value of `ɵentryName`.
* - **Value**: An array of objects, each representing a browser bundle with:
* - `path`: The filename or URL of the associated JavaScript bundle to preload.
* - `dynamicImport`: A boolean indicating whether the bundle is loaded via a dynamic `import()`.
* If `true`, the bundle is lazily loaded, impacting its preloading behavior.
*
* ### Example
* ```ts
* {
* 'src/app/lazy/lazy.ts': [{ path: 'src/app/lazy/lazy.js', dynamicImport: true }]
* }
* ```
*/
readonly entryPointToBrowserMapping?: Readonly<
Record<string, ReadonlyArray<{ path: string; dynamicImport: boolean }> | undefined>
>;
}
/**

View File

@ -9,7 +9,11 @@
import { APP_BASE_HREF, PlatformLocation } from '@angular/common';
import { ApplicationRef, Compiler, Injector, runInInjectionContext, ɵConsole } from '@angular/core';
import { INITIAL_CONFIG, platformServer } from '@angular/platform-server';
import { Route, Router, ɵloadChildren as loadChildrenHelper } from '@angular/router';
import {
Route as AngularRoute,
Router,
ɵloadChildren as loadChildrenHelper,
} from '@angular/router';
import { ServerAssets } from '../assets';
import { Console } from '../console';
import { AngularAppManifest, getAngularAppManifest } from '../manifest';
@ -25,6 +29,16 @@ import {
} from './route-config';
import { RouteTree, RouteTreeNodeMetadata } from './route-tree';
interface Route extends AngularRoute {
ɵentryName?: string;
}
/**
* The maximum number of module preload link elements that should be added for
* initial scripts.
*/
const MODULE_PRELOAD_MAX = 10;
/**
* Regular expression to match segments preceded by a colon in a string.
*/
@ -87,6 +101,8 @@ interface AngularRouterConfigResult {
appShellRoute?: string;
}
type EntryPointToBrowserMapping = AngularAppManifest['entryPointToBrowserMapping'];
/**
* Traverses an array of route configurations to generate route tree node metadata.
*
@ -104,6 +120,8 @@ async function* traverseRoutesConfig(options: {
serverConfigRouteTree: RouteTree<ServerConfigRouteTreeAdditionalMetadata> | undefined;
invokeGetPrerenderParams: boolean;
includePrerenderFallbackRoutes: boolean;
entryPointToBrowserMapping: EntryPointToBrowserMapping | undefined;
parentPreloads?: readonly string[];
}): AsyncIterableIterator<RouteTreeNodeMetadata | { error: string }> {
const {
routes,
@ -111,13 +129,15 @@ async function* traverseRoutesConfig(options: {
parentInjector,
parentRoute,
serverConfigRouteTree,
entryPointToBrowserMapping,
parentPreloads,
invokeGetPrerenderParams,
includePrerenderFallbackRoutes,
} = options;
for (const route of routes) {
try {
const { path = '', redirectTo, loadChildren, children } = route;
const { path = '', redirectTo, loadChildren, loadComponent, children, ɵentryName } = route;
const currentRoutePath = joinUrlParts(parentRoute, path);
// Get route metadata from the server config route tree, if available
@ -140,13 +160,17 @@ async function* traverseRoutesConfig(options: {
const metadata: ServerConfigRouteTreeNodeMetadata = {
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,
presentInClientRouter: undefined,
};
delete metadata.presentInClientRouter;
if (ɵentryName && loadComponent) {
appendPreloadToMetadata(ɵentryName, entryPointToBrowserMapping, metadata, true);
}
if (metadata.renderMode === RenderMode.Prerender) {
// Handle SSG routes
@ -180,11 +204,20 @@ async function* traverseRoutesConfig(options: {
...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,
@ -198,6 +231,7 @@ async function* traverseRoutesConfig(options: {
routes: childRoutes,
parentInjector: injector,
parentRoute: currentRoutePath,
parentPreloads: metadata.preload,
});
}
}
@ -209,6 +243,36 @@ async function* traverseRoutesConfig(options: {
}
}
/**
* Appends preload information to the metadata object based on the specified entry-point and chunk mappings.
*
* This function extracts preload data for a given entry-point from the provided chunk mappings. It adds the
* corresponding browser bundles to the metadata's preload list, ensuring no duplicates and limiting the total
* preloads to a predefined maximum.
*/
function appendPreloadToMetadata(
entryName: string,
entryPointToBrowserMapping: EntryPointToBrowserMapping,
metadata: ServerConfigRouteTreeNodeMetadata,
includeDynamicImports: boolean,
): void {
if (!entryPointToBrowserMapping) {
return;
}
const preload = entryPointToBrowserMapping[entryName];
if (preload?.length) {
// Merge existing preloads with new ones, ensuring uniqueness and limiting the total to the maximum allowed.
const preloadPaths =
preload
.filter(({ dynamicImport }) => includeDynamicImports || !dynamicImport)
.map(({ path }) => path) ?? [];
const combinedPreloads = [...(metadata.preload ?? []), ...preloadPaths];
metadata.preload = Array.from(new Set(combinedPreloads)).slice(0, MODULE_PRELOAD_MAX);
}
}
/**
* Handles SSG (Static Site Generation) routes by invoking `getPrerenderParams` and yielding
* all parameterized paths, returning any errors encountered.
@ -391,6 +455,7 @@ function buildServerConfigRouteTree({ routes, appShellRoute }: ServerRoutesConfi
* @param invokeGetPrerenderParams - A boolean flag indicating whether to invoke `getPrerenderParams` for parameterized SSG routes
* to handle prerendering paths. Defaults to `false`.
* @param includePrerenderFallbackRoutes - A flag indicating whether to include fallback routes in the result. Defaults to `true`.
* @param entryPointToBrowserMapping - Maps the entry-point name to the associated JavaScript browser bundles.
*
* @returns A promise that resolves to an object of type `AngularRouterConfigResult` or errors.
*/
@ -400,6 +465,7 @@ export async function getRoutesFromAngularRouterConfig(
url: URL,
invokeGetPrerenderParams = false,
includePrerenderFallbackRoutes = true,
entryPointToBrowserMapping: EntryPointToBrowserMapping | undefined = undefined,
): Promise<AngularRouterConfigResult> {
const { protocol, host } = url;
@ -469,6 +535,7 @@ export async function getRoutesFromAngularRouterConfig(
serverConfigRouteTree,
invokeGetPrerenderParams,
includePrerenderFallbackRoutes,
entryPointToBrowserMapping,
});
for await (const result of traverseRoutes) {
@ -569,6 +636,7 @@ export function extractRoutesAndCreateRouteTree(options: {
url,
invokeGetPrerenderParams,
includePrerenderFallbackRoutes,
manifest.entryPointToBrowserMapping,
);
for (const { route, ...metadata } of routes) {

View File

@ -65,6 +65,11 @@ export interface RouteTreeNodeMetadata {
* Specifies the rendering mode used for this route.
*/
renderMode: RenderMode;
/**
* A list of resource that should be preloaded by the browser.
*/
preload?: readonly string[];
}
/**

View File

@ -22,7 +22,7 @@ import { ServerRoute, provideServerRoutesConfig } from '../src/routes/route-conf
*
* @param routes - An array of route definitions to be used by the Angular Router.
* @param serverRoutes - An array of server route definitions for server-side rendering.
* @param [baseHref='/'] - An optional base href for the HTML template (default is `/`).
* @param baseHref - An optional base href to be used in the HTML template.
* @param additionalServerAssets - A record of additional server assets to include,
* where the keys are asset paths and the values are asset details.
* @param locale - An optional locale to configure for the application during testing.

View File

@ -0,0 +1,223 @@
import assert from 'node:assert';
import { replaceInFile, writeMultipleFiles } from '../../../utils/fs';
import { execAndWaitForOutputToMatch, ng, noSilentNg, silentNg } from '../../../utils/process';
import { installWorkspacePackages, uninstallPackage } from '../../../utils/packages';
import { ngServe, updateJsonFile, useSha } from '../../../utils/project';
import { getGlobalVariable } from '../../../utils/env';
import { findFreePort } from '../../../utils/network';
export default async function () {
assert(
getGlobalVariable('argv')['esbuild'],
'This test should not be called in the Webpack suite.',
);
// Forcibly remove in case another test doesn't clean itself up.
await uninstallPackage('@angular/ssr');
await ng('add', '@angular/ssr', '--server-routing', '--skip-confirmation', '--skip-install');
await useSha();
await installWorkspacePackages();
await updateJsonFile('angular.json', (workspaceJson) => {
const appProject = workspaceJson.projects['test-project'];
appProject.architect['build'].options.namedChunks = true;
});
// Add routes
await writeMultipleFiles({
'src/app/app.routes.ts': `
import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: '',
loadComponent: () => import('./home/home.component').then(c => c.HomeComponent),
},
{
path: 'ssg',
loadChildren: () => import('./ssg.routes').then(m => m.routes),
},
{
path: 'ssr',
loadComponent: () => import('./ssr/ssr.component').then(c => c.SsrComponent),
},
{
path: 'csr',
loadComponent: () => import('./csr/csr.component').then(c => c.CsrComponent),
},
];
`,
'src/app/app.routes.server.ts': `
import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
{
path: 'ssr',
renderMode: RenderMode.Server,
},
{
path: 'csr',
renderMode: RenderMode.Client,
},
{
path: '**',
renderMode: RenderMode.Prerender,
},
];
`,
'src/app/cross-dep.ts': `export const foo = 'foo';`,
'src/app/ssg.routes.ts': `
import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: '',
loadComponent: () => import('./ssg/ssg.component').then(c => c.SsgComponent),
},
{
path: 'one',
loadComponent: () => import('./ssg-one/ssg-one.component').then(c => c.SsgOneComponent),
},
{
path: 'two',
loadComponent: () => import('./ssg-two/ssg-two.component').then(c => c.SsgTwoComponent),
},
];`,
});
// Generate components for the above routes
const componentNames: string[] = ['home', 'ssg', 'csr', 'ssr', 'ssg-one', 'ssg-two'];
for (const componentName of componentNames) {
await silentNg('generate', 'component', componentName);
}
// Add a cross-dependency
await Promise.all([
replaceInFile(
'src/app/ssg-one/ssg-one.component.ts',
`OneComponent {`,
`OneComponent {
async ngOnInit() {
await import('../cross-dep');
}
`,
),
replaceInFile(
'src/app/ssg-two/ssg-two.component.ts',
`TwoComponent {`,
`TwoComponent {
async ngOnInit() {
await import('../cross-dep');
}
`,
),
]);
// Test both vite and `ng build`
await runTests(await ngServe());
await noSilentNg('build', '--output-mode=server');
await runTests(await spawnServer());
}
const RESPONSE_EXPECTS: Record<
string,
{
matches: RegExp[];
notMatches: RegExp[];
}
> = {
'/': {
matches: [/<link rel="modulepreload" href="(home\.component-[a-zA-Z0-9]{8}\.js)">/],
notMatches: [/ssg\.component/, /ssr\.component/, /csr\.component/, /cross-dep-/],
},
'/ssg': {
matches: [
/<link rel="modulepreload" href="(ssg\.routes-[a-zA-Z0-9]{8}\.js)">/,
/<link rel="modulepreload" href="(ssg\.component-[a-zA-Z0-9]{8}\.js)">/,
],
notMatches: [
/home\.component/,
/ssr\.component/,
/csr\.component/,
/ssg-one\.component/,
/ssg-two\.component/,
/cross-dep-/,
],
},
'/ssg/one': {
matches: [
/<link rel="modulepreload" href="(ssg\.routes-[a-zA-Z0-9]{8}\.js)">/,
/<link rel="modulepreload" href="(ssg-one\.component-[a-zA-Z0-9]{8}\.js)">/,
/<link rel="modulepreload" href="(cross-dep-[a-zA-Z0-9]{8}\.js)">/,
],
notMatches: [
/home\.component/,
/ssr\.component/,
/csr\.component/,
/ssg-two\.component/,
/ssg\.component/,
],
},
'/ssg/two': {
matches: [
/<link rel="modulepreload" href="(ssg\.routes-[a-zA-Z0-9]{8}\.js)">/,
/<link rel="modulepreload" href="(ssg-two\.component-[a-zA-Z0-9]{8}\.js)">/,
/<link rel="modulepreload" href="(cross-dep-[a-zA-Z0-9]{8}\.js)">/,
],
notMatches: [
/home\.component/,
/ssr\.component/,
/csr\.component/,
/ssg-one\.component/,
/ssg\.component/,
],
},
'/ssr': {
matches: [/<link rel="modulepreload" href="(ssr\.component-[a-zA-Z0-9]{8}\.js)">/],
notMatches: [/home\.component/, /ssg\.component/, /csr\.component/],
},
'/csr': {
matches: [/<link rel="modulepreload" href="(csr\.component-[a-zA-Z0-9]{8}\.js)">/],
notMatches: [/home\.component/, /ssg\.component/, /ssr\.component/, /cross-dep-/],
},
};
async function runTests(port: number): Promise<void> {
for (const [pathname, { matches, notMatches }] of Object.entries(RESPONSE_EXPECTS)) {
const res = await fetch(`http://localhost:${port}${pathname}`);
const text = await res.text();
for (const match of matches) {
assert.match(text, match, `Response for '${pathname}': ${match} was not matched in content.`);
// Ensure that the url is correct and it's a 200.
const link = text.match(match)?.[1];
const preloadRes = await fetch(`http://localhost:${port}/${link}`);
assert.equal(preloadRes.status, 200);
}
for (const match of notMatches) {
assert.doesNotMatch(
text,
match,
`Response for '${pathname}': ${match} was matched in content.`,
);
}
}
}
async function spawnServer(): Promise<number> {
const port = await findFreePort();
await execAndWaitForOutputToMatch(
'npm',
['run', 'serve:ssr:test-project'],
/Node Express server listening on/,
{
'PORT': String(port),
},
);
return port;
}