Skip to content

Commit 4fe4c9b

Browse files
committed
fix(@angular-devkit/build-angular): update budgets to check differential builds separately
Fixes #15792. Previously, budgets would include content for both versions of a differential build. Thus the `initial` budget would count content from the ES5 **and** ES2015 bundles together. This is a very misleading statistic because no user would download both versions. I've updated the budget calculators to take this into account and generate size values for both builds which are then checked independently of each other. The only calculators I changed are the `InitialCalculator` (for computing `initial` bundle sizes) and `BundleCalculator` (for computing named bundles). Since budgets are handled by Webpack for builds without differential loading, the `initial` bundle will always have those two sizes. The `BundleCalculator` might reference a bundle which does not have differential loading performed (such as a CSS file), so it emits sizes depending on whether or not multiple builds were found for that chunk. Most of the other calculators don't really need to take differential loading into account. `AnyScriptCalculator` and `AnyCalculator` already apply on a file-by-file basis, so they generate sizes for both build versions already. `AnyComponentStyleCalculator` only applies to CSS which does not have differential builds. The wierd ones here are `AllCalculator` and `AllScriptCalculator` which reference files with and without differential builds. Conceptually, they should be separated, as a "total" budget specified by an app developer probably wanted it to mean "the total resources a user would have to download", which would only be one differential build at a time. However, I don't see a good way of identifying which assets belong to which differential build. Even if an asset belongs to a chunk with differential builds, we don't know which build takes which assets into account. I decided to leave this for the time being, but it is probably something we should look into separately. Since budgets take differential loading into account, users might reasonably want to set different budgets for different builds (ie. "initial-es2015 bundle should be capped at 100k, but initial-es5 bundle can go to 150k"). That's more of a feature request, so I also left that out for a future PR.
1 parent 07cc376 commit 4fe4c9b

3 files changed

Lines changed: 218 additions & 40 deletions

File tree

packages/angular_devkit/build_angular/src/angular-cli-files/utilities/bundle-calculator.ts

Lines changed: 116 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88
import * as webpack from 'webpack';
9+
import { ProcessBundleFile, ProcessBundleResult } from '../../../src/utils/process-bundle';
910
import { Budget } from '../../browser/schema';
1011

1112
export interface Size {
@@ -29,6 +30,11 @@ export enum ThresholdSeverity {
2930
ERROR = 'error',
3031
}
3132

33+
enum DifferentialBuildType {
34+
ORIGINAL = 'es2015',
35+
DOWNLEVEL = 'es5',
36+
}
37+
3238
export function* calculateThresholds(budget: Budget): IterableIterator<Threshold> {
3339
if (budget.maximumWarning) {
3440
yield {
@@ -91,8 +97,17 @@ export function* calculateThresholds(budget: Budget): IterableIterator<Threshold
9197
}
9298
}
9399

94-
export function calculateSizes(budget: Budget, stats: webpack.Stats.ToJsonOutput): Size[] {
95-
const calculatorMap: Record<Budget['type'], { new(...args: any[]): Calculator }> = {
100+
/**
101+
* Calculates the sizes for bundles in the budget type provided.
102+
*
103+
* Precondition: Differential builds are enabled.
104+
*/
105+
export function calculateSizes(
106+
budget: Budget,
107+
stats: webpack.Stats.ToJsonOutput,
108+
processResults: ProcessBundleResult[],
109+
): Size[] {
110+
const calculatorMap: Record<Budget['type'], { new(...args: unknown[]): Calculator }> = {
96111
all: AllCalculator,
97112
allScript: AllScriptCalculator,
98113
any: AnyCalculator,
@@ -111,19 +126,55 @@ export function calculateSizes(budget: Budget, stats: webpack.Stats.ToJsonOutput
111126
throw new Error('Webpack stats output did not include asset information.');
112127
}
113128

114-
const calculator = new ctor(budget, chunks, assets);
129+
const calculator = new ctor(budget, chunks, assets, processResults);
115130

116131
return calculator.calculate();
117132
}
118133

134+
type ArrayElement<T> = T extends Array<infer U> ? U : never;
135+
type Chunk = ArrayElement<Exclude<webpack.Stats.ToJsonOutput['chunks'], undefined>>;
136+
type Asset = ArrayElement<Exclude<webpack.Stats.ToJsonOutput['assets'], undefined>>;
119137
export abstract class Calculator {
120138
constructor (
121139
protected budget: Budget,
122-
protected chunks: Exclude<webpack.Stats.ToJsonOutput['chunks'], undefined>,
123-
protected assets: Exclude<webpack.Stats.ToJsonOutput['assets'], undefined>,
140+
protected chunks: Chunk[],
141+
protected assets: Asset[],
142+
protected processResults: ProcessBundleResult[],
124143
) {}
125144

126145
abstract calculate(): Size[];
146+
147+
/** Calculates the size of the given chunk for the provided build type. */
148+
protected calculateChunkSize(
149+
chunk: Chunk,
150+
buildType: DifferentialBuildType,
151+
): number {
152+
// Look for a process result containing different builds for this chunk.
153+
const processResult = this.processResults
154+
.find((processResult) => processResult.name === chunk.id.toString());
155+
156+
if (processResult) {
157+
// Found a differential build, use the correct size information.
158+
const processResultFile = getDifferentialBuildResult(
159+
processResult, buildType);
160+
161+
return processResultFile && processResultFile.size || 0;
162+
} else {
163+
// No differential builds, get the chunk size by summing its assets.
164+
return chunk.files
165+
.filter((file) => !file.endsWith('.map'))
166+
.map((file: string) => {
167+
const asset = this.assets.find((asset) => asset.name === file);
168+
if (!asset) {
169+
throw new Error(`Could not find asset for file: ${file}`);
170+
}
171+
172+
return asset;
173+
})
174+
.map((asset) => asset.size)
175+
.reduce((l, r) => l + r);
176+
}
177+
}
127178
}
128179

129180
/**
@@ -136,22 +187,32 @@ class BundleCalculator extends Calculator {
136187
return [];
137188
}
138189

139-
const size: number = this.chunks
140-
.filter(chunk => chunk.names.indexOf(budgetName) !== -1)
141-
.reduce((files, chunk) => [...files, ...chunk.files], [])
142-
.filter((file: string) => !file.endsWith('.map'))
143-
.map((file: string) => {
144-
const asset = this.assets.find((asset) => asset.name === file);
145-
if (!asset) {
146-
throw new Error(`Could not find asset for file: ${file}`);
147-
}
148-
149-
return asset;
150-
})
151-
.map((asset) => asset.size)
152-
.reduce((total: number, size: number) => total + size, 0);
190+
// The chunk may or may not have differential builds. Compute the size for
191+
// each then check afterwards if they are all the same.
192+
const buildSizes = Object.values(DifferentialBuildType).map((buildType) => {
193+
const size = this.chunks
194+
.filter(chunk => chunk.names.indexOf(budgetName) !== -1)
195+
.map((chunk) => this.calculateChunkSize(chunk, buildType))
196+
.reduce((l, r) => l + r);
197+
198+
return {size, label: `${this.budget.name}-${buildType}`};
199+
});
200+
201+
// If there are multiple sizes, then there are differential builds. Display
202+
// them all.
203+
if (!allEquivalent(buildSizes.map((buildSize) => buildSize.size))) {
204+
return buildSizes;
205+
}
206+
207+
if (buildSizes.length === 0) {
208+
return [];
209+
}
153210

154-
return [{size, label: this.budget.name}];
211+
// Only one size, must not be a differential build.
212+
return [{
213+
label: this.budget.name,
214+
size: buildSizes[0].size,
215+
}];
155216
}
156217
}
157218

@@ -160,22 +221,15 @@ class BundleCalculator extends Calculator {
160221
*/
161222
class InitialCalculator extends Calculator {
162223
calculate() {
163-
const size = this.chunks
164-
.filter(chunk => chunk.initial)
165-
.reduce((files, chunk) => [...files, ...chunk.files], [])
166-
.filter((file: string) => !file.endsWith('.map'))
167-
.map((file: string) => {
168-
const asset = this.assets.find((asset) => asset.name === file);
169-
if (!asset) {
170-
throw new Error(`Could not find asset for file: ${file}`);
171-
}
172-
173-
return asset;
174-
})
175-
.map((asset) => asset.size)
176-
.reduce((total: number, size: number) => total + size, 0);
177-
178-
return [{size, label: 'initial'}];
224+
return Object.values(DifferentialBuildType).map((buildType) => {
225+
return {
226+
label: `initial-${buildType}`,
227+
size: this.chunks
228+
.filter(chunk => chunk.initial)
229+
.map((chunk) => this.calculateChunkSize(chunk, buildType))
230+
.reduce((l, r) => l + r),
231+
};
232+
});
179233
}
180234
}
181235

@@ -286,3 +340,29 @@ export function calculateBytes(
286340

287341
return baselineBytes + value * factor;
288342
}
343+
344+
/** Returns the {@link ProcessBundleFile} for the given {@link DifferentialBuildType}. */
345+
function getDifferentialBuildResult(
346+
processResult: ProcessBundleResult, buildType: DifferentialBuildType):
347+
ProcessBundleFile|null {
348+
switch (buildType) {
349+
case DifferentialBuildType.ORIGINAL: return processResult.original || null;
350+
case DifferentialBuildType.DOWNLEVEL: return processResult.downlevel || null;
351+
}
352+
}
353+
354+
/** Returns whether or not all items in the list are equivalent to each other. */
355+
function allEquivalent<T>(items: T[]): boolean {
356+
if (items.length === 0) {
357+
return true;
358+
}
359+
360+
const first = items[0];
361+
for (const item of items.slice(1)) {
362+
if (item !== first) {
363+
return false;
364+
}
365+
}
366+
367+
return true;
368+
}

packages/angular_devkit/build_angular/src/angular-cli-files/utilities/bundle-calculator_spec.ts

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
* Use of this source code is governed by an MIT-style license that can be
66
* found in the LICENSE file at https://angular.io/license
77
*/
8-
import { calculateBytes, calculateThresholds, ThresholdType, ThresholdSeverity } from './bundle-calculator';
9-
import { Type } from '../../browser/schema';
8+
import * as webpack from 'webpack';
9+
import { Budget, Type } from '../../browser/schema';
10+
import { ProcessBundleResult } from '../../utils/process-bundle';
11+
import { ThresholdSeverity, ThresholdType, calculateBytes, calculateSizes, calculateThresholds } from './bundle-calculator';
1012

1113
describe('bundle-calculator', () => {
1214
describe('calculateThresholds()', () => {
@@ -66,6 +68,102 @@ describe('bundle-calculator', () => {
6668
});
6769
});
6870

71+
describe('calculateSizes()', () => {
72+
it('calculates initial bundle sizes', () => {
73+
const budget: Budget = { type: Type.Initial };
74+
const webpackStats = {
75+
chunks: [
76+
{ id: 0, initial: true },
77+
{ id: 1, initial: false },
78+
],
79+
assets: [],
80+
} as unknown as webpack.Stats.ToJsonOutput;
81+
const processResults: ProcessBundleResult[] = [
82+
{
83+
name: '0',
84+
original: { filename: 'main-es2015.js', size: 10 },
85+
downlevel: { filename: 'main-es5.js', size: 20 },
86+
},
87+
{
88+
name: '1',
89+
original: { filename: 'unrelated.js', size: 30 },
90+
},
91+
];
92+
93+
const sizes = calculateSizes(budget, webpackStats, processResults);
94+
95+
expect(sizes.length).toBe(2);
96+
expect(sizes).toContain({ label: 'initial-es2015', size: 10 });
97+
expect(sizes).toContain({ label: 'initial-es5', size: 20 });
98+
});
99+
100+
it('calculates named bundle differential sizes', () => {
101+
const budget: Budget = { type: Type.Bundle, name: 'custom-budget' };
102+
const webpackStats = {
103+
chunks: [
104+
{ id: 0, names: ['custom-budget'] },
105+
{ id: 1, names: ['unrelated'] },
106+
],
107+
assets: [],
108+
} as unknown as webpack.Stats.ToJsonOutput;
109+
const processResults: ProcessBundleResult[] = [
110+
{
111+
name: '0',
112+
original: {
113+
filename: 'custom-budget-es2015.js',
114+
size: 10,
115+
},
116+
downlevel: {
117+
filename: 'custom-budget-es5.js',
118+
size: 20,
119+
},
120+
},
121+
{
122+
name: '1',
123+
original: {
124+
filename: 'unrelated.js',
125+
size: 30,
126+
},
127+
},
128+
];
129+
130+
const sizes = calculateSizes(budget, webpackStats, processResults);
131+
132+
expect(sizes.length).toBe(2);
133+
expect(sizes).toContain({ label: 'custom-budget-es2015', size: 10 });
134+
expect(sizes).toContain({ label: 'custom-budget-es5', size: 20 });
135+
});
136+
137+
it('calculates named bundle non-differential sizes', () => {
138+
const budget: Budget = { type: Type.Bundle, name: 'custom-budget' };
139+
const webpackStats = {
140+
chunks: [
141+
{ id: 0, names: ['custom-budget'], files: [ 'first.js', 'second.js' ] },
142+
{ id: 1, names: ['unrelated'] },
143+
],
144+
assets: [
145+
{ name: 'first.js', size: 10 },
146+
{ name: 'second.js', size: 20 },
147+
],
148+
} as unknown as webpack.Stats.ToJsonOutput;
149+
const processResults: ProcessBundleResult[] = [
150+
// No process result for custom-budget.
151+
{
152+
name: '1',
153+
original: {
154+
filename: 'unrelated.js',
155+
size: 40,
156+
},
157+
},
158+
];
159+
160+
const sizes = calculateSizes(budget, webpackStats, processResults);
161+
162+
expect(sizes.length).toBe(1);
163+
expect(sizes).toContain({ label: 'custom-budget', size: 30 });
164+
});
165+
});
166+
69167
describe('calculateBytes()', () => {
70168
it('converts an integer with no postfix', () => {
71169
expect(calculateBytes('0')).toBe(0);

packages/angular_devkit/build_angular/src/browser/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import {
2828
normalizeExtraEntryPoints,
2929
} from '../angular-cli-files/models/webpack-configs';
3030
import { markAsyncChunksNonInitial } from '../angular-cli-files/utilities/async-chunks';
31-
import { ThresholdSeverity, calculateSizes, calculateThresholds, ThresholdType, Threshold } from '../angular-cli-files/utilities/bundle-calculator';
31+
import { Threshold, ThresholdSeverity, ThresholdType, calculateSizes, calculateThresholds } from '../angular-cli-files/utilities/bundle-calculator';
3232
import {
3333
IndexHtmlTransform,
3434
writeIndexHtml,
@@ -625,7 +625,7 @@ export function buildWebpackBrowser(
625625

626626
const budgets = options.budgets || [];
627627
for (const budget of budgets) {
628-
const sizes = calculateSizes(budget, webpackStats);
628+
const sizes = calculateSizes(budget, webpackStats, processResults);
629629
for (const threshold of calculateThresholds(budget)) {
630630
for (const {size, label} of sizes) {
631631
if (exceededThreshold(size, threshold)) {

0 commit comments

Comments
 (0)