Skip to content

Commit d78d2ad

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 e4448bb commit d78d2ad

File tree

15 files changed

+705
-8
lines changed

15 files changed

+705
-8
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: 2 additions & 0 deletions
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

@@ -287,6 +288,7 @@ export class AotCompilation extends AngularCompilation {
287288
transformers.before ??= [];
288289
transformers.before.push(
289290
replaceBootstrap(() => typeScriptProgram.getProgram().getTypeChecker()),
291+
lazyRoutesTransformer(typeScriptProgram.getProgram(), compilerHost),
290292
);
291293
transformers.before.push(webWorkerTransform);
292294

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

Lines changed: 2 additions & 0 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

@@ -137,6 +138,7 @@ export class JitCompilation extends AngularCompilation {
137138
replaceResourcesTransform,
138139
constructorParametersDownlevelTransform,
139140
webWorkerTransform,
141+
lazyRoutesTransformer(typeScriptProgram.getProgram(), compilerHost),
140142
],
141143
};
142144

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
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+
program: ts.Program,
15+
compilerHost: ts.CompilerHost,
16+
): ts.TransformerFactory<ts.SourceFile> {
17+
const compilerOptions = program.getCompilerOptions();
18+
const moduleResolutionCache = compilerHost.getModuleResolutionCache?.();
19+
assert(
20+
typeof compilerOptions.basePath === 'string',
21+
'compilerOptions.basePath should be a string.',
22+
);
23+
const basePath = compilerOptions.basePath;
24+
25+
return (context: ts.TransformationContext) => {
26+
const factory = context.factory;
27+
28+
const visitor = (node: ts.Node): ts.Node => {
29+
if (!ts.isObjectLiteralExpression(node)) {
30+
return ts.visitEachChild(node, visitor, context);
31+
}
32+
33+
let hasPathProperty = false;
34+
let loadComponentOrChildrenProperty: ts.PropertyAssignment | undefined;
35+
for (const prop of node.properties) {
36+
if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name)) {
37+
continue;
38+
}
39+
40+
const propertyNameText = prop.name.text;
41+
if (propertyNameText === 'path') {
42+
hasPathProperty = true;
43+
} else if (propertyNameText === 'loadComponent' || propertyNameText === 'loadChildren') {
44+
loadComponentOrChildrenProperty = prop;
45+
}
46+
47+
if (hasPathProperty && loadComponentOrChildrenProperty) {
48+
break;
49+
}
50+
}
51+
52+
const initializer = loadComponentOrChildrenProperty?.initializer;
53+
if (
54+
!hasPathProperty ||
55+
!initializer ||
56+
!ts.isArrowFunction(initializer) ||
57+
!ts.isCallExpression(initializer.body) ||
58+
!ts.isPropertyAccessExpression(initializer.body.expression) ||
59+
initializer.body.expression.name.text !== 'then' ||
60+
!ts.isCallExpression(initializer.body.expression.expression) ||
61+
initializer.body.expression.expression.expression.kind !== ts.SyntaxKind.ImportKeyword
62+
) {
63+
return ts.visitEachChild(node, visitor, context);
64+
}
65+
66+
const callExpressionArgument = initializer.body.expression.expression.arguments[0];
67+
if (
68+
!ts.isStringLiteral(callExpressionArgument) &&
69+
!ts.isNoSubstitutionTemplateLiteral(callExpressionArgument)
70+
) {
71+
return ts.visitEachChild(node, visitor, context);
72+
}
73+
74+
const resolvedPath = ts.resolveModuleName(
75+
callExpressionArgument.text,
76+
node.getSourceFile().fileName,
77+
compilerOptions,
78+
compilerHost,
79+
moduleResolutionCache,
80+
)?.resolvedModule?.resolvedFileName;
81+
82+
if (!resolvedPath) {
83+
return ts.visitEachChild(node, visitor, context);
84+
}
85+
86+
const resolvedRelativePath = relative(basePath, resolvedPath);
87+
88+
// Create the new property
89+
// Exmaple: `...(typeof ngServerMode !== "undefined" && ngServerMode ? { ɵentryName: "src/home.ts" } : undefined)`
90+
const newProperty = factory.createSpreadAssignment(
91+
factory.createParenthesizedExpression(
92+
factory.createConditionalExpression(
93+
factory.createBinaryExpression(
94+
factory.createBinaryExpression(
95+
factory.createTypeOfExpression(factory.createIdentifier('ngServerMode')),
96+
factory.createToken(ts.SyntaxKind.ExclamationEqualsEqualsToken),
97+
factory.createStringLiteral('undefined'),
98+
),
99+
factory.createToken(ts.SyntaxKind.AmpersandAmpersandToken),
100+
factory.createIdentifier('ngServerMode'),
101+
),
102+
factory.createToken(ts.SyntaxKind.QuestionToken),
103+
factory.createObjectLiteralExpression(
104+
[
105+
factory.createPropertyAssignment(
106+
factory.createIdentifier('ɵentryName'),
107+
factory.createStringLiteral(resolvedRelativePath),
108+
),
109+
],
110+
false,
111+
),
112+
factory.createToken(ts.SyntaxKind.ColonToken),
113+
factory.createIdentifier('undefined'),
114+
),
115+
),
116+
);
117+
118+
return factory.updateObjectLiteralExpression(node, [...node.properties, newProperty]);
119+
};
120+
121+
return (sourceFile) => {
122+
const text = sourceFile.text;
123+
if (!text.includes('loadComponent') && !text.includes('loadChildren')) {
124+
// Fast check
125+
return sourceFile;
126+
}
127+
128+
return ts.visitEachChild(sourceFile, visitor, context);
129+
};
130+
};
131+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
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 ts from 'typescript';
10+
import { lazyRoutesTransformer } from './lazy-routes-transformer';
11+
12+
describe('lazyRoutesTransformer', () => {
13+
let program: ts.Program;
14+
let compilerHost: ts.CompilerHost;
15+
16+
beforeEach(() => {
17+
// Mock a basic TypeScript program and compilerHost
18+
program = ts.createProgram(['/project/src/dummy.ts'], { basePath: '/project/' });
19+
compilerHost = {
20+
getNewLine: () => '\n',
21+
fileExists: () => true,
22+
readFile: () => '',
23+
writeFile: () => undefined,
24+
getCanonicalFileName: (fileName: string) => fileName,
25+
getCurrentDirectory: () => '/project',
26+
getDefaultLibFileName: () => 'lib.d.ts',
27+
getSourceFile: () => undefined,
28+
useCaseSensitiveFileNames: () => true,
29+
resolveModuleNames: (moduleNames, containingFile) =>
30+
moduleNames.map(
31+
(name) =>
32+
({
33+
resolvedFileName: `/project/src/${name}.ts`,
34+
}) as ts.ResolvedModule,
35+
),
36+
};
37+
});
38+
39+
const transformSourceFile = (sourceCode: string): ts.SourceFile => {
40+
const sourceFile = ts.createSourceFile(
41+
'/project/src/dummy.ts',
42+
sourceCode,
43+
ts.ScriptTarget.ESNext,
44+
true,
45+
ts.ScriptKind.TS,
46+
);
47+
48+
const transformer = lazyRoutesTransformer(program, compilerHost);
49+
const result = ts.transform(sourceFile, [transformer]);
50+
51+
return result.transformed[0];
52+
};
53+
54+
it('should add ɵentryName property to object with loadComponent and path', () => {
55+
const source = `
56+
const routes = [
57+
{
58+
path: 'home',
59+
loadComponent: () => import('./home').then(m => m.HomeComponent)
60+
}
61+
];
62+
`;
63+
64+
const transformedSourceFile = transformSourceFile(source);
65+
const transformedCode = ts.createPrinter().printFile(transformedSourceFile);
66+
67+
expect(transformedCode).toContain(
68+
`...(typeof ngServerMode !== "undefined" && ngServerMode ? { ɵentryName: "src/home.ts" } : undefined)`,
69+
);
70+
});
71+
72+
it('should not modify unrelated object literals', () => {
73+
const source = `
74+
const routes = [
75+
{
76+
path: 'home',
77+
component: HomeComponent
78+
}
79+
];
80+
`;
81+
82+
const transformedSourceFile = transformSourceFile(source);
83+
const transformedCode = ts.createPrinter().printFile(transformedSourceFile);
84+
85+
expect(transformedCode).not.toContain(`ɵentryName`);
86+
});
87+
88+
it('should ignore loadComponent without a valid import call', () => {
89+
const source = `
90+
const routes = [
91+
{
92+
path: 'home',
93+
loadComponent: () => someFunction()
94+
}
95+
];
96+
`;
97+
98+
const transformedSourceFile = transformSourceFile(source);
99+
const transformedCode = ts.createPrinter().printFile(transformedSourceFile);
100+
101+
expect(transformedCode).not.toContain(`ɵentryName`);
102+
});
103+
104+
it('should resolve paths relative to basePath', () => {
105+
const source = `
106+
const routes = [
107+
{
108+
path: 'about',
109+
loadChildren: () => import('./features/about').then(m => m.AboutModule)
110+
}
111+
];
112+
`;
113+
114+
const transformedSourceFile = transformSourceFile(source);
115+
const transformedCode = ts.createPrinter().printFile(transformedSourceFile);
116+
117+
expect(transformedCode).toContain(
118+
`...(typeof ngServerMode !== "undefined" && ngServerMode ? { ɵentryName: "src/features/about.ts" } : undefined)`,
119+
);
120+
});
121+
});

0 commit comments

Comments
 (0)