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 00000000000..1365556a96d --- /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 00000000000..d3f6a6aec1a --- /dev/null +++ b/packages/react-native-babel-preset/src/__tests__/fix-hermes-v1-class-in-finally-test.js @@ -0,0 +1,135 @@ +/** + * 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 { + 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(); + }" + `); +}); 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 00000000000..1512c8b91e9 --- /dev/null +++ b/packages/react-native-babel-preset/src/__tests__/fix-hermes-v1-super-in-object-accessor-test.js @@ -0,0 +1,128 @@ +/** + * 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; + } + };" + `); +}); diff --git a/packages/react-native-babel-preset/src/configs/main.js b/packages/react-native-babel-preset/src/configs/main.js index 5ceeced5f42..fca192d519b 100644 --- a/packages/react-native-babel-preset/src/configs/main.js +++ b/packages/react-native-babel-preset/src/configs/main.js @@ -231,6 +231,21 @@ const getPreset = (src, options, babel) => { ...options.hermesParserOptions, }, ], + // Hermes V1 native runtime workarounds. See each plugin file header + // for the matching facebook/hermes commit and the bug it patches. + ...(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 00000000000..9710c60e4d9 --- /dev/null +++ b/packages/react-native-babel-preset/src/fix-hermes-v1-async-arrow-non-simple-params.js @@ -0,0 +1,93 @@ +/** + * 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 the fix. +// +// 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 00000000000..289aec9224a --- /dev/null +++ b/packages/react-native-babel-preset/src/fix-hermes-v1-class-in-finally.js @@ -0,0 +1,97 @@ +/** + * 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 the fix. +// +// 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. +// +// 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; + } + + const arrow = t.arrowFunctionExpression([], path.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 00000000000..4d793ac6729 --- /dev/null +++ b/packages/react-native-babel-preset/src/fix-hermes-v1-super-in-object-accessor.js @@ -0,0 +1,74 @@ +/** + * 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 the fix. +// +// 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. +// +// 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 !== '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 e785c975938..cd3575d6e75 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;