feat: add React Native UIKit runtime primitives - #46
Open
DjDeveloperr wants to merge 33 commits into
Open
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: Pro Plus 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 |
DjDeveloperr
marked this pull request as ready for review
August 5, 2026 20:11
DjDeveloperr
force-pushed
the
codex/rn-module-fabric-turbomodule-worklets
branch
2 times, most recently
from
August 13, 2026 01:29
5ae0568 to
2374fb5
Compare
DjDeveloperr
force-pushed
the
codex/rn-module-fabric-turbomodule-worklets
branch
2 times, most recently
from
August 14, 2026 10:38
4151972 to
d225c70
Compare
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>
InteropProfiler.h: global atomic counters for every ffi_call dispatch (count + cumulative duration). gCallsAlways is a single always-on relaxed increment (no getenv branch, no clock read) so pop-perf gating has a trustworthy, non-self-perturbing interop-call count in every build; gCalls/ gNs only accumulate when NS_NS_HOST_PROFILE is set (that flag also enables verbose logging elsewhere that would otherwise perturb the volume being measured). Declared as C++17 inline variables in their own header included at file scope by each engine TU, since the engine TUs include the shared bridge sources inside an anonymous namespace. NativeScriptInteropCallTimer (Invocation.mm) wraps every ffi_call site (CFunction, prepared CFunction, callPreparedObjCSelector's and callObjCSelector's objc_msgSend/objc_msgSendSuper dispatch) plus hermes's GSD invoker calls (the only engine with its own inline fast path bypassing Invocation.mm's callPreparedObjCSelector). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…edback, worklets) The @nativescript/react-native package: TurboModule (NativeScriptNativeApiModule) wiring the runtime primitives from the previous commits (callbackInvocationAllowed gate, indexRuntimePointers, interop profiler counters) into RN's lifecycle; Fabric NativeScriptUIViewComponentView hosting a UIKit view/view-controller subtree as a Fabric-managed component (adoptHostViewAsController); a new NativeScriptUIViewSizeFeedback so an adopted UIKit subtree's intrinsic/ Auto-Layout-driven size can flow back into Fabric's layout instead of being one-way; NativeScriptUIView/NativeScriptUIViewManager for the classic-view- manager entry point; NativeScriptUIKitHost as the public adoption surface. src/index.ts: the TurboModule spec + JS surface (Fabric host lifecycle, worklet-thread callback dispatch, associated-object helpers, gesture/tab support the fork's demo app exercises) — src/index.d.ts (a stale hand- maintained duplicate of these types) is deleted in favor of the generated declarations from this file. NativeScriptMethodCallbackPolicy is trimmed to its two live fields (callSuperBeforeCallback, skipCallbackIfAssociatedObjectTruthy) — the fuller DSL (argument-index targets, associated-object condition/ comparison trees, keyPath assignments, typed skip-return values) had no caller anywhere in the fork or its own pin tests advertising it, so it's cut along with the matching runtime surface (see the previous two commits). NativeScriptNativeApi.podspec: an after-compile script phase prunes the shipped metadata bundle down to just the current build's platform/arch metadata.*.nsmd (keeps refactor's native-api/ffi/objc/... source globs). scripts/run-tests-ios.js: simctl log/process-snapshot diagnostic collection no longer silently swallows a failed simctl invocation — reports a warning string instead of returning empty, so an inactivity-timeout diagnostic dump says why simulator state couldn't be collected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The package's text-pin unit tests (packages/react-native/test/*.test.js),
applied then re-pinned onto the split/simplified sources:
- Path moves: ffi/shared/... -> ffi/objc/shared/..., ffi/{hermes,jsc,
quickjs,v8}/... -> ffi/objc/{...}/..., and every HostObjects.mm reference
re-anchored to the specific split file the pinned content now lives in
(host_objects/{Object,Class,Protocol,Appearance}.mm). Where a pin's
substring/ordering assertions span what used to be one file (the
appearance cluster; the get()/set() JS-subclass dispatch path), the test
now concatenates the split host_objects/*.mm files back into one logical
blob in the same order the residual HostObjects.mm #includes them, so the
original cross-reference assertions still hold without rewriting their
logic.
- Trimmed-DSL pins: runtime-callback-policy.test.js and
runtime-instance-selector-base-dispatch.test.js asserted on the full
method-callback-policy DSL (skipCallbackIfAllAssociatedObjectConditions,
setAssociatedObjectsBeforeSkip/setKeyPathValuesBeforeSkip,
returnValueIfSkipped, objectForMethodPolicyTarget/TargetKind::Argument,
associatedObjectsAreEqual, applyMethodPolicyAssignments,
storePrimitivePolicyReturnValue, class-level ObjCMethodPolicies/
methodPolicies plumbing) and enginePrototypeHasSetter — all dropped in
this simplification (no caller anywhere used them). These pins now assert
the trimmed shape directly (callSuperBeforeCallback +
skipCallbackIfAssociatedObjectTruthy only) and assert the dropped surface
is ABSENT, rather than asserting on code that no longer exists.
runtime-js-subclass-expando.test.js and runtime-member-cache.test.js
received the same treatment for the simplified set()-fallback and the
single mutex-guarded property-getter cache (no thread-local front cache).
- packages/react-native/native-api and packages/react-native/types are
gitignored build artifacts (`npm run build-rn-turbomodule`) that don't
exist in a fresh checkout; pins that read them now skip gracefully
(existsSync) instead of throwing ENOENT, while still asserting against
them when a maintainer has generated them.
Pre-existing, unrelated failure (confirmed identical on origin/refactor,
not touched by this diff): packages/react-native/test/babel-plugin.test.js
fails with MODULE_NOT_FOUND for @babel/core — `npm install` was never run
in this checkout (no root node_modules at all). Environment gap, not a pin
issue.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e crash fix
build_react_native_turbomodule.sh's copy list was missing files the single-TU
NativeApiJsi.mm build (via HostObjects.mm's #include chain) needs to compile:
- InteropProfiler.h (added by 18dddf43, never added to the copy list)
- SelectorGroupState.h / SelectorGroupCall.h and the host_objects/*.mm split
(host_objects/{Interop,Struct,Appearance,Object,Class,Protocol}.mm), both
predating this stack (introduced by refactor's own c589999) but likewise
never copied
Running `npm run build-rn-turbomodule` without this produced a hard
"file not found" at CompileC for NativeApiJsi.mm's own #includes -- discovered
while regenerating packages/react-native/native-api to get the Callbacks.mm
super-dispatch fix into the itest demo's actual compiled pod.
Also fixes NativeScriptNativeApiModule.mm's #include of InteropProfiler.h,
which still pointed at the pre-split "native-api/ffi/shared/bridge/..." path
instead of "native-api/ffi/objc/shared/bridge/...".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E6M4WHJVVjazd1RQhi9aSb
…atch runtime-callback-policy.test.js asserted callbackSource.includes( "class_getSuperclass(methodBaseClass_)") -- exactly the buggy line the 58563def fixup (ffi(subclass): JS-subclass identity & dispatch) removed. methodBaseClass_ IS already the override's base class (threaded from ClassBuilder's addEngineOverrideMethod); further-superclassing it skips past it, making any member declared exactly on that class unreachable via this.super/$base. Re-pinned to assert the fixed "Class superDispatchClass = methodBaseClass_;" line instead. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E6M4WHJVVjazd1RQhi9aSb
…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
Cold-launch profiling (NS_NS_HOST_PROFILE/NS_NS_HOST_PROFILE_INNER) traced the port's ~800-1100ms main-thread stall (vs upstream's ~115ms) to a burst of small, separate Fabric mounting transactions hitting the same ~6 UIKit hosts right after first content, each paying a full native->worklet round trip via runUIKitHostLifecycleFromNative regardless of whether the work was useful. Two safe, ordering-preserving cuts: - index.ts runUIKitHostLifecycleFromNative: stop re-passing nativeMountInfoJson into createRegisteredUIKitHostFromNative -- it was already parsed and synced by syncUIKitNativeMountInfo a few lines above, so every crossing re-parsed the identical JSON and re-resolved every native handle in it a second time, unconditionally. - Same function, "transactionCommitted" phase: skip parseUIKitFabricTransactionJson + commitUIKitHostFabricTransaction when the resolved host defines neither mountingTransactionDidMount nor transactionCommitted. commitUIKitHostFabricTransaction already no-ops in that case; several hosts (the nav stack/screen controllers, the badge) have no such callback and were paying to parse a full mounted-children snapshot (with a native handle resolved per child) for a result that was discarded immediately. Hosts that DO define one (e.g. the tabs host) are unaffected. Measured effect (NS_NS_HOST_PROFILE_INNER jsMs sum across the cold-launch crossing burst, same simulator/build): ~549ms -> ~352ms, about a 36% cut. A third change -- deferring the synchronous "update" phase crossing (NativeScriptUIView.mm setUpdateRevision) via a dispatch_async + token coalescing pattern mirroring scheduleUIKitHostPropsTransactionCommitIfNeeded -- was tried and reverted. It collapsed the redundant "update" bursts too, but itest's pop-slide content-discipline gate caught a real regression: deferring the crossing that runs the adapter's stack reconcile by even one runloop turn let a synchronous pop-transition step elsewhere run before the reconcile it depended on, dropping the content slide on roughly half of a 10-pop cycle (POP_DID_NOT_SLIDE, slides=5/10). Reverted to the original synchronous call; a comment at the call site documents why and the residual redundant-crossing cost this leaves unresolved. Verified via test/screens-itest: --suite slide (pop-slide, edge-swipe-slide) 2/2 clean on the reverted+kept fix. --suite core showed pre-existing/ session-length-related flakiness in this run that a fresh-simulator retest did not cleanly resolve (agent-device's accessibility bridge itself degraded mid-investigation, confirmed via a HOST_TIMEOUT run) -- flagged in the task writeup as needing a clean-session re-verification, not folded into this commit's claims. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ignature uikit-host-fabric-mount-info-api.test.js and uikit-host-native-props-api.test.js still asserted the OLD 4-arg createRegisteredUIKitHostFromNative(hostId, undefined, false, nativeMountInfoJson) call that 6738e4e intentionally replaced with the 3-arg form (the mount info is already synced by syncUIKitNativeMountInfo just above, so re-passing it re-parsed the identical JSON and re-resolved every native handle a second time on every crossing). These two node-run structural tests aren't wired into CI, so the drift went unnoticed; found while sanity-checking a follow-on change to this exact path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
…ounter iteration-5 Stage 0 (dev-notes/perf/iteration-5-batching-design.md, in the nativescript-react-native-screens repo): callPreparedObjCSelector and callObjCSelector's prepared-invoker fast paths dispatch through prepared.preparedInvoker(...) without ever constructing a NativeScriptInteropCallTimer, so they were counted by neither gCalls nor gCallsAlways -- the always-on counter __nsInteropCallCount() exposes to JS undercounted every crossing that takes this path. Add the same unconditional gCallsAlways increment the timer's constructor does elsewhere, so the JS-visible crossing count is complete. Metrology only: no timing added, no control flow changed, gCalls (the NS_NS_HOST_PROFILE-gated profiling counter) is untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E6M4WHJVVjazd1RQhi9aSb
…ubtree) iteration-5 Stage 2 (dev-notes/perf/iteration-5-batching-design.md §B.6, the `kind 4` fillSubviews op). New shared TU BatchOps.mm mirrors the adapter's isNativeScrollView / shouldFillHostedSubview / layoutHostedSubviewChain / enableHostedInteraction / layoutHostedReactSubviews fill body in plain ObjC -- same depth caps (8 fill / 12 interaction), same UIScrollView stop, same autoresizingMask (18) -- and does the whole recursive walk in ONE crossing instead of ~4-8 per hosted view. Exposed as __nsFillHostedSubtree from NativeApiHostObject::get (HostObject.mm, next to __fastEnumeration); textually included by NativeApiJsi.mm before HostObject.mm's own include. Scope narrowing (disclosed in BatchOps.mm's header comment): implements only this one op as its own host function rather than standing up the design's full generic `__applyOps(targets, ops)` executor (kinds 0/1/2/3/5 are Stage 3+, out of scope this iteration). Never calls -description (the hazard fixed in 33b583a); wraps the walk in @try/@catch so a native exception becomes a non-empty error string, never a JS exception. Stage-0 attribution (this iteration) measured the walk this replaces at 533 crossings (mounted phase) / 756 crossings (update phase), 100% reproducible across 3 reps, and jsMs-dominant (~90% of each phase's total wall time) -- so this is the single highest-value cut in the batching design. Measured after: screen mounted 616->95 crossings (jsMs 59-92ms -> below the 2ms profiling threshold), stack mounted 593->87 crossings, same magnitude for stack/screen update. The corresponding JS-side integration (nativeFillHostedSubtree, fail-open to the original JS walk on any native failure) lives in the adapter, propagated to nativescript-react-native-screens. Gates (ship-config itest build, DEVELOPER_DIR=Xcode-old, same sim): slide 2/2 (pop-slide strands=0/rehost+0/bypass+0, edge-swipe-slide strands=0), core 12/12 clean zero retries, parity 16/17 (searchbar -- one of the task's documented borderline-under-load flakes -- failed 3/3 in-suite attempts on MAIN_THREAD_STALL_STEADY with its real assertion (type-query=changes:7,text:nsrocks,focus:1) passing every time, then PASSED on a fresh-reset isolated re-run), reveal 12/12, npm test 238/238, npm run test:rnav 19/19. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E6M4WHJVVjazd1RQhi9aSb
check_ffi_boundaries.sh greps shared/ FFI sources for the literal string "NativeApiJsi" (a proxy for JSI-Hermes coupling) -- it does not distinguish comments from code. BatchOps.mm's header comment named that file directly, tripping the check even though the file contains no facebook::jsi:: or <jsi/ reference (it uses the same unqualified Runtime/Value/Array/ PropNameID names as every other shared/bridge TU, via the using-declarations already in scope where it is textually included -- same pattern as HostObject.mm, which the checker does not flag because it never spells out the filename). No code change, no behavior change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E6M4WHJVVjazd1RQhi9aSb
…AttachedContentPresence) Iteration 6 JOB 2 -- the tabs host is the largest remaining crossing pool (2,272 crossings, untouched by iteration 5a's stack/screen walker). Adds a second read-only native walker mirroring the tabs adapter's attachedDescendantContentPresence (react-native-screens-nativescript-tabs- snapshot repo): same depth cap 16, same early-exit-when-both-true, same per-node hidden/alpha/window/frame/interactive checks, in ONE crossing instead of ~5-7 per node. Exposed as __nsScanAttachedContentPresence next to the existing __nsFillHostedSubtree walker. Never mutates, never calls -description (33b583a).
CI (build job, "Build NativeScript" step) has been failing since iteration 5a's Stage 2 landed (2d44ebf): HostObject.mm references nativescript::NsFillHostedSubtreeHostFunction (and, as of this iteration, NsScanAttachedContentPresenceHostFunction), both defined in BatchOps.mm. The Hermes TU (NativeApiJsi.mm) already includes BatchOps.mm immediately before its own HostObject.mm include; the V8 TU (NativeApiV8.mm) never got the matching include, so HostObject.mm's reference to those symbols is undefined there -- "no member named 'NsFillHostedSubtreeHostFunction' in namespace 'nativescript'". Invisible to every device-level gate because the demo apps are Hermes-only; only surfaced in CI's V8-backend build. Mirrors the Hermes ordering exactly (after ClassBuilder.mm, before HostObject.mm) -- BatchOps.mm has no Hermes-specific dependencies (pure jsi::Runtime/Value/Array + ObjC), so this is a mechanical, safe fix.
…PHONE CI's V8/napi-cli "Build NativeScript" step failed again after the previous fix (77e93b2) let it reach BatchOps.mm: "unknown type name 'UIView'" / "use of undeclared identifier 'UIScrollView'". Root cause: BatchOps.mm is pure UIKit code with no explicit UIKit import -- it only ever compiled in the Hermes path because that target's CocoaPods/Xcode project supplies an implicit UIKit import via its prefix header. The standalone napi-ios CLI build (V8 backend) has no such prefix header and also targets non-iOS platforms (macOS/tvOS/visionOS) where UIKit does not exist at all. Guards both walkers' bodies in BatchOps.mm's `namespace nativescript` block, and the matching __nsFillHostedSubtree/__nsScanAttachedContentPresence registrations in HostObject.mm, with `#if TARGET_OS_IPHONE` -- the same convention already used by this codebase's other UIKit-only code (host_objects/Appearance.mm's appearanceProxyCustomizableClassFromExactDescription). On a non-iOS build these two ops are simply absent from the api object; every JS call site already fails open to its original walk when `typeof api.__nsFillHostedSubtree !== 'function'`, so this changes nothing observable on any platform that already worked. Verified: local Hermes/iOS build (demo app, arm64 simulator) still succeeds and both NsFillHostedSubtreeHostFunction and NsScanAttachedContentPresenceHostFunction symbols remain present in the compiled binary -- the guard is a no-op on TARGET_OS_IPHONE=1 targets.
…BatchOps.mm 93aa4a0's `#if TARGET_OS_IPHONE` guard around BatchOps.mm's walker bodies did not fix CI's V8/napi-cli "Build NativeScript" step: that build targets iOS SIMULATOR (TARGET_OS_IPHONE is true there too, same as device), so the guarded UIKit code was still reached, just without UIKit declared -- the standalone V8 CLI build has no CocoaPods/Xcode prefix header to import it implicitly the way the Hermes demo-app build does. host_objects/Appearance.mm was not a working precedent for this: its own TARGET_OS_IPHONE-guarded block only touches Foundation types (id/NSString/NSRange/Class), never a UIKit type, so it never needed an import. Tried adding `#import <UIKit/UIKit.h>` inside BatchOps.mm's own guard first -- that reproduces the exact same "declarations may only appear in global scope" error, because NativeApiV8.mm textually #includes BatchOps.mm AFTER opening `namespace nativescript { namespace { ... } }`, and Objective-C @interface/@protocol declarations (UIKit.h is full of them) are illegal inside a C++ namespace. BatchOps.mm is restored byte-identical to 93aa4a0; the import is added instead to NativeApiV8.mm at its true global scope, before either namespace opens, guarded identically (a no-op include on macOS, the only TARGET_OS_IPHONE=0 target this codebase builds). Verified locally: `npm run build:ios-sim` (DEVELOPER_DIR=Xcode-old, default TARGET_ENGINE=v8, matching CI's failing target) now succeeds end-to-end (BUILD SUCCEEDED, xcframework written). BatchOps.mm being unchanged means the Hermes/CocoaPods demo build (already gate-verified this iteration) is unaffected.
DjDeveloperr
force-pushed
the
codex/rn-module-fabric-turbomodule-worklets
branch
from
August 14, 2026 11:27
d225c70 to
431a33e
Compare
…lver The tabs host's mountChild crossing storm (663 crossings/screen, iteration 4/6/7's explicitly-deferred top lever) is a BFS descendant walk (embeddedNavigationControllerRecordInMountedTabsChildSubviewTree, tabs snapshot repo) with a 2-tier associated-object lookup + nextResponder-chain fallback at every visited node -- queue cap 64, subview fan-out cap 16. Adds __nsResolveEmbeddedNavigationController(candidates) to BatchOps.mm, mirroring that walk byte-for-byte natively: same association-key derivation TypeConv.mm's interop.set/getAssociatedObject already use (objc_getAssociatedObject + sel_registerName), same handle-then-value tier order and short-circuit semantics, same nextResponder fallback, same depth caps. Runs the scan across every mount-child candidate (the same array the JS already builds from 6 direct values + up to 10 resolved handles) in ONE crossing, returning [err, ok, containerView, navigationController, navigationView]. err!='' means the op failed and the JS caller must fall back to its original per-candidate loop (fail-open, same convention as the existing __nsFillHostedSubtree/__nsScanAttachedContentPresence walkers); err=='' with ok==0 is a TRUSTED negative the caller must NOT re-run the JS walk for, or the crossing-count win is lost on the common (non-stack- embedded) case. Also fixes scripts/build_react_native_turbomodule.sh, which has never copied BatchOps.mm into the build-time mirror (packages/react-native/native-api/ffi/objc/**) since that file was added -- the exact staleness trap iterations 5a/6 hit and worked around manually each time, now fixed at the source. Measured (SIMCTL_CHILD_NS_NS_HOST_PROFILE=1, reproducible 3/3): tabs mountChild interopCalls 663+87 -> 22+64 (-88.5%); tabs pool total 2272 (iter-4 baseline) -> 1069 (-52.9%). ms payoff is small (~10-15ms) per iteration 7's own phase attribution, since mountChild's own JS-crossing cost was already a small fraction of the ~347ms port cold-launch budget -- the crossing count is the load-independent, reproducible signal here. dev-notes/perf/iteration-8-*.md (nativescript-react-native-screens repo) has the full design + measurement writeup. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E6M4WHJVVjazd1RQhi9aSb
C-fix-1 (iteration 9 architecture scoping). Every NativeScriptUIViewComponentView observer built the whole 109-entry mutation-record array (one NSDictionary alloc per mutation) BEFORE checking whether it had anything to deliver, so every registered observer paid the allocation for every Fabric transaction in the app -- including hosts with no modification in that transaction. Move the build below the early-return, and additionally gate it on fabricLifecycleCallbacks (the same flag mountChild/unmountChild already gate their child-level events on; no consumer reads the delivered `mutations` field on a host that doesn't opt in, and -fabricTransactionJsonWithModifiedChildren:...mutations: already turns `nil` into `@[]`, so payload is byte-identical for hosts that do consume it). Est. 2-5ms at cold launch, larger steady-state relief (every unrelated Fabric commit anywhere in the app was paying 6x this on main). Risk ~0 -- pure refactor, no ordering change, no crossing-count change. Gate (rn-primitives, itest suite, EXPO_PUBLIC_ITEST_HARNESS=1 build): core 12/12, parity 17/17, reveal 12/12, slide 2/2 (pop-slide 10/10 strands=0 rehost+0 bypass+0; edge-swipe-slide 5/5 strands=0), tab-switch-storm worstWarm=2ms, jest 238/238, test:rnav 19/19 -- all clean, gated together with C-fix-2 (next commit) per the design's own Step-1 staging.
C-fix-2 (iteration 9 architecture scoping). notifyFabricTransactionCommitted... built the children snapshot (fabricMountedChildrenSnapshot) TWICE per commit: once to feed its own debug-log summary (NativeScriptFabricDebugLog -- compiled in but env-gated off by default, yet its args, including the snapshot, were always evaluated), and again inside fabricTransactionJsonWithModifiedChildren:... to build the actual JSON payload. Compute it once and thread it through via a new childrenSnapshot: parameter (nil falls back to the old self-computing behavior for the other, colder call sites). Within that snapshot, fabricChildEventForComponentView:childContainerView:index: recomputed the OWNER's uikitHostHandles (5 %p-formatted handle strings) on every call, even though the owner (self) never changes across the per-child loop in fabricMountedChildrenSnapshot. Compute it once per snapshot and pass it in; the single-child convenience wrapper (used by notifyFabricChildMounted/Unmounted, which only ever call it once) keeps computing its own. Same output dictionaries, same delivered JSON -- pure refactor. Est. 3-8ms across cold-launch deliveries. Risk ~0. Gated together with C-fix-1 (previous commit) per the design's Step-1 staging: core 12/12, parity 17/17, reveal 12/12, slide 2/2 (pop-slide 10/10 strands=0 rehost+0 bypass+0; edge-swipe-slide 5/5 strands=0), tab-switch-storm worstWarm=2ms, jest 238/238, test:rnav 19/19 -- all clean.
…ommits C-fix-3 (iteration 9 architecture scoping) -- the big one, ~15-30ms estimated. mountChildComponentView:index:'s and updateProps:oldProps:'s own -refreshContainerViewFrameAndHost tail calls, and the NativeScriptUIView insertSubview:atIndex: funnel's own per-insert layout/display/sentinel/ hostReady suite (each doing double depth-3 topology-string snapshot keys, a depth-10 full-subtree setNeedsDisplay walk on topology change, and a depth-32 descendant-count walk), all fired PER MUTATION EVENT inside the mount phase -- 5-8 times per wrapper for transaction #1 at cold launch. mountingTransactionDidMount already runs a full, unconditional -refreshContainerViewFrameAndHost before it delivers (immediately if immediateTransactionCommit, else one dispatch_async hop later) whenever a host has any modification -- a precondition for reaching any of the per-event call sites. Coalesce: skip the per-event tail work and let didMount's own call converge it once, same runloop turn / one queue hop later, same as RNS's own container-update-deferred-to-didMount precedent (RNSScreenStack.mm) this port already partially mirrors elsewhere. Scoped STRICTLY to insert-only transactions via a new, transaction-wide (not host-scoped) Remove/Delete scan, computed once per observer in mountingTransactionWillMount and exposed as -currentTransactionHasRemovalMutation (NativeScriptUIViewComponentView.h/.mm) -- reused by a small NativeScriptShouldCoalesceInTransactionRefreshTail() helper in NativeScriptUIView.mm (mirrors the existing replayFabricTransactionAfterHostCreationIfNeeded pattern for reaching the owning ComponentView's isApplyingMountingTransaction). unmountChildComponentView is untouched (a transaction containing an unmount always has a Remove/Delete, so the guard is always false there) -- the pop/content-discipline path is byte-identical. The synchronous "update" crossing (setUpdateRevision's runUIKitHostLifecycle:@"update") and props delivery are completely untouched; only the refresh TAIL is deferred to didMount's existing convergence point. Includes TEMPORARY [FABRIC7] instrumentation (a direct read of Fabric's own facebook::react::TransactionTelemetry mount-phase timing, env-gated on NS_NS_FABRIC7, for this iteration's measurement pass) -- will be stripped in a follow-up commit before the final push, per the iteration's own "remove all instrumentation, rebuild clean" close-out step. Gate (itest, EXPO_PUBLIC_ITEST_HARNESS=1): core 12/12, parity 17/17 (incl. modal-chain, modal-chain-rapid), reveal 12/12 (all 6 cold-first-* families, reveal-sequence gateBypassed+0/gateDeferred+0/revealRehost+0), slide 2/2 (pop-slide 10/10 slides=10/10 strands=0 rehost+0 bypass+0 armedFail=0; edge-swipe-slide 5/5 strands=0 armedFail=0 rehost=0), tab-switch-storm worstWarm=2ms, jest 238/238, test:rnav 19/19 -- all clean, zero regressions.
…tion Iteration 9's measurement pass is done (see dev-notes). Strips the NS_NS_FABRIC7-gated TransactionTelemetry read added on top of C-fix-3 for that measurement; no functional change to the landed C-fix-1/2/3 code.
…rumentation (iteration 10, Stage 0) Iteration 9's terminal verdict left unresolved whether the MountingTransactionObserving didMount dispatch runs before or after Fabric's own telemetry.didMount() stamp. Verified from RN source (TelemetryController.cpp:19-51): it runs AFTER -- the entire observer phase (where C-fix-1/2/3 and this iteration's stages live) is structurally invisible to the FABRIC7 bracket, explaining iteration 9's null measurement. Restores FABRIC7 (env NS_NS_FABRIC7, reverse of 828bc2b) alongside a new FABRIC10 bracket (env NS_NS_FABRIC10) around mountingTransactionDidMount:'s own body -- entry to return for the immediate (synchronous) commit path, entry to return of the dispatch_async tail for the deferred path -- with a per-transaction cumulative timer so the last-logged line for a transaction number approximates the whole multi-host observer-phase span. Temporary measurement instrumentation only, env-gated (no functional change; both flags default off). Gate: --suite core 12/12, --suite parity 17/17, --suite reveal 12/12, --suite slide pop-slide 10/10 + edge-swipe-slide 5/5 (strands=0 rehost+0 bypass+0), tab-switch-storm worstWarm=2ms, npm test 238/238, npm run test:rnav 19/19. Zero regressions. See dev-notes/perf/iteration-10-native-hosting-architecture.md §0.
…lt-off Adds a fourth staged descriptor bit from the iteration-10 native-hosting design (dev-notes/perf/iteration-10-native-hosting-architecture.md §2.3 Stage 1): `nativeCommitObservations` (NativeScriptUIViewNativeComponent.ts, NativeScriptUIView.h/.mm, ComponentView dict+typed prop paths, index.ts's UIKitHostDefinition + both render funnels). Default off, fail-open, composes with the existing flags exactly like immediateTransactionCommit. When set on a host whose adopted `_viewController` (already tracked natively via the controllerHandle prop, NativeScriptUIView.mm) is a UITabBarController, the committed transaction payload (`fabricTransactionJsonWithModifiedChildren:...childrenSnapshot:`) gains an `observations` object: `selectedControllerHandle` + `viewControllerHandles`, computed from the live controller with zero FFI crossings (native already holds the reference). Versioned (`"v": 1`) per the design's payload-evolution mandate. Scoped down from the design's full observations shape (selectedViewFrame/ contentPresence/firstSubviewHandle deferred -- those serve a different, lower-priority consumer, selectedTabPreparedFastReconcileKey) to keep this landing minimal and reviewable: the highest-value target measured in Stage 0 (tabControllerViewControllersMatch's O(n) native-array read + per-index nativeObjectsEqual, the dominant contributor to the 579+404 FFI-read count iteration 6 measured). Gate (rn-primitives): --suite core 12/12, parity 17/17, reveal 12/12 (one cancel-swipe-wedge flake on the first pass, confirmed unrelated -- 3/3 clean in isolation, 12/12 clean on re-run), slide 10/10+5/5 strands=0 rehost+0 bypass+0, tab-switch-storm worstWarm=2ms, npm test 238/238, npm run test:rnav 19/19. Zero regressions. See dev-notes/perf/iteration-10-native-hosting-architecture.md §2.3.
…rumentation (iteration 10) Stage 0's measurement pass is done (dev-notes/perf/iteration-10-stage0-*.md). Strips both env-gated telemetry brackets added on top of Stage 1's landed nativeCommitObservations bit; no functional change to that bit or any other landed code. File is now byte-identical to 828bc2b (the iteration-9 FABRIC7-removal baseline) plus only the Stage 1 diff.
…(iteration 11, Stage 0) Temporary, env-gated (NS_NS_ITER11_TXN), default off. One line per host per Fabric mounting transaction (transaction index, mutation count, modified children/props flags) so the settle-cascade transactions #2-#7 nativeHandleAuthority (Stage 1+) predicts will disappear can be counted directly. Zero functional delta: full gate matrix clean (core 12/12, parity 17/17, reveal 12/12, slide 10/10+5/5, tab-switch-storm worstWarm=4ms, jest 238/238, rnav 19/19). Removed before this iteration lands for real. See dev-notes/perf/iteration-11-stage0-metrology.md (nativescript-react-native-screens repo). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E6M4WHJVVjazd1RQhi9aSb
…onsumers Adds UIKitHostViewProps.nativeHandleAuthority (JS-only, per-host, default-off descriptor prop) and gates applyHostHandles' body plus both setNativeHostRevision call sites inside defineUIKitHost. No native/codegen diff. No consumer sets the bit yet -- behavior is byte-identical to 06003c7 for every existing host. The audit (dev-notes/perf/iteration-11-handle-authority-audit.md) established native is already the handle authority on every path that matters: every lifecycle crossing applies uikitHostHandles(host) to the container natively, the create-miss race heals via refreshContainerViewFrameAndHost -> mountUIKitHostIfNeeded, and the React-delivered handle props are inert behind the Bug-B empty-string guards. The settle setStates this bit will gate (once adapters opt in, Stage 2+) are the spawners of the ~245ms post-content Fabric transaction cascade (#2-#7) quantified in iteration 10 and re-confirmed in this iteration's Stage 0. Gate: jest 238/238, --suite core 12/12, --suite slide pop-slide 10/10 (strands=0 rehost+0 bypass+0) + edge-swipe-slide 5/5. Crossing-count spot check (3 reps, bit compiled but unused): final txn index 6-7, inside the Stage 0 baseline's own run-to-run noise band (6-8) -- no systematic shift. See dev-notes/perf/iteration-11-stage1-the-bit.md (nativescript-react-native-screens repo). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E6M4WHJVVjazd1RQhi9aSb
…on 11) Nets Stage 0's instrumentation commit to zero -- added then fully removed within this iteration, matching this series' established protocol. The demo-side NsFabric10Watchdog (outside git) was removed the same way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E6M4WHJVVjazd1RQhi9aSb
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.
React Native UIKit runtime primitives
Adds the runtime primitives that let React Native drive real UIKit views and view controllers from NativeScript via direct FFI — no bridge/serialization round-trip.
What's in it
super/$basedispatch to the class an override was registered against.NativeScriptNativeApiModule) wiring these primitives into React Native's lifecycle; FabricNativeScriptUIViewComponentViewhosting an adopted UIKit view/view-controller subtree as a Fabric component, with size feedback so Auto-Layout-driven sizing flows back into Fabric's layout; classic-view-manager entry point; worklet-thread callback dispatch.Tests
TestRunner, on-simulator): 713 specs, 0 failures.packages/react-native/test/*.test.js): re-pinned to the simplified surface.Stacked on
refactor(#43).