Skip to content

Commit 9b0200e

Browse files
authored
Convert iOS native module event arguments to JS without UTF-8
## Summary: Native modules send events to JS with `RCTEventEmitter sendEventWithName:body:`, which ends in `RCTInstance -callFunctionOnJSModule:method:args:`. Core modules use it too: for example, `RCTNetworking` sends the whole text of an XHR response in `didReceiveNetworkData`, and `RCTWebSocketModule` sends each message. **Current behavior.** The arguments go through `folly::dynamic`. On the sending thread, `convertIdToFollyDynamic` encodes every string as UTF-8. On the JS thread, `jsi::valueFromDynamic` decodes it again into a JS string. Text that isn't ASCII is the expensive case: a 1 MB response with accented text costs about 3.9 ms on the JS thread and 3.2 ms on the sending thread. **With this change.** - On the sending thread, `RCTInstance` reads the arguments into a small tree with the structure that `convertIdToFollyDynamic` creates. Strings keep their own ASCII or UTF-16 characters instead of being encoded as UTF-8. When CoreFoundation exposes the characters, the tree points at them in an immutable copy of the string; otherwise it copies them. - On the JS thread, the JS values are created from the tree, without Objective-C calls, the same way `valueFromDynamic` does. - `ReactInstance` gets a `callFunctionOnModule` overload that takes an `ArgFactory`, which creates the arguments on the JS thread. It has the same shape as `TurboModule::ArgFactory`, which `emitDeviceEvent` uses. Both overloads share the module lookup, so Android and other callers of the `folly::dynamic` overload run the same code as before. JS receives the same values as before: - Numbers use the same rules as `convertIdToFollyDynamic`. - Number keys become strings, and other non-string keys are skipped. - Values of unsupported types become `null`. - Strings with a lone surrogate, which UTF-8 can't encode, keep the UTF-8 path, so they still arrive as `""`. The order of properties in event objects now follows the `NSDictionary`. Before, it followed the `folly::dynamic` hash map, so it was arbitrary in both cases. On JSC, a string with both a NUL and non-ASCII characters is no longer cut off at the NUL, as `JSStringCreateWithUTF8CString` did. Hermes output is the same. ## Changelog: [IOS] [CHANGED] - Events sent by native modules are converted to JS values without a UTF-8 round trip ## Test Plan: - `clang-format --dry-run --Werror` on the changed files passes. - RNTester built with `xcodebuild` in Release and run on a physical iPhone 15 Pro (iOS 26.6.1). - Added `ReactInstanceTest.testCallFunctionOnModule_withArgFactory` and `testCallFunctionOnModule_withEmptyArgFactory` for the new overload. I couldn't run the C++ tests locally. I updated the C++ API snapshots by hand, because I couldn't run Doxygen here. - **Same output.** - A test payload was sent through the old and the new conversion in the same app run, and JS compared the results deeply. They were identical. The payload had: - empty, NUL, BOM, emoji, accented, CJK and lone-surrogate strings, and a mutable string changed after sending; - `BOOL`, `char`, `unsigned char`, `short`, `int`, negative, `unsigned long long`, 2^60, `float`, `double`, NaN and `NSDecimalNumber` numbers; - `NSNull`, `NSDate` and `NSURL` values; - nested arrays and dictionaries; - number, bool and `NSNull` keys. - Every XHR response text in the measurement below matched the original string. - **Measurement.** RNTester Playground, XHR `GET` of `data:` URLs with ASCII or accented text: 1000 requests at 1 KB, 30 at 100 KB and 10 at 1 MB. Also 1000 small events sent from a background queue: a keyboard-like payload, and one with short keys and values created at run time. - Timed on the JS thread: creating the JS arguments, `valueFromDynamic` before and the tree conversion after. - Timed on the sending thread: `convertIdToFollyDynamic` before and building the tree after. - The two arms are the same binary with the conversion switched by a launch argument, in the order before, after, after, before, 4 launches per arm. The presses were driven by [Argent](https://github.com/software-mansion/argent) on the physical iPhone. - iPhone 15 Pro, Release, medians per event. The ranges of the two arms are separate in every row. | Event | JS thread before | after | change | Sending thread before | after | change | |---|--:|--:|--:|--:|--:|--:| | `didReceiveNetworkData`, accented, 1 KB | 4.82 µs | 0.36 µs | −92% | 4.11 µs | 0.38 µs | −91% | | `didReceiveNetworkData`, accented, 100 KB | 382 µs | 8.6 µs | −98% | 311 µs | 3.1 µs | −99% | | `didReceiveNetworkData`, accented, 1 MB | 3.87 ms | 0.09 ms | −98% | 3.18 ms | 0.03 ms | −99% | | `didReceiveNetworkData`, ASCII, 1 KB | 0.65 µs | 0.34 µs | −47% | 0.97 µs | 0.38 µs | −61% | | `didReceiveNetworkData`, ASCII, 100 KB | 12.1 µs | 7.8 µs | −36% | 5.8 µs | 0.35 µs | −94% | | `didReceiveNetworkData`, ASCII, 1 MB | 97.6 µs | 71.8 µs | −26% | 49.3 µs | 0.33 µs | −99% | | `didReceiveNetworkResponse` (headers) | 1.08 µs | 0.74 µs | −31% | 2.45 µs | 0.78 µs | −68% | | keyboard-like event | 1.79 µs | 1.17 µs | −35% | 5.83 µs | 2.28 µs | −61% | | short strings created at run time | 1.04 µs | 0.61 µs | −41% | 2.58 µs | 0.79 µs | −70% | 🤖 Generated with [Claude Code](https://claude.com/claude-code) Differential Revision: D127583929 Pull Request resolved: #59449
1 parent 16424c2 commit 9b0200e

8 files changed

Lines changed: 216 additions & 48 deletions

File tree

‎packages/react-native/ReactCommon/react/nativemodule/core/platform/ios/ReactCommon/RCTTurboModule.mm‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,9 +120,18 @@ static T convertNSStringToJSI(jsi::Runtime &runtime, __unsafe_unretained NSStrin
120120
});
121121
jsi::Object result = jsi::Object(runtime);
122122
for (NSUInteger i = 0; i < count; i++) {
123+
// Number keys become strings, and other keys that aren't strings are skipped, as
124+
// convertIdToFollyDynamic does.
125+
id key = keysAndObjects[i];
126+
if (![key isKindOfClass:[NSString class]]) {
127+
if (![key isKindOfClass:[NSNumber class]] || [key isKindOfClass:[@YES class]]) {
128+
continue;
129+
}
130+
key = [key stringValue];
131+
}
123132
result.setProperty(
124133
runtime,
125-
convertNSStringToJSI<jsi::PropNameID>(runtime, keysAndObjects[i]),
134+
convertNSStringToJSI<jsi::PropNameID>(runtime, key),
126135
convertUnretainedObjCObjectToJSIValue(runtime, keysAndObjects[count + i]));
127136
}
128137
return result;

‎packages/react-native/ReactCommon/react/runtime/ReactInstance.cpp‎

Lines changed: 98 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -309,60 +309,113 @@ void ReactInstance::loadScript(
309309
});
310310
}
311311

312+
namespace {
313+
312314
/*
313315
* Calls a method on a JS module that has been registered with
314-
* `registerCallableModule`. Used to invoke a JS function from platform code.
316+
* `registerCallableModule`, with the arguments that `addArgs` adds.
315317
*/
316-
void ReactInstance::callFunctionOnModule(
318+
template <typename AddArgs>
319+
void callCallableModuleMethod(
320+
jsi::Runtime& runtime,
321+
std::unordered_map<std::string, std::variant<jsi::Function, jsi::Object>>&
322+
callableModules,
317323
const std::string& moduleName,
318324
const std::string& methodName,
319-
folly::dynamic&& args) {
320-
bufferedRuntimeExecutor_->execute([this,
321-
moduleName = moduleName,
322-
methodName = methodName,
323-
args = std::move(args)](
324-
jsi::Runtime& runtime) {
325-
TraceSection s(
326-
"ReactInstance::callFunctionOnModule",
327-
"moduleName",
328-
moduleName,
329-
"methodName",
330-
methodName);
331-
auto it = callableModules_.find(moduleName);
332-
if (it == callableModules_.end()) {
333-
std::ostringstream knownModules;
334-
int i = 0;
335-
for (it = callableModules_.begin(); it != callableModules_.end();
336-
it++, i++) {
337-
const char* space = (i > 0 ? ", " : " ");
338-
knownModules << space << it->first;
339-
}
340-
throw jsi::JSError(
341-
runtime,
342-
"Failed to call into JavaScript module method " + moduleName + "." +
343-
methodName +
344-
"(). Module has not been registered as callable. Registered callable JavaScript modules (n = " +
345-
std::to_string(callableModules_.size()) +
346-
"):" + knownModules.str() +
347-
". Did you forget to call `registerCallableModule`?");
325+
const AddArgs& addArgs) {
326+
TraceSection s(
327+
"ReactInstance::callFunctionOnModule",
328+
"moduleName",
329+
moduleName,
330+
"methodName",
331+
methodName);
332+
auto it = callableModules.find(moduleName);
333+
if (it == callableModules.end()) {
334+
std::ostringstream knownModules;
335+
int i = 0;
336+
for (it = callableModules.begin(); it != callableModules.end(); it++, i++) {
337+
const char* space = (i > 0 ? ", " : " ");
338+
knownModules << space << it->first;
348339
}
340+
throw jsi::JSError(
341+
runtime,
342+
"Failed to call into JavaScript module method " + moduleName + "." +
343+
methodName +
344+
"(). Module has not been registered as callable. Registered callable JavaScript modules (n = " +
345+
std::to_string(callableModules.size()) + "):" + knownModules.str() +
346+
". Did you forget to call `registerCallableModule`?");
347+
}
349348

350-
if (std::holds_alternative<jsi::Function>(it->second)) {
351-
auto module =
352-
std::get<jsi::Function>(it->second).call(runtime).asObject(runtime);
353-
it->second = std::move(module);
354-
}
349+
if (std::holds_alternative<jsi::Function>(it->second)) {
350+
auto module =
351+
std::get<jsi::Function>(it->second).call(runtime).asObject(runtime);
352+
it->second = std::move(module);
353+
}
355354

356-
auto& module = std::get<jsi::Object>(it->second);
357-
auto method = module.getPropertyAsFunction(runtime, methodName.c_str());
355+
auto& module = std::get<jsi::Object>(it->second);
356+
auto method = module.getPropertyAsFunction(runtime, methodName.c_str());
358357

359-
std::vector<jsi::Value> jsArgs;
360-
for (auto& arg : args) {
361-
jsArgs.push_back(jsi::valueFromDynamic(runtime, arg));
362-
}
363-
method.callWithThis(
364-
runtime, module, (const jsi::Value*)jsArgs.data(), jsArgs.size());
365-
});
358+
std::vector<jsi::Value> jsArgs;
359+
addArgs(jsArgs);
360+
method.callWithThis(
361+
runtime, module, (const jsi::Value*)jsArgs.data(), jsArgs.size());
362+
}
363+
364+
} // namespace
365+
366+
/*
367+
* Calls a method on a JS module that has been registered with
368+
* `registerCallableModule`. Used to invoke a JS function from platform code.
369+
*/
370+
void ReactInstance::callFunctionOnModule(
371+
const std::string& moduleName,
372+
const std::string& methodName,
373+
folly::dynamic&& args) {
374+
if (moduleName.empty() || methodName.empty()) {
375+
return;
376+
}
377+
bufferedRuntimeExecutor_->execute(
378+
[this,
379+
moduleName = moduleName,
380+
methodName = methodName,
381+
args = std::move(args)](jsi::Runtime& runtime) {
382+
callCallableModuleMethod(
383+
runtime,
384+
callableModules_,
385+
moduleName,
386+
methodName,
387+
[&](std::vector<jsi::Value>& jsArgs) {
388+
jsArgs.reserve(args.size());
389+
for (auto& arg : args) {
390+
jsArgs.push_back(jsi::valueFromDynamic(runtime, arg));
391+
}
392+
});
393+
});
394+
}
395+
396+
void ReactInstance::callFunctionOnModule(
397+
const std::string& moduleName,
398+
const std::string& methodName,
399+
ArgFactory&& argFactory) {
400+
if (moduleName.empty() || methodName.empty()) {
401+
return;
402+
}
403+
bufferedRuntimeExecutor_->execute(
404+
[this,
405+
moduleName = moduleName,
406+
methodName = methodName,
407+
argFactory = std::move(argFactory)](jsi::Runtime& runtime) {
408+
callCallableModuleMethod(
409+
runtime,
410+
callableModules_,
411+
moduleName,
412+
methodName,
413+
[&](std::vector<jsi::Value>& jsArgs) {
414+
if (argFactory) {
415+
argFactory(runtime, jsArgs);
416+
}
417+
});
418+
});
366419
}
367420

368421
void ReactInstance::registerSegment(

‎packages/react-native/ReactCommon/react/runtime/ReactInstance.h‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ namespace facebook::react {
2727
class ReactInstance final : private jsinspector_modern::InstanceTargetDelegate {
2828
public:
2929
using BindingsInstallFunc = std::function<void(jsi::Runtime &runtime)>;
30+
using ArgFactory = std::function<void(jsi::Runtime &runtime, std::vector<jsi::Value> &args)>;
3031

3132
ReactInstance(
3233
std::unique_ptr<JSRuntime> runtime,
@@ -71,6 +72,12 @@ class ReactInstance final : private jsinspector_modern::InstanceTargetDelegate {
7172

7273
void callFunctionOnModule(const std::string &moduleName, const std::string &methodName, folly::dynamic &&args);
7374

75+
/**
76+
* Like the overload above, but `argFactory` adds the arguments on the JS thread, so platform
77+
* values can be converted to JS values directly.
78+
*/
79+
void callFunctionOnModule(const std::string &moduleName, const std::string &methodName, ArgFactory &&argFactory);
80+
7481
void handleMemoryPressureJs(int pressureLevel);
7582

7683
/**

‎packages/react-native/ReactCommon/react/runtime/platform/ios/ReactCommon/RCTInstance.mm‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@
4242
#import <react/renderer/core/EventEmitterListener.h>
4343
#import <react/runtime/ReactInstance.h>
4444
#import <react/utils/ContextContainer.h>
45-
#import <react/utils/FollyConvert.h>
4645
#import <react/utils/ManagedObjectWrapper.h>
4746

4847
#import "ObjCTimerRegistry.h"
@@ -174,14 +173,26 @@ - (void)dealloc
174173

175174
- (void)callFunctionOnJSModule:(NSString *)moduleName method:(NSString *)method args:(NSArray *)args
176175
{
176+
const char *moduleNameStr = [moduleName UTF8String];
177+
const char *methodNameStr = [method UTF8String];
178+
if (moduleNameStr == nullptr || methodNameStr == nullptr) {
179+
return;
180+
}
181+
// The arguments are converted on the JS thread, like the values of codegen's AsyncEventEmitter.
182+
NSArray *callArgs = [args copy];
177183
// This is called from arbitrary threads, while -invalidate destroys _reactInstance on
178184
// the JS thread, so checking _valid and dereferencing have to happen as one step.
179185
std::lock_guard<std::mutex> lock(_invalidationMutex);
180186
if (!_valid || !_reactInstance) {
181187
return;
182188
}
183189
_reactInstance->callFunctionOnModule(
184-
[moduleName UTF8String] ?: "", [method UTF8String] ?: "", convertIdToFollyDynamic(args ? args : @[]));
190+
moduleNameStr, methodNameStr, [callArgs](jsi::Runtime &runtime, std::vector<jsi::Value> &jsArgs) {
191+
jsArgs.reserve(callArgs.count);
192+
for (id arg in callArgs) {
193+
jsArgs.push_back(TurboModuleConvertUtils::convertObjCObjectToJSIValue(runtime, arg));
194+
}
195+
});
185196
}
186197

187198
- (void)invalidate

‎packages/react-native/ReactCommon/react/runtime/tests/cxx/ReactInstanceTest.cpp‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -821,4 +821,86 @@ function getResult() {
821821
EXPECT_EQ(result.getNumber(), 1);
822822
}
823823

824+
TEST_F(ReactInstanceTest, testCallFunctionOnModule_withArgFactory) {
825+
initializeRuntimeWithScript(R"xyz123(
826+
let result;
827+
const module = {
828+
bar: (a, b) => {
829+
result = a + ':' + b;
830+
},
831+
};
832+
RN$registerCallableModule('foo', () => module);
833+
function getResult() {
834+
return result;
835+
}
836+
)xyz123");
837+
838+
instance_->callFunctionOnModule(
839+
"foo", "bar", [](jsi::Runtime& runtime, std::vector<jsi::Value>& args) {
840+
args.emplace_back(jsi::String::createFromAscii(runtime, "baz"));
841+
args.emplace_back(2);
842+
});
843+
step();
844+
845+
auto result = runtime_->global()
846+
.getPropertyAsFunction(*runtime_, "getResult")
847+
.call(*runtime_);
848+
EXPECT_EQ(result.getString(*runtime_).utf8(*runtime_), "baz:2");
849+
}
850+
851+
TEST_F(ReactInstanceTest, testCallFunctionOnModule_withEmptyArgFactory) {
852+
initializeRuntimeWithScript(R"xyz123(
853+
let result;
854+
const module = {
855+
bar: (...args) => {
856+
result = args.length;
857+
},
858+
};
859+
RN$registerCallableModule('foo', () => module);
860+
function getResult() {
861+
return result;
862+
}
863+
)xyz123");
864+
865+
instance_->callFunctionOnModule("foo", "bar", ReactInstance::ArgFactory{});
866+
step();
867+
868+
auto result = runtime_->global()
869+
.getPropertyAsFunction(*runtime_, "getResult")
870+
.call(*runtime_);
871+
EXPECT_EQ(result.getNumber(), 0);
872+
}
873+
874+
TEST_F(ReactInstanceTest, testCallFunctionOnModule_withEmptyNames) {
875+
initializeRuntimeWithScript(R"xyz123(
876+
let calls = 0;
877+
const module = {
878+
bar: () => {
879+
calls++;
880+
},
881+
};
882+
RN$registerCallableModule('foo', () => module);
883+
function getCalls() {
884+
return calls;
885+
}
886+
)xyz123");
887+
888+
bool argFactoryCalled = false;
889+
auto argFactory = [&](jsi::Runtime& /*runtime*/,
890+
std::vector<jsi::Value>& /*args*/) {
891+
argFactoryCalled = true;
892+
};
893+
instance_->callFunctionOnModule("", "bar", argFactory);
894+
instance_->callFunctionOnModule("foo", "", argFactory);
895+
instance_->callFunctionOnModule("", "bar", folly::dynamic::array());
896+
instance_->callFunctionOnModule("foo", "", folly::dynamic::array());
897+
step();
898+
899+
EXPECT_FALSE(argFactoryCalled);
900+
auto calls = runtime_->global()
901+
.getPropertyAsFunction(*runtime_, "getCalls")
902+
.call(*runtime_);
903+
EXPECT_EQ(calls.getNumber(), 0);
904+
}
905+
824906
} // namespace facebook::react

‎scripts/cxx-api/api-snapshots/ReactAndroidFrameworksCxx.api‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3350,7 +3350,9 @@ class facebook::react::ReactInstance {
33503350
public facebook::react::RuntimeExecutor getUnbufferedRuntimeExecutor() noexcept;
33513351
public std::shared_ptr<facebook::react::CallInvoker> createJSCallInvoker() noexcept;
33523352
public std::shared_ptr<facebook::react::RuntimeScheduler> getRuntimeScheduler() noexcept;
3353+
public using ArgFactory = std::function<void(facebook::jsi::Runtime& runtime, std::vector<facebook::jsi::Value>& args)>;
33533354
public using BindingsInstallFunc = std::function<void(facebook::jsi::Runtime& runtime)>;
3355+
public void callFunctionOnModule(const std::string& moduleName, const std::string& methodName, facebook::react::ReactInstance::ArgFactory&& argFactory);
33543356
public void callFunctionOnModule(const std::string& moduleName, const std::string& methodName, folly::dynamic&& args);
33553357
public void handleMemoryPressureJs(int pressureLevel);
33563358
public void initializeRuntime(facebook::react::ReactInstance::JSRuntimeFlags options, facebook::react::ReactInstance::BindingsInstallFunc bindingsInstallFunc) noexcept;

‎scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5537,7 +5537,9 @@ class facebook::react::ReactInstance {
55375537
public facebook::react::RuntimeExecutor getUnbufferedRuntimeExecutor() noexcept;
55385538
public std::shared_ptr<facebook::react::CallInvoker> createJSCallInvoker() noexcept;
55395539
public std::shared_ptr<facebook::react::RuntimeScheduler> getRuntimeScheduler() noexcept;
5540+
public using ArgFactory = std::function<void(facebook::jsi::Runtime& runtime, std::vector<facebook::jsi::Value>& args)>;
55405541
public using BindingsInstallFunc = std::function<void(facebook::jsi::Runtime& runtime)>;
5542+
public void callFunctionOnModule(const std::string& moduleName, const std::string& methodName, facebook::react::ReactInstance::ArgFactory&& argFactory);
55415543
public void callFunctionOnModule(const std::string& moduleName, const std::string& methodName, folly::dynamic&& args);
55425544
public void handleMemoryPressureJs(int pressureLevel);
55435545
public void initializeRuntime(facebook::react::ReactInstance::JSRuntimeFlags options, facebook::react::ReactInstance::BindingsInstallFunc bindingsInstallFunc) noexcept;

‎scripts/cxx-api/api-snapshots/ReactCommonFrameworksCxx.api‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1966,7 +1966,9 @@ class facebook::react::ReactInstance {
19661966
public facebook::react::RuntimeExecutor getUnbufferedRuntimeExecutor() noexcept;
19671967
public std::shared_ptr<facebook::react::CallInvoker> createJSCallInvoker() noexcept;
19681968
public std::shared_ptr<facebook::react::RuntimeScheduler> getRuntimeScheduler() noexcept;
1969+
public using ArgFactory = std::function<void(facebook::jsi::Runtime& runtime, std::vector<facebook::jsi::Value>& args)>;
19691970
public using BindingsInstallFunc = std::function<void(facebook::jsi::Runtime& runtime)>;
1971+
public void callFunctionOnModule(const std::string& moduleName, const std::string& methodName, facebook::react::ReactInstance::ArgFactory&& argFactory);
19701972
public void callFunctionOnModule(const std::string& moduleName, const std::string& methodName, folly::dynamic&& args);
19711973
public void handleMemoryPressureJs(int pressureLevel);
19721974
public void initializeRuntime(facebook::react::ReactInstance::JSRuntimeFlags options, facebook::react::ReactInstance::BindingsInstallFunc bindingsInstallFunc) noexcept;

0 commit comments

Comments
 (0)