Skip to content

fix(@angular-devkit/build-angular): downlevel class properties when targeting Safari <=v15 #24357

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
Dec 2, 2022
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
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Copy link
Member Author

Choose a reason for hiding this comment

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

FYI: Discussed with @clydin that we should just apply for v14 too. The range can be that loose just to be safe. older versions will have the plugin enabled anyway.

]),
);

export type DiagnosticReporter = (type: 'error' | 'warning' | 'info', message: string) => void;

/**
Expand Down Expand Up @@ -45,7 +62,6 @@ export interface ApplicationPresetOptions {
linkerPluginCreator: typeof import('@angular/compiler-cli/linker/babel').createEs2015LinkerPlugin;
};

forcePresetEnv?: boolean;
forceAsyncTransformation?: boolean;
instrumentCode?: {
includedBasePath: string;
Expand Down Expand Up @@ -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'],
},
]);
Expand Down
13 changes: 3 additions & 10 deletions packages/angular_devkit/build_angular/src/babel/webpack-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,6 @@ export default custom<ApplicationPresetOptions>(() => {

const customOptions: ApplicationPresetOptions = {
forceAsyncTransformation: false,
forcePresetEnv: false,
angularLinker: undefined,
i18n: undefined,
instrumentCode: undefined,
Expand All @@ -105,14 +104,6 @@ export default custom<ApplicationPresetOptions>(() => {
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
Expand All @@ -121,7 +112,9 @@ export default custom<ApplicationPresetOptions>(() => {
!/[\\/][_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 (
Expand Down
56 changes: 56 additions & 0 deletions tests/legacy-cli/e2e/tests/misc/safari-15-class-properties.ts
Original file line number Diff line number Diff line change
@@ -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+=)/;
Copy link
Member Author

Choose a reason for hiding this comment

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

This is a sanity test. Obviously it's not validating using AST, nor validating Safari. Should be helpful regardless though.


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);
}
}