From b752f510d945bc1223577f701a34235430375e43 Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Fri, 23 Oct 2026 23:38:01 +0200 Subject: [PATCH] Convert iOS native module event arguments to JS without UTF-8 Events sent with RCTEventEmitter went through folly::dynamic: every string was encoded as UTF-8 on the sending thread and decoded again on the JS thread. RCTInstance now reads the arguments on the sending thread into a small tree with the same structure, where strings keep their ASCII or UTF-16 characters, and the JS thread creates the JS values from it. ReactInstance gets a callFunctionOnModule overload that takes an ArgFactory, which creates the arguments on the JS thread. Co-Authored-By: Claude Opus 5.5 --- .../react/runtime/ReactInstance.cpp | 136 ++++++--- .../ReactCommon/react/runtime/ReactInstance.h | 7 + .../platform/ios/ReactCommon/RCTInstance.mm | 257 +++++++++++++++++- .../runtime/tests/cxx/ReactInstanceTest.cpp | 50 ++++ .../ReactAndroidFrameworksCxx.api | 2 + .../api-snapshots/ReactAppleFrameworksCxx.api | 2 + .../ReactCommonFrameworksCxx.api | 2 + 7 files changed, 410 insertions(+), 46 deletions(-) diff --git a/packages/react-native/ReactCommon/react/runtime/ReactInstance.cpp b/packages/react-native/ReactCommon/react/runtime/ReactInstance.cpp index 3366122b4843..390553377f48 100644 --- a/packages/react-native/ReactCommon/react/runtime/ReactInstance.cpp +++ b/packages/react-native/ReactCommon/react/runtime/ReactInstance.cpp @@ -309,60 +309,106 @@ void ReactInstance::loadScript( }); } +namespace { + /* * Calls a method on a JS module that has been registered with - * `registerCallableModule`. Used to invoke a JS function from platform code. + * `registerCallableModule`, with the arguments that `addArgs` adds. */ -void ReactInstance::callFunctionOnModule( +template +void callCallableModuleMethod( + jsi::Runtime& runtime, + std::unordered_map>& + callableModules, const std::string& moduleName, const std::string& methodName, - folly::dynamic&& args) { - bufferedRuntimeExecutor_->execute([this, - moduleName = moduleName, - methodName = methodName, - args = std::move(args)]( - jsi::Runtime& runtime) { - TraceSection s( - "ReactInstance::callFunctionOnModule", - "moduleName", - moduleName, - "methodName", - methodName); - auto it = callableModules_.find(moduleName); - if (it == callableModules_.end()) { - std::ostringstream knownModules; - int i = 0; - for (it = callableModules_.begin(); it != callableModules_.end(); - it++, i++) { - const char* space = (i > 0 ? ", " : " "); - knownModules << space << it->first; - } - throw jsi::JSError( - runtime, - "Failed to call into JavaScript module method " + moduleName + "." + - methodName + - "(). Module has not been registered as callable. Registered callable JavaScript modules (n = " + - std::to_string(callableModules_.size()) + - "):" + knownModules.str() + - ". Did you forget to call `registerCallableModule`?"); + const AddArgs& addArgs) { + TraceSection s( + "ReactInstance::callFunctionOnModule", + "moduleName", + moduleName, + "methodName", + methodName); + auto it = callableModules.find(moduleName); + if (it == callableModules.end()) { + std::ostringstream knownModules; + int i = 0; + for (it = callableModules.begin(); it != callableModules.end(); it++, i++) { + const char* space = (i > 0 ? ", " : " "); + knownModules << space << it->first; } + throw jsi::JSError( + runtime, + "Failed to call into JavaScript module method " + moduleName + "." + + methodName + + "(). Module has not been registered as callable. Registered callable JavaScript modules (n = " + + std::to_string(callableModules.size()) + "):" + knownModules.str() + + ". Did you forget to call `registerCallableModule`?"); + } - if (std::holds_alternative(it->second)) { - auto module = - std::get(it->second).call(runtime).asObject(runtime); - it->second = std::move(module); - } + if (std::holds_alternative(it->second)) { + auto module = + std::get(it->second).call(runtime).asObject(runtime); + it->second = std::move(module); + } - auto& module = std::get(it->second); - auto method = module.getPropertyAsFunction(runtime, methodName.c_str()); + auto& module = std::get(it->second); + auto method = module.getPropertyAsFunction(runtime, methodName.c_str()); - std::vector jsArgs; - for (auto& arg : args) { - jsArgs.push_back(jsi::valueFromDynamic(runtime, arg)); - } - method.callWithThis( - runtime, module, (const jsi::Value*)jsArgs.data(), jsArgs.size()); - }); + std::vector jsArgs; + addArgs(jsArgs); + method.callWithThis( + runtime, module, (const jsi::Value*)jsArgs.data(), jsArgs.size()); +} + +} // namespace + +/* + * Calls a method on a JS module that has been registered with + * `registerCallableModule`. Used to invoke a JS function from platform code. + */ +void ReactInstance::callFunctionOnModule( + const std::string& moduleName, + const std::string& methodName, + folly::dynamic&& args) { + bufferedRuntimeExecutor_->execute( + [this, + moduleName = moduleName, + methodName = methodName, + args = std::move(args)](jsi::Runtime& runtime) { + callCallableModuleMethod( + runtime, + callableModules_, + moduleName, + methodName, + [&](std::vector& jsArgs) { + for (auto& arg : args) { + jsArgs.push_back(jsi::valueFromDynamic(runtime, arg)); + } + }); + }); +} + +void ReactInstance::callFunctionOnModule( + const std::string& moduleName, + const std::string& methodName, + ArgFactory&& argFactory) { + bufferedRuntimeExecutor_->execute( + [this, + moduleName = moduleName, + methodName = methodName, + argFactory = std::move(argFactory)](jsi::Runtime& runtime) { + callCallableModuleMethod( + runtime, + callableModules_, + moduleName, + methodName, + [&](std::vector& jsArgs) { + if (argFactory) { + argFactory(runtime, jsArgs); + } + }); + }); } void ReactInstance::registerSegment( diff --git a/packages/react-native/ReactCommon/react/runtime/ReactInstance.h b/packages/react-native/ReactCommon/react/runtime/ReactInstance.h index 502d2f0380fb..6b82e548a2da 100644 --- a/packages/react-native/ReactCommon/react/runtime/ReactInstance.h +++ b/packages/react-native/ReactCommon/react/runtime/ReactInstance.h @@ -27,6 +27,7 @@ namespace facebook::react { class ReactInstance final : private jsinspector_modern::InstanceTargetDelegate { public: using BindingsInstallFunc = std::function; + using ArgFactory = std::function &args)>; ReactInstance( std::unique_ptr runtime, @@ -71,6 +72,12 @@ class ReactInstance final : private jsinspector_modern::InstanceTargetDelegate { void callFunctionOnModule(const std::string &moduleName, const std::string &methodName, folly::dynamic &&args); + /** + * Like the overload above, but `argFactory` adds the arguments on the JS thread, so platform + * values can be converted to JS values directly. + */ + void callFunctionOnModule(const std::string &moduleName, const std::string &methodName, ArgFactory &&argFactory); + void handleMemoryPressureJs(int pressureLevel); /** diff --git a/packages/react-native/ReactCommon/react/runtime/platform/ios/ReactCommon/RCTInstance.mm b/packages/react-native/ReactCommon/react/runtime/platform/ios/ReactCommon/RCTInstance.mm index 40480593c587..5f14b3ff58a7 100644 --- a/packages/react-native/ReactCommon/react/runtime/platform/ios/ReactCommon/RCTInstance.mm +++ b/packages/react-native/ReactCommon/react/runtime/platform/ios/ReactCommon/RCTInstance.mm @@ -7,7 +7,12 @@ #import "RCTInstance.h" +#import + #import +#import +#import +#import #import #import @@ -64,6 +69,244 @@ @interface RCTInstance () @end +namespace { + +bool hasLoneSurrogate(const UniChar *characters, size_t length) +{ + bool hasSurrogate = false; + for (size_t i = 0; i < length; i++) { + hasSurrogate |= (characters[i] & 0xF800) == 0xD800; + } + if (!hasSurrogate) { + return false; + } + for (size_t i = 0; i < length; i++) { + if ((characters[i] & 0xFC00) == 0xD800 && i + 1 < length && (characters[i + 1] & 0xFC00) == 0xDC00) { + i++; + } else if ((characters[i] & 0xF800) == 0xD800) { + return true; + } + } + return false; +} + +// An argument of a JS module call, read on the calling thread so that the JS thread can create +// the JS value without Objective-C calls. It has the structure that `convertIdToFollyDynamic` +// creates, but strings keep their ASCII or UTF-16 characters instead of being encoded as UTF-8. +class CallArgument { + public: + explicit CallArgument(id value) + { + if ([value isKindOfClass:[NSString class]]) { + kind_ = Kind::String; + setString(value); + } else if ([value isKindOfClass:[NSNumber class]]) { + setNumber(value); + } else if ([value isKindOfClass:[NSArray class]]) { + kind_ = Kind::Array; + children_.reserve([value count]); + for (id element in value) { + children_.emplace_back(element); + } + } else if ([value isKindOfClass:[NSDictionary class]]) { + kind_ = Kind::Object; + children_.reserve([value count] * 2); + for (id key in value) { + if ([key isKindOfClass:[NSString class]]) { + children_.emplace_back(key); + } else if (NSString *numberKey = numberKeyString(key)) { + children_.emplace_back(numberKey); + } else { + continue; + } + children_.emplace_back(value[key]); + } + } + } + + jsi::Value toJSValue(jsi::Runtime &runtime) const + { + switch (kind_) { + case Kind::Null: + return jsi::Value::null(); + case Kind::Boolean: + return jsi::Value(boolean_); + case Kind::Number: + return jsi::Value(number_); + case Kind::String: + return createString(runtime); + case Kind::Array: { + jsi::Array array(runtime, children_.size()); + for (size_t i = 0; i < children_.size(); i++) { + array.setValueAtIndex(runtime, i, children_[i].toJSValue(runtime)); + } + return array; + } + case Kind::Object: { + jsi::Object object(runtime); + for (size_t i = 0; i + 1 < children_.size(); i += 2) { + object.setProperty( + runtime, children_[i].createString(runtime), children_[i + 1].toJSValue(runtime)); + } + return object; + } + } + } + + private: + enum class Kind : uint8_t { Null, Boolean, Number, String, Array, Object }; + enum class Encoding : uint8_t { Ascii, Utf16, Utf8 }; + + // Number keys become strings, and other keys are skipped, as `convertIdToFollyDynamic` does. Kept + // out of the constructor so that each level of nesting needs less stack. + __attribute__((noinline)) static NSString *numberKeyString(id key) + { + folly::dynamic dynamicKey = convertIdToFollyDynamic(key); + return dynamicKey.isNumber() ? @(dynamicKey.asString().c_str()) : nil; + } + + void setNumber(NSNumber *value) + { + // The same rules as `convertIdToFollyDynamic`. + switch ([value objCType][0]) { + case _C_BOOL: + kind_ = Kind::Boolean; + boolean_ = [value boolValue]; + return; + case _C_CHR: + if ([value isKindOfClass:[@YES class]]) { + kind_ = Kind::Boolean; + boolean_ = [value boolValue]; + return; + } + kind_ = Kind::Number; + number_ = (double)[value longLongValue]; + return; + case _C_UCHR: + case _C_SHT: + case _C_USHT: + case _C_INT: + case _C_UINT: + case _C_LNG: + case _C_ULNG: + case _C_LNG_LNG: + case _C_ULNG_LNG: + kind_ = Kind::Number; + number_ = (double)[value longLongValue]; + return; + case _C_FLT: + case _C_DBL: + kind_ = Kind::Number; + number_ = [value doubleValue]; + return; + default: + return; + } + } + + void setString(NSString *value) + { + string_ = [value copy]; + auto string = (__bridge CFStringRef)string_; + CFIndex length = CFStringGetLength(string); + length_ = static_cast(length); + // Point at the string's own characters when CoreFoundation exposes them, and copy them + // otherwise. + if ((asciiCharacters_ = CFStringGetCStringPtr(string, kCFStringEncodingASCII)) != nullptr) { + encoding_ = Encoding::Ascii; + return; + } + if ((utf16Characters_ = CFStringGetCharactersPtr(string)) == nullptr) { + ownedCharacters_.resize(length_); + if (CFStringGetBytes( + string, + CFRangeMake(0, length), + kCFStringEncodingASCII, + 0, + false, + // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast) + reinterpret_cast(ownedCharacters_.data()), + length, + nullptr) == length) { + encoding_ = Encoding::Ascii; + string_ = nil; + return; + } + ownedCharacters_.clear(); + ownedUtf16Characters_.resize(length_); + // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast) + CFStringGetCharacters(string, CFRangeMake(0, length), reinterpret_cast(ownedUtf16Characters_.data())); + } + encoding_ = Encoding::Utf16; + // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast) + const auto *characters = utf16Characters_ ?: reinterpret_cast(ownedUtf16Characters_.data()); + // A lone surrogate can't be encoded as UTF-8, so such strings keep converting through UTF-8 + // as before. + if (hasLoneSurrogate(characters, length_)) { + NSData *data = [string_ dataUsingEncoding:NSUTF8StringEncoding]; + encoding_ = Encoding::Utf8; + utf16Characters_ = nullptr; + ownedUtf16Characters_.clear(); + ownedCharacters_.assign(static_cast(data.bytes), data.length); + length_ = data.length; + } + if (utf16Characters_ == nullptr) { + string_ = nil; + } + } + + template + T createString(jsi::Runtime &runtime) const + { + constexpr bool kPropNameID = std::is_same_v; + switch (encoding_) { + case Encoding::Ascii: { + const char *characters = asciiCharacters_ ?: ownedCharacters_.data(); + if constexpr (kPropNameID) { + return jsi::PropNameID::forAscii(runtime, characters, length_); + } else { + return jsi::String::createFromAscii(runtime, characters, length_); + } + } + case Encoding::Utf16: { + // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast) + const auto *characters = + utf16Characters_ ? reinterpret_cast(utf16Characters_) : ownedUtf16Characters_.data(); + if constexpr (kPropNameID) { + return jsi::PropNameID::forUtf16(runtime, characters, length_); + } else { + return jsi::String::createFromUtf16(runtime, characters, length_); + } + } + case Encoding::Utf8: { + // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast) + const auto *characters = reinterpret_cast(ownedCharacters_.data()); + if constexpr (kPropNameID) { + return jsi::PropNameID::forUtf8(runtime, characters, length_); + } else { + return jsi::String::createFromUtf8(runtime, characters, length_); + } + } + } + } + + Kind kind_{Kind::Null}; + Encoding encoding_{Encoding::Ascii}; + bool boolean_{false}; + double number_{0}; + // Keeps `asciiCharacters_` or `utf16Characters_` valid when they point into the string. + NSString *string_{nil}; + const char *asciiCharacters_{nullptr}; + const UniChar *utf16Characters_{nullptr}; + size_t length_{0}; + std::string ownedCharacters_; + std::u16string ownedUtf16Characters_; + // Array elements, or object keys and values in turn. + std::vector children_; +}; + +} // namespace + @implementation RCTInstance { std::unique_ptr _reactInstance; std::shared_ptr _jsRuntimeFactory; @@ -173,6 +416,11 @@ - (void)dealloc - (void)callFunctionOnJSModule:(NSString *)moduleName method:(NSString *)method args:(NSArray *)args { + auto callArgs = std::make_shared>(); + callArgs->reserve(args.count); + for (id arg in args) { + callArgs->emplace_back(arg); + } // This is called from arbitrary threads, while -invalidate destroys _reactInstance on // the JS thread, so checking _valid and dereferencing have to happen as one step. std::lock_guard lock(_invalidationMutex); @@ -180,7 +428,14 @@ - (void)callFunctionOnJSModule:(NSString *)moduleName method:(NSString *)method return; } _reactInstance->callFunctionOnModule( - [moduleName UTF8String] ?: "", [method UTF8String] ?: "", convertIdToFollyDynamic(args ? args : @[])); + [moduleName UTF8String] ?: "", + [method UTF8String] ?: "", + [callArgs](jsi::Runtime &runtime, std::vector &jsArgs) { + jsArgs.reserve(callArgs->size()); + for (const auto &arg : *callArgs) { + jsArgs.push_back(arg.toJSValue(runtime)); + } + }); } - (void)invalidate 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 e521a3209887..b6ceb357c0b6 100644 --- a/packages/react-native/ReactCommon/react/runtime/tests/cxx/ReactInstanceTest.cpp +++ b/packages/react-native/ReactCommon/react/runtime/tests/cxx/ReactInstanceTest.cpp @@ -821,4 +821,54 @@ function getResult() { EXPECT_EQ(result.getNumber(), 1); } +TEST_F(ReactInstanceTest, testCallFunctionOnModule_withArgFactory) { + initializeRuntimeWithScript(R"xyz123( +let result; +const module = { + bar: (a, b) => { + result = a + ':' + b; + }, +}; +RN$registerCallableModule('foo', () => module); +function getResult() { + return result; +} + )xyz123"); + + instance_->callFunctionOnModule( + "foo", "bar", [](jsi::Runtime& runtime, std::vector& args) { + args.emplace_back(jsi::String::createFromAscii(runtime, "baz")); + args.emplace_back(2); + }); + step(); + + auto result = runtime_->global() + .getPropertyAsFunction(*runtime_, "getResult") + .call(*runtime_); + EXPECT_EQ(result.getString(*runtime_).utf8(*runtime_), "baz:2"); +} + +TEST_F(ReactInstanceTest, testCallFunctionOnModule_withEmptyArgFactory) { + initializeRuntimeWithScript(R"xyz123( +let result; +const module = { + bar: (...args) => { + result = args.length; + }, +}; +RN$registerCallableModule('foo', () => module); +function getResult() { + return result; +} + )xyz123"); + + instance_->callFunctionOnModule("foo", "bar", ReactInstance::ArgFactory{}); + step(); + + auto result = runtime_->global() + .getPropertyAsFunction(*runtime_, "getResult") + .call(*runtime_); + EXPECT_EQ(result.getNumber(), 0); +} + } // namespace facebook::react diff --git a/scripts/cxx-api/api-snapshots/ReactAndroidFrameworksCxx.api b/scripts/cxx-api/api-snapshots/ReactAndroidFrameworksCxx.api index d2b41ea569c8..f94272fa1c48 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidFrameworksCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidFrameworksCxx.api @@ -3346,7 +3346,9 @@ class facebook::react::ReactInstance { public facebook::react::RuntimeExecutor getUnbufferedRuntimeExecutor() noexcept; public std::shared_ptr createJSCallInvoker() noexcept; public std::shared_ptr getRuntimeScheduler() noexcept; + public using ArgFactory = std::function& args)>; public using BindingsInstallFunc = std::function; + public void callFunctionOnModule(const std::string& moduleName, const std::string& methodName, facebook::react::ReactInstance::ArgFactory&& argFactory); public void callFunctionOnModule(const std::string& moduleName, const std::string& methodName, folly::dynamic&& args); public void handleMemoryPressureJs(int pressureLevel); public void initializeRuntime(facebook::react::ReactInstance::JSRuntimeFlags options, facebook::react::ReactInstance::BindingsInstallFunc bindingsInstallFunc) noexcept; diff --git a/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api index 6b5e45141f07..68882ba2b74a 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api @@ -5562,7 +5562,9 @@ class facebook::react::ReactInstance { public facebook::react::RuntimeExecutor getUnbufferedRuntimeExecutor() noexcept; public std::shared_ptr createJSCallInvoker() noexcept; public std::shared_ptr getRuntimeScheduler() noexcept; + public using ArgFactory = std::function& args)>; public using BindingsInstallFunc = std::function; + public void callFunctionOnModule(const std::string& moduleName, const std::string& methodName, facebook::react::ReactInstance::ArgFactory&& argFactory); public void callFunctionOnModule(const std::string& moduleName, const std::string& methodName, folly::dynamic&& args); public void handleMemoryPressureJs(int pressureLevel); public void initializeRuntime(facebook::react::ReactInstance::JSRuntimeFlags options, facebook::react::ReactInstance::BindingsInstallFunc bindingsInstallFunc) noexcept; diff --git a/scripts/cxx-api/api-snapshots/ReactCommonFrameworksCxx.api b/scripts/cxx-api/api-snapshots/ReactCommonFrameworksCxx.api index c06d3fc44d2a..f60022805738 100644 --- a/scripts/cxx-api/api-snapshots/ReactCommonFrameworksCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactCommonFrameworksCxx.api @@ -1964,7 +1964,9 @@ class facebook::react::ReactInstance { public facebook::react::RuntimeExecutor getUnbufferedRuntimeExecutor() noexcept; public std::shared_ptr createJSCallInvoker() noexcept; public std::shared_ptr getRuntimeScheduler() noexcept; + public using ArgFactory = std::function& args)>; public using BindingsInstallFunc = std::function; + public void callFunctionOnModule(const std::string& moduleName, const std::string& methodName, facebook::react::ReactInstance::ArgFactory&& argFactory); public void callFunctionOnModule(const std::string& moduleName, const std::string& methodName, folly::dynamic&& args); public void handleMemoryPressureJs(int pressureLevel); public void initializeRuntime(facebook::react::ReactInstance::JSRuntimeFlags options, facebook::react::ReactInstance::BindingsInstallFunc bindingsInstallFunc) noexcept;