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/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/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[], 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); + }); +});