Skip to content

Commit 18d0d35

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 7554cc1 commit 18d0d35

File tree

10 files changed

+496
-7
lines changed

10 files changed

+496
-7
lines changed

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[],
@@ -70,6 +73,7 @@ export async function executePostBundleSteps(
7073
serverEntryPoint,
7174
prerenderOptions,
7275
appShellOptions,
76+
publicPath,
7377
workspaceRoot,
7478
partialSSRBuild,
7579
} = options;
@@ -107,13 +111,17 @@ export async function executePostBundleSteps(
107111
}
108112

109113
// Create server manifest
114+
const initialFilesPaths = new Set(initialFiles.keys());
110115
if (serverEntryPoint) {
111116
const { manifestContent, serverAssetsChunks } = generateAngularServerAppManifest(
112117
additionalHtmlOutputFiles,
113118
outputFiles,
114119
optimizationOptions.styles.inlineCritical ?? false,
115120
undefined,
116121
locale,
122+
initialFilesPaths,
123+
metafile,
124+
publicPath,
117125
);
118126

119127
additionalOutputFiles.push(
@@ -194,6 +202,9 @@ export async function executePostBundleSteps(
194202
optimizationOptions.styles.inlineCritical ?? false,
195203
serializableRouteTreeNodeForManifest,
196204
locale,
205+
initialFilesPaths,
206+
metafile,
207+
publicPath,
197208
);
198209

199210
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/utils/server-rendering/manifest.ts

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,24 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9+
import type { Metafile } from 'esbuild';
910
import { extname } from 'node:path';
1011
import {
1112
NormalizedApplicationBuildOptions,
1213
getLocaleBaseHref,
1314
} from '../../builders/application/options';
1415
import { type BuildOutputFile, BuildOutputFileType } from '../../tools/esbuild/bundler-context';
1516
import { createOutputFile } from '../../tools/esbuild/utils';
17+
import { shouldOptimizeChunks } from '../environment-options';
1618

1719
export const SERVER_APP_MANIFEST_FILENAME = 'angular-app-manifest.mjs';
1820
export const SERVER_APP_ENGINE_MANIFEST_FILENAME = 'angular-app-engine-manifest.mjs';
1921

22+
interface FilesMapping {
23+
path: string;
24+
dynamicImport: boolean;
25+
}
26+
2027
const MAIN_SERVER_OUTPUT_FILENAME = 'main.server.mjs';
2128

2229
/**
@@ -103,6 +110,9 @@ export default {
103110
* server-side rendering and routing.
104111
* @param locale - An optional string representing the locale or language code to be used for
105112
* the application, helping with localization and rendering content specific to the locale.
113+
* @param initialFiles - A list of initial files that preload tags have already been added for.
114+
* @param metafile - An esbuild metafile object.
115+
* @param publicPath - The configured public path.
106116
*
107117
* @returns An object containing:
108118
* - `manifestContent`: A string of the SSR manifest content.
@@ -114,6 +124,9 @@ export function generateAngularServerAppManifest(
114124
inlineCriticalCss: boolean,
115125
routes: readonly unknown[] | undefined,
116126
locale: string | undefined,
127+
initialFiles: Set<string>,
128+
metafile: Metafile,
129+
publicPath: string | undefined,
117130
): {
118131
manifestContent: string;
119132
serverAssetsChunks: BuildOutputFile[];
@@ -138,15 +151,102 @@ export function generateAngularServerAppManifest(
138151
}
139152
}
140153

154+
// When routes have been extracted, mappings are no longer needed, as preloads will be included in the metadata.
155+
// When shouldOptimizeChunks is enabled the metadata is no longer correct and thus we cannot generate the mappings.
156+
const serverToBrowserMappings =
157+
routes?.length || shouldOptimizeChunks
158+
? undefined
159+
: generateLazyLoadedFilesMappings(metafile, initialFiles, publicPath);
160+
141161
const manifestContent = `
142162
export default {
143163
bootstrap: () => import('./main.server.mjs').then(m => m.default),
144164
inlineCriticalCss: ${inlineCriticalCss},
165+
locale: ${JSON.stringify(locale, undefined, 2)},
166+
serverToBrowserMappings: ${JSON.stringify(serverToBrowserMappings, undefined, 2)},
145167
routes: ${JSON.stringify(routes, undefined, 2)},
146168
assets: new Map([\n${serverAssetsContent.join(', \n')}\n]),
147-
locale: ${locale !== undefined ? `'${locale}'` : undefined},
148169
};
149170
`;
150171

151172
return { manifestContent, serverAssetsChunks };
152173
}
174+
175+
/**
176+
* Generates a mapping of lazy-loaded files from a given metafile.
177+
*
178+
* This function processes the outputs of a metafile to create a mapping
179+
* between MJS files (server bundles) and their corresponding JS files
180+
* that should be lazily loaded. It filters out files that do not have
181+
* an entry point, do not export any modules, or are not of the
182+
* appropriate file extensions (.js or .mjs).
183+
*
184+
* @param metafile - An object containing metadata about the output files,
185+
* including entry points, exports, and imports.
186+
* @param initialFiles - A set of initial file names that are considered
187+
* already loaded and should be excluded from the mapping.
188+
* @param publicPath - The configured public path.
189+
*
190+
* @returns A record where the keys are MJS file names (server bundles) and
191+
* the values are arrays of corresponding JS file names (browser bundles).
192+
*/
193+
function generateLazyLoadedFilesMappings(
194+
metafile: Metafile,
195+
initialFiles: Set<string>,
196+
publicPath = '',
197+
): Record<string, FilesMapping[]> {
198+
const entryPointToBundles = new Map<
199+
string,
200+
{ js: FilesMapping[] | undefined; mjs: string | undefined }
201+
>();
202+
203+
for (const [fileName, { entryPoint, exports, imports }] of Object.entries(metafile.outputs)) {
204+
const extension = extname(fileName);
205+
206+
// Skip files that don't have an entryPoint, no exports, or are not .js or .mjs
207+
if (!entryPoint || exports?.length < 1 || (extension !== '.js' && extension !== '.mjs')) {
208+
continue;
209+
}
210+
211+
const data = entryPointToBundles.get(entryPoint) ?? { js: undefined, mjs: undefined };
212+
if (extension === '.js') {
213+
const importedPaths: FilesMapping[] = [
214+
{
215+
path: `${publicPath}${fileName}`,
216+
dynamicImport: false,
217+
},
218+
];
219+
220+
for (const { kind, external, path } of imports) {
221+
if (
222+
external ||
223+
initialFiles.has(path) ||
224+
(kind !== 'dynamic-import' && kind !== 'import-statement')
225+
) {
226+
continue;
227+
}
228+
229+
importedPaths.push({
230+
path: `${publicPath}${path}`,
231+
dynamicImport: kind === 'dynamic-import',
232+
});
233+
}
234+
235+
data.js = importedPaths;
236+
} else {
237+
data.mjs = fileName;
238+
}
239+
240+
entryPointToBundles.set(entryPoint, data);
241+
}
242+
243+
const bundlesReverseLookup: Record<string, FilesMapping[]> = {};
244+
// Populate resultedLookup with mjs as key and js as value
245+
for (const { js, mjs } of entryPointToBundles.values()) {
246+
if (mjs && js?.length) {
247+
bundlesReverseLookup[mjs] = js;
248+
}
249+
}
250+
251+
return bundlesReverseLookup;
252+
}

packages/angular/ssr/src/app.ts

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,7 @@ export class AngularServerApp {
249249
matchedRoute: RouteTreeNodeMetadata,
250250
requestContext?: unknown,
251251
): Promise<Response | null> {
252-
const { renderMode, headers, status } = matchedRoute;
252+
const { renderMode, headers, status, preload } = matchedRoute;
253253

254254
if (!this.allowStaticRouteRender && renderMode === RenderMode.Prerender) {
255255
return null;
@@ -284,7 +284,12 @@ export class AngularServerApp {
284284
);
285285
} else if (renderMode === RenderMode.Client) {
286286
// Serve the client-side rendered version if the route is configured for CSR.
287-
return new Response(await this.assets.getServerAsset('index.csr.html').text(), responseInit);
287+
const html = appendPreloadHintsToHtml(
288+
await this.assets.getServerAsset('index.csr.html').text(),
289+
preload,
290+
);
291+
292+
return new Response(html, responseInit);
288293
}
289294

290295
const {
@@ -351,6 +356,8 @@ export class AngularServerApp {
351356
}
352357
}
353358

359+
html = appendPreloadHintsToHtml(html, preload);
360+
354361
return new Response(html, responseInit);
355362
}
356363

@@ -412,3 +419,36 @@ export function destroyAngularServerApp(): void {
412419

413420
angularServerApp = undefined;
414421
}
422+
423+
/**
424+
* Appends module preload hints to an HTML string for specified JavaScript resources.
425+
*appendPreloadToMetadata
426+
* This function enhances the HTML by injecting `<link rel="modulepreload">` elements
427+
* for each provided resource, allowing browsers to preload the specified JavaScript
428+
* modules for better performance.
429+
*
430+
* @param html - The original HTML string to which preload hints will be added.
431+
* @param preload - An array of URLs representing the JavaScript resources to preload.
432+
* If `undefined` or empty, the original HTML string is returned unchanged.
433+
* @returns The modified HTML string with the preload hints injected before the closing `</body>` tag.
434+
* If `</body>` is not found, the links are not added.
435+
*/
436+
function appendPreloadHintsToHtml(html: string, preload: readonly string[] | undefined): string {
437+
if (!preload?.length) {
438+
return html;
439+
}
440+
441+
const bodyCloseIdx = html.lastIndexOf('</body>');
442+
if (bodyCloseIdx === -1) {
443+
return html;
444+
}
445+
446+
// Note: Module preloads should be placed at the end before the closing body tag to avoid a performance penalty.
447+
// Placing them earlier can cause the browser to prioritize downloading these modules
448+
// over other critical page resources like images, CSS, and fonts.
449+
return [
450+
html.slice(0, bodyCloseIdx),
451+
...preload.map((val) => `<link rel="modulepreload" href="${val}">`),
452+
html.slice(bodyCloseIdx),
453+
].join('\n');
454+
}

packages/angular/ssr/src/manifest.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,34 @@ export interface AngularAppManifest {
104104
* the application, aiding with localization and rendering content specific to the locale.
105105
*/
106106
readonly locale?: string;
107+
108+
/**
109+
* Maps server bundle filenames to the corresponding JavaScript browser bundles for preloading.
110+
*
111+
* This mapping ensures that when a server bundle is loaded, the related browser bundles are preloaded.
112+
* This helps to improve performance and reduce latency by ensuring that necessary resources are available
113+
* when needed in the browser.
114+
*
115+
* - **Key**: The filename of the server bundle, typically in `.mjs` format. This represents the server-side
116+
* bundle that will be loaded.
117+
* - **Value**: An array of objects where each object contains:
118+
* - `path`: The filename or URL of the related JavaScript browser bundle to be preloaded.
119+
* - `dynamicImport`: A boolean indicating whether the browser bundle is loaded via dynamic `import()`.
120+
* If `true`, the bundle is lazily loaded using a dynamic import, which may affect how it should be preloaded.
121+
*
122+
* Example:
123+
* ```ts
124+
* {
125+
* 'server-bundle.mjs': [{ path: 'browser-bundle.js', dynamicImport: true }]
126+
* }
127+
* ```
128+
* In this example, when the server bundle `server-bundle.mjs` is loaded, the browser bundle `browser-bundle.js`
129+
* will be preloaded, and it will be dynamically loaded in the browser.
130+
*/
131+
readonly serverToBrowserMappings?: Record<
132+
string,
133+
ReadonlyArray<{ path: string; dynamicImport: boolean }>
134+
>;
107135
}
108136

109137
/**

0 commit comments

Comments
 (0)