Skip to content

Commit aa262df

Browse files
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.
1 parent 5dd4daf commit aa262df

5 files changed

Lines changed: 267 additions & 6 deletions

File tree

packages/angular/build/src/builders/application/chunk-optimizer.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,5 +413,22 @@ export async function optimizeChunks(
413413
}
414414
}
415415

416+
// Rebuild browserMetafile from the updated combined metafile and output files.
417+
// Chunk optimization only affects browser chunks, so serverMetafile is unchanged.
418+
const browserOutputPaths = new Set(
419+
original.outputFiles.filter((f) => f.type === BuildOutputFileType.Browser).map((f) => f.path),
420+
);
421+
const newBrowserMetafile: Metafile = { inputs: {}, outputs: {} };
422+
for (const [path, output] of Object.entries(original.metafile.outputs)) {
423+
if (!browserOutputPaths.has(path)) {
424+
continue;
425+
}
426+
newBrowserMetafile.outputs[path] = output;
427+
for (const inputPath of Object.keys(output.inputs)) {
428+
newBrowserMetafile.inputs[inputPath] ??= original.metafile.inputs[inputPath];
429+
}
430+
}
431+
original.browserMetafile = newBrowserMetafile;
432+
416433
return original;
417434
}

packages/angular/build/src/builders/application/execute-build.ts

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,13 @@
77
*/
88

99
import { BuilderContext } from '@angular-devkit/architect';
10+
import type { Metafile } from 'esbuild';
1011
import { createAngularCompilation } from '../../tools/angular/compilation';
1112
import { SourceFileCache } from '../../tools/esbuild/angular/source-file-cache';
1213
import { generateBudgetStats } from '../../tools/esbuild/budget-stats';
1314
import { BundleContextResult, BundlerContext } from '../../tools/esbuild/bundler-context';
1415
import { ExecutionResult, RebuildState } from '../../tools/esbuild/bundler-execution-result';
15-
import { BuildOutputFileType } from '../../tools/esbuild/bundler-files';
16+
import { BuildOutputFileType, type InitialFileRecord } from '../../tools/esbuild/bundler-files';
1617
import { checkCommonJSModules } from '../../tools/esbuild/commonjs-checker';
1718
import { extractLicenses } from '../../tools/esbuild/license-extractor';
1819
import { profileAsync } from '../../tools/esbuild/profiling';
@@ -34,6 +35,38 @@ import { inlineI18n, loadActiveTranslations } from './i18n';
3435
import { NormalizedApplicationBuildOptions } from './options';
3536
import { createComponentStyleBundler, setupBundlerContexts } from './setup-bundling';
3637

38+
/**
39+
* Returns a copy of the given metafile containing only outputs that appear in the
40+
* provided initial-files map, with inputs filtered to those referenced by those outputs.
41+
*/
42+
function createInitialMetafile(
43+
metafile: Metafile,
44+
initialFiles: Map<string, InitialFileRecord>,
45+
): Metafile {
46+
const filteredOutputs: Metafile['outputs'] = {};
47+
const referencedInputs = new Set<string>();
48+
49+
for (const [path, output] of Object.entries(metafile.outputs)) {
50+
if (!initialFiles.has(path)) {
51+
continue;
52+
}
53+
filteredOutputs[path] = output;
54+
for (const inputPath of Object.keys(output.inputs)) {
55+
referencedInputs.add(inputPath);
56+
}
57+
}
58+
59+
const filteredInputs: Metafile['inputs'] = {};
60+
for (const path of referencedInputs) {
61+
const input = metafile.inputs[path];
62+
if (input) {
63+
filteredInputs[path] = input;
64+
}
65+
}
66+
67+
return { inputs: filteredInputs, outputs: filteredOutputs };
68+
}
69+
3770
// eslint-disable-next-line max-lines-per-function
3871
export async function executeBuild(
3972
options: NormalizedApplicationBuildOptions,
@@ -322,11 +355,28 @@ export async function executeBuild(
322355
BuildOutputFileType.Root,
323356
);
324357

325-
// Write metafile if stats option is enabled
358+
// Write metafiles if stats option is enabled
326359
if (options.stats) {
360+
const { browserMetafile, serverMetafile } = bundlingResult;
361+
362+
executionResult.addOutputFile(
363+
'browser-stats.json',
364+
JSON.stringify(browserMetafile, null, 2),
365+
BuildOutputFileType.Root,
366+
);
367+
executionResult.addOutputFile(
368+
'browser-initial-stats.json',
369+
JSON.stringify(createInitialMetafile(browserMetafile, initialFiles), null, 2),
370+
BuildOutputFileType.Root,
371+
);
372+
executionResult.addOutputFile(
373+
'server-stats.json',
374+
JSON.stringify(serverMetafile, null, 2),
375+
BuildOutputFileType.Root,
376+
);
327377
executionResult.addOutputFile(
328-
'stats.json',
329-
JSON.stringify(metafile, null, 2),
378+
'server-initial-stats.json',
379+
JSON.stringify(createInitialMetafile(serverMetafile, initialFiles), null, 2),
330380
BuildOutputFileType.Root,
331381
);
332382
}
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { buildApplication } from '../../index';
10+
import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup';
11+
12+
describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
13+
describe('Option: "statsJson"', () => {
14+
describe('browser-only build', () => {
15+
it('generates all four stats files when statsJson is true', async () => {
16+
harness.useTarget('build', {
17+
...BASE_OPTIONS,
18+
statsJson: true,
19+
});
20+
21+
const { result } = await harness.executeOnce();
22+
expect(result?.success).toBeTrue();
23+
harness.expectFile('dist/browser-stats.json').toExist();
24+
harness.expectFile('dist/browser-initial-stats.json').toExist();
25+
harness.expectFile('dist/server-stats.json').toExist();
26+
harness.expectFile('dist/server-initial-stats.json').toExist();
27+
});
28+
29+
it('does not generate any stats files when statsJson is false', async () => {
30+
harness.useTarget('build', {
31+
...BASE_OPTIONS,
32+
statsJson: false,
33+
});
34+
35+
const { result } = await harness.executeOnce();
36+
expect(result?.success).toBeTrue();
37+
harness.expectFile('dist/browser-stats.json').toNotExist();
38+
harness.expectFile('dist/browser-initial-stats.json').toNotExist();
39+
harness.expectFile('dist/server-stats.json').toNotExist();
40+
harness.expectFile('dist/server-initial-stats.json').toNotExist();
41+
});
42+
43+
it('does not generate legacy stats.json when statsJson is true', async () => {
44+
harness.useTarget('build', {
45+
...BASE_OPTIONS,
46+
statsJson: true,
47+
});
48+
49+
const { result } = await harness.executeOnce();
50+
expect(result?.success).toBeTrue();
51+
harness.expectFile('dist/stats.json').toNotExist();
52+
});
53+
54+
it('browser-stats.json contains valid esbuild metafile with inputs and outputs', async () => {
55+
harness.useTarget('build', {
56+
...BASE_OPTIONS,
57+
statsJson: true,
58+
});
59+
60+
const { result } = await harness.executeOnce();
61+
expect(result?.success).toBeTrue();
62+
63+
const content = harness.readFile('dist/browser-stats.json');
64+
const parsed = JSON.parse(content) as { inputs: unknown; outputs: unknown };
65+
expect(parsed.inputs).toBeDefined();
66+
expect(parsed.outputs).toBeDefined();
67+
});
68+
69+
it('browser-initial-stats.json contains only a subset of browser-stats.json outputs', async () => {
70+
harness.useTarget('build', {
71+
...BASE_OPTIONS,
72+
statsJson: true,
73+
});
74+
75+
const { result } = await harness.executeOnce();
76+
expect(result?.success).toBeTrue();
77+
78+
const allStats = JSON.parse(harness.readFile('dist/browser-stats.json')) as {
79+
outputs: Record<string, unknown>;
80+
};
81+
const initialStats = JSON.parse(harness.readFile('dist/browser-initial-stats.json')) as {
82+
outputs: Record<string, unknown>;
83+
};
84+
85+
const allOutputCount = Object.keys(allStats.outputs).length;
86+
const initialOutputCount = Object.keys(initialStats.outputs).length;
87+
88+
expect(allOutputCount).toBeGreaterThanOrEqual(initialOutputCount);
89+
for (const path of Object.keys(initialStats.outputs)) {
90+
expect(allStats.outputs[path]).toBeDefined();
91+
}
92+
});
93+
94+
it('server-stats.json contains an empty outputs object for browser-only builds', async () => {
95+
harness.useTarget('build', {
96+
...BASE_OPTIONS,
97+
statsJson: true,
98+
});
99+
100+
const { result } = await harness.executeOnce();
101+
expect(result?.success).toBeTrue();
102+
103+
const content = harness.readFile('dist/server-stats.json');
104+
const parsed = JSON.parse(content) as { outputs: Record<string, unknown> };
105+
expect(Object.keys(parsed.outputs).length).toBe(0);
106+
});
107+
});
108+
109+
describe('SSR build', () => {
110+
beforeEach(async () => {
111+
await harness.modifyFile('src/tsconfig.app.json', (content) => {
112+
const tsConfig = JSON.parse(content) as { files?: string[] };
113+
tsConfig.files ??= [];
114+
tsConfig.files.push('main.server.ts');
115+
116+
return JSON.stringify(tsConfig);
117+
});
118+
});
119+
120+
it('generates all four stats files for an SSR build', async () => {
121+
harness.useTarget('build', {
122+
...BASE_OPTIONS,
123+
server: 'src/main.server.ts',
124+
ssr: true,
125+
statsJson: true,
126+
});
127+
128+
const { result } = await harness.executeOnce();
129+
expect(result?.success).toBeTrue();
130+
harness.expectFile('dist/browser-stats.json').toExist();
131+
harness.expectFile('dist/browser-initial-stats.json').toExist();
132+
harness.expectFile('dist/server-stats.json').toExist();
133+
harness.expectFile('dist/server-initial-stats.json').toExist();
134+
});
135+
136+
it('server-stats.json has non-empty outputs for an SSR build', async () => {
137+
harness.useTarget('build', {
138+
...BASE_OPTIONS,
139+
server: 'src/main.server.ts',
140+
ssr: true,
141+
statsJson: true,
142+
});
143+
144+
const { result } = await harness.executeOnce();
145+
expect(result?.success).toBeTrue();
146+
147+
const content = harness.readFile('dist/server-stats.json');
148+
const parsed = JSON.parse(content) as { outputs: Record<string, unknown> };
149+
expect(Object.keys(parsed.outputs).length).toBeGreaterThan(0);
150+
});
151+
152+
it('browser-stats.json does not contain server output paths for an SSR build', async () => {
153+
harness.useTarget('build', {
154+
...BASE_OPTIONS,
155+
server: 'src/main.server.ts',
156+
ssr: true,
157+
statsJson: true,
158+
});
159+
160+
const { result } = await harness.executeOnce();
161+
expect(result?.success).toBeTrue();
162+
163+
const browserStats = JSON.parse(harness.readFile('dist/browser-stats.json')) as {
164+
outputs: Record<string, unknown>;
165+
};
166+
const serverStats = JSON.parse(harness.readFile('dist/server-stats.json')) as {
167+
outputs: Record<string, unknown>;
168+
};
169+
170+
const browserPaths = new Set(Object.keys(browserStats.outputs));
171+
for (const path of Object.keys(serverStats.outputs)) {
172+
expect(browserPaths.has(path))
173+
.withContext(`Server output '${path}' should not appear in browser-stats.json`)
174+
.toBeFalse();
175+
}
176+
});
177+
});
178+
});
179+
});

packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,7 @@ export class ComponentStylesheetBundler {
259259
}
260260
}
261261

262-
const metafile = result.metafile;
262+
const { metafile, browserMetafile, serverMetafile } = result;
263263
// Remove entryPoint fields from outputs to prevent the internal component styles from being
264264
// treated as initial files. Also mark the entry as a component resource for stat reporting.
265265
Object.values(metafile.outputs).forEach((output) => {
@@ -274,6 +274,8 @@ export class ComponentStylesheetBundler {
274274
contents,
275275
outputFiles,
276276
metafile,
277+
browserMetafile,
278+
serverMetafile,
277279
referencedFiles,
278280
externalImports: result.externalImports,
279281
initialFiles: new Map(),

packages/angular/build/src/tools/esbuild/bundler-context.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ export type BundleContextResult =
3333
errors: undefined;
3434
warnings: Message[];
3535
metafile: Metafile;
36+
browserMetafile: Metafile;
37+
serverMetafile: Metafile;
3638
outputFiles: BuildOutputFile[];
3739
initialFiles: Map<string, InitialFileRecord>;
3840
externalImports: {
@@ -109,6 +111,8 @@ export class BundlerContext {
109111
let errors: Message[] | undefined;
110112
const warnings: Message[] = [];
111113
const metafile: Metafile = { inputs: {}, outputs: {} };
114+
const browserMetafile: Metafile = { inputs: {}, outputs: {} };
115+
const serverMetafile: Metafile = { inputs: {}, outputs: {} };
112116
const initialFiles = new Map<string, InitialFileRecord>();
113117
const externalImportsBrowser = new Set<string>();
114118
const externalImportsServer = new Set<string>();
@@ -123,12 +127,17 @@ export class BundlerContext {
123127
continue;
124128
}
125129

126-
// Combine metafiles used for the stats option as well as bundle budgets and console output
130+
// Combine metafiles used for the bundle budgets and console output
127131
if (result.metafile) {
128132
Object.assign(metafile.inputs, result.metafile.inputs);
129133
Object.assign(metafile.outputs, result.metafile.outputs);
130134
}
131135

136+
Object.assign(browserMetafile.inputs, result.browserMetafile.inputs);
137+
Object.assign(browserMetafile.outputs, result.browserMetafile.outputs);
138+
Object.assign(serverMetafile.inputs, result.serverMetafile.inputs);
139+
Object.assign(serverMetafile.outputs, result.serverMetafile.outputs);
140+
132141
result.initialFiles.forEach((value, key) => initialFiles.set(key, value));
133142

134143
outputFiles.push(...result.outputFiles);
@@ -151,6 +160,8 @@ export class BundlerContext {
151160
errors,
152161
warnings,
153162
metafile,
163+
browserMetafile,
164+
serverMetafile,
154165
initialFiles,
155166
outputFiles,
156167
externalImports: {
@@ -391,6 +402,8 @@ export class BundlerContext {
391402
...result,
392403
outputFiles,
393404
initialFiles,
405+
browserMetafile: isPlatformServer ? { inputs: {}, outputs: {} } : result.metafile,
406+
serverMetafile: isPlatformServer ? result.metafile : { inputs: {}, outputs: {} },
394407
externalImports: {
395408
[isPlatformServer ? 'server' : 'browser']: externalImports,
396409
},

0 commit comments

Comments
 (0)