From 9545676bec661e441b770c2523819e052e72a12f Mon Sep 17 00:00:00 2001 From: Troy Steuwer Date: Sun, 1 Mar 2026 18:13:39 -0500 Subject: [PATCH 01/12] feat(@angular/build): Support splitting browser and server stats json files for easier consumption This feature supports splitting out the browser and server stats json files so it's easier to inspect the bundle in various analyzers. Today, everything gets dumped into a single file and it's nearly impossible to use without hours of fix -> remove unused browser chunks -> analyze and starting the loop all over again. This feature implements the feature request I made in #28185, along with another developers request to see a stats json file for just the initial page bundle. I've tested this out in my own repository and it's already helped an incredible amount. Fixes #28185 #28671 --- .../src/builders/application/execute-build.ts | 16 +++++-- .../src/builders/application/schema.json | 2 +- .../angular/build/src/tools/esbuild/utils.ts | 44 +++++++++++++++++++ .../src/builders/browser-esbuild/schema.json | 2 +- .../src/builders/browser/schema.json | 2 +- .../builders/browser/specs/stats-json_spec.ts | 6 +-- .../src/builders/server/schema.json | 2 +- .../src/tools/webpack/configs/common.ts | 7 ++- 8 files changed, 67 insertions(+), 14 deletions(-) diff --git a/packages/angular/build/src/builders/application/execute-build.ts b/packages/angular/build/src/builders/application/execute-build.ts index aaddc5b6ef7e..d844ab0814c6 100644 --- a/packages/angular/build/src/builders/application/execute-build.ts +++ b/packages/angular/build/src/builders/application/execute-build.ts @@ -20,6 +20,7 @@ import { checkCommonJSModules } from '../../tools/esbuild/commonjs-checker'; import { extractLicenses } from '../../tools/esbuild/license-extractor'; import { profileAsync } from '../../tools/esbuild/profiling'; import { + buildMetafileForType, calculateEstimatedTransferSizes, logBuildStats, transformSupportedBrowsersToTargets, @@ -301,13 +302,22 @@ export async function executeBuild( BuildOutputFileType.Root, ); + const ssrOutputEnabled: boolean = !!ssrOptions; + // Write metafile if stats option is enabled if (options.stats) { executionResult.addOutputFile( - 'stats.json', - JSON.stringify(metafile, null, 2), + 'browser-stats.json', + JSON.stringify(buildMetafileForType(metafile, 'browser', outputFiles), null, 2), BuildOutputFileType.Root, ); + if (ssrOutputEnabled) { + executionResult.addOutputFile( + 'server-stats.json', + JSON.stringify(buildMetafileForType(metafile, 'server', outputFiles), null, 2), + BuildOutputFileType.Root, + ); + } } if (!jsonLogs) { @@ -322,7 +332,7 @@ export async function executeBuild( colors, changedFiles, estimatedTransferSizes, - !!ssrOptions, + ssrOutputEnabled, verbose, ), ); diff --git a/packages/angular/build/src/builders/application/schema.json b/packages/angular/build/src/builders/application/schema.json index 5498a21fe004..76203437650b 100644 --- a/packages/angular/build/src/builders/application/schema.json +++ b/packages/angular/build/src/builders/application/schema.json @@ -535,7 +535,7 @@ }, "statsJson": { "type": "boolean", - "description": "Generates a 'stats.json' file which can be analyzed with https://esbuild.github.io/analyze/.", + "description": "Generates a 'browser-stats.json' (and 'server-stats.json' when SSR is enabled) file which can be analyzed with https://esbuild.github.io/analyze/.", "default": false }, "budgets": { diff --git a/packages/angular/build/src/tools/esbuild/utils.ts b/packages/angular/build/src/tools/esbuild/utils.ts index 2730dafae97c..9132822fda28 100644 --- a/packages/angular/build/src/tools/esbuild/utils.ts +++ b/packages/angular/build/src/tools/esbuild/utils.ts @@ -29,6 +29,50 @@ import { PrerenderedRoutesRecord, } from './bundler-execution-result'; +export function buildMetafileForType( + metafile: Metafile, + type: 'browser' | 'server', + outputFiles: BuildOutputFile[], +): Metafile { + const outputPathsForType = new Set( + outputFiles + .filter(({ type: fileType }) => { + const isServerFile = + fileType === BuildOutputFileType.ServerApplication || + fileType === BuildOutputFileType.ServerRoot; + + return type === 'server' ? isServerFile : !isServerFile; + }) + .map(({ path }) => path), + ); + + const filteredOutputs: Metafile['outputs'] = {}; + for (const [outputPath, output] of Object.entries(metafile.outputs)) { + if (outputPathsForType.has(outputPath)) { + filteredOutputs[outputPath] = output; + } + } + + const referencedInputs = new Set(); + for (const output of Object.values(filteredOutputs)) { + for (const inputPath of Object.keys(output.inputs)) { + referencedInputs.add(inputPath); + } + } + + const filteredInputs: Metafile['inputs'] = {}; + for (const [inputPath, input] of Object.entries(metafile.inputs)) { + if (referencedInputs.has(inputPath)) { + filteredInputs[inputPath] = input; + } + } + + return { + inputs: filteredInputs, + outputs: filteredOutputs, + }; +} + export function logBuildStats( metafile: Metafile, outputFiles: BuildOutputFile[], diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/schema.json b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/schema.json index edb91222d954..5db2b753d7bf 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/schema.json +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/schema.json @@ -406,7 +406,7 @@ }, "statsJson": { "type": "boolean", - "description": "Generates a 'stats.json' file which can be analyzed using tools such as 'webpack-bundle-analyzer'.", + "description": "Generates a 'browser-stats.json' file which can be analyzed using tools such as 'webpack-bundle-analyzer'.", "default": false }, "budgets": { diff --git a/packages/angular_devkit/build_angular/src/builders/browser/schema.json b/packages/angular_devkit/build_angular/src/builders/browser/schema.json index 301eeafcc4f1..bf73adeabb29 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser/schema.json +++ b/packages/angular_devkit/build_angular/src/builders/browser/schema.json @@ -394,7 +394,7 @@ }, "statsJson": { "type": "boolean", - "description": "Generates a 'stats.json' file which can be analyzed using tools such as 'webpack-bundle-analyzer'.", + "description": "Generates a 'browser-stats.json' (and 'server-stats.json' when SSR is enabled) file which can be analyzed using tools such as 'webpack-bundle-analyzer'.", "default": false }, "budgets": { diff --git a/packages/angular_devkit/build_angular/src/builders/browser/specs/stats-json_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/specs/stats-json_spec.ts index 175b61d7ca12..2bbe9e52e72d 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser/specs/stats-json_spec.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser/specs/stats-json_spec.ts @@ -21,13 +21,13 @@ describe('Browser Builder stats json', () => { it('works', async () => { const { files } = await browserBuild(architect, host, target, { statsJson: true }); - expect('stats.json' in files).toBe(true); + expect('browser-stats.json' in files).toBe(true); }); it('works with profile flag', async () => { const { files } = await browserBuild(architect, host, target, { statsJson: true }); - expect('stats.json' in files).toBe(true); - const stats = JSON.parse(await files['stats.json']); + expect('browser-stats.json' in files).toBe(true); + const stats = JSON.parse(await files['browser-stats.json']); expect(stats.chunks[0].modules[0].profile.building).toBeDefined(); }); }); diff --git a/packages/angular_devkit/build_angular/src/builders/server/schema.json b/packages/angular_devkit/build_angular/src/builders/server/schema.json index 2375e1176add..99115adb333c 100644 --- a/packages/angular_devkit/build_angular/src/builders/server/schema.json +++ b/packages/angular_devkit/build_angular/src/builders/server/schema.json @@ -208,7 +208,7 @@ }, "statsJson": { "type": "boolean", - "description": "Generates a 'stats.json' file which can be analyzed using tools such as 'webpack-bundle-analyzer'.", + "description": "Generates a 'browser-stats.json' and 'server-stats.json' file which can be analyzed using tools such as 'webpack-bundle-analyzer'.", "default": false }, "watch": { diff --git a/packages/angular_devkit/build_angular/src/tools/webpack/configs/common.ts b/packages/angular_devkit/build_angular/src/tools/webpack/configs/common.ts index b18c8ebc9be9..8375751e9403 100644 --- a/packages/angular_devkit/build_angular/src/tools/webpack/configs/common.ts +++ b/packages/angular_devkit/build_angular/src/tools/webpack/configs/common.ts @@ -83,9 +83,8 @@ export async function getCommonConfig(wco: WebpackConfigOptions): Promise Date: Sun, 1 Mar 2026 19:16:38 -0500 Subject: [PATCH 02/12] fix(@angular/build): Fixing the missing browser initial stats file that was in my main branch off of @angular/angular-cli repository This addresses an issue of including the (browser|server)-initial-stats.json file that was in my original repository. It also fixes unit tests that were also addressed in the original repo. --- .../src/builders/application/execute-build.ts | 22 +++++++++++++++++-- .../angular/build/src/tools/esbuild/utils.ts | 14 +++++++++--- .../browser/tests/options/stats-json_spec.ts | 20 ++++++++++++----- .../src/tools/webpack/configs/common.ts | 3 +++ 4 files changed, 48 insertions(+), 11 deletions(-) diff --git a/packages/angular/build/src/builders/application/execute-build.ts b/packages/angular/build/src/builders/application/execute-build.ts index d844ab0814c6..62f8d948566d 100644 --- a/packages/angular/build/src/builders/application/execute-build.ts +++ b/packages/angular/build/src/builders/application/execute-build.ts @@ -308,13 +308,31 @@ export async function executeBuild( if (options.stats) { executionResult.addOutputFile( 'browser-stats.json', - JSON.stringify(buildMetafileForType(metafile, 'browser', outputFiles), null, 2), + JSON.stringify(buildMetafileForType(metafile, 'browser', false, outputFiles), null, 2), + BuildOutputFileType.Root, + ); + executionResult.addOutputFile( + 'browser-initial-stats.json', + JSON.stringify( + buildMetafileForType(metafile, 'browser', true, outputFiles, initialFiles), + null, + 2, + ), BuildOutputFileType.Root, ); if (ssrOutputEnabled) { executionResult.addOutputFile( 'server-stats.json', - JSON.stringify(buildMetafileForType(metafile, 'server', outputFiles), null, 2), + JSON.stringify(buildMetafileForType(metafile, 'server', false, outputFiles), null, 2), + BuildOutputFileType.Root, + ); + executionResult.addOutputFile( + 'server-initial-stats.json', + JSON.stringify( + buildMetafileForType(metafile, 'server', true, outputFiles, initialFiles), + null, + 2, + ), BuildOutputFileType.Root, ); } diff --git a/packages/angular/build/src/tools/esbuild/utils.ts b/packages/angular/build/src/tools/esbuild/utils.ts index 9132822fda28..c27fda8f275a 100644 --- a/packages/angular/build/src/tools/esbuild/utils.ts +++ b/packages/angular/build/src/tools/esbuild/utils.ts @@ -32,8 +32,12 @@ import { export function buildMetafileForType( metafile: Metafile, type: 'browser' | 'server', + initial: boolean, outputFiles: BuildOutputFile[], + initialFiles?: Map, ): Metafile { + const isServer = type === 'server'; + const outputPathsForType = new Set( outputFiles .filter(({ type: fileType }) => { @@ -41,16 +45,20 @@ export function buildMetafileForType( fileType === BuildOutputFileType.ServerApplication || fileType === BuildOutputFileType.ServerRoot; - return type === 'server' ? isServerFile : !isServerFile; + return isServer ? isServerFile : !isServerFile; }) .map(({ path }) => path), ); const filteredOutputs: Metafile['outputs'] = {}; for (const [outputPath, output] of Object.entries(metafile.outputs)) { - if (outputPathsForType.has(outputPath)) { - filteredOutputs[outputPath] = output; + if (!outputPathsForType.has(outputPath)) { + continue; + } + if (initial && !initialFiles?.has(outputPath)) { + continue; } + filteredOutputs[outputPath] = output; } const referencedInputs = new Set(); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/tests/options/stats-json_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/tests/options/stats-json_spec.ts index 609e21fcef5a..2a901997e333 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser/tests/options/stats-json_spec.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser/tests/options/stats-json_spec.ts @@ -26,12 +26,18 @@ describeBuilder(buildWebpackBrowser, BROWSER_BUILDER_INFO, (harness) => { expect(result?.success).toBe(true); - if (harness.expectFile('dist/stats.json').toExist()) { - const content = harness.readFile('dist/stats.json'); + if (harness.expectFile('dist/browser-stats.json').toExist()) { + const content = harness.readFile('dist/browser-stats.json'); expect(() => JSON.parse(content)) .withContext('Expected Webpack Stats file to be valid JSON.') .not.toThrow(); } + if (harness.expectFile('dist/browser-initial-stats.json').toExist()) { + const initialContent = harness.readFile('dist/browser-initial-stats.json'); + expect(() => JSON.parse(initialContent)) + .withContext('Expected Webpack Stats file to be valid JSON.') + .not.toThrow(); + } }); // TODO: Investigate why this profiling object is no longer present in Webpack 5.90.3+ and if this should even be tested @@ -45,8 +51,8 @@ describeBuilder(buildWebpackBrowser, BROWSER_BUILDER_INFO, (harness) => { expect(result?.success).toBe(true); - if (harness.expectFile('dist/stats.json').toExist()) { - const stats = JSON.parse(harness.readFile('dist/stats.json')); + if (harness.expectFile('dist/browser-stats.json').toExist()) { + const stats = JSON.parse(harness.readFile('dist/browser-stats.json')); expect(stats?.chunks?.[0]?.modules?.[0]?.profile?.building).toBeDefined(); } }); @@ -61,7 +67,8 @@ describeBuilder(buildWebpackBrowser, BROWSER_BUILDER_INFO, (harness) => { expect(result?.success).toBe(true); - harness.expectFile('dist/stats.json').toNotExist(); + harness.expectFile('dist/browser-stats.json').toNotExist(); + harness.expectFile('dist/browser-initial-stats.json').toNotExist(); }); it('does not generate a Webpack Stats file in output when not present', async () => { @@ -73,7 +80,8 @@ describeBuilder(buildWebpackBrowser, BROWSER_BUILDER_INFO, (harness) => { expect(result?.success).toBe(true); - harness.expectFile('dist/stats.json').toNotExist(); + harness.expectFile('dist/browser-stats.json').toNotExist(); + harness.expectFile('dist/browser-initial-stats.json').toNotExist(); }); }); }); diff --git a/packages/angular_devkit/build_angular/src/tools/webpack/configs/common.ts b/packages/angular_devkit/build_angular/src/tools/webpack/configs/common.ts index 8375751e9403..37c7423c566d 100644 --- a/packages/angular_devkit/build_angular/src/tools/webpack/configs/common.ts +++ b/packages/angular_devkit/build_angular/src/tools/webpack/configs/common.ts @@ -245,6 +245,9 @@ export async function getCommonConfig(wco: WebpackConfigOptions): Promise Date: Sun, 12 Apr 2026 09:22:45 -0400 Subject: [PATCH 03/12] Reverting these changes per Alans recommendation --- packages/angular/build/src/builders/application/schema.json | 2 +- .../build_angular/src/builders/browser-esbuild/schema.json | 2 +- .../build_angular/src/builders/browser/schema.json | 2 +- .../build_angular/src/builders/server/schema.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/angular/build/src/builders/application/schema.json b/packages/angular/build/src/builders/application/schema.json index 76203437650b..5498a21fe004 100644 --- a/packages/angular/build/src/builders/application/schema.json +++ b/packages/angular/build/src/builders/application/schema.json @@ -535,7 +535,7 @@ }, "statsJson": { "type": "boolean", - "description": "Generates a 'browser-stats.json' (and 'server-stats.json' when SSR is enabled) file which can be analyzed with https://esbuild.github.io/analyze/.", + "description": "Generates a 'stats.json' file which can be analyzed with https://esbuild.github.io/analyze/.", "default": false }, "budgets": { diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/schema.json b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/schema.json index 5db2b753d7bf..edb91222d954 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/schema.json +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/schema.json @@ -406,7 +406,7 @@ }, "statsJson": { "type": "boolean", - "description": "Generates a 'browser-stats.json' file which can be analyzed using tools such as 'webpack-bundle-analyzer'.", + "description": "Generates a 'stats.json' file which can be analyzed using tools such as 'webpack-bundle-analyzer'.", "default": false }, "budgets": { diff --git a/packages/angular_devkit/build_angular/src/builders/browser/schema.json b/packages/angular_devkit/build_angular/src/builders/browser/schema.json index bf73adeabb29..301eeafcc4f1 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser/schema.json +++ b/packages/angular_devkit/build_angular/src/builders/browser/schema.json @@ -394,7 +394,7 @@ }, "statsJson": { "type": "boolean", - "description": "Generates a 'browser-stats.json' (and 'server-stats.json' when SSR is enabled) file which can be analyzed using tools such as 'webpack-bundle-analyzer'.", + "description": "Generates a 'stats.json' file which can be analyzed using tools such as 'webpack-bundle-analyzer'.", "default": false }, "budgets": { diff --git a/packages/angular_devkit/build_angular/src/builders/server/schema.json b/packages/angular_devkit/build_angular/src/builders/server/schema.json index 99115adb333c..2375e1176add 100644 --- a/packages/angular_devkit/build_angular/src/builders/server/schema.json +++ b/packages/angular_devkit/build_angular/src/builders/server/schema.json @@ -208,7 +208,7 @@ }, "statsJson": { "type": "boolean", - "description": "Generates a 'browser-stats.json' and 'server-stats.json' file which can be analyzed using tools such as 'webpack-bundle-analyzer'.", + "description": "Generates a 'stats.json' file which can be analyzed using tools such as 'webpack-bundle-analyzer'.", "default": false }, "watch": { From dd80e0f4a0ff4ade3b0b902747881005b26b83a5 Mon Sep 17 00:00:00 2001 From: Troy Steuwer Date: Sun, 12 Apr 2026 09:43:07 -0400 Subject: [PATCH 04/12] type(fix) Addressing Alans concerns and removing the function and keeping the bundles separate --- .../builders/application/chunk-optimizer.ts | 15 ++++++ .../src/builders/application/execute-build.ts | 49 +++++++++++------ .../esbuild/angular/component-stylesheets.ts | 1 + .../src/tools/esbuild/bundler-context.ts | 19 +++++++ .../angular/build/src/tools/esbuild/utils.ts | 52 ------------------- 5 files changed, 69 insertions(+), 67 deletions(-) diff --git a/packages/angular/build/src/builders/application/chunk-optimizer.ts b/packages/angular/build/src/builders/application/chunk-optimizer.ts index e6827479b784..bd83c5079671 100644 --- a/packages/angular/build/src/builders/application/chunk-optimizer.ts +++ b/packages/angular/build/src/builders/application/chunk-optimizer.ts @@ -285,6 +285,21 @@ export async function optimizeChunks( } original.metafile = newMetafile; + // Update the isolated browser metafile to reflect the optimized output. + // Server outputs are excluded since chunk optimization only affects browser bundles. + const serverOutputPaths = new Set(Object.keys(original.serverMetafile?.outputs ?? {})); + const browserOutputs: Metafile['outputs'] = {}; + const browserInputs: Metafile['inputs'] = {}; + for (const [path, output] of Object.entries(newMetafile.outputs)) { + if (!serverOutputPaths.has(path)) { + browserOutputs[path] = output; + for (const inputPath of Object.keys(output.inputs)) { + browserInputs[inputPath] = newMetafile.inputs[inputPath]; + } + } + } + original.browserMetafile = { inputs: browserInputs, outputs: browserOutputs }; + // Remove used chunks and associated sourcemaps from the original result original.outputFiles = original.outputFiles.filter( (file) => diff --git a/packages/angular/build/src/builders/application/execute-build.ts b/packages/angular/build/src/builders/application/execute-build.ts index 62f8d948566d..a4cfb6e03a5f 100644 --- a/packages/angular/build/src/builders/application/execute-build.ts +++ b/packages/angular/build/src/builders/application/execute-build.ts @@ -7,6 +7,7 @@ */ import { BuilderContext } from '@angular-devkit/architect'; +import type { Metafile } from 'esbuild'; import { createAngularCompilation } from '../../tools/angular/compilation'; import { SourceFileCache } from '../../tools/esbuild/angular/source-file-cache'; import { generateBudgetStats } from '../../tools/esbuild/budget-stats'; @@ -14,13 +15,13 @@ import { BuildOutputFileType, BundleContextResult, BundlerContext, + InitialFileRecord, } from '../../tools/esbuild/bundler-context'; import { ExecutionResult, RebuildState } from '../../tools/esbuild/bundler-execution-result'; import { checkCommonJSModules } from '../../tools/esbuild/commonjs-checker'; import { extractLicenses } from '../../tools/esbuild/license-extractor'; import { profileAsync } from '../../tools/esbuild/profiling'; import { - buildMetafileForType, calculateEstimatedTransferSizes, logBuildStats, transformSupportedBrowsersToTargets, @@ -38,6 +39,32 @@ import { inlineI18n, loadActiveTranslations } from './i18n'; import { NormalizedApplicationBuildOptions } from './options'; import { createComponentStyleBundler, setupBundlerContexts } from './setup-bundling'; +function filterMetafileByInitialFiles( + metafile: Metafile, + initialFiles: Map, +): Metafile { + const filteredOutputs: Metafile['outputs'] = {}; + const referencedInputs = new Set(); + + for (const [path, output] of Object.entries(metafile.outputs)) { + if (initialFiles.has(path)) { + filteredOutputs[path] = output; + for (const inputPath of Object.keys(output.inputs)) { + referencedInputs.add(inputPath); + } + } + } + + const filteredInputs: Metafile['inputs'] = {}; + for (const [inputPath, input] of Object.entries(metafile.inputs)) { + if (referencedInputs.has(inputPath)) { + filteredInputs[inputPath] = input; + } + } + + return { inputs: filteredInputs, outputs: filteredOutputs }; +} + // eslint-disable-next-line max-lines-per-function export async function executeBuild( options: NormalizedApplicationBuildOptions, @@ -210,7 +237,7 @@ export async function executeBuild( executionResult.setExternalMetadata(implicitBrowser, implicitServer, [...explicitExternal]); } - const { metafile, initialFiles, outputFiles } = bundlingResult; + const { metafile, browserMetafile, serverMetafile, initialFiles, outputFiles } = bundlingResult; executionResult.outputFiles.push(...outputFiles); @@ -308,31 +335,23 @@ export async function executeBuild( if (options.stats) { executionResult.addOutputFile( 'browser-stats.json', - JSON.stringify(buildMetafileForType(metafile, 'browser', false, outputFiles), null, 2), + JSON.stringify(browserMetafile, null, 2), BuildOutputFileType.Root, ); executionResult.addOutputFile( 'browser-initial-stats.json', - JSON.stringify( - buildMetafileForType(metafile, 'browser', true, outputFiles, initialFiles), - null, - 2, - ), + JSON.stringify(filterMetafileByInitialFiles(browserMetafile, initialFiles), null, 2), BuildOutputFileType.Root, ); - if (ssrOutputEnabled) { + if (ssrOutputEnabled && serverMetafile) { executionResult.addOutputFile( 'server-stats.json', - JSON.stringify(buildMetafileForType(metafile, 'server', false, outputFiles), null, 2), + JSON.stringify(serverMetafile, null, 2), BuildOutputFileType.Root, ); executionResult.addOutputFile( 'server-initial-stats.json', - JSON.stringify( - buildMetafileForType(metafile, 'server', true, outputFiles, initialFiles), - null, - 2, - ), + JSON.stringify(filterMetafileByInitialFiles(serverMetafile, initialFiles), null, 2), BuildOutputFileType.Root, ); } diff --git a/packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts b/packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts index 3b8d12ec1461..b6670b3ab98a 100644 --- a/packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts +++ b/packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts @@ -278,6 +278,7 @@ export class ComponentStylesheetBundler { contents, outputFiles, metafile, + browserMetafile: metafile, referencedFiles, externalImports: result.externalImports, initialFiles: new Map(), diff --git a/packages/angular/build/src/tools/esbuild/bundler-context.ts b/packages/angular/build/src/tools/esbuild/bundler-context.ts index 864ca2c6fdd9..f2fcd3411797 100644 --- a/packages/angular/build/src/tools/esbuild/bundler-context.ts +++ b/packages/angular/build/src/tools/esbuild/bundler-context.ts @@ -29,6 +29,8 @@ export type BundleContextResult = errors: undefined; warnings: Message[]; metafile: Metafile; + browserMetafile: Metafile; + serverMetafile?: Metafile; outputFiles: BuildOutputFile[]; initialFiles: Map; externalImports: { @@ -128,6 +130,8 @@ export class BundlerContext { let errors: Message[] | undefined; const warnings: Message[] = []; const metafile: Metafile = { inputs: {}, outputs: {} }; + const browserMetafile: Metafile = { inputs: {}, outputs: {} }; + let serverMetafile: Metafile | undefined; const initialFiles = new Map(); const externalImportsBrowser = new Set(); const externalImportsServer = new Set(); @@ -148,6 +152,17 @@ export class BundlerContext { Object.assign(metafile.outputs, result.metafile.outputs); } + // Keep browser and server metafiles isolated for separate stats output + if (result.browserMetafile) { + Object.assign(browserMetafile.inputs, result.browserMetafile.inputs); + Object.assign(browserMetafile.outputs, result.browserMetafile.outputs); + } + if (result.serverMetafile) { + serverMetafile ??= { inputs: {}, outputs: {} }; + Object.assign(serverMetafile.inputs, result.serverMetafile.inputs); + Object.assign(serverMetafile.outputs, result.serverMetafile.outputs); + } + result.initialFiles.forEach((value, key) => initialFiles.set(key, value)); outputFiles.push(...result.outputFiles); @@ -170,6 +185,8 @@ export class BundlerContext { errors, warnings, metafile, + browserMetafile, + serverMetafile, initialFiles, outputFiles, externalImports: { @@ -415,6 +432,8 @@ export class BundlerContext { }, externalConfiguration, errors: undefined, + browserMetafile: isPlatformServer ? { inputs: {}, outputs: {} } : result.metafile, + serverMetafile: isPlatformServer ? result.metafile : undefined, }; } diff --git a/packages/angular/build/src/tools/esbuild/utils.ts b/packages/angular/build/src/tools/esbuild/utils.ts index c27fda8f275a..2730dafae97c 100644 --- a/packages/angular/build/src/tools/esbuild/utils.ts +++ b/packages/angular/build/src/tools/esbuild/utils.ts @@ -29,58 +29,6 @@ import { PrerenderedRoutesRecord, } from './bundler-execution-result'; -export function buildMetafileForType( - metafile: Metafile, - type: 'browser' | 'server', - initial: boolean, - outputFiles: BuildOutputFile[], - initialFiles?: Map, -): Metafile { - const isServer = type === 'server'; - - const outputPathsForType = new Set( - outputFiles - .filter(({ type: fileType }) => { - const isServerFile = - fileType === BuildOutputFileType.ServerApplication || - fileType === BuildOutputFileType.ServerRoot; - - return isServer ? isServerFile : !isServerFile; - }) - .map(({ path }) => path), - ); - - const filteredOutputs: Metafile['outputs'] = {}; - for (const [outputPath, output] of Object.entries(metafile.outputs)) { - if (!outputPathsForType.has(outputPath)) { - continue; - } - if (initial && !initialFiles?.has(outputPath)) { - continue; - } - filteredOutputs[outputPath] = output; - } - - const referencedInputs = new Set(); - for (const output of Object.values(filteredOutputs)) { - for (const inputPath of Object.keys(output.inputs)) { - referencedInputs.add(inputPath); - } - } - - const filteredInputs: Metafile['inputs'] = {}; - for (const [inputPath, input] of Object.entries(metafile.inputs)) { - if (referencedInputs.has(inputPath)) { - filteredInputs[inputPath] = input; - } - } - - return { - inputs: filteredInputs, - outputs: filteredOutputs, - }; -} - export function logBuildStats( metafile: Metafile, outputFiles: BuildOutputFile[], From 72ef689ddcbcf3449f8b0c9fc317023ca6657362 Mon Sep 17 00:00:00 2001 From: Troy Steuwer Date: Mon, 13 Apr 2026 10:26:50 -0400 Subject: [PATCH 05/12] Updating per code review instructions and adding more unit tests --- .../builders/application/chunk-optimizer.ts | 14 +---- .../src/builders/application/execute-build.ts | 53 +++++++--------- .../angular/build/src/tools/esbuild/utils.ts | 34 +++++++++++ .../browser/tests/options/stats-json_spec.ts | 61 +++++++++++++++++++ .../src/tools/webpack/configs/common.ts | 27 +++++++- .../webpack/plugins/json-stats-plugin.ts | 11 +++- 6 files changed, 153 insertions(+), 47 deletions(-) diff --git a/packages/angular/build/src/builders/application/chunk-optimizer.ts b/packages/angular/build/src/builders/application/chunk-optimizer.ts index bd83c5079671..c21182ba4385 100644 --- a/packages/angular/build/src/builders/application/chunk-optimizer.ts +++ b/packages/angular/build/src/builders/application/chunk-optimizer.ts @@ -26,7 +26,7 @@ import { BundleContextResult, InitialFileRecord, } from '../../tools/esbuild/bundler-context'; -import { createOutputFile } from '../../tools/esbuild/utils'; +import { createOutputFile, filterMetafile } from '../../tools/esbuild/utils'; import { assertIsError } from '../../utils/error'; /** @@ -288,17 +288,7 @@ export async function optimizeChunks( // Update the isolated browser metafile to reflect the optimized output. // Server outputs are excluded since chunk optimization only affects browser bundles. const serverOutputPaths = new Set(Object.keys(original.serverMetafile?.outputs ?? {})); - const browserOutputs: Metafile['outputs'] = {}; - const browserInputs: Metafile['inputs'] = {}; - for (const [path, output] of Object.entries(newMetafile.outputs)) { - if (!serverOutputPaths.has(path)) { - browserOutputs[path] = output; - for (const inputPath of Object.keys(output.inputs)) { - browserInputs[inputPath] = newMetafile.inputs[inputPath]; - } - } - } - original.browserMetafile = { inputs: browserInputs, outputs: browserOutputs }; + original.browserMetafile = filterMetafile(newMetafile, (path) => !serverOutputPaths.has(path)); // Remove used chunks and associated sourcemaps from the original result original.outputFiles = original.outputFiles.filter( diff --git a/packages/angular/build/src/builders/application/execute-build.ts b/packages/angular/build/src/builders/application/execute-build.ts index a4cfb6e03a5f..3d4fd113f918 100644 --- a/packages/angular/build/src/builders/application/execute-build.ts +++ b/packages/angular/build/src/builders/application/execute-build.ts @@ -7,7 +7,6 @@ */ import { BuilderContext } from '@angular-devkit/architect'; -import type { Metafile } from 'esbuild'; import { createAngularCompilation } from '../../tools/angular/compilation'; import { SourceFileCache } from '../../tools/esbuild/angular/source-file-cache'; import { generateBudgetStats } from '../../tools/esbuild/budget-stats'; @@ -15,7 +14,6 @@ import { BuildOutputFileType, BundleContextResult, BundlerContext, - InitialFileRecord, } from '../../tools/esbuild/bundler-context'; import { ExecutionResult, RebuildState } from '../../tools/esbuild/bundler-execution-result'; import { checkCommonJSModules } from '../../tools/esbuild/commonjs-checker'; @@ -23,6 +21,7 @@ import { extractLicenses } from '../../tools/esbuild/license-extractor'; import { profileAsync } from '../../tools/esbuild/profiling'; import { calculateEstimatedTransferSizes, + filterMetafile, logBuildStats, transformSupportedBrowsersToTargets, } from '../../tools/esbuild/utils'; @@ -39,32 +38,6 @@ import { inlineI18n, loadActiveTranslations } from './i18n'; import { NormalizedApplicationBuildOptions } from './options'; import { createComponentStyleBundler, setupBundlerContexts } from './setup-bundling'; -function filterMetafileByInitialFiles( - metafile: Metafile, - initialFiles: Map, -): Metafile { - const filteredOutputs: Metafile['outputs'] = {}; - const referencedInputs = new Set(); - - for (const [path, output] of Object.entries(metafile.outputs)) { - if (initialFiles.has(path)) { - filteredOutputs[path] = output; - for (const inputPath of Object.keys(output.inputs)) { - referencedInputs.add(inputPath); - } - } - } - - const filteredInputs: Metafile['inputs'] = {}; - for (const [inputPath, input] of Object.entries(metafile.inputs)) { - if (referencedInputs.has(inputPath)) { - filteredInputs[inputPath] = input; - } - } - - return { inputs: filteredInputs, outputs: filteredOutputs }; -} - // eslint-disable-next-line max-lines-per-function export async function executeBuild( options: NormalizedApplicationBuildOptions, @@ -335,23 +308,39 @@ export async function executeBuild( if (options.stats) { executionResult.addOutputFile( 'browser-stats.json', - JSON.stringify(browserMetafile, null, 2), + JSON.stringify( + filterMetafile(browserMetafile, (path) => !initialFiles.has(path)), + null, + 2, + ), BuildOutputFileType.Root, ); executionResult.addOutputFile( 'browser-initial-stats.json', - JSON.stringify(filterMetafileByInitialFiles(browserMetafile, initialFiles), null, 2), + JSON.stringify( + filterMetafile(browserMetafile, (path) => initialFiles.has(path)), + null, + 2, + ), BuildOutputFileType.Root, ); if (ssrOutputEnabled && serverMetafile) { executionResult.addOutputFile( 'server-stats.json', - JSON.stringify(serverMetafile, null, 2), + JSON.stringify( + filterMetafile(serverMetafile, (path) => !initialFiles.has(path)), + null, + 2, + ), BuildOutputFileType.Root, ); executionResult.addOutputFile( 'server-initial-stats.json', - JSON.stringify(filterMetafileByInitialFiles(serverMetafile, initialFiles), null, 2), + JSON.stringify( + filterMetafile(serverMetafile, (path) => initialFiles.has(path)), + null, + 2, + ), BuildOutputFileType.Root, ); } diff --git a/packages/angular/build/src/tools/esbuild/utils.ts b/packages/angular/build/src/tools/esbuild/utils.ts index 2730dafae97c..d6f1e19748a4 100644 --- a/packages/angular/build/src/tools/esbuild/utils.ts +++ b/packages/angular/build/src/tools/esbuild/utils.ts @@ -29,6 +29,40 @@ import { PrerenderedRoutesRecord, } from './bundler-execution-result'; +/** + * Filters an esbuild metafile to only include outputs matching a given predicate, + * along with the inputs referenced by those outputs. + * @param metafile The esbuild metafile to filter. + * @param predicate A function that receives an output path and returns `true` if the output + * should be included. + * @returns A new metafile containing only the matching outputs and their referenced inputs. + */ +export function filterMetafile( + metafile: Metafile, + predicate: (outputPath: string) => boolean, +): Metafile { + const filteredOutputs: Metafile['outputs'] = {}; + const referencedInputs = new Set(); + + for (const [path, output] of Object.entries(metafile.outputs)) { + if (predicate(path)) { + filteredOutputs[path] = output; + for (const inputPath of Object.keys(output.inputs)) { + referencedInputs.add(inputPath); + } + } + } + + const filteredInputs: Metafile['inputs'] = {}; + for (const [inputPath, input] of Object.entries(metafile.inputs)) { + if (referencedInputs.has(inputPath)) { + filteredInputs[inputPath] = input; + } + } + + return { inputs: filteredInputs, outputs: filteredOutputs }; +} + export function logBuildStats( metafile: Metafile, outputFiles: BuildOutputFile[], diff --git a/packages/angular_devkit/build_angular/src/builders/browser/tests/options/stats-json_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/tests/options/stats-json_spec.ts index 2a901997e333..59b8c01f434d 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser/tests/options/stats-json_spec.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser/tests/options/stats-json_spec.ts @@ -57,6 +57,67 @@ describeBuilder(buildWebpackBrowser, BROWSER_BUILDER_INFO, (harness) => { } }); + it('browser-initial-stats.json contains only initial chunks', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + statsJson: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + + const raw = harness.readFile('dist/browser-initial-stats.json'); + const stats = JSON.parse(raw) as { chunks?: { initial?: boolean }[] }; + + for (const chunk of stats.chunks ?? []) { + expect(chunk.initial) + .withContext('browser-initial-stats.json should only contain initial chunks') + .toBeTrue(); + } + }); + + it('browser-stats.json contains only non-initial chunks', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + statsJson: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + + const raw = harness.readFile('dist/browser-stats.json'); + const stats = JSON.parse(raw) as { chunks?: { initial?: boolean }[] }; + + for (const chunk of stats.chunks ?? []) { + expect(chunk.initial) + .withContext('browser-stats.json should not contain initial chunks') + .toBeFalse(); + } + }); + + it('browser-stats.json and browser-initial-stats.json chunks have no overlap', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + statsJson: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + + type StatsJson = { chunks?: { id?: number | string; initial?: boolean }[] }; + const nonInitialStats = JSON.parse(harness.readFile('dist/browser-stats.json')) as StatsJson; + const initialStats = JSON.parse( + harness.readFile('dist/browser-initial-stats.json'), + ) as StatsJson; + + const nonInitialIds = new Set((nonInitialStats.chunks ?? []).map((c) => c.id)); + for (const chunk of initialStats.chunks ?? []) { + expect(nonInitialIds.has(chunk.id)) + .withContext(`Chunk '${chunk.id}' should not appear in both stats files`) + .toBeFalse(); + } + }); + it('does not generate a Webpack Stats file in output when false', async () => { harness.useTarget('build', { ...BASE_OPTIONS, diff --git a/packages/angular_devkit/build_angular/src/tools/webpack/configs/common.ts b/packages/angular_devkit/build_angular/src/tools/webpack/configs/common.ts index 37c7423c566d..8e6d9f54de4c 100644 --- a/packages/angular_devkit/build_angular/src/tools/webpack/configs/common.ts +++ b/packages/angular_devkit/build_angular/src/tools/webpack/configs/common.ts @@ -243,10 +243,35 @@ export async function getCommonConfig(wco: WebpackConfigOptions): Promise => + new Set( + data.chunks?.filter((c) => c.initial).flatMap((c) => (c.id != null ? [c.id] : [])) ?? [], + ); + extraPlugins.push( - new JsonStatsPlugin(path.resolve(root, buildOptions.outputPath, 'browser-stats.json')), + new JsonStatsPlugin( + path.resolve(root, buildOptions.outputPath, 'browser-stats.json'), + (data) => { + const initialChunkIds = getInitialChunkIds(data); + + return { + ...data, + chunks: data.chunks?.filter((c) => !c.initial), + assets: data.assets?.filter((a) => !a.chunks?.some((id) => initialChunkIds.has(id))), + }; + }, + ), new JsonStatsPlugin( path.resolve(root, buildOptions.outputPath, 'browser-initial-stats.json'), + (data) => { + const initialChunkIds = getInitialChunkIds(data); + + return { + ...data, + chunks: data.chunks?.filter((c) => c.initial), + assets: data.assets?.filter((a) => a.chunks?.some((id) => initialChunkIds.has(id))), + }; + }, ), ); } diff --git a/packages/angular_devkit/build_angular/src/tools/webpack/plugins/json-stats-plugin.ts b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/json-stats-plugin.ts index 8f7a798338a0..567313d3b582 100644 --- a/packages/angular_devkit/build_angular/src/tools/webpack/plugins/json-stats-plugin.ts +++ b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/json-stats-plugin.ts @@ -10,17 +10,24 @@ import { createWriteStream } from 'node:fs'; import { mkdir } from 'node:fs/promises'; import { dirname } from 'node:path'; import { pipeline } from 'node:stream/promises'; +import type { StatsCompilation } from 'webpack'; import { Compiler } from 'webpack'; import { assertIsError } from '../../../utils/error'; import { addError } from '../../../utils/webpack-diagnostics'; export class JsonStatsPlugin { - constructor(private readonly statsOutputPath: string) {} + constructor( + private readonly statsOutputPath: string, + private readonly transform?: (data: StatsCompilation) => StatsCompilation, + ) {} apply(compiler: Compiler) { compiler.hooks.done.tapPromise('angular-json-stats', async (stats) => { const { stringifyChunked } = await import('@discoveryjs/json-ext'); - const data = stats.toJson('verbose'); + let data = stats.toJson('verbose'); + if (this.transform) { + data = this.transform(data); + } try { await mkdir(dirname(this.statsOutputPath), { recursive: true }); From 7e770b022812da29766b1c67e21122f56d6d011c Mon Sep 17 00:00:00 2001 From: Troy Steuwer Date: Tue, 14 Apr 2026 09:09:51 -0400 Subject: [PATCH 06/12] Revert "Updating per code review instructions and adding more unit tests" This reverts commit 72ef689ddcbcf3449f8b0c9fc317023ca6657362. --- .../builders/application/chunk-optimizer.ts | 14 ++++- .../src/builders/application/execute-build.ts | 53 +++++++++------- .../angular/build/src/tools/esbuild/utils.ts | 34 ----------- .../browser/tests/options/stats-json_spec.ts | 61 ------------------- .../src/tools/webpack/configs/common.ts | 27 +------- .../webpack/plugins/json-stats-plugin.ts | 11 +--- 6 files changed, 47 insertions(+), 153 deletions(-) diff --git a/packages/angular/build/src/builders/application/chunk-optimizer.ts b/packages/angular/build/src/builders/application/chunk-optimizer.ts index c21182ba4385..bd83c5079671 100644 --- a/packages/angular/build/src/builders/application/chunk-optimizer.ts +++ b/packages/angular/build/src/builders/application/chunk-optimizer.ts @@ -26,7 +26,7 @@ import { BundleContextResult, InitialFileRecord, } from '../../tools/esbuild/bundler-context'; -import { createOutputFile, filterMetafile } from '../../tools/esbuild/utils'; +import { createOutputFile } from '../../tools/esbuild/utils'; import { assertIsError } from '../../utils/error'; /** @@ -288,7 +288,17 @@ export async function optimizeChunks( // Update the isolated browser metafile to reflect the optimized output. // Server outputs are excluded since chunk optimization only affects browser bundles. const serverOutputPaths = new Set(Object.keys(original.serverMetafile?.outputs ?? {})); - original.browserMetafile = filterMetafile(newMetafile, (path) => !serverOutputPaths.has(path)); + const browserOutputs: Metafile['outputs'] = {}; + const browserInputs: Metafile['inputs'] = {}; + for (const [path, output] of Object.entries(newMetafile.outputs)) { + if (!serverOutputPaths.has(path)) { + browserOutputs[path] = output; + for (const inputPath of Object.keys(output.inputs)) { + browserInputs[inputPath] = newMetafile.inputs[inputPath]; + } + } + } + original.browserMetafile = { inputs: browserInputs, outputs: browserOutputs }; // Remove used chunks and associated sourcemaps from the original result original.outputFiles = original.outputFiles.filter( diff --git a/packages/angular/build/src/builders/application/execute-build.ts b/packages/angular/build/src/builders/application/execute-build.ts index 3d4fd113f918..a4cfb6e03a5f 100644 --- a/packages/angular/build/src/builders/application/execute-build.ts +++ b/packages/angular/build/src/builders/application/execute-build.ts @@ -7,6 +7,7 @@ */ import { BuilderContext } from '@angular-devkit/architect'; +import type { Metafile } from 'esbuild'; import { createAngularCompilation } from '../../tools/angular/compilation'; import { SourceFileCache } from '../../tools/esbuild/angular/source-file-cache'; import { generateBudgetStats } from '../../tools/esbuild/budget-stats'; @@ -14,6 +15,7 @@ import { BuildOutputFileType, BundleContextResult, BundlerContext, + InitialFileRecord, } from '../../tools/esbuild/bundler-context'; import { ExecutionResult, RebuildState } from '../../tools/esbuild/bundler-execution-result'; import { checkCommonJSModules } from '../../tools/esbuild/commonjs-checker'; @@ -21,7 +23,6 @@ import { extractLicenses } from '../../tools/esbuild/license-extractor'; import { profileAsync } from '../../tools/esbuild/profiling'; import { calculateEstimatedTransferSizes, - filterMetafile, logBuildStats, transformSupportedBrowsersToTargets, } from '../../tools/esbuild/utils'; @@ -38,6 +39,32 @@ import { inlineI18n, loadActiveTranslations } from './i18n'; import { NormalizedApplicationBuildOptions } from './options'; import { createComponentStyleBundler, setupBundlerContexts } from './setup-bundling'; +function filterMetafileByInitialFiles( + metafile: Metafile, + initialFiles: Map, +): Metafile { + const filteredOutputs: Metafile['outputs'] = {}; + const referencedInputs = new Set(); + + for (const [path, output] of Object.entries(metafile.outputs)) { + if (initialFiles.has(path)) { + filteredOutputs[path] = output; + for (const inputPath of Object.keys(output.inputs)) { + referencedInputs.add(inputPath); + } + } + } + + const filteredInputs: Metafile['inputs'] = {}; + for (const [inputPath, input] of Object.entries(metafile.inputs)) { + if (referencedInputs.has(inputPath)) { + filteredInputs[inputPath] = input; + } + } + + return { inputs: filteredInputs, outputs: filteredOutputs }; +} + // eslint-disable-next-line max-lines-per-function export async function executeBuild( options: NormalizedApplicationBuildOptions, @@ -308,39 +335,23 @@ export async function executeBuild( if (options.stats) { executionResult.addOutputFile( 'browser-stats.json', - JSON.stringify( - filterMetafile(browserMetafile, (path) => !initialFiles.has(path)), - null, - 2, - ), + JSON.stringify(browserMetafile, null, 2), BuildOutputFileType.Root, ); executionResult.addOutputFile( 'browser-initial-stats.json', - JSON.stringify( - filterMetafile(browserMetafile, (path) => initialFiles.has(path)), - null, - 2, - ), + JSON.stringify(filterMetafileByInitialFiles(browserMetafile, initialFiles), null, 2), BuildOutputFileType.Root, ); if (ssrOutputEnabled && serverMetafile) { executionResult.addOutputFile( 'server-stats.json', - JSON.stringify( - filterMetafile(serverMetafile, (path) => !initialFiles.has(path)), - null, - 2, - ), + JSON.stringify(serverMetafile, null, 2), BuildOutputFileType.Root, ); executionResult.addOutputFile( 'server-initial-stats.json', - JSON.stringify( - filterMetafile(serverMetafile, (path) => initialFiles.has(path)), - null, - 2, - ), + JSON.stringify(filterMetafileByInitialFiles(serverMetafile, initialFiles), null, 2), BuildOutputFileType.Root, ); } diff --git a/packages/angular/build/src/tools/esbuild/utils.ts b/packages/angular/build/src/tools/esbuild/utils.ts index d6f1e19748a4..2730dafae97c 100644 --- a/packages/angular/build/src/tools/esbuild/utils.ts +++ b/packages/angular/build/src/tools/esbuild/utils.ts @@ -29,40 +29,6 @@ import { PrerenderedRoutesRecord, } from './bundler-execution-result'; -/** - * Filters an esbuild metafile to only include outputs matching a given predicate, - * along with the inputs referenced by those outputs. - * @param metafile The esbuild metafile to filter. - * @param predicate A function that receives an output path and returns `true` if the output - * should be included. - * @returns A new metafile containing only the matching outputs and their referenced inputs. - */ -export function filterMetafile( - metafile: Metafile, - predicate: (outputPath: string) => boolean, -): Metafile { - const filteredOutputs: Metafile['outputs'] = {}; - const referencedInputs = new Set(); - - for (const [path, output] of Object.entries(metafile.outputs)) { - if (predicate(path)) { - filteredOutputs[path] = output; - for (const inputPath of Object.keys(output.inputs)) { - referencedInputs.add(inputPath); - } - } - } - - const filteredInputs: Metafile['inputs'] = {}; - for (const [inputPath, input] of Object.entries(metafile.inputs)) { - if (referencedInputs.has(inputPath)) { - filteredInputs[inputPath] = input; - } - } - - return { inputs: filteredInputs, outputs: filteredOutputs }; -} - export function logBuildStats( metafile: Metafile, outputFiles: BuildOutputFile[], diff --git a/packages/angular_devkit/build_angular/src/builders/browser/tests/options/stats-json_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/tests/options/stats-json_spec.ts index 59b8c01f434d..2a901997e333 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser/tests/options/stats-json_spec.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser/tests/options/stats-json_spec.ts @@ -57,67 +57,6 @@ describeBuilder(buildWebpackBrowser, BROWSER_BUILDER_INFO, (harness) => { } }); - it('browser-initial-stats.json contains only initial chunks', async () => { - harness.useTarget('build', { - ...BASE_OPTIONS, - statsJson: true, - }); - - const { result } = await harness.executeOnce(); - expect(result?.success).toBe(true); - - const raw = harness.readFile('dist/browser-initial-stats.json'); - const stats = JSON.parse(raw) as { chunks?: { initial?: boolean }[] }; - - for (const chunk of stats.chunks ?? []) { - expect(chunk.initial) - .withContext('browser-initial-stats.json should only contain initial chunks') - .toBeTrue(); - } - }); - - it('browser-stats.json contains only non-initial chunks', async () => { - harness.useTarget('build', { - ...BASE_OPTIONS, - statsJson: true, - }); - - const { result } = await harness.executeOnce(); - expect(result?.success).toBe(true); - - const raw = harness.readFile('dist/browser-stats.json'); - const stats = JSON.parse(raw) as { chunks?: { initial?: boolean }[] }; - - for (const chunk of stats.chunks ?? []) { - expect(chunk.initial) - .withContext('browser-stats.json should not contain initial chunks') - .toBeFalse(); - } - }); - - it('browser-stats.json and browser-initial-stats.json chunks have no overlap', async () => { - harness.useTarget('build', { - ...BASE_OPTIONS, - statsJson: true, - }); - - const { result } = await harness.executeOnce(); - expect(result?.success).toBe(true); - - type StatsJson = { chunks?: { id?: number | string; initial?: boolean }[] }; - const nonInitialStats = JSON.parse(harness.readFile('dist/browser-stats.json')) as StatsJson; - const initialStats = JSON.parse( - harness.readFile('dist/browser-initial-stats.json'), - ) as StatsJson; - - const nonInitialIds = new Set((nonInitialStats.chunks ?? []).map((c) => c.id)); - for (const chunk of initialStats.chunks ?? []) { - expect(nonInitialIds.has(chunk.id)) - .withContext(`Chunk '${chunk.id}' should not appear in both stats files`) - .toBeFalse(); - } - }); - it('does not generate a Webpack Stats file in output when false', async () => { harness.useTarget('build', { ...BASE_OPTIONS, diff --git a/packages/angular_devkit/build_angular/src/tools/webpack/configs/common.ts b/packages/angular_devkit/build_angular/src/tools/webpack/configs/common.ts index 8e6d9f54de4c..37c7423c566d 100644 --- a/packages/angular_devkit/build_angular/src/tools/webpack/configs/common.ts +++ b/packages/angular_devkit/build_angular/src/tools/webpack/configs/common.ts @@ -243,35 +243,10 @@ export async function getCommonConfig(wco: WebpackConfigOptions): Promise => - new Set( - data.chunks?.filter((c) => c.initial).flatMap((c) => (c.id != null ? [c.id] : [])) ?? [], - ); - extraPlugins.push( - new JsonStatsPlugin( - path.resolve(root, buildOptions.outputPath, 'browser-stats.json'), - (data) => { - const initialChunkIds = getInitialChunkIds(data); - - return { - ...data, - chunks: data.chunks?.filter((c) => !c.initial), - assets: data.assets?.filter((a) => !a.chunks?.some((id) => initialChunkIds.has(id))), - }; - }, - ), + new JsonStatsPlugin(path.resolve(root, buildOptions.outputPath, 'browser-stats.json')), new JsonStatsPlugin( path.resolve(root, buildOptions.outputPath, 'browser-initial-stats.json'), - (data) => { - const initialChunkIds = getInitialChunkIds(data); - - return { - ...data, - chunks: data.chunks?.filter((c) => c.initial), - assets: data.assets?.filter((a) => a.chunks?.some((id) => initialChunkIds.has(id))), - }; - }, ), ); } diff --git a/packages/angular_devkit/build_angular/src/tools/webpack/plugins/json-stats-plugin.ts b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/json-stats-plugin.ts index 567313d3b582..8f7a798338a0 100644 --- a/packages/angular_devkit/build_angular/src/tools/webpack/plugins/json-stats-plugin.ts +++ b/packages/angular_devkit/build_angular/src/tools/webpack/plugins/json-stats-plugin.ts @@ -10,24 +10,17 @@ import { createWriteStream } from 'node:fs'; import { mkdir } from 'node:fs/promises'; import { dirname } from 'node:path'; import { pipeline } from 'node:stream/promises'; -import type { StatsCompilation } from 'webpack'; import { Compiler } from 'webpack'; import { assertIsError } from '../../../utils/error'; import { addError } from '../../../utils/webpack-diagnostics'; export class JsonStatsPlugin { - constructor( - private readonly statsOutputPath: string, - private readonly transform?: (data: StatsCompilation) => StatsCompilation, - ) {} + constructor(private readonly statsOutputPath: string) {} apply(compiler: Compiler) { compiler.hooks.done.tapPromise('angular-json-stats', async (stats) => { const { stringifyChunked } = await import('@discoveryjs/json-ext'); - let data = stats.toJson('verbose'); - if (this.transform) { - data = this.transform(data); - } + const data = stats.toJson('verbose'); try { await mkdir(dirname(this.statsOutputPath), { recursive: true }); From 00d9d6083a88b2444d25821c04fe700bd6f9fc78 Mon Sep 17 00:00:00 2001 From: Troy Steuwer Date: Tue, 14 Apr 2026 09:10:03 -0400 Subject: [PATCH 07/12] Revert "type(fix)" This reverts commit dd80e0f4a0ff4ade3b0b902747881005b26b83a5. --- .../builders/application/chunk-optimizer.ts | 15 ------ .../src/builders/application/execute-build.ts | 49 ++++++----------- .../esbuild/angular/component-stylesheets.ts | 1 - .../src/tools/esbuild/bundler-context.ts | 19 ------- .../angular/build/src/tools/esbuild/utils.ts | 52 +++++++++++++++++++ 5 files changed, 67 insertions(+), 69 deletions(-) diff --git a/packages/angular/build/src/builders/application/chunk-optimizer.ts b/packages/angular/build/src/builders/application/chunk-optimizer.ts index bd83c5079671..e6827479b784 100644 --- a/packages/angular/build/src/builders/application/chunk-optimizer.ts +++ b/packages/angular/build/src/builders/application/chunk-optimizer.ts @@ -285,21 +285,6 @@ export async function optimizeChunks( } original.metafile = newMetafile; - // Update the isolated browser metafile to reflect the optimized output. - // Server outputs are excluded since chunk optimization only affects browser bundles. - const serverOutputPaths = new Set(Object.keys(original.serverMetafile?.outputs ?? {})); - const browserOutputs: Metafile['outputs'] = {}; - const browserInputs: Metafile['inputs'] = {}; - for (const [path, output] of Object.entries(newMetafile.outputs)) { - if (!serverOutputPaths.has(path)) { - browserOutputs[path] = output; - for (const inputPath of Object.keys(output.inputs)) { - browserInputs[inputPath] = newMetafile.inputs[inputPath]; - } - } - } - original.browserMetafile = { inputs: browserInputs, outputs: browserOutputs }; - // Remove used chunks and associated sourcemaps from the original result original.outputFiles = original.outputFiles.filter( (file) => diff --git a/packages/angular/build/src/builders/application/execute-build.ts b/packages/angular/build/src/builders/application/execute-build.ts index a4cfb6e03a5f..62f8d948566d 100644 --- a/packages/angular/build/src/builders/application/execute-build.ts +++ b/packages/angular/build/src/builders/application/execute-build.ts @@ -7,7 +7,6 @@ */ import { BuilderContext } from '@angular-devkit/architect'; -import type { Metafile } from 'esbuild'; import { createAngularCompilation } from '../../tools/angular/compilation'; import { SourceFileCache } from '../../tools/esbuild/angular/source-file-cache'; import { generateBudgetStats } from '../../tools/esbuild/budget-stats'; @@ -15,13 +14,13 @@ import { BuildOutputFileType, BundleContextResult, BundlerContext, - InitialFileRecord, } from '../../tools/esbuild/bundler-context'; import { ExecutionResult, RebuildState } from '../../tools/esbuild/bundler-execution-result'; import { checkCommonJSModules } from '../../tools/esbuild/commonjs-checker'; import { extractLicenses } from '../../tools/esbuild/license-extractor'; import { profileAsync } from '../../tools/esbuild/profiling'; import { + buildMetafileForType, calculateEstimatedTransferSizes, logBuildStats, transformSupportedBrowsersToTargets, @@ -39,32 +38,6 @@ import { inlineI18n, loadActiveTranslations } from './i18n'; import { NormalizedApplicationBuildOptions } from './options'; import { createComponentStyleBundler, setupBundlerContexts } from './setup-bundling'; -function filterMetafileByInitialFiles( - metafile: Metafile, - initialFiles: Map, -): Metafile { - const filteredOutputs: Metafile['outputs'] = {}; - const referencedInputs = new Set(); - - for (const [path, output] of Object.entries(metafile.outputs)) { - if (initialFiles.has(path)) { - filteredOutputs[path] = output; - for (const inputPath of Object.keys(output.inputs)) { - referencedInputs.add(inputPath); - } - } - } - - const filteredInputs: Metafile['inputs'] = {}; - for (const [inputPath, input] of Object.entries(metafile.inputs)) { - if (referencedInputs.has(inputPath)) { - filteredInputs[inputPath] = input; - } - } - - return { inputs: filteredInputs, outputs: filteredOutputs }; -} - // eslint-disable-next-line max-lines-per-function export async function executeBuild( options: NormalizedApplicationBuildOptions, @@ -237,7 +210,7 @@ export async function executeBuild( executionResult.setExternalMetadata(implicitBrowser, implicitServer, [...explicitExternal]); } - const { metafile, browserMetafile, serverMetafile, initialFiles, outputFiles } = bundlingResult; + const { metafile, initialFiles, outputFiles } = bundlingResult; executionResult.outputFiles.push(...outputFiles); @@ -335,23 +308,31 @@ export async function executeBuild( if (options.stats) { executionResult.addOutputFile( 'browser-stats.json', - JSON.stringify(browserMetafile, null, 2), + JSON.stringify(buildMetafileForType(metafile, 'browser', false, outputFiles), null, 2), BuildOutputFileType.Root, ); executionResult.addOutputFile( 'browser-initial-stats.json', - JSON.stringify(filterMetafileByInitialFiles(browserMetafile, initialFiles), null, 2), + JSON.stringify( + buildMetafileForType(metafile, 'browser', true, outputFiles, initialFiles), + null, + 2, + ), BuildOutputFileType.Root, ); - if (ssrOutputEnabled && serverMetafile) { + if (ssrOutputEnabled) { executionResult.addOutputFile( 'server-stats.json', - JSON.stringify(serverMetafile, null, 2), + JSON.stringify(buildMetafileForType(metafile, 'server', false, outputFiles), null, 2), BuildOutputFileType.Root, ); executionResult.addOutputFile( 'server-initial-stats.json', - JSON.stringify(filterMetafileByInitialFiles(serverMetafile, initialFiles), null, 2), + JSON.stringify( + buildMetafileForType(metafile, 'server', true, outputFiles, initialFiles), + null, + 2, + ), BuildOutputFileType.Root, ); } diff --git a/packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts b/packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts index b6670b3ab98a..3b8d12ec1461 100644 --- a/packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts +++ b/packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts @@ -278,7 +278,6 @@ export class ComponentStylesheetBundler { contents, outputFiles, metafile, - browserMetafile: metafile, referencedFiles, externalImports: result.externalImports, initialFiles: new Map(), diff --git a/packages/angular/build/src/tools/esbuild/bundler-context.ts b/packages/angular/build/src/tools/esbuild/bundler-context.ts index f2fcd3411797..864ca2c6fdd9 100644 --- a/packages/angular/build/src/tools/esbuild/bundler-context.ts +++ b/packages/angular/build/src/tools/esbuild/bundler-context.ts @@ -29,8 +29,6 @@ export type BundleContextResult = errors: undefined; warnings: Message[]; metafile: Metafile; - browserMetafile: Metafile; - serverMetafile?: Metafile; outputFiles: BuildOutputFile[]; initialFiles: Map; externalImports: { @@ -130,8 +128,6 @@ export class BundlerContext { let errors: Message[] | undefined; const warnings: Message[] = []; const metafile: Metafile = { inputs: {}, outputs: {} }; - const browserMetafile: Metafile = { inputs: {}, outputs: {} }; - let serverMetafile: Metafile | undefined; const initialFiles = new Map(); const externalImportsBrowser = new Set(); const externalImportsServer = new Set(); @@ -152,17 +148,6 @@ export class BundlerContext { Object.assign(metafile.outputs, result.metafile.outputs); } - // Keep browser and server metafiles isolated for separate stats output - if (result.browserMetafile) { - Object.assign(browserMetafile.inputs, result.browserMetafile.inputs); - Object.assign(browserMetafile.outputs, result.browserMetafile.outputs); - } - if (result.serverMetafile) { - serverMetafile ??= { inputs: {}, outputs: {} }; - Object.assign(serverMetafile.inputs, result.serverMetafile.inputs); - Object.assign(serverMetafile.outputs, result.serverMetafile.outputs); - } - result.initialFiles.forEach((value, key) => initialFiles.set(key, value)); outputFiles.push(...result.outputFiles); @@ -185,8 +170,6 @@ export class BundlerContext { errors, warnings, metafile, - browserMetafile, - serverMetafile, initialFiles, outputFiles, externalImports: { @@ -432,8 +415,6 @@ export class BundlerContext { }, externalConfiguration, errors: undefined, - browserMetafile: isPlatformServer ? { inputs: {}, outputs: {} } : result.metafile, - serverMetafile: isPlatformServer ? result.metafile : undefined, }; } diff --git a/packages/angular/build/src/tools/esbuild/utils.ts b/packages/angular/build/src/tools/esbuild/utils.ts index 2730dafae97c..c27fda8f275a 100644 --- a/packages/angular/build/src/tools/esbuild/utils.ts +++ b/packages/angular/build/src/tools/esbuild/utils.ts @@ -29,6 +29,58 @@ import { PrerenderedRoutesRecord, } from './bundler-execution-result'; +export function buildMetafileForType( + metafile: Metafile, + type: 'browser' | 'server', + initial: boolean, + outputFiles: BuildOutputFile[], + initialFiles?: Map, +): Metafile { + const isServer = type === 'server'; + + const outputPathsForType = new Set( + outputFiles + .filter(({ type: fileType }) => { + const isServerFile = + fileType === BuildOutputFileType.ServerApplication || + fileType === BuildOutputFileType.ServerRoot; + + return isServer ? isServerFile : !isServerFile; + }) + .map(({ path }) => path), + ); + + const filteredOutputs: Metafile['outputs'] = {}; + for (const [outputPath, output] of Object.entries(metafile.outputs)) { + if (!outputPathsForType.has(outputPath)) { + continue; + } + if (initial && !initialFiles?.has(outputPath)) { + continue; + } + filteredOutputs[outputPath] = output; + } + + const referencedInputs = new Set(); + for (const output of Object.values(filteredOutputs)) { + for (const inputPath of Object.keys(output.inputs)) { + referencedInputs.add(inputPath); + } + } + + const filteredInputs: Metafile['inputs'] = {}; + for (const [inputPath, input] of Object.entries(metafile.inputs)) { + if (referencedInputs.has(inputPath)) { + filteredInputs[inputPath] = input; + } + } + + return { + inputs: filteredInputs, + outputs: filteredOutputs, + }; +} + export function logBuildStats( metafile: Metafile, outputFiles: BuildOutputFile[], From 8d5d1c3b95eba68cb2f4c052cb2aa2418487d313 Mon Sep 17 00:00:00 2001 From: Troy Steuwer Date: Tue, 14 Apr 2026 09:10:06 -0400 Subject: [PATCH 08/12] Revert "Reverting these changes per Alans recommendation" This reverts commit eab4b3a1b517ce75edc4e4913ce61843234bd6c3. --- packages/angular/build/src/builders/application/schema.json | 2 +- .../build_angular/src/builders/browser-esbuild/schema.json | 2 +- .../build_angular/src/builders/browser/schema.json | 2 +- .../build_angular/src/builders/server/schema.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/angular/build/src/builders/application/schema.json b/packages/angular/build/src/builders/application/schema.json index 5498a21fe004..76203437650b 100644 --- a/packages/angular/build/src/builders/application/schema.json +++ b/packages/angular/build/src/builders/application/schema.json @@ -535,7 +535,7 @@ }, "statsJson": { "type": "boolean", - "description": "Generates a 'stats.json' file which can be analyzed with https://esbuild.github.io/analyze/.", + "description": "Generates a 'browser-stats.json' (and 'server-stats.json' when SSR is enabled) file which can be analyzed with https://esbuild.github.io/analyze/.", "default": false }, "budgets": { diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/schema.json b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/schema.json index edb91222d954..5db2b753d7bf 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/schema.json +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/schema.json @@ -406,7 +406,7 @@ }, "statsJson": { "type": "boolean", - "description": "Generates a 'stats.json' file which can be analyzed using tools such as 'webpack-bundle-analyzer'.", + "description": "Generates a 'browser-stats.json' file which can be analyzed using tools such as 'webpack-bundle-analyzer'.", "default": false }, "budgets": { diff --git a/packages/angular_devkit/build_angular/src/builders/browser/schema.json b/packages/angular_devkit/build_angular/src/builders/browser/schema.json index 301eeafcc4f1..bf73adeabb29 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser/schema.json +++ b/packages/angular_devkit/build_angular/src/builders/browser/schema.json @@ -394,7 +394,7 @@ }, "statsJson": { "type": "boolean", - "description": "Generates a 'stats.json' file which can be analyzed using tools such as 'webpack-bundle-analyzer'.", + "description": "Generates a 'browser-stats.json' (and 'server-stats.json' when SSR is enabled) file which can be analyzed using tools such as 'webpack-bundle-analyzer'.", "default": false }, "budgets": { diff --git a/packages/angular_devkit/build_angular/src/builders/server/schema.json b/packages/angular_devkit/build_angular/src/builders/server/schema.json index 2375e1176add..99115adb333c 100644 --- a/packages/angular_devkit/build_angular/src/builders/server/schema.json +++ b/packages/angular_devkit/build_angular/src/builders/server/schema.json @@ -208,7 +208,7 @@ }, "statsJson": { "type": "boolean", - "description": "Generates a 'stats.json' file which can be analyzed using tools such as 'webpack-bundle-analyzer'.", + "description": "Generates a 'browser-stats.json' and 'server-stats.json' file which can be analyzed using tools such as 'webpack-bundle-analyzer'.", "default": false }, "watch": { From 6f7da7fc0c849f1ff54f4396d0ea3b3162dfc86f Mon Sep 17 00:00:00 2001 From: Troy Steuwer Date: Tue, 14 Apr 2026 09:10:09 -0400 Subject: [PATCH 09/12] Revert "fix(@angular/build): Fixing the missing browser initial stats file that was in my main branch off of @angular/angular-cli repository" This reverts commit 24c509f5105b00ea73c9873a44b1e24622ab7782. --- .../src/builders/application/execute-build.ts | 22 ++----------------- .../angular/build/src/tools/esbuild/utils.ts | 14 +++--------- .../browser/tests/options/stats-json_spec.ts | 20 +++++------------ .../src/tools/webpack/configs/common.ts | 3 --- 4 files changed, 11 insertions(+), 48 deletions(-) diff --git a/packages/angular/build/src/builders/application/execute-build.ts b/packages/angular/build/src/builders/application/execute-build.ts index 62f8d948566d..d844ab0814c6 100644 --- a/packages/angular/build/src/builders/application/execute-build.ts +++ b/packages/angular/build/src/builders/application/execute-build.ts @@ -308,31 +308,13 @@ export async function executeBuild( if (options.stats) { executionResult.addOutputFile( 'browser-stats.json', - JSON.stringify(buildMetafileForType(metafile, 'browser', false, outputFiles), null, 2), - BuildOutputFileType.Root, - ); - executionResult.addOutputFile( - 'browser-initial-stats.json', - JSON.stringify( - buildMetafileForType(metafile, 'browser', true, outputFiles, initialFiles), - null, - 2, - ), + JSON.stringify(buildMetafileForType(metafile, 'browser', outputFiles), null, 2), BuildOutputFileType.Root, ); if (ssrOutputEnabled) { executionResult.addOutputFile( 'server-stats.json', - JSON.stringify(buildMetafileForType(metafile, 'server', false, outputFiles), null, 2), - BuildOutputFileType.Root, - ); - executionResult.addOutputFile( - 'server-initial-stats.json', - JSON.stringify( - buildMetafileForType(metafile, 'server', true, outputFiles, initialFiles), - null, - 2, - ), + JSON.stringify(buildMetafileForType(metafile, 'server', outputFiles), null, 2), BuildOutputFileType.Root, ); } diff --git a/packages/angular/build/src/tools/esbuild/utils.ts b/packages/angular/build/src/tools/esbuild/utils.ts index c27fda8f275a..9132822fda28 100644 --- a/packages/angular/build/src/tools/esbuild/utils.ts +++ b/packages/angular/build/src/tools/esbuild/utils.ts @@ -32,12 +32,8 @@ import { export function buildMetafileForType( metafile: Metafile, type: 'browser' | 'server', - initial: boolean, outputFiles: BuildOutputFile[], - initialFiles?: Map, ): Metafile { - const isServer = type === 'server'; - const outputPathsForType = new Set( outputFiles .filter(({ type: fileType }) => { @@ -45,20 +41,16 @@ export function buildMetafileForType( fileType === BuildOutputFileType.ServerApplication || fileType === BuildOutputFileType.ServerRoot; - return isServer ? isServerFile : !isServerFile; + return type === 'server' ? isServerFile : !isServerFile; }) .map(({ path }) => path), ); const filteredOutputs: Metafile['outputs'] = {}; for (const [outputPath, output] of Object.entries(metafile.outputs)) { - if (!outputPathsForType.has(outputPath)) { - continue; - } - if (initial && !initialFiles?.has(outputPath)) { - continue; + if (outputPathsForType.has(outputPath)) { + filteredOutputs[outputPath] = output; } - filteredOutputs[outputPath] = output; } const referencedInputs = new Set(); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/tests/options/stats-json_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/tests/options/stats-json_spec.ts index 2a901997e333..609e21fcef5a 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser/tests/options/stats-json_spec.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser/tests/options/stats-json_spec.ts @@ -26,18 +26,12 @@ describeBuilder(buildWebpackBrowser, BROWSER_BUILDER_INFO, (harness) => { expect(result?.success).toBe(true); - if (harness.expectFile('dist/browser-stats.json').toExist()) { - const content = harness.readFile('dist/browser-stats.json'); + if (harness.expectFile('dist/stats.json').toExist()) { + const content = harness.readFile('dist/stats.json'); expect(() => JSON.parse(content)) .withContext('Expected Webpack Stats file to be valid JSON.') .not.toThrow(); } - if (harness.expectFile('dist/browser-initial-stats.json').toExist()) { - const initialContent = harness.readFile('dist/browser-initial-stats.json'); - expect(() => JSON.parse(initialContent)) - .withContext('Expected Webpack Stats file to be valid JSON.') - .not.toThrow(); - } }); // TODO: Investigate why this profiling object is no longer present in Webpack 5.90.3+ and if this should even be tested @@ -51,8 +45,8 @@ describeBuilder(buildWebpackBrowser, BROWSER_BUILDER_INFO, (harness) => { expect(result?.success).toBe(true); - if (harness.expectFile('dist/browser-stats.json').toExist()) { - const stats = JSON.parse(harness.readFile('dist/browser-stats.json')); + if (harness.expectFile('dist/stats.json').toExist()) { + const stats = JSON.parse(harness.readFile('dist/stats.json')); expect(stats?.chunks?.[0]?.modules?.[0]?.profile?.building).toBeDefined(); } }); @@ -67,8 +61,7 @@ describeBuilder(buildWebpackBrowser, BROWSER_BUILDER_INFO, (harness) => { expect(result?.success).toBe(true); - harness.expectFile('dist/browser-stats.json').toNotExist(); - harness.expectFile('dist/browser-initial-stats.json').toNotExist(); + harness.expectFile('dist/stats.json').toNotExist(); }); it('does not generate a Webpack Stats file in output when not present', async () => { @@ -80,8 +73,7 @@ describeBuilder(buildWebpackBrowser, BROWSER_BUILDER_INFO, (harness) => { expect(result?.success).toBe(true); - harness.expectFile('dist/browser-stats.json').toNotExist(); - harness.expectFile('dist/browser-initial-stats.json').toNotExist(); + harness.expectFile('dist/stats.json').toNotExist(); }); }); }); diff --git a/packages/angular_devkit/build_angular/src/tools/webpack/configs/common.ts b/packages/angular_devkit/build_angular/src/tools/webpack/configs/common.ts index 37c7423c566d..8375751e9403 100644 --- a/packages/angular_devkit/build_angular/src/tools/webpack/configs/common.ts +++ b/packages/angular_devkit/build_angular/src/tools/webpack/configs/common.ts @@ -245,9 +245,6 @@ export async function getCommonConfig(wco: WebpackConfigOptions): Promise Date: Tue, 14 Apr 2026 09:10:11 -0400 Subject: [PATCH 10/12] Revert "feat(@angular/build): Support splitting browser and server stats json files for easier consumption" This reverts commit 9545676bec661e441b770c2523819e052e72a12f. --- .../src/builders/application/execute-build.ts | 16 ++----- .../src/builders/application/schema.json | 2 +- .../angular/build/src/tools/esbuild/utils.ts | 44 ------------------- .../src/builders/browser-esbuild/schema.json | 2 +- .../src/builders/browser/schema.json | 2 +- .../builders/browser/specs/stats-json_spec.ts | 6 +-- .../src/builders/server/schema.json | 2 +- .../src/tools/webpack/configs/common.ts | 7 +-- 8 files changed, 14 insertions(+), 67 deletions(-) diff --git a/packages/angular/build/src/builders/application/execute-build.ts b/packages/angular/build/src/builders/application/execute-build.ts index d844ab0814c6..aaddc5b6ef7e 100644 --- a/packages/angular/build/src/builders/application/execute-build.ts +++ b/packages/angular/build/src/builders/application/execute-build.ts @@ -20,7 +20,6 @@ import { checkCommonJSModules } from '../../tools/esbuild/commonjs-checker'; import { extractLicenses } from '../../tools/esbuild/license-extractor'; import { profileAsync } from '../../tools/esbuild/profiling'; import { - buildMetafileForType, calculateEstimatedTransferSizes, logBuildStats, transformSupportedBrowsersToTargets, @@ -302,22 +301,13 @@ export async function executeBuild( BuildOutputFileType.Root, ); - const ssrOutputEnabled: boolean = !!ssrOptions; - // Write metafile if stats option is enabled if (options.stats) { executionResult.addOutputFile( - 'browser-stats.json', - JSON.stringify(buildMetafileForType(metafile, 'browser', outputFiles), null, 2), + 'stats.json', + JSON.stringify(metafile, null, 2), BuildOutputFileType.Root, ); - if (ssrOutputEnabled) { - executionResult.addOutputFile( - 'server-stats.json', - JSON.stringify(buildMetafileForType(metafile, 'server', outputFiles), null, 2), - BuildOutputFileType.Root, - ); - } } if (!jsonLogs) { @@ -332,7 +322,7 @@ export async function executeBuild( colors, changedFiles, estimatedTransferSizes, - ssrOutputEnabled, + !!ssrOptions, verbose, ), ); diff --git a/packages/angular/build/src/builders/application/schema.json b/packages/angular/build/src/builders/application/schema.json index 76203437650b..5498a21fe004 100644 --- a/packages/angular/build/src/builders/application/schema.json +++ b/packages/angular/build/src/builders/application/schema.json @@ -535,7 +535,7 @@ }, "statsJson": { "type": "boolean", - "description": "Generates a 'browser-stats.json' (and 'server-stats.json' when SSR is enabled) file which can be analyzed with https://esbuild.github.io/analyze/.", + "description": "Generates a 'stats.json' file which can be analyzed with https://esbuild.github.io/analyze/.", "default": false }, "budgets": { diff --git a/packages/angular/build/src/tools/esbuild/utils.ts b/packages/angular/build/src/tools/esbuild/utils.ts index 9132822fda28..2730dafae97c 100644 --- a/packages/angular/build/src/tools/esbuild/utils.ts +++ b/packages/angular/build/src/tools/esbuild/utils.ts @@ -29,50 +29,6 @@ import { PrerenderedRoutesRecord, } from './bundler-execution-result'; -export function buildMetafileForType( - metafile: Metafile, - type: 'browser' | 'server', - outputFiles: BuildOutputFile[], -): Metafile { - const outputPathsForType = new Set( - outputFiles - .filter(({ type: fileType }) => { - const isServerFile = - fileType === BuildOutputFileType.ServerApplication || - fileType === BuildOutputFileType.ServerRoot; - - return type === 'server' ? isServerFile : !isServerFile; - }) - .map(({ path }) => path), - ); - - const filteredOutputs: Metafile['outputs'] = {}; - for (const [outputPath, output] of Object.entries(metafile.outputs)) { - if (outputPathsForType.has(outputPath)) { - filteredOutputs[outputPath] = output; - } - } - - const referencedInputs = new Set(); - for (const output of Object.values(filteredOutputs)) { - for (const inputPath of Object.keys(output.inputs)) { - referencedInputs.add(inputPath); - } - } - - const filteredInputs: Metafile['inputs'] = {}; - for (const [inputPath, input] of Object.entries(metafile.inputs)) { - if (referencedInputs.has(inputPath)) { - filteredInputs[inputPath] = input; - } - } - - return { - inputs: filteredInputs, - outputs: filteredOutputs, - }; -} - export function logBuildStats( metafile: Metafile, outputFiles: BuildOutputFile[], diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/schema.json b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/schema.json index 5db2b753d7bf..edb91222d954 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/schema.json +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/schema.json @@ -406,7 +406,7 @@ }, "statsJson": { "type": "boolean", - "description": "Generates a 'browser-stats.json' file which can be analyzed using tools such as 'webpack-bundle-analyzer'.", + "description": "Generates a 'stats.json' file which can be analyzed using tools such as 'webpack-bundle-analyzer'.", "default": false }, "budgets": { diff --git a/packages/angular_devkit/build_angular/src/builders/browser/schema.json b/packages/angular_devkit/build_angular/src/builders/browser/schema.json index bf73adeabb29..301eeafcc4f1 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser/schema.json +++ b/packages/angular_devkit/build_angular/src/builders/browser/schema.json @@ -394,7 +394,7 @@ }, "statsJson": { "type": "boolean", - "description": "Generates a 'browser-stats.json' (and 'server-stats.json' when SSR is enabled) file which can be analyzed using tools such as 'webpack-bundle-analyzer'.", + "description": "Generates a 'stats.json' file which can be analyzed using tools such as 'webpack-bundle-analyzer'.", "default": false }, "budgets": { diff --git a/packages/angular_devkit/build_angular/src/builders/browser/specs/stats-json_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/specs/stats-json_spec.ts index 2bbe9e52e72d..175b61d7ca12 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser/specs/stats-json_spec.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser/specs/stats-json_spec.ts @@ -21,13 +21,13 @@ describe('Browser Builder stats json', () => { it('works', async () => { const { files } = await browserBuild(architect, host, target, { statsJson: true }); - expect('browser-stats.json' in files).toBe(true); + expect('stats.json' in files).toBe(true); }); it('works with profile flag', async () => { const { files } = await browserBuild(architect, host, target, { statsJson: true }); - expect('browser-stats.json' in files).toBe(true); - const stats = JSON.parse(await files['browser-stats.json']); + expect('stats.json' in files).toBe(true); + const stats = JSON.parse(await files['stats.json']); expect(stats.chunks[0].modules[0].profile.building).toBeDefined(); }); }); diff --git a/packages/angular_devkit/build_angular/src/builders/server/schema.json b/packages/angular_devkit/build_angular/src/builders/server/schema.json index 99115adb333c..2375e1176add 100644 --- a/packages/angular_devkit/build_angular/src/builders/server/schema.json +++ b/packages/angular_devkit/build_angular/src/builders/server/schema.json @@ -208,7 +208,7 @@ }, "statsJson": { "type": "boolean", - "description": "Generates a 'browser-stats.json' and 'server-stats.json' file which can be analyzed using tools such as 'webpack-bundle-analyzer'.", + "description": "Generates a 'stats.json' file which can be analyzed using tools such as 'webpack-bundle-analyzer'.", "default": false }, "watch": { diff --git a/packages/angular_devkit/build_angular/src/tools/webpack/configs/common.ts b/packages/angular_devkit/build_angular/src/tools/webpack/configs/common.ts index 8375751e9403..b18c8ebc9be9 100644 --- a/packages/angular_devkit/build_angular/src/tools/webpack/configs/common.ts +++ b/packages/angular_devkit/build_angular/src/tools/webpack/configs/common.ts @@ -83,8 +83,9 @@ export async function getCommonConfig(wco: WebpackConfigOptions): Promise Date: Tue, 14 Apr 2026 09:50:25 -0400 Subject: [PATCH 11/12] Updating changes to be more inline with the code review requests. Reverting all previous changes and then splitting out the browser/server metafiles and then filtering at the end when were generating the json files --- .../src/builders/application/execute-build.ts | 30 ++++++++++++++++--- .../esbuild/angular/component-stylesheets.ts | 1 + .../src/tools/esbuild/bundler-context.ts | 19 ++++++++++++ .../angular/build/src/tools/esbuild/utils.ts | 30 +++++++++++++++++++ 4 files changed, 76 insertions(+), 4 deletions(-) diff --git a/packages/angular/build/src/builders/application/execute-build.ts b/packages/angular/build/src/builders/application/execute-build.ts index aaddc5b6ef7e..e23ad03ae5fd 100644 --- a/packages/angular/build/src/builders/application/execute-build.ts +++ b/packages/angular/build/src/builders/application/execute-build.ts @@ -21,6 +21,7 @@ import { extractLicenses } from '../../tools/esbuild/license-extractor'; import { profileAsync } from '../../tools/esbuild/profiling'; import { calculateEstimatedTransferSizes, + filterMetafile, logBuildStats, transformSupportedBrowsersToTargets, } from '../../tools/esbuild/utils'; @@ -209,7 +210,7 @@ export async function executeBuild( executionResult.setExternalMetadata(implicitBrowser, implicitServer, [...explicitExternal]); } - const { metafile, initialFiles, outputFiles } = bundlingResult; + const { metafile, browserMetafile, serverMetafile, initialFiles, outputFiles } = bundlingResult; executionResult.outputFiles.push(...outputFiles); @@ -301,13 +302,34 @@ export async function executeBuild( BuildOutputFileType.Root, ); - // Write metafile if stats option is enabled + // Write metafiles if stats option is enabled, split by browser/server and initial/non-initial if (options.stats) { + const filterInitialFiles = (outputPath: string) => initialFiles.has(outputPath); + const filterNonInitialFiles = (outputPath: string) => !initialFiles.has(outputPath); + + executionResult.addOutputFile( + 'browser-stats.json', + JSON.stringify(filterMetafile(browserMetafile, filterNonInitialFiles), null, 2), + BuildOutputFileType.Root, + ); executionResult.addOutputFile( - 'stats.json', - JSON.stringify(metafile, null, 2), + 'browser-initial-stats.json', + JSON.stringify(filterMetafile(browserMetafile, filterInitialFiles), null, 2), BuildOutputFileType.Root, ); + + if (serverMetafile) { + executionResult.addOutputFile( + 'server-stats.json', + JSON.stringify(filterMetafile(serverMetafile, filterNonInitialFiles), null, 2), + BuildOutputFileType.Root, + ); + executionResult.addOutputFile( + 'server-initial-stats.json', + JSON.stringify(filterMetafile(serverMetafile, filterInitialFiles), null, 2), + BuildOutputFileType.Root, + ); + } } if (!jsonLogs) { diff --git a/packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts b/packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts index 3b8d12ec1461..b6670b3ab98a 100644 --- a/packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts +++ b/packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts @@ -278,6 +278,7 @@ export class ComponentStylesheetBundler { contents, outputFiles, metafile, + browserMetafile: metafile, referencedFiles, externalImports: result.externalImports, initialFiles: new Map(), diff --git a/packages/angular/build/src/tools/esbuild/bundler-context.ts b/packages/angular/build/src/tools/esbuild/bundler-context.ts index 864ca2c6fdd9..8544c426a294 100644 --- a/packages/angular/build/src/tools/esbuild/bundler-context.ts +++ b/packages/angular/build/src/tools/esbuild/bundler-context.ts @@ -29,6 +29,8 @@ export type BundleContextResult = errors: undefined; warnings: Message[]; metafile: Metafile; + browserMetafile: Metafile; + serverMetafile?: Metafile; outputFiles: BuildOutputFile[]; initialFiles: Map; externalImports: { @@ -128,6 +130,8 @@ export class BundlerContext { let errors: Message[] | undefined; const warnings: Message[] = []; const metafile: Metafile = { inputs: {}, outputs: {} }; + const browserMetafile: Metafile = { inputs: {}, outputs: {} }; + let serverMetafile: Metafile | undefined; const initialFiles = new Map(); const externalImportsBrowser = new Set(); const externalImportsServer = new Set(); @@ -148,6 +152,17 @@ export class BundlerContext { Object.assign(metafile.outputs, result.metafile.outputs); } + // Keep browser and server metafiles isolated for separate stats output + if (result.browserMetafile) { + Object.assign(browserMetafile.inputs, result.browserMetafile.inputs); + Object.assign(browserMetafile.outputs, result.browserMetafile.outputs); + } + if (result.serverMetafile) { + serverMetafile ??= { inputs: {}, outputs: {} }; + Object.assign(serverMetafile.inputs, result.serverMetafile.inputs); + Object.assign(serverMetafile.outputs, result.serverMetafile.outputs); + } + result.initialFiles.forEach((value, key) => initialFiles.set(key, value)); outputFiles.push(...result.outputFiles); @@ -170,6 +185,8 @@ export class BundlerContext { errors, warnings, metafile, + browserMetafile, + serverMetafile, initialFiles, outputFiles, externalImports: { @@ -414,6 +431,8 @@ export class BundlerContext { [isPlatformServer ? 'server' : 'browser']: externalImports, }, externalConfiguration, + browserMetafile: isPlatformServer ? { inputs: {}, outputs: {} } : result.metafile, + serverMetafile: isPlatformServer ? result.metafile : undefined, errors: undefined, }; } diff --git a/packages/angular/build/src/tools/esbuild/utils.ts b/packages/angular/build/src/tools/esbuild/utils.ts index 2730dafae97c..a17c80df42d1 100644 --- a/packages/angular/build/src/tools/esbuild/utils.ts +++ b/packages/angular/build/src/tools/esbuild/utils.ts @@ -29,6 +29,36 @@ import { PrerenderedRoutesRecord, } from './bundler-execution-result'; +/** + * Filters a metafile to only include outputs matching a predicate, + * along with the inputs those outputs directly reference. + */ +export function filterMetafile( + metafile: Metafile, + predicate: (outputPath: string) => boolean, +): Metafile { + const filteredOutputs: Metafile['outputs'] = {}; + const referencedInputs = new Set(); + + for (const [path, output] of Object.entries(metafile.outputs)) { + if (predicate(path)) { + filteredOutputs[path] = output; + for (const inputPath of Object.keys(output.inputs)) { + referencedInputs.add(inputPath); + } + } + } + + const filteredInputs: Metafile['inputs'] = {}; + for (const [inputPath, input] of Object.entries(metafile.inputs)) { + if (referencedInputs.has(inputPath)) { + filteredInputs[inputPath] = input; + } + } + + return { inputs: filteredInputs, outputs: filteredOutputs }; +} + export function logBuildStats( metafile: Metafile, outputFiles: BuildOutputFile[], From 41219f3054c6027e49a8a2bd68aa9f3c540205cf Mon Sep 17 00:00:00 2001 From: Troy Steuwer Date: Tue, 14 Apr 2026 09:58:38 -0400 Subject: [PATCH 12/12] Adding unit tests --- .../tests/options/stats-json_spec.ts | 185 ++++++++++++++++++ .../build/src/tools/esbuild/utils_spec.ts | 134 +++++++++++++ 2 files changed, 319 insertions(+) create mode 100644 packages/angular/build/src/builders/application/tests/options/stats-json_spec.ts create mode 100644 packages/angular/build/src/tools/esbuild/utils_spec.ts diff --git a/packages/angular/build/src/builders/application/tests/options/stats-json_spec.ts b/packages/angular/build/src/builders/application/tests/options/stats-json_spec.ts new file mode 100644 index 000000000000..73e33f573d37 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/stats-json_spec.ts @@ -0,0 +1,185 @@ +/** + * @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 { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +/** Minimal subset of an esbuild metafile used by stats assertions. */ +interface StatsMetafile { + inputs: Record; + outputs: Record }>; +} + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Option: "statsJson"', () => { + describe('browser-only build', () => { + beforeEach(() => { + harness.useTarget('build', { + ...BASE_OPTIONS, + statsJson: true, + }); + }); + + it('generates browser-stats.json and browser-initial-stats.json', async () => { + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + harness.expectFile('dist/browser-stats.json').toExist(); + harness.expectFile('dist/browser-initial-stats.json').toExist(); + }); + + it('does not generate server stats files when SSR is disabled', async () => { + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + harness.expectFile('dist/server-stats.json').toNotExist(); + harness.expectFile('dist/server-initial-stats.json').toNotExist(); + }); + + it('does not generate the legacy stats.json file', async () => { + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + harness.expectFile('dist/stats.json').toNotExist(); + }); + + it('stats files contain valid esbuild metafile structure', async () => { + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + + for (const filename of ['dist/browser-stats.json', 'dist/browser-initial-stats.json']) { + const stats = JSON.parse(harness.readFile(filename)) as StatsMetafile; + expect(stats.inputs).withContext(`${filename} must have an inputs field`).toBeDefined(); + expect(stats.outputs).withContext(`${filename} must have an outputs field`).toBeDefined(); + } + }); + + it('output paths do not overlap between browser-stats.json and browser-initial-stats.json', async () => { + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + + const nonInitialPaths = new Set( + Object.keys( + (JSON.parse(harness.readFile('dist/browser-stats.json')) as StatsMetafile).outputs, + ), + ); + const initialPaths = Object.keys( + (JSON.parse(harness.readFile('dist/browser-initial-stats.json')) as StatsMetafile) + .outputs, + ); + + for (const outputPath of initialPaths) { + expect(nonInitialPaths.has(outputPath)) + .withContext(`Output '${outputPath}' must not appear in both stats files`) + .toBeFalse(); + } + }); + + it('inputs in each stats file are only those referenced by included outputs', async () => { + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + + for (const filename of ['dist/browser-stats.json', 'dist/browser-initial-stats.json']) { + const stats = JSON.parse(harness.readFile(filename)) as StatsMetafile; + const referencedInputs = new Set( + Object.values(stats.outputs).flatMap((output) => Object.keys(output.inputs)), + ); + + for (const inputPath of Object.keys(stats.inputs)) { + expect(referencedInputs.has(inputPath)) + .withContext( + `Input '${inputPath}' in '${filename}' is not referenced by any included output`, + ) + .toBeTrue(); + } + } + }); + }); + + describe('when statsJson is false', () => { + it('does not generate any stats files', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + statsJson: false, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + harness.expectFile('dist/browser-stats.json').toNotExist(); + harness.expectFile('dist/browser-initial-stats.json').toNotExist(); + harness.expectFile('dist/stats.json').toNotExist(); + }); + }); + + describe('SSR build', () => { + beforeEach(async () => { + await harness.modifyFile('src/tsconfig.app.json', (content) => { + const tsConfig = JSON.parse(content) as { files?: string[] }; + tsConfig.files ??= []; + tsConfig.files.push('main.server.ts'); + + return JSON.stringify(tsConfig); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + statsJson: true, + server: 'src/main.server.ts', + ssr: true, + }); + }); + + it('generates all four stats files', async () => { + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + harness.expectFile('dist/browser-stats.json').toExist(); + harness.expectFile('dist/browser-initial-stats.json').toExist(); + harness.expectFile('dist/server-stats.json').toExist(); + harness.expectFile('dist/server-initial-stats.json').toExist(); + }); + + it('server stats files contain valid esbuild metafile structure', async () => { + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + + for (const filename of ['dist/server-stats.json', 'dist/server-initial-stats.json']) { + const stats = JSON.parse(harness.readFile(filename)) as StatsMetafile; + expect(stats.inputs).withContext(`${filename} must have an inputs field`).toBeDefined(); + expect(stats.outputs).withContext(`${filename} must have an outputs field`).toBeDefined(); + } + }); + + it('server output paths do not overlap between server-stats.json and server-initial-stats.json', async () => { + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + + const nonInitialPaths = new Set( + Object.keys( + (JSON.parse(harness.readFile('dist/server-stats.json')) as StatsMetafile).outputs, + ), + ); + const initialPaths = Object.keys( + (JSON.parse(harness.readFile('dist/server-initial-stats.json')) as StatsMetafile).outputs, + ); + + for (const outputPath of initialPaths) { + expect(nonInitialPaths.has(outputPath)) + .withContext(`Output '${outputPath}' must not appear in both server stats files`) + .toBeFalse(); + } + }); + }); + }); +}); diff --git a/packages/angular/build/src/tools/esbuild/utils_spec.ts b/packages/angular/build/src/tools/esbuild/utils_spec.ts new file mode 100644 index 000000000000..702eb13d815a --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/utils_spec.ts @@ -0,0 +1,134 @@ +/** + * @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 { filterMetafile } from './utils'; + +// Derive the Metafile type from filterMetafile's own signature to avoid a direct esbuild import. +type TestMetafile = Parameters[0]; + +/** + * Builds a minimal Metafile-shaped object for testing filterMetafile. + * @param outputsWithInputs Maps each output path to the input paths it references. + * @param unreferencedInputs Additional input paths that exist in the metafile but + * are not referenced by any output. + */ +function createMetafile( + outputsWithInputs: Record, + unreferencedInputs: string[] = [], +): TestMetafile { + const inputs: TestMetafile['inputs'] = {}; + const outputs: TestMetafile['outputs'] = {}; + + for (const path of unreferencedInputs) { + inputs[path] = { bytes: 0, imports: [] }; + } + + for (const [outputPath, inputPaths] of Object.entries(outputsWithInputs)) { + const outputInputs: TestMetafile['outputs'][string]['inputs'] = {}; + for (const inputPath of inputPaths) { + outputInputs[inputPath] = { bytesInOutput: 0 }; + inputs[inputPath] ??= { bytes: 0, imports: [] }; + } + outputs[outputPath] = { bytes: 0, inputs: outputInputs, imports: [], exports: [] }; + } + + return { inputs, outputs }; +} + +describe('filterMetafile', () => { + it('returns only outputs matching the predicate', () => { + const metafile = createMetafile({ + 'browser/main.js': ['src/main.ts'], + 'browser/polyfills.js': ['src/polyfills.ts'], + 'server/server.mjs': ['src/server.ts'], + }); + + const result = filterMetafile(metafile, (path) => path.startsWith('browser/')); + + expect(Object.keys(result.outputs)).toEqual( + jasmine.arrayContaining(['browser/main.js', 'browser/polyfills.js']), + ); + expect(Object.keys(result.outputs)).not.toContain('server/server.mjs'); + }); + + it('includes only inputs referenced by outputs that match the predicate', () => { + const metafile = createMetafile({ + 'browser/main.js': ['src/main.ts', 'src/app.ts'], + 'server/server.mjs': ['src/server.ts'], + }); + + const result = filterMetafile(metafile, (path) => path.startsWith('browser/')); + + expect(Object.keys(result.inputs)).toContain('src/main.ts'); + expect(Object.keys(result.inputs)).toContain('src/app.ts'); + expect(Object.keys(result.inputs)).not.toContain('src/server.ts'); + }); + + it('excludes unreferenced inputs even when they exist in the original metafile', () => { + const metafile = createMetafile({ 'browser/main.js': ['src/main.ts'] }, [ + 'src/unreferenced.ts', + ]); + + const result = filterMetafile(metafile, () => true); + + expect(Object.keys(result.inputs)).not.toContain('src/unreferenced.ts'); + }); + + it('returns empty outputs and inputs when predicate never matches', () => { + const metafile = createMetafile({ + 'browser/main.js': ['src/main.ts'], + 'browser/polyfills.js': ['src/polyfills.ts'], + }); + + const result = filterMetafile(metafile, () => false); + + expect(Object.keys(result.outputs)).toEqual([]); + expect(Object.keys(result.inputs)).toEqual([]); + }); + + it('returns all outputs and their referenced inputs when predicate always matches', () => { + const metafile = createMetafile({ + 'browser/main.js': ['src/main.ts'], + 'browser/polyfills.js': ['src/polyfills.ts'], + }); + + const result = filterMetafile(metafile, () => true); + + expect(Object.keys(result.outputs).length).toBe(2); + expect(Object.keys(result.inputs)).toEqual( + jasmine.arrayContaining(['src/main.ts', 'src/polyfills.ts']), + ); + }); + + it('deduplicates inputs referenced by multiple matching outputs', () => { + const metafile = createMetafile({ + 'browser/main.js': ['src/shared.ts', 'src/main.ts'], + 'browser/polyfills.js': ['src/shared.ts', 'src/polyfills.ts'], + }); + + const result = filterMetafile(metafile, () => true); + + const inputKeys = Object.keys(result.inputs); + const sharedOccurrences = inputKeys.filter((k) => k === 'src/shared.ts').length; + expect(sharedOccurrences).toBe(1); + }); + + it('does not mutate the original metafile', () => { + const metafile = createMetafile({ + 'browser/main.js': ['src/main.ts'], + 'server/server.mjs': ['src/server.ts'], + }); + const originalOutputCount = Object.keys(metafile.outputs).length; + const originalInputCount = Object.keys(metafile.inputs).length; + + filterMetafile(metafile, (path) => path.startsWith('browser/')); + + expect(Object.keys(metafile.outputs).length).toBe(originalOutputCount); + expect(Object.keys(metafile.inputs).length).toBe(originalInputCount); + }); +});