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 00000000000..88b6b302cf6 --- /dev/null +++ b/packages/react-native/Libraries/Core/Devtools/__tests__/globalEvalWithSourceUrl-itest.js @@ -0,0 +1,83 @@ +/** + * 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'; + +const SOURCE_URL = 'globalEvalWithSourceUrl-itest.bundle'; + +function getHelper(): (code: string, sourceUrl?: string) => unknown { + // $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: () => unknown): string { + try { + fn(); + } catch (e) { + return String(e?.stack ?? ''); + } + throw new Error('Expected the evaluated code to throw'); +} + +describe('globalEvalWithSourceUrl', () => { + afterEach(() => { + // $FlowFixMe[prop-missing] + delete globalThis.__fantomEvalMarker; + }); + + it('is installed on the bridgeless runtime', () => { + // $FlowFixMe[prop-missing] + expect(typeof global.globalEvalWithSourceUrl).toBe('function'); + }); + + it('evaluates the same source as JS eval() in the global scope', () => { + const source = 'globalThis.__fantomEvalMarker = 17; 17'; + + // eslint-disable-next-line no-eval + expect(eval(source)).toBe(17); + // $FlowFixMe[prop-missing] + expect(globalThis.__fantomEvalMarker).toBe(17); + + // $FlowFixMe[prop-missing] + delete globalThis.__fantomEvalMarker; + + expect(getHelper()(source, SOURCE_URL)).toBe(17); + // $FlowFixMe[prop-missing] + expect(globalThis.__fantomEvalMarker).toBe(17); + }); + + 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', () => { + // 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', + ); + }); +}); diff --git a/packages/react-native/ReactCommon/react/runtime/ReactInstance.cpp b/packages/react-native/ReactCommon/react/runtime/ReactInstance.cpp index d809b00d7af..a32a40ba70c 100644 --- a/packages/react-native/ReactCommon/react/runtime/ReactInstance.cpp +++ b/packages/react-native/ReactCommon/react/runtime/ReactInstance.cpp @@ -458,6 +458,36 @@ void ReactInstance::initializeRuntime( defineReactInstanceFlags(runtime, options); + // 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", + 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 858ab872315..3887b7bdc85 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(); @@ -255,6 +259,53 @@ 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, + testGlobalEvalWithSourceUrlMatchesEvalWhenEvalIsEnabled) { + initializeRuntimeWithScript(""); + + 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) { auto valBefore = tryEval("__RCTProfileIsProfiling === true", "false"); EXPECT_EQ(valBefore.getBool(), false);