From b9f59b6f3676bb5f0a4eeb0e00456c1479b7779f Mon Sep 17 00:00:00 2001 From: Troy Steuwer Date: Sun, 17 May 2026 08:42:50 -0400 Subject: [PATCH] feat(@angular/build): Support splitting browser and server stats jsonfiles 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 and addresses #28185 #28671. Today, everything gets dumped into a single file and it's nearly impossible to use without hours of `fix -> remove unused browser/server 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. This will be required to be in the next Major version as it will break any existing build pipeline that relies on a single stats.json file. --- .../builders/application/chunk-optimizer.ts | 20 +++ .../src/builders/application/execute-build.ts | 61 ++++++- .../tests/options/stats-json_spec.ts | 165 ++++++++++++++++++ .../esbuild/angular/component-stylesheets.ts | 4 +- .../src/tools/esbuild/bundler-context.ts | 15 +- 5 files changed, 259 insertions(+), 6 deletions(-) create mode 100644 packages/angular/build/src/builders/application/tests/options/stats-json_spec.ts diff --git a/packages/angular/build/src/builders/application/chunk-optimizer.ts b/packages/angular/build/src/builders/application/chunk-optimizer.ts index 1586b48155ea..872f8fa56f55 100644 --- a/packages/angular/build/src/builders/application/chunk-optimizer.ts +++ b/packages/angular/build/src/builders/application/chunk-optimizer.ts @@ -413,5 +413,25 @@ export async function optimizeChunks( } } + // Rebuild browserMetafile from the updated combined metafile and output files. + // Chunk optimization only affects browser chunks, so serverMetafile is unchanged. + const browserOutputPaths = new Set( + original.outputFiles.filter((f) => f.type === BuildOutputFileType.Browser).map((f) => f.path), + ); + const newBrowserMetafile: Metafile = { inputs: {}, outputs: {} }; + for (const [path, output] of Object.entries(original.metafile.outputs)) { + if (!browserOutputPaths.has(path)) { + continue; + } + newBrowserMetafile.outputs[path] = output; + for (const inputPath of Object.keys(output.inputs)) { + const input = original.metafile.inputs[inputPath]; + if (input) { + newBrowserMetafile.inputs[inputPath] ??= input; + } + } + } + original.browserMetafile = newBrowserMetafile; + return original; } diff --git a/packages/angular/build/src/builders/application/execute-build.ts b/packages/angular/build/src/builders/application/execute-build.ts index d751eb7d298e..0395c38ef85a 100644 --- a/packages/angular/build/src/builders/application/execute-build.ts +++ b/packages/angular/build/src/builders/application/execute-build.ts @@ -7,12 +7,13 @@ */ 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'; import { BundleContextResult, BundlerContext } from '../../tools/esbuild/bundler-context'; import { ExecutionResult, RebuildState } from '../../tools/esbuild/bundler-execution-result'; -import { BuildOutputFileType } from '../../tools/esbuild/bundler-files'; +import { BuildOutputFileType, type InitialFileRecord } from '../../tools/esbuild/bundler-files'; import { checkCommonJSModules } from '../../tools/esbuild/commonjs-checker'; import { extractLicenses } from '../../tools/esbuild/license-extractor'; import { profileAsync } from '../../tools/esbuild/profiling'; @@ -34,6 +35,38 @@ import { inlineI18n, loadActiveTranslations } from './i18n'; import { NormalizedApplicationBuildOptions } from './options'; import { createComponentStyleBundler, setupBundlerContexts } from './setup-bundling'; +/** + * Returns a copy of the given metafile containing only outputs that appear in the + * provided initial-files map, with inputs filtered to those referenced by those outputs. + */ +function createInitialMetafile( + 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)) { + continue; + } + filteredOutputs[path] = output; + for (const inputPath of Object.keys(output.inputs)) { + referencedInputs.add(inputPath); + } + } + + const filteredInputs: Metafile['inputs'] = {}; + for (const path of referencedInputs) { + const input = metafile.inputs[path]; + if (input) { + filteredInputs[path] = input; + } + } + + return { inputs: filteredInputs, outputs: filteredOutputs }; +} + // eslint-disable-next-line max-lines-per-function export async function executeBuild( options: NormalizedApplicationBuildOptions, @@ -322,13 +355,33 @@ export async function executeBuild( BuildOutputFileType.Root, ); - // Write metafile if stats option is enabled + // Write metafiles if stats option is enabled if (options.stats) { + const { browserMetafile, serverMetafile } = bundlingResult; + + executionResult.addOutputFile( + 'browser-stats.json', + JSON.stringify(browserMetafile, null, 2), + BuildOutputFileType.Root, + ); executionResult.addOutputFile( - 'stats.json', - JSON.stringify(metafile, null, 2), + 'browser-initial-stats.json', + JSON.stringify(createInitialMetafile(browserMetafile, initialFiles), null, 2), BuildOutputFileType.Root, ); + + if (ssrOptions) { + executionResult.addOutputFile( + 'server-stats.json', + JSON.stringify(serverMetafile, null, 2), + BuildOutputFileType.Root, + ); + executionResult.addOutputFile( + 'server-initial-stats.json', + JSON.stringify(createInitialMetafile(serverMetafile, initialFiles), null, 2), + BuildOutputFileType.Root, + ); + } } if (!jsonLogs && !options.quiet) { 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..8ed22a52d8b3 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/stats-json_spec.ts @@ -0,0 +1,165 @@ +/** + * @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'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Option: "statsJson"', () => { + describe('browser-only build', () => { + it('generates only browser stats files when statsJson is true', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + statsJson: true, + }); + + 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').toNotExist(); + harness.expectFile('dist/server-initial-stats.json').toNotExist(); + }); + + it('does not generate any stats files when statsJson is false', 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/server-stats.json').toNotExist(); + harness.expectFile('dist/server-initial-stats.json').toNotExist(); + }); + + it('does not generate legacy stats.json when statsJson is true', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + statsJson: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('dist/stats.json').toNotExist(); + }); + + it('browser-stats.json contains valid esbuild metafile with inputs and outputs', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + statsJson: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + const content = harness.readFile('dist/browser-stats.json'); + const parsed = JSON.parse(content) as { inputs: unknown; outputs: unknown }; + expect(parsed.inputs).toBeDefined(); + expect(parsed.outputs).toBeDefined(); + }); + + it('browser-initial-stats.json contains only a subset of browser-stats.json outputs', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + statsJson: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + const allStats = JSON.parse(harness.readFile('dist/browser-stats.json')) as { + outputs: Record; + }; + const initialStats = JSON.parse(harness.readFile('dist/browser-initial-stats.json')) as { + outputs: Record; + }; + + const allOutputCount = Object.keys(allStats.outputs).length; + const initialOutputCount = Object.keys(initialStats.outputs).length; + + expect(allOutputCount).toBeGreaterThanOrEqual(initialOutputCount); + for (const path of Object.keys(initialStats.outputs)) { + expect(allStats.outputs[path]).toBeDefined(); + } + }); + }); + + 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); + }); + }); + + it('generates all four stats files for an SSR build', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + server: 'src/main.server.ts', + ssr: true, + statsJson: true, + }); + + 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.json has non-empty outputs for an SSR build', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + server: 'src/main.server.ts', + ssr: true, + statsJson: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + const content = harness.readFile('dist/server-stats.json'); + const parsed = JSON.parse(content) as { outputs: Record }; + expect(Object.keys(parsed.outputs).length).toBeGreaterThan(0); + }); + + it('browser-stats.json does not contain server output paths for an SSR build', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + server: 'src/main.server.ts', + ssr: true, + statsJson: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + const browserStats = JSON.parse(harness.readFile('dist/browser-stats.json')) as { + outputs: Record; + }; + const serverStats = JSON.parse(harness.readFile('dist/server-stats.json')) as { + outputs: Record; + }; + + const browserPaths = new Set(Object.keys(browserStats.outputs)); + for (const path of Object.keys(serverStats.outputs)) { + expect(browserPaths.has(path)) + .withContext(`Server output '${path}' should not appear in browser-stats.json`) + .toBeFalse(); + } + }); + }); + }); +}); 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 79008d140729..15cbdcfd3f3d 100644 --- a/packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts +++ b/packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts @@ -259,7 +259,7 @@ export class ComponentStylesheetBundler { } } - const metafile = result.metafile; + const { metafile, browserMetafile, serverMetafile } = result; // Remove entryPoint fields from outputs to prevent the internal component styles from being // treated as initial files. Also mark the entry as a component resource for stat reporting. Object.values(metafile.outputs).forEach((output) => { @@ -274,6 +274,8 @@ export class ComponentStylesheetBundler { contents, outputFiles, metafile, + browserMetafile, + serverMetafile, 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 968815a52fd5..f605474472a1 100644 --- a/packages/angular/build/src/tools/esbuild/bundler-context.ts +++ b/packages/angular/build/src/tools/esbuild/bundler-context.ts @@ -33,6 +33,8 @@ export type BundleContextResult = errors: undefined; warnings: Message[]; metafile: Metafile; + browserMetafile: Metafile; + serverMetafile: Metafile; outputFiles: BuildOutputFile[]; initialFiles: Map; externalImports: { @@ -109,6 +111,8 @@ export class BundlerContext { let errors: Message[] | undefined; const warnings: Message[] = []; const metafile: Metafile = { inputs: {}, outputs: {} }; + const browserMetafile: Metafile = { inputs: {}, outputs: {} }; + const serverMetafile: Metafile = { inputs: {}, outputs: {} }; const initialFiles = new Map(); const externalImportsBrowser = new Set(); const externalImportsServer = new Set(); @@ -123,12 +127,17 @@ export class BundlerContext { continue; } - // Combine metafiles used for the stats option as well as bundle budgets and console output + // Combine metafiles used for the bundle budgets and console output if (result.metafile) { Object.assign(metafile.inputs, result.metafile.inputs); Object.assign(metafile.outputs, result.metafile.outputs); } + Object.assign(browserMetafile.inputs, result.browserMetafile.inputs); + Object.assign(browserMetafile.outputs, result.browserMetafile.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); @@ -151,6 +160,8 @@ export class BundlerContext { errors, warnings, metafile, + browserMetafile, + serverMetafile, initialFiles, outputFiles, externalImports: { @@ -391,6 +402,8 @@ export class BundlerContext { ...result, outputFiles, initialFiles, + browserMetafile: isPlatformServer ? { inputs: {}, outputs: {} } : result.metafile, + serverMetafile: isPlatformServer ? result.metafile : { inputs: {}, outputs: {} }, externalImports: { [isPlatformServer ? 'server' : 'browser']: externalImports, },