refactor: organize direct FFI backends - #43
Open
DjDeveloperr wants to merge 87 commits into
Open
DjDeveloperr wants to merge 87 commits into
DjDeveloperr wants to merge 87 commits into
Conversation
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Advanced Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Multi-candidate Resources resolution for resolveMainPath() (bundle resourcePath, executable-relative Contents/Resources, argv[0], _NSGetExecutablePath, cwd) so the CLI/test-runner processes that don't run from a standard .app bundle can still find app/index.js or a package.json "main", gated behind NS_BUNDLE_LOADER_DEBUG logging. NativeScript.mm: runMainApplication now tries resolveMainPath() before falling back to "./app/index.js". Switch runtime_ from unique_ptr to a raw pointer with an explicit resetRuntime() teardown point: at process exit, static-destruction order relative to the ObjC runtime is unspecified, so an implicit unique_ptr destructor can run after dependencies it needs are already gone; restartWithConfig: also needs the old runtime to outlive the new one's Init(). ThreadSafeFunction.mm: turn the global cleanup-hook mutex/condvar/map into leaked-singleton accessors (heap-allocated, never destructed) for the same static-destruction-order reason. ci.yml: enable IOS_TEST_VERBOSE_SPECS for per-spec start/done logging. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Object expandos (setObjectExpando/findObjectExpando/forgetObjectExpandos) gain a per-runtime key: a worklet spins up an additional Runtime on its own thread against the same shared bridge, so a Value created in one Runtime must never leak into another. Storage becomes native-pointer -> property -> owning-runtime, all under one objectExpandosMutex_ (also now guarding the existing objectExpandoOwnerCounts_ refcounts, since a host-object dtor can release its owner count from either thread relative to a get/set). runtimeObjectExpandoKey() derives the per-runtime identity: the JSI-facing engines (V8/JSC/QuickJS) key on runtime.state().get(), Hermes keys on the Runtime& address directly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… no aggregate globals NativeApiBackendConfig gains callbackInvocationAllowed (teardown-safety gate for RN) and indexRuntimePointers (default true; RN sets false). NativeApiBridge::addSymbol() only eagerly resolves objc_lookUpClass / protocol pointers when indexRuntimePointers_ is set — RN launch cost: don't realize every class/protocol at symbol-index time when RN never touches most of them at startup. Callbacks.mm invoke() now checks bridge_->callbackInvocationAllowed() before running the callback and zero-returns instead when the host is tearing down or reloading. NativeApiJsiReactNative.h: RN config sets installGlobalSymbols=false (unchanged behavior) and now also indexRuntimePointers=false. Install.mm's else-branch drops the InstallAggregateGlobals call for RN — unused, and building it eagerly cost launch time. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ointer guards interop.setAssociatedObject/getAssociatedObject: the sanctioned way to persist state on a native UIKit-backed object across engine calls. JS expandos on a host-object wrapper do not round-trip (a fresh wrapper can be handed back for the same native receiver on the next call); a real objc_setAssociatedObject does, because it lives on the native object itself. Target accepts a live wrapped object/pointer or the decimal text of a raw address. convertNativeReturnValue: an id-typed return that is actually a Class now resolves through the class-symbol path (by runtime pointer, then runtime class, then bare class_getName) instead of falling into makeNativeObjectValue. nativeObjectPointerMayBeObject (`raw > 0x1000`) guards every id-typed return path (nativeObjectIsStringLike, findCachedNativeObjectReturn, convertNativeReturnValue) against dereferencing a misread register value — without it, a non-object primitive read back as `id` can crash on object_getClass/isKindOfClass:. Primitive type-alias table: long/ulong/NSInteger/NSUInteger (mdTypeSLong/ mdTypeULong), BOOL/CGFloat (platform width)/NSTimeInterval/CFTimeInterval, so signatures can use the platform typedef names instead of only the fixed-width primitives. packages/objc-node-api/index.d.ts: types for the associated-object API. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The ClassBuilder ("extend()"/native-subclass) surface's identity and
dispatch primitives:
- Object.mm: NativeApiObjectHostObject gains a superDispatchClass_ (set at
construction or via setSuperDispatchClass), used to answer `this.super`
correctly after a wrapper has been re-seated (see below) instead of always
recomputing the receiver's immediate runtime superclass.
detachObjectPreservingBridgeState() disowns a wrapper WITHOUT forgetting
its round-trip value or dropping its expandos — used when an initializer
returns the same receiver a second, divergent wrapper had already claimed.
get()'s engine-extended branch now falls through, for inherited METHODS
only (accessors stay deferred to avoid re-entrant shadowing), to metadata
method resolution via the nearest metadata ancestor, so a first access to
an inherited (non-overridden) selector on a JS subclass resolves instead of
hard-returning undefined. set() hoists the JS-accessor-setter attempt
above the metadata/runtime setter paths (an accessor override must win)
and, in the no-JS-setter fallback, stores the expando unconditionally
(dropped enginePrototypeHasSetter — reaching that branch already proves no
JS setter fired, so re-probing for one was redundant).
- classPrototypeForObject gains a symbol-name fallback (classes only known
by symbol, not yet indexed by runtime pointer with indexRuntimePointers
off).
- Class.mm: makeNativeObjectValue takes an optional superDispatchClass,
threaded onto both the fresh-wrapper and cached-wrapper paths.
- Callbacks.mm: a per-callback NativeApiMethodCallbackPolicy (trimmed to the
subset with a live consumer: callSuperBeforeCallback +
skipCallbackIfAssociatedObjectTruthy, read off a JS function's
`__nativeScriptMethodPolicy` expando via NativeScriptRuntime.nativeMethodPolicy).
invokeMethodSuper() calls the ObjC super implementation via
objc_msgSendSuper before the JS override runs when the policy asks for it.
shouldSkipConstructingMethodCallback suppresses a non-init method callback
reaching a receiver still marked under construction. bindThis_ callbacks'
`this` now carries the override's superDispatchClass too.
- ClassBuilder.mm: preservedNativeApiInitializerSelfReturn detects an
initializer returning the same receiver a wrapper was already created for
and keeps that one wrapper live (detaching the divergent duplicate) rather
than letting two wrappers fight over the same native receiver's bridge
state. callNativeApiBaseObjectSelector wraps $base/super dispatch with
this handling. nativeAccessorCallbackPolicy auto-applies a re-entrancy
guard key to every native accessor (getter/setter) override.
- HostObject.mm: __setObjectConstructionState / __setObjectAccessorCallbackState
native entry points backing the above (associated objects, not JS
expandos — expandos don't round-trip across proxy instances for the same
native receiver).
- Install.mm (JS bootstrap): alloc/init construction marks/unmarks
construction state around JS-subclass instantiation;
installInstanceClassIdentity gives extended prototypes a `class`/
`superclass` identity that resolves to the actual (possibly further
subclassed) constructor; indexed-collection method aliases
(objectAtIndexedSubscript/setObjectAtIndexedSubscript/Symbol.iterator) for
extend()ed NSFastEnumeration-like classes, with accessor callback-state
wrapping folded into the same helper.
- V8HostObjects.mm: the masking (kNone) host-object interceptor's get/set
now check the real V8 prototype chain first (findPrototypeDescriptor/
tryResolvePrototypeGet/tryInvokePrototypeSetter) so a JS-defined prototype
accessor is honored ahead of the interceptor.
- Per-engine (hermes/jsc/quickjs/v8) selector-group call sites: after a
prepared instance-initializer selector call, apply
preservedNativeApiInitializerSelfReturn to the result.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
[SomeView appearance] (and appearanceWhenContainedIn: etc.) hands back an opaque _UIAppearance proxy: UIKit forwards recognized selectors to an internal invocation-recording store instead of actually running them, and there's no public way to ask "what class are you a proxy for" besides parsing `-description`'s `<Customizable class: ClassName>` format. New host_objects/Appearance.mm holds the primitives built on that: parse the description once, tag the recovered class onto the proxy as an expando, then read/write a class-keyed (not proxy-instance-keyed — UIAppearance state is effectively global per class/containment chain) property cache so get() sees what a prior set() wrote instead of round-tripping through UIKit's opaque recording. Setters cache too, since an appearance proxy setter doesn't reliably support read-your-write. Wired in everywhere a UIAppearance proxy's properties can be read or written: - host_objects/Object.mm get()/set(): consult/populate the appearance cache before falling through to metadata/runtime property resolution. tagStaticAppearanceSelectorResult (needs the complete NativeApiObjectHostObject type) stays here and tags+installs accessors on the result of any `[SomeClass appearance...]`-family call. - host_objects/Class.mm: intercepts the `appearance` static method itself so its result gets tagged/accessor-installed rather than staying a plain callable selector-group function. - host_objects/Protocol.mm: the same cache read/write for protocol-declared properties. - Invocation.mm: callPreparedObjCSelector/callObjCSelector tag every fast-path and generic-tail result, and cache every property-setter call (NativeApiPreparedObjCInvocation gains propertySetterName so a successful setter call can cache without re-deriving the property name). callObjCSelector also allows a forwarded property selector through when the receiver is a tagged appearance proxy (class_getInstanceMethod/ respondsToSelector: can both say no for a selector UIKit will still forward). - SelectorGroupCall.h: the shared resolveNativeApiSelectorGroupCall() short-circuits a property-getter call through the appearance cache before ever touching ObjC, and gains a gsdAllowed field so appearance static selectors are excluded from every engine's raw-GSD fast path (which bypasses proxy tagging). - Per-engine (hermes/jsc/quickjs/v8) GSD/fast-path tails: cache a successful setter call's value and tag/re-tag the result, mirroring the generic path. Also brings in the runtimeReadablePropertyGetter cache (simplified to a single mutex-guarded (Class, property) -> selector map, no thread-local front cache) and objectGetPathCanReadRuntimeProperty, both prerequisites for the appearance-adjacent set() success-path expando write (fixes a set-then-get asymmetry for write-only/asymmetrically-named runtime properties) and reused by get()'s inherited-method resolution added in the previous commit. classPrototypeForObject's symbol-name fallback (needed when a class isn't yet runtime-pointer-indexed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ssing A bound selector-group method function (e.g. `view.viewWithTag`) is cached as a native-object expando keyed by the underlying ObjC pointer (Object.mm's `bridge_->setObjectExpando(..., methodFunction)`), so the cache survives independently of the `NativeApiObjectHostObject` wrapper it was bound to. Once that original wrapper is torn down (its owning JS proxy collected) and the SAME native pointer is later re-wrapped by a fresh `NativeApiObjectHostObject` on another crossing, the stale cached function still resolves its receiver via the dead wrapper's weak/lifetime state -- `data.boundReceiverState->object()` (SelectorGroupCall.h) and `state.boundReceiver.lock()` (NativeApiJsi.mm) both silently return nil -- so every call through it threw "Objective-C selector requires a native receiver" even though the method is being invoked on a live object. Reproduced 100% of the time on cold launch of every itest scenario (including plain `nav-stack`, previously 12/12 clean), isolated away from the react-native-screens adapter and the simulator via: (1) fresh never-booted simulator device still crashed, (2) causally disabling the adapter's only recent change did not stop it, (3) an attached lldb session showed `state.boundReceiver` / `data.boundReceiverState` resolving a dead weak_ptr (strong=0) at the exact throw site. Fix: when the bound receiver has died, fall back to resolving from the call's actual `thisValue` (the live receiver `.method(...)` was invoked on) instead of throwing -- exactly what the unbound path already does. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E6M4WHJVVjazd1RQhi9aSb
taggedAppearanceProxyClass's untagged fallback (appearanceProxyCustomizable- ClassFromExactDescription) sends a real Objective-C `-description` message to ANY object on its first property read, as a heuristic to detect UIAppearance proxies. That runs unconditionally inside NativeApiObjectHostObject::get(), so it fires for every property access on every native object, including objects handed to JS reentrantly as callback arguments while native code's own machinery is still on the stack. Root-caused via os_log breadcrumbs bracketing the sheet-detents-custom spike end to end (both in-app and inside the interop bridge itself): the customDetentWithIdentifierResolver resolver block was invoked correctly, and returning a bare CGFloat constant from it always worked -- the block's own return-value marshalling was never broken. The hang was reading ctx.maximumDetentValue: the first property access on the live, UIKit-owned UISheetPresentationControllerDetentResolutionContext object triggers this generic appearance-proxy check, which calls -description on it -- and that deadlocks inside UIKit's own detent-resolution machinery, which is still running on the same call stack. Fix: skip the -description fallback when gNativeCallerThreadEngineCallback- Depth > 0 (already used elsewhere in this file's own call chain to detect exactly this situation). A real UIAppearance proxy is only ever obtained by JS calling an `+appearance`-family method itself -- an outbound call this engine makes, never something delivered inbound as a callback argument -- so the guard never regresses genuine appearance-proxy detection; it only disables an unsafe heuristic for objects that were never appearance proxies to begin with. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
findClassForRuntimeClass only ever consulted the concrete class hierarchy (class_getSuperclass), so a selector declared solely on a conformed Objective-C protocol had no path to its metadata whenever the class that actually implements it doesn't carry that conformance in a header the metadata generator parsed -- a category/class-extension conformance, or (as with UIViewControllerTransitionCoordinator) a fully private concrete class. createEngineCallback then threw "Native callback metadata is unavailable." for any block parameter on such a method, forcing callers to hand-supply the ObjC encoding via interop.Block(fn, "..."). Adds a runtime protocol-conformance fallback (class_copyProtocolList + protocol_copyProtocolList, walked alongside the existing class walk), consulted only once the ordinary class/protocol-declared-on-header lookup misses, and cached per runtime Class so well-declared classes pay nothing extra. Deterministic tie-break for a class conforming to several protocols declaring the same selector: most-derived class first, then ancestors; within one class's own adopted-protocol list, class_copyProtocolList's order; each protocol's inherited protocols expanded depth-first ahead of its next sibling. First match wins. Covered by two new fixtures/tests mirroring the real-world shape (hidden vs. declared protocol conformance) plus the existing 717-test macOS suite (unchanged 8 pre-existing DBL_MAX failures, no new ones). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BQPGUU8YECUhbBvwkW4Kek
NAPI_GUARD now logs the failing call, NAPI_CALLBACK_BEGIN_VARGS_FAST is added, and napi_runtime becomes jsr_ns_runtime across every JSR implementation. (cherry picked from commit a09460e)
…adapter The vendored trees become submodules with local changes carried as patches, and the adapter propagates every napi_status and stops clobbering pending errors. (cherry picked from commit c3d6419)
Replaces the adapter with the upstream-shaped one, fixes the napi_post_finalizer argument order, and drains queued jobs only once the stack unwinds. (cherry picked from commit e77e7b1)
Moves to libJavaScriptCore.so, handles symbol-keyed properties, and installs the unhandled promise rejection tracker. (cherry picked from commit ff97a6d)
Replaces the previous implementation with the one from the updates branch, and carries the accompanying inspector changes. (cherry picked from commit 65413f8)
(cherry picked from commit 6dd09a3)
(cherry picked from commit 11a71e0)
…ple and Android (cherry picked from commit a94ced2)
(cherry picked from commit 0d2b946)
…K r29 (cherry picked from commit 53135f4)
(cherry picked from commit 3f5c673)
(cherry picked from commit 2f20a95)
(cherry picked from commit 957f1f3)
(cherry picked from commit d4e2f34)
(cherry picked from commit 130c728)
…ess specs (cherry picked from commit eff805d)
(cherry picked from commit 0fec65b)
(cherry picked from commit 1c68fc5)
…le build (cherry picked from commit 6cc2d23)
(cherry picked from commit daad6082818715b18bca7f01f24681cb5db8473c)
(cherry picked from commit 516f3220a7a0d26fb6c0520c28d5a84fb44fd289)
(cherry picked from commit c92f338dab01e793049f22a3ee5b0c46ba54e830)
First step of the Apple-side extraction (branch 2a). Introduces
NativeScript/jsi as the platform-neutral home for the engine binding layer that
Android will also compile, and moves in the pieces that are already neutral.
Moved verbatim:
ffi/objc/shared/NativeApiBackendConfig.h -> jsi/shared/
ffi/objc/shared/Tasks.{h,cpp} -> jsi/shared/
SignatureDispatchCore.h could not simply move: it is only half neutral. The
mechanism (FNV-1a hashing, SignatureCallKind, the prepared-invoker and
dispatch-entry types, the sorted-table lookup, the NS_DISABLE_GSD switch) is
common, but the rest walks the Objective-C metadata through
metagen::MDMetadataReader and metagen::MDTypeKind. Android's metadata is a
different binary format, so that half stays under ffi/objc. The neutral half is
now jsi/shared/SignatureHashing.h, which SignatureDispatchCore.h includes.
Every consumer includes these through their full "ffi/objc/shared/..." path, so
forwarding headers are left behind and no Apple include or source file changes.
The only other Apple change is the CMake path for Tasks.cpp.
Adds scripts/check_jsi_layer_neutral.sh, which fails if Objective-C or a .mm
file appears under NativeScript/jsi. This is not paranoia: `#import
<Foundation/Foundation.h>` compiles fine under -x c++ on macOS, so a green Apple
build does not prove the layer is Android-compilable, and the regression would
only surface much later on the Android side.
Verified: iOS builds and reports 713 specs / 0 failures, unchanged from the
parent branch; the shared header also compiles standalone with
`clang++ -x c++ -std=c++20`. No Android file is touched by this branch.
(cherry picked from commit 9d62ca31c1ea543eb647325c9b8b6655dc44a749)
First engine through the shared-layer split, chosen as the pilot because it needed the least surgery: its contract header had two Objective-C references and its three implementation files had none. The `nativescript::engine` classes wrapping QuickJS -- Runtime, Value, Object, Function, Array, String, BigInt, ArrayBuffer and the host-object plumbing -- move to jsi/quickjs/ and become .cpp. They were already plain C++; the .mm extension was incidental, inherited from living under ffi/objc. The Apple bridge's baggage stays behind in ffi/objc/quickjs/, which now holds Foundation, <objc/*>, the Mach-O metadata section lookup, the metadata reader and the NativeApiClassBuilderProtocol forward declaration, then includes the neutral header. Apple sources include the same path as before, so no Apple include changed. The header's other platform includes (dispatch, dlfcn, ffi) turned out to be vestigial -- nothing in the header body used them. Proof this is genuinely neutral, rather than merely compiling on a Mac: the moved header passes -fsyntax-only as plain C++ with no Foundation in reach. That matters because #import <Foundation/Foundation.h> compiles fine under -x c++ on macOS, so a green Apple build alone would prove nothing. Verified: iOS TestRunner on QuickJS, 713 specs, 0 failures. check_jsi_layer_neutral.sh passes. No Android file touched. (cherry picked from commit cf98d6ff6875ad83d5c54c177ed5bda94531cfd5)
Same split as QuickJS: the `nativescript::engine` classes wrapping V8 move to jsi/v8/ as .cpp, the Apple bridge's Foundation/objc/Mach-O/metadata preamble stays behind in a header at the original path that includes the neutral one. No Apple include changed. V8 is the first engine whose RuntimeState was not already neutral. Its argument-marshalling caches -- which memoise "this JS value denotes that native type / method" -- stored Class and SEL directly. They now hold const void*, with the casts moved into NativeApiV8Marshalling.mm where the rest of the Objective-C already lives. That is a generalisation rather than a workaround: the Android runtime needs exactly the same cache holding jclass and jmethodID, and all four of these are pointers. Making the slot opaque is what lets one cache serve both bridges. Verified: iOS TestRunner on V8, 713 specs, 0 failures. The neutral header also passes -fsyntax-only as plain C++ with no Foundation in reach. (cherry picked from commit d40017efec63ed0dc8b5384b30663a8e9a81effb)
Same split as the other two engines: engine layer to jsi/jsc/ as .cpp, Apple preamble left in a forwarding header at the original path. No Apple include changed. JSC's one genuine tie to Foundation was makeJSString, which built a JSString by routing the UTF-8 through NSString to get UTF-16. Replaced with a strict UTF-8 decoder in plain C++. JSStringCreateWithUTF8CString is not a substitute on its own, which is presumably why the NSString path existed: it takes a NUL-terminated C string, so it silently truncates at an embedded U+0000 -- a legal JavaScript character. The decoder preserves the old behaviour exactly, including falling back to JSStringCreateWithUTF8CString on malformed input, which is what the previous code did when NSString returned nil. The decoder was unit-tested standalone before being wired in: 16 cases covering ASCII, embedded NUL, 2/3/4-byte sequences, surrogate-pair splitting, U+10FFFF, and the rejection paths (lone continuation, truncation, overlong forms, directly-encoded surrogates, out-of-range, invalid lead bytes). Verified: iOS TestRunner on JSC, 713 specs, 1 failure -- byte-identical to the pre-move run, same spec (ApiTests SpecialCaseProperty_When_CustomSelector_ ImplementedInJS), which fails on this branch's parent too. (cherry picked from commit 8a1221e6cab46319bd1ea73f4aa7b7e70d8542ed)
Hermes is structurally unlike the other three engines: it exposes the real
facebook::jsi API instead of reimplementing the nativescript::engine shape, so
there is no engine layer to extract. Its public header was already neutral.
What moves is therefore small.
jsi/hermes/NativeApiJsi.h - the install/create declaration. Android's
Hermes also exposes facebook::jsi, so this
is shareable verbatim.
jsi/shared/PreparedSignatureDispatch.h - the GSD lookup side.
The second one completes the split started in the first commit of this branch.
Given a dispatch id, this header finds the generated trampoline; it needs only
the hashing/lookup core, never the metadata walk that computes the ids. So the
line falls in the same place as before: mechanism shared, metadata traversal
per-platform. Its include changed from SignatureDispatchCore.h to
SignatureHashing.h, which is what it actually used.
Forwarding headers at both old paths, so no Apple include changed. The
Apple-side PreparedSignatureDispatch.h forwarder still pulls in
SignatureDispatchCore.h first, because its callers have always had that
transitively.
Verified: iOS TestRunner on QuickJS -- which includes the moved GSD header --
713 specs, 0 failures. The Hermes target compiles, links, and launches.
Its suite cannot be run: Apple Hermes hangs after ~10 specs. That is
pre-existing and unrelated to this branch -- a build at 15e5418, the commit
before the Android-updates work began, hangs identically. Worth fixing, but as
its own piece of work.
(cherry picked from commit 66a26288a8490c59cd31b9d4afa6390b54810f64)
check_ffi_boundaries.sh forbade `#include "jsi/` in every non-Hermes backend. That was a sound proxy when the only thing a quoted jsi/ path could mean was facebook::jsi -- but NativeScript/jsi is now our own platform-neutral engine layer, which every backend is supposed to include. The rule started failing on exactly the includes this branch is built around. Narrow it to `#include "jsi/hermes/`, which preserves the actual intent: facebook::jsi and the Hermes contract header stay Hermes-only, while the shared engine layer is open to all. The facebook::jsi, <jsi/ and NativeApiJsi markers are untouched. The ffi/jni/napi rule deliberately keeps the blanket form: that backend is the Node-API one and must not reach into the engine layer at all. The JSI-based JNI backend will live in ffi/jni/jsi. Also wire check_jsi_layer_neutral.sh into package.json as check:jsi-neutral, so it runs wherever check:ffi-boundaries does. It is the check that catches an Objective-C import landing in the shared tree -- which a green Apple build never will, since #import compiles fine under -x c++ on macOS. (cherry picked from commit 726c96f10c3532adebb8e58769c54f333b92cfec)
…ctor Android Runtime Updates and One Shared Engine Layer
Shared JSI Layer
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
NativeScript/ffiso Hermes owns the public JSI entrypoint, direct-engine bridge internals live underffi/direct, and shared code is named for what it actually shares.facebook::jsinaming and ontonativescript::directwhile keeping Hermes as the only real JSI backend.*Gsd.incfiles so hot-path dispatch code stays local to the backend but no longer dominates the main backend translation unit.react-native-workletsintegration that installs the NativeScript Native API into the Worklets UI runtime throughWorkletRuntimeHolderNativeState when the Worklets headers are present, while preserving the default no-Worklets TurboModule path.Validation
./scripts/check_ffi_boundaries.shgit diff --checknode packages/react-native/test/config-plugin.test.jsnode packages/react-native/test/cli.test.js./scripts/build_react_native_turbomodule.sh-> package tarball includesnative-api/ffi/hermes/NativeApiJsiGsd.incand the optional RNWorklets header-search path./scripts/test_react_native_turbomodule.sh-> RN 0.85.3 Release simulator smoke passed with markerNATIVESCRIPT_RN_TURBO_SMOKE_PASS;installed: true,backend: "hermes",nativeCallsRanOnMainThread: trueWorklets positive smoke: generated RN 0.85.3 app with
react-native-worklets@0.9.1andreact-native-worklets/plugin; Release simulator build passed with markerNATIVESCRIPT_RN_WORKLETS_SMOKE_PASS;workletsInstalled: true,backend: "hermes",nativeCallsRanOnMainThread: falseBackend GSD split compile check:
./scripts/build_nativescript.sh --<engine> --no-sim --no-iphone --macos --no-catalyst --no-xrfor Hermes, V8, JSC, and QuickJS./scripts/build_metadata_generator.shBUILD_SIMULATOR=false BUILD_IPHONE=false BUILD_MACOS=true BUILD_VISION=false BUILD_CATALYST=false ./scripts/build_nativescript.sh --macos --no-iphone --no-simulator --jsc --ffi-direct --gsd-jscBUILD_SIMULATOR=false BUILD_IPHONE=false BUILD_MACOS=true BUILD_VISION=false BUILD_CATALYST=false ./scripts/build_nativescript.sh --macos --no-iphone --no-simulator --quickjs --ffi-direct --gsd-quickjsBUILD_SIMULATOR=false BUILD_IPHONE=false BUILD_MACOS=true BUILD_VISION=false BUILD_CATALYST=false ./scripts/build_nativescript.sh --macos --no-iphone --no-simulator --v8 --ffi-direct --gsd-v8MACOS_TEST_ENGINE=hermes MACOS_TEST_FFI_BACKEND=direct MACOS_TEST_GSD_BACKEND=hermes ... node scripts/run-tests-macos.js build/test-results/macos-hermes-gsd-on-junit.xml-> 713 specs, 0 failures, 8 skippedMACOS_TEST_ENGINE=hermes MACOS_TEST_FFI_BACKEND=direct MACOS_TEST_GSD_BACKEND=none ... node scripts/run-tests-macos.js build/test-results/macos-hermes-gsd-off-junit.xml-> 713 specs, 0 failures, 8 skipped./scripts/build_react_native_turbomodule.sh --no-packnode benchmarks/objc-dispatch/run.js --runtime napi-node --iterations 250000 --include-gsd-offnode benchmarks/objc-dispatch/run.js --runtime ios-package --package-tgz packages/ios-{v8,jsc,quickjs,hermes}/dist/*.tgz --variant-label <engine> --iterations 250000 --include-gsd-offRN Hermes JSI relaunch benchmark, 3 GSD-on launches and 3 GSD-off launches -> 486/489 passed, 0 failed on every launch
NO_UPDATE_VERSION=1 IOS_VARIANT=ios-<engine> NPM_PACKAGE_NAME=@nativescript/ios-<engine>-napi-bench NPM_PACKAGE_VERSION=0.0.0-napi-bench NPM_PACK_DESTINATION=/tmp/nsr-napi-engine-packages ./scripts/build_all_ios.sh --<engine> --ffi-napi --gsd-napi --no-iphone --simulator --no-macosfor V8/JSC/QuickJS/Hermes generic Node-API packagesnode benchmarks/objc-dispatch/run.js --runtime ios-package --package-tgz /tmp/nsr-napi-engine-packages/nativescript-ios-<engine>-napi-bench-0.0.0-napi-bench.tgz --variant-label "<engine> generic Node-API" --iterations 250000 --include-gsd-offfor V8/JSC/QuickJS/HermesThe metadata generation steps still print existing SDK/private-header diagnostics, but the commands exit successfully.
Benchmarks
Lower is better.
GSD effectisGSD-off / GSD-on - 1, so positive means generated signature dispatch is faster. Objective-C dispatch benchmarks used 250k base iterations.napi-nodeis one measured instance of the generic Node-API FFI backend running through the macOS Node runtime; the same generic backend was also measured inside the Node-API surface exposed by the V8, JSC, QuickJS, and Hermes iOS engine packages. Direct backend rows measure the PR's engine-native FFI paths.Objective-C Dispatch Totals
Environment: local Apple Silicon Mac. iOS package apps ran on iPhone 16 Pro iOS 18.5 Simulator. Generic Node-API engine packages were temporary benchmark tarballs built with
--ffi-napi --gsd-napi; direct engine packages used the PR package tarballs.Engine-Native Direct Backends
Generic Node-API Backend
Direct vs Generic Node-API
Objective-C Dispatch Cases: Engine-Native Direct
V8
JSC
QuickJS
Hermes
Objective-C Dispatch Cases: Generic Node-API
The
macOS nodetable is the generic Node-API backend running under Node. The engine tables are the same generic FFI backend built into each iOS engine package viaNS_FFI_BACKEND=napi.macOS node
V8 iOS package
JSC iOS package
QuickJS iOS package
Hermes iOS package
React Native TurboModule FFI
Environment: RN Hermes JSI app, iPhone 17 Pro iOS 26.5 Simulator, 3 GSD-on launches and 3 GSD-off launches. Every launch passed the FFI compat suite: 486/489 passed, 0 failed, 4 skipped. Values are median ns/op.
GSD helps RN on covered direct Hermes JSI FFI calls such as
NSObject.respondsToSelector,NSString.length, delegate callback dispatch,UIViewController.new, andUITabBarController.alloc. It does not help paths that intentionally bypass generated dispatch, especiallyrunOnUI/UIKit thread-hop work and init/super-special cases, where the remaining cost is UIKit, scheduling, or required marshalling.React Native Worklets Prototype
The Worklets integration is intentionally optional. Native code uses
__has_include(<worklets/Compat/Holders.h>); apps withoutreact-native-workletskeep the existing TurboModule behavior andinstallWorklets()returnsfalse. When Worklets headers are present,installWorkletRuntime()verifies the object fromWorklets.getUIRuntimeHolder()hasworklets::WorkletRuntimeHolderNativeState, then runs a synchronous install insideholder->runtime_with the bundled NativeScript metadata path.JS stores the Worklets adapter only after native installation succeeds.
NativeScript.runOnUI()then delegates only whenWorklets.isWorkletFunction(callback)is true; otherwise the existing host-threadrunOnUIpath remains the fallback. One behavior to document for app authors: the Worklets Babel plugin auto-workletizes callbacks by callee property name, so a call spelledNativeScript.runOnUI(...)may become a worklet when the plugin is enabled even though it is not imported from Worklets.Hermes Prototype Native-Call Follow-up
Environment: High Power Mode, iPhone 16 Pro iOS 18.5 Simulator, 250k base iterations. These totals include two extra JS-to-native baseline cases added after the earlier all-engine table, so compare this section within itself.
Validation added for this follow-up:
IOS_VARIANT=ios-hermes ./scripts/build_all_ios.sh --hermesnode benchmarks/objc-dispatch/run.js --runtime ios-package --package-tgz packages/ios-hermes/dist/nativescript-ios-hermes-0.0.2.tgz --variant-label hermes-before-selector-fastpath --iterations 250000 --include-gsd-off --work-root build/benchmarks/objc-dispatch-hermes-prototype-beforenode benchmarks/objc-dispatch/run.js --runtime ios-package --package-tgz packages/ios-hermes/dist/nativescript-ios-hermes-0.0.2.tgz --variant-label hermes-after-selector-fastpath --iterations 250000 --include-gsd-off --work-root build/benchmarks/objc-dispatch-hermes-prototype-aftertest/cli/memory/run_memory_tests_all_engines.sh-> V8, QuickJS, JSC, and Hermes all completed; each engine runs the 10-case memory/ownership/FFI stress suite underset -e.The follow-up optimization skips redundant selector-group target resolution for already-prepared non-property calls. It improved the Hermes GSD-on total by 46.50ms (-4.3%) in this run, with the ObjC-only portion dropping 41.45ms (-4.2%). GSD-off moved only 8.54ms (-0.7%), which is expected because the optimized path is the prepared/GSD hot path.
JS-to-native baseline
The benchmark now measures an actual native function both directly and through a plain JS prototype. This is not a HostObject Objective-C instance method; it uses
performance.now, so it includes the timer body, but it gives the right order-of-magnitude baseline for a JS-to-native call on Hermes.For comparison, after the selector fast path the covered Objective-C bridge calls are still materially above that native-function floor, but GSD now helps Hermes on the hot cases:
Nitro architecture notes
Primary sources reviewed: HybridObject.cpp, HybridObject.hpp, HybridObjectPrototype.cpp, Prototype.hpp, HybridFunction.hpp, PropNameIDCache.hpp, PropNameIDCache.cpp, and the Hybrid Objects docs.
Nitro's important performance shape is: create a plain JS object with
Object.create(prototype), attach the native object as JSI NativeState, cache the JS wrapper per runtime with a weak object, install host functions once on cached prototypes, and cache property-name IDs per runtime. That avoids per-member HostObject lookup on normal method access. Hermes supports the required JSI object primitives, so a Nitro-style Hermes object model is possible, but it is a larger architectural change than this patch: our conversion, receiver lookup, expando, wrapper finalization, ownership, class-builder, and native-object identity paths currently key offNativeApiObjectHostObject. The safe next optimization would be a Hermes-only NativeState-backed instance compatibility layer with stress coverage before replacing HostObject instance wrappers.Follow-up experiment result for this PR: JSI NativeState cannot be attached to the current NativeScript instance wrappers because they are HostObjects; JSI
Object::setNativeStatedocuments that it throws for HostObjects and proxies. Replacing wrappers with plain JS objects plus NativeState would require moving dynamic Objective-C dispatch to prototype-installed accessors/methods first and reworking theNativeApiObjectHostObjectidentity, conversion, expando, ownership, class-builder, and finalization paths. Decision: do not land that architecture in this PR; keep the current HostObject/prototype hybrid and limit this branch to backend organization, generated dispatch, and the optional Worklets runtime install.