Skip to content

Commit 061c5f3

Browse files
dgp1130vikerman
authored andcommitted
fix(@angular-devkit/build-angular): remove async files from initial bundle budget.
Refs #15792. Static files listed in `angular.json` were being accounted in the `initial` bundle budget even when they were deferred asynchronously with `"lazy": true` or `"inject": false`. Webpack belives these files to be `initial`, so this commit corrects that by finding all extra entry points and excluding ones which are explicitly marked by the application developer as asynchronous. One edge case would be that the main bundle might transitively depend on one of these static files, and thus pull it into the `initial` bundle. However, this is not possible because the files are not present until the end of the build and cannot be depended upon by a Webpack build step. Thus all files listed by the application developer can be safely assumed to truly be loaded asynchronously.
1 parent 154ad9b commit 061c5f3

File tree

4 files changed

+194
-3
lines changed

4 files changed

+194
-3
lines changed
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. 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.io/license
7+
*/
8+
import * as webpack from 'webpack';
9+
import { NormalizedEntryPoint } from '../models/webpack-configs';
10+
11+
/**
12+
* Webpack stats may incorrectly mark extra entry points `initial` chunks, when
13+
* they are actually loaded asynchronously and thus not in the main bundle. This
14+
* function finds extra entry points in Webpack stats and corrects this value
15+
* whereever necessary. Does not modify {@param webpackStats}.
16+
*/
17+
export function markAsyncChunksNonInitial(
18+
webpackStats: webpack.Stats.ToJsonOutput,
19+
extraEntryPoints: NormalizedEntryPoint[],
20+
): Exclude<webpack.Stats.ToJsonOutput['chunks'], undefined> {
21+
const {chunks = [], entrypoints: entryPoints = {}} = webpackStats;
22+
23+
// Find all Webpack chunk IDs not injected into the main bundle. We don't have
24+
// to worry about transitive dependencies because extra entry points cannot be
25+
// depended upon in Webpack, thus any extra entry point with `inject: false`,
26+
// **cannot** be loaded in main bundle.
27+
const asyncEntryPoints = extraEntryPoints.filter((entryPoint) => !entryPoint.inject);
28+
const asyncChunkIds = flatMap(asyncEntryPoints,
29+
(entryPoint) => entryPoints[entryPoint.bundleName].chunks);
30+
31+
// Find chunks for each ID.
32+
const asyncChunks = asyncChunkIds.map((chunkId) => {
33+
const chunk = chunks.find((chunk) => chunk.id === chunkId);
34+
if (!chunk) {
35+
throw new Error(`Failed to find chunk (${chunkId}) in set:\n${
36+
JSON.stringify(chunks)}`);
37+
}
38+
39+
return chunk;
40+
})
41+
// All Webpack chunks are dependent on `runtime`, which is never an async
42+
// entry point, simply ignore this one.
43+
.filter((chunk) => chunk.names.indexOf('runtime') === -1);
44+
45+
// A chunk is considered `initial` only if Webpack already belives it to be initial
46+
// and the application developer did not mark it async via an extra entry point.
47+
return chunks.map((chunk) => ({
48+
...chunk,
49+
initial: chunk.initial && !asyncChunks.find((asyncChunk) => asyncChunk === chunk),
50+
}));
51+
}
52+
53+
function flatMap<T, R>(list: T[], mapper: (item: T, index: number, array: T[]) => R[]): R[] {
54+
return ([] as R[]).concat(...list.map(mapper));
55+
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. 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.io/license
7+
*/
8+
import * as webpack from 'webpack';
9+
import { markAsyncChunksNonInitial } from './async-chunks';
10+
11+
describe('async-chunks', () => {
12+
describe('markAsyncChunksNonInitial()', () => {
13+
it('sets `initial: false` for all extra entry points loaded asynchronously', () => {
14+
const chunks = [
15+
{
16+
id: 0,
17+
names: ['first'],
18+
initial: true,
19+
},
20+
{
21+
id: 1,
22+
names: ['second'],
23+
initial: true,
24+
},
25+
{
26+
id: 'third', // IDs can be strings too.
27+
names: ['third'],
28+
initial: true,
29+
},
30+
];
31+
const entrypoints = {
32+
first: {
33+
chunks: [0],
34+
},
35+
second: {
36+
chunks: [1],
37+
},
38+
third: {
39+
chunks: ['third'],
40+
},
41+
};
42+
const webpackStats = { chunks, entrypoints } as unknown as webpack.Stats.ToJsonOutput;
43+
44+
const extraEntryPoints = [
45+
{
46+
bundleName: 'first',
47+
inject: false, // Loaded asynchronously.
48+
input: 'first.css',
49+
},
50+
{
51+
bundleName: 'second',
52+
inject: true,
53+
input: 'second.js',
54+
},
55+
{
56+
bundleName: 'third',
57+
inject: false, // Loaded asynchronously.
58+
input: 'third.js',
59+
},
60+
];
61+
62+
const newChunks = markAsyncChunksNonInitial(webpackStats, extraEntryPoints);
63+
64+
expect(newChunks).toEqual([
65+
{
66+
id: 0,
67+
names: ['first'],
68+
initial: false, // No longer initial because it was marked async.
69+
},
70+
{
71+
id: 1,
72+
names: ['second'],
73+
initial: true,
74+
},
75+
{
76+
id: 'third',
77+
names: ['third'],
78+
initial: false, // No longer initial because it was marked async.
79+
},
80+
] as Exclude<webpack.Stats.ToJsonOutput['chunks'], undefined>);
81+
});
82+
83+
it('ignores runtime dependency of async chunks', () => {
84+
const chunks = [
85+
{
86+
id: 0,
87+
names: ['asyncStuff'],
88+
initial: true,
89+
},
90+
{
91+
id: 1,
92+
names: ['runtime'],
93+
initial: true,
94+
},
95+
];
96+
const entrypoints = {
97+
asyncStuff: {
98+
chunks: [0, 1], // Includes runtime as a dependency.
99+
},
100+
};
101+
const webpackStats = { chunks, entrypoints } as unknown as webpack.Stats.ToJsonOutput;
102+
103+
const extraEntryPoints = [
104+
{
105+
bundleName: 'asyncStuff',
106+
inject: false, // Loaded asynchronously.
107+
input: 'asyncStuff.js',
108+
},
109+
];
110+
111+
const newChunks = markAsyncChunksNonInitial(webpackStats, extraEntryPoints);
112+
113+
expect(newChunks).toEqual([
114+
{
115+
id: 0,
116+
names: ['asyncStuff'],
117+
initial: false, // No longer initial because it was marked async.
118+
},
119+
{
120+
id: 1,
121+
names: ['runtime'],
122+
initial: true, // Still initial, even though its a dependency.
123+
},
124+
] as Exclude<webpack.Stats.ToJsonOutput['chunks'], undefined>);
125+
});
126+
});
127+
});

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ class BundleCalculator extends Calculator {
169169
}
170170

171171
/**
172-
* The sum of all initial chunks (marked as initial by webpack).
172+
* The sum of all initial chunks (marked as initial).
173173
*/
174174
class InitialCalculator extends Calculator {
175175
calculate() {

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
getWorkerConfig,
2828
normalizeExtraEntryPoints,
2929
} from '../angular-cli-files/models/webpack-configs';
30+
import { markAsyncChunksNonInitial } from '../angular-cli-files/utilities/async-chunks';
3031
import { ThresholdSeverity, checkBudgets } from '../angular-cli-files/utilities/bundle-calculator';
3132
import {
3233
IndexHtmlTransform,
@@ -266,11 +267,19 @@ export function buildWebpackBrowser(
266267
}).pipe(
267268
// tslint:disable-next-line: no-big-function
268269
concatMap(async buildEvent => {
269-
const { webpackStats, success, emittedFiles = [] } = buildEvent;
270-
if (!webpackStats) {
270+
const { webpackStats: webpackRawStats, success, emittedFiles = [] } = buildEvent;
271+
if (!webpackRawStats) {
271272
throw new Error('Webpack stats build result is required.');
272273
}
273274

275+
// Fix incorrectly set `initial` value on chunks.
276+
const extraEntryPoints = normalizeExtraEntryPoints(options.styles || [], 'styles')
277+
.concat(normalizeExtraEntryPoints(options.scripts || [], 'scripts'));
278+
const webpackStats = {
279+
...webpackRawStats,
280+
chunks: markAsyncChunksNonInitial(webpackRawStats, extraEntryPoints),
281+
};
282+
274283
if (!success && useBundleDownleveling) {
275284
// If using bundle downleveling then there is only one build
276285
// If it fails show any diagnostic messages and bail

0 commit comments

Comments
 (0)