From 8b2ceae092b290b0c42f8bf1c273ac34b494ddd3 Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Fri, 23 Oct 2026 23:38:01 +0200 Subject: [PATCH 1/5] 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 fc0f82d267b2..a007d6a5b5f6 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 @@ -62,6 +67,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; @@ -171,6 +414,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); @@ -178,7 +426,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 4de5fdc97f51..c64a6dbc6af2 100644 --- a/scripts/cxx-api/api-snapshots/ReactAndroidFrameworksCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAndroidFrameworksCxx.api @@ -3348,7 +3348,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 cb8ca685d4f2..07edc3c0b236 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api @@ -5543,7 +5543,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 cb97eccc7f11..d4e1b205d440 100644 --- a/scripts/cxx-api/api-snapshots/ReactCommonFrameworksCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactCommonFrameworksCxx.api @@ -1965,7 +1965,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; From 0b8e13ffa0addea6ed296a3f2322ef60312e6780 Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Mon, 26 Oct 2026 13:44:04 +0100 Subject: [PATCH 2/5] Convert event arguments lazily with convertObjCObjectToJSIValue Drop CallArgument and convert the arguments on the JS thread with the TurboModule helper, like codegen's AsyncEventEmitter does. The helper now turns number keys into strings and skips other keys that aren't strings, as convertIdToFollyDynamic does, instead of treating every key as an NSString. Co-Authored-By: Claude Opus 5.5 --- .../ios/ReactCommon/RCTTurboModule.mm | 13 +- .../platform/ios/ReactCommon/RCTInstance.mm | 257 +----------------- 2 files changed, 17 insertions(+), 253 deletions(-) diff --git a/packages/react-native/ReactCommon/react/nativemodule/core/platform/ios/ReactCommon/RCTTurboModule.mm b/packages/react-native/ReactCommon/react/nativemodule/core/platform/ios/ReactCommon/RCTTurboModule.mm index f7c26ce7a728..2f464823eef5 100644 --- a/packages/react-native/ReactCommon/react/nativemodule/core/platform/ios/ReactCommon/RCTTurboModule.mm +++ b/packages/react-native/ReactCommon/react/nativemodule/core/platform/ios/ReactCommon/RCTTurboModule.mm @@ -120,9 +120,20 @@ static T convertNSStringToJSI(jsi::Runtime &runtime, __unsafe_unretained NSStrin }); jsi::Object result = jsi::Object(runtime); for (NSUInteger i = 0; i < count; i++) { + // Number keys become strings, and other keys that aren't strings are skipped, as + // convertIdToFollyDynamic does. + __unsafe_unretained id key = keysAndObjects[i]; + NSString *numberKey = nil; + if (![key isKindOfClass:[NSString class]]) { + if (![key isKindOfClass:[NSNumber class]] || [key isKindOfClass:[@YES class]]) { + continue; + } + numberKey = [key stringValue]; + key = numberKey; + } result.setProperty( runtime, - convertNSStringToJSI(runtime, keysAndObjects[i]), + convertNSStringToJSI(runtime, key), convertUnretainedObjCObjectToJSIValue(runtime, keysAndObjects[count + i])); } return result; 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 a007d6a5b5f6..3eb21f67a93e 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,12 +7,7 @@ #import "RCTInstance.h" -#import - #import -#import -#import -#import #import #import @@ -47,7 +42,6 @@ #import #import #import -#import #import #import "ObjCTimerRegistry.h" @@ -67,244 +61,6 @@ @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; @@ -414,11 +170,8 @@ - (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); - } + // The arguments are converted on the JS thread, like the values of codegen's AsyncEventEmitter. + NSArray *callArgs = [args copy]; // 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); @@ -429,9 +182,9 @@ - (void)callFunctionOnJSModule:(NSString *)moduleName method:(NSString *)method [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)); + jsArgs.reserve(callArgs.count); + for (id arg in callArgs) { + jsArgs.push_back(TurboModuleConvertUtils::convertObjCObjectToJSIValue(runtime, arg)); } }); } From 5106a806a6523eec2485ec48ad457bd540a15ebf Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Mon, 26 Oct 2026 14:45:36 +0100 Subject: [PATCH 3/5] Skip calls without a module or method name, and reserve the arguments Co-Authored-By: Claude Opus 5.5 --- .../react-native/ReactCommon/react/runtime/ReactInstance.cpp | 1 + .../react/runtime/platform/ios/ReactCommon/RCTInstance.mm | 3 +++ 2 files changed, 4 insertions(+) diff --git a/packages/react-native/ReactCommon/react/runtime/ReactInstance.cpp b/packages/react-native/ReactCommon/react/runtime/ReactInstance.cpp index 390553377f48..fd727cd8823c 100644 --- a/packages/react-native/ReactCommon/react/runtime/ReactInstance.cpp +++ b/packages/react-native/ReactCommon/react/runtime/ReactInstance.cpp @@ -382,6 +382,7 @@ void ReactInstance::callFunctionOnModule( moduleName, methodName, [&](std::vector& jsArgs) { + jsArgs.reserve(args.size()); for (auto& arg : args) { jsArgs.push_back(jsi::valueFromDynamic(runtime, arg)); } 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 3eb21f67a93e..7dbdf66c7cab 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 @@ -170,6 +170,9 @@ - (void)dealloc - (void)callFunctionOnJSModule:(NSString *)moduleName method:(NSString *)method args:(NSArray *)args { + if (moduleName.length == 0 || method.length == 0) { + return; + } // The arguments are converted on the JS thread, like the values of codegen's AsyncEventEmitter. NSArray *callArgs = [args copy]; // This is called from arbitrary threads, while -invalidate destroys _reactInstance on From 506ede75bc80a247cbb84bc9e22627fee0fd19e5 Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Tue, 27 Oct 2026 14:02:42 +0100 Subject: [PATCH 4/5] Skip calls with an empty module or method name in callFunctionOnModule Move the empty-name check from RCTInstance into both ReactInstance::callFunctionOnModule overloads, so it returns before anything is scheduled on the JS thread for every caller. RCTInstance's parameters are nonnull, so it passes UTF8String directly without a fallback. Co-Authored-By: Claude Opus 5.5 --- .../react/runtime/ReactInstance.cpp | 6 ++++ .../platform/ios/ReactCommon/RCTInstance.mm | 7 +--- .../runtime/tests/cxx/ReactInstanceTest.cpp | 32 +++++++++++++++++++ 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/packages/react-native/ReactCommon/react/runtime/ReactInstance.cpp b/packages/react-native/ReactCommon/react/runtime/ReactInstance.cpp index fd727cd8823c..7dde590b0664 100644 --- a/packages/react-native/ReactCommon/react/runtime/ReactInstance.cpp +++ b/packages/react-native/ReactCommon/react/runtime/ReactInstance.cpp @@ -371,6 +371,9 @@ void ReactInstance::callFunctionOnModule( const std::string& moduleName, const std::string& methodName, folly::dynamic&& args) { + if (moduleName.empty() || methodName.empty()) { + return; + } bufferedRuntimeExecutor_->execute( [this, moduleName = moduleName, @@ -394,6 +397,9 @@ void ReactInstance::callFunctionOnModule( const std::string& moduleName, const std::string& methodName, ArgFactory&& argFactory) { + if (moduleName.empty() || methodName.empty()) { + return; + } bufferedRuntimeExecutor_->execute( [this, moduleName = moduleName, 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 7dbdf66c7cab..cc42abecbc97 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 @@ -170,9 +170,6 @@ - (void)dealloc - (void)callFunctionOnJSModule:(NSString *)moduleName method:(NSString *)method args:(NSArray *)args { - if (moduleName.length == 0 || method.length == 0) { - return; - } // The arguments are converted on the JS thread, like the values of codegen's AsyncEventEmitter. NSArray *callArgs = [args copy]; // This is called from arbitrary threads, while -invalidate destroys _reactInstance on @@ -182,9 +179,7 @@ - (void)callFunctionOnJSModule:(NSString *)moduleName method:(NSString *)method return; } _reactInstance->callFunctionOnModule( - [moduleName UTF8String] ?: "", - [method UTF8String] ?: "", - [callArgs](jsi::Runtime &runtime, std::vector &jsArgs) { + [moduleName UTF8String], [method UTF8String], [callArgs](jsi::Runtime &runtime, std::vector &jsArgs) { jsArgs.reserve(callArgs.count); for (id arg in callArgs) { jsArgs.push_back(TurboModuleConvertUtils::convertObjCObjectToJSIValue(runtime, arg)); 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 b6ceb357c0b6..e8c12e2d8106 100644 --- a/packages/react-native/ReactCommon/react/runtime/tests/cxx/ReactInstanceTest.cpp +++ b/packages/react-native/ReactCommon/react/runtime/tests/cxx/ReactInstanceTest.cpp @@ -871,4 +871,36 @@ function getResult() { EXPECT_EQ(result.getNumber(), 0); } +TEST_F(ReactInstanceTest, testCallFunctionOnModule_withEmptyNames) { + initializeRuntimeWithScript(R"xyz123( +let calls = 0; +const module = { + bar: () => { + calls++; + }, +}; +RN$registerCallableModule('foo', () => module); +function getCalls() { + return calls; +} + )xyz123"); + + bool argFactoryCalled = false; + auto argFactory = [&](jsi::Runtime& /*runtime*/, + std::vector& /*args*/) { + argFactoryCalled = true; + }; + instance_->callFunctionOnModule("", "bar", argFactory); + instance_->callFunctionOnModule("foo", "", argFactory); + instance_->callFunctionOnModule("", "bar", folly::dynamic::array()); + instance_->callFunctionOnModule("foo", "", folly::dynamic::array()); + step(); + + EXPECT_FALSE(argFactoryCalled); + auto calls = runtime_->global() + .getPropertyAsFunction(*runtime_, "getCalls") + .call(*runtime_); + EXPECT_EQ(calls.getNumber(), 0); +} + } // namespace facebook::react From bad4fb29f684c9553a538f2fdb45c0e0789d2f24 Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Fri, 30 Oct 2026 21:04:08 +0100 Subject: [PATCH 5/5] Bail out before converting nil module or method names Co-Authored-By: Claude Opus 5.5 --- .../react/runtime/platform/ios/ReactCommon/RCTInstance.mm | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 cc42abecbc97..5c996dc58815 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 @@ -170,6 +170,11 @@ - (void)dealloc - (void)callFunctionOnJSModule:(NSString *)moduleName method:(NSString *)method args:(NSArray *)args { + const char *moduleNameStr = [moduleName UTF8String]; + const char *methodNameStr = [method UTF8String]; + if (moduleNameStr == nullptr || methodNameStr == nullptr) { + return; + } // The arguments are converted on the JS thread, like the values of codegen's AsyncEventEmitter. NSArray *callArgs = [args copy]; // This is called from arbitrary threads, while -invalidate destroys _reactInstance on @@ -179,7 +184,7 @@ - (void)callFunctionOnJSModule:(NSString *)moduleName method:(NSString *)method return; } _reactInstance->callFunctionOnModule( - [moduleName UTF8String], [method UTF8String], [callArgs](jsi::Runtime &runtime, std::vector &jsArgs) { + moduleNameStr, methodNameStr, [callArgs](jsi::Runtime &runtime, std::vector &jsArgs) { jsArgs.reserve(callArgs.count); for (id arg in callArgs) { jsArgs.push_back(TurboModuleConvertUtils::convertObjCObjectToJSIValue(runtime, arg));