diff --git a/packages/react-native-babel-preset/src/__tests__/fix-hermes-v1-async-arrow-non-simple-params-test.js b/packages/react-native-babel-preset/src/__tests__/fix-hermes-v1-async-arrow-non-simple-params-test.js new file mode 100644 index 000000000000..1365556a96d9 --- /dev/null +++ b/packages/react-native-babel-preset/src/__tests__/fix-hermes-v1-async-arrow-non-simple-params-test.js @@ -0,0 +1,115 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +'use strict'; + +const {transform} = require('../__mocks__/test-helpers'); +const fixHermesV1AsyncArrowNonSimpleParams = require('../fix-hermes-v1-async-arrow-non-simple-params'); + +test('rewrites destructured object param with default to simple identifier', () => { + const code = ` + const fn = async ({a = 1, b} = {}) => { + return await fetch(a + b); + }; + `; + + expect(transform(code, [fixHermesV1AsyncArrowNonSimpleParams])) + .toMatchInlineSnapshot(` + "const fn = async _p => { + var { + a = 1, + b + } = _p === undefined ? {} : _p; + return await fetch(a + b); + };" + `); +}); + +test('rewrites destructured array param to simple identifier', () => { + const code = ` + const fn = async ([a, b]) => await Promise.resolve(a + b); + `; + + expect(transform(code, [fixHermesV1AsyncArrowNonSimpleParams])) + .toMatchInlineSnapshot(` + "const fn = async _p => { + var [a, b] = _p; + return await Promise.resolve(a + b); + };" + `); +}); + +test('rewrites assignment-pattern param without enclosing destructure', () => { + const code = ` + const fn = async (x = 5) => await use(x); + `; + + expect(transform(code, [fixHermesV1AsyncArrowNonSimpleParams])) + .toMatchInlineSnapshot(` + "const fn = async _p => { + var x = _p === undefined ? 5 : _p; + return await use(x); + };" + `); +}); + +test('wraps body in inner async arrow when rest param is present', () => { + const code = ` + const fn = async (...args) => await handle(args); + `; + + expect(transform(code, [fixHermesV1AsyncArrowNonSimpleParams])) + .toMatchInlineSnapshot(` + "const fn = (...args) => (async () => { + return await handle(args); + })();" + `); +}); + +test('leaves async arrow with only simple identifier params alone', () => { + const code = ` + const fn = async (a, b) => await fetch(a + b); + `; + + expect( + transform(code, [fixHermesV1AsyncArrowNonSimpleParams]), + ).toMatchInlineSnapshot(`"const fn = async (a, b) => await fetch(a + b);"`); +}); + +test('leaves non-async arrow alone', () => { + const code = ` + const fn = ({a = 1, b} = {}) => a + b; + `; + + expect(transform(code, [fixHermesV1AsyncArrowNonSimpleParams])) + .toMatchInlineSnapshot(` + "const fn = ({ + a = 1, + b + } = {}) => a + b;" + `); +}); + +test('handles multiple params mixing simple and complex', () => { + const code = ` + const fn = async (a, {b}, c = 1) => await all(a, b, c); + `; + + expect(transform(code, [fixHermesV1AsyncArrowNonSimpleParams])) + .toMatchInlineSnapshot(` + "const fn = async (a, _p, _p2) => { + var { + b + } = _p; + var c = _p2 === undefined ? 1 : _p2; + return await all(a, b, c); + };" + `); +}); diff --git a/packages/react-native-babel-preset/src/__tests__/fix-hermes-v1-class-in-finally-test.js b/packages/react-native-babel-preset/src/__tests__/fix-hermes-v1-class-in-finally-test.js new file mode 100644 index 000000000000..2cbf12238556 --- /dev/null +++ b/packages/react-native-babel-preset/src/__tests__/fix-hermes-v1-class-in-finally-test.js @@ -0,0 +1,181 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +'use strict'; + +const {transform} = require('../__mocks__/test-helpers'); +const fixHermesV1ClassInFinally = require('../fix-hermes-v1-class-in-finally'); + +test('wraps class declaration in finally block in IIFE', () => { + const code = ` + function run() { + try { + risky(); + } finally { + class Logger { + log() { console.log('done'); } + } + new Logger().log(); + } + } + `; + + expect(transform(code, [fixHermesV1ClassInFinally])).toMatchInlineSnapshot(` + "function run() { + try { + risky(); + } finally { + var Logger = (() => { + class Logger { + log() { + console.log('done'); + } + } + return Logger; + })(); + new Logger().log(); + } + }" + `); +}); + +test('wraps class expression in finally block in IIFE', () => { + const code = ` + function run() { + try { + risky(); + } finally { + const Logger = class { + log() {} + }; + new Logger().log(); + } + } + `; + + expect(transform(code, [fixHermesV1ClassInFinally])).toMatchInlineSnapshot(` +"function run() { + try { + risky(); + } finally { + const Logger = (() => class Logger { + log() {} + })(); + new Logger().log(); + } +}" +`); +}); + +test('leaves class outside finally block alone', () => { + const code = ` + function run() { + try { + class Inside {} + return new Inside(); + } catch (e) { + class Caught {} + return new Caught(); + } + } + `; + + expect(transform(code, [fixHermesV1ClassInFinally])).toMatchInlineSnapshot(` + "function run() { + try { + class Inside {} + return new Inside(); + } catch (e) { + class Caught {} + return new Caught(); + } + }" + `); +}); + +test('leaves class declared at module scope alone', () => { + const code = ` + class Module {} + new Module(); + `; + + expect(transform(code, [fixHermesV1ClassInFinally])).toMatchInlineSnapshot(` + "class Module {} + new Module();" + `); +}); + +test('does not enter nested function scope', () => { + const code = ` + try {} finally { + function inner() { + class NestedFn {} + return new NestedFn(); + } + inner(); + } + `; + + expect(transform(code, [fixHermesV1ClassInFinally])).toMatchInlineSnapshot(` + "try {} finally { + function inner() { + class NestedFn {} + return new NestedFn(); + } + inner(); + }" + `); +}); + +test('preserves inferred name when wrapping a named-binding class expression', () => { + const code = ` + function run() { + try {} finally { + const Service = class { + ping() {} + }; + return new Service(); + } + } + `; + + expect(transform(code, [fixHermesV1ClassInFinally])).toMatchInlineSnapshot(` +"function run() { + try {} finally { + const Service = (() => class Service { + ping() {} + })(); + return new Service(); + } +}" +`); +}); + +test('leaves an unbound class expression in finally anonymous', () => { + const code = ` + function run() { + try {} finally { + return register(class { + run() {} + }); + } + } + `; + + expect(transform(code, [fixHermesV1ClassInFinally])).toMatchInlineSnapshot(` +"function run() { + try {} finally { + return register((() => class { + run() {} + })()); + } +}" +`); +}); diff --git a/packages/react-native-babel-preset/src/__tests__/fix-hermes-v1-super-in-object-accessor-test.js b/packages/react-native-babel-preset/src/__tests__/fix-hermes-v1-super-in-object-accessor-test.js new file mode 100644 index 000000000000..efa29f80033c --- /dev/null +++ b/packages/react-native-babel-preset/src/__tests__/fix-hermes-v1-super-in-object-accessor-test.js @@ -0,0 +1,147 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +'use strict'; + +const {transform} = require('../__mocks__/test-helpers'); +const fixHermesV1SuperInObjectAccessor = require('../fix-hermes-v1-super-in-object-accessor'); + +test('rewrites identifier-keyed object getter using super.x to computed string key', () => { + const code = ` + const obj = { + get name() { + return super.name; + }, + }; + `; + + expect(transform(code, [fixHermesV1SuperInObjectAccessor])) + .toMatchInlineSnapshot(` + "const obj = { + get [\\"name\\"]() { + return super.name; + } + };" + `); +}); + +test('rewrites identifier-keyed object setter using super.x to computed string key', () => { + const code = ` + const obj = { + set value(v) { + super.value = v; + }, + }; + `; + + expect(transform(code, [fixHermesV1SuperInObjectAccessor])) + .toMatchInlineSnapshot(` + "const obj = { + set [\\"value\\"](v) { + super.value = v; + } + };" + `); +}); + +test('leaves super inside class method alone', () => { + const code = ` + class Child extends Parent { + get name() { + return super.name; + } + } + `; + + expect(transform(code, [fixHermesV1SuperInObjectAccessor])) + .toMatchInlineSnapshot(` + "class Child extends Parent { + get name() { + return super.name; + } + }" + `); +}); + +test('leaves super inside regular object method alone', () => { + const code = ` + const obj = { + run() { + return super.run(); + }, + }; + `; + + expect(transform(code, [fixHermesV1SuperInObjectAccessor])) + .toMatchInlineSnapshot(` + "const obj = { + run() { + return super.run(); + } + };" + `); +}); + +test('leaves super() call alone', () => { + const code = ` + class Child extends Parent { + constructor() { + super(); + } + } + `; + + expect(transform(code, [fixHermesV1SuperInObjectAccessor])) + .toMatchInlineSnapshot(` + "class Child extends Parent { + constructor() { + super(); + } + }" + `); +}); + +test('skips already-computed accessor', () => { + const code = ` + const obj = { + get [keyName]() { + return super.value; + }, + }; + `; + + expect(transform(code, [fixHermesV1SuperInObjectAccessor])) + .toMatchInlineSnapshot(` + "const obj = { + get [keyName]() { + return super.value; + } + };" + `); +}); + +test('rewrites numeric-keyed object getter using super.x to computed string key', () => { + const code = ` + const obj = { + get 0() { + return super.value; + }, + }; + `; + + expect(transform(code, [fixHermesV1SuperInObjectAccessor])) + .toMatchInlineSnapshot(` +"const obj = { + get [\\"0\\"]() { + return super.value; + } +};" +`); +}); diff --git a/packages/react-native-babel-preset/src/configs/main.js b/packages/react-native-babel-preset/src/configs/main.js index 836d00b651c8..457fcbda2f1f 100644 --- a/packages/react-native-babel-preset/src/configs/main.js +++ b/packages/react-native-babel-preset/src/configs/main.js @@ -238,6 +238,24 @@ const getPreset = (src, options, babel) => { ...options.hermesParserOptions, }, ], + // Hermes V1 native runtime workarounds. Each plugin's header names + // the facebook/hermes commit that fixes the bug it patches. They are + // gated by the Hermes profile, not the Hermes version, so they do not + // self-disable: drop each one once RN bundles a Hermes that includes + // its fix. + ...(isHermesProfile + ? [ + [require('../fix-hermes-v1-class-in-finally')], + [require('../fix-hermes-v1-super-in-object-accessor')], + ...(preserveAsync + ? [ + [ + require('../fix-hermes-v1-async-arrow-non-simple-params'), + ], + ] + : []), + ] + : []), [require('babel-plugin-transform-flow-enums')], ...(preserveBlockScoping ? [] diff --git a/packages/react-native-babel-preset/src/fix-hermes-v1-async-arrow-non-simple-params.js b/packages/react-native-babel-preset/src/fix-hermes-v1-async-arrow-non-simple-params.js new file mode 100644 index 000000000000..81d1c1172dfe --- /dev/null +++ b/packages/react-native-babel-preset/src/fix-hermes-v1-async-arrow-non-simple-params.js @@ -0,0 +1,96 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +'use strict'; + +// Workaround for https://github.com/facebook/hermes/issues/1761. +// Fixed in Hermes mainline by https://github.com/facebook/hermes/commit/68bfb3a48b31 +// (2025-09-11) but the bundled Hermes V1 in this version of React Native +// (250829098.0.13, 2025-08-29 branch cut) predates it, and the fix is not yet +// backported to the `250829098.0.0-stable` branch (`.0.15` tip; graft attempt +// facebook/hermes#2030 is still open), so the bug is live. Remove this +// transform per the note in `configs/main.js`. +// +// Async arrow functions with non-simple parameters (destructured patterns, +// defaults, rest) cause Hermes V1 to resolve `await` with `undefined` while +// the function body continues executing in the background. This rewrites the +// arrow into one with a simple identifier parameter and inline destructuring +// so Hermes never sees the buggy shape. +// +// Ported from `babel-preset-expo` (https://github.com/expo/expo/pull/45601), +// MIT licensed. + +module.exports = ({types: t}) => ({ + name: 'fix-hermes-v1-async-arrow-non-simple-params', + visitor: { + ArrowFunctionExpression(path) { + const {node} = path; + if (!node.async || node.params.every(p => t.isIdentifier(p))) { + return; + } + + // Hermes V1 rejects any rest param on async arrows. Wrap the body in + // a sync arrow that calls an inner async arrow with no params. + if (node.params.some(p => t.isRestElement(p))) { + const body = !t.isBlockStatement(node.body) + ? t.blockStatement([t.returnStatement(node.body)]) + : node.body; + const innerAsync = t.arrowFunctionExpression([], body, true); + node.async = false; + node.body = t.callExpression(innerAsync, []); + return; + } + + const newParams = []; + const init = []; + for (const param of node.params) { + if (t.isIdentifier(param)) { + newParams.push(param); + continue; + } + + const sym = path.scope.generateUidIdentifier('p'); + if (t.isAssignmentPattern(param)) { + newParams.push(sym); + init.push( + t.variableDeclaration('var', [ + t.variableDeclarator( + param.left, + t.conditionalExpression( + t.binaryExpression( + '===', + t.cloneNode(sym), + t.identifier('undefined'), + ), + param.right, + t.cloneNode(sym), + ), + ), + ]), + ); + } else { + newParams.push(sym); + init.push( + t.variableDeclaration('var', [ + t.variableDeclarator(param, t.cloneNode(sym)), + ]), + ); + } + } + + const body = !t.isBlockStatement(node.body) + ? t.blockStatement([t.returnStatement(node.body)]) + : node.body; + body.body.unshift(...init); + node.params = newParams; + node.body = body; + }, + }, +}); diff --git a/packages/react-native-babel-preset/src/fix-hermes-v1-class-in-finally.js b/packages/react-native-babel-preset/src/fix-hermes-v1-class-in-finally.js new file mode 100644 index 000000000000..17c731a6bce9 --- /dev/null +++ b/packages/react-native-babel-preset/src/fix-hermes-v1-class-in-finally.js @@ -0,0 +1,131 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +'use strict'; + +// Workaround for the variable-caching-for-legacy-classes bug in Hermes V1. +// Fixed in Hermes mainline by https://github.com/facebook/hermes/commit/1e94fbe0ebb4 +// (2026-02-12) but the bundled Hermes V1 in this version of React Native +// (250829098.0.13, 2025-08-29 branch cut) predates it, and the fix is not yet +// backported to the `250829098.0.0-stable` branch (`.0.15` tip), so the bug is +// live. Remove this transform per the note in `configs/main.js`. +// +// Class declarations inside a `finally` block trip Hermes V1's variable +// caching path. Wrap them in an IIFE so the class lives in its own function +// scope and the cache miss never happens. Declarations become a `var` binding +// (matching the validated babel-preset-expo source), which widens a +// block-scoped class to function scope: referencing the class before its +// declaration in the same `finally`, or relying on its block scoping, changes +// behaviour. +// +// Ported from `babel-preset-expo` (https://github.com/expo/expo/pull/45601), +// MIT licensed. + +function isInFinalizerScope(path) { + let inner = path; + let parentPath = path.parentPath; + while (parentPath) { + const type = parentPath.node.type; + switch (type) { + case 'FunctionExpression': + case 'FunctionDeclaration': + case 'ArrowFunctionExpression': + case 'ObjectMethod': + case 'ClassMethod': + case 'ClassPrivateMethod': + case 'StaticBlock': + return false; + case 'TryStatement': + if (inner.key === 'finalizer') { + return true; + } + break; + } + inner = parentPath; + parentPath = parentPath.parentPath; + } + return false; +} + +module.exports = ({types: t}) => ({ + name: 'fix-hermes-v1-class-in-finally', + visitor: { + ClassDeclaration(path) { + const id = path.node.id; + if ( + (path.node.decorators && path.node.decorators.length) || + !id || + !isInFinalizerScope(path) + ) { + return; + } + + const inner = t.classDeclaration( + t.cloneNode(id), + path.node.superClass, + path.node.body, + [], + ); + + const arrow = t.arrowFunctionExpression( + [], + t.blockStatement([inner, t.returnStatement(t.cloneNode(id))]), + ); + + path.replaceWith( + t.variableDeclaration('var', [ + t.variableDeclarator(t.cloneNode(id), t.callExpression(arrow, [])), + ]), + ); + path.skip(); + }, + + ClassExpression(path) { + if ( + (path.node.decorators && path.node.decorators.length) || + !isInFinalizerScope(path) + ) { + return; + } + + let node = path.node; + if (!node.id) { + // Preserve the name the class would infer from its binding; the IIFE + // wrapper drops it because NamedEvaluation only applies to a direct + // `name = class {}`. + const parent = path.parent; + let binding = null; + if ( + parent.type === 'VariableDeclarator' && + parent.id.type === 'Identifier' + ) { + binding = parent.id; + } else if ( + parent.type === 'AssignmentExpression' && + parent.left.type === 'Identifier' + ) { + binding = parent.left; + } + if (binding) { + node = t.classExpression( + t.cloneNode(binding), + node.superClass, + node.body, + [], + ); + } + } + + const arrow = t.arrowFunctionExpression([], node); + path.replaceWith(t.callExpression(arrow, [])); + path.skip(); + }, + }, +}); diff --git a/packages/react-native-babel-preset/src/fix-hermes-v1-super-in-object-accessor.js b/packages/react-native-babel-preset/src/fix-hermes-v1-super-in-object-accessor.js new file mode 100644 index 000000000000..61816de73557 --- /dev/null +++ b/packages/react-native-babel-preset/src/fix-hermes-v1-super-in-object-accessor.js @@ -0,0 +1,79 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @noflow + */ + +'use strict'; + +// Workaround for the genFunctionExpression home-object bug in Hermes V1. +// Fixed in Hermes mainline by https://github.com/facebook/hermes/commit/18a963465944 +// (2025-11-04) but the bundled Hermes V1 in this version of React Native +// (250829098.0.13, 2025-08-29 branch cut) predates it, and the fix is not yet +// backported to the `250829098.0.0-stable` branch (`.0.15` tip), so the bug is +// live. Remove this transform per the note in `configs/main.js`. +// +// Object-literal getters and setters that use `super.x` lookups trip Hermes +// V1's home-object path. Rewriting the accessor with a computed string key +// avoids the buggy codegen. Numeric-literal keys are normalised to string +// keys so they take the same safe path. +// +// Ported from `babel-preset-expo` (https://github.com/expo/expo/pull/45601), +// MIT licensed. + +function findEnclosingNonComputedObjectAccessor(path) { + let parentPath = path.parentPath; + while (parentPath) { + const node = parentPath.node; + const type = node.type; + switch (type) { + case 'ClassMethod': + case 'ClassPrivateMethod': + case 'FunctionExpression': + case 'FunctionDeclaration': + case 'StaticBlock': + case 'ClassProperty': + case 'ClassPrivateProperty': + return null; + case 'ObjectMethod': + if (!node.computed && (node.kind === 'get' || node.kind === 'set')) { + return node; + } + return null; + } + parentPath = parentPath.parentPath; + } + return null; +} + +module.exports = ({types: t}) => ({ + name: 'fix-hermes-v1-super-in-object-accessor', + visitor: { + Super(path) { + // Only `super.x` / `super[expr]` reach the buggy home-object path. + // `super()` lives only in derived class constructors and takes a + // different codepath. + const parent = path.parent; + if (parent.type !== 'MemberExpression' || parent.object !== path.node) { + return; + } + + const accessor = findEnclosingNonComputedObjectAccessor(path); + if (accessor) { + const key = accessor.key; + if (key.type === 'Identifier') { + accessor.key = t.stringLiteral(key.name); + } else if (key.type === 'NumericLiteral') { + accessor.key = t.stringLiteral(String(key.value)); + } else if (key.type !== 'StringLiteral') { + return; + } + accessor.computed = true; + } + }, + }, +}); diff --git a/packages/react-native-babel-preset/src/index.js b/packages/react-native-babel-preset/src/index.js index e785c9759386..cd3575d6e755 100644 --- a/packages/react-native-babel-preset/src/index.js +++ b/packages/react-native-babel-preset/src/index.js @@ -41,6 +41,13 @@ module.exports.getCacheKey = () => { readFileSync(require.resolve('./configs/lazy-imports.js')), readFileSync(require.resolve('./passthrough-syntax-plugins.js')), readFileSync(require.resolve('./plugin-warn-on-deep-imports.js')), + readFileSync( + require.resolve('./fix-hermes-v1-async-arrow-non-simple-params.js'), + ), + readFileSync(require.resolve('./fix-hermes-v1-class-in-finally.js')), + readFileSync( + require.resolve('./fix-hermes-v1-super-in-object-accessor.js'), + ), ].forEach(part => key.update(part)); cacheKey = key.digest('hex'); return cacheKey;