Skip to content

Convert JS strings passed to iOS native modules without UTF-8 - #59422

Open
hryhoriiK97 wants to merge 1 commit into
react:mainfrom
hryhoriiK97:ios-turbomodule-string-arguments
Open

hryhoriiK97 wants to merge 1 commit into
react:mainfrom
hryhoriiK97:ios-turbomodule-string-arguments

Conversation

@hryhoriiK97

@hryhoriiK97 hryhoriiK97 commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Summary:

This is the other direction of #59325.

On iOS, every JS string that reaches a native module goes through convertJSIStringToNSString (ReactCommon/react/nativemodule/core/platform/ios/ReactCommon/RCTTurboModule.mm): string arguments, every key of an object argument, and string values inside objects and arrays. It read the string with jsi::String::utf8, so the runtime first converted its own characters to UTF-8, and then -[NSString initWithBytes:length:encoding:NSUTF8StringEncoding] decoded that UTF-8 again. For text with accented letters or emoji, both conversions run over the whole string.

With this change.

  • The string's own characters are read with jsi::String::getStringData. Hermes implements it natively and passes its ASCII or UTF-16 characters directly; they are copied once, with -initWithBytes:length:encoding:NSASCIIStringEncoding or -initWithCharacters:length:.
  • A string with a lone surrogate, or one that starts with a byte order mark (U+FEFF), still goes through UTF-8, which replaces the surrogate or drops the leading mark as before. Native code often needs the string as UTF-8, and an NSString with a lone surrogate has none: UTF8String returns NULL, so for example a fetch body with one would be dropped. Finding one is a single pass over the characters, and it costs nothing measurable. A string that arrives in several chunks also goes through UTF-8; no runtime in the repository splits strings.
  • JSCRuntime gets a native getStringData that passes the JSStringRef's UTF-16 characters (JSStringGetCharactersPtr). Without it, JSC would use JSI's default getStringData, which converts to UTF-8 and then to UTF-16, and the new path would be up to 2.6× slower on JSC than before.

Every string converts to the same NSString as before, including a \0 (kept since #57906), lone surrogates and a leading byte order mark.

A runtime that doesn't implement getStringData itself uses JSI's default, which converts to UTF-8 and then to UTF-16, so on such a runtime this conversion is slower than before, mostly for ASCII text. That applies to runtimes outside this repository, such as @react-native-community/javascriptcore; the same few lines as in JSCRuntime would fix it there.

Changelog:

[IOS] [CHANGED] - Strings passed from JS to native modules convert faster

Test Plan:

  • clang-format --dry-run --Werror (the repository's pinned version) on both files passes.
  • RNTester built with xcodebuild in Release and run on a physical iPhone 15 Pro (iOS 26.6.1), with Hermes:
    • In the TurboModule example, "Run function call tests" returns the expected value for all 18 methods.
    • Same output: a local build that ran both conversions on every string and compared them saw 1,276,000 strings (the benchmarks below and the TurboModule, Linking, AppState, Alert and TextInput examples) and found no difference.
    • Edge cases, each passed to getString and compared with what comes back, with and without this change: an empty string, "a\x00b", emoji, accented letters, Japanese, and mixed text come back unchanged in both; "x\uD800y" (a lone surrogate) comes back with the surrogate replaced, in both.
  • JSC: a macOS program built with the system JavaScriptCore and this PR's JSCRuntime.cpp and jsi.cpp converts ASCII, empty, \0, lone-surrogate, emoji, accented, Japanese and concatenated strings, and strings with a byte order mark at the start or inside, with the old and the new function, and every result is the same. Old → new, best of 9 rounds: ASCII −34% at 16 characters to −58% at 10 KB; accented text −61% to −97% (10 KB: 24.8 → 0.72 µs).
  • Measurement on the iPhone. RNTester Playground calling NativeSampleTurboModule.getString with JSON-like text in ASCII or with accented letters, and getObject with 20 keys whose values are 24-character strings, timed with performance.now() (20,000 calls up to 128 characters, 5,000 at 1 KB and for the objects, 1,000 at 10 KB, 200 at 100 KB, 20 at 1 MB). The same Release binary, this PR's code plus a launch argument that switches back to the old conversion, alternating, 4 launches each after a warm-up launch. The presses were driven by Argent on the physical iPhone. getString also returns the string, which since Convert NSStrings returned to JS to UTF-8 only once #59325 is a single copy, so the times below include that return. Mean time per call:
Argument before this PR change
accented letters, 128 characters 2.75 µs 1.71 µs −38%
accented letters, 1 KB 9.69 µs 2.00 µs −79%
accented letters, 10 KB 80.82 µs 4.64 µs −94%
accented letters, 100 KB 783.74 µs 26.06 µs −97%
accented letters, 1 MB 8.01 ms 0.34 ms −96%
ASCII, 128 characters 2.06 µs 1.85 µs −10%
ASCII, 1 KB 3.83 µs 2.40 µs −37%
ASCII, 10 KB 22.97 µs 8.69 µs −62%
ASCII, 100 KB 211.38 µs 69.70 µs −67%
ASCII, 1 MB 2.14 ms 0.68 ms −68%
object, 20 keys of accented strings 18.45 µs 12.43 µs −33%
object, 20 keys of ASCII strings 12.96 µs 11.71 µs −10%

For every row, the ranges of the two arms don't overlap. For 16-character strings, the change was −8% with accented letters and within noise for ASCII. An earlier run of the same comparison (5 and 6 launches, up to 10 KB) gave the same results within 3 points.

  • A fetch body. In the same launches, the JS-thread time of XMLHttpRequest.send(body) with a 1 MB JSON body, which converts the body to an NSString for RCTNetworking before anything is sent (median of 25 sends per launch, posted to a closed local port so the network isn't timed):
Body before this PR change
1 MB JSON, ASCII 2.60 ms 1.35 ms −48%
1 MB JSON, accented letters 7.95 ms 0.25 ms −97%

🤖 Generated with Claude Code

convertJSIStringToNSString read every JS string argument (including object
keys and string values inside objects and arrays) as UTF-8 and decoded it
again into an NSString. Copy the string's own ASCII or UTF-16 characters
with jsi::String::getStringData instead, and give JSCRuntime a native
getStringData that passes the JSString's UTF-16 characters directly.
Strings with a lone surrogate or a leading byte order mark, or that arrive
in several chunks, still go through UTF-8, so every string converts to the
same NSString as before.

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 23, 2026
@facebook-github-tools facebook-github-tools Bot added p: Software Mansion Partner: Software Mansion Partner labels Oct 23, 2026
@hryhoriiK97

Copy link
Copy Markdown
Contributor Author

@javache this is the other direction of #59325 — JS → native strings now skip UTF-8 too, the way you suggested there. Output is the same as before. Thanks for taking a look!

@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 23, 2026

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