Skip to content

Commit 509feb7

Browse files
committed
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
1 parent 4db4dd4 commit 509feb7

File tree

20 files changed

+740
-17
lines changed

20 files changed

+740
-17
lines changed

packages/angular/build/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ ts_library(
127127
"@npm//@angular/compiler-cli",
128128
"@npm//@babel/core",
129129
"@npm//prettier",
130+
"@npm//typescript",
130131
],
131132
)
132133

packages/angular/build/src/builders/application/execute-build.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,12 +247,13 @@ export async function executeBuild(
247247

248248
// Perform i18n translation inlining if enabled
249249
if (i18nOptions.shouldInline) {
250-
const result = await inlineI18n(options, executionResult, initialFiles);
250+
const result = await inlineI18n(metafile, options, executionResult, initialFiles);
251251
executionResult.addErrors(result.errors);
252252
executionResult.addWarnings(result.warnings);
253253
executionResult.addPrerenderedRoutes(result.prerenderedRoutes);
254254
} else {
255255
const result = await executePostBundleSteps(
256+
metafile,
256257
options,
257258
executionResult.outputFiles,
258259
executionResult.assetFiles,

packages/angular/build/src/builders/application/execute-post-bundle.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9+
import type { Metafile } from 'esbuild';
910
import assert from 'node:assert';
1011
import {
1112
BuildOutputFile,
@@ -34,6 +35,7 @@ import { OutputMode } from './schema';
3435

3536
/**
3637
* Run additional builds steps including SSG, AppShell, Index HTML file and Service worker generation.
38+
* @param metafile An esbuild metafile object.
3739
* @param options The normalized application builder options used to create the build.
3840
* @param outputFiles The output files of an executed build.
3941
* @param assetFiles The assets of an executed build.
@@ -42,6 +44,7 @@ import { OutputMode } from './schema';
4244
*/
4345
// eslint-disable-next-line max-lines-per-function
4446
export async function executePostBundleSteps(
47+
metafile: Metafile,
4548
options: NormalizedApplicationBuildOptions,
4649
outputFiles: BuildOutputFile[],
4750
assetFiles: BuildOutputAsset[],
@@ -71,6 +74,7 @@ export async function executePostBundleSteps(
7174
serverEntryPoint,
7275
prerenderOptions,
7376
appShellOptions,
77+
publicPath,
7478
workspaceRoot,
7579
partialSSRBuild,
7680
} = options;
@@ -108,6 +112,7 @@ export async function executePostBundleSteps(
108112
}
109113

110114
// Create server manifest
115+
const initialFilesPaths = new Set(initialFiles.keys());
111116
if (serverEntryPoint) {
112117
const { manifestContent, serverAssetsChunks } = generateAngularServerAppManifest(
113118
additionalHtmlOutputFiles,
@@ -116,6 +121,9 @@ export async function executePostBundleSteps(
116121
undefined,
117122
locale,
118123
baseHref,
124+
initialFilesPaths,
125+
metafile,
126+
publicPath,
119127
);
120128

121129
additionalOutputFiles.push(
@@ -197,6 +205,9 @@ export async function executePostBundleSteps(
197205
serializableRouteTreeNodeForManifest,
198206
locale,
199207
baseHref,
208+
initialFilesPaths,
209+
metafile,
210+
publicPath,
200211
);
201212

202213
for (const chunk of serverAssetsChunks) {

packages/angular/build/src/builders/application/i18n.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88

99
import { BuilderContext } from '@angular-devkit/architect';
10+
import type { Metafile } from 'esbuild';
1011
import { join } from 'node:path';
1112
import { BuildOutputFileType, InitialFileRecord } from '../../tools/esbuild/bundler-context';
1213
import {
@@ -23,11 +24,13 @@ import { NormalizedApplicationBuildOptions, getLocaleBaseHref } from './options'
2324
/**
2425
* Inlines all active locales as specified by the application build options into all
2526
* application JavaScript files created during the build.
27+
* @param metafile An esbuild metafile object.
2628
* @param options The normalized application builder options used to create the build.
2729
* @param executionResult The result of an executed build.
2830
* @param initialFiles A map containing initial file information for the executed build.
2931
*/
3032
export async function inlineI18n(
33+
metafile: Metafile,
3134
options: NormalizedApplicationBuildOptions,
3235
executionResult: ExecutionResult,
3336
initialFiles: Map<string, InitialFileRecord>,
@@ -80,6 +83,7 @@ export async function inlineI18n(
8083
additionalOutputFiles,
8184
prerenderedRoutes: generatedRoutes,
8285
} = await executePostBundleSteps(
86+
metafile,
8387
{
8488
...options,
8589
baseHref,

packages/angular/build/src/tools/angular/compilation/aot-compilation.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
ensureSourceFileVersions,
1818
} from '../angular-host';
1919
import { replaceBootstrap } from '../transformers/jit-bootstrap-transformer';
20+
import { lazyRoutesTransformer } from '../transformers/lazy-routes-transformer';
2021
import { createWorkerTransformer } from '../transformers/web-worker-transformer';
2122
import { AngularCompilation, DiagnosticModes, EmitFileResult } from './angular-compilation';
2223
import { collectHmrCandidates } from './hmr-candidates';
@@ -47,6 +48,10 @@ class AngularCompilationState {
4748
export class AotCompilation extends AngularCompilation {
4849
#state?: AngularCompilationState;
4950

51+
constructor(private readonly browserOnlyBuild: boolean) {
52+
super();
53+
}
54+
5055
async initialize(
5156
tsconfig: string,
5257
hostOptions: AngularHostOptions,
@@ -300,8 +305,12 @@ export class AotCompilation extends AngularCompilation {
300305
transformers.before ??= [];
301306
transformers.before.push(
302307
replaceBootstrap(() => typeScriptProgram.getProgram().getTypeChecker()),
308+
webWorkerTransform,
303309
);
304-
transformers.before.push(webWorkerTransform);
310+
311+
if (!this.browserOnlyBuild) {
312+
transformers.before.push(lazyRoutesTransformer(compilerOptions, compilerHost));
313+
}
305314

306315
// Emit is handled in write file callback when using TypeScript
307316
if (useTypeScriptTranspilation) {

packages/angular/build/src/tools/angular/compilation/factory.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,22 +14,26 @@ import type { AngularCompilation } from './angular-compilation';
1414
* compilation either for AOT or JIT mode. By default a parallel compilation is created
1515
* that uses a Node.js worker thread.
1616
* @param jit True, for Angular JIT compilation; False, for Angular AOT compilation.
17+
* @param browserOnlyBuild True, for browser only builds; False, for browser and server builds.
1718
* @returns An instance of an Angular compilation object.
1819
*/
19-
export async function createAngularCompilation(jit: boolean): Promise<AngularCompilation> {
20+
export async function createAngularCompilation(
21+
jit: boolean,
22+
browserOnlyBuild: boolean,
23+
): Promise<AngularCompilation> {
2024
if (useParallelTs) {
2125
const { ParallelCompilation } = await import('./parallel-compilation');
2226

23-
return new ParallelCompilation(jit);
27+
return new ParallelCompilation(jit, browserOnlyBuild);
2428
}
2529

2630
if (jit) {
2731
const { JitCompilation } = await import('./jit-compilation');
2832

29-
return new JitCompilation();
33+
return new JitCompilation(browserOnlyBuild);
3034
} else {
3135
const { AotCompilation } = await import('./aot-compilation');
3236

33-
return new AotCompilation();
37+
return new AotCompilation(browserOnlyBuild);
3438
}
3539
}

packages/angular/build/src/tools/angular/compilation/jit-compilation.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { loadEsmModule } from '../../../utils/load-esm';
1313
import { profileSync } from '../../esbuild/profiling';
1414
import { AngularHostOptions, createAngularCompilerHost } from '../angular-host';
1515
import { createJitResourceTransformer } from '../transformers/jit-resource-transformer';
16+
import { lazyRoutesTransformer } from '../transformers/lazy-routes-transformer';
1617
import { createWorkerTransformer } from '../transformers/web-worker-transformer';
1718
import { AngularCompilation, DiagnosticModes, EmitFileResult } from './angular-compilation';
1819

@@ -29,6 +30,10 @@ class JitCompilationState {
2930
export class JitCompilation extends AngularCompilation {
3031
#state?: JitCompilationState;
3132

33+
constructor(private readonly browserOnlyBuild: boolean) {
34+
super();
35+
}
36+
3237
async initialize(
3338
tsconfig: string,
3439
hostOptions: AngularHostOptions,
@@ -116,8 +121,8 @@ export class JitCompilation extends AngularCompilation {
116121
replaceResourcesTransform,
117122
webWorkerTransform,
118123
} = this.#state;
119-
const buildInfoFilename =
120-
typeScriptProgram.getCompilerOptions().tsBuildInfoFile ?? '.tsbuildinfo';
124+
const compilerOptions = typeScriptProgram.getCompilerOptions();
125+
const buildInfoFilename = compilerOptions.tsBuildInfoFile ?? '.tsbuildinfo';
121126

122127
const emittedFiles: EmitFileResult[] = [];
123128
const writeFileCallback: ts.WriteFileCallback = (filename, contents, _a, _b, sourceFiles) => {
@@ -140,6 +145,10 @@ export class JitCompilation extends AngularCompilation {
140145
],
141146
};
142147

148+
if (!this.browserOnlyBuild) {
149+
transformers.before.push(lazyRoutesTransformer(compilerOptions, compilerHost));
150+
}
151+
143152
// TypeScript will loop until there are no more affected files in the program
144153
while (
145154
typeScriptProgram.emitNextAffectedFile(writeFileCallback, undefined, undefined, transformers)

packages/angular/build/src/tools/angular/compilation/parallel-compilation.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,10 @@ import { AngularCompilation, DiagnosticModes, EmitFileResult } from './angular-c
2626
export class ParallelCompilation extends AngularCompilation {
2727
readonly #worker: WorkerPool;
2828

29-
constructor(readonly jit: boolean) {
29+
constructor(
30+
private readonly jit: boolean,
31+
private readonly browserOnlyBuild: boolean,
32+
) {
3033
super();
3134

3235
// TODO: Convert to import.meta usage during ESM transition
@@ -99,6 +102,7 @@ export class ParallelCompilation extends AngularCompilation {
99102
fileReplacements: hostOptions.fileReplacements,
100103
tsconfig,
101104
jit: this.jit,
105+
browserOnlyBuild: this.browserOnlyBuild,
102106
stylesheetPort: stylesheetChannel.port2,
103107
optionsPort: optionsChannel.port2,
104108
optionsSignal,

packages/angular/build/src/tools/angular/compilation/parallel-worker.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { JitCompilation } from './jit-compilation';
1717

1818
export interface InitRequest {
1919
jit: boolean;
20+
browserOnlyBuild: boolean;
2021
tsconfig: string;
2122
fileReplacements?: Record<string, string>;
2223
stylesheetPort: MessagePort;
@@ -31,7 +32,9 @@ let compilation: AngularCompilation | undefined;
3132
const sourceFileCache = new SourceFileCache();
3233

3334
export async function initialize(request: InitRequest) {
34-
compilation ??= request.jit ? new JitCompilation() : new AotCompilation();
35+
compilation ??= request.jit
36+
? new JitCompilation(request.browserOnlyBuild)
37+
: new AotCompilation(request.browserOnlyBuild);
3538

3639
const stylesheetRequests = new Map<string, [(value: string) => void, (reason: Error) => void]>();
3740
request.stylesheetPort.on('message', ({ requestId, value, error }) => {
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import assert from 'node:assert';
10+
import { relative } from 'node:path/posix';
11+
import ts from 'typescript';
12+
13+
export function lazyRoutesTransformer(
14+
compilerOptions: ts.CompilerOptions,
15+
compilerHost: ts.CompilerHost,
16+
): ts.TransformerFactory<ts.SourceFile> {
17+
const moduleResolutionCache = compilerHost.getModuleResolutionCache?.();
18+
assert(
19+
typeof compilerOptions.basePath === 'string',
20+
'compilerOptions.basePath should be a string.',
21+
);
22+
const basePath = compilerOptions.basePath;
23+
24+
return (context: ts.TransformationContext) => {
25+
const factory = context.factory;
26+
27+
const visitor = (node: ts.Node): ts.Node => {
28+
if (!ts.isObjectLiteralExpression(node)) {
29+
return ts.visitEachChild(node, visitor, context);
30+
}
31+
32+
let hasPathProperty = false;
33+
let loadComponentOrChildrenProperty: ts.PropertyAssignment | undefined;
34+
for (const prop of node.properties) {
35+
if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name)) {
36+
continue;
37+
}
38+
39+
const propertyNameText = prop.name.text;
40+
if (propertyNameText === 'path') {
41+
hasPathProperty = true;
42+
} else if (propertyNameText === 'loadComponent' || propertyNameText === 'loadChildren') {
43+
loadComponentOrChildrenProperty = prop;
44+
}
45+
46+
if (hasPathProperty && loadComponentOrChildrenProperty) {
47+
break;
48+
}
49+
}
50+
51+
const initializer = loadComponentOrChildrenProperty?.initializer;
52+
if (
53+
!hasPathProperty ||
54+
!initializer ||
55+
!ts.isArrowFunction(initializer) ||
56+
!ts.isCallExpression(initializer.body) ||
57+
!ts.isPropertyAccessExpression(initializer.body.expression) ||
58+
initializer.body.expression.name.text !== 'then' ||
59+
!ts.isCallExpression(initializer.body.expression.expression) ||
60+
initializer.body.expression.expression.expression.kind !== ts.SyntaxKind.ImportKeyword
61+
) {
62+
return ts.visitEachChild(node, visitor, context);
63+
}
64+
65+
const callExpressionArgument = initializer.body.expression.expression.arguments[0];
66+
if (
67+
!ts.isStringLiteral(callExpressionArgument) &&
68+
!ts.isNoSubstitutionTemplateLiteral(callExpressionArgument)
69+
) {
70+
return ts.visitEachChild(node, visitor, context);
71+
}
72+
73+
const resolvedPath = ts.resolveModuleName(
74+
callExpressionArgument.text,
75+
node.getSourceFile().fileName,
76+
compilerOptions,
77+
compilerHost,
78+
moduleResolutionCache,
79+
)?.resolvedModule?.resolvedFileName;
80+
81+
if (!resolvedPath) {
82+
return ts.visitEachChild(node, visitor, context);
83+
}
84+
85+
const resolvedRelativePath = relative(basePath, resolvedPath);
86+
87+
// Create the new property
88+
// Exmaple: `...(typeof ngServerMode !== "undefined" && ngServerMode ? { ɵentryName: "src/home.ts" } : undefined)`
89+
const newProperty = factory.createSpreadAssignment(
90+
factory.createParenthesizedExpression(
91+
factory.createConditionalExpression(
92+
factory.createBinaryExpression(
93+
factory.createBinaryExpression(
94+
factory.createTypeOfExpression(factory.createIdentifier('ngServerMode')),
95+
factory.createToken(ts.SyntaxKind.ExclamationEqualsEqualsToken),
96+
factory.createStringLiteral('undefined'),
97+
),
98+
factory.createToken(ts.SyntaxKind.AmpersandAmpersandToken),
99+
factory.createIdentifier('ngServerMode'),
100+
),
101+
factory.createToken(ts.SyntaxKind.QuestionToken),
102+
factory.createObjectLiteralExpression(
103+
[
104+
factory.createPropertyAssignment(
105+
factory.createIdentifier('ɵentryName'),
106+
factory.createStringLiteral(resolvedRelativePath),
107+
),
108+
],
109+
false,
110+
),
111+
factory.createToken(ts.SyntaxKind.ColonToken),
112+
factory.createIdentifier('undefined'),
113+
),
114+
),
115+
);
116+
117+
return factory.updateObjectLiteralExpression(node, [...node.properties, newProperty]);
118+
};
119+
120+
return (sourceFile) => {
121+
const text = sourceFile.text;
122+
if (!text.includes('loadC')) {
123+
// Fast check for 'loadComponent' and 'loadChildren'.
124+
return sourceFile;
125+
}
126+
127+
return ts.visitEachChild(sourceFile, visitor, context);
128+
};
129+
};
130+
}

0 commit comments

Comments
 (0)