Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(@angular/ssr): add modulepreload for lazy-loaded routes #28919

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
102 changes: 101 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,17 +6,24 @@
* 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';

interface FilesMapping {
path: string;
dynamicImport: boolean;
}

const MAIN_SERVER_OUTPUT_FILENAME = 'main.server.mjs';

/**
Expand Down Expand Up @@ -103,6 +110,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 +124,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 +151,102 @@ 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 serverToBrowserMappings =
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)},
serverToBrowserMappings: ${JSON.stringify(serverToBrowserMappings, 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, FilesMapping[]> {
const entryPointToBundles = new Map<
string,
{ js: FilesMapping[] | 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') {
alan-agius4 marked this conversation as resolved.
Show resolved Hide resolved
const importedPaths: FilesMapping[] = [
{
path: `${publicPath}${fileName}`,
dynamicImport: false,
},
];

for (const { kind, external, path } of imports) {
alan-agius4 marked this conversation as resolved.
Show resolved Hide resolved
if (
external ||
initialFiles.has(path) ||
(kind !== 'dynamic-import' && kind !== 'import-statement')
) {
continue;
}

importedPaths.push({
path: `${publicPath}${path}`,
dynamicImport: kind === 'dynamic-import',
});
}

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

entryPointToBundles.set(entryPoint, data);
}

const bundlesReverseLookup: Record<string, FilesMapping[]> = {};
// 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;
}
44 changes: 42 additions & 2 deletions packages/angular/ssr/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ export class AngularServerApp {
matchedRoute: RouteTreeNodeMetadata,
requestContext?: unknown,
): Promise<Response | null> {
const { renderMode, headers, status } = matchedRoute;
const { renderMode, headers, status, preload } = matchedRoute;

if (!this.allowStaticRouteRender && renderMode === RenderMode.Prerender) {
return null;
Expand Down Expand Up @@ -284,7 +284,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 @@ -351,6 +356,8 @@ export class AngularServerApp {
}
}

html = appendPreloadHintsToHtml(html, preload);

return new Response(html, responseInit);
}

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

angularServerApp = undefined;
}

/**
* Appends module preload hints to an HTML string for specified JavaScript resources.
*appendPreloadToMetadata
* 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 {
alan-agius4 marked this conversation as resolved.
Show resolved Hide resolved
if (!preload?.length) {
return html;
}

const bodyCloseIdx = html.lastIndexOf('</body>');
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider: Users can do pretty weird things in the HTML like:

<!DOCTYPE html>
<html>
  <body>
    <!-- ... -->
  </body>
  <script>
    document.documentElement.outerHTML.replace('</body>', '</body><script src="..."></script>');
  </script>
</html>

Technically the last </body> would be in the JS, not the HTML.

It's a pretty extreme scenario, so I don't know that we really need to be too concerned about it. The "correct" solution is likely using a real HTML parser, but that might be worth it for this kind of edge case.

I mainly just wanted to point out this potential issue, up to you if it's worth solving. If we do choose to leave this, I would recommend at least having a comment which calls out that behavior.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is quite an extreme edge case, that I feel that we should not worry about right now.

if (bodyCloseIdx === -1) {
return html;
}

// Note: Module preloads should be placed at the end before the closing body tag to avoid a performance penalty.
// Placing them earlier can cause the browser to prioritize downloading these modules
// over other critical page resources like images, CSS, and fonts.
return [
html.slice(0, bodyCloseIdx),
...preload.map((val) => `<link rel="modulepreload" href="${val}">`),
alan-agius4 marked this conversation as resolved.
Show resolved Hide resolved
html.slice(bodyCloseIdx),
alan-agius4 marked this conversation as resolved.
Show resolved Hide resolved
].join('\n');
}
28 changes: 28 additions & 0 deletions packages/angular/ssr/src/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,34 @@ export interface AngularAppManifest {
* the application, aiding with localization and rendering content specific to the locale.
*/
readonly locale?: string;

/**
* Maps server bundle filenames to the corresponding JavaScript browser bundles for preloading.
*
* This mapping ensures that when a server bundle is loaded, the related browser bundles are preloaded.
* This helps to improve performance and reduce latency by ensuring that necessary resources are available
* when needed in the browser.
*
* - **Key**: The filename of the server bundle, typically in `.mjs` format. This represents the server-side
* bundle that will be loaded.
* - **Value**: An array of objects where each object contains:
* - `path`: The filename or URL of the related JavaScript browser bundle to be preloaded.
* - `dynamicImport`: A boolean indicating whether the browser bundle is loaded via dynamic `import()`.
* If `true`, the bundle is lazily loaded using a dynamic import, which may affect how it should be preloaded.
*
* Example:
* ```ts
* {
* 'server-bundle.mjs': [{ path: 'browser-bundle.js', dynamicImport: true }]
* }
* ```
* In this example, when the server bundle `server-bundle.mjs` is loaded, the browser bundle `browser-bundle.js`
* will be preloaded, and it will be dynamically loaded in the browser.
*/
readonly serverToBrowserMappings?: Record<
string,
ReadonlyArray<{ path: string; dynamicImport: boolean }>
>;
}

/**
Expand Down
Loading