mirror of
https://github.com/angular/angular-cli.git
synced 2025-05-16 02:24:10 +08:00
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
This commit is contained in:
parent
faa710e32d
commit
8d7a51dfc9
@ -127,6 +127,7 @@ ts_library(
|
||||
"@npm//@angular/compiler-cli",
|
||||
"@npm//@babel/core",
|
||||
"@npm//prettier",
|
||||
"@npm//typescript",
|
||||
],
|
||||
)
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
@ -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.
|
||||
@ -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[],
|
||||
@ -71,6 +74,7 @@ export async function executePostBundleSteps(
|
||||
serverEntryPoint,
|
||||
prerenderOptions,
|
||||
appShellOptions,
|
||||
publicPath,
|
||||
workspaceRoot,
|
||||
partialSSRBuild,
|
||||
} = options;
|
||||
@ -108,6 +112,7 @@ export async function executePostBundleSteps(
|
||||
}
|
||||
|
||||
// Create server manifest
|
||||
const initialFilesPaths = new Set(initialFiles.keys());
|
||||
if (serverEntryPoint) {
|
||||
const { manifestContent, serverAssetsChunks } = generateAngularServerAppManifest(
|
||||
additionalHtmlOutputFiles,
|
||||
@ -116,6 +121,9 @@ export async function executePostBundleSteps(
|
||||
undefined,
|
||||
locale,
|
||||
baseHref,
|
||||
initialFilesPaths,
|
||||
metafile,
|
||||
publicPath,
|
||||
);
|
||||
|
||||
additionalOutputFiles.push(
|
||||
@ -197,6 +205,9 @@ export async function executePostBundleSteps(
|
||||
serializableRouteTreeNodeForManifest,
|
||||
locale,
|
||||
baseHref,
|
||||
initialFilesPaths,
|
||||
metafile,
|
||||
publicPath,
|
||||
);
|
||||
|
||||
for (const chunk of serverAssetsChunks) {
|
||||
|
@ -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 {
|
||||
@ -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>,
|
||||
@ -79,6 +82,7 @@ export async function inlineI18n(
|
||||
additionalOutputFiles,
|
||||
prerenderedRoutes: generatedRoutes,
|
||||
} = await executePostBundleSteps(
|
||||
metafile,
|
||||
{
|
||||
...options,
|
||||
baseHref: getLocaleBaseHref(baseHref, i18nOptions, locale) ?? baseHref,
|
||||
|
@ -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';
|
||||
import { collectHmrCandidates } from './hmr-candidates';
|
||||
@ -47,6 +48,10 @@ class AngularCompilationState {
|
||||
export class AotCompilation extends AngularCompilation {
|
||||
#state?: AngularCompilationState;
|
||||
|
||||
constructor(private readonly browserOnlyBuild: boolean) {
|
||||
super();
|
||||
}
|
||||
|
||||
async initialize(
|
||||
tsconfig: string,
|
||||
hostOptions: AngularHostOptions,
|
||||
@ -314,8 +319,12 @@ export class AotCompilation extends AngularCompilation {
|
||||
transformers.before ??= [];
|
||||
transformers.before.push(
|
||||
replaceBootstrap(() => typeScriptProgram.getProgram().getTypeChecker()),
|
||||
webWorkerTransform,
|
||||
);
|
||||
transformers.before.push(webWorkerTransform);
|
||||
|
||||
if (!this.browserOnlyBuild) {
|
||||
transformers.before.push(lazyRoutesTransformer(compilerOptions, compilerHost));
|
||||
}
|
||||
|
||||
// Emit is handled in write file callback when using TypeScript
|
||||
if (useTypeScriptTranspilation) {
|
||||
|
@ -14,22 +14,26 @@ import type { AngularCompilation } from './angular-compilation';
|
||||
* compilation either for AOT or JIT mode. By default a parallel compilation is created
|
||||
* that uses a Node.js worker thread.
|
||||
* @param jit True, for Angular JIT compilation; False, for Angular AOT compilation.
|
||||
* @param browserOnlyBuild True, for browser only builds; False, for browser and server builds.
|
||||
* @returns An instance of an Angular compilation object.
|
||||
*/
|
||||
export async function createAngularCompilation(jit: boolean): Promise<AngularCompilation> {
|
||||
export async function createAngularCompilation(
|
||||
jit: boolean,
|
||||
browserOnlyBuild: boolean,
|
||||
): Promise<AngularCompilation> {
|
||||
if (useParallelTs) {
|
||||
const { ParallelCompilation } = await import('./parallel-compilation');
|
||||
|
||||
return new ParallelCompilation(jit);
|
||||
return new ParallelCompilation(jit, browserOnlyBuild);
|
||||
}
|
||||
|
||||
if (jit) {
|
||||
const { JitCompilation } = await import('./jit-compilation');
|
||||
|
||||
return new JitCompilation();
|
||||
return new JitCompilation(browserOnlyBuild);
|
||||
} else {
|
||||
const { AotCompilation } = await import('./aot-compilation');
|
||||
|
||||
return new AotCompilation();
|
||||
return new AotCompilation(browserOnlyBuild);
|
||||
}
|
||||
}
|
||||
|
@ -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';
|
||||
|
||||
@ -29,6 +30,10 @@ class JitCompilationState {
|
||||
export class JitCompilation extends AngularCompilation {
|
||||
#state?: JitCompilationState;
|
||||
|
||||
constructor(private readonly browserOnlyBuild: boolean) {
|
||||
super();
|
||||
}
|
||||
|
||||
async initialize(
|
||||
tsconfig: string,
|
||||
hostOptions: AngularHostOptions,
|
||||
@ -116,8 +121,8 @@ export class JitCompilation extends AngularCompilation {
|
||||
replaceResourcesTransform,
|
||||
webWorkerTransform,
|
||||
} = this.#state;
|
||||
const buildInfoFilename =
|
||||
typeScriptProgram.getCompilerOptions().tsBuildInfoFile ?? '.tsbuildinfo';
|
||||
const compilerOptions = typeScriptProgram.getCompilerOptions();
|
||||
const buildInfoFilename = compilerOptions.tsBuildInfoFile ?? '.tsbuildinfo';
|
||||
|
||||
const emittedFiles: EmitFileResult[] = [];
|
||||
const writeFileCallback: ts.WriteFileCallback = (filename, contents, _a, _b, sourceFiles) => {
|
||||
@ -140,6 +145,10 @@ export class JitCompilation extends AngularCompilation {
|
||||
],
|
||||
};
|
||||
|
||||
if (!this.browserOnlyBuild) {
|
||||
transformers.before.push(lazyRoutesTransformer(compilerOptions, compilerHost));
|
||||
}
|
||||
|
||||
// TypeScript will loop until there are no more affected files in the program
|
||||
while (
|
||||
typeScriptProgram.emitNextAffectedFile(writeFileCallback, undefined, undefined, transformers)
|
||||
|
@ -26,7 +26,10 @@ import { AngularCompilation, DiagnosticModes, EmitFileResult } from './angular-c
|
||||
export class ParallelCompilation extends AngularCompilation {
|
||||
readonly #worker: WorkerPool;
|
||||
|
||||
constructor(readonly jit: boolean) {
|
||||
constructor(
|
||||
private readonly jit: boolean,
|
||||
private readonly browserOnlyBuild: boolean,
|
||||
) {
|
||||
super();
|
||||
|
||||
// TODO: Convert to import.meta usage during ESM transition
|
||||
@ -99,6 +102,7 @@ export class ParallelCompilation extends AngularCompilation {
|
||||
fileReplacements: hostOptions.fileReplacements,
|
||||
tsconfig,
|
||||
jit: this.jit,
|
||||
browserOnlyBuild: this.browserOnlyBuild,
|
||||
stylesheetPort: stylesheetChannel.port2,
|
||||
optionsPort: optionsChannel.port2,
|
||||
optionsSignal,
|
||||
|
@ -17,6 +17,7 @@ import { JitCompilation } from './jit-compilation';
|
||||
|
||||
export interface InitRequest {
|
||||
jit: boolean;
|
||||
browserOnlyBuild: boolean;
|
||||
tsconfig: string;
|
||||
fileReplacements?: Record<string, string>;
|
||||
stylesheetPort: MessagePort;
|
||||
@ -31,7 +32,9 @@ let compilation: AngularCompilation | undefined;
|
||||
const sourceFileCache = new SourceFileCache();
|
||||
|
||||
export async function initialize(request: InitRequest) {
|
||||
compilation ??= request.jit ? new JitCompilation() : new AotCompilation();
|
||||
compilation ??= request.jit
|
||||
? new JitCompilation(request.browserOnlyBuild)
|
||||
: new AotCompilation(request.browserOnlyBuild);
|
||||
|
||||
const stylesheetRequests = new Map<string, [(value: string) => void, (reason: Error) => void]>();
|
||||
request.stylesheetPort.on('message', ({ requestId, value, error }) => {
|
||||
|
@ -0,0 +1,225 @@
|
||||
/**
|
||||
* @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';
|
||||
|
||||
/**
|
||||
* A transformer factory that adds a property to the lazy-loaded route object.
|
||||
* This property is used to allow for the retrieval of the module path during SSR.
|
||||
*
|
||||
* @param compilerOptions The compiler options.
|
||||
* @param compilerHost The compiler host.
|
||||
* @returns A transformer factory.
|
||||
*
|
||||
* @example
|
||||
* **Before:**
|
||||
* ```ts
|
||||
* const routes: Routes = [
|
||||
* {
|
||||
* path: 'lazy',
|
||||
* loadChildren: () => import('./lazy/lazy.module').then(m => m.LazyModule)
|
||||
* }
|
||||
* ];
|
||||
* ```
|
||||
*
|
||||
* **After:**
|
||||
* ```ts
|
||||
* const routes: Routes = [
|
||||
* {
|
||||
* path: 'lazy',
|
||||
* loadChildren: () => import('./lazy/lazy.module').then(m => m.LazyModule),
|
||||
* ...(typeof ngServerMode !== "undefined" && ngServerMode ? { ɵentryName: "./lazy/lazy.module.ts" }: {})
|
||||
* }
|
||||
* ];
|
||||
* ```
|
||||
*/
|
||||
export function lazyRoutesTransformer(
|
||||
compilerOptions: ts.CompilerOptions,
|
||||
compilerHost: ts.CompilerHost,
|
||||
): ts.TransformerFactory<ts.SourceFile> {
|
||||
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)) {
|
||||
// Not an object literal, so skip it.
|
||||
return ts.visitEachChild(node, visitor, context);
|
||||
}
|
||||
|
||||
const loadFunction = getLoadComponentOrChildrenProperty(node)?.initializer;
|
||||
// Check if the initializer is an arrow function or a function expression
|
||||
if (
|
||||
!loadFunction ||
|
||||
(!ts.isArrowFunction(loadFunction) && !ts.isFunctionExpression(loadFunction))
|
||||
) {
|
||||
return ts.visitEachChild(node, visitor, context);
|
||||
}
|
||||
|
||||
let callExpression: ts.CallExpression | undefined;
|
||||
|
||||
if (ts.isArrowFunction(loadFunction)) {
|
||||
// Handle arrow functions: body can either be a block or a direct call expression
|
||||
const body = loadFunction.body;
|
||||
|
||||
if (ts.isBlock(body)) {
|
||||
// Arrow function with a block: check the first statement for a return call expression
|
||||
const firstStatement = body.statements[0];
|
||||
|
||||
if (
|
||||
firstStatement &&
|
||||
ts.isReturnStatement(firstStatement) &&
|
||||
firstStatement.expression &&
|
||||
ts.isCallExpression(firstStatement.expression)
|
||||
) {
|
||||
callExpression = firstStatement.expression;
|
||||
}
|
||||
} else if (ts.isCallExpression(body)) {
|
||||
// Arrow function with a direct call expression as its body
|
||||
callExpression = body;
|
||||
}
|
||||
} else if (ts.isFunctionExpression(loadFunction)) {
|
||||
// Handle function expressions: check for a return statement with a call expression
|
||||
const returnExpression = loadFunction.body.statements.find(
|
||||
ts.isReturnStatement,
|
||||
)?.expression;
|
||||
|
||||
if (returnExpression && ts.isCallExpression(returnExpression)) {
|
||||
callExpression = returnExpression;
|
||||
}
|
||||
}
|
||||
|
||||
if (!callExpression) {
|
||||
return ts.visitEachChild(node, visitor, context);
|
||||
}
|
||||
|
||||
// Optionally check for the 'then' property access expression
|
||||
const expression = callExpression.expression;
|
||||
if (
|
||||
!ts.isCallExpression(expression) &&
|
||||
ts.isPropertyAccessExpression(expression) &&
|
||||
expression.name.text !== 'then'
|
||||
) {
|
||||
return ts.visitEachChild(node, visitor, context);
|
||||
}
|
||||
|
||||
const importExpression = ts.isPropertyAccessExpression(expression)
|
||||
? expression.expression // Navigate to the underlying expression for 'then'
|
||||
: callExpression;
|
||||
|
||||
// Ensure the underlying expression is an import call
|
||||
if (
|
||||
!ts.isCallExpression(importExpression) ||
|
||||
importExpression.expression.kind !== ts.SyntaxKind.ImportKeyword
|
||||
) {
|
||||
return ts.visitEachChild(node, visitor, context);
|
||||
}
|
||||
|
||||
// Check if the argument to the import call is a string literal
|
||||
const callExpressionArgument = importExpression.arguments[0];
|
||||
if (!ts.isStringLiteralLike(callExpressionArgument)) {
|
||||
// Not a string literal, so skip it.
|
||||
return ts.visitEachChild(node, visitor, context);
|
||||
}
|
||||
|
||||
const resolvedPath = ts.resolveModuleName(
|
||||
callExpressionArgument.text,
|
||||
node.getSourceFile().fileName,
|
||||
compilerOptions,
|
||||
compilerHost,
|
||||
moduleResolutionCache,
|
||||
)?.resolvedModule?.resolvedFileName;
|
||||
|
||||
if (!resolvedPath) {
|
||||
// Could not resolve the module, so skip it.
|
||||
return ts.visitEachChild(node, visitor, context);
|
||||
}
|
||||
|
||||
const resolvedRelativePath = relative(basePath, resolvedPath);
|
||||
|
||||
// Create the new property
|
||||
// Example: `...(typeof ngServerMode !== "undefined" && ngServerMode ? { ɵentryName: "src/home.ts" }: {})`
|
||||
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),
|
||||
),
|
||||
]),
|
||||
factory.createToken(ts.SyntaxKind.ColonToken),
|
||||
factory.createObjectLiteralExpression([]),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Add the new property to the object literal.
|
||||
return factory.updateObjectLiteralExpression(node, [...node.properties, newProperty]);
|
||||
};
|
||||
|
||||
return (sourceFile) => {
|
||||
const text = sourceFile.text;
|
||||
if (!text.includes('loadC')) {
|
||||
// Fast check for 'loadComponent' and 'loadChildren'.
|
||||
return sourceFile;
|
||||
}
|
||||
|
||||
return ts.visitEachChild(sourceFile, visitor, context);
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the property assignment for the `loadComponent` or `loadChildren` property of a route object.
|
||||
*
|
||||
* @param node The object literal expression to search.
|
||||
* @returns The property assignment if found, otherwise `undefined`.
|
||||
*/
|
||||
function getLoadComponentOrChildrenProperty(
|
||||
node: ts.ObjectLiteralExpression,
|
||||
): ts.PropertyAssignment | undefined {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
return loadComponentOrChildrenProperty;
|
||||
}
|
@ -0,0 +1,208 @@
|
||||
/**
|
||||
* @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.getCompilerOptions(), compilerHost);
|
||||
const result = ts.transform(sourceFile, [transformer]);
|
||||
|
||||
return result.transformed[0];
|
||||
};
|
||||
|
||||
it('should return the same object when the routes array contains an empty object', () => {
|
||||
const source = `
|
||||
const routes = [{}];
|
||||
`;
|
||||
|
||||
const transformedSourceFile = transformSourceFile(source);
|
||||
const transformedCode = ts.createPrinter().printFile(transformedSourceFile);
|
||||
|
||||
expect(transformedCode).toContain(`const routes = [{}]`);
|
||||
});
|
||||
|
||||
it('should add ɵentryName property to object with loadComponent and path (Arrow function)', () => {
|
||||
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" } : {})`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should add ɵentryName property to object with loadComponent and path (Arrow function with return)', () => {
|
||||
const source = `
|
||||
const routes = [
|
||||
{
|
||||
path: 'home',
|
||||
loadComponent: () => {
|
||||
return 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" } : {})`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should add ɵentryName property to object with loadComponent and path (Arrow function without .then)', () => {
|
||||
const source = `
|
||||
const routes = [
|
||||
{
|
||||
path: 'about',
|
||||
loadComponent: () => import('./about')
|
||||
}
|
||||
];
|
||||
`;
|
||||
|
||||
const transformedSourceFile = transformSourceFile(source);
|
||||
const transformedCode = ts.createPrinter().printFile(transformedSourceFile);
|
||||
|
||||
expect(transformedCode).toContain(
|
||||
`...(typeof ngServerMode !== "undefined" && ngServerMode ? { ɵentryName: "src/about.ts" } : {})`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should add ɵentryName property to object with loadComponent using return and .then', () => {
|
||||
const source = `
|
||||
const routes = [
|
||||
{
|
||||
path: '',
|
||||
loadComponent: () => {
|
||||
return 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" } : {})`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should add ɵentryName property to object with loadComponent and path (Function expression)', () => {
|
||||
const source = `
|
||||
const routes = [
|
||||
{
|
||||
path: 'home',
|
||||
loadComponent: function () { return 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" } : {})`,
|
||||
);
|
||||
});
|
||||
|
||||
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" } : {})`,
|
||||
);
|
||||
});
|
||||
});
|
@ -40,6 +40,7 @@ export interface CompilerPluginOptions {
|
||||
sourcemap: boolean | 'external';
|
||||
tsconfig: string;
|
||||
jit?: boolean;
|
||||
browserOnlyBuild?: boolean;
|
||||
|
||||
/** Skip TypeScript compilation setup. This is useful to re-use the TypeScript compilation from another plugin. */
|
||||
noopTypeScriptCompilation?: boolean;
|
||||
@ -119,7 +120,7 @@ export function createCompilerPlugin(
|
||||
// Create new reusable compilation for the appropriate mode based on the `jit` plugin option
|
||||
const compilation: AngularCompilation = pluginOptions.noopTypeScriptCompilation
|
||||
? new NoopCompilation()
|
||||
: await createAngularCompilation(!!pluginOptions.jit);
|
||||
: await createAngularCompilation(!!pluginOptions.jit, !!pluginOptions.browserOnlyBuild);
|
||||
// Compilation is initially assumed to have errors until emitted
|
||||
let hasCompilationErrors = true;
|
||||
|
||||
|
@ -31,6 +31,7 @@ export function createCompilerPluginOptions(
|
||||
const incremental = !!options.watch;
|
||||
|
||||
return {
|
||||
browserOnlyBuild: !options.serverEntryPoint,
|
||||
sourcemap: !!sourcemapOptions.scripts && (sourcemapOptions.hidden ? 'external' : true),
|
||||
thirdPartySourcemaps: sourcemapOptions.vendor,
|
||||
tsconfig,
|
||||
|
@ -6,14 +6,21 @@
|
||||
* found in the LICENSE file at https://angular.dev/license
|
||||
*/
|
||||
|
||||
import type { Metafile } from 'esbuild';
|
||||
import { extname } from 'node:path';
|
||||
import { NormalizedApplicationBuildOptions } 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';
|
||||
|
||||
/**
|
||||
@ -97,6 +104,9 @@ export default {
|
||||
* the application, helping with localization and rendering content specific to the locale.
|
||||
* @param baseHref - The base HREF for the application. This is used to set the base URL
|
||||
* for all relative URLs in the application.
|
||||
* @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.
|
||||
@ -109,6 +119,9 @@ export function generateAngularServerAppManifest(
|
||||
routes: readonly unknown[] | undefined,
|
||||
locale: string | undefined,
|
||||
baseHref: string,
|
||||
initialFiles: Set<string>,
|
||||
metafile: Metafile,
|
||||
publicPath: string | undefined,
|
||||
): {
|
||||
manifestContent: string;
|
||||
serverAssetsChunks: BuildOutputFile[];
|
||||
@ -132,6 +145,13 @@ 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 entryPointToBrowserMapping =
|
||||
routes?.length || shouldOptimizeChunks
|
||||
? undefined
|
||||
: generateLazyLoadedFilesMappings(metafile, initialFiles, publicPath);
|
||||
|
||||
const manifestContent = `
|
||||
export default {
|
||||
bootstrap: () => import('./main.server.mjs').then(m => m.default),
|
||||
@ -139,6 +159,7 @@ export default {
|
||||
baseHref: '${baseHref}',
|
||||
locale: ${JSON.stringify(locale)},
|
||||
routes: ${JSON.stringify(routes, undefined, 2)},
|
||||
entryPointToBrowserMapping: ${JSON.stringify(entryPointToBrowserMapping, undefined, 2)},
|
||||
assets: {
|
||||
${Object.entries(serverAssets)
|
||||
.map(([key, value]) => `'${key}': ${value}`)
|
||||
@ -149,3 +170,50 @@ export default {
|
||||
|
||||
return { manifestContent, serverAssetsChunks };
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps entry points to their corresponding browser bundles for lazy loading.
|
||||
*
|
||||
* This function processes a metafile's outputs to generate a mapping between browser-side entry points
|
||||
* and the associated JavaScript files that should be loaded in the browser. It includes the entry-point's
|
||||
* own path and any valid imports while excluding initial files or external resources.
|
||||
*/
|
||||
function generateLazyLoadedFilesMappings(
|
||||
metafile: Metafile,
|
||||
initialFiles: Set<string>,
|
||||
publicPath = '',
|
||||
): Record<string, FilesMapping[]> {
|
||||
const entryPointToBundles: Record<string, FilesMapping[]> = {};
|
||||
for (const [fileName, { entryPoint, exports, imports }] of Object.entries(metafile.outputs)) {
|
||||
// Skip files that don't have an entryPoint, no exports, or are not .js
|
||||
if (!entryPoint || exports?.length < 1 || !fileName.endsWith('.js')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const importedPaths: FilesMapping[] = [
|
||||
{
|
||||
path: `${publicPath}${fileName}`,
|
||||
dynamicImport: false,
|
||||
},
|
||||
];
|
||||
|
||||
for (const { kind, external, path } of imports) {
|
||||
if (
|
||||
external ||
|
||||
initialFiles.has(path) ||
|
||||
(kind !== 'dynamic-import' && kind !== 'import-statement')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
importedPaths.push({
|
||||
path: `${publicPath}${path}`,
|
||||
dynamicImport: kind === 'dynamic-import',
|
||||
});
|
||||
}
|
||||
|
||||
entryPointToBundles[entryPoint] = importedPaths;
|
||||
}
|
||||
|
||||
return entryPointToBundles;
|
||||
}
|
||||
|
@ -251,7 +251,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;
|
||||
@ -293,8 +293,8 @@ export class AngularServerApp {
|
||||
);
|
||||
} else if (renderMode === RenderMode.Client) {
|
||||
// Serve the client-side rendered version if the route is configured for CSR.
|
||||
let html = await assets.getServerAsset('index.csr.html').text();
|
||||
html = await this.runTransformsOnHtml(html, url);
|
||||
let html = await this.assets.getServerAsset('index.csr.html').text();
|
||||
html = await this.runTransformsOnHtml(html, url, preload);
|
||||
|
||||
return new Response(html, responseInit);
|
||||
}
|
||||
@ -308,7 +308,7 @@ export class AngularServerApp {
|
||||
|
||||
this.boostrap ??= await bootstrap();
|
||||
let html = await assets.getIndexServerHtml().text();
|
||||
html = await this.runTransformsOnHtml(html, url);
|
||||
html = await this.runTransformsOnHtml(html, url, preload);
|
||||
html = await renderAngular(
|
||||
html,
|
||||
this.boostrap,
|
||||
@ -385,13 +385,22 @@ export class AngularServerApp {
|
||||
*
|
||||
* @param html - The raw HTML content to be transformed.
|
||||
* @param url - The URL associated with the HTML content, used for context during transformations.
|
||||
* @param preload - An array of URLs representing the JavaScript resources to preload.
|
||||
* @returns A promise that resolves to the transformed HTML string.
|
||||
*/
|
||||
private async runTransformsOnHtml(html: string, url: URL): Promise<string> {
|
||||
private async runTransformsOnHtml(
|
||||
html: string,
|
||||
url: URL,
|
||||
preload: readonly string[] | undefined,
|
||||
): Promise<string> {
|
||||
if (this.hooks.has('html:transform:pre')) {
|
||||
html = await this.hooks.run('html:transform:pre', { html, url });
|
||||
}
|
||||
|
||||
if (preload?.length) {
|
||||
html = appendPreloadHintsToHtml(html, preload);
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
}
|
||||
@ -430,3 +439,30 @@ 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.
|
||||
* @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[]): string {
|
||||
const bodyCloseIdx = html.lastIndexOf('</body>');
|
||||
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}">`),
|
||||
html.slice(bodyCloseIdx),
|
||||
].join('\n');
|
||||
}
|
||||
|
@ -110,6 +110,26 @@ export interface AngularAppManifest {
|
||||
* the application, aiding with localization and rendering content specific to the locale.
|
||||
*/
|
||||
readonly locale?: string;
|
||||
|
||||
/**
|
||||
* Maps entry-point names to their corresponding browser bundles and loading strategies.
|
||||
*
|
||||
* - **Key**: The entry-point name, typically the value of `ɵentryName`.
|
||||
* - **Value**: An array of objects, each representing a browser bundle with:
|
||||
* - `path`: The filename or URL of the associated JavaScript bundle to preload.
|
||||
* - `dynamicImport`: A boolean indicating whether the bundle is loaded via a dynamic `import()`.
|
||||
* If `true`, the bundle is lazily loaded, impacting its preloading behavior.
|
||||
*
|
||||
* ### Example
|
||||
* ```ts
|
||||
* {
|
||||
* 'src/app/lazy/lazy.ts': [{ path: 'src/app/lazy/lazy.js', dynamicImport: true }]
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
readonly entryPointToBrowserMapping?: Readonly<
|
||||
Record<string, ReadonlyArray<{ path: string; dynamicImport: boolean }> | undefined>
|
||||
>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -9,7 +9,11 @@
|
||||
import { APP_BASE_HREF, PlatformLocation } from '@angular/common';
|
||||
import { ApplicationRef, Compiler, Injector, runInInjectionContext, ɵConsole } from '@angular/core';
|
||||
import { INITIAL_CONFIG, platformServer } from '@angular/platform-server';
|
||||
import { Route, Router, ɵloadChildren as loadChildrenHelper } from '@angular/router';
|
||||
import {
|
||||
Route as AngularRoute,
|
||||
Router,
|
||||
ɵloadChildren as loadChildrenHelper,
|
||||
} from '@angular/router';
|
||||
import { ServerAssets } from '../assets';
|
||||
import { Console } from '../console';
|
||||
import { AngularAppManifest, getAngularAppManifest } from '../manifest';
|
||||
@ -25,6 +29,16 @@ import {
|
||||
} from './route-config';
|
||||
import { RouteTree, RouteTreeNodeMetadata } from './route-tree';
|
||||
|
||||
interface Route extends AngularRoute {
|
||||
ɵentryName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The maximum number of module preload link elements that should be added for
|
||||
* initial scripts.
|
||||
*/
|
||||
const MODULE_PRELOAD_MAX = 10;
|
||||
|
||||
/**
|
||||
* Regular expression to match segments preceded by a colon in a string.
|
||||
*/
|
||||
@ -87,6 +101,8 @@ interface AngularRouterConfigResult {
|
||||
appShellRoute?: string;
|
||||
}
|
||||
|
||||
type EntryPointToBrowserMapping = AngularAppManifest['entryPointToBrowserMapping'];
|
||||
|
||||
/**
|
||||
* Traverses an array of route configurations to generate route tree node metadata.
|
||||
*
|
||||
@ -104,6 +120,8 @@ async function* traverseRoutesConfig(options: {
|
||||
serverConfigRouteTree: RouteTree<ServerConfigRouteTreeAdditionalMetadata> | undefined;
|
||||
invokeGetPrerenderParams: boolean;
|
||||
includePrerenderFallbackRoutes: boolean;
|
||||
entryPointToBrowserMapping: EntryPointToBrowserMapping | undefined;
|
||||
parentPreloads?: readonly string[];
|
||||
}): AsyncIterableIterator<RouteTreeNodeMetadata | { error: string }> {
|
||||
const {
|
||||
routes,
|
||||
@ -111,13 +129,15 @@ async function* traverseRoutesConfig(options: {
|
||||
parentInjector,
|
||||
parentRoute,
|
||||
serverConfigRouteTree,
|
||||
entryPointToBrowserMapping,
|
||||
parentPreloads,
|
||||
invokeGetPrerenderParams,
|
||||
includePrerenderFallbackRoutes,
|
||||
} = options;
|
||||
|
||||
for (const route of routes) {
|
||||
try {
|
||||
const { path = '', redirectTo, loadChildren, children } = route;
|
||||
const { path = '', redirectTo, loadChildren, loadComponent, children, ɵentryName } = route;
|
||||
const currentRoutePath = joinUrlParts(parentRoute, path);
|
||||
|
||||
// Get route metadata from the server config route tree, if available
|
||||
@ -140,13 +160,17 @@ async function* traverseRoutesConfig(options: {
|
||||
const metadata: ServerConfigRouteTreeNodeMetadata = {
|
||||
renderMode: RenderMode.Prerender,
|
||||
...matchedMetaData,
|
||||
preload: parentPreloads,
|
||||
// Match Angular router behavior
|
||||
// ['one', 'two', ''] -> 'one/two/'
|
||||
// ['one', 'two', 'three'] -> 'one/two/three'
|
||||
route: path === '' ? addTrailingSlash(currentRoutePath) : currentRoutePath,
|
||||
presentInClientRouter: undefined,
|
||||
};
|
||||
|
||||
delete metadata.presentInClientRouter;
|
||||
if (ɵentryName && loadComponent) {
|
||||
appendPreloadToMetadata(ɵentryName, entryPointToBrowserMapping, metadata, true);
|
||||
}
|
||||
|
||||
if (metadata.renderMode === RenderMode.Prerender) {
|
||||
// Handle SSG routes
|
||||
@ -180,11 +204,20 @@ async function* traverseRoutesConfig(options: {
|
||||
...options,
|
||||
routes: children,
|
||||
parentRoute: currentRoutePath,
|
||||
parentPreloads: metadata.preload,
|
||||
});
|
||||
}
|
||||
|
||||
// Load and process lazy-loaded child routes
|
||||
if (loadChildren) {
|
||||
if (ɵentryName) {
|
||||
// When using `loadChildren`, the entire feature area (including multiple routes) is loaded.
|
||||
// As a result, we do not want all dynamic-import dependencies to be preload, because it involves multiple dependencies
|
||||
// across different child routes. In contrast, `loadComponent` only loads a single component, which allows
|
||||
// for precise control over preloading, ensuring that the files preloaded are exactly those required for that specific route.
|
||||
appendPreloadToMetadata(ɵentryName, entryPointToBrowserMapping, metadata, false);
|
||||
}
|
||||
|
||||
const loadedChildRoutes = await loadChildrenHelper(
|
||||
route,
|
||||
compiler,
|
||||
@ -198,6 +231,7 @@ async function* traverseRoutesConfig(options: {
|
||||
routes: childRoutes,
|
||||
parentInjector: injector,
|
||||
parentRoute: currentRoutePath,
|
||||
parentPreloads: metadata.preload,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -209,6 +243,36 @@ async function* traverseRoutesConfig(options: {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends preload information to the metadata object based on the specified entry-point and chunk mappings.
|
||||
*
|
||||
* This function extracts preload data for a given entry-point from the provided chunk mappings. It adds the
|
||||
* corresponding browser bundles to the metadata's preload list, ensuring no duplicates and limiting the total
|
||||
* preloads to a predefined maximum.
|
||||
*/
|
||||
function appendPreloadToMetadata(
|
||||
entryName: string,
|
||||
entryPointToBrowserMapping: EntryPointToBrowserMapping,
|
||||
metadata: ServerConfigRouteTreeNodeMetadata,
|
||||
includeDynamicImports: boolean,
|
||||
): void {
|
||||
if (!entryPointToBrowserMapping) {
|
||||
return;
|
||||
}
|
||||
|
||||
const preload = entryPointToBrowserMapping[entryName];
|
||||
|
||||
if (preload?.length) {
|
||||
// Merge existing preloads with new ones, ensuring uniqueness and limiting the total to the maximum allowed.
|
||||
const preloadPaths =
|
||||
preload
|
||||
.filter(({ dynamicImport }) => includeDynamicImports || !dynamicImport)
|
||||
.map(({ path }) => path) ?? [];
|
||||
const combinedPreloads = [...(metadata.preload ?? []), ...preloadPaths];
|
||||
metadata.preload = Array.from(new Set(combinedPreloads)).slice(0, MODULE_PRELOAD_MAX);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles SSG (Static Site Generation) routes by invoking `getPrerenderParams` and yielding
|
||||
* all parameterized paths, returning any errors encountered.
|
||||
@ -391,6 +455,7 @@ function buildServerConfigRouteTree({ routes, appShellRoute }: ServerRoutesConfi
|
||||
* @param invokeGetPrerenderParams - A boolean flag indicating whether to invoke `getPrerenderParams` for parameterized SSG routes
|
||||
* to handle prerendering paths. Defaults to `false`.
|
||||
* @param includePrerenderFallbackRoutes - A flag indicating whether to include fallback routes in the result. Defaults to `true`.
|
||||
* @param entryPointToBrowserMapping - Maps the entry-point name to the associated JavaScript browser bundles.
|
||||
*
|
||||
* @returns A promise that resolves to an object of type `AngularRouterConfigResult` or errors.
|
||||
*/
|
||||
@ -400,6 +465,7 @@ export async function getRoutesFromAngularRouterConfig(
|
||||
url: URL,
|
||||
invokeGetPrerenderParams = false,
|
||||
includePrerenderFallbackRoutes = true,
|
||||
entryPointToBrowserMapping: EntryPointToBrowserMapping | undefined = undefined,
|
||||
): Promise<AngularRouterConfigResult> {
|
||||
const { protocol, host } = url;
|
||||
|
||||
@ -469,6 +535,7 @@ export async function getRoutesFromAngularRouterConfig(
|
||||
serverConfigRouteTree,
|
||||
invokeGetPrerenderParams,
|
||||
includePrerenderFallbackRoutes,
|
||||
entryPointToBrowserMapping,
|
||||
});
|
||||
|
||||
for await (const result of traverseRoutes) {
|
||||
@ -569,6 +636,7 @@ export function extractRoutesAndCreateRouteTree(options: {
|
||||
url,
|
||||
invokeGetPrerenderParams,
|
||||
includePrerenderFallbackRoutes,
|
||||
manifest.entryPointToBrowserMapping,
|
||||
);
|
||||
|
||||
for (const { route, ...metadata } of routes) {
|
||||
|
@ -65,6 +65,11 @@ export interface RouteTreeNodeMetadata {
|
||||
* Specifies the rendering mode used for this route.
|
||||
*/
|
||||
renderMode: RenderMode;
|
||||
|
||||
/**
|
||||
* A list of resource that should be preloaded by the browser.
|
||||
*/
|
||||
preload?: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -22,7 +22,7 @@ import { ServerRoute, provideServerRoutesConfig } from '../src/routes/route-conf
|
||||
*
|
||||
* @param routes - An array of route definitions to be used by the Angular Router.
|
||||
* @param serverRoutes - An array of server route definitions for server-side rendering.
|
||||
* @param [baseHref='/'] - An optional base href for the HTML template (default is `/`).
|
||||
* @param baseHref - An optional base href to be used in the HTML template.
|
||||
* @param additionalServerAssets - A record of additional server assets to include,
|
||||
* where the keys are asset paths and the values are asset details.
|
||||
* @param locale - An optional locale to configure for the application during testing.
|
||||
|
@ -0,0 +1,223 @@
|
||||
import assert from 'node:assert';
|
||||
import { replaceInFile, writeMultipleFiles } from '../../../utils/fs';
|
||||
import { execAndWaitForOutputToMatch, ng, noSilentNg, silentNg } from '../../../utils/process';
|
||||
import { installWorkspacePackages, uninstallPackage } from '../../../utils/packages';
|
||||
import { ngServe, updateJsonFile, useSha } from '../../../utils/project';
|
||||
import { getGlobalVariable } from '../../../utils/env';
|
||||
import { findFreePort } from '../../../utils/network';
|
||||
|
||||
export default async function () {
|
||||
assert(
|
||||
getGlobalVariable('argv')['esbuild'],
|
||||
'This test should not be called in the Webpack suite.',
|
||||
);
|
||||
|
||||
// Forcibly remove in case another test doesn't clean itself up.
|
||||
await uninstallPackage('@angular/ssr');
|
||||
await ng('add', '@angular/ssr', '--server-routing', '--skip-confirmation', '--skip-install');
|
||||
await useSha();
|
||||
await installWorkspacePackages();
|
||||
|
||||
await updateJsonFile('angular.json', (workspaceJson) => {
|
||||
const appProject = workspaceJson.projects['test-project'];
|
||||
appProject.architect['build'].options.namedChunks = true;
|
||||
});
|
||||
|
||||
// Add routes
|
||||
await writeMultipleFiles({
|
||||
'src/app/app.routes.ts': `
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
loadComponent: () => import('./home/home.component').then(c => c.HomeComponent),
|
||||
},
|
||||
{
|
||||
path: 'ssg',
|
||||
loadChildren: () => import('./ssg.routes').then(m => m.routes),
|
||||
},
|
||||
{
|
||||
path: 'ssr',
|
||||
loadComponent: () => import('./ssr/ssr.component').then(c => c.SsrComponent),
|
||||
},
|
||||
{
|
||||
path: 'csr',
|
||||
loadComponent: () => import('./csr/csr.component').then(c => c.CsrComponent),
|
||||
},
|
||||
];
|
||||
`,
|
||||
'src/app/app.routes.server.ts': `
|
||||
import { RenderMode, ServerRoute } from '@angular/ssr';
|
||||
|
||||
export const serverRoutes: ServerRoute[] = [
|
||||
{
|
||||
path: 'ssr',
|
||||
renderMode: RenderMode.Server,
|
||||
},
|
||||
{
|
||||
path: 'csr',
|
||||
renderMode: RenderMode.Client,
|
||||
},
|
||||
{
|
||||
path: '**',
|
||||
renderMode: RenderMode.Prerender,
|
||||
},
|
||||
];
|
||||
`,
|
||||
'src/app/cross-dep.ts': `export const foo = 'foo';`,
|
||||
'src/app/ssg.routes.ts': `
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
loadComponent: () => import('./ssg/ssg.component').then(c => c.SsgComponent),
|
||||
},
|
||||
{
|
||||
path: 'one',
|
||||
loadComponent: () => import('./ssg-one/ssg-one.component').then(c => c.SsgOneComponent),
|
||||
},
|
||||
{
|
||||
path: 'two',
|
||||
loadComponent: () => import('./ssg-two/ssg-two.component').then(c => c.SsgTwoComponent),
|
||||
},
|
||||
];`,
|
||||
});
|
||||
|
||||
// Generate components for the above routes
|
||||
const componentNames: string[] = ['home', 'ssg', 'csr', 'ssr', 'ssg-one', 'ssg-two'];
|
||||
|
||||
for (const componentName of componentNames) {
|
||||
await silentNg('generate', 'component', componentName);
|
||||
}
|
||||
|
||||
// Add a cross-dependency
|
||||
await Promise.all([
|
||||
replaceInFile(
|
||||
'src/app/ssg-one/ssg-one.component.ts',
|
||||
`OneComponent {`,
|
||||
`OneComponent {
|
||||
async ngOnInit() {
|
||||
await import('../cross-dep');
|
||||
}
|
||||
`,
|
||||
),
|
||||
replaceInFile(
|
||||
'src/app/ssg-two/ssg-two.component.ts',
|
||||
`TwoComponent {`,
|
||||
`TwoComponent {
|
||||
async ngOnInit() {
|
||||
await import('../cross-dep');
|
||||
}
|
||||
`,
|
||||
),
|
||||
]);
|
||||
|
||||
// Test both vite and `ng build`
|
||||
await runTests(await ngServe());
|
||||
|
||||
await noSilentNg('build', '--output-mode=server');
|
||||
await runTests(await spawnServer());
|
||||
}
|
||||
|
||||
const RESPONSE_EXPECTS: Record<
|
||||
string,
|
||||
{
|
||||
matches: RegExp[];
|
||||
notMatches: RegExp[];
|
||||
}
|
||||
> = {
|
||||
'/': {
|
||||
matches: [/<link rel="modulepreload" href="(home\.component-[a-zA-Z0-9]{8}\.js)">/],
|
||||
notMatches: [/ssg\.component/, /ssr\.component/, /csr\.component/, /cross-dep-/],
|
||||
},
|
||||
'/ssg': {
|
||||
matches: [
|
||||
/<link rel="modulepreload" href="(ssg\.routes-[a-zA-Z0-9]{8}\.js)">/,
|
||||
/<link rel="modulepreload" href="(ssg\.component-[a-zA-Z0-9]{8}\.js)">/,
|
||||
],
|
||||
notMatches: [
|
||||
/home\.component/,
|
||||
/ssr\.component/,
|
||||
/csr\.component/,
|
||||
/ssg-one\.component/,
|
||||
/ssg-two\.component/,
|
||||
/cross-dep-/,
|
||||
],
|
||||
},
|
||||
'/ssg/one': {
|
||||
matches: [
|
||||
/<link rel="modulepreload" href="(ssg\.routes-[a-zA-Z0-9]{8}\.js)">/,
|
||||
/<link rel="modulepreload" href="(ssg-one\.component-[a-zA-Z0-9]{8}\.js)">/,
|
||||
/<link rel="modulepreload" href="(cross-dep-[a-zA-Z0-9]{8}\.js)">/,
|
||||
],
|
||||
notMatches: [
|
||||
/home\.component/,
|
||||
/ssr\.component/,
|
||||
/csr\.component/,
|
||||
/ssg-two\.component/,
|
||||
/ssg\.component/,
|
||||
],
|
||||
},
|
||||
'/ssg/two': {
|
||||
matches: [
|
||||
/<link rel="modulepreload" href="(ssg\.routes-[a-zA-Z0-9]{8}\.js)">/,
|
||||
/<link rel="modulepreload" href="(ssg-two\.component-[a-zA-Z0-9]{8}\.js)">/,
|
||||
/<link rel="modulepreload" href="(cross-dep-[a-zA-Z0-9]{8}\.js)">/,
|
||||
],
|
||||
notMatches: [
|
||||
/home\.component/,
|
||||
/ssr\.component/,
|
||||
/csr\.component/,
|
||||
/ssg-one\.component/,
|
||||
/ssg\.component/,
|
||||
],
|
||||
},
|
||||
'/ssr': {
|
||||
matches: [/<link rel="modulepreload" href="(ssr\.component-[a-zA-Z0-9]{8}\.js)">/],
|
||||
notMatches: [/home\.component/, /ssg\.component/, /csr\.component/],
|
||||
},
|
||||
'/csr': {
|
||||
matches: [/<link rel="modulepreload" href="(csr\.component-[a-zA-Z0-9]{8}\.js)">/],
|
||||
notMatches: [/home\.component/, /ssg\.component/, /ssr\.component/, /cross-dep-/],
|
||||
},
|
||||
};
|
||||
|
||||
async function runTests(port: number): Promise<void> {
|
||||
for (const [pathname, { matches, notMatches }] of Object.entries(RESPONSE_EXPECTS)) {
|
||||
const res = await fetch(`http://localhost:${port}${pathname}`);
|
||||
const text = await res.text();
|
||||
|
||||
for (const match of matches) {
|
||||
assert.match(text, match, `Response for '${pathname}': ${match} was not matched in content.`);
|
||||
|
||||
// Ensure that the url is correct and it's a 200.
|
||||
const link = text.match(match)?.[1];
|
||||
const preloadRes = await fetch(`http://localhost:${port}/${link}`);
|
||||
assert.equal(preloadRes.status, 200);
|
||||
}
|
||||
|
||||
for (const match of notMatches) {
|
||||
assert.doesNotMatch(
|
||||
text,
|
||||
match,
|
||||
`Response for '${pathname}': ${match} was matched in content.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function spawnServer(): Promise<number> {
|
||||
const port = await findFreePort();
|
||||
await execAndWaitForOutputToMatch(
|
||||
'npm',
|
||||
['run', 'serve:ssr:test-project'],
|
||||
/Node Express server listening on/,
|
||||
{
|
||||
'PORT': String(port),
|
||||
},
|
||||
);
|
||||
|
||||
return port;
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user