Skip to content

Commit

Permalink
feat(@angular/ssr): add modulepreload for lazy-loaded routes
Browse files Browse the repository at this point in the history
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
  • Loading branch information
alan-agius4 committed Nov 21, 2024
1 parent 8c534da commit 95f2b19
Show file tree
Hide file tree
Showing 10 changed files with 340 additions and 6 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand All @@ -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[],
Expand Down Expand Up @@ -70,6 +73,7 @@ export async function executePostBundleSteps(
serverEntryPoint,
prerenderOptions,
appShellOptions,
publicPath,
workspaceRoot,
partialSSRBuild,
} = options;
Expand Down Expand Up @@ -107,13 +111,17 @@ export async function executePostBundleSteps(
}

// Create server manifest
const initialFilesPaths = new Set(initialFiles.keys());
if (serverEntryPoint) {
const { manifestContent, serverAssetsChunks } = generateAngularServerAppManifest(
additionalHtmlOutputFiles,
outputFiles,
optimizationOptions.styles.inlineCritical ?? false,
undefined,
locale,
initialFilesPaths,
metafile,
publicPath,
);

additionalOutputFiles.push(
Expand Down Expand Up @@ -194,6 +202,9 @@ export async function executePostBundleSteps(
optimizationOptions.styles.inlineCritical ?? false,
serializableRouteTreeNodeForManifest,
locale,
initialFilesPaths,
metafile,
publicPath,
);

for (const chunk of serverAssetsChunks) {
Expand Down
4 changes: 4 additions & 0 deletions packages/angular/build/src/builders/application/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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>,
Expand Down Expand Up @@ -80,6 +83,7 @@ export async function inlineI18n(
additionalOutputFiles,
prerenderedRoutes: generatedRoutes,
} = await executePostBundleSteps(
metafile,
{
...options,
baseHref,
Expand Down
84 changes: 83 additions & 1 deletion packages/angular/build/src/utils/server-rendering/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@
* found in the LICENSE file at https://angular.dev/license
*/

import type { Metafile } from 'esbuild';
import { extname } from 'node:path';
import {
NormalizedApplicationBuildOptions,
getLocaleBaseHref,
} 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';
Expand Down Expand Up @@ -103,6 +105,9 @@ export default {
* server-side rendering and routing.
* @param locale - An optional string representing the locale or language code to be used for
* the application, helping with localization and rendering content specific to the locale.
* @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.
Expand All @@ -114,6 +119,9 @@ export function generateAngularServerAppManifest(
inlineCriticalCss: boolean,
routes: readonly unknown[] | undefined,
locale: string | undefined,
initialFiles: Set<string>,
metafile: Metafile,
publicPath: string | undefined,
): {
manifestContent: string;
serverAssetsChunks: BuildOutputFile[];
Expand All @@ -138,15 +146,89 @@ 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 serverToBrowserMapppings =
routes?.length || shouldOptimizeChunks
? undefined
: generateLazyLoadedFilesMappings(metafile, initialFiles, publicPath);

const manifestContent = `
export default {
bootstrap: () => import('./main.server.mjs').then(m => m.default),
inlineCriticalCss: ${inlineCriticalCss},
locale: ${JSON.stringify(locale, undefined, 2)},
serverToBrowserMapppings: ${JSON.stringify(serverToBrowserMapppings, undefined, 2)},
routes: ${JSON.stringify(routes, undefined, 2)},
assets: new Map([\n${serverAssetsContent.join(', \n')}\n]),
locale: ${locale !== undefined ? `'${locale}'` : undefined},
};
`;

return { manifestContent, serverAssetsChunks };
}

/**
* Generates a mapping of lazy-loaded files from a given metafile.
*
* This function processes the outputs of a metafile to create a mapping
* between MJS files (server bundles) and their corresponding JS files
* that should be lazily loaded. It filters out files that do not have
* an entry point, do not export any modules, or are not of the
* appropriate file extensions (.js or .mjs).
*
* @param metafile - An object containing metadata about the output files,
* including entry points, exports, and imports.
* @param initialFiles - A set of initial file names that are considered
* already loaded and should be excluded from the mapping.
* @param publicPath - The configured public path.
*
* @returns A record where the keys are MJS file names (server bundles) and
* the values are arrays of corresponding JS file names (browser bundles).
*/
function generateLazyLoadedFilesMappings(
metafile: Metafile,
initialFiles: Set<string>,
publicPath = '',
): Record<string, readonly string[]> {
const entryPointToBundles = new Map<
string,
{ js: string[] | undefined; mjs: string | undefined }
>();

for (const [fileName, { entryPoint, exports, imports }] of Object.entries(metafile.outputs)) {
const extension = extname(fileName);

// Skip files that don't have an entryPoint, no exports, or are not .js or .mjs
if (!entryPoint || exports?.length < 1 || (extension !== '.js' && extension !== '.mjs')) {
continue;
}

const data = entryPointToBundles.get(entryPoint) ?? { js: undefined, mjs: undefined };
if (extension === '.js') {
const importedPaths: string[] = [`${publicPath}${fileName}`];
for (const { kind, external, path } of imports) {
if (external || kind !== 'import-statement' || initialFiles.has(path)) {
continue;
}

importedPaths.push(`${publicPath}${path}`);
}

data.js = importedPaths;
} else {
data.mjs = fileName;
}

entryPointToBundles.set(entryPoint, data);
}

const bundlesReverseLookup: Record<string, readonly string[]> = {};
// Populate resultedLookup with mjs as key and js as value
for (const { js, mjs } of entryPointToBundles.values()) {
if (mjs && js?.length) {
bundlesReverseLookup[mjs] = js;
}
}

return bundlesReverseLookup;
}
42 changes: 40 additions & 2 deletions packages/angular/ssr/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ export class AngularServerApp {
return Response.redirect(new URL(redirectTo, new URL(request.url)), (status as any) ?? 302);
}

const { renderMode, headers } = matchedRoute;
const { renderMode, headers, preload } = matchedRoute;
if (!this.allowStaticRouteRender && renderMode === RenderMode.Prerender) {
return null;
}
Expand Down Expand Up @@ -285,7 +285,12 @@ export class AngularServerApp {
);
} else if (renderMode === RenderMode.Client) {
// Serve the client-side rendered version if the route is configured for CSR.
return new Response(await this.assets.getServerAsset('index.csr.html').text(), responseInit);
const html = appendPreloadHintsToHtml(
await this.assets.getServerAsset('index.csr.html').text(),
preload,
);

return new Response(html, responseInit);
}

const {
Expand Down Expand Up @@ -352,6 +357,8 @@ export class AngularServerApp {
}
}

html = appendPreloadHintsToHtml(html, preload);

return new Response(html, responseInit);
}

Expand Down Expand Up @@ -413,3 +420,34 @@ 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.
* If `undefined` or empty, the original HTML string is returned unchanged.
* @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[] | undefined): string {
if (!preload?.length) {
return html;
}

const bodyCloseIdx = html.lastIndexOf('</body>');
if (bodyCloseIdx === -1) {
return html;
}

return [
html.slice(0, bodyCloseIdx),
'\n',
...preload.map((val) => `<link rel="modulepreload" href="${val}">\n`),
html.slice(bodyCloseIdx),
].join('');
}
11 changes: 11 additions & 0 deletions packages/angular/ssr/src/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,17 @@ export interface AngularAppManifest {
* the application, aiding with localization and rendering content specific to the locale.
*/
readonly locale?: string;

/**
* Maps server bundle filenames to the related JavaScript browser bundles for preloading.
*
* This mapping ensures that when a server bundle is loaded, the corresponding browser bundles
* are preloaded to improve performance and reduce latency.
*
* - **Key**: The filename of the server bundle, typically in the `.mjs` format.
* - **Value**: An array of JavaScript browser bundle filenames to be preloaded.
*/
readonly serverToBrowserMapppings?: Record<string, readonly string[]>;
}

/**
Expand Down
Loading

0 comments on commit 95f2b19

Please sign in to comment.