From 9f1c146c4ac71e1a9c4e05eed04d6bf2b79699ed Mon Sep 17 00:00:00 2001 From: ChristopherPHolder Date: Fri, 23 Oct 2026 22:56:17 +0200 Subject: [PATCH] perf(@angular/build): bundle a single copy of domino for the server `@angular/platform-server/init` (bundled into the server polyfills) and `@angular/platform-server` (bundled into the main server bundle) both import the domino DOM implementation shipped with the package. As the two bundles are separate esbuild contexts, each output contained its own copy, and both were evaluated at runtime. Besides the size and memory cost, the two copies are distinct class hierarchies: DOM globals captured at module evaluation time (the scenario `init` exists for) come from the polyfills copy, while the platform renders with the other, so `instanceof` checks between them fail. The server polyfills bundle now re-exports the domino module it already contains, and the main server bundle redirects the `@angular/platform-server` import of domino to that export. The polyfills bundle is already imported first and marked external by every server bundle, which keeps the evaluation order unchanged. Fixes #34514 --- .../behavior/platform-server-domino_spec.ts | 92 ++++++++++++ .../tools/esbuild/application-code-bundle.ts | 23 ++- .../esbuild/platform-server-domino-plugin.ts | 136 ++++++++++++++++++ 3 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 packages/angular/build/src/builders/application/tests/behavior/platform-server-domino_spec.ts create mode 100644 packages/angular/build/src/tools/esbuild/platform-server-domino-plugin.ts diff --git a/packages/angular/build/src/builders/application/tests/behavior/platform-server-domino_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/platform-server-domino_spec.ts new file mode 100644 index 000000000000..14d3915e491a --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/platform-server-domino_spec.ts @@ -0,0 +1,92 @@ +/** + * @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 { buildApplication } from '../../index'; +import { OutputMode, Platform } from '../../schema'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +/** + * `@angular/platform-server/init` (bundled into the server polyfills) and + * `@angular/platform-server` (bundled into the main server bundle) both import this file. + */ +const DOMINO_BUNDLE = 'third_party/domino/bundled-domino.mjs'; + +/** The export of the server polyfills bundle. The output may escape non-ASCII identifiers. */ +const DOMINO_EXPORT = '(?:ɵ|\\\\u0275)domino'; +const POLYFILLS_EXPORT_RE = new RegExp(`export \\{[^}]*${DOMINO_EXPORT}`); +const MAIN_IMPORT_RE = new RegExp( + `import \\{\\s*${DOMINO_EXPORT}\\s*\\} from "\\./polyfills\\.server\\.mjs"`, +); + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "Domino is bundled once for the server"', () => { + beforeEach(async () => { + await harness.modifyFile('src/tsconfig.app.json', (content) => { + const tsConfig = JSON.parse(content); + tsConfig.files ??= []; + tsConfig.files.push('main.server.ts', 'server.ts'); + + return JSON.stringify(tsConfig); + }); + + await harness.writeFile('src/server.ts', `console.log('Hello!');`); + + // The default template uses i18n which requires `@angular/localize` to prerender. + await harness.writeFile('src/app/app.component.html', `

Hello!

`); + }); + + it('shares the server polyfills copy of Domino with the main server bundle', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + server: 'src/main.server.ts', + ssr: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/server/polyfills.server.mjs').content.toContain(DOMINO_BUNDLE); + harness.expectFile('dist/server/polyfills.server.mjs').content.toMatch(POLYFILLS_EXPORT_RE); + + harness.expectFile('dist/server/main.server.mjs').content.not.toContain(DOMINO_BUNDLE); + harness.expectFile('dist/server/main.server.mjs').content.toMatch(MAIN_IMPORT_RE); + }); + + it('shares the server polyfills copy of Domino when "outputMode" is "server"', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + server: 'src/main.server.ts', + ssr: { entry: 'src/server.ts' }, + outputMode: OutputMode.Server, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/server/polyfills.server.mjs').content.toContain(DOMINO_BUNDLE); + harness.expectFile('dist/server/main.server.mjs').content.not.toContain(DOMINO_BUNDLE); + harness.expectFile('dist/server/main.server.mjs').content.toMatch(MAIN_IMPORT_RE); + harness.expectFile('dist/server/server.mjs').content.not.toContain(DOMINO_BUNDLE); + }); + + it('shares the server polyfills copy of Domino when "ssr.platform" is "neutral"', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + server: 'src/main.server.ts', + ssr: { entry: 'src/server.ts', platform: Platform.Neutral }, + outputMode: OutputMode.Server, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/server/polyfills.server.mjs').content.toContain(DOMINO_BUNDLE); + harness.expectFile('dist/server/main.server.mjs').content.not.toContain(DOMINO_BUNDLE); + }); + }); +}); diff --git a/packages/angular/build/src/tools/esbuild/application-code-bundle.ts b/packages/angular/build/src/tools/esbuild/application-code-bundle.ts index c98ad3bcae28..e362327848ab 100644 --- a/packages/angular/build/src/tools/esbuild/application-code-bundle.ts +++ b/packages/angular/build/src/tools/esbuild/application-code-bundle.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import type { BuildOptions, Plugin } from 'esbuild'; +import type { BuildOptions, Plugin, PluginBuild } from 'esbuild'; import assert from 'node:assert'; import { extname, relative } from 'node:path'; import type { NormalizedApplicationBuildOptions } from '../../builders/application/options'; @@ -30,6 +30,11 @@ import { createExternalPackagesPlugin } from './external-packages-plugin'; import { createAngularLocaleDataPlugin } from './i18n-locale-plugin'; import type { LoadResultCache } from './load-result-cache'; import { createLoaderImportAttributePlugin } from './loader-import-attribute-plugin'; +import { + createPlatformServerDominoExport, + createPlatformServerDominoPlugin, + resolvePlatformServerDomino, +} from './platform-server-domino-plugin'; import { createRxjsEsmResolutionPlugin } from './rxjs-esm-resolution-plugin'; import { createServerBundleMetadata } from './server-bundle-metadata-plugin'; import { createSourcemapIgnorelistPlugin } from './sourcemap-ignorelist-plugin'; @@ -192,6 +197,13 @@ export function createServerPolyfillBundleOptions( namespace, false, loadResultCache, + // Export the Domino copy bundled via `@angular/platform-server/init` so that the other + // server bundles can share it instead of bundling their own copy. + async (build) => { + const dominoPath = await resolvePlatformServerDomino(build, options.workspaceRoot); + + return dominoPath && createPlatformServerDominoExport(dominoPath); + }, ); if (!polyfillBundleOptions) { @@ -308,6 +320,9 @@ export function createServerMainCodeBundleOptions( buildOptions.plugins.push(createRxjsEsmResolutionPlugin()); } + // Use the Domino copy exported by the server polyfills bundle instead of bundling another copy. + buildOptions.plugins.push(createPlatformServerDominoPlugin(workspaceRoot)); + // Mark manifest and polyfills file as external as these are generated by a different bundle step. (buildOptions.external ??= []).push(...SERVER_GENERATED_EXTERNALS); const isNodePlatform = options.ssrOptions?.platform !== Platform.Neutral; @@ -677,6 +692,7 @@ function getEsBuildCommonPolyfillsOptions( namespace: string, tryToResolvePolyfillsAsRelative: boolean, loadResultCache: LoadResultCache | undefined, + additionalContent?: (build: PluginBuild) => Promise, ): BuildOptions | undefined { const { jit, workspaceRoot, i18nOptions, externalPackages } = options; @@ -753,6 +769,11 @@ function getEsBuildCommonPolyfillsOptions( contents += `(globalThis.$localize ??= {}).locale = "${i18nOptions.sourceLocale}";\n`; } + const extraContent = await additionalContent?.(build); + if (extraContent) { + contents += '\n' + extraContent; + } + return { contents, loader: 'js', diff --git a/packages/angular/build/src/tools/esbuild/platform-server-domino-plugin.ts b/packages/angular/build/src/tools/esbuild/platform-server-domino-plugin.ts new file mode 100644 index 000000000000..dc060b3cc180 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/platform-server-domino-plugin.ts @@ -0,0 +1,136 @@ +/** + * @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 type { Plugin, PluginBuild } from 'esbuild'; +import { access } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import { toPosixPath } from '../../utils/path'; + +/** + * The name of the export in the server polyfills bundle that provides the Domino DOM + * implementation to the other server bundles. + */ +const SERVER_POLYFILLS_DOMINO_EXPORT = 'ɵdomino'; + +/** + * The server polyfills bundle filename. Other server bundles import Domino from this file. + */ +const SERVER_POLYFILLS_FILENAME = './polyfills.server.mjs'; + +/** + * The path of the Domino bundle within the `@angular/platform-server` package. + * `@angular/platform-server/init` and `@angular/platform-server` both import this file. + */ +const DOMINO_PACKAGE_PATH = 'third_party/domino/bundled-domino.mjs'; + +/** + * The import specifier used by the `@angular/platform-server` entry points to import Domino. + */ +const DOMINO_IMPORT_SPECIFIER = '../' + DOMINO_PACKAGE_PATH; + +const DOMINO_NAMESPACE = 'angular:platform-server-domino'; + +/** + * Resolves the path of the Domino bundle within the `@angular/platform-server` package + * that is used by the application. + * + * `@angular/platform-server/init` (bundled into the server polyfills) and + * `@angular/platform-server` (bundled into the main server bundle) both contain a copy of + * Domino when bundled separately. The server polyfills bundle exports its copy via + * `SERVER_POLYFILLS_DOMINO_EXPORT` so that the other server bundles can share it instead of + * bundling their own. + * + * @param build The esbuild plugin build used to resolve the package. + * @param workspaceRoot The root directory of the workspace. + * @returns The absolute path to the Domino bundle, or `undefined` if it cannot be shared. + */ +export async function resolvePlatformServerDomino( + build: PluginBuild, + workspaceRoot: string, +): Promise { + const result = await build.resolve('@angular/platform-server/package.json', { + kind: 'import-statement', + resolveDir: workspaceRoot, + }); + + // The package is not installed or will not be bundled. + if (result.errors.length > 0 || result.external || !result.path) { + return undefined; + } + + const dominoPath = join(dirname(result.path), DOMINO_PACKAGE_PATH); + try { + await access(dominoPath); + } catch { + // Unexpected package layout. Each bundle will contain its own copy of Domino. + return undefined; + } + + return dominoPath; +} + +/** + * Creates the module content that re-exports Domino from the server polyfills bundle. + * + * @param dominoPath The absolute path to the Domino bundle. + * @returns The module content. + */ +export function createPlatformServerDominoExport(dominoPath: string): string { + const specifier = JSON.stringify(toPosixPath(dominoPath)); + + return `export { default as ${SERVER_POLYFILLS_DOMINO_EXPORT} } from ${specifier};`; +} + +/** + * Creates a plugin that redirects the Domino import of `@angular/platform-server` to the + * copy exported by the server polyfills bundle. + * + * The server polyfills bundle is imported by every server bundle before any other code and + * is marked as external. This ensures a single copy of Domino is loaded at runtime, which + * also keeps the DOM globals provided by `@angular/platform-server/init` consistent with the + * DOM implementation used by the server platform. + * + * @param workspaceRoot The root directory of the workspace. + * @returns An esbuild plugin. + */ +export function createPlatformServerDominoPlugin(workspaceRoot: string): Plugin { + return { + name: 'angular-platform-server-domino', + setup(build) { + let sharedDominoPath: Promise | undefined; + + build.onResolve({ filter: /bundled-domino\.mjs$/ }, async (args) => { + if (args.path !== DOMINO_IMPORT_SPECIFIER || args.kind !== 'import-statement') { + return null; + } + + sharedDominoPath ??= resolvePlatformServerDomino(build, workspaceRoot); + const dominoPath = await sharedDominoPath; + + // Only redirect imports of the exact file exported by the server polyfills bundle. + if (!dominoPath || resolve(args.resolveDir, args.path) !== dominoPath) { + return null; + } + + // The path must not look like a JavaScript file to avoid other plugins loading it. + return { + path: DOMINO_NAMESPACE, + namespace: DOMINO_NAMESPACE, + }; + }); + + build.onLoad({ filter: /./, namespace: DOMINO_NAMESPACE }, () => ({ + contents: + `export { ${SERVER_POLYFILLS_DOMINO_EXPORT} as default } ` + + `from '${SERVER_POLYFILLS_FILENAME}';`, + loader: 'js', + resolveDir: workspaceRoot, + })); + }, + }; +}