Repository navigation
Convert iOS native module event arguments to JS without UTF-8 - #59449
meta-codesync[bot] merged 6 commits into
Conversation
|
Hi @javache, this one is similar to #59325, but for events. On iOS, event arguments don't go through If it's easier to review, I can move |
|
Hi @javache, the iOS visual diff isn't from this change: a system overlay covers the top of the crop, and the content matches the baseline. On an iPhone 15 Pro, before and after are pixel-identical. Could you apply |
| 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]); | ||
| } | ||
| } |
There was a problem hiding this comment.
This is very similar to convertObjCObjectToJSIValue in RCTTurboModule.
Can we extract it a single shared helper that we use across both paths.
There was a problem hiding this comment.
Thank you for the comment! Done 👍
| } else { | ||
| continue; | ||
| } | ||
| children_.emplace_back(value[key]); |
There was a problem hiding this comment.
Why do this conversion up-front (requiring additional allocation) instead of lazily in the toJSValue path?
| [moduleName UTF8String] ?: "", | ||
| [method UTF8String] ?: "", |
There was a problem hiding this comment.
Early return if either moduleName or method is the empty string.
There was a problem hiding this comment.
Sorry, I wanted to avoid the ?: "" fallback here entirely, that should never happen.
That can be an early return inside callFunctionOnModule?
| moduleName, | ||
| methodName, | ||
| [&](std::vector<jsi::Value>& jsArgs) { | ||
| for (auto& arg : args) { |
|
@javache has imported this pull request. If you are a Meta employee, you can view this in D127583929. |
…omodule-faster-object-conversion Keeps react#59449's key handling in convertNSDictionaryToJSIObject: number keys become strings, and other keys that aren't strings are skipped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
f05f675 to
506ede7
Compare
|
@javache I rebased on main and resolved the conflict, ready for another look 🙂 |
christophpurrer
left a comment
There was a problem hiding this comment.
Review automatically exported from Phabricator review in Meta.
| } | ||
| _reactInstance->callFunctionOnModule( | ||
| [moduleName UTF8String] ?: "", [method UTF8String] ?: "", convertIdToFollyDynamic(args ? args : @[])); | ||
| [moduleName UTF8String], [method UTF8String], [callArgs](jsi::Runtime &runtime, std::vector<jsi::Value> &jsArgs) { |
There was a problem hiding this comment.
Let's extract these locals and do the bailout here - otherwise it will crash turning a nullptr into an std::string
const char *moduleNameStr = [moduleName UTF8String];
const char *methodNameStr = [method UTF8String];
If (moduleNameStr == nullptr || methodNameStr == nullptr) {
return;
}
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@javache @christophpurrer the nullptr bailout is in bad4fb2. Could you take another look when you have a moment? |
| __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; | ||
| } |
There was a problem hiding this comment.
Simplify?
| __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; | |
| } | |
| id key = keysAndObjects[i]; | |
| if (![key isKindOfClass:[NSString class]]) { | |
| if (![key isKindOfClass:[NSNumber class]] || [key isKindOfClass:[@YES class]]) { | |
| continue; | |
| } | |
| key = [key stringValue]; | |
| } |
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
This pull request was successfully merged by @hryhoriiK97 in 9b0200e When will my fix make it into a release? | How to file a pick request? |
## 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: react#59449



Summary:
Native modules send events to JS with
RCTEventEmitter sendEventWithName:body:, which ends inRCTInstance -callFunctionOnJSModule:method:args:. Core modules use it too: for example,RCTNetworkingsends the whole text of an XHR response indidReceiveNetworkData, andRCTWebSocketModulesends each message.Current behavior. The arguments go through
folly::dynamic. On the sending thread,convertIdToFollyDynamicencodes every string as UTF-8. On the JS thread,jsi::valueFromDynamicdecodes 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.
RCTInstancereads the arguments into a small tree with the structure thatconvertIdToFollyDynamiccreates. 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.valueFromDynamicdoes.ReactInstancegets acallFunctionOnModuleoverload that takes anArgFactory, which creates the arguments on the JS thread. It has the same shape asTurboModule::ArgFactory, whichemitDeviceEventuses. Both overloads share the module lookup, so Android and other callers of thefolly::dynamicoverload run the same code as before.JS receives the same values as before:
convertIdToFollyDynamic.null."".The order of properties in event objects now follows the
NSDictionary. Before, it followed thefolly::dynamichash 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, asJSStringCreateWithUTF8CStringdid. 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 --Werroron the changed files passes.xcodebuildin Release and run on a physical iPhone 15 Pro (iOS 26.6.1).ReactInstanceTest.testCallFunctionOnModule_withArgFactoryandtestCallFunctionOnModule_withEmptyArgFactoryfor 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.BOOL,char,unsigned char,short,int, negative,unsigned long long, 2^60,float,double, NaN andNSDecimalNumbernumbers;NSNull,NSDateandNSURLvalues;NSNullkeys.GETofdata: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.valueFromDynamicbefore and the tree conversion after.convertIdToFollyDynamicbefore and building the tree after.didReceiveNetworkData, accented, 1 KBdidReceiveNetworkData, accented, 100 KBdidReceiveNetworkData, accented, 1 MBdidReceiveNetworkData, ASCII, 1 KBdidReceiveNetworkData, ASCII, 100 KBdidReceiveNetworkData, ASCII, 1 MBdidReceiveNetworkResponse(headers)🤖 Generated with Claude Code