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 deprecated `@Effect` decorator by NGRX and potentially other existing code as well 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 authored and clydin committed Sep 21, 2022
1 parent bdb3745 commit 1e5d4a7
Show file tree
Hide file tree
Showing 32 changed files with 411 additions and 199 deletions.
31 changes: 9 additions & 22 deletions packages/angular_devkit/build_angular/src/babel/webpack-loader.ts
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
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.
// Otherwise fallback to false due to https://github.com/microsoft/TypeScript/issues/45995
// which breaks the deprecated `@Effects` NGRX decorator and potentially other existing code as well.
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
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,
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 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
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
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
Expand Up @@ -27,11 +27,11 @@ describe('Browser Builder AOT', () => {
const run = await architect.scheduleTarget(targetSpec, overrides);
const output = (await run.result) as BrowserBuilderOutput;

expect(output.success).toBe(true);
expect(output.success).toBeTrue();

const fileName = join(normalize(output.outputPath), 'main.js');
const fileName = join(normalize(output.outputs[0].path), 'main.js');
const content = virtualFs.fileBufferToString(await host.read(normalize(fileName)).toPromise());
expect(content).toContain('AppComponent.ɵcmp');
expect(content).toContain('AppComponent_Factory');

await run.stop();
});
Expand Down
Expand Up @@ -65,8 +65,7 @@ describe('Browser Builder lazy modules', () => {

const { files } = await browserBuild(architect, host, target, { aot: true });
const data = await files['src_app_lazy_lazy_module_ts.js'];
expect(data).not.toBeUndefined();
expect(data).toContain('LazyModule.ɵmod');
expect(data).toContain('this.ɵmod');
});
});

Expand Down Expand Up @@ -126,7 +125,7 @@ describe('Browser Builder lazy modules', () => {
});

const { files } = await browserBuild(architect, host, target);
expect(files['src_lazy-module_ts.js']).not.toBeUndefined();
expect(files['src_lazy-module_ts.js']).toBeDefined();
});

it(`supports lazy bundle for dynamic import() calls`, async () => {
Expand All @@ -140,7 +139,7 @@ describe('Browser Builder lazy modules', () => {
host.replaceInFile('src/tsconfig.app.json', '"main.ts"', `"main.ts","lazy-module.ts"`);

const { files } = await browserBuild(architect, host, target);
expect(files['lazy-module.js']).not.toBeUndefined();
expect(files['lazy-module.js']).toBeDefined();
});

it(`supports making a common bundle for shared lazy modules`, async () => {
Expand All @@ -151,8 +150,8 @@ describe('Browser Builder lazy modules', () => {
});

const { files } = await browserBuild(architect, host, target);
expect(files['src_one_ts.js']).not.toBeUndefined();
expect(files['src_two_ts.js']).not.toBeUndefined();
expect(files['src_one_ts.js']).toBeDefined();
expect(files['src_two_ts.js']).toBeDefined();
expect(files['default-node_modules_angular_common_fesm2020_http_mjs.js']).toBeDefined();
});

Expand All @@ -164,8 +163,8 @@ describe('Browser Builder lazy modules', () => {
});

const { files } = await browserBuild(architect, host, target, { commonChunk: false });
expect(files['src_one_ts.js']).not.toBeUndefined();
expect(files['src_two_ts.js']).not.toBeUndefined();
expect(files['src_one_ts.js']).toBeDefined();
expect(files['src_two_ts.js']).toBeDefined();
expect(files['default-node_modules_angular_common_fesm2020_http_mjs.js']).toBeUndefined();
});
});
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
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,9 +84,9 @@ 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',
}),
);
});
Expand All @@ -96,12 +96,12 @@ describeBuilder(buildWebpackBrowser, BROWSER_BUILDER_INFO, (harness) => {
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
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
Expand Up @@ -152,6 +152,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 [getPlatformServerExportsConfig(wco), getCommonConfig(wco), getStylesConfig(wco)];
Expand Down
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;
}

0 comments on commit 1e5d4a7

Please sign in to comment.