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
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import {
BundleContextResult,
InitialFileRecord,
} from '../../tools/esbuild/bundler-context';
import { createOutputFile } from '../../tools/esbuild/utils';
import { createOutputFile, filterMetafile } from '../../tools/esbuild/utils';
import { assertIsError } from '../../utils/error';

/**
Expand Down Expand Up @@ -285,6 +285,11 @@ export async function optimizeChunks(
}
original.metafile = newMetafile;

// Update the isolated browser metafile to reflect the optimized output.
// Server outputs are excluded since chunk optimization only affects browser bundles.
const serverOutputPaths = new Set(Object.keys(original.serverMetafile?.outputs ?? {}));
original.browserMetafile = filterMetafile(newMetafile, (path) => !serverOutputPaths.has(path));

// Remove used chunks and associated sourcemaps from the original result
original.outputFiles = original.outputFiles.filter(
(file) =>
Expand Down
44 changes: 40 additions & 4 deletions packages/angular/build/src/builders/application/execute-build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -301,13 +302,48 @@ export async function executeBuild(
BuildOutputFileType.Root,
);

const ssrOutputEnabled: boolean = !!ssrOptions;

// Write metafile if stats option is enabled
if (options.stats) {
executionResult.addOutputFile(
'stats.json',
JSON.stringify(metafile, null, 2),
'browser-stats.json',
JSON.stringify(
filterMetafile(browserMetafile, (path) => !initialFiles.has(path)),
null,
2,
),
BuildOutputFileType.Root,
);
executionResult.addOutputFile(
'browser-initial-stats.json',
JSON.stringify(
filterMetafile(browserMetafile, (path) => initialFiles.has(path)),
null,
2,
),
BuildOutputFileType.Root,
);
if (ssrOutputEnabled && serverMetafile) {
executionResult.addOutputFile(
'server-stats.json',
JSON.stringify(
filterMetafile(serverMetafile, (path) => !initialFiles.has(path)),
null,
2,
),
BuildOutputFileType.Root,
);
executionResult.addOutputFile(
'server-initial-stats.json',
JSON.stringify(
filterMetafile(serverMetafile, (path) => initialFiles.has(path)),
null,
2,
),
BuildOutputFileType.Root,
);
}
}

if (!jsonLogs) {
Expand All @@ -322,7 +358,7 @@ export async function executeBuild(
colors,
changedFiles,
estimatedTransferSizes,
!!ssrOptions,
ssrOutputEnabled,
verbose,
),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,7 @@ export class ComponentStylesheetBundler {
contents,
outputFiles,
metafile,
browserMetafile: metafile,
referencedFiles,
externalImports: result.externalImports,
initialFiles: new Map(),
Expand Down
19 changes: 19 additions & 0 deletions packages/angular/build/src/tools/esbuild/bundler-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,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 @@ -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<string, InitialFileRecord>();
const externalImportsBrowser = new Set<string>();
const externalImportsServer = new Set<string>();
Expand All @@ -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);
Expand All @@ -170,6 +185,8 @@ export class BundlerContext {
errors,
warnings,
metafile,
browserMetafile,
serverMetafile,
initialFiles,
outputFiles,
externalImports: {
Expand Down Expand Up @@ -415,6 +432,8 @@ export class BundlerContext {
},
externalConfiguration,
errors: undefined,
browserMetafile: isPlatformServer ? { inputs: {}, outputs: {} } : result.metafile,
serverMetafile: isPlatformServer ? result.metafile : undefined,
};
}

Expand Down
34 changes: 34 additions & 0 deletions packages/angular/build/src/tools/esbuild/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,40 @@ import {
PrerenderedRoutesRecord,
} from './bundler-execution-result';

/**
* Filters an esbuild metafile to only include outputs matching a given predicate,
* along with the inputs referenced by those outputs.
* @param metafile The esbuild metafile to filter.
* @param predicate A function that receives an output path and returns `true` if the output
* should be included.
* @returns A new metafile containing only the matching outputs and their referenced inputs.
*/
export function filterMetafile(
metafile: Metafile,
predicate: (outputPath: string) => boolean,
): Metafile {
const filteredOutputs: Metafile['outputs'] = {};
const referencedInputs = new Set<string>();

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[],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,13 @@ describe('Browser Builder stats json', () => {

it('works', async () => {
const { files } = await browserBuild(architect, host, target, { statsJson: true });
expect('stats.json' in files).toBe(true);
expect('browser-stats.json' in files).toBe(true);
});

it('works with profile flag', async () => {
const { files } = await browserBuild(architect, host, target, { statsJson: true });
expect('stats.json' in files).toBe(true);
const stats = JSON.parse(await files['stats.json']);
expect('browser-stats.json' in files).toBe(true);
const stats = JSON.parse(await files['browser-stats.json']);
expect(stats.chunks[0].modules[0].profile.building).toBeDefined();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,18 @@ describeBuilder(buildWebpackBrowser, BROWSER_BUILDER_INFO, (harness) => {

expect(result?.success).toBe(true);

if (harness.expectFile('dist/stats.json').toExist()) {
const content = harness.readFile('dist/stats.json');
if (harness.expectFile('dist/browser-stats.json').toExist()) {
const content = harness.readFile('dist/browser-stats.json');
expect(() => JSON.parse(content))
.withContext('Expected Webpack Stats file to be valid JSON.')
.not.toThrow();
}
if (harness.expectFile('dist/browser-initial-stats.json').toExist()) {
const initialContent = harness.readFile('dist/browser-initial-stats.json');
expect(() => JSON.parse(initialContent))
.withContext('Expected Webpack Stats file to be valid JSON.')
.not.toThrow();
}
});

// TODO: Investigate why this profiling object is no longer present in Webpack 5.90.3+ and if this should even be tested
Expand All @@ -45,12 +51,73 @@ describeBuilder(buildWebpackBrowser, BROWSER_BUILDER_INFO, (harness) => {

expect(result?.success).toBe(true);

if (harness.expectFile('dist/stats.json').toExist()) {
const stats = JSON.parse(harness.readFile('dist/stats.json'));
if (harness.expectFile('dist/browser-stats.json').toExist()) {
const stats = JSON.parse(harness.readFile('dist/browser-stats.json'));
expect(stats?.chunks?.[0]?.modules?.[0]?.profile?.building).toBeDefined();
}
});

it('browser-initial-stats.json contains only initial chunks', async () => {
harness.useTarget('build', {
...BASE_OPTIONS,
statsJson: true,
});

const { result } = await harness.executeOnce();
expect(result?.success).toBe(true);

const raw = harness.readFile('dist/browser-initial-stats.json');
const stats = JSON.parse(raw) as { chunks?: { initial?: boolean }[] };

for (const chunk of stats.chunks ?? []) {
expect(chunk.initial)
.withContext('browser-initial-stats.json should only contain initial chunks')
.toBeTrue();
}
});

it('browser-stats.json contains only non-initial chunks', async () => {
harness.useTarget('build', {
...BASE_OPTIONS,
statsJson: true,
});

const { result } = await harness.executeOnce();
expect(result?.success).toBe(true);

const raw = harness.readFile('dist/browser-stats.json');
const stats = JSON.parse(raw) as { chunks?: { initial?: boolean }[] };

for (const chunk of stats.chunks ?? []) {
expect(chunk.initial)
.withContext('browser-stats.json should not contain initial chunks')
.toBeFalse();
}
});

it('browser-stats.json and browser-initial-stats.json chunks have no overlap', async () => {
harness.useTarget('build', {
...BASE_OPTIONS,
statsJson: true,
});

const { result } = await harness.executeOnce();
expect(result?.success).toBe(true);

type StatsJson = { chunks?: { id?: number | string; initial?: boolean }[] };
const nonInitialStats = JSON.parse(harness.readFile('dist/browser-stats.json')) as StatsJson;
const initialStats = JSON.parse(
harness.readFile('dist/browser-initial-stats.json'),
) as StatsJson;

const nonInitialIds = new Set((nonInitialStats.chunks ?? []).map((c) => c.id));
for (const chunk of initialStats.chunks ?? []) {
expect(nonInitialIds.has(chunk.id))
.withContext(`Chunk '${chunk.id}' should not appear in both stats files`)
.toBeFalse();
}
});

it('does not generate a Webpack Stats file in output when false', async () => {
harness.useTarget('build', {
...BASE_OPTIONS,
Expand All @@ -61,7 +128,8 @@ describeBuilder(buildWebpackBrowser, BROWSER_BUILDER_INFO, (harness) => {

expect(result?.success).toBe(true);

harness.expectFile('dist/stats.json').toNotExist();
harness.expectFile('dist/browser-stats.json').toNotExist();
harness.expectFile('dist/browser-initial-stats.json').toNotExist();
});

it('does not generate a Webpack Stats file in output when not present', async () => {
Expand All @@ -73,7 +141,8 @@ describeBuilder(buildWebpackBrowser, BROWSER_BUILDER_INFO, (harness) => {

expect(result?.success).toBe(true);

harness.expectFile('dist/stats.json').toNotExist();
harness.expectFile('dist/browser-stats.json').toNotExist();
harness.expectFile('dist/browser-initial-stats.json').toNotExist();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,8 @@ export async function getCommonConfig(wco: WebpackConfigOptions): Promise<Config
// Once TypeScript provides support for keeping the dynamic import this workaround can be
// changed to a direct dynamic import.
const { VERSION: NG_VERSION } = await import('@angular/compiler-cli');
const { GLOBAL_DEFS_FOR_TERSER, GLOBAL_DEFS_FOR_TERSER_WITH_AOT } = await import(
'@angular/compiler-cli/private/tooling'
);
const { GLOBAL_DEFS_FOR_TERSER, GLOBAL_DEFS_FOR_TERSER_WITH_AOT } =
await import('@angular/compiler-cli/private/tooling');

// determine hashing format
const hashFormat = getOutputHashFormat(buildOptions.outputHashing);
Expand Down Expand Up @@ -244,8 +243,36 @@ export async function getCommonConfig(wco: WebpackConfigOptions): Promise<Config
}

if (buildOptions.statsJson) {
const getInitialChunkIds = (data: import('webpack').StatsCompilation): Set<string | number> =>
new Set(
data.chunks?.filter((c) => c.initial).flatMap((c) => (c.id != null ? [c.id] : [])) ?? [],
);

extraPlugins.push(
new JsonStatsPlugin(path.resolve(root, buildOptions.outputPath, 'stats.json')),
new JsonStatsPlugin(
path.resolve(root, buildOptions.outputPath, 'browser-stats.json'),
(data) => {
const initialChunkIds = getInitialChunkIds(data);

return {
...data,
chunks: data.chunks?.filter((c) => !c.initial),
assets: data.assets?.filter((a) => !a.chunks?.some((id) => initialChunkIds.has(id))),
};
},
),
new JsonStatsPlugin(
path.resolve(root, buildOptions.outputPath, 'browser-initial-stats.json'),
(data) => {
const initialChunkIds = getInitialChunkIds(data);

return {
...data,
chunks: data.chunks?.filter((c) => c.initial),
assets: data.assets?.filter((a) => a.chunks?.some((id) => initialChunkIds.has(id))),
};
},
),
);
}

Expand Down
Loading
Loading