Skip to content

Commit

Permalink
fix(@angular-devkit/build-angular): remove async files from initial
Browse files Browse the repository at this point in the history
… 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.
  • Loading branch information
dgp1130 authored and vikerman committed Dec 10, 2019
1 parent 154ad9b commit 061c5f3
Show file tree
Hide file tree
Showing 4 changed files with 194 additions and 3 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* @license
* Copyright Google Inc. 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.io/license
*/
import * as webpack from 'webpack';
import { NormalizedEntryPoint } from '../models/webpack-configs';

/**
* Webpack stats may incorrectly mark extra entry points `initial` chunks, when
* they are actually loaded asynchronously and thus not in the main bundle. This
* function finds extra entry points in Webpack stats and corrects this value
* whereever necessary. Does not modify {@param webpackStats}.
*/
export function markAsyncChunksNonInitial(
webpackStats: webpack.Stats.ToJsonOutput,
extraEntryPoints: NormalizedEntryPoint[],
): Exclude<webpack.Stats.ToJsonOutput['chunks'], undefined> {
const {chunks = [], entrypoints: entryPoints = {}} = webpackStats;

// Find all Webpack chunk IDs not injected into the main bundle. We don't have
// to worry about transitive dependencies because extra entry points cannot be
// depended upon in Webpack, thus any extra entry point with `inject: false`,
// **cannot** be loaded in main bundle.
const asyncEntryPoints = extraEntryPoints.filter((entryPoint) => !entryPoint.inject);
const asyncChunkIds = flatMap(asyncEntryPoints,
(entryPoint) => entryPoints[entryPoint.bundleName].chunks);

// Find chunks for each ID.
const asyncChunks = asyncChunkIds.map((chunkId) => {
const chunk = chunks.find((chunk) => chunk.id === chunkId);
if (!chunk) {
throw new Error(`Failed to find chunk (${chunkId}) in set:\n${
JSON.stringify(chunks)}`);
}

return chunk;
})
// All Webpack chunks are dependent on `runtime`, which is never an async
// entry point, simply ignore this one.
.filter((chunk) => chunk.names.indexOf('runtime') === -1);

// A chunk is considered `initial` only if Webpack already belives it to be initial
// and the application developer did not mark it async via an extra entry point.
return chunks.map((chunk) => ({
...chunk,
initial: chunk.initial && !asyncChunks.find((asyncChunk) => asyncChunk === chunk),
}));
}

function flatMap<T, R>(list: T[], mapper: (item: T, index: number, array: T[]) => R[]): R[] {
return ([] as R[]).concat(...list.map(mapper));
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/**
* @license
* Copyright Google Inc. 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.io/license
*/
import * as webpack from 'webpack';
import { markAsyncChunksNonInitial } from './async-chunks';

describe('async-chunks', () => {
describe('markAsyncChunksNonInitial()', () => {
it('sets `initial: false` for all extra entry points loaded asynchronously', () => {
const chunks = [
{
id: 0,
names: ['first'],
initial: true,
},
{
id: 1,
names: ['second'],
initial: true,
},
{
id: 'third', // IDs can be strings too.
names: ['third'],
initial: true,
},
];
const entrypoints = {
first: {
chunks: [0],
},
second: {
chunks: [1],
},
third: {
chunks: ['third'],
},
};
const webpackStats = { chunks, entrypoints } as unknown as webpack.Stats.ToJsonOutput;

const extraEntryPoints = [
{
bundleName: 'first',
inject: false, // Loaded asynchronously.
input: 'first.css',
},
{
bundleName: 'second',
inject: true,
input: 'second.js',
},
{
bundleName: 'third',
inject: false, // Loaded asynchronously.
input: 'third.js',
},
];

const newChunks = markAsyncChunksNonInitial(webpackStats, extraEntryPoints);

expect(newChunks).toEqual([
{
id: 0,
names: ['first'],
initial: false, // No longer initial because it was marked async.
},
{
id: 1,
names: ['second'],
initial: true,
},
{
id: 'third',
names: ['third'],
initial: false, // No longer initial because it was marked async.
},
] as Exclude<webpack.Stats.ToJsonOutput['chunks'], undefined>);
});

it('ignores runtime dependency of async chunks', () => {
const chunks = [
{
id: 0,
names: ['asyncStuff'],
initial: true,
},
{
id: 1,
names: ['runtime'],
initial: true,
},
];
const entrypoints = {
asyncStuff: {
chunks: [0, 1], // Includes runtime as a dependency.
},
};
const webpackStats = { chunks, entrypoints } as unknown as webpack.Stats.ToJsonOutput;

const extraEntryPoints = [
{
bundleName: 'asyncStuff',
inject: false, // Loaded asynchronously.
input: 'asyncStuff.js',
},
];

const newChunks = markAsyncChunksNonInitial(webpackStats, extraEntryPoints);

expect(newChunks).toEqual([
{
id: 0,
names: ['asyncStuff'],
initial: false, // No longer initial because it was marked async.
},
{
id: 1,
names: ['runtime'],
initial: true, // Still initial, even though its a dependency.
},
] as Exclude<webpack.Stats.ToJsonOutput['chunks'], undefined>);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ class BundleCalculator extends Calculator {
}

/**
* The sum of all initial chunks (marked as initial by webpack).
* The sum of all initial chunks (marked as initial).
*/
class InitialCalculator extends Calculator {
calculate() {
Expand Down
13 changes: 11 additions & 2 deletions packages/angular_devkit/build_angular/src/browser/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
getWorkerConfig,
normalizeExtraEntryPoints,
} from '../angular-cli-files/models/webpack-configs';
import { markAsyncChunksNonInitial } from '../angular-cli-files/utilities/async-chunks';
import { ThresholdSeverity, checkBudgets } from '../angular-cli-files/utilities/bundle-calculator';
import {
IndexHtmlTransform,
Expand Down Expand Up @@ -266,11 +267,19 @@ export function buildWebpackBrowser(
}).pipe(
// tslint:disable-next-line: no-big-function
concatMap(async buildEvent => {
const { webpackStats, success, emittedFiles = [] } = buildEvent;
if (!webpackStats) {
const { webpackStats: webpackRawStats, success, emittedFiles = [] } = buildEvent;
if (!webpackRawStats) {
throw new Error('Webpack stats build result is required.');
}

// Fix incorrectly set `initial` value on chunks.
const extraEntryPoints = normalizeExtraEntryPoints(options.styles || [], 'styles')
.concat(normalizeExtraEntryPoints(options.scripts || [], 'scripts'));
const webpackStats = {
...webpackRawStats,
chunks: markAsyncChunksNonInitial(webpackRawStats, extraEntryPoints),
};

if (!success && useBundleDownleveling) {
// If using bundle downleveling then there is only one build
// If it fails show any diagnostic messages and bail
Expand Down

0 comments on commit 061c5f3

Please sign in to comment.