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 angular#26484
  • Loading branch information
alan-agius4 committed Dec 2, 2024
1 parent e4448bb commit d78d2ad
Show file tree
Hide file tree
Showing 15 changed files with 705 additions and 8 deletions.
1 change: 1 addition & 0 deletions packages/angular/build/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ ts_library(
"@npm//@angular/compiler-cli",
"@npm//@babel/core",
"@npm//prettier",
"@npm//typescript",
],
)

Expand Down
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 @@ -71,6 +74,7 @@ export async function executePostBundleSteps(
serverEntryPoint,
prerenderOptions,
appShellOptions,
publicPath,
workspaceRoot,
partialSSRBuild,
} = options;
Expand Down Expand Up @@ -108,6 +112,7 @@ export async function executePostBundleSteps(
}

// Create server manifest
const initialFilesPaths = new Set(initialFiles.keys());
if (serverEntryPoint) {
const { manifestContent, serverAssetsChunks } = generateAngularServerAppManifest(
additionalHtmlOutputFiles,
Expand All @@ -116,6 +121,9 @@ export async function executePostBundleSteps(
undefined,
locale,
baseHref,
initialFilesPaths,
metafile,
publicPath,
);

additionalOutputFiles.push(
Expand Down Expand Up @@ -197,6 +205,9 @@ export async function executePostBundleSteps(
serializableRouteTreeNodeForManifest,
locale,
baseHref,
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
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
ensureSourceFileVersions,
} from '../angular-host';
import { replaceBootstrap } from '../transformers/jit-bootstrap-transformer';
import { lazyRoutesTransformer } from '../transformers/lazy-routes-transformer';
import { createWorkerTransformer } from '../transformers/web-worker-transformer';
import { AngularCompilation, DiagnosticModes, EmitFileResult } from './angular-compilation';

Expand Down Expand Up @@ -287,6 +288,7 @@ export class AotCompilation extends AngularCompilation {
transformers.before ??= [];
transformers.before.push(
replaceBootstrap(() => typeScriptProgram.getProgram().getTypeChecker()),
lazyRoutesTransformer(typeScriptProgram.getProgram(), compilerHost),
);
transformers.before.push(webWorkerTransform);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { loadEsmModule } from '../../../utils/load-esm';
import { profileSync } from '../../esbuild/profiling';
import { AngularHostOptions, createAngularCompilerHost } from '../angular-host';
import { createJitResourceTransformer } from '../transformers/jit-resource-transformer';
import { lazyRoutesTransformer } from '../transformers/lazy-routes-transformer';
import { createWorkerTransformer } from '../transformers/web-worker-transformer';
import { AngularCompilation, DiagnosticModes, EmitFileResult } from './angular-compilation';

Expand Down Expand Up @@ -137,6 +138,7 @@ export class JitCompilation extends AngularCompilation {
replaceResourcesTransform,
constructorParametersDownlevelTransform,
webWorkerTransform,
lazyRoutesTransformer(typeScriptProgram.getProgram(), compilerHost),
],
};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

import assert from 'node:assert';
import { relative } from 'node:path/posix';
import ts from 'typescript';

export function lazyRoutesTransformer(
program: ts.Program,
compilerHost: ts.CompilerHost,
): ts.TransformerFactory<ts.SourceFile> {
const compilerOptions = program.getCompilerOptions();
const moduleResolutionCache = compilerHost.getModuleResolutionCache?.();
assert(
typeof compilerOptions.basePath === 'string',
'compilerOptions.basePath should be a string.',
);
const basePath = compilerOptions.basePath;

return (context: ts.TransformationContext) => {
const factory = context.factory;

const visitor = (node: ts.Node): ts.Node => {
if (!ts.isObjectLiteralExpression(node)) {
return ts.visitEachChild(node, visitor, context);
}

let hasPathProperty = false;
let loadComponentOrChildrenProperty: ts.PropertyAssignment | undefined;
for (const prop of node.properties) {
if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name)) {
continue;
}

const propertyNameText = prop.name.text;
if (propertyNameText === 'path') {
hasPathProperty = true;
} else if (propertyNameText === 'loadComponent' || propertyNameText === 'loadChildren') {
loadComponentOrChildrenProperty = prop;
}

if (hasPathProperty && loadComponentOrChildrenProperty) {
break;
}
}

const initializer = loadComponentOrChildrenProperty?.initializer;
if (
!hasPathProperty ||
!initializer ||
!ts.isArrowFunction(initializer) ||
!ts.isCallExpression(initializer.body) ||
!ts.isPropertyAccessExpression(initializer.body.expression) ||
initializer.body.expression.name.text !== 'then' ||
!ts.isCallExpression(initializer.body.expression.expression) ||
initializer.body.expression.expression.expression.kind !== ts.SyntaxKind.ImportKeyword
) {
return ts.visitEachChild(node, visitor, context);
}

const callExpressionArgument = initializer.body.expression.expression.arguments[0];
if (
!ts.isStringLiteral(callExpressionArgument) &&
!ts.isNoSubstitutionTemplateLiteral(callExpressionArgument)
) {
return ts.visitEachChild(node, visitor, context);
}

const resolvedPath = ts.resolveModuleName(
callExpressionArgument.text,
node.getSourceFile().fileName,
compilerOptions,
compilerHost,
moduleResolutionCache,
)?.resolvedModule?.resolvedFileName;

if (!resolvedPath) {
return ts.visitEachChild(node, visitor, context);
}

const resolvedRelativePath = relative(basePath, resolvedPath);

// Create the new property
// Exmaple: `...(typeof ngServerMode !== "undefined" && ngServerMode ? { ɵentryName: "src/home.ts" } : undefined)`
const newProperty = factory.createSpreadAssignment(
factory.createParenthesizedExpression(
factory.createConditionalExpression(
factory.createBinaryExpression(
factory.createBinaryExpression(
factory.createTypeOfExpression(factory.createIdentifier('ngServerMode')),
factory.createToken(ts.SyntaxKind.ExclamationEqualsEqualsToken),
factory.createStringLiteral('undefined'),
),
factory.createToken(ts.SyntaxKind.AmpersandAmpersandToken),
factory.createIdentifier('ngServerMode'),
),
factory.createToken(ts.SyntaxKind.QuestionToken),
factory.createObjectLiteralExpression(
[
factory.createPropertyAssignment(
factory.createIdentifier('ɵentryName'),
factory.createStringLiteral(resolvedRelativePath),
),
],
false,
),
factory.createToken(ts.SyntaxKind.ColonToken),
factory.createIdentifier('undefined'),
),
),
);

return factory.updateObjectLiteralExpression(node, [...node.properties, newProperty]);
};

return (sourceFile) => {
const text = sourceFile.text;
if (!text.includes('loadComponent') && !text.includes('loadChildren')) {
// Fast check
return sourceFile;
}

return ts.visitEachChild(sourceFile, visitor, context);
};
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

import ts from 'typescript';
import { lazyRoutesTransformer } from './lazy-routes-transformer';

describe('lazyRoutesTransformer', () => {
let program: ts.Program;
let compilerHost: ts.CompilerHost;

beforeEach(() => {
// Mock a basic TypeScript program and compilerHost
program = ts.createProgram(['/project/src/dummy.ts'], { basePath: '/project/' });
compilerHost = {
getNewLine: () => '\n',
fileExists: () => true,
readFile: () => '',
writeFile: () => undefined,
getCanonicalFileName: (fileName: string) => fileName,
getCurrentDirectory: () => '/project',
getDefaultLibFileName: () => 'lib.d.ts',
getSourceFile: () => undefined,
useCaseSensitiveFileNames: () => true,
resolveModuleNames: (moduleNames, containingFile) =>
moduleNames.map(
(name) =>
({
resolvedFileName: `/project/src/${name}.ts`,
}) as ts.ResolvedModule,
),
};
});

const transformSourceFile = (sourceCode: string): ts.SourceFile => {
const sourceFile = ts.createSourceFile(
'/project/src/dummy.ts',
sourceCode,
ts.ScriptTarget.ESNext,
true,
ts.ScriptKind.TS,
);

const transformer = lazyRoutesTransformer(program, compilerHost);
const result = ts.transform(sourceFile, [transformer]);

return result.transformed[0];
};

it('should add ɵentryName property to object with loadComponent and path', () => {
const source = `
const routes = [
{
path: 'home',
loadComponent: () => import('./home').then(m => m.HomeComponent)
}
];
`;

const transformedSourceFile = transformSourceFile(source);
const transformedCode = ts.createPrinter().printFile(transformedSourceFile);

expect(transformedCode).toContain(
`...(typeof ngServerMode !== "undefined" && ngServerMode ? { ɵentryName: "src/home.ts" } : undefined)`,
);
});

it('should not modify unrelated object literals', () => {
const source = `
const routes = [
{
path: 'home',
component: HomeComponent
}
];
`;

const transformedSourceFile = transformSourceFile(source);
const transformedCode = ts.createPrinter().printFile(transformedSourceFile);

expect(transformedCode).not.toContain(`ɵentryName`);
});

it('should ignore loadComponent without a valid import call', () => {
const source = `
const routes = [
{
path: 'home',
loadComponent: () => someFunction()
}
];
`;

const transformedSourceFile = transformSourceFile(source);
const transformedCode = ts.createPrinter().printFile(transformedSourceFile);

expect(transformedCode).not.toContain(`ɵentryName`);
});

it('should resolve paths relative to basePath', () => {
const source = `
const routes = [
{
path: 'about',
loadChildren: () => import('./features/about').then(m => m.AboutModule)
}
];
`;

const transformedSourceFile = transformSourceFile(source);
const transformedCode = ts.createPrinter().printFile(transformedSourceFile);

expect(transformedCode).toContain(
`...(typeof ngServerMode !== "undefined" && ngServerMode ? { ɵentryName: "src/features/about.ts" } : undefined)`,
);
});
});
Loading

0 comments on commit d78d2ad

Please sign in to comment.