From b752f510d945bc1223577f701a34235430375e43 Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Fri, 23 Oct 2026 23:38:01 +0200 Subject: [PATCH 1/4] 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; From d05ed19b6825743c421601c2a10e99baf605b587 Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Mon, 26 Oct 2026 13:44:04 +0100 Subject: [PATCH 2/4] 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 | 14 +- .../platform/ios/ReactCommon/RCTInstance.mm | 257 +----------------- 2 files changed, 17 insertions(+), 254 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 701122cc63f5..a5f4ae926d05 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 @@ -88,8 +88,18 @@ static int32_t getUniqueId() static jsi::Object convertNSDictionaryToJSIObject(jsi::Runtime &runtime, NSDictionary *value) { jsi::Object result = jsi::Object(runtime); - for (NSString *k in value) { - result.setProperty(runtime, convertNSStringToJSIString(runtime, k), convertObjCObjectToJSIValue(runtime, value[k])); + for (id k in value) { + // Number keys become strings, and other keys that aren't strings are skipped, as + // convertIdToFollyDynamic does. + NSString *key = k; + if (![k isKindOfClass:[NSString class]]) { + if (![k isKindOfClass:[NSNumber class]] || [k isKindOfClass:[@YES class]]) { + continue; + } + key = [k stringValue]; + } + result.setProperty( + runtime, convertNSStringToJSIString(runtime, key), convertObjCObjectToJSIValue(runtime, value[k])); } 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 5f14b3ff58a7..e0fa563fd1d3 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 @@ -49,7 +44,6 @@ #import #import #import -#import #import #import "ObjCTimerRegistry.h" @@ -69,244 +63,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; @@ -416,11 +172,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); @@ -431,9 +184,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 843adb2aa16e6781bf0e5e56b0fb684d9a2d4b02 Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Mon, 26 Oct 2026 14:45:36 +0100 Subject: [PATCH 3/4] 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 e0fa563fd1d3..6e06a8b23fd2 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 @@ -172,6 +172,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 f05f675a5c0922a5335de149d94ee5f770ec55bf Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Tue, 27 Oct 2026 14:02:42 +0100 Subject: [PATCH 4/4] 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 6e06a8b23fd2..3c736dba6edf 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 @@ -172,9 +172,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 @@ -184,9 +181,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