You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 9b0200e
Browse filesBrowse the repository at this point in the historyBrowse files
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
0 commit comments