Skip to content

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

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

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

Conversation

@hryhoriiK97

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. Short strings that CoreFoundation stores inside the pointer have no character storage, so they are copied into a heap-allocated std::u16string.

With this change.

  • Dictionaries read their keys and values at once with getObjects:andKeys:count:, and arrays with getObjects:range:. The objects are retained once, in a vector, while they are converted, because JS code that runs while properties are set could change the container. Below that, the conversion doesn't retain each value again.
  • Keys are created as property names directly, with jsi::PropNameID::forAscii or forUtf16.
  • Strings of up to 64 characters without character storage are copied to the stack instead of the heap. ASCII ones are created as ASCII.

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:

  • clang-format --dry-run --Werror on the changed file passes.
  • RNTester built with xcodebuild in Release and run on a physical iPhone 15 Pro (iOS 26.6.1).
  • Same output. A test payload converted with this change and with the current code, in the same build switched by a launch argument, gave byte-identical JS values, compared deeply in JS. The payload had:
    • empty, NUL, BOM, emoji, accented, CJK and lone-surrogate strings, and a mutable string;
    • booleans, integers of several sizes, floats, NaN and NSDecimalNumber;
    • NSNull, NSDate and NSURL values;
    • nested arrays and dictionaries.
  • Measurement. RNTester Playground calling NativeSampleTurboModule methods in a loop. The calls include the unchanged conversion of the arguments from JS. The two arms are the same binary with the conversion switched by a launch argument, in alternating order: 8 launches without and 4 with this change. 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.34 µs 8.43 µs −10%
getObject, nested object like a keyboard event 6.69 µs 6.00 µs −10%
getObject, 3 keys 2.79 µs 2.66 µs −5%
getValue (returns an object) 2.96 µs 2.79 µs −6%

The ranges of the two arms are separate for the first two rows and overlap for the last two. Calls that don't return objects are the same within noise.

🤖 Generated with Claude Code

hryhoriiK97 and others added 2 commits October 24, 2026 08:44
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 <noreply@anthropic.com>
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
@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

No visual differences at 618107cc5d73.

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.

Drop CallArgument and convert the arguments on the JS thread with the
TurboModule helper, like codegen's AsyncEventEmitter does. The helper
now turns number keys into strings and skips other keys that aren't
strings, as convertIdToFollyDynamic does, instead of treating every key
as an NSString.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@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.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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

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

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?

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.

hryhoriiK97 and others added 2 commits October 27, 2026 10:32
…omodule-faster-object-conversion

Keeps react#59449's key handling in convertNSDictionaryToJSIObject: number keys
become strings, and other keys that aren't strings are skipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>

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