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.

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