Skip to content

refactor: organize direct FFI backends - #43

Open
DjDeveloperr wants to merge 15 commits into
mainfrom
refactor
Open

refactor: organize direct FFI backends#43
DjDeveloperr wants to merge 15 commits into
mainfrom
refactor

Conversation

@DjDeveloperr

@DjDeveloperr DjDeveloperr commented May 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Reorganize NativeScript/ffi so Hermes owns the public JSI entrypoint, direct-engine bridge internals live under ffi/direct, and shared code is named for what it actually shares.
  • Move direct adapters for V8, JSC, and QuickJS off facebook::jsi naming and onto nativescript::direct while keeping Hermes as the only real JSI backend.
  • Centralize signature-dispatch hashing/lookup support, add direct prepared dispatch for V8/JSC/QuickJS, and wire Hermes/RN TurboModule staging to generated signature dispatch.
  • Update CMake, build scripts, RN podspec packaging, and FFI boundary checks to enforce the new architecture.
  • Extract each backend's generated-signature-dispatch adapter into backend-owned *Gsd.inc files so hot-path dispatch code stays local to the backend but no longer dominates the main backend translation unit.
  • Prototype optional react-native-worklets integration that installs the NativeScript Native API into the Worklets UI runtime through WorkletRuntimeHolder NativeState when the Worklets headers are present, while preserving the default no-Worklets TurboModule path.

Validation

  • ./scripts/check_ffi_boundaries.sh

  • git diff --check

  • node packages/react-native/test/config-plugin.test.js

  • node packages/react-native/test/cli.test.js

  • ./scripts/build_react_native_turbomodule.sh -> package tarball includes native-api/ffi/hermes/NativeApiJsiGsd.inc and the optional RNWorklets header-search path

  • ./scripts/test_react_native_turbomodule.sh -> RN 0.85.3 Release simulator smoke passed with marker NATIVESCRIPT_RN_TURBO_SMOKE_PASS; installed: true, backend: "hermes", nativeCallsRanOnMainThread: true

  • Worklets positive smoke: generated RN 0.85.3 app with react-native-worklets@0.9.1 and react-native-worklets/plugin; Release simulator build passed with marker NATIVESCRIPT_RN_WORKLETS_SMOKE_PASS; workletsInstalled: true, backend: "hermes", nativeCallsRanOnMainThread: false

  • Backend GSD split compile check: ./scripts/build_nativescript.sh --<engine> --no-sim --no-iphone --macos --no-catalyst --no-xr for Hermes, V8, JSC, and QuickJS

  • ./scripts/build_metadata_generator.sh

  • BUILD_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-jsc

  • BUILD_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-quickjs

  • BUILD_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-v8

  • MACOS_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 skipped

  • MACOS_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-pack

  • node benchmarks/objc-dispatch/run.js --runtime napi-node --iterations 250000 --include-gsd-off

  • node 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-off

  • RN 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-macos for V8/JSC/QuickJS/Hermes generic Node-API packages

  • node 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-off for V8/JSC/QuickJS/Hermes

The metadata generation steps still print existing SDK/private-header diagnostics, but the commands exit successfully.

Benchmarks

Lower is better. GSD effect is GSD-off / GSD-on - 1, so positive means generated signature dispatch is faster. Objective-C dispatch benchmarks used 250k base iterations. napi-node is 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

engine GSD-on total (ms) GSD-off total (ms) GSD effect speedup
V8 450.83 626.79 +39.0% 1.390x
JSC 720.51 940.68 +30.6% 1.306x
QuickJS 516.16 651.49 +26.2% 1.262x
Hermes 857.40 949.86 +10.8% 1.108x

Generic Node-API Backend

host/runtime GSD-on total (ms) GSD-off total (ms) GSD effect speedup
macOS node 2711.58 2776.23 +2.4% 1.024x
V8 iOS package 2197.34 2346.14 +6.8% 1.068x
JSC iOS package 4852.69 5039.86 +3.9% 1.039x
QuickJS iOS package 2084.67 2320.65 +11.3% 1.113x
Hermes iOS package 2321.71 2464.25 +6.1% 1.061x

Direct vs Generic Node-API

engine direct GSD-on (ms) generic Node-API GSD-on (ms) direct speedup
V8 450.83 2197.34 4.874x
JSC 720.51 4852.69 6.735x
QuickJS 516.16 2084.67 4.039x
Hermes 857.40 2321.71 2.708x

Objective-C Dispatch Cases: Engine-Native Direct

V8

case ops GSD-on ns/op GSD-off ns/op GSD effect
js.loop.baseline 250000 39.8 54.0 +35.5%
NSObject.respondsToSelector 250000 82.7 159.8 +93.4%
NSObject.isKindOfClass 250000 328.1 324.4 -1.1%
NSObject.description.getter 62500 497.9 568.8 +14.3%
NSObject.hash.getter 250000 69.6 81.6 +17.2%
NSString.length.getter 250000 66.5 80.3 +20.9%
NSString.characterAtIndex 250000 90.9 124.6 +37.0%
NSString.compare 250000 92.6 193.9 +109.5%
NSString.hasPrefix 250000 112.2 238.0 +112.2%
NSArray.objectAtIndex 250000 353.3 386.2 +9.3%
NSMutableArray.count.getter 250000 63.8 78.5 +23.1%
NSMutableArray.addRemoveObject 125000 189.5 328.1 +73.1%
NSMutableDictionary.setRemoveObject 125000 170.4 456.1 +167.6%
NSDate.timeIntervalSince1970 250000 68.5 83.2 +21.4%
CoreGraphics.CGPointMake 125000 66.3 63.4 -4.4%

JSC

case ops GSD-on ns/op GSD-off ns/op GSD effect
js.loop.baseline 250000 3.1 3.3 +5.4%
NSObject.respondsToSelector 250000 263.0 385.3 +46.5%
NSObject.isKindOfClass 250000 445.8 464.1 +4.1%
NSObject.description.getter 62500 681.3 713.0 +4.6%
NSObject.hash.getter 250000 82.3 82.5 +0.3%
NSString.length.getter 250000 85.6 83.7 -2.2%
NSString.characterAtIndex 250000 206.7 238.7 +15.5%
NSString.compare 250000 175.0 304.9 +74.3%
NSString.hasPrefix 250000 197.9 347.1 +75.4%
NSArray.objectAtIndex 250000 419.9 465.7 +10.9%
NSMutableArray.count.getter 250000 73.8 84.7 +14.7%
NSMutableArray.addRemoveObject 125000 486.4 643.2 +32.2%
NSMutableDictionary.setRemoveObject 125000 453.7 905.1 +99.5%
NSDate.timeIntervalSince1970 250000 121.0 131.3 +8.5%
CoreGraphics.CGPointMake 125000 16.8 14.2 -15.6%

QuickJS

case ops GSD-on ns/op GSD-off ns/op GSD effect
js.loop.baseline 250000 48.8 52.5 +7.6%
NSObject.respondsToSelector 250000 129.0 172.7 +33.9%
NSObject.isKindOfClass 250000 269.0 272.7 +1.4%
NSObject.description.getter 62500 506.2 530.0 +4.7%
NSObject.hash.getter 250000 91.3 98.6 +8.0%
NSString.length.getter 250000 90.2 98.3 +9.0%
NSString.characterAtIndex 250000 121.9 157.3 +29.0%
NSString.compare 250000 127.9 211.8 +65.6%
NSString.hasPrefix 250000 142.2 227.4 +59.9%
NSArray.objectAtIndex 250000 309.2 324.4 +4.9%
NSMutableArray.count.getter 250000 89.6 112.3 +25.3%
NSMutableArray.addRemoveObject 125000 282.2 407.0 +44.2%
NSMutableDictionary.setRemoveObject 125000 234.7 483.3 +105.9%
NSDate.timeIntervalSince1970 250000 94.0 104.8 +11.5%
CoreGraphics.CGPointMake 125000 109.6 114.9 +4.8%

Hermes

case ops GSD-on ns/op GSD-off ns/op GSD effect
js.loop.baseline 250000 62.2 61.0 -2.0%
NSObject.respondsToSelector 250000 233.2 249.3 +6.9%
NSObject.isKindOfClass 250000 412.8 424.3 +2.8%
NSObject.description.getter 62500 508.8 526.0 +3.4%
NSObject.hash.getter 250000 167.0 177.9 +6.6%
NSString.length.getter 250000 169.2 173.1 +2.3%
NSString.characterAtIndex 250000 222.7 273.7 +22.9%
NSString.compare 250000 218.0 271.5 +24.5%
NSString.hasPrefix 250000 242.8 297.5 +22.6%
NSArray.objectAtIndex 250000 414.2 451.0 +8.9%
NSMutableArray.count.getter 250000 170.7 168.2 -1.4%
NSMutableArray.addRemoveObject 125000 629.5 701.1 +11.4%
NSMutableDictionary.setRemoveObject 125000 532.3 689.0 +29.4%
NSDate.timeIntervalSince1970 250000 170.6 175.2 +2.7%
CoreGraphics.CGPointMake 125000 119.7 123.0 +2.7%

Objective-C Dispatch Cases: Generic Node-API

The macOS node table is the generic Node-API backend running under Node. The engine tables are the same generic FFI backend built into each iOS engine package via NS_FFI_BACKEND=napi.

macOS node

case ops GSD-on ns/op GSD-off ns/op GSD effect
js.loop.baseline 250000 5.8 5.6 -2.8%
NSObject.respondsToSelector 250000 658.9 683.8 +3.8%
NSObject.isKindOfClass 250000 805.9 801.4 -0.5%
NSObject.description.getter 62500 1072.8 1046.7 -2.4%
NSObject.hash.getter 250000 605.3 579.6 -4.3%
NSString.length.getter 250000 413.4 444.5 +7.5%
NSString.characterAtIndex 250000 487.7 481.6 -1.3%
NSString.compare 250000 1178.4 1143.1 -3.0%
NSString.hasPrefix 250000 1162.4 1162.7 +0.0%
NSArray.objectAtIndex 250000 839.3 925.1 +10.2%
NSMutableArray.count.getter 250000 452.0 474.7 +5.0%
NSMutableArray.addRemoveObject 125000 2457.3 2681.5 +9.1%
NSMutableDictionary.setRemoveObject 125000 3497.3 3529.1 +0.9%
NSDate.timeIntervalSince1970 250000 426.8 442.5 +3.7%
CoreGraphics.CGPointMake 125000 6.4 8.0 +23.6%

V8 iOS package

case ops GSD-on ns/op GSD-off ns/op GSD effect
js.loop.baseline 250000 34.2 39.1 +14.4%
NSObject.respondsToSelector 250000 464.0 576.0 +24.2%
NSObject.isKindOfClass 250000 694.2 717.2 +3.3%
NSObject.description.getter 62500 799.6 916.8 +14.7%
NSObject.hash.getter 250000 436.7 533.8 +22.2%
NSString.length.getter 250000 365.9 392.3 +7.2%
NSString.characterAtIndex 250000 390.5 443.4 +13.6%
NSString.compare 250000 918.7 959.6 +4.4%
NSString.hasPrefix 250000 927.3 943.6 +1.8%
NSArray.objectAtIndex 250000 827.1 853.4 +3.2%
NSMutableArray.count.getter 250000 396.3 406.1 +2.5%
NSMutableArray.addRemoveObject 125000 1953.7 2128.3 +8.9%
NSMutableDictionary.setRemoveObject 125000 2606.1 2636.5 +1.2%
NSDate.timeIntervalSince1970 250000 369.7 390.0 +5.5%
CoreGraphics.CGPointMake 125000 66.2 66.6 +0.6%

JSC iOS package

case ops GSD-on ns/op GSD-off ns/op GSD effect
js.loop.baseline 250000 3.4 3.4 -0.8%
NSObject.respondsToSelector 250000 1006.9 1140.1 +13.2%
NSObject.isKindOfClass 250000 1498.9 1471.0 -1.9%
NSObject.description.getter 62500 1405.4 1451.3 +3.3%
NSObject.hash.getter 250000 944.8 1032.0 +9.2%
NSString.length.getter 250000 859.4 912.8 +6.2%
NSString.characterAtIndex 250000 956.9 999.9 +4.5%
NSString.compare 250000 2070.1 2219.2 +7.2%
NSString.hasPrefix 250000 2140.5 2177.9 +1.7%
NSArray.objectAtIndex 250000 1415.6 1386.3 -2.1%
NSMutableArray.count.getter 250000 863.3 895.7 +3.8%
NSMutableArray.addRemoveObject 125000 4648.4 4959.6 +6.7%
NSMutableDictionary.setRemoveObject 125000 6159.9 6308.6 +2.4%
NSDate.timeIntervalSince1970 250000 868.0 869.8 +0.2%
CoreGraphics.CGPointMake 125000 7.0 6.5 -6.7%

QuickJS iOS package

case ops GSD-on ns/op GSD-off ns/op GSD effect
js.loop.baseline 250000 48.0 52.7 +9.6%
NSObject.respondsToSelector 250000 469.8 573.5 +22.1%
NSObject.isKindOfClass 250000 666.2 686.2 +3.0%
NSObject.description.getter 62500 813.7 914.8 +12.4%
NSObject.hash.getter 250000 409.7 528.9 +29.1%
NSString.length.getter 250000 339.7 378.3 +11.4%
NSString.characterAtIndex 250000 391.2 459.8 +17.5%
NSString.compare 250000 827.5 893.4 +8.0%
NSString.hasPrefix 250000 843.6 900.0 +6.7%
NSArray.objectAtIndex 250000 831.9 911.5 +9.6%
NSMutableArray.count.getter 250000 384.8 416.6 +8.3%
NSMutableArray.addRemoveObject 125000 1929.5 2171.0 +12.5%
NSMutableDictionary.setRemoveObject 125000 2253.2 2511.7 +11.5%
NSDate.timeIntervalSince1970 250000 346.2 375.3 +8.4%
CoreGraphics.CGPointMake 125000 115.9 122.2 +5.4%

Hermes iOS package

case ops GSD-on ns/op GSD-off ns/op GSD effect
js.loop.baseline 250000 65.3 55.9 -14.3%
NSObject.respondsToSelector 250000 468.0 585.2 +25.1%
NSObject.isKindOfClass 250000 655.0 656.3 +0.2%
NSObject.description.getter 62500 860.8 1006.5 +16.9%
NSObject.hash.getter 250000 424.1 517.9 +22.1%
NSString.length.getter 250000 363.0 377.4 +4.0%
NSString.characterAtIndex 250000 484.4 475.8 -1.8%
NSString.compare 250000 903.2 958.0 +6.1%
NSString.hasPrefix 250000 936.5 992.6 +6.0%
NSArray.objectAtIndex 250000 1000.6 1016.6 +1.6%
NSMutableArray.count.getter 250000 438.4 467.1 +6.6%
NSMutableArray.addRemoveObject 125000 2328.4 2419.1 +3.9%
NSMutableDictionary.setRemoveObject 125000 2503.6 2658.0 +6.2%
NSDate.timeIntervalSince1970 250000 359.7 382.2 +6.3%
CoreGraphics.CGPointMake 125000 126.7 122.6 -3.2%

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.

case ops GSD-on ns/op GSD-off ns/op GSD effect
native.uikit.UITabBarController.new.firstWithView 1 977039.3 1112937.9 +13.9%
rn.uikit.UITabBarController.new.firstWithView 1 621375.1 549958.0 -11.5%
rn.global.NSObject.cached 20000 40.1 41.5 +3.5%
rn.objc.NSObject.new 10 4408.3 3975.0 -9.8%
rn.objc.NSObject.alloc 10 3616.6 3525.0 -2.5%
rn.objc.NSObject.alloc.init 10 8841.7 8237.5 -6.8%
rn.getClass.UIView.cached 5000 495.8 471.7 -4.9%
rn.objc.NSObject.respondsToSelector 10000 165.3 231.9 +40.3%
rn.objc.NSString.length 10000 116.6 159.2 +36.5%
rn.callback.delegate.sameThread 2000 305.0 390.3 +28.0%
native.uikit.UIColor.factory.batch 100 139.5 129.9 -6.9%
rn.runOnUI.UIColor.factory.batch 100 17010.0 15606.7 -8.2%
rn.uikit.UIView.new 10 22037.5 21829.2 -0.9%
rn.uikit.UIViewController.new 10 20666.7 22983.4 +11.2%
rn.uikit.UITabBarController.alloc 5 19208.2 21141.6 +10.1%
rn.uikit.UITabBarController.alloc.init 5 538766.6 515983.2 -4.2%
native.uikit.UITabBarController.new.warm 5 347209.0 301599.5 -13.1%
rn.uikit.UITabBarController.new.warm 5 420233.4 392300.0 -6.6%

GSD helps RN on covered direct Hermes JSI FFI calls such as NSObject.respondsToSelector, NSString.length, delegate callback dispatch, UIViewController.new, and UITabBarController.alloc. It does not help paths that intentionally bypass generated dispatch, especially runOnUI/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 without react-native-worklets keep the existing TurboModule behavior and installWorklets() returns false. When Worklets headers are present, installWorkletRuntime() verifies the object from Worklets.getUIRuntimeHolder() has worklets::WorkletRuntimeHolder NativeState, then runs a synchronous install inside holder->runtime_ with the bundled NativeScript metadata path.

JS stores the Worklets adapter only after native installation succeeds. NativeScript.runOnUI() then delegates only when Worklets.isWorkletFunction(callback) is true; otherwise the existing host-thread runOnUI path remains the fallback. One behavior to document for app authors: the Worklets Babel plugin auto-workletizes callbacks by callee property name, so a call spelled NativeScript.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 --hermes
  • node 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-before
  • node 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-after
  • test/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 under set -e.
Hermes run GSD-on total (ms) GSD-off total (ms) total GSD effect ObjC-only GSD-on (ms) ObjC-only GSD-off (ms) ObjC-only GSD effect
before selector fast path 1085.66 1215.89 +12.0% 976.57 1099.40 +12.6%
after selector fast path 1039.16 1207.34 +16.2% 935.12 1093.56 +16.9%

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.

case before GSD-on ns/op after GSD-on ns/op after GSD-off ns/op
js.nativeFunction.performance.now 77.1 71.8 75.2
js.prototype.nativeFunction.performance.now 78.2 78.1 73.2

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:

case GSD-on ns/op GSD-off ns/op GSD effect
NSObject.respondsToSelector 265.4 316.6 +19.3%
NSObject.isKindOfClass 467.4 527.4 +12.8%
NSString.characterAtIndex 275.5 348.7 +26.6%
NSString.compare 262.8 355.7 +35.3%
NSMutableDictionary.setRemoveObject 660.2 860.8 +24.3%

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 off NativeApiObjectHostObject. 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::setNativeState documents 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 the NativeApiObjectHostObject identity, 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.

@coderabbitai

coderabbitai Bot commented May 28, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 7e44956e-7b33-41cc-9678-53e097257442

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

DjDeveloperr and others added 14 commits August 25, 2026 19:50
)

* chore(repo): remove duplicated runtime artifacts

* refactor(runtime): consolidate backend ownership and teardown

* test(runtime): cover backend memory and dispatch behavior

* fix(runtime): preserve ownership through teardown
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant