From 60b506dcbb13101e0ff1f959903eae182717dc8c Mon Sep 17 00:00:00 2001 From: Alan Agius Date: Wed, 21 Sep 2022 07:12:06 +0000 Subject: [PATCH] feat(@angular-devkit/build-angular): use Browserslist to determine ECMA output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: https://github.com/microsoft/TypeScript/issues/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. --- .../build_angular/src/babel/webpack-loader.ts | 31 ++-- .../browser-esbuild/compiler-plugin.ts | 17 +- .../src/builders/browser-esbuild/index.ts | 14 +- .../builders/browser-esbuild/stylesheets.ts | 2 + .../builders/browser/specs/allow-js_spec.ts | 12 +- .../src/builders/browser/specs/aot_spec.ts | 6 +- .../browser/specs/lazy-module_spec.ts | 15 +- .../browser/specs/resolve-json-module_spec.ts | 4 +- .../tests/behavior/browser-support_spec.ts | 26 ++-- .../behavior/serve_service-worker_spec.ts | 6 +- .../src/builders/server/index.ts | 1 + .../build_angular/src/utils/build-options.ts | 3 +- .../src/utils/esbuild-targets.ts | 43 +++++ .../src/utils/webpack-browser-config.ts | 4 - .../src/webpack/configs/common.ts | 25 +-- .../webpack/plugins/css-optimizer-plugin.ts | 35 +---- .../plugins/javascript-optimizer-plugin.ts | 30 ++-- .../plugins/javascript-optimizer-worker.ts | 14 +- .../src/webpack/plugins/typescript.ts | 13 +- .../src/webpack/utils/helpers.ts | 21 --- .../test/hello-world-app/tsconfig.json | 7 +- .../test/hello-world-lib/tsconfig.json | 5 +- .../test/angular-app/src/tsconfig.app.json | 2 +- .../test/angular-app/tsconfig.json | 4 +- .../test/basic-app/tsconfig.json | 4 +- .../migrations/migration-collection.json | 5 + .../update-15/update-typescript-target.ts | 77 +++++++++ .../update-typescript-target_spec.ts | 147 ++++++++++++++++++ .../workspace/files/tsconfig.json.template | 7 +- .../e2e/assets/12.0-project/tsconfig.json | 7 +- .../e2e/assets/webpack/test-app/tsconfig.json | 2 +- .../e2e/tests/build/scripts-output-hashing.ts | 20 ++- 32 files changed, 410 insertions(+), 199 deletions(-) create mode 100644 packages/angular_devkit/build_angular/src/utils/esbuild-targets.ts create mode 100644 packages/schematics/angular/migrations/update-15/update-typescript-target.ts create mode 100644 packages/schematics/angular/migrations/update-15/update-typescript-target_spec.ts diff --git a/packages/angular_devkit/build_angular/src/babel/webpack-loader.ts b/packages/angular_devkit/build_angular/src/babel/webpack-loader.ts index 6d23d24c25cb..78af1b28d480 100644 --- a/packages/angular_devkit/build_angular/src/babel/webpack-loader.ts +++ b/packages/angular_devkit/build_angular/src/babel/webpack-loader.ts @@ -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'; @@ -72,15 +71,8 @@ export default custom(() => { 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; @@ -114,24 +106,19 @@ export default custom(() => { } // 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; diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/compiler-plugin.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/compiler-plugin.ts index 9125b013228a..59202089ea47 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/compiler-plugin.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/compiler-plugin.ts @@ -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 diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/index.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/index.ts index 07a1880e2bb2..fb858a9e3e33 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/index.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/index.ts @@ -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'; @@ -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( @@ -99,6 +105,7 @@ export async function buildEsbuildBrowser( optimizationOptions, sourcemapOptions, tsconfig, + target, ), // Execute esbuild to bundle the global stylesheets bundleGlobalStylesheets( @@ -107,6 +114,7 @@ export async function buildEsbuildBrowser( options, optimizationOptions, sourcemapOptions, + target, ), ]); @@ -248,6 +256,7 @@ async function bundleCode( optimizationOptions: NormalizedOptimizationOptions, sourcemapOptions: SourceMapClass, tsconfig: string, + target: string[], ) { let fileReplacements: Record | undefined; if (options.fileReplacements) { @@ -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 @@ -313,6 +322,7 @@ async function bundleCode( outputNames, includePaths: options.stylePreprocessorOptions?.includePaths, externalDependencies: options.externalDependencies, + target, }, ), ], @@ -329,6 +339,7 @@ async function bundleGlobalStylesheets( options: BrowserBuilderOptions, optimizationOptions: NormalizedOptimizationOptions, sourcemapOptions: SourceMapClass, + target: string[], ) { const outputFiles: OutputFile[] = []; const initialFiles: FileInfo[] = []; @@ -360,6 +371,7 @@ async function bundleGlobalStylesheets( includePaths: options.stylePreprocessorOptions?.includePaths, preserveSymlinks: options.preserveSymlinks, externalDependencies: options.externalDependencies, + target, }, ); diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets.ts index 0f932bd12849..2bfaf70f867c 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets.ts @@ -20,6 +20,7 @@ export interface BundleStylesheetOptions { outputNames?: { bundles?: string; media?: string }; includePaths?: string[]; externalDependencies?: string[]; + target: string[]; } async function bundleStylesheet( @@ -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'], diff --git a/packages/angular_devkit/build_angular/src/builders/browser/specs/allow-js_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/specs/allow-js_spec.ts index 4f5ab84c0eff..049b95ee8e42 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser/specs/allow-js_spec.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser/specs/allow-js_spec.ts @@ -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); @@ -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 }; @@ -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 }; diff --git a/packages/angular_devkit/build_angular/src/builders/browser/specs/aot_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/specs/aot_spec.ts index 44f2cba1eca4..ae6b620aa37c 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser/specs/aot_spec.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser/specs/aot_spec.ts @@ -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(); }); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/specs/lazy-module_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/specs/lazy-module_spec.ts index 5e025a65cb81..fa0f7c381b1a 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser/specs/lazy-module_spec.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser/specs/lazy-module_spec.ts @@ -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'); }); }); @@ -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 () => { @@ -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 () => { @@ -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(); }); @@ -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(); }); }); diff --git a/packages/angular_devkit/build_angular/src/builders/browser/specs/resolve-json-module_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/specs/resolve-json-module_spec.ts index 7cee64ef8497..c47fd03462e7 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser/specs/resolve-json-module_spec.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser/specs/resolve-json-module_spec.ts @@ -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 }; diff --git a/packages/angular_devkit/build_angular/src/builders/browser/tests/behavior/browser-support_spec.ts b/packages/angular_devkit/build_angular/src/builders/browser/tests/behavior/browser-support_spec.ts index e1f46f8efc69..7e488ddcbc2b 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser/tests/behavior/browser-support_spec.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser/tests/behavior/browser-support_spec.ts @@ -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', { @@ -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', }), ); }); @@ -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', { diff --git a/packages/angular_devkit/build_angular/src/builders/dev-server/tests/behavior/serve_service-worker_spec.ts b/packages/angular_devkit/build_angular/src/builders/dev-server/tests/behavior/serve_service-worker_spec.ts index bbd5872ad711..51d663d61dcf 100644 --- a/packages/angular_devkit/build_angular/src/builders/dev-server/tests/behavior/serve_service-worker_spec.ts +++ b/packages/angular_devkit/build_angular/src/builders/dev-server/tests/behavior/serve_service-worker_spec.ts @@ -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', diff --git a/packages/angular_devkit/build_angular/src/builders/server/index.ts b/packages/angular_devkit/build_angular/src/builders/server/index.ts index 554d7d333e0a..62737cbbb14c 100644 --- a/packages/angular_devkit/build_angular/src/builders/server/index.ts +++ b/packages/angular_devkit/build_angular/src/builders/server/index.ts @@ -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)]; diff --git a/packages/angular_devkit/build_angular/src/utils/build-options.ts b/packages/angular_devkit/build_angular/src/utils/build-options.ts index 9282353cc1a2..a986b9786415 100644 --- a/packages/angular_devkit/build_angular/src/utils/build-options.ts +++ b/packages/angular_devkit/build_angular/src/utils/build-options.ts @@ -72,7 +72,7 @@ export interface BuildOptions { cache: NormalizedCachedOptions; codeCoverage?: boolean; codeCoverageExclude?: string[]; - supportedBrowsers: string[]; + supportedBrowsers?: string[]; } export interface WebpackDevServerOptions @@ -87,6 +87,5 @@ export interface WebpackConfigOptions { buildOptions: T; tsConfig: ParsedConfiguration; tsConfigPath: string; - scriptTarget: import('typescript').ScriptTarget; projectName: string; } diff --git a/packages/angular_devkit/build_angular/src/utils/esbuild-targets.ts b/packages/angular_devkit/build_angular/src/utils/esbuild-targets.ts new file mode 100644 index 000000000000..276adf234690 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/utils/esbuild-targets.ts @@ -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; +} diff --git a/packages/angular_devkit/build_angular/src/utils/webpack-browser-config.ts b/packages/angular_devkit/build_angular/src/utils/webpack-browser-config.ts index b0d2686dbd36..abdcfa35c863 100644 --- a/packages/angular_devkit/build_angular/src/utils/webpack-browser-config.ts +++ b/packages/angular_devkit/build_angular/src/utils/webpack-browser-config.ts @@ -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, @@ -55,7 +52,6 @@ export async function generateWebpackConfig( tsConfig, tsConfigPath, projectName, - scriptTarget, }; wco.buildOptions.progress = defaultProgress(wco.buildOptions.progress); diff --git a/packages/angular_devkit/build_angular/src/webpack/configs/common.ts b/packages/angular_devkit/build_angular/src/webpack/configs/common.ts index 636e9ef073da..be04758a5bc6 100644 --- a/packages/angular_devkit/build_angular/src/webpack/configs/common.ts +++ b/packages/angular_devkit/build_angular/src/webpack/configs/common.ts @@ -40,7 +40,6 @@ import { externalizePackages, getCacheSettings, getInstrumentationExcludedPaths, - getMainFieldsAndConditionNames, getOutputHashFormat, getStatsOptions, globalScriptsByBundleName, @@ -50,16 +49,7 @@ const VENDORS_TEST = /[\\/]node_modules[\\/]/; // eslint-disable-next-line max-lines-per-function export async function getCommonConfig(wco: WebpackConfigOptions): Promise { - const { - root, - projectRoot, - buildOptions, - tsConfig, - projectName, - sourceRoot, - tsConfigPath, - scriptTarget, - } = wco; + const { root, projectRoot, buildOptions, tsConfig, projectName, sourceRoot, tsConfigPath } = wco; const { cache, codeCoverage, @@ -270,7 +260,7 @@ export async function getCommonConfig(wco: WebpackConfigOptions): Promise, advanced: boolean | undefined, ): Promise<{ code: string; map?: object }> { const result = await minify( @@ -193,7 +190,8 @@ async function optimizeWithTerser( passes: advanced ? 2 : 1, pure_getters: advanced, }, - ecma: target, + // terser only supports up to ES2020 + ecma: 2020, // esbuild in the first pass is used to minify identifiers instead of mangle here mangle: false, // esbuild in the first pass is used to minify function names diff --git a/packages/angular_devkit/build_angular/src/webpack/plugins/typescript.ts b/packages/angular_devkit/build_angular/src/webpack/plugins/typescript.ts index 7aa644732d05..043919f1e2cf 100644 --- a/packages/angular_devkit/build_angular/src/webpack/plugins/typescript.ts +++ b/packages/angular_devkit/build_angular/src/webpack/plugins/typescript.ts @@ -25,9 +25,16 @@ export function createIvyPlugin( declarationMap: false, }; - if (tsConfig.options.target === undefined || tsConfig.options.target <= ScriptTarget.ES5) { - throw new Error( - 'ES output older than ES2015 is not supported. Please update TypeScript "target" compiler option to ES2015 or later.', + if (tsConfig.options.target === undefined || tsConfig.options.target < ScriptTarget.ES2022) { + tsConfig.options.target = 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. + tsConfig.options.useDefineForClassFields ??= false; + + wco.logger.warn( + 'TypeScript compiler options "target" and "useDefineForClassFields" are set to "ES2022" and ' + + '"false" respectively by the Angular CLI. To control ECMA version and features use the Browerslist configuration.', ); } diff --git a/packages/angular_devkit/build_angular/src/webpack/utils/helpers.ts b/packages/angular_devkit/build_angular/src/webpack/utils/helpers.ts index 9fc774bb8369..022842e0239a 100644 --- a/packages/angular_devkit/build_angular/src/webpack/utils/helpers.ts +++ b/packages/angular_devkit/build_angular/src/webpack/utils/helpers.ts @@ -11,7 +11,6 @@ import { createHash } from 'crypto'; import { existsSync } from 'fs'; import glob from 'glob'; import * as path from 'path'; -import { ScriptTarget } from 'typescript'; import type { Configuration, WebpackOptionsNormalized } from 'webpack'; import { AssetPatternClass, @@ -317,23 +316,3 @@ export function getStatsOptions(verbose = false): WebpackStatsOptions { ? { ...webpackOutputOptions, ...verboseWebpackOutputOptions } : webpackOutputOptions; } - -export function getMainFieldsAndConditionNames( - target: ScriptTarget, - platformServer: boolean, -): Pick { - const mainFields = platformServer - ? ['es2015', 'module', 'main'] - : ['es2015', 'browser', 'module', 'main']; - const conditionNames = ['es2015', '...']; - - if (target >= ScriptTarget.ES2020) { - mainFields.unshift('es2020'); - conditionNames.unshift('es2020'); - } - - return { - mainFields, - conditionNames, - }; -} diff --git a/packages/angular_devkit/build_angular/test/hello-world-app/tsconfig.json b/packages/angular_devkit/build_angular/test/hello-world-app/tsconfig.json index 91d00e2ae8f7..26bec6bf178a 100644 --- a/packages/angular_devkit/build_angular/test/hello-world-app/tsconfig.json +++ b/packages/angular_devkit/build_angular/test/hello-world-app/tsconfig.json @@ -8,13 +8,14 @@ "moduleResolution": "node", "emitDecoratorMetadata": true, "experimentalDecorators": true, - "target": "es2020", - "module": "es2020", + "target": "es2022", + "module": "es2022", + "useDefineForClassFields": false, "typeRoots": [ "node_modules/@types" ], "lib": [ - "es2020", + "es2022", "dom" ] }, diff --git a/packages/angular_devkit/build_angular/test/hello-world-lib/tsconfig.json b/packages/angular_devkit/build_angular/test/hello-world-lib/tsconfig.json index 455f55ed0b41..26bfbc225506 100644 --- a/packages/angular_devkit/build_angular/test/hello-world-lib/tsconfig.json +++ b/packages/angular_devkit/build_angular/test/hello-world-lib/tsconfig.json @@ -8,12 +8,13 @@ "moduleResolution": "node", "experimentalDecorators": true, "target": "es2015", - "module": "es2020", + "module": "es2022", + "useDefineForClassFields": false, "typeRoots": [ "node_modules/@types" ], "lib": [ - "es2020", + "es2022", "dom" ] }, diff --git a/packages/angular_devkit/build_webpack/test/angular-app/src/tsconfig.app.json b/packages/angular_devkit/build_webpack/test/angular-app/src/tsconfig.app.json index c1ec1d179974..4191001bb4d4 100644 --- a/packages/angular_devkit/build_webpack/test/angular-app/src/tsconfig.app.json +++ b/packages/angular_devkit/build_webpack/test/angular-app/src/tsconfig.app.json @@ -2,7 +2,7 @@ "extends": "../tsconfig.json", "compilerOptions": { "outDir": "../out-tsc/app", - "module": "es2020", + "module": "es2022", "types": [] }, "exclude": ["test.ts", "**/*.spec.ts"] diff --git a/packages/angular_devkit/build_webpack/test/angular-app/tsconfig.json b/packages/angular_devkit/build_webpack/test/angular-app/tsconfig.json index aa337c9e758d..5e7ab16f0c42 100644 --- a/packages/angular_devkit/build_webpack/test/angular-app/tsconfig.json +++ b/packages/angular_devkit/build_webpack/test/angular-app/tsconfig.json @@ -8,9 +8,9 @@ "moduleResolution": "node", "emitDecoratorMetadata": true, "experimentalDecorators": true, - "target": "es2020", + "target": "es2022", "typeRoots": ["node_modules/@types"], - "lib": ["es2020", "dom"] + "lib": ["es2022", "dom"] }, "angularCompilerOptions": { "enableIvy": true, diff --git a/packages/angular_devkit/build_webpack/test/basic-app/tsconfig.json b/packages/angular_devkit/build_webpack/test/basic-app/tsconfig.json index caa9637b15dc..985646bca926 100644 --- a/packages/angular_devkit/build_webpack/test/basic-app/tsconfig.json +++ b/packages/angular_devkit/build_webpack/test/basic-app/tsconfig.json @@ -7,10 +7,10 @@ "declaration": false, "moduleResolution": "node", "experimentalDecorators": true, - "target": "es2020", + "target": "es2022", "module": "esnext", "typeRoots": ["node_modules/@types"], - "lib": ["es2020", "dom"] + "lib": ["es2022", "dom"] }, "angularCompilerOptions": { "disableTypeScriptVersionCheck": true diff --git a/packages/schematics/angular/migrations/migration-collection.json b/packages/schematics/angular/migrations/migration-collection.json index ac1dcc473d78..7ca61d6466de 100644 --- a/packages/schematics/angular/migrations/migration-collection.json +++ b/packages/schematics/angular/migrations/migration-collection.json @@ -9,6 +9,11 @@ "version": "15.0.0", "factory": "./update-15/remove-platform-server-exports", "description": "Remove exported `@angular/platform-server` `renderModule` method. The `renderModule` method is now exported by the Angular CLI." + }, + "update-typescript-target": { + "version": "15.0.0", + "factory": "./update-15/update-typescript-target", + "description": "Update TypeScript compiler `target` and set `useDefineForClassFields`. These changes are for IDE purposes as TypeScript compiler options `target` and `useDefineForClassFields` are set to `ES2022` and `false` respectively by the Angular CLI. To control ECMA version and features use the Browerslist configuration. For more information, see https://github.com/browserslist/browserslist" } } } diff --git a/packages/schematics/angular/migrations/update-15/update-typescript-target.ts b/packages/schematics/angular/migrations/update-15/update-typescript-target.ts new file mode 100644 index 000000000000..008bf26e893d --- /dev/null +++ b/packages/schematics/angular/migrations/update-15/update-typescript-target.ts @@ -0,0 +1,77 @@ +/** + * @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 + */ + +import { JsonObject } from '@angular-devkit/core'; +import { Rule, Tree } from '@angular-devkit/schematics'; +import { JSONFile } from '../../utility/json-file'; +import { getWorkspace } from '../../utility/workspace'; +import { Builders } from '../../utility/workspace-models'; + +export default function (): Rule { + return async (host) => { + // Workspace level tsconfig + updateTarget(host, 'tsconfig.json'); + + const workspace = await getWorkspace(host); + + // Find all tsconfig which are refereces used by builders + for (const [, project] of workspace.projects) { + for (const [, target] of project.targets) { + // Update all other known CLI builders that use a tsconfig + const tsConfigs = [target.options || {}, ...Object.values(target.configurations || {})] + .filter((opt) => typeof opt?.tsConfig === 'string') + .map((opt) => (opt as { tsConfig: string }).tsConfig); + + const uniqueTsConfigs = [...new Set(tsConfigs)]; + + if (uniqueTsConfigs.length < 1) { + continue; + } + + switch (target.builder as Builders) { + case Builders.Server: + case Builders.Karma: + case Builders.Browser: + case Builders.NgPackagr: + for (const tsConfig of uniqueTsConfigs) { + removeOrUpdateTarget(host, tsConfig); + } + break; + } + } + } + }; +} + +function removeOrUpdateTarget(host: Tree, tsConfigPath: string): void { + const json = new JSONFile(host, tsConfigPath); + if (typeof json.get(['extends']) === 'string') { + json.remove(['compilerOptions', 'target']); + } else { + updateTarget(host, tsConfigPath); + } +} + +const ESNEXT_ES2022_REGEXP = /^es(?:next|2022)$/i; +function updateTarget(host: Tree, tsConfigPath: string): void { + const json = new JSONFile(host, tsConfigPath); + const jsonPath = ['compilerOptions']; + const compilerOptions = json.get(jsonPath); + + if (compilerOptions && typeof compilerOptions === 'object') { + const { target } = compilerOptions as JsonObject; + + if (typeof target === 'string' && !ESNEXT_ES2022_REGEXP.test(target)) { + json.modify(jsonPath, { + ...compilerOptions, + 'target': 'ES2022', + 'useDefineForClassFields': false, + }); + } + } +} diff --git a/packages/schematics/angular/migrations/update-15/update-typescript-target_spec.ts b/packages/schematics/angular/migrations/update-15/update-typescript-target_spec.ts new file mode 100644 index 000000000000..87b4e0b9fa7d --- /dev/null +++ b/packages/schematics/angular/migrations/update-15/update-typescript-target_spec.ts @@ -0,0 +1,147 @@ +/** + * @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 + */ + +import { isJsonObject } from '@angular-devkit/core'; +import { EmptyTree } from '@angular-devkit/schematics'; +import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing'; +import { Builders, ProjectType, WorkspaceSchema } from '../../utility/workspace-models'; + +describe('Migration to update target and add useDefineForClassFields', () => { + const schematicName = 'update-typescript-target'; + + const schematicRunner = new SchematicTestRunner( + 'migrations', + require.resolve('../migration-collection.json'), + ); + + function createJsonFile(tree: EmptyTree, filePath: string, content: {}): void { + const stringifiedContent = JSON.stringify(content, undefined, 2); + if (tree.exists(filePath)) { + tree.overwrite(filePath, stringifiedContent); + } else { + tree.create(filePath, stringifiedContent); + } + } + + function getCompilerOptionsValue(tree: UnitTestTree, filePath: string): Record { + const json = tree.readJson(filePath); + if (isJsonObject(json) && isJsonObject(json.compilerOptions)) { + return json.compilerOptions; + } + + throw new Error(`Cannot retrieve 'compilerOptions'.`); + } + + function createWorkSpaceConfig(tree: EmptyTree) { + const angularConfig: WorkspaceSchema = { + version: 1, + projects: { + app: { + root: '', + sourceRoot: 'src', + projectType: ProjectType.Application, + prefix: 'app', + architect: { + build: { + builder: Builders.Browser, + options: { + tsConfig: 'src/tsconfig.app.json', + main: '', + polyfills: '', + }, + configurations: { + production: { + tsConfig: 'src/tsconfig.app.prod.json', + }, + }, + }, + test: { + builder: Builders.Karma, + options: { + karmaConfig: '', + tsConfig: 'src/tsconfig.spec.json', + }, + }, + }, + }, + }, + }; + + createJsonFile(tree, 'angular.json', angularConfig); + } + + let tree: EmptyTree; + beforeEach(() => { + tree = new EmptyTree(); + createWorkSpaceConfig(tree); + + // Create tsconfigs + const compilerOptions = { target: 'es2015', module: 'es2020' }; + const configWithExtends = { extends: './tsconfig.json', compilerOptions }; + + // Workspace + createJsonFile(tree, 'tsconfig.json', { compilerOptions }); + + // Application + createJsonFile(tree, 'src/tsconfig.app.json', configWithExtends); + createJsonFile(tree, 'src/tsconfig.app.prod.json', configWithExtends); + createJsonFile(tree, 'src/tsconfig.spec.json', { compilerOptions }); + }); + + it(`should update target and add useDefineForClassFields in workspace 'tsconfig.json'`, async () => { + const newTree = await schematicRunner.runSchematicAsync(schematicName, {}, tree).toPromise(); + const compilerOptions = getCompilerOptionsValue(newTree, 'tsconfig.json'); + expect(compilerOptions).toEqual( + jasmine.objectContaining({ + target: 'ES2022', + useDefineForClassFields: false, + }), + ); + }); + + it(`should remove target value from tsconfig referenced in options and configuration`, async () => { + const newTree = await schematicRunner.runSchematicAsync(schematicName, {}, tree).toPromise(); + { + const compilerOptions = getCompilerOptionsValue(newTree, 'src/tsconfig.app.prod.json'); + expect(compilerOptions['target']).toBeUndefined(); + expect(compilerOptions['useDefineForClassFields']).toBeUndefined(); + } + { + const compilerOptions = getCompilerOptionsValue(newTree, 'src/tsconfig.app.json'); + expect(compilerOptions['target']).toBeUndefined(); + expect(compilerOptions['useDefineForClassFields']).toBeUndefined(); + } + }); + + it('should add target and useDefineForClassFields when tsconfig is not extended', async () => { + const newTree = await schematicRunner.runSchematicAsync(schematicName, {}, tree).toPromise(); + const compilerOptions = getCompilerOptionsValue(newTree, 'src/tsconfig.spec.json'); + expect(compilerOptions).toEqual( + jasmine.objectContaining({ + target: 'ES2022', + useDefineForClassFields: false, + }), + ); + }); + + it('should not add useDefineForClassFields when tsconfig target is ES2022', async () => { + createJsonFile(tree, 'tsconfig.json', { compilerOptions: { 'target': 'es2022' } }); + const newTree = await schematicRunner.runSchematicAsync(schematicName, {}, tree).toPromise(); + + const compilerOptions = getCompilerOptionsValue(newTree, 'tsconfig.json'); + expect(compilerOptions).toEqual({ target: 'es2022' }); + }); + + it('should not add useDefineForClassFields when tsconfig target is ESNEXT', async () => { + createJsonFile(tree, 'tsconfig.json', { compilerOptions: { 'target': 'esnext' } }); + const newTree = await schematicRunner.runSchematicAsync(schematicName, {}, tree).toPromise(); + + const compilerOptions = getCompilerOptionsValue(newTree, 'tsconfig.json'); + expect(compilerOptions).toEqual({ target: 'esnext' }); + }); +}); diff --git a/packages/schematics/angular/workspace/files/tsconfig.json.template b/packages/schematics/angular/workspace/files/tsconfig.json.template index 81dfa6bef428..cac9dd40cf28 100644 --- a/packages/schematics/angular/workspace/files/tsconfig.json.template +++ b/packages/schematics/angular/workspace/files/tsconfig.json.template @@ -16,10 +16,11 @@ "experimentalDecorators": true, "moduleResolution": "node", "importHelpers": true, - "target": "es2020", - "module": "es2020", + "target": "ES2022", + "module": "ES2022", + "useDefineForClassFields": false, "lib": [ - "es2020", + "ES2022", "dom" ] }, diff --git a/tests/legacy-cli/e2e/assets/12.0-project/tsconfig.json b/tests/legacy-cli/e2e/assets/12.0-project/tsconfig.json index 6df828326e3f..4977e18060ed 100644 --- a/tests/legacy-cli/e2e/assets/12.0-project/tsconfig.json +++ b/tests/legacy-cli/e2e/assets/12.0-project/tsconfig.json @@ -14,10 +14,11 @@ "experimentalDecorators": true, "moduleResolution": "node", "importHelpers": true, - "target": "es2017", - "module": "es2020", + "target": "es2022", + "module": "es2022", + "useDefineForClassFields": false, "lib": [ - "es2018", + "ES2022", "dom" ] }, diff --git a/tests/legacy-cli/e2e/assets/webpack/test-app/tsconfig.json b/tests/legacy-cli/e2e/assets/webpack/test-app/tsconfig.json index 46d83664cba4..0102307af01b 100644 --- a/tests/legacy-cli/e2e/assets/webpack/test-app/tsconfig.json +++ b/tests/legacy-cli/e2e/assets/webpack/test-app/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "baseUrl": "", - "module": "es2020", + "module": "es2022", "moduleResolution": "node", "target": "es2015", "noImplicitAny": false, diff --git a/tests/legacy-cli/e2e/tests/build/scripts-output-hashing.ts b/tests/legacy-cli/e2e/tests/build/scripts-output-hashing.ts index 430b7a8478ac..925969b68da8 100644 --- a/tests/legacy-cli/e2e/tests/build/scripts-output-hashing.ts +++ b/tests/legacy-cli/e2e/tests/build/scripts-output-hashing.ts @@ -1,4 +1,9 @@ -import { expectFileMatchToExist, expectFileToMatch, writeMultipleFiles } from '../../utils/fs'; +import { + expectFileMatchToExist, + expectFileToMatch, + writeFile, + writeMultipleFiles, +} from '../../utils/fs'; import { ng } from '../../utils/process'; import { updateJsonFile, updateTsConfig } from '../../utils/project'; @@ -23,17 +28,16 @@ export default async function () { build.configurations['production'].outputHashing = 'all'; configJson['cli'] = { cache: { enabled: 'false' } }; }); - await updateTsConfig((json) => { - json['compilerOptions']['target'] = 'es2017'; - json['compilerOptions']['module'] = 'es2020'; - }); + + // Chrome 65 does not support optional catch in try/catch blocks. + await writeFile('.browserslistrc', 'Chrome 65'); + await ng('build', '--configuration=production'); const filenameBuild1 = await getScriptsFilename(); await expectFileToMatch(`dist/test-project/${filenameBuild1}`, 'try{console.log()}catch(c){}'); - await updateTsConfig((json) => { - json['compilerOptions']['target'] = 'es2019'; - }); + await writeFile('.browserslistrc', 'last 1 Chrome version'); + await ng('build', '--configuration=production'); const filenameBuild2 = await getScriptsFilename(); await expectFileToMatch(`dist/test-project/${filenameBuild2}`, 'try{console.log()}catch{}');