From 13bb0a1c093479ec00b24df153eac1a53f963808 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Mon, 28 Sep 2026 15:08:11 +0200 Subject: [PATCH 1/4] Install globalEvalWithSourceUrl on bridgeless ReactInstance Bridge mode already exposes this helper from JSIExecutor so Metro debug loaders can evaluate fetched JS via JSI. Hermes rejects JS eval() of Metro __d(...) source, so lazy chunks fail on New Architecture. --- .../react/runtime/ReactInstance.cpp | 28 +++++++++++++++++++ .../runtime/tests/cxx/ReactInstanceTest.cpp | 10 +++++++ 2 files changed, 38 insertions(+) diff --git a/packages/react-native/ReactCommon/react/runtime/ReactInstance.cpp b/packages/react-native/ReactCommon/react/runtime/ReactInstance.cpp index d809b00d7af2..bc9fbb571e93 100644 --- a/packages/react-native/ReactCommon/react/runtime/ReactInstance.cpp +++ b/packages/react-native/ReactCommon/react/runtime/ReactInstance.cpp @@ -458,6 +458,34 @@ void ReactInstance::initializeRuntime( defineReactInstanceFlags(runtime, options); + // Bridge JSIExecutor installs this so debug loaders can evaluate Metro JS + // via JSI. Hermes does not support JS eval() of Metro `__d(...)` source. + defineReadOnlyGlobal( + runtime, + "globalEvalWithSourceUrl", + jsi::Function::createFromHostFunction( + runtime, + jsi::PropNameID::forAscii(runtime, "globalEvalWithSourceUrl"), + 2, + [](jsi::Runtime& rt, + const jsi::Value& /*thisValue*/, + const jsi::Value* args, + size_t count) { + if (count != 1 && count != 2) { + throw jsi::JSError( + rt, "globalEvalWithSourceUrl arg count must be 1 or 2"); + } + + auto code = args[0].asString(rt).utf8(rt); + std::string url; + if (count > 1 && args[1].isString()) { + url = args[1].asString(rt).utf8(rt); + } + + return rt.evaluateJavaScript( + std::make_unique(std::move(code)), url); + })); + defineReadOnlyGlobal( runtime, "RN$useAlwaysAvailableJSErrorHandling", diff --git a/packages/react-native/ReactCommon/react/runtime/tests/cxx/ReactInstanceTest.cpp b/packages/react-native/ReactCommon/react/runtime/tests/cxx/ReactInstanceTest.cpp index 858ab8723153..e8f0888fa578 100644 --- a/packages/react-native/ReactCommon/react/runtime/tests/cxx/ReactInstanceTest.cpp +++ b/packages/react-native/ReactCommon/react/runtime/tests/cxx/ReactInstanceTest.cpp @@ -255,6 +255,16 @@ TEST_F(ReactInstanceTest, testBridgelessFlagIsSet) { EXPECT_EQ(val.getBool(), true); } +TEST_F(ReactInstanceTest, testGlobalEvalWithSourceUrlIsInstalled) { + auto before = tryEval("typeof globalEvalWithSourceUrl === 'function'", "false"); + EXPECT_EQ(before.getBool(), false); + initializeRuntimeWithScript(""); + auto isFn = eval("typeof globalEvalWithSourceUrl === 'function'"); + EXPECT_EQ(isFn.getBool(), true); + auto result = eval("globalEvalWithSourceUrl('1 + 2')"); + EXPECT_EQ(result.getNumber(), 3); +} + TEST_F(ReactInstanceTest, testProfilingFlag) { auto valBefore = tryEval("__RCTProfileIsProfiling === true", "false"); EXPECT_EQ(valBefore.getBool(), false); From c60a4a196a1632cb9347cd143f11356ab3f1d336 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Wed, 30 Sep 2026 12:01:03 +0200 Subject: [PATCH 2/4] Add Fantom and C++ tests for bridgeless globalEvalWithSourceUrl Document that the helper is installed on the New Architecture runtime and can evaluate Metro-shaped source, including how that compares to JS eval(). --- .../globalEvalWithSourceUrl-itest.js | 47 +++++++++++++++++++ .../runtime/tests/cxx/ReactInstanceTest.cpp | 17 +++++++ 2 files changed, 64 insertions(+) create mode 100644 packages/react-native/Libraries/Core/Devtools/__tests__/globalEvalWithSourceUrl-itest.js diff --git a/packages/react-native/Libraries/Core/Devtools/__tests__/globalEvalWithSourceUrl-itest.js b/packages/react-native/Libraries/Core/Devtools/__tests__/globalEvalWithSourceUrl-itest.js new file mode 100644 index 000000000000..49ad087f5d39 --- /dev/null +++ b/packages/react-native/Libraries/Core/Devtools/__tests__/globalEvalWithSourceUrl-itest.js @@ -0,0 +1,47 @@ +/** + * 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. + * + * @flow strict + * @format + */ + +import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; + +describe('globalEvalWithSourceUrl', () => { + it('is installed on the bridgeless runtime', () => { + // $FlowFixMe[prop-missing] + expect(typeof global.globalEvalWithSourceUrl).toBe('function'); + }); + + it('evaluates source via JSI, documenting how that differs from JS eval', () => { + // $FlowFixMe[prop-missing] + const helper = global.globalEvalWithSourceUrl; + expect(typeof helper).toBe('function'); + + // Same shape Metro serves for a lazy chunk (source, not bytecode). + const source = 'globalThis.__fantomEvalMarker = 17; 17'; + + let evalError: mixed = null; + try { + // eslint-disable-next-line no-eval + eval(source); + } catch (e) { + evalError = e; + } + + const helperResult = helper(source, 'globalEvalWithSourceUrl-itest.bundle'); + + expect(globalThis.__fantomEvalMarker).toBe(17); + expect(helperResult).toBe(17); + + if (evalError != null) { + // Lean Hermes: JS eval() is the unsupported path; the helper is JSI. + expect(String(evalError.message || evalError)).toMatch( + /Parsing source code unsupported|eval/i, + ); + } + }); +}); diff --git a/packages/react-native/ReactCommon/react/runtime/tests/cxx/ReactInstanceTest.cpp b/packages/react-native/ReactCommon/react/runtime/tests/cxx/ReactInstanceTest.cpp index e8f0888fa578..67bc397a9d39 100644 --- a/packages/react-native/ReactCommon/react/runtime/tests/cxx/ReactInstanceTest.cpp +++ b/packages/react-native/ReactCommon/react/runtime/tests/cxx/ReactInstanceTest.cpp @@ -265,6 +265,23 @@ TEST_F(ReactInstanceTest, testGlobalEvalWithSourceUrlIsInstalled) { EXPECT_EQ(result.getNumber(), 3); } +TEST_F(ReactInstanceTest, testEvalVersusGlobalEvalWithSourceUrl) { + initializeRuntimeWithScript(""); + + eval("global.__fromHelper = 0; global.__fromEval = 0; global.__evalError = '';"); + eval("globalEvalWithSourceUrl('global.__fromHelper = 1', 'chunk.js')"); + EXPECT_EQ(eval("global.__fromHelper").getNumber(), 1); + + auto evalOk = eval( + "(function(){ try { eval('global.__fromEval = 1'); return true; } catch (e) { global.__evalError = String(e); return false; } })()"); + if (evalOk.getBool()) { + EXPECT_EQ(eval("global.__fromEval").getNumber(), 1); + } else { + auto err = eval("global.__evalError"); + EXPECT_TRUE(err.isString()); + } +} + TEST_F(ReactInstanceTest, testProfilingFlag) { auto valBefore = tryEval("__RCTProfileIsProfiling === true", "false"); EXPECT_EQ(valBefore.getBool(), false); From c19158be14e1e6a00defcef43136b2a2b5df3836 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Thu, 1 Oct 2026 23:41:11 +0200 Subject: [PATCH 3/4] Assert eval() behaviour alongside globalEvalWithSourceUrl in tests The Fantom test now asserts eval()'s result and the source URL attribution difference. The C++ test builds a runtime with RuntimeConfig::EnableEval=false to show eval() throws while globalEvalWithSourceUrl still evaluates source. --- .../globalEvalWithSourceUrl-itest.js | 77 +++++++++++++------ .../react/runtime/ReactInstance.cpp | 6 +- .../runtime/tests/cxx/ReactInstanceTest.cpp | 54 +++++++++---- 3 files changed, 98 insertions(+), 39 deletions(-) diff --git a/packages/react-native/Libraries/Core/Devtools/__tests__/globalEvalWithSourceUrl-itest.js b/packages/react-native/Libraries/Core/Devtools/__tests__/globalEvalWithSourceUrl-itest.js index 49ad087f5d39..ace9b8c158ac 100644 --- a/packages/react-native/Libraries/Core/Devtools/__tests__/globalEvalWithSourceUrl-itest.js +++ b/packages/react-native/Libraries/Core/Devtools/__tests__/globalEvalWithSourceUrl-itest.js @@ -10,38 +10,71 @@ import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; +const SOURCE_URL = 'globalEvalWithSourceUrl-itest.bundle'; + +function getHelper(): (code: string, sourceUrl?: string) => mixed { + // $FlowFixMe[prop-missing] + const helper = global.globalEvalWithSourceUrl; + if (typeof helper !== 'function') { + throw new Error( + `Expected global.globalEvalWithSourceUrl to be a function, got ${typeof helper}`, + ); + } + return helper; +} + +function getStack(fn: () => mixed): string { + try { + fn(); + } catch (e) { + return String(e?.stack ?? ''); + } + throw new Error('Expected the evaluated code to throw'); +} + describe('globalEvalWithSourceUrl', () => { - it('is installed on the bridgeless runtime', () => { + afterEach(() => { // $FlowFixMe[prop-missing] - expect(typeof global.globalEvalWithSourceUrl).toBe('function'); + delete globalThis.__fantomEvalMarker; }); - it('evaluates source via JSI, documenting how that differs from JS eval', () => { + it('is installed on the bridgeless runtime', () => { // $FlowFixMe[prop-missing] - const helper = global.globalEvalWithSourceUrl; - expect(typeof helper).toBe('function'); + expect(typeof global.globalEvalWithSourceUrl).toBe('function'); + }); - // Same shape Metro serves for a lazy chunk (source, not bytecode). + it('evaluates the same source as JS eval() in the global scope', () => { const source = 'globalThis.__fantomEvalMarker = 17; 17'; - let evalError: mixed = null; - try { - // eslint-disable-next-line no-eval - eval(source); - } catch (e) { - evalError = e; - } + // eslint-disable-next-line no-eval + expect(eval(source)).toBe(17); + // $FlowFixMe[prop-missing] + expect(globalThis.__fantomEvalMarker).toBe(17); - const helperResult = helper(source, 'globalEvalWithSourceUrl-itest.bundle'); + // $FlowFixMe[prop-missing] + delete globalThis.__fantomEvalMarker; + expect(getHelper()(source, SOURCE_URL)).toBe(17); + // $FlowFixMe[prop-missing] expect(globalThis.__fantomEvalMarker).toBe(17); - expect(helperResult).toBe(17); - - if (evalError != null) { - // Lean Hermes: JS eval() is the unsupported path; the helper is JSI. - expect(String(evalError.message || evalError)).toMatch( - /Parsing source code unsupported|eval/i, - ); - } + }); + + it('attributes evaluated code to the given source URL, unlike eval()', () => { + const source = 'throw new Error("thrown from evaluated source")'; + + // eslint-disable-next-line no-eval + const evalStack = getStack(() => eval(source)); + const helperStack = getStack(() => getHelper()(source, SOURCE_URL)); + + expect(evalStack).not.toContain(SOURCE_URL); + expect(helperStack).toContain(SOURCE_URL); + }); + + it('rejects an invalid argument count', () => { + const helper = getHelper(); + // $FlowFixMe[incompatible-call] + expect(() => helper()).toThrow( + 'globalEvalWithSourceUrl arg count must be 1 or 2', + ); }); }); diff --git a/packages/react-native/ReactCommon/react/runtime/ReactInstance.cpp b/packages/react-native/ReactCommon/react/runtime/ReactInstance.cpp index bc9fbb571e93..a32a40ba70c1 100644 --- a/packages/react-native/ReactCommon/react/runtime/ReactInstance.cpp +++ b/packages/react-native/ReactCommon/react/runtime/ReactInstance.cpp @@ -458,8 +458,10 @@ void ReactInstance::initializeRuntime( defineReactInstanceFlags(runtime, options); - // Bridge JSIExecutor installs this so debug loaders can evaluate Metro JS - // via JSI. Hermes does not support JS eval() of Metro `__d(...)` source. + // Bridge JSIExecutor installs this so debug bundle loaders can evaluate + // fetched Metro source through Runtime::evaluateJavaScript, with a source + // URL for stack traces. Unlike JS eval(), that path is not disabled by + // Hermes' RuntimeConfig::EnableEval or by lean engine builds. defineReadOnlyGlobal( runtime, "globalEvalWithSourceUrl", diff --git a/packages/react-native/ReactCommon/react/runtime/tests/cxx/ReactInstanceTest.cpp b/packages/react-native/ReactCommon/react/runtime/tests/cxx/ReactInstanceTest.cpp index 67bc397a9d39..3887b7bdc857 100644 --- a/packages/react-native/ReactCommon/react/runtime/tests/cxx/ReactInstanceTest.cpp +++ b/packages/react-native/ReactCommon/react/runtime/tests/cxx/ReactInstanceTest.cpp @@ -118,9 +118,13 @@ class ReactInstanceTest : public ::testing::Test { protected: ReactInstanceTest() = default; + virtual ::hermes::vm::RuntimeConfig makeRuntimeConfig() { + return ::hermes::vm::RuntimeConfig(); + } + void SetUp() override { - auto runtime = - std::make_unique(hermes::makeHermesRuntime()); + auto runtime = std::make_unique( + hermes::makeHermesRuntime(makeRuntimeConfig())); runtime_ = &runtime->getRuntime(); messageQueueThread_ = std::make_shared(); auto mockRegistry = std::make_unique(); @@ -256,7 +260,8 @@ TEST_F(ReactInstanceTest, testBridgelessFlagIsSet) { } TEST_F(ReactInstanceTest, testGlobalEvalWithSourceUrlIsInstalled) { - auto before = tryEval("typeof globalEvalWithSourceUrl === 'function'", "false"); + auto before = + tryEval("typeof globalEvalWithSourceUrl === 'function'", "false"); EXPECT_EQ(before.getBool(), false); initializeRuntimeWithScript(""); auto isFn = eval("typeof globalEvalWithSourceUrl === 'function'"); @@ -265,21 +270,40 @@ TEST_F(ReactInstanceTest, testGlobalEvalWithSourceUrlIsInstalled) { EXPECT_EQ(result.getNumber(), 3); } -TEST_F(ReactInstanceTest, testEvalVersusGlobalEvalWithSourceUrl) { +TEST_F( + ReactInstanceTest, + testGlobalEvalWithSourceUrlMatchesEvalWhenEvalIsEnabled) { initializeRuntimeWithScript(""); - eval("global.__fromHelper = 0; global.__fromEval = 0; global.__evalError = '';"); - eval("globalEvalWithSourceUrl('global.__fromHelper = 1', 'chunk.js')"); - EXPECT_EQ(eval("global.__fromHelper").getNumber(), 1); - - auto evalOk = eval( - "(function(){ try { eval('global.__fromEval = 1'); return true; } catch (e) { global.__evalError = String(e); return false; } })()"); - if (evalOk.getBool()) { - EXPECT_EQ(eval("global.__fromEval").getNumber(), 1); - } else { - auto err = eval("global.__evalError"); - EXPECT_TRUE(err.isString()); + EXPECT_EQ(eval("eval('1 + 2')").getNumber(), 3); + EXPECT_EQ( + eval("globalEvalWithSourceUrl('1 + 2', 'chunk.js')").getNumber(), 3); +} + +// Hermes gates eval() and the Function constructor behind +// RuntimeConfig::EnableEval, but Runtime::evaluateJavaScript is not gated. +// globalEvalWithSourceUrl goes through the latter, which is why the debug +// bundle loaders prefer it over eval(). +class ReactInstanceWithoutEvalTest : public ReactInstanceTest { + protected: + ::hermes::vm::RuntimeConfig makeRuntimeConfig() override { + return ::hermes::vm::RuntimeConfig::Builder().withEnableEval(false).build(); } +}; + +TEST_F( + ReactInstanceWithoutEvalTest, + testGlobalEvalWithSourceUrlWorksWhenEvalIsDisabled) { + initializeRuntimeWithScript(""); + + auto evalOutcome = eval( + "(function() { try { eval('1 + 2'); return 'no error'; } catch (e) { return String(e.message); } })()"); + EXPECT_THAT( + evalOutcome.getString(*runtime_).utf8(*runtime_), + HasSubstr("Parsing source code unsupported")); + + EXPECT_EQ( + eval("globalEvalWithSourceUrl('1 + 2', 'chunk.js')").getNumber(), 3); } TEST_F(ReactInstanceTest, testProfilingFlag) { From 9483b90607b268da5f205b67902e5bd0126fd16e Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Fri, 2 Oct 2026 12:51:21 +0200 Subject: [PATCH 4/4] Fix Flow errors in globalEvalWithSourceUrl Fantom test Use unknown instead of the deprecated mixed, and read the host function untyped in the arity test so Flow does not reject the deliberate bad call. --- .../__tests__/globalEvalWithSourceUrl-itest.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/react-native/Libraries/Core/Devtools/__tests__/globalEvalWithSourceUrl-itest.js b/packages/react-native/Libraries/Core/Devtools/__tests__/globalEvalWithSourceUrl-itest.js index ace9b8c158ac..88b6b302cf6a 100644 --- a/packages/react-native/Libraries/Core/Devtools/__tests__/globalEvalWithSourceUrl-itest.js +++ b/packages/react-native/Libraries/Core/Devtools/__tests__/globalEvalWithSourceUrl-itest.js @@ -12,7 +12,7 @@ import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; const SOURCE_URL = 'globalEvalWithSourceUrl-itest.bundle'; -function getHelper(): (code: string, sourceUrl?: string) => mixed { +function getHelper(): (code: string, sourceUrl?: string) => unknown { // $FlowFixMe[prop-missing] const helper = global.globalEvalWithSourceUrl; if (typeof helper !== 'function') { @@ -23,7 +23,7 @@ function getHelper(): (code: string, sourceUrl?: string) => mixed { return helper; } -function getStack(fn: () => mixed): string { +function getStack(fn: () => unknown): string { try { fn(); } catch (e) { @@ -71,8 +71,11 @@ describe('globalEvalWithSourceUrl', () => { }); it('rejects an invalid argument count', () => { - const helper = getHelper(); - // $FlowFixMe[incompatible-call] + // Read it untyped on purpose: this test calls the host function with an + // arity the typed wrapper would not allow. + const helper: (...args: Array) => unknown = + // $FlowFixMe[prop-missing] + global.globalEvalWithSourceUrl; expect(() => helper()).toThrow( 'globalEvalWithSourceUrl arg count must be 1 or 2', );