Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions packages/angular/build/src/builders/application/chunk-optimizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}
Comment thread
tsteuwer-accesso marked this conversation as resolved.
original.browserMetafile = newBrowserMetafile;

return original;
}
61 changes: 57 additions & 4 deletions packages/angular/build/src/builders/application/execute-build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<string, InitialFileRecord>,
): Metafile {
const filteredOutputs: Metafile['outputs'] = {};
const referencedInputs = new Set<string>();

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,
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
};
const initialStats = JSON.parse(harness.readFile('dist/browser-initial-stats.json')) as {
outputs: Record<string, unknown>;
};

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<string, unknown> };
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<string, unknown>;
};
const serverStats = JSON.parse(harness.readFile('dist/server-stats.json')) as {
outputs: Record<string, unknown>;
};

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();
}
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -274,6 +274,8 @@ export class ComponentStylesheetBundler {
contents,
outputFiles,
metafile,
browserMetafile,
serverMetafile,
referencedFiles,
externalImports: result.externalImports,
initialFiles: new Map(),
Expand Down
15 changes: 14 additions & 1 deletion packages/angular/build/src/tools/esbuild/bundler-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ export type BundleContextResult =
errors: undefined;
warnings: Message[];
metafile: Metafile;
browserMetafile: Metafile;
serverMetafile: Metafile;
outputFiles: BuildOutputFile[];
initialFiles: Map<string, InitialFileRecord>;
externalImports: {
Expand Down Expand Up @@ -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<string, InitialFileRecord>();
const externalImportsBrowser = new Set<string>();
const externalImportsServer = new Set<string>();
Expand All @@ -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);
Expand All @@ -151,6 +160,8 @@ export class BundlerContext {
errors,
warnings,
metafile,
browserMetafile,
serverMetafile,
initialFiles,
outputFiles,
externalImports: {
Expand Down Expand Up @@ -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,
},
Expand Down
Loading