Skip to content

Convert iOS native module objects to JS with fewer lookups and retains - #59450

Open
hryhoriiK97 wants to merge 2 commits into
react:mainfrom
hryhoriiK97:ios-turbomodule-faster-object-conversion
Open

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

Conversation

@hryhoriiK97

@hryhoriiK97 hryhoriiK97 commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Summary:

TurboModuleConvertUtils::convertObjCObjectToJSIValue converts native values to JS for TurboModule return values, promise results, callback arguments and codegen event emitters.

Current behavior. For an NSDictionary, it enumerates the keys and then looks up each value again with value[k]. Each key becomes a jsi::String, which the runtime then turns into a property name. Each nested value is retained and released at every level of the recursion.

With this change.

  • Dictionaries read their keys and values at once with getObjects:andKeys:count:, and arrays with getObjects:range:. The objects aren't retained again: the container holds them while they are converted.
  • Keys are created as property names directly, with jsi::PropNameID::forAscii or forUtf16.

JS receives the same values as before.

Changelog:

[IOS] [CHANGED] - Objects returned from native modules are converted to JS with fewer lookups and retains

Test Plan:

  • RNTester built with xcodebuild in Release and run on a physical iPhone 15 Pro (iOS 26.6.1). The measured build had this change on top of Convert iOS native module event arguments to JS without UTF-8 #59449, so both arms below include Convert iOS native module event arguments to JS without UTF-8 #59449's key handling.
  • Same output. A payload built natively was converted with this change and with the current code, in the same build switched at runtime, and gave identical JS values, compared in JS down to UTF-16 code units. The payload had:
    • empty, NUL, BOM, emoji, accented, CJK and lone-surrogate strings, a mutable string, and strings of 64, 65 and 500 characters;
    • booleans, integers of several sizes, floats, -0, NaN, infinity and NSDecimalNumber;
    • NSNull, NSDate and NSURL values;
    • string, number, boolean and NSNull keys;
    • nested and empty arrays and dictionaries, and ones with 40 entries.
  • Measurement. RNTester Playground calling NativeSampleTurboModule methods in a loop: 5 rounds of 20,000 calls per method on each launch. The calls include the unchanged conversion of the arguments from JS. The arms are the same binary with the conversion switched by a launch argument, 4 launches each, in rotating order. The presses were driven by Argent on the physical iPhone. iPhone 15 Pro, Release, medians per call:
Call before this PR change
getObject, 20 keys 9.57 µs 8.56 µs −10%
getObject, nested object like a keyboard event 6.84 µs 6.09 µs −11%
getObject, 3 keys 2.80 µs 2.62 µs −7%
getValue (returns an object) 3.07 µs 2.82 µs −8%

The ranges of the two arms are separate for the first two rows. For the last two they overlap only because of one launch of the current code. A version that also copied short strings to the stack was within 1% of this one on every row, so it isn't included.

  • Not run: the repository's pinned clang-format, which needs dotslash.

🤖 Generated with Claude Code

TurboModule return values, promise results, callback arguments and codegen
events are converted by convertObjCObjectToJSIValue. Dictionaries now read
their keys and values at once instead of looking up each key again, keys
are created as property names directly, nested values are retained once
per container instead of once per call, and short strings without character
storage are copied to the stack instead of the heap.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@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
@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

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

Visual regression

No visual differences at da67a6cd4441.

android: no capture

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

Let's coordinate with #59449 and extract to a shared helper first, and then optimize that.

@hryhoriiK97

Copy link
Copy Markdown
Contributor Author

Let's coordinate with #59449 and extract to a shared helper first, and then optimize that.

@javache makes sense! #59449 now uses convertObjCObjectToJSIValue for events and is ready for review. I'll update this PR after #59449 is merged, rebasing it on top so the optimization applies to the shared helper.

Comment on lines +118 to +121
static jsi::String convertNSStringToJSIString(jsi::Runtime &runtime, NSString *value)
{
return convertNSStringToJSI<jsi::String>(runtime, value);
}

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.

Call the templated helper directly in callers of convertNSStringToJSIString

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, removed the wrapper - da67a6c

Comment on lines +132 to +133
__unsafe_unretained id stackObjects[kStackCount];
std::vector<__unsafe_unretained id> heapObjects;

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.

The stack optimization isn't helpful here, since we end having to copy it in the return value anyway.

Just use heapObjects everywhere and return std::move() it

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

Comment on lines +101 to +112
if (length <= kStackLength) {
UInt8 ascii[kStackLength];
CFIndex asciiLength = 0;
if (CFStringGetBytes(
string, CFRangeMake(0, length), kCFStringEncodingASCII, 0, false, ascii, length, &asciiLength) == length) {
// NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast)
return fromAscii(reinterpret_cast<const char *>(ascii), static_cast<size_t>(asciiLength));
}
UniChar characters[kStackLength];
CFStringGetCharacters(string, CFRangeMake(0, length), characters);
return fromUtf16(characters, static_cast<size_t>(length));
}

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.

Was this path observed to be making things more efficient?

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.

Not on its own, so I measured it now: putting it back on top of the reworked version is within ±1% on every benchmark (20-key, nested, 3-key objects), even though those keys are short tagged-pointer strings. So I removed it - da67a6c

jsi::Array result = jsi::Array(runtime, value.count);
for (size_t i = 0; i < value.count; i++) {
result.setValueAtIndex(runtime, i, convertObjCObjectToJSIValue(runtime, value[i]));
std::vector<id> objects = getRetainedObjects(value);

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.

Why do we need to retain them? The NSArray who the caller is holding on to is already doing so.

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.

I was worried about JS changing the container while properties are set, but that would need a setter on Object.prototype. It's unretained now - da67a6c

- Call convertNSStringToJSI<jsi::String> directly instead of
  convertNSStringToJSIString.
- Read a container's objects into one vector and return it.
- Don't retain the objects: the container holds them while they are
  converted.
- Drop the stack copy of short strings. With it back, the benchmarks
  were within 1%, so it didn't help.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@hryhoriiK97
hryhoriiK97 force-pushed the ios-turbomodule-faster-object-conversion branch from 08a67d0 to da67a6c Compare October 27, 2026 09:37
@hryhoriiK97
hryhoriiK97 requested a review from javache October 27, 2026 09:39
@meta-codesync

meta-codesync Bot commented Oct 27, 2026

Copy link
Copy Markdown

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

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.

2 participants