Skip to content

Commit

Permalink
feat(@angular-devkit/build-angular): use Browserslist to determine EC…
Browse files Browse the repository at this point in the history
…MA output

With this change we reduce the reliance on the TypeScript target compiler option to output a certain ECMA version. Instead we now use the browsers that are configured in the Browserslist configuration to determine which ECMA features and version are needed.
This is done by passing the transpiled TypeScript to Babel preset-env.

**Note about useDefineForClassFields**: while setting this to `false` will output JavaScript which is not spec compliant, this is needed because TypeScript introduced class fields many years before it was ratified in TC39. The latest version of the spec have a different runtime behavior to TypeScript’s implementation but the same syntax. Therefore, we opt-out from using upcoming ECMA runtime behavior to better support the ECO system and libraries that depend on the non spec compliant output. One of biggest case is usages of the deprected `@Effect` decorator by NGRX which otherwise would cause runtime failures. Dropping `useDefineForClassFields` will be considered in a future major releases. For more information see: microsoft/TypeScript#45995.

BREAKING CHANGE: Internally. the Angular CLI now always set the TypeScript `target` to `ES2022` and `useDefineForClassFields` to `false` unless the target is set to `ES2022` or later in the TypeScript configuration. To control ECMA version and features use the Browerslist configuration.
  • Loading branch information
alan-agius4 committed Sep 20, 2022
1 parent 113e2c0 commit cf4e01e
Show file tree
Hide file tree
Showing 30 changed files with 403 additions and 191 deletions.
31 changes: 9 additions & 22 deletions packages/angular_devkit/build_angular/src/babel/webpack-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
*/

import { custom } from 'babel-loader';
import { ScriptTarget } from 'typescript';
import { loadEsmModule } from '../utils/load-esm';
import { VERSION } from '../utils/package-version';
import { ApplicationPresetOptions, I18nPluginCreators } from './presets/application';
Expand Down Expand Up @@ -72,15 +71,8 @@ export default custom<ApplicationPresetOptions>(() => {

return {
async customOptions(options, { source, map }) {
const {
i18n,
scriptTarget,
aot,
optimize,
instrumentCode,
supportedBrowsers,
...rawOptions
} = options as AngularBabelLoaderOptions;
const { i18n, aot, optimize, instrumentCode, supportedBrowsers, ...rawOptions } =
options as AngularBabelLoaderOptions;

// Must process file if plugins are added
let shouldProcess = Array.isArray(rawOptions.plugins) && rawOptions.plugins.length > 0;
Expand Down Expand Up @@ -114,24 +106,19 @@ export default custom<ApplicationPresetOptions>(() => {
}

// Analyze for ES target processing
const esTarget = scriptTarget as ScriptTarget | undefined;
const isJsFile = /\.[cm]?js$/.test(this.resourcePath);

if (isJsFile && customOptions.supportedBrowsers?.length) {
if (customOptions.supportedBrowsers?.length) {
// Applications code ES version can be controlled using TypeScript's `target` option.
// However, this doesn't effect libraries and hence we use preset-env to downlevel ES fetaures
// based on the supported browsers in browserlist.
customOptions.forcePresetEnv = true;
}

if ((esTarget !== undefined && esTarget >= ScriptTarget.ES2017) || isJsFile) {
// Application code (TS files) will only contain native async if target is ES2017+.
// However, third-party libraries can regardless of the target option.
// APF packages with code in [f]esm2015 directories is downlevelled to ES2015 and
// will not have native async.
customOptions.forceAsyncTransformation =
!/[\\/][_f]?esm2015[\\/]/.test(this.resourcePath) && source.includes('async');
}
// Application code (TS files) will only contain native async if target is ES2017+.
// However, third-party libraries can regardless of the target option.
// APF packages with code in [f]esm2015 directories is downlevelled to ES2015 and
// will not have native async.
customOptions.forceAsyncTransformation =
!/[\\/][_f]?esm2015[\\/]/.test(this.resourcePath) && source.includes('async');

shouldProcess ||=
customOptions.forceAsyncTransformation || customOptions.forcePresetEnv || false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -182,16 +182,13 @@ export function createCompilerPlugin(
enableResourceInlining: false,
});

// Adjust the esbuild output target based on the tsconfig target
if (
compilerOptions.target === undefined ||
compilerOptions.target <= ts.ScriptTarget.ES2015
) {
build.initialOptions.target = 'es2015';
} else if (compilerOptions.target >= ts.ScriptTarget.ESNext) {
build.initialOptions.target = 'esnext';
} else {
build.initialOptions.target = ts.ScriptTarget[compilerOptions.target].toLowerCase();
if (compilerOptions.target === undefined || compilerOptions.target < ts.ScriptTarget.ES2022) {
// If 'useDefineForClassFields' is already defined in the users project leave the value as is.
// Othereise fallback to false to due https://github.com/microsoft/TypeScript/issues/45995
// which breaks the deprecated @Effects NGRX decorator.
compilerOptions.target = ts.ScriptTarget.ES2022;
compilerOptions.useDefineForClassFields ??= false;
// TODO: show warning about this override when we have access to the logger.
}

// The file emitter created during `onStart` that will be used during the build in `onLoad` callbacks for TS files
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@ import * as path from 'path';
import { NormalizedOptimizationOptions, deleteOutputDir } from '../../utils';
import { copyAssets } from '../../utils/copy-assets';
import { assertIsError } from '../../utils/error';
import { transformSupportedBrowsersToTargets } from '../../utils/esbuild-targets';
import { FileInfo } from '../../utils/index-file/augment-index-html';
import { IndexHtmlGenerator } from '../../utils/index-file/index-html-generator';
import { generateEntryPoints } from '../../utils/package-chunk-sort';
import { augmentAppWithServiceWorker } from '../../utils/service-worker';
import { getSupportedBrowsers } from '../../utils/supported-browsers';
import { getIndexInputFile, getIndexOutputFile } from '../../utils/webpack-browser-config';
import { resolveGlobalStyles } from '../../webpack/configs';
import { createCompilerPlugin } from './compiler-plugin';
Expand Down Expand Up @@ -89,6 +91,10 @@ export async function buildEsbuildBrowser(
return { success: false };
}

const target = transformSupportedBrowsersToTargets(
getSupportedBrowsers(projectRoot, context.logger),
);

const [codeResults, styleResults] = await Promise.all([
// Execute esbuild to bundle the application code
bundleCode(
Expand All @@ -99,6 +105,7 @@ export async function buildEsbuildBrowser(
optimizationOptions,
sourcemapOptions,
tsconfig,
target,
),
// Execute esbuild to bundle the global stylesheets
bundleGlobalStylesheets(
Expand All @@ -107,6 +114,7 @@ export async function buildEsbuildBrowser(
options,
optimizationOptions,
sourcemapOptions,
target,
),
]);

Expand Down Expand Up @@ -248,6 +256,7 @@ async function bundleCode(
optimizationOptions: NormalizedOptimizationOptions,
sourcemapOptions: SourceMapClass,
tsconfig: string,
target: string[],
) {
let fileReplacements: Record<string, string> | undefined;
if (options.fileReplacements) {
Expand All @@ -267,7 +276,7 @@ async function bundleCode(
entryPoints,
entryNames: outputNames.bundles,
assetNames: outputNames.media,
target: 'es2020',
target: 'es2022',
supported: {
// Native async/await is not supported with Zone.js. Disabling support here will cause
// esbuild to downlevel async/await and for await...of to a Zone.js supported form. However, esbuild
Expand All @@ -276,8 +285,8 @@ async function bundleCode(
// NOTE: If esbuild adds support in the future, the babel support for async generators can be disabled.
'async-await': false,
},
mainFields: ['es2020', 'browser', 'module', 'main'],
conditions: ['es2020', 'es2015', 'module'],
mainFields: ['es2022', 'browser', 'module', 'main'],
conditions: ['es2022', 'es2015', 'module'],
resolveExtensions: ['.ts', '.tsx', '.mjs', '.js'],
logLevel: options.verbose ? 'debug' : 'silent',
metafile: true,
Expand Down Expand Up @@ -313,6 +322,7 @@ async function bundleCode(
outputNames,
includePaths: options.stylePreprocessorOptions?.includePaths,
externalDependencies: options.externalDependencies,
target,
},
),
],
Expand All @@ -329,6 +339,7 @@ async function bundleGlobalStylesheets(
options: BrowserBuilderOptions,
optimizationOptions: NormalizedOptimizationOptions,
sourcemapOptions: SourceMapClass,
target: string[],
) {
const outputFiles: OutputFile[] = [];
const initialFiles: FileInfo[] = [];
Expand Down Expand Up @@ -360,6 +371,7 @@ async function bundleGlobalStylesheets(
includePaths: options.stylePreprocessorOptions?.includePaths,
preserveSymlinks: options.preserveSymlinks,
externalDependencies: options.externalDependencies,
target,
},
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export interface BundleStylesheetOptions {
outputNames?: { bundles?: string; media?: string };
includePaths?: string[];
externalDependencies?: string[];
target: string[];
}

async function bundleStylesheet(
Expand All @@ -43,6 +44,7 @@ async function bundleStylesheet(
outdir: options.workspaceRoot,
write: false,
platform: 'browser',
target: options.target,
preserveSymlinks: options.preserveSymlinks,
external: options.externalDependencies,
conditions: ['style', 'sass'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ describe('Browser Builder allow js', () => {

host.replaceInFile(
'tsconfig.json',
'"target": "es2020"',
'"target": "es2020", "allowJs": true',
'"target": "es2022"',
'"target": "es2022", "allowJs": true',
);

const run = await architect.scheduleTarget(targetSpec);
Expand All @@ -56,8 +56,8 @@ describe('Browser Builder allow js', () => {

host.replaceInFile(
'tsconfig.json',
'"target": "es2020"',
'"target": "es2020", "allowJs": true',
'"target": "es2022"',
'"target": "es2022", "allowJs": true',
);

const overrides = { aot: true };
Expand All @@ -83,8 +83,8 @@ describe('Browser Builder allow js', () => {

host.replaceInFile(
'tsconfig.json',
'"target": "es2020"',
'"target": "es2020", "allowJs": true',
'"target": "es2022"',
'"target": "es2022", "allowJs": true',
);

const overrides = { watch: true };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ describe('Browser Builder resolve json module', () => {

host.replaceInFile(
'tsconfig.json',
'"target": "es2020"',
'"target": "es2020", "resolveJsonModule": true',
'"target": "es2022"',
'"target": "es2022", "resolveJsonModule": true',
);

const overrides = { watch: true };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,12 @@ describeBuilder(buildWebpackBrowser, BROWSER_BUILDER_INFO, (harness) => {
});

it('warns when IE is present in browserslist', async () => {
await harness.writeFile(
await harness.appendToFile(
'.browserslistrc',
`
IE 9
IE 11
`,
IE 9
IE 11
`,
);

harness.useTarget('build', {
Expand All @@ -84,24 +84,24 @@ describeBuilder(buildWebpackBrowser, BROWSER_BUILDER_INFO, (harness) => {
jasmine.objectContaining({
level: 'warn',
message:
`One or more browsers which are configured in the project's Browserslist configuration ` +
'will be ignored as ES5 output is not supported by the Angular CLI.\n' +
`Ignored browsers: ie 11, ie 9`,
`One or more browsers which are configured in the project's Browserslist ` +
'configuration will be ignored as ES5 output is not supported by the Angular CLI.\n' +
'Ignored browsers: ie 11, ie 9',
}),
);
});

it('downlevels "for await...of"', async () => {
it('downlevels "for await...of" when targetting ES2018+', async () => {
// Add an async function to the project
await harness.writeFile(
'src/main.ts',
`
(async () => {
for await (const o of [1, 2, 3]) {
console.log("for await...of");
}
})();
`,
(async () => {
for await (const o of [1, 2, 3]) {
console.log("for await...of");
}
})();
`,
);

harness.useTarget('build', {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,11 @@ describeBuilder(serveWebpackBrowser, DEV_SERVER_BUILDER_INFO, (harness) => {
};

describe('Behavior: "dev-server builder serves service worker"', () => {
beforeEach(() => {
beforeEach(async () => {
// Application code is not needed for these tests
await harness.writeFile('src/main.ts', '');
await harness.writeFile('src/polyfills.ts', '');

harness.useProject('test', {
root: '.',
sourceRoot: 'src',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ async function initialize(
context,
(wco) => {
// We use the platform to determine the JavaScript syntax output.
wco.buildOptions.supportedBrowsers ??= [];
wco.buildOptions.supportedBrowsers.push(...browserslist('maintained node versions'));

return [getCommonConfig(wco), getStylesConfig(wco)];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ export interface BuildOptions {
cache: NormalizedCachedOptions;
codeCoverage?: boolean;
codeCoverageExclude?: string[];
supportedBrowsers: string[];
supportedBrowsers?: string[];
}

export interface WebpackDevServerOptions
Expand All @@ -87,6 +87,5 @@ export interface WebpackConfigOptions<T = BuildOptions> {
buildOptions: T;
tsConfig: ParsedConfiguration;
tsConfigPath: string;
scriptTarget: import('typescript').ScriptTarget;
projectName: string;
}
43 changes: 43 additions & 0 deletions packages/angular_devkit/build_angular/src/utils/esbuild-targets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* @license
* Copyright Google LLC 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
*/

/**
* Transform browserlists result to esbuild target.
* @see https://esbuild.github.io/api/#target
*/
export function transformSupportedBrowsersToTargets(supportedBrowsers: string[]): string[] {
const transformed: string[] = [];

// https://esbuild.github.io/api/#target
const esBuildSupportedBrowsers = new Set(['safari', 'firefox', 'edge', 'chrome', 'ios', 'node']);

for (const browser of supportedBrowsers) {
let [browserName, version] = browser.split(' ');

// browserslist uses the name `ios_saf` for iOS Safari whereas esbuild uses `ios`
if (browserName === 'ios_saf') {
browserName = 'ios';
}

// browserslist uses ranges `15.2-15.3` versions but only the lowest is required
// to perform minimum supported feature checks. esbuild also expects a single version.
[version] = version.split('-');

if (esBuildSupportedBrowsers.has(browserName)) {
if (browserName === 'safari' && version === 'TP') {
// esbuild only supports numeric versions so `TP` is converted to a high number (999) since
// a Technology Preview (TP) of Safari is assumed to support all currently known features.
version = '999';
}

transformed.push(browserName + version);
}
}

return transformed;
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,6 @@ export async function generateWebpackConfig(
const tsConfigPath = path.resolve(workspaceRoot, options.tsConfig);
const tsConfig = await readTsconfig(tsConfigPath);

const ts = await import('typescript');
const scriptTarget = tsConfig.options.target || ts.ScriptTarget.ES2015;

const buildOptions: NormalizedBrowserBuilderSchema = { ...options, ...extraBuildOptions };
const wco: BrowserWebpackConfigOptions = {
root: workspaceRoot,
Expand All @@ -55,7 +52,6 @@ export async function generateWebpackConfig(
tsConfig,
tsConfigPath,
projectName,
scriptTarget,
};

wco.buildOptions.progress = defaultProgress(wco.buildOptions.progress);
Expand Down
Loading

0 comments on commit cf4e01e

Please sign in to comment.