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, },