diff --git a/packages/angular_devkit/build_angular/src/babel/presets/application.ts b/packages/angular_devkit/build_angular/src/babel/presets/application.ts index 366f07e48a10..13b184d6e37e 100644 --- a/packages/angular_devkit/build_angular/src/babel/presets/application.ts +++ b/packages/angular_devkit/build_angular/src/babel/presets/application.ts @@ -14,9 +14,26 @@ import type { makeLocalePlugin, } from '@angular/localize/tools'; import { strict as assert } from 'assert'; +import browserslist from 'browserslist'; import * as fs from 'fs'; import * as path from 'path'; +/** + * List of browsers which are affected by a WebKit bug where class field + * initializers might have incorrect variable scopes. + * + * See: https://github.com/angular/angular-cli/issues/24355#issuecomment-1333477033 + * See: https://github.com/WebKit/WebKit/commit/e8788a34b3d5f5b4edd7ff6450b80936bff396f2 + */ +const safariClassFieldScopeBugBrowsers = new Set( + browserslist([ + // Safari <15 is technically not supported via https://angular.io/guide/browser-support, + // but we apply the workaround if forcibly selected. + 'Safari <=15', + 'iOS <=15', + ]), +); + export type DiagnosticReporter = (type: 'error' | 'warning' | 'info', message: string) => void; /** @@ -45,7 +62,6 @@ export interface ApplicationPresetOptions { linkerPluginCreator: typeof import('@angular/compiler-cli/linker/babel').createEs2015LinkerPlugin; }; - forcePresetEnv?: boolean; forceAsyncTransformation?: boolean; instrumentCode?: { includedBasePath: string; @@ -171,13 +187,26 @@ export default function (api: unknown, options: ApplicationPresetOptions) { ); } - if (options.forcePresetEnv) { + // 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 features + // based on the supported browsers in browserslist. + if (options.supportedBrowsers) { + const includePlugins: string[] = []; + + // If a Safari browser affected by the class field scope bug is selected, we + // downlevel class properties by ensuring the class properties Babel plugin + // is always included- regardless of the preset-env targets. + if (options.supportedBrowsers.some((b) => safariClassFieldScopeBugBrowsers.has(b))) { + includePlugins.push('@babel/plugin-proposal-class-properties'); + } + presets.push([ require('@babel/preset-env').default, { bugfixes: true, modules: false, targets: options.supportedBrowsers, + include: includePlugins, exclude: ['transform-typeof-symbol'], }, ]); 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 78af1b28d480..a43c01b03f3d 100644 --- a/packages/angular_devkit/build_angular/src/babel/webpack-loader.ts +++ b/packages/angular_devkit/build_angular/src/babel/webpack-loader.ts @@ -79,7 +79,6 @@ export default custom(() => { const customOptions: ApplicationPresetOptions = { forceAsyncTransformation: false, - forcePresetEnv: false, angularLinker: undefined, i18n: undefined, instrumentCode: undefined, @@ -105,14 +104,6 @@ export default custom(() => { shouldProcess = true; } - // Analyze for ES target processing - 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; - } - // 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 @@ -121,7 +112,9 @@ export default custom(() => { !/[\\/][_f]?esm2015[\\/]/.test(this.resourcePath) && source.includes('async'); shouldProcess ||= - customOptions.forceAsyncTransformation || customOptions.forcePresetEnv || false; + customOptions.forceAsyncTransformation || + customOptions.supportedBrowsers !== undefined || + false; // Analyze for i18n inlining if ( diff --git a/tests/legacy-cli/e2e/tests/misc/safari-15-class-properties.ts b/tests/legacy-cli/e2e/tests/misc/safari-15-class-properties.ts new file mode 100644 index 000000000000..a0dce7186cff --- /dev/null +++ b/tests/legacy-cli/e2e/tests/misc/safari-15-class-properties.ts @@ -0,0 +1,56 @@ +import { expectFileToExist, readFile, writeFile } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; + +const unexpectedStaticFieldErrorMessage = + 'Found unexpected static field. This indicates that the Safari <=v15 ' + + 'workaround for a scope variable tracking is not working. ' + + 'See: https://github.com/angular/angular-cli/pull/24357'; + +export default async function () { + await updateJsonFile('angular.json', (workspace) => { + const build = workspace.projects['test-project'].architect.build; + build.defaultConfiguration = undefined; + build.options = { + ...build.options, + optimization: false, + outputHashing: 'none', + }; + }); + + // Matches two types of static fields that indicate that the Safari bug + // may still occur. With the workaround this should not appear in bundles. + // - static { this.ecmp = bla } + // - static #_ = this.ecmp = bla + const staticIndicatorRegex = /static\s+(\{|#[_\d]+\s+=)/; + + await ng('build'); + await expectFileToExist('dist/test-project/main.js'); + const mainContent = await readFile('dist/test-project/main.js'); + + // TODO: This default cause can be removed in the future when Safari v15 + // is longer included in the default browserlist configuration of CLI apps. + if (staticIndicatorRegex.test(mainContent)) { + throw new Error(unexpectedStaticFieldErrorMessage); + } + + await writeFile('.browserslistrc', 'last 1 chrome version'); + + await ng('build'); + await expectFileToExist('dist/test-project/main.js'); + const mainContentChromeLatest = await readFile('dist/test-project/main.js'); + + if (!staticIndicatorRegex.test(mainContentChromeLatest)) { + throw new Error('Expected static fields to be used when Safari <=v15 is not targeted.'); + } + + await writeFile('.browserslistrc', 'Safari <=15'); + + await ng('build'); + await expectFileToExist('dist/test-project/main.js'); + const mainContentSafari15Explicit = await readFile('dist/test-project/main.js'); + + if (staticIndicatorRegex.test(mainContentSafari15Explicit)) { + throw new Error(unexpectedStaticFieldErrorMessage); + } +}