Skip to content

Convert iOS native module event arguments to JS without UTF-8 - #59449

Open
hryhoriiK97 wants to merge 1 commit into
react:mainfrom
hryhoriiK97:ios-event-arguments-without-dynamic
Open

hryhoriiK97 wants to merge 1 commit into
react:mainfrom
hryhoriiK97:ios-event-arguments-without-dynamic

Conversation

@hryhoriiK97

Copy link
Copy Markdown
Contributor

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 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

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>
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Oct 24, 2026
@facebook-github-tools facebook-github-tools Bot added p: Software Mansion Partner: Software Mansion Partner labels Oct 24, 2026
@hryhoriiK97

Copy link
Copy Markdown
Contributor Author

Hi @javache, this one is similar to #59325, but for events. On iOS, event arguments don't go through folly::dynamic anymore, so strings aren't converted to UTF-8 and back. I used the same string order you suggested in #59325. Android isn't changed.

If it's easier to review, I can move CallArgument to a separate file. And #59450 is a smaller change to the TurboModule converter, it doesn't depend on this one.

@facebook-github-tools facebook-github-tools Bot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label Oct 24, 2026
@github-actions

Copy link
Copy Markdown

Visual regression

1 visual difference(s) at b752f510d945. Visual differences need maintainer approval. Review the report, then apply visual-change-approved.

ios scroll-view/horizontal-scroll-to/start: Changed, 170437 px (44.442%)

diff

Expected and Captured
Expected Captured
expected captured

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. p: Software Mansion Partner: Software Mansion Partner Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant