Skip to content

Convert NSStrings returned to JS to UTF-8 only once - #59325

Closed
hryhoriiK97 wants to merge 2 commits into
react:mainfrom
hryhoriiK97:ios-turbomodule-string-conversion
Closed

hryhoriiK97 wants to merge 2 commits into
react:mainfrom
hryhoriiK97:ios-turbomodule-string-conversion

Conversation

@hryhoriiK97

@hryhoriiK97 hryhoriiK97 commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Summary:

On iOS, every NSString a TurboModule returns to JS goes through convertNSStringToJSIString (ReactCommon/react/nativemodule/core/platform/ios/ReactCommon/RCTTurboModule.mm). That covers promise results, callbacks, and every string key and value inside returned objects. It also covers the body of every fetch(...).text() and .json() response, which RN reads through FileReader.

Current behavior.

  • The function calls UTF8String twice, once to check it against nullptr and once for the value.
  • It copies the result into a temporary std::string for jsi::String::createFromUtf8, which measures it with strlen.
  • For any string that isn't stored as ASCII, such as one with accented letters or an emoji, each UTF8String call converts the whole string to UTF-8, and Hermes then converts it back to UTF-16. For a 1 MB JSON response with accented letters this takes about 7 ms on the JS thread.
  • Because the length comes from strlen, a string with a \0 character is cut off at it.
  • A string with a lone surrogate, which UTF8String can't encode, arrives as "".

With this change.

  • Strings stored as ASCII are passed as they are (CFStringGetCStringPtr → createFromAscii).
  • Other strings pass their own UTF-16 characters (CFStringGetCharactersPtr → createFromUtf16), which Hermes stores without converting.
  • If CoreFoundation can't expose either pointer, the characters are copied once with CFStringGetCharacters and passed as UTF-16.
  • JSCRuntime gets a native createStringFromUtf16 (JSStringCreateWithCharacters). Before, JSC used JSI's default, which escapes every character as \uXXXX and calls eval.

Every string converts to the same JS string as before, with two exceptions:

Changelog:

[IOS] [FIXED] - Strings returned from native modules to JS convert faster, are no longer truncated at a \0 character, and are no longer replaced with an empty string when they contain a lone surrogate

Test Plan:

  • RCTTurboModuleStringConversionTests (new, RNTesterUnitTests, run by yarn test-ios).
    • Each test converts strings through TurboModuleConvertUtils::convertObjCObjectToJSIValue with a Hermes runtime and checks that the JS string has the same UTF-16 code units.
    • Each test also checks which branch its input takes: ASCII pointer, UTF-16 pointer, or copy (a tagged-pointer string and an NSString subclass).
    • Inputs: ASCII, non-ASCII text (Polish, Japanese) and emoji, empty and large strings, strings with a \0 (including one with an ASCII pointer), and strings with a lone surrogate (with and without a UTF-16 pointer).
    • All 10 pass on an iPhone simulator, three runs in a row. With the old function, the \0 and both lone-surrogate tests fail and the other 7 pass.
  • The pinned clang-format (scripts/clang-format.js --check) passes on all three files.
  • JSC: a macOS program built with the system JavaScriptCore and this PR's JSCRuntime.cpp and jsi.cpp converts ASCII, Polish, CJK with emoji, empty, \0 and lone-surrogate strings with the new function and reads every character back in JS. All of them match. With the old function, the \0 and lone-surrogate strings don't. Speed on JSC, old function → new function, median of 9 rounds: accented letters and emoji −86% at 128 characters and −98% to −99% from 1 KB to 1 MB (1 MB: 4.1 ms → 26 µs); ASCII −24% to −33%.
  • Measurement on a physical iPhone 15 Pro (iOS 26.6.1) with Hermes, RNTester built with xcodebuild in Release, at sizes from 16 characters to 1 MB.
    • Inputs: JSON-like text in ASCII, with accented letters (such as ż, ó, é), and with emoji, created from UTF-8 data the way strings from the network or files arrive.
    • Conversion alone: the old function and this PR's function run in turn in the app on the phone, with a Hermes runtime, converting the same string in a loop. Each value is the median of 15 rounds, averaged over two launches; the two launches agree within 3 points. Both functions return the same JS string for every input.
    • readAsText in JS: the same binary with the conversion switched by a launch environment variable, in two cycles of before, after, after, before, following a warmup launch. Each value is the median of 500 reads (60 for 1 MB, first 2 left out as warmups), averaged over the four launches per arm. Every read returned the original string in both arms.

Conversion alone, time per string:

Size ASCII Accented letters Emoji
16 characters* 0.088 → 0.043 µs (−52%) 0.088 → 0.044 µs (−50%) 0.089 → 0.044 µs (−50%)
128 characters 0.11 → 0.04 µs (−64%) 0.93 → 0.07 µs (−92%) 0.92 → 0.07 µs (−92%)
1 KB 0.19 → 0.06 µs (−68%) 7.7 → 0.09 µs (−99%) 7.2 → 0.10 µs (−99%)
10 KB 0.92 → 0.20 µs (−78%) 72 → 0.55 µs (−99%) 67 → 0.65 µs (−99%)
100 KB 9.1 → 2.3 µs (−74%) 704 → 2.6 µs (−99.6%) 663 → 2.7 µs (−99.6%)
1 MB 86 → 20 µs (−77%) 7.18 ms → 47 µs (−99.3%) 6.67 ms → 41 µs (−99.4%)

* At 16 characters, the text from the start of the JSON is still ASCII in all three inputs.

FileReader.readAsText in JS (the path fetch's text() and json() take), median per read:

Size ASCII Accented letters Emoji
1 KB 79 µs → 78 µs 55 µs → 47 µs (−15%) 45 µs → 38 µs (−17%)
10 KB 37 µs → 36 µs 137 µs → 68 µs (−51%) 133 µs → 67 µs (−50%)
100 KB 65 µs → 59 µs (−9%) 1,010 µs → 334 µs (−67%) 979 µs → 331 µs (−66%)
1 MB 186 µs → 134 µs (−28%) 9.96 ms → 3.03 ms (−70%) 9.62 ms → 3.00 ms (−69%)

At 1 KB, a readAsText call is mostly the fixed cost of the module call and the promise, so the gain there is smaller. The 1 KB ASCII input is the first one read in each launch, so its time also includes warmup.

  • Real fetch calls. I also profiled RNTester (Release, before this change) with Time Profiler on the same iPhone while it fetched 10 KB to 1 MB JSON files from a local HTTP server over Wi-Fi with .text() and .json(). convertNSStringToJSIString was about 29% of all JS-thread time, the largest React Native cost on that thread, and Hermes converting that UTF-8 back to UTF-16 was another 12%.

🤖 Generated with Claude Code

`convertNSStringToJSIString` called `UTF8String` twice and copied the
result into a `std::string` to measure it. A non-ASCII string was
converted to UTF-8 twice, a `\0` cut the string off, and a lone
surrogate turned it into "".

Pass the string's own ASCII characters when CoreFoundation exposes them,
and otherwise convert it to UTF-8 once with `CFStringGetBytes` and pass
it with its length, so Hermes keeps a `\0` (JSC still stops at it).
Strings that UTF-8 can't encode (lone surrogates) are passed as UTF-16.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@hryhoriiK97

Copy link
Copy Markdown
Contributor Author

@christophpurrer From the git history, it looks like you've worked a lot on this file recently, so you seem like the right person to ask. This converts an NSString to UTF-8 once instead of twice.

Passing UTF-16 directly would be ~15× faster on Hermes, but JSC has no native createStringFromUtf16. JSI then falls back to escaping every character and calling eval, which is ~10× slower than today (see "Alternatives considered"). Would you prefer a Hermes-only UTF-16 path, or keep this one?

@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 21, 2026
@facebook-github-tools facebook-github-tools Bot added 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. labels Oct 21, 2026
}
// UTF-8 can't encode unpaired surrogates, so pass those strings as UTF-16.
std::u16string utf16(length, u'\0');
CFStringGetCharacters(string, CFRangeMake(0, length), reinterpret_cast<UniChar *>(utf16.data()));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CFStringGetCharactersPtr would still be preferred here, although also claim it may return null...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. It now tries CFStringGetCharactersPtr first and only copies when that returns null. Each branch has its own test.

@javache

javache commented Oct 22, 2026

Copy link
Copy Markdown
Contributor

It worth adding createStringFromUtf16 to JSC to avoid that having to fallback to eval.

Comment on lines +72 to +86
std::string utf8(CFStringGetMaximumSizeForEncoding(length, kCFStringEncodingUTF8), '\0');
CFIndex utf8Length = 0;
CFIndex convertedLength = CFStringGetBytes(
string,
CFRangeMake(0, length),
kCFStringEncodingUTF8,
0,
false,
reinterpret_cast<UInt8 *>(utf8.data()),
static_cast<CFIndex>(utf8.size()),
&utf8Length);
if (convertedLength == length) {
return jsi::String::createFromUtf8(
runtime, reinterpret_cast<const uint8_t *>(utf8.data()), static_cast<size_t>(utf8Length));
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this pass is worth it in Hermes - when giving it utf8 input, it will copy to utf16 anyway if it's not all ASCII https://github.com/facebook/hermes/blob/static_h/lib/VM/StringPrimitive.cpp#L169

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, removed it. It now follows the order you suggested.

@javache javache left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ordering that should we look, based on Hermes optimization

  1. CFStringGetCStringPtr(..., kCFStringEncodingASCII) → createFromAscii(pointer, length).
  2. Otherwise, CFStringGetCharactersPtr → createFromUtf16(pointer, length).
  3. If neither pointer is available, copy with CFStringGetCharacters into a UTF-16 buffer, then call createFromUtf16.

@javache

javache commented Oct 22, 2026

Copy link
Copy Markdown
Contributor

I wish we could do similar optimizations on Android too, but I think we're constrained there with folly::dynamic being the intermediate representation

…atively on JSC

Following review: Hermes converts non-ASCII UTF-8 input to UTF-16 anyway,
so converting to UTF-8 first only adds work. Strings stored as ASCII are
still passed with createFromAscii; other strings now pass their own UTF-16
characters (CFStringGetCharactersPtr), or one CFStringGetCharacters copy
when CoreFoundation can't expose them.

JSCRuntime only overrode the ASCII and UTF-8 string constructors, so
createFromUtf16 went through JSI's default, which escapes every character
and calls eval. It now uses JSStringCreateWithCharacters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@hryhoriiK97

hryhoriiK97 commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor Author

It worth adding createStringFromUtf16 to JSC to avoid that having to fallback to eval.

@javache added it to JSCRuntime in this PR with JSStringCreateWithCharacters. On the system JavaScriptCore, non-ASCII strings now convert 98–99% faster than before, so JSC gets the gain too 🚀

@hryhoriiK97
hryhoriiK97 requested a review from javache October 22, 2026 10:23
@hryhoriiK97

hryhoriiK97 commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor Author

Ordering that should we look, based on Hermes optimization

  1. CFStringGetCStringPtr(..., kCFStringEncodingASCII) → createFromAscii(pointer, length).
  2. Otherwise, CFStringGetCharactersPtr → createFromUtf16(pointer, length).
  3. If neither pointer is available, copy with CFStringGetCharacters into a UTF-16 buffer, then call createFromUtf16.

Thanks for the review @javache! Updated to the order you suggested, and added createStringFromUtf16 to JSCRuntime so JSC no longer falls back to eval. Non-ASCII strings now convert ~99% faster, new numbers are in the description.

@meta-codesync

meta-codesync Bot commented Oct 22, 2026

Copy link
Copy Markdown

@javache has imported this pull request. If you are a Meta employee, you can view this in D126905172.

@meta-codesync meta-codesync Bot closed this in 4fac9c8 Oct 23, 2026
@react-native-bot

Copy link
Copy Markdown
Collaborator

This pull request was successfully merged by @hryhoriiK97 in 4fac9c8

When will my fix make it into a release? | How to file a pick request?

@react-native-bot react-native-bot added the Merged This PR has been merged. label Oct 23, 2026
@meta-codesync

meta-codesync Bot commented Oct 23, 2026

Copy link
Copy Markdown

@javache merged this pull request in 4fac9c8.

meta-codesync Bot pushed a commit that referenced this pull request Oct 27, 2026
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 that starts with a byte order mark (U+FEFF) loses that mark, as decoding UTF-8 used to drop it. A string with a lone surrogate still goes through UTF-8, which replaces the surrogate 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

Pull Request resolved: #59422

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. Strings with a byte order mark (`"\uFEFFabc"`, `"\uFEFF"`, `"\uFEFF\uFEFFabc"`, `"a\uFEFFb"`, one with accented letters, one with a lone surrogate, and an object key) come back the same in both: one leading mark is dropped, a mark in the middle or a second one is kept.
- **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](https://github.com/software-mansion/argent) on the physical iPhone. `getString` also returns the string, which since #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](https://claude.com/claude-code)

Reviewed By: cipolleschi

Differential Revision: D127648419

Pulled By: javache

fbshipit-source-id: bfc769b185414927e8a56a87ecafd38ed9896d39
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. Merged This PR has been merged. 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.

3 participants