Skip to content

feat(@angular-devkit/build-angular): add experimentalRollupPass #15690

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Oct 10, 2019
Merged
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
1 change: 1 addition & 0 deletions packages/angular_devkit/build_angular/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"postcss-loader": "3.0.0",
"raw-loader": "3.1.0",
"regenerator-runtime": "0.13.3",
"rollup": "1.21.4",
"rxjs": "6.5.3",
"sass": "1.23.0",
"sass-loader": "8.0.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ export interface BuildOptions {

/* When specified it will be used instead of the script target in the tsconfig.json. */
scriptTargetOverride?: ScriptTarget;

experimentalRollupPass?: boolean;
}

export interface WebpackTestOptions extends BuildOptions {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,14 @@ import {
import { tags } from '@angular-devkit/core';
import * as CopyWebpackPlugin from 'copy-webpack-plugin';
import * as path from 'path';
import { RollupOptions } from 'rollup';
import { ScriptTarget } from 'typescript';
import {
Compiler,
Configuration,
ContextReplacementPlugin,
HashedModuleIdsPlugin,
Rule,
compilation,
debug,
} from 'webpack';
Expand All @@ -29,6 +31,7 @@ import { BundleBudgetPlugin } from '../../plugins/bundle-budget';
import { CleanCssWebpackPlugin } from '../../plugins/cleancss-webpack-plugin';
import { NamedLazyChunksPlugin } from '../../plugins/named-chunks-plugin';
import { ScriptsWebpackPlugin } from '../../plugins/scripts-webpack-plugin';
import { WebpackRollupLoader } from '../../plugins/webpack';
import { findAllNodeModules, findUp } from '../../utilities/find-up';
import { WebpackConfigOptions } from '../build-options';
import { getEsVersionForFileName, getOutputHashFormat, normalizeExtraEntryPoints } from './utils';
Expand Down Expand Up @@ -57,6 +60,7 @@ export function getCommonConfig(wco: WebpackConfigOptions): Configuration {

// tslint:disable-next-line:no-any
const extraPlugins: any[] = [];
const extraRules: Rule[] = [];
const entryPoints: { [key: string]: string[] } = {};

const targetInFileName = getEsVersionForFileName(
Expand All @@ -65,7 +69,51 @@ export function getCommonConfig(wco: WebpackConfigOptions): Configuration {
);

if (buildOptions.main) {
entryPoints['main'] = [path.resolve(root, buildOptions.main)];
const mainPath = path.resolve(root, buildOptions.main);
entryPoints['main'] = [mainPath];

if (buildOptions.experimentalRollupPass) {
// NOTE: the following are known problems with experimentalRollupPass
// - vendorChunk, commonChunk, namedChunks: these won't work, because by the time webpack
// sees the chunks, the context of where they came from is lost.
// - webWorkerTsConfig: workers must be imported via a root relative path (e.g.
// `app/search/search.worker`) instead of a relative path (`/search.worker`) because
// of the same reason as above.
// - loadChildren string syntax: doesn't work because rollup cannot follow the imports.

// Rollup options, except entry module, which is automatically inferred.
const rollupOptions: RollupOptions = {};

// Add rollup plugins/rules.
extraRules.push({
test: mainPath,
// Ensure rollup loader executes after other loaders.
enforce: 'post',
use: [{
loader: WebpackRollupLoader,
options: rollupOptions,
}],
});

// Rollup bundles will include the dynamic System.import that was inside Angular and webpack
// will emit warnings because it can't resolve it. We just ignore it.
// TODO: maybe use https://webpack.js.org/configuration/stats/#statswarningsfilter instead.

// Ignore all "Critical dependency: the request of a dependency is an expression" warnings.
extraPlugins.push(new ContextReplacementPlugin(/./));
// Ignore "System.import() is deprecated" warnings for the main file and js files.
// Might still get them if @angular/core gets split into a lazy module.
extraRules.push({
test: mainPath,
enforce: 'post',
parser: { system: true },
});
extraRules.push({
test: /\.js$/,
enforce: 'post',
parser: { system: true },
});
}
}

let differentialLoadingNeeded = false;
Expand Down Expand Up @@ -482,6 +530,7 @@ export function getCommonConfig(wco: WebpackConfigOptions): Configuration {
enforce: 'pre',
...sourceMapUseRule,
},
...extraRules,
],
},
optimization: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
/**
* @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
*/

/**
* @license
* @author Erik Desjardins
* Forked as of SHA 10fb020f997a146725963b202d79290c8798a7a0 from https://github.com/erikdesjardins/webpack-rollup-loader.
* Licensed under a MIT license.
* See https://github.com/erikdesjardins/webpack-rollup-loader/blob/10fb020f997a146725963b202d79290c8798a7a0/LICENSE for full license.
*/

import { VirtualFileSystemDecorator } from '@ngtools/webpack/src/virtual_file_system_decorator';
import { dirname, join } from 'path';
import { OutputAsset, OutputChunk, rollup } from 'rollup';
import { RawSourceMap } from 'source-map';
import webpack = require('webpack');

function splitRequest(request: string) {
const inx = request.lastIndexOf('!');
if (inx === -1) {
return {
loaders: '',
resource: request,
};
} else {
return {
loaders: request.slice(0, inx + 1),
resource: request.slice(inx + 1),
};
}
}

// Load resolve paths using Webpack.
function webpackResolutionPlugin(
loaderContext: webpack.loader.LoaderContext,
entryId: string,
entryIdCodeAndMap: { code: string, map: RawSourceMap },
) {
return {
name: 'webpack-resolution-plugin',
resolveId: (id: string, importerId: string) => {
if (id === entryId) {
return entryId;
} else {
return new Promise((resolve, reject) => {
// split apart resource paths because Webpack's this.resolve() can't handle `loader!`
// prefixes
const parts = splitRequest(id);
const importerParts = splitRequest(importerId);

// resolve the full path of the imported file with Webpack's module loader
// this will figure out node_modules imports, Webpack aliases, etc.
loaderContext.resolve(
dirname(importerParts.resource),
parts.resource,
(err, fullPath) => err ? reject(err) : resolve(parts.loaders + fullPath),
);
});
}
},
load: (id: string) => {
if (id === entryId) {
return entryIdCodeAndMap;
}

return new Promise((resolve, reject) => {
// load the module with Webpack
// this will apply all relevant loaders, etc.
loaderContext.loadModule(
id,
(err, source, map) => err ? reject(err) : resolve({ code: source, map: map }),
);
});
},
};
}

export default function webpackRollupLoader(
this: webpack.loader.LoaderContext,
source: string,
sourceMap: RawSourceMap,
) {
// Note: this loader isn't cacheable because it will add the lazy chunks to the
// virtual file system on completion.
const callback = this.async();
if (!callback) {
throw new Error('Async loader support is required.');
}
const options = this.query || {};
const entryId = this.resourcePath;
const sourcemap = this.sourceMap;

// Get the VirtualFileSystemDecorator that AngularCompilerPlugin added so we can write to it.
// Since we use webpackRollupLoader as a post loader, this should be there.
// TODO: we should be able to do this in a more elegant way by again decorating webpacks
// input file system inside a custom WebpackRollupPlugin, modelled after AngularCompilerPlugin.
const vfs = this._compiler.inputFileSystem as VirtualFileSystemDecorator;
const virtualWrite = (path: string, data: string) =>
vfs.getWebpackCompilerHost().writeFile(path, data, false);

// Bundle with Rollup
const rollupOptions = {
...options,
input: entryId,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: should we disable treeshaking, such as pure functional calls removals if scripts optimisation is false?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we don't even want to use this if script optimization if false. But I haven't gotten around to further matching options into the rollup pass. I think it makes more sense to do that later after we start getting feedback from its shortcomings. We might have to add a lot of config then.

plugins: [
...(options.plugins || []),
webpackResolutionPlugin(this, entryId, { code: source, map: sourceMap }),
],
};

rollup(rollupOptions)
.then(build => build.generate({ format: 'es', sourcemap }))
.then(
(result) => {
const [mainChunk, ...otherChunksOrAssets] = result.output;

// Write other chunks and assets to the virtual file system so that webpack can load them.
const resultDir = dirname(entryId);
otherChunksOrAssets.forEach(chunkOrAsset => {
const { fileName, type } = chunkOrAsset;
if (type == 'chunk') {
const { code, map } = chunkOrAsset as OutputChunk;
virtualWrite(join(resultDir, fileName), code);
if (map) {
// Also write the map if there's one.
// Probably need scriptsSourceMap set on CLI to load it.
virtualWrite(join(resultDir, `${fileName}.map`), map.toString());
}
} else if (type == 'asset') {
const { source } = chunkOrAsset as OutputAsset;
// Source might be a Buffer. Just assuming it's a string for now.
virtualWrite(join(resultDir, fileName), source as string);
}
});

// Always return the main chunk from webpackRollupLoader.
// Cast to any here is needed because of a typings incompatibility between source-map versions.
// tslint:disable-next-line:no-any
callback(null, mainChunk.code, (mainChunk as any).map);
},
(err) => callback(err),
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ export {

import { join } from 'path';
export const RawCssLoader = require.resolve(join(__dirname, 'raw-css-loader'));
export const WebpackRollupLoader = require.resolve(join(__dirname, 'webpack-rollup-loader'));
5 changes: 5 additions & 0 deletions packages/angular_devkit/build_angular/src/browser/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,11 @@
"anonymous",
"use-credentials"
]
},
"experimentalRollupPass": {
"type": "boolean",
"description": "Concatenate modules with Rollup before bundling them with Webpack.",
"default": false
}
},
"additionalProperties": false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,18 @@ export async function generateWebpackConfig(
throw new Error(`The 'buildOptimizer' option cannot be used without 'aot'.`);
}

// Ensure Rollup Concatenation is only used with compatible options.
if (options.experimentalRollupPass) {
if (!options.aot) {
throw new Error(`The 'experimentalRollupPass' option cannot be used without 'aot'.`);
}

if (options.vendorChunk || options.commonChunk || options.namedChunks) {
throw new Error(`The 'experimentalRollupPass' option cannot be used with the`
+ `'vendorChunk', 'commonChunk', 'namedChunks' options set to true.`);
}
}

const tsConfigPath = path.resolve(workspaceRoot, options.tsConfig);
const tsConfig = readTsconfig(tsConfigPath);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/**
* @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 { Architect } from '@angular-devkit/architect';
import {
BrowserBuildOutput,
browserBuild,
createArchitect,
host,
lazyModuleFiles,
lazyModuleFnImport,
} from '../utils';


describe('Browser Builder Rollup Concatenation test', () => {
const target = { project: 'app', target: 'build' };
const overrides = {
experimentalRollupPass: true,
// JIT Rollup bundles will include require calls to .css and .html file, that have lost their
// path context. AOT code already inlines resources so that's not a problem.
aot: true,
// Webpack can't separate rolled-up modules into chunks.
vendorChunk: false,
commonChunk: false,
namedChunks: false,
};
const prodOverrides = {
// Usual prod options.
fileReplacements: [{
replace: 'src/environments/environment.ts',
with: 'src/environments/environment.prod.ts',
}],
optimization: true,
sourceMap: false,
extractCss: true,
namedChunks: false,
aot: true,
extractLicenses: true,
vendorChunk: false,
buildOptimizer: true,
// Extra prod options we need for experimentalRollupPass.
commonChunk: false,
// Just for convenience.
outputHashing: 'none',
};
const rollupProdOverrides = {
...prodOverrides,
experimentalRollupPass: true,
};
let architect: Architect;

const getOutputSize = async (output: BrowserBuildOutput) =>
(await Promise.all(
Object.keys(output.files)
.filter(name => name.endsWith('.js') &&
// These aren't concatenated by Rollup so no point comparing.
!['runtime.js', 'polyfills.js'].includes(name))
.map(name => output.files[name]),
))
.map(content => content.length)
.reduce((acc, curr) => acc + curr, 0);

beforeEach(async () => {
await host.initialize().toPromise();
architect = (await createArchitect(host.root())).architect;
});

afterEach(async () => host.restore().toPromise());

it('works', async () => {
await browserBuild(architect, host, target, overrides);
});

it('works with lazy modules', async () => {
host.writeMultipleFiles(lazyModuleFiles);
host.writeMultipleFiles(lazyModuleFnImport);
await browserBuild(architect, host, target, overrides);
});

it('creates smaller or same size bundles for app without lazy bundles', async () => {
const prodOutput = await browserBuild(architect, host, target, prodOverrides);
const prodSize = await getOutputSize(prodOutput);
const rollupProdOutput = await browserBuild(architect, host, target, rollupProdOverrides);
const rollupProd = await getOutputSize(rollupProdOutput);
expect(prodSize).toBeGreaterThan(rollupProd);
});

it('creates smaller bundles for apps with lazy bundles', async () => {
host.writeMultipleFiles(lazyModuleFiles);
host.writeMultipleFiles(lazyModuleFnImport);
const prodOutput = await browserBuild(architect, host, target, prodOverrides);
const prodSize = await getOutputSize(prodOutput);
const rollupProdOutput = await browserBuild(architect, host, target, rollupProdOverrides);
const rollupProd = await getOutputSize(rollupProdOutput);
expect(prodSize).toBeGreaterThan(rollupProd);
});
});
Loading