From 8d7a51dfc9658aa2f0f0c527435c05c2b10f34e5 Mon Sep 17 00:00:00 2001 From: Alan Agius Date: Tue, 10 Dec 2024 10:59:55 +0000 Subject: [PATCH] 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 --- packages/angular/build/BUILD.bazel | 1 + .../src/builders/application/execute-build.ts | 3 +- .../application/execute-post-bundle.ts | 11 + .../build/src/builders/application/i18n.ts | 4 + .../angular/compilation/aot-compilation.ts | 11 +- .../src/tools/angular/compilation/factory.ts | 12 +- .../angular/compilation/jit-compilation.ts | 13 +- .../compilation/parallel-compilation.ts | 6 +- .../angular/compilation/parallel-worker.ts | 5 +- .../transformers/lazy-routes-transformer.ts | 225 ++++++++++++++++++ .../lazy-routes-transformer_spec.ts | 208 ++++++++++++++++ .../tools/esbuild/angular/compiler-plugin.ts | 3 +- .../tools/esbuild/compiler-plugin-options.ts | 1 + .../src/utils/server-rendering/manifest.ts | 68 ++++++ packages/angular/ssr/src/app.ts | 46 +++- packages/angular/ssr/src/manifest.ts | 20 ++ packages/angular/ssr/src/routes/ng-routes.ts | 74 +++++- packages/angular/ssr/src/routes/route-tree.ts | 5 + packages/angular/ssr/test/testing-utils.ts | 2 +- .../server-routes-preload-links.ts | 223 +++++++++++++++++ 20 files changed, 921 insertions(+), 20 deletions(-) create mode 100644 packages/angular/build/src/tools/angular/transformers/lazy-routes-transformer.ts create mode 100644 packages/angular/build/src/tools/angular/transformers/lazy-routes-transformer_spec.ts create mode 100644 tests/legacy-cli/e2e/tests/build/server-rendering/server-routes-preload-links.ts diff --git a/packages/angular/build/BUILD.bazel b/packages/angular/build/BUILD.bazel index 5f8cb394d6f6..1aa140f21887 100644 --- a/packages/angular/build/BUILD.bazel +++ b/packages/angular/build/BUILD.bazel @@ -127,6 +127,7 @@ ts_library( "@npm//@angular/compiler-cli", "@npm//@babel/core", "@npm//prettier", + "@npm//typescript", ], ) diff --git a/packages/angular/build/src/builders/application/execute-build.ts b/packages/angular/build/src/builders/application/execute-build.ts index b5ca83a76405..3da1b3721f11 100644 --- a/packages/angular/build/src/builders/application/execute-build.ts +++ b/packages/angular/build/src/builders/application/execute-build.ts @@ -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, diff --git a/packages/angular/build/src/builders/application/execute-post-bundle.ts b/packages/angular/build/src/builders/application/execute-post-bundle.ts index 31b4a9d2e97c..5066d5aaca01 100644 --- a/packages/angular/build/src/builders/application/execute-post-bundle.ts +++ b/packages/angular/build/src/builders/application/execute-post-bundle.ts @@ -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) { diff --git a/packages/angular/build/src/builders/application/i18n.ts b/packages/angular/build/src/builders/application/i18n.ts index 101956f6319a..f526286e35a7 100644 --- a/packages/angular/build/src/builders/application/i18n.ts +++ b/packages/angular/build/src/builders/application/i18n.ts @@ -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, @@ -79,6 +82,7 @@ export async function inlineI18n( additionalOutputFiles, prerenderedRoutes: generatedRoutes, } = await executePostBundleSteps( + metafile, { ...options, baseHref: getLocaleBaseHref(baseHref, i18nOptions, locale) ?? baseHref, diff --git a/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts b/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts index f111224c36d8..a807e0ea791d 100644 --- a/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts +++ b/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts @@ -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) { diff --git a/packages/angular/build/src/tools/angular/compilation/factory.ts b/packages/angular/build/src/tools/angular/compilation/factory.ts index 5984b4815f6a..91447dea24cf 100644 --- a/packages/angular/build/src/tools/angular/compilation/factory.ts +++ b/packages/angular/build/src/tools/angular/compilation/factory.ts @@ -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 { +export async function createAngularCompilation( + jit: boolean, + browserOnlyBuild: boolean, +): Promise { 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); } } diff --git a/packages/angular/build/src/tools/angular/compilation/jit-compilation.ts b/packages/angular/build/src/tools/angular/compilation/jit-compilation.ts index db2de81b4ae7..a811cb50ec0a 100644 --- a/packages/angular/build/src/tools/angular/compilation/jit-compilation.ts +++ b/packages/angular/build/src/tools/angular/compilation/jit-compilation.ts @@ -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) diff --git a/packages/angular/build/src/tools/angular/compilation/parallel-compilation.ts b/packages/angular/build/src/tools/angular/compilation/parallel-compilation.ts index f3b3503f6988..be612cbfcad4 100644 --- a/packages/angular/build/src/tools/angular/compilation/parallel-compilation.ts +++ b/packages/angular/build/src/tools/angular/compilation/parallel-compilation.ts @@ -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, diff --git a/packages/angular/build/src/tools/angular/compilation/parallel-worker.ts b/packages/angular/build/src/tools/angular/compilation/parallel-worker.ts index 2669951c12e4..d67fbb9bd06c 100644 --- a/packages/angular/build/src/tools/angular/compilation/parallel-worker.ts +++ b/packages/angular/build/src/tools/angular/compilation/parallel-worker.ts @@ -17,6 +17,7 @@ import { JitCompilation } from './jit-compilation'; export interface InitRequest { jit: boolean; + browserOnlyBuild: boolean; tsconfig: string; fileReplacements?: Record; 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 void, (reason: Error) => void]>(); request.stylesheetPort.on('message', ({ requestId, value, error }) => { diff --git a/packages/angular/build/src/tools/angular/transformers/lazy-routes-transformer.ts b/packages/angular/build/src/tools/angular/transformers/lazy-routes-transformer.ts new file mode 100644 index 000000000000..10d45d00d714 --- /dev/null +++ b/packages/angular/build/src/tools/angular/transformers/lazy-routes-transformer.ts @@ -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 { + 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; +} diff --git a/packages/angular/build/src/tools/angular/transformers/lazy-routes-transformer_spec.ts b/packages/angular/build/src/tools/angular/transformers/lazy-routes-transformer_spec.ts new file mode 100644 index 000000000000..4dd388f28eb1 --- /dev/null +++ b/packages/angular/build/src/tools/angular/transformers/lazy-routes-transformer_spec.ts @@ -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" } : {})`, + ); + }); +}); diff --git a/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts b/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts index 0e3e3fa8e0fb..740c2d119b5a 100644 --- a/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts +++ b/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.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; diff --git a/packages/angular/build/src/tools/esbuild/compiler-plugin-options.ts b/packages/angular/build/src/tools/esbuild/compiler-plugin-options.ts index 355bbad228ff..93a92fa7e9a1 100644 --- a/packages/angular/build/src/tools/esbuild/compiler-plugin-options.ts +++ b/packages/angular/build/src/tools/esbuild/compiler-plugin-options.ts @@ -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, diff --git a/packages/angular/build/src/utils/server-rendering/manifest.ts b/packages/angular/build/src/utils/server-rendering/manifest.ts index ca2886f11d89..bdd7fdd28df9 100644 --- a/packages/angular/build/src/utils/server-rendering/manifest.ts +++ b/packages/angular/build/src/utils/server-rendering/manifest.ts @@ -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, + 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, + publicPath = '', +): Record { + const entryPointToBundles: Record = {}; + 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; +} diff --git a/packages/angular/ssr/src/app.ts b/packages/angular/ssr/src/app.ts index 08d5ca13a180..85f1ca9818ad 100644 --- a/packages/angular/ssr/src/app.ts +++ b/packages/angular/ssr/src/app.ts @@ -251,7 +251,7 @@ export class AngularServerApp { matchedRoute: RouteTreeNodeMetadata, requestContext?: unknown, ): Promise { - 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 { + private async runTransformsOnHtml( + html: string, + url: URL, + preload: readonly string[] | undefined, + ): Promise { 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 `` 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 `` tag. + * If `` is not found, the links are not added. + */ +function appendPreloadHintsToHtml(html: string, preload: readonly string[]): string { + const bodyCloseIdx = html.lastIndexOf(''); + 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) => ``), + html.slice(bodyCloseIdx), + ].join('\n'); +} diff --git a/packages/angular/ssr/src/manifest.ts b/packages/angular/ssr/src/manifest.ts index 2c0d642ec2ae..f37ca613314b 100644 --- a/packages/angular/ssr/src/manifest.ts +++ b/packages/angular/ssr/src/manifest.ts @@ -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 | undefined> + >; } /** diff --git a/packages/angular/ssr/src/routes/ng-routes.ts b/packages/angular/ssr/src/routes/ng-routes.ts index 1f4f7c7d5613..be91636ccd25 100644 --- a/packages/angular/ssr/src/routes/ng-routes.ts +++ b/packages/angular/ssr/src/routes/ng-routes.ts @@ -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 | undefined; invokeGetPrerenderParams: boolean; includePrerenderFallbackRoutes: boolean; + entryPointToBrowserMapping: EntryPointToBrowserMapping | undefined; + parentPreloads?: readonly string[]; }): AsyncIterableIterator { 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 { 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) { diff --git a/packages/angular/ssr/src/routes/route-tree.ts b/packages/angular/ssr/src/routes/route-tree.ts index 85b1279aaee5..ba79688aa3c6 100644 --- a/packages/angular/ssr/src/routes/route-tree.ts +++ b/packages/angular/ssr/src/routes/route-tree.ts @@ -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[]; } /** diff --git a/packages/angular/ssr/test/testing-utils.ts b/packages/angular/ssr/test/testing-utils.ts index dd18ea29b516..f872487b06d8 100644 --- a/packages/angular/ssr/test/testing-utils.ts +++ b/packages/angular/ssr/test/testing-utils.ts @@ -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. diff --git a/tests/legacy-cli/e2e/tests/build/server-rendering/server-routes-preload-links.ts b/tests/legacy-cli/e2e/tests/build/server-rendering/server-routes-preload-links.ts new file mode 100644 index 000000000000..77670e5eb64d --- /dev/null +++ b/tests/legacy-cli/e2e/tests/build/server-rendering/server-routes-preload-links.ts @@ -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: [//], + notMatches: [/ssg\.component/, /ssr\.component/, /csr\.component/, /cross-dep-/], + }, + '/ssg': { + matches: [ + //, + //, + ], + notMatches: [ + /home\.component/, + /ssr\.component/, + /csr\.component/, + /ssg-one\.component/, + /ssg-two\.component/, + /cross-dep-/, + ], + }, + '/ssg/one': { + matches: [ + //, + //, + //, + ], + notMatches: [ + /home\.component/, + /ssr\.component/, + /csr\.component/, + /ssg-two\.component/, + /ssg\.component/, + ], + }, + '/ssg/two': { + matches: [ + //, + //, + //, + ], + notMatches: [ + /home\.component/, + /ssr\.component/, + /csr\.component/, + /ssg-one\.component/, + /ssg\.component/, + ], + }, + '/ssr': { + matches: [//], + notMatches: [/home\.component/, /ssg\.component/, /csr\.component/], + }, + '/csr': { + matches: [//], + notMatches: [/home\.component/, /ssg\.component/, /ssr\.component/, /cross-dep-/], + }, +}; + +async function runTests(port: number): Promise { + 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 { + const port = await findFreePort(); + await execAndWaitForOutputToMatch( + 'npm', + ['run', 'serve:ssr:test-project'], + /Node Express server listening on/, + { + 'PORT': String(port), + }, + ); + + return port; +}