Skip to content

Commit 913f62e

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 8c534da commit 913f62e

File tree

10 files changed

+356
-7
lines changed

10 files changed

+356
-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: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@
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';
@@ -103,6 +105,9 @@ export default {
103105
* server-side rendering and routing.
104106
* @param locale - An optional string representing the locale or language code to be used for
105107
* the application, helping with localization and rendering content specific to the locale.
108+
* @param initialFiles - A list of initial files that preload tags have already been added for.
109+
* @param metafile - An esbuild metafile object.
110+
* @param publicPath - The configured public path.
106111
*
107112
* @returns An object containing:
108113
* - `manifestContent`: A string of the SSR manifest content.
@@ -114,6 +119,9 @@ export function generateAngularServerAppManifest(
114119
inlineCriticalCss: boolean,
115120
routes: readonly unknown[] | undefined,
116121
locale: string | undefined,
122+
initialFiles: Set<string>,
123+
metafile: Metafile,
124+
publicPath: string | undefined,
117125
): {
118126
manifestContent: string;
119127
serverAssetsChunks: BuildOutputFile[];
@@ -138,15 +146,89 @@ export function generateAngularServerAppManifest(
138146
}
139147
}
140148

149+
// When routes have been extracted, mappings are no longer needed, as preloads will be included in the metadata.
150+
// When shouldOptimizeChunks is enabled the metadata is no longer correct and thus we cannot generate the mappings.
151+
const serverToBrowserMappings =
152+
routes?.length || shouldOptimizeChunks
153+
? undefined
154+
: generateLazyLoadedFilesMappings(metafile, initialFiles, publicPath);
155+
141156
const manifestContent = `
142157
export default {
143158
bootstrap: () => import('./main.server.mjs').then(m => m.default),
144159
inlineCriticalCss: ${inlineCriticalCss},
160+
locale: ${JSON.stringify(locale, undefined, 2)},
161+
serverToBrowserMappings: ${JSON.stringify(serverToBrowserMappings, undefined, 2)},
145162
routes: ${JSON.stringify(routes, undefined, 2)},
146163
assets: new Map([\n${serverAssetsContent.join(', \n')}\n]),
147-
locale: ${locale !== undefined ? `'${locale}'` : undefined},
148164
};
149165
`;
150166

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

packages/angular/ssr/src/app.ts

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,7 @@ export class AngularServerApp {
251251
return Response.redirect(new URL(redirectTo, new URL(request.url)), (status as any) ?? 302);
252252
}
253253

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

291296
const {
@@ -352,6 +357,8 @@ export class AngularServerApp {
352357
}
353358
}
354359

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

@@ -413,3 +420,33 @@ export function destroyAngularServerApp(): void {
413420

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

packages/angular/ssr/src/manifest.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,17 @@ 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 related JavaScript browser bundles for preloading.
110+
*
111+
* This mapping ensures that when a server bundle is loaded, the corresponding browser bundles
112+
* are preloaded to improve performance and reduce latency.
113+
*
114+
* - **Key**: The filename of the server bundle, typically in the `.mjs` format.
115+
* - **Value**: An array of JavaScript browser bundle filenames to be preloaded.
116+
*/
117+
readonly serverToBrowserMappings?: Record<string, readonly string[]>;
107118
}
108119

109120
/**

0 commit comments

Comments
 (0)