Skip to content

feat: add React Native UIKit runtime primitives#46

Draft
DjDeveloperr wants to merge 155 commits into
refactorfrom
codex/rn-module-fabric-turbomodule-worklets
Draft

feat: add React Native UIKit runtime primitives#46
DjDeveloperr wants to merge 155 commits into
refactorfrom
codex/rn-module-fabric-turbomodule-worklets

Conversation

@DjDeveloperr

@DjDeveloperr DjDeveloperr commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator

Summary

Generic React Native UIKit / Fabric / worklet runtime primitives in @nativescript/react-native, plus the @nativescript/react-native-screens package built on top of them — a thin (~7k-line, single-file) NativeScript-interop engine that drives a real UIKit UINavigationController and is consumed by stock, unforked @react-navigation/native-stack.

Runtime primitives: RN Native API install isolated from aggregate global symbols; callback-lifetime policy for RN/worklet runtimes; UIKit host lifecycle, transaction, touch/hit-test, target/action, and Fabric layout-trait support.

What changed (architecture)

  • Retired the earlier ~45k-line in-fork react-native-screens port (device-buggy). Replaced by the thin packages/react-native-screens adapter in this repo, consumed by unmodified react-navigation (the fork is gone; only a one-line peer-dep is added to native-stack).
  • Runtime interop fixes (regressions the thin adapter surfaced): inherited-ObjC-selector resolution on ClassBuilder subclasses (5806956f); Fabric content mounting into no-lifecycle-callback hosts (fc9dbe6f); Yoga layout for hosted subviews no longer poisoning Fabric's cache (ca7018e5); skip Auto-Layout-owned subviews in the detached-children fill pass (059c8a8e).
  • Parity landed: descriptor + React-element header items (left/right/custom title, as UIBarButtonItem.customView/titleView), header styling (fonts/colors/blur/RTL/dark), ScreenFooter, FullWindowOverlay, two-sided + initial lifecycle events, onHeaderHeightChange, react-freeze, tabs re-snapshot, animation subset (none, modal fade/flip, replaceAnimation).
  • Custom stackAnimation animators (new): the animated variants are served by an engine-owned UIViewControllerAnimatedTransitioning vended from the navigation delegate, with a bounded force-complete watchdog and a full-width interactive pan (UIKit's own edge recognizer is bound to the native transition, so a custom-animated screen needs its own). animationControllerForOperation: returns nil for default / slide_from_right / ios_from_* / none / flip, so default navigation and its stock edge back-swipe are untouched — asserted, see below.
  • SearchBar (new, landed): headerSearchBarOptions / <SearchBar> build a real UISearchController on navigationItem.searchController (descriptor path, mirroring upstream updateSearchController), with focus/blur/change/submit/cancel events and the focus/blur/clear/setText/cancel commands. The abort that held this back was a bogus selector name — see below.
  • statusBar / orientation window traits (new, landed): statusBarStyle / statusBarHidden / statusBarAnimation / screenOrientation / homeIndicatorHidden / hideKeyboardOnSwipe on the visible screen. Requires UIViewControllerBasedStatusBarAppearance = YES in the app's Info.plist (same as upstream).
  • In progress: content-mount timing, page-sheet modal + sheets/detents.

Test net (host-side jest)

  • React Navigation native-stack suite: 19/19 against this package — identical to 19/19 against upstream react-native-screens@4.25.2, i.e. the native-stack contract is satisfied with zero regression vs upstream.
  • Package unit + surface suites: 97/97 (62 + 17 search bar + 18 window traits). react-navigation core/routers/elements canaries: 684/684.
  • Commands (from packages/react-native-screens): npm test, npm run test:rnav.

Verification

  • runtime: for f in packages/react-native/test/*.test.js; do node "$f" || exit 1; done; npm run check:ffi-boundaries
  • on-simulator: asserting integration harness (itest) + interleaved splash-stratified measurement rig for latency/blank metrics.

On-simulator itest (iPhone 17 / iOS 26, Release build, cold-launch deep link, no Metro)

cd test/screens-itest && ./itest run --suite core8/8 green: nav-stack, modal,
header-native, header-react, footer, overlay, tabs, back-swipe. Each scenario drives itself
from JS and is cross-checked host-side against the accessibility tree.

./itest run --suite parity5/5 green, gating the animator work plus the two features
that were previously parked as xfail:

  • anim-variants — a mid-screen (non-edge) drag pops a fade screen but does nothing on a
    none screen or on a default-animation screen, and the stock edge drag still pops the
    default one. The engine offers its full-width pan only for screens it hands to a custom
    animator, so those four together are a decision procedure for which variants take that path —
    and the last two are the regression guard that default navigation never entered it.

  • anim-interactive — the full-width pan pops a custom-animator screen.

  • anim-frames — captures frames of a 6s fade push in flight. A mid-fade frame (incoming
    screen partly transparent over the outgoing one) is something neither UIKit's own push nor a
    completed transition can produce, so it is direct evidence both that the custom animator is
    driving the pixels and that animationDuration feeds it.

  • searchbar — pushes the search screen, proves a real UISearchController was assigned (the
    search node exists in the FULL accessibility tree), taps the field and types: the app must
    observe onChangeText with the typed text (on device: changes:7,text:nsrocks,focus:1).

  • statusbar — pushes each window-trait screen, parks for a frame, and the host asserts the
    pixels of the status bar (FRAME_CHECKS + a minimal PNG reader in lib/png.js): the bar
    is system-drawn, so it is invisible to both JS and the accessibility tree. Every style is
    paired with a contradicting background so iOS's own contrast adaptation cannot produce a
    pass — white glyphs over a light background, dark glyphs over a dark one, an empty band when
    hidden, and the default restored after the pop.

Note on why the animator scenarios are behavioral rather than timed: measuring the push and checking it tracks
animationDuration does not work here — react-navigation's transitionStart/transitionEnd
pair is delivered through the engine's reconcile and JS hops, and measured on-device those come
out indistinguishable (default 833ms, fade(1800) 621ms, none 708ms,
slide_from_bottom(900) 734ms).

Fixed since the last review — both features now merged and gated

  • SearchBar — focusing the bar aborted the app with Objective-C selector is not available: setShowsCancelButton (from searchBarTextDidBeginEditing). The engine called
    setShowsCancelButton(flag, animated) behind a typeof guard that a NativeScript proxy
    satisfies for a selector it cannot dispatch; the real selector is
    -[UISearchBar setShowsCancelButton:animated:], bridged as setShowsCancelButtonAnimated.
    All 63 typeof-guarded selectors in the engine were audited against the UIKit/Foundation
    metadata — the only other name with no real counterpart was a dead subview.pointInside(…)
    fallback in the FullWindowOverlay hit-test (real name pointInsideWithEvent), now removed.
    Gated by the asserting searchbar scenario in --suite parity.
  • statusBar window traits — two independent defects. (1) The overrides were passed to
    __extendClass as plain function values, but every trait is a metadata property, and only
    an accessor descriptor binds a property override (methodOverridesForName skips every
    member.property) — so the extended class carried no overrides at all. (2) Nothing forwarded
    UIKit's query down to the screen: with UIViewControllerBasedStatusBarAppearance = YES iOS
    asks the key window's root controller and walks only childViewControllerFor…, which
    neither a UINavigationController nor RN's root (a bare [UIViewController new]) answers.
    Upstream fixes this natively by swizzling those getters on UIViewController process-wide
    (ios/UIViewController+RNScreens.mm); this JS-only adapter builds a forwarding subclass with
    __extendClass and re-classes the window root with object_setClass (the subclass adds no
    ivars, so the instance layout is unchanged), while engine-owned navigation controllers — the
    stack's and a presented modal's — are built from that subclass directly. Gated by the
    pixel-asserting statusbar scenario in --suite parity.

Known-broken, parked as xfail

Each of these has a fully written asserting scenario in ./itest run --suite xfail, so it
turns green the moment the behavior is fixed:

  • slide_from_bottom (a variant of the animator work that is merged) — a push
    sometimes lands with blank content: route stack and nav-bar title correct, content area
    empty. Reproduced 3/3 when the push followed an earlier push/pop in the same launch, and
    mounts correctly when it is the first push of a cold launch — so this looks like the port's
    known content-mount timing rather than anything specific to that animator (fade / none
    drive the same probe screen). Parked as xfail because an order-dependent scenario cannot
    gate.

Update — push-latency win + install-path consolidation (f32d99d2, 53c16b30)

Two follow-ups, each measurement-grounded and gate-clean (unit 106/106, test:rnav 19/19, itest core 8/8, parity 5/5):

  • Dropped the dead push content-wait (f32d99d2). shouldDelayPushUntilControllerHasContent
    held every animated push back for two reconciles unconditionally, then consulted a probe
    (controllerHasReactContent) that answers true for any non-hidden subview at depth > 0 — so it
    never once gated a real push. Instrumented over cold cycles, t(contentReady) − t(push) was
    negative on all measurable pushes (content is hosted before the push), invalidating the
    "content lands ~270ms after the push" model the wait was written against. Interleaved A/B (JS
    navigate() → UIKit viewControllers mutation, same demo build, 12 cold pairs): with the
    wait 2/12 cycles installed the push at all (the other 10 dropped it, the screen limping in via
    the non-animated fallback tail); without it 12/12 installed via the animated
    pushViewControllerAnimated branch (median ~635ms navigate→install). No blank replaced it —
    frame-exact scoring read CUM detail-blank 0.0 on both builds. controllerHasReactContent is
    kept for the modal-present path, where content genuinely lands after presentation. This is also
    the root cause of the earlier "50/58 first navigations never animate" observation: it was the
    content-wait pre-empting the push, not a classification gap.

  • applyStackModel install funnel (53c16b30). Every viewControllers mutation and all
    model bookkeeping now flow through one worklet. Seven install sites (reconcile, dismiss-repair,
    transition-fallback, modal-dismiss-restore, modal-base-sync, modal-sync, did-show-fallback)
    collapse to a single classifier; recordStackModel is the sole writer of
    stackNativeKeys/Counts (replacing ~13 scattered pairs) and the post-install
    layout → configureStackControllers → configureNavigationAppearance → updateNativeBackGesture
    tail (repeated ~5×) collapses to one helper. A new stackModelVersions counter lets
    timer-scheduled installs carry the model version they were armed against as a token; the
    funnel drops a stale-token call unless native still disagrees with the target and no newer model
    superseded it, so the blind re-install timers become no-ops. Behavior-preserving — every
    path issues byte-identical UIKit calls (proven by the unchanged itest core/parity). Net the
    engine file grows ~73 code lines (the funnel's doc block + stale-token machinery); the win is
    structural de-duplication, not raw LOC.

Also fixes a pre-existing itest harness eagerness bug the funnel work surfaced: fillRef tapped
a search-field ref whose rect was captured from the poll snapshot before the UISearchBar's
accessibility frame settled (tap landed at the bar's origin corner instead of the field centre, so
nothing was typed). It now re-resolves the ref from a fresh snapshot immediately before the tap.
Independent of the engine change — the searchbar scenario failed identically on the parent
commit.

  • Modal present/dismiss lifecycle — state machine (a2dee23b). Replaces the modal boolean
    sprawl (stackModalPresentedModally, stackModalDismissRequestedFromJS) with a coherent,
    epoch-guarded state machine that advances on observed UIKit state
    (presentingViewController / beingPresented / view.window) rather than depending on the
    presentViewController/dismissViewController completion firing — the assumption that defeated
    the earlier scoped patches. Behavior-neutral checkpoint: modal still maps to
    OverFullScreen
    (the mapping flip is a later stage).
    • Registry: stackModalPhase (idle → present-requested → presenting → presented →
      dismiss-requested → dismissing), stackModalEpoch (bumped on every phase entry; every
      completion / observation probe / drain no-ops on epoch mismatch), stackModalQueued (the one
      latest-wins queued dismiss).
    • Present boundary: reconcileStack records the modal desire (from firstModalIndex) above
      the transition gate and arms a coalesced dispatch_async(main) drain; it no longer
      presents/dismisses inline. drainModalStack is the sole caller of
      presentModalStack/dismissModalStack and never mutates viewControllers itself (all
      installs stay in applyStackModel); modalMachineAction is the pure transition table (dismiss
      is issued from exactly one edge).
    • Present gate (hostPresentationBlocked): refuses to present while the host is mid-transition /
      not in a window; parked with no timer re-arm. Liveness = boundary drains from
      finishTransition, did-show, and every reconcile.
    • Observation fan: a bounded fixed one-shot setTimeout fan (never self-re-arming), epoch-guarded
      and read-only, drives observed-presented (stable 2 probes) / observed-gone /
      observed-wedged(never-taken safe-drop); the completions are accelerators only.
    • Deleted the destructive dismiss watchdogs (setTimeout(complete, 420/700) +
      force-hide/removeFromSuperview). Safe teardown: finalize only when the dismiss is observed
      complete; drop (registry/delegate clear + finishTransition(cancelled)) when never taken —
      never reach into the modal view's superview (UIKit owns it).
    • Determining spike (throwaway, reverted before this commit): instrumented ~15 cold cycles ×
      3 presenter identities. Verdict — under the reproducible cadence the present completion does
      fire reliably (15/15 OverFullScreen; every issued present under pageSheet), ~700 ms after
      issue, with the presented controller attaching to the window at ~101 ms; none of the
      "completion lost / never completed / never taken" cases reproduce. The real hazard is a late
      completion racing an early dismiss — which is exactly what the machine (queue-a-dismiss +
      advance-on-observed-state) tolerates. All three presenter identities (tab-bar host /
      parentNavigationController / top-most presentedViewController walk) attach identically
      (~101 ms median), so no presenter change is warranted on latency grounds.
    • Gates: unit 120/120 (+14 machine transition-table / epoch-no-op / single-dismiss-issue-point
      tests), test:rnav 19/19, itest --suite core 8/8 (incl. modal, accessibility-asserted
      "Modal" present → Home), itest --suite parity 5/5, and the interleave rig
      end_home_clean 100% (12/12) on the machine vs 100% on the 53c16b30 control (stalled
      0/10) — proof the refactor did not break the working modal.

Modal lifecycle — Stages 3–5: real page-sheet modals now land (2f58bb26, 49460b5c, dda9e2c1)

Building on the observed-state modal machine (Stage 2), presentation:'modal' / 'pageSheet' / 'formSheet' now present as real UIKit sheets that survive rapid present→dismiss.

  • Stage 3 — native content readiness (2f58bb26). Retired the false-positive controllerHasReactContent view probe from the modal present gate (the bare ActivityView wrapper chain always satisfied it). The modal present edge now waits on real native readiness: the screen controller gains a guarded hostReady handler that sets screenContentReady[screenId], modal screens carry emitOffWindowHostReady (so the signal reaches them before they are presented), and the gate consults it through the pure modalPresentReadinessAction — present when ready, else HOLD for exactly one reconcile of grace then present regardless (a bounded deadline, never the unbounded hold that deadlocks).
  • Stage 4 — real sheet mapping (49460b5c). modal → UIModalPresentationAutomatic (page sheet on iPhone), pageSheet → PageSheet, formSheet → FormSheet, plus the iOS-18 prefersPageSizing restore. fullScreenModal deliberately stays OverFullScreen (true FullScreen detaches the RN root surface and kills touch). What makes the sheet survive the rapid cadence that reverted 60924dac is the machine, not a watchdog: the hostPresentationBlocked drain gate presents only from an idle boundary, an early dismiss is CANCELLED (present-requested) or QUEUED latest-wins (presenting), and teardown runs only against an observed-completed presentation.
  • Stage 5 — the non-vacuous itest gate (dda9e2c1). Three new core scenarios: page-sheet-visible (presented view INSET from the top and content present in two frames ≥300ms apart — distinguishes a real sheet from both OverFullScreen and the old stranded-blank failure), swipe-down-syncs (a swipe-down dismisses the sheet and collapses JS routes to [Home], and a gestureEnabled:false modal SURVIVES the identical swipe), and rapid-present-dismiss (present→dismiss at 200/300/400ms ×3, clean [Home] after each — the race that stranded 60924dac). The existing modal scenario now asserts sheet-inset. A presented modal removes the ProbeOverlay from the a11y tree, so the harness gained host-autonomous auto-capture + auto-swipe (driven off the modal's content label).

Gates (all green, on-sim Release, sim BF759806, no Metro):

  • npm test 123/123 (+3 readiness + updated mapping tests), npm run test:rnav 19/19.
  • itest --suite core 11/11 (all attempt=1, incl. the 3 new scenarios with real frame checks), itest --suite parity 5/5 unregressed.
  • Interleave end_home_clean 27/27 = 100% over 9 rapid present→dismiss launches — the sheet presents AND dismisses cleanly under the rapid cadence (vs 60924dac's 0%).
  • Human-pace parity: the port's page sheet is pixel-identical to upstream react-native-screens 4.25.2 (org.nativescript.uikit.demo.original) in every measured band (nav-bar y=78 vs OverFullScreen's 62; top dim-band 198 vs 247), and swipe-down dismisses to a clean Home.

Header — Part 2: per-navigationItem appearance so differing headers cross-fade (5f185b9e)

The engine previously installed a single UINavigationBarAppearance on the whole navigationBar for the top screen. Part 2 moves the appearance onto each screen's own navigationItem (standardAppearance / scrollEdgeAppearance / compactAppearance), matching upstream RNSScreenStackHeaderConfig, so two stacked screens with different header styling each carry their own appearance endpoint. The build is factored into shared buildScreenBarAppearance + navigationBarAppearanceSignature helpers (byte-identical fields to the old inline block), a new applyNavigationItemAppearance called for every screen from configureScreenController (memoised per screen-controller by native hash, capped like the whole-bar memo), and the whole-bar assignment kept as a fallback for runtimes without UINavigationBarAppearance. The install continues to flow through the applyStackModel funnel (via configureStackAfterInstallconfigureScreenController), preserving the modal state-machine / real-sheet mapping / readiness gate byte-for-byte.

  • Integration. Cherry-picked onto the post-modal base (dda9e2c1); the added/removed lines are byte-identical to the original Part 2 patch — no modal, sheet, readiness, or funnel behaviour changed (auto-merged with no conflicts; verified by diff-of-diffs).
  • Gates (all green): npm test 132/132 (+9 Part 2 tests: appearance set on the navigationItem not just the bar, per-screen appearance objects, per-controller memo skip/rebuild, large-title scroll-edge split, whole-bar fallback), npm run test:rnav 19/19, itest --suite core 11/11 + --suite parity 5/5 unregressed (fresh Release, sim BF759806, no Metro).
  • Sim-verified vs upstream (org.nativescript.uikit.demo.original, RNS 4.25.2). Drove a push between two opaque, contrasting-header screens (red #d81e1e → blue #1436d8) on both apps (throwaway routes, reverted after) and captured the transition at 60fps. The port is frame-identical to upstream: on a native push iOS 26 snaps the opaque bar background to the incoming colour at transition start (bar already blue while the outgoing red content is still mid-slide) rather than dissolving — and upstream RNS does exactly the same, frame for frame (sampled nav-bar band: red rgb(215,28,27) held, then blue rgb(18,52,215) in one frame, in both apps; the mid-transition frames are pixel-equivalent). iOS only alpha-fades the bar on a transparent→opaque change (both apps show the Home→red fade-in identically). Net: the port's per-navigationItem appearance reproduces upstream's on-screen behaviour exactly — faithful parity.

Sheets / detents — real UISheetPresentationController (Wave 3, #54)

Building on the real page-sheet mapping (5f185b9e), formSheet screens now drive a genuine UISheetPresentationController: detents, grabber, corner radius, largest-undimmed detent, scrolling-expands, and the onSheetDetentChanged event. All engine consumption — the vendored types.ts already matched upstream index-based props exactly, so nothing was added to the type surface.

  • Stage 1 — resolvers (5c93008c). Three pure 'worklet' detent resolvers (resolveSheetAllowedDetents / resolveSheetLargestUndimmedDetent / resolveSheetInitialDetentIndex) mirror upstream helpers/sheet.tsx verbatim but never throw (a worklet throw wedges the push — upstream's __DEV__ throws become the production fallbacks), plus sheetDetentIndexFromIdentifier and a config-signature memo. Full enum-table jest coverage.
  • Stage 2 — native config (e35631a4). configureSheetPresentation applies the sheet config on the SYSTEM-detent path (iOS-15 medium/large, mirroring RNSScreen.mm L932-967 — the exact degradation upstream RNS ships when custom detents are unavailable). Gated on formSheet; every native touch null-guarded + try/catch; the controller is read AFTER the presentation style is written (materialisation-order hazard); a signature memo short-circuits the modal-sync drain. Two call sites — present path in presentModalStack, and a no-re-present re-apply in the drainModalStack modal-sync branch (a live setOptions change cross-fades via animateChanges). Pure presentation-controller property writing — issues no present/dismiss, so it cannot reintroduce the stranding race.
  • Stage 3 — the detent-change event (b641b8e0). Extends the ONE presentation-controller delegate the modal machine owns to also report detent changes. A sheet PC is detected by null-checking presentationController.detents; the delegate is created against UISheetPresentationControllerDelegate (which inherits the adaptive protocol, so swipe-dismiss keeps flowing) with a try/catch fallback to the adaptive protocol. The new sheetPresentationControllerDidChangeSelectedDetentIdentifier handler maps the settled identifier to an index and emits onSheetDetentChanged {index, isStable:true} on the modal root.
  • On-device fix (723761ba). Two corrections found driving the scenario on the simulator: (1) the pure resolvers were declared BELOW the delegate handler that calls them — a worklet capturing a later helper serializes a dead closure that throws, so the handler threw before emitting (UIKit invoked the method, confirmed with a native fire-counter, but the event never reached JS); moved the helpers above every consumer. (2) UIKit defaults a two-system-detent sheet to large, contradicting the documented sheetInitialDetentIndex default of 0 (first/smallest) — the config now selects the initial detent explicitly so a [0.5,1.0] sheet opens at medium as documented.
  • Stage 4 — the itest gate (a5e20281). Three new parity scenarios, all green on the simulator: sheet-detents (opens at medium → a host scroll-to-edge drag expands to large, firing onSheetDetentChanged index 1 → dismiss to clean [Home]), sheet-grabber-visible (a NativeScript.runOnUI native-fact probe reads prefersGrabberVisible off the live sheet PC — 1 for grabber:true, 0 for the negative twin), and sheet-corner-radius (probe reads preferredCornerRadius 24, then a setParams flip to 4 re-applies on the LIVE sheet without re-presenting). The harness gained a dragY verb and an ordered auto-gesture sequence.
  • Stage 5 — custom-detent block spike (a61366fc). Spiked risk JavaScriptCore Node-API issues #1: iOS-16 customDetentWithIdentifierResolver:resolver: takes a block that RETURNS a CGFloat, whereas every proven arg-block in the engine returns void. Verdict: NOT viable on this runtime — applying one [0.37] custom detent HANGS the worklet bridge (both spike runs HOST_TIMEOUT, no crash report). The engine therefore ships the system-detent path (the same fallback upstream RNS uses). The reproducer (sheet-detents-custom, gated on a spike route param, kept out of every auto-run suite) stays invokable so a future runtime that fixes primitive-returning arg-block marshalling can re-verify.

Gates (all green, on-sim Release, sim BF759806, no Metro):

  • npm test 162/162 (+30 sheet: resolvers enum tables, configureSheetPresentation vs a fake sheet controller, detent-change delegate mapping), npm run test:rnav 19/19.
  • itest --suite core 11/11 (delegate protocol swap leaves swipe-down-syncs + the modal machine green), itest --suite parity 8/8 including the three new sheet scenarios.
  • On-sim, human-pace: the form sheet opens at the medium detent, a grabber drag expands it to large AND fires onSheetDetentChanged, and it dismisses to a clean Home; the grabber / corner-radius facts are read directly off the live UISheetPresentationController by the native-fact probe.

Animation / transition + gesture parity completeness (#65)

Completes the RNS animation/transition surface after Wave 5's six custom animators. Five independently-green staged commits (anim-completenesscodex/rn-module-fabric-turbomodule-worklets):

  • Stage 1 — ios_from_left + none-for-modal + pixel gates (782d5901). ios_from_left was misrouted to UIKit's native trailing-edge push; upstream groups it with slide_from_left onto RNSScreenStackAnimationSlideFromLeft (RNSConvert.mm L82-84), so it now uses the same inverted horizontal-slide animator (added to stackAnimationUsesCustomAnimator + stackAnimationIsInverted + the animator body). stackAnimation: 'none' now suppresses the animation for modal present / dismiss / push-inside-modal too — three argument-only masks with screenDisablesAnimation on the governing screen (root modal on present, departing top on dismiss, new-top-when-growing / departing-top-when-shrinking on modal-sync), mirroring upstream's stackAnimation != None gate (RNSScreenStack.mm L473-474 / L529-537); the modal machine's phase/epoch/issuance is byte-identical (only the animated argument to UIKit changes). The stale modalTransitionStyle "PARITY DEBT" comment is deleted (the push variants landed). Two verification scenarios: anim-frames promoted to a real pixel gate (before>180 / mid∈[90,190] / settled<70 with a darkened AnimFadeSlow body — the mid-fade dark-over-light composite is impossible for a non-animated install, so this doubles as the first-nav-animates gate), and anim-none (three push/pop cycles, every captured frame reads either clearly light (Home) or clearly dark (AnimNone), never an intermediate blend).

  • Stage 2 — multi-edge push + replace window-guard (fec04a59). isPush required exactly +1, so a single commit landing ≥2 routes (a coalesced navigate, or a cold-launch deep link straight to a nested route) fell to the non-animated tail. Relaxed to nextCount > previousCount with prefix equality; a multi-edge commit now seats every controller below the new top non-animated and animates only the final push (upstream RNSScreenStack.mm L657-663). The common single-push path is byte-identical — the multi-edge seating is gated behind nextCount > previousCount + 1. A push-replace now animates only when the outgoing top is in a window (previousTop.view.window != nil, upstream L636). §H jest pack added.

  • Stage 3 — anim-fade-modal native-fact probe (2286002b, test-only). Three full-screen (OverFullScreen) modals (fade / flip / no animation) proven via a NativeScript.runOnUI worklet that reads the presented modal navigation controller's live modalTransitionStyle off the engine registry (the only Release channel that can see this native property), published per-route to dodge cross-modal staleness. Asserts CrossDissolve (2) / FlipHorizontal (1) / CoverVertical (0), plus a content-mount host check and a bonus non-gating mid-dissolve capture.

  • Stage 4 — gesture props (871b0ef9). fullScreenSwipeEnabled (EXPLICIT-true only — deliberately NOT upstream's iOS-26 undefined→YES, which would flip every default screen onto the engine pan): a default (non-custom-animator) screen that opts in is offered the engine full-width pan, and a stackFullWidthSwipeInFlight flag raised at pan-Began makes the animator delegate vend it a simple_push for the interactive pop (else the swipe pops with no animation); the stock edge-gesture decline is extended to hand the swipe over. gestureResponseDistance is a pure port of upstream isInGestureResponseDistance (start/end/top/bottom, −1/omitted = no constraint, RTL x-mirror) applied in the pan's gestureRecognizerShouldBegin. swipeDirection: 'vertical' reads the swipe on the Y axis (distance = view height, no RTL flip). customAnimationOnSwipe is the animator-selection half only (partial, as scoped). Default navigation is untouched: everything is gated behind explicit opt-in, and the existing watchdogs bound the worst case to a snapped pop, never a wedge. New helpers declared above all callers (capture-order hazard).

  • Stage 5 — anim-swipe-props scenario (763db74a). Exercises Stage 4 on default screens: FullSwipe (a full-width swipeWide pops it) and GestureDistance (a swipe begun near the top no-ops for a full hold; the same swipe begun below pops). Adds a swipeAt|<xStartFrac>|<yFrac> coordinate verb to the harness.

Out of scope (named and stopped): reanimated/gesture-handler custom transitions (goBackGesture, transitionAnimation, stack-level customAnimation), onTransitionProgress + useTransitionProgress, fullScreenSwipeShadowEnabled, the iOS-26 interactiveContentPopGestureRecognizer repro (our engine pan is the pre-26 equivalent). Deferred-with-scope: modal-interior custom animators (the modal nav controller has no UINavigationControllerDelegate, so none is the only in-modal transition control today).

Stage-4 substage outcome: both substages landed on device (no fallback). fullScreenSwipeEnabled (4b — the risky default-screen vend) pops FullSwipe via a full-width swipe, and gestureResponseDistance (4a) rejects a swipe begun above the box and allows one below. swipeDirection: 'vertical' and customAnimationOnSwipe (partial) are jest-covered; the on-device scenario asserts fullScreenSwipe + gestureResponseDistance.

Gates (all green, on-sim Release, sim BF759806, no Metro):

  • npm test 169/169 (+7: ios_from_left routing, the multi-edge push classification, and the Stage 4 gesture-prop helpers incl. isInGestureResponseDistance), npm run test:rnav 19/19.
  • itest --suite core 11/11, itest --suite parity 11/11 (the eight prior parity scenarios plus anim-none, anim-fade-modal, anim-swipe-props; the promoted anim-frames pixel gate now asserts rather than being reviewer-read).
  • On-sim evidence: anim-frames — the three captured frames read before mean=238, mid-fade mean=115 (the dark-over-light composite mid-flight, impossible for a non-animated install), settled mean=16. anim-none — all six push/pop frames read cleanly light (~238) or dark (~16), never an intermediate. anim-fade-modal — the native-fact probe read modalTransitionStyle 2 / 1 / 0 for fade / flip / default. anim-swipe-propsFullSwipe pops via a full-width swipe (fullScreenSwipe default-screen vend), and on GestureDistance a swipe begun at y≈175pt (above the 350pt box) no-ops while one begun at y≈542pt pops.
  • The Stage 2 funnel change keeps the common single-push path byte-identical (the multi-edge branch is gated behind nextCount > previousCount + 1), and nav-stack (push/pop/popToTop/replace) is green on-sim; a dedicated ns-regress interleaved A/B was therefore not run (not a blocker).

Parity tail — 6 small public-surface items (task #66)

Six small in-scope RNS parity holes, one staged commit each, toward an honest "full RNS parity" claim. All additive and gated on props the stock demo does not set, so normal flows are byte-identical. Merged as merge(parity-tail-batch) (549584ab).

  • Port modules like CommonJS module system, Workers from original runtime to Node-API #3 onFinishTransitioning (d7e68f64) — the ScreenStack settle event. Emitted once per settled transition from the two chokepoints upstream fires it at: finishTransition (push / pop / interactive-cancel, and the modal-present completion that routes through it) and completeModalDismissalBookkeeping (modal dismiss). A no-op unless the host forwarded the public handler. Host NativeScriptScreenStack accepts onFinishTransitioning and forwards it. Unit: fake-ctx emit capture at all three points.
  • Debug memory management #4 nativeContainerStyle (84b55127) — the iOS UINavigationController container background, applied in createController + update, reset on removal, never touching a stack that did not set it (upstream RNSScreenStack.mm L1275-1277). Pure apply/reset/none decision. Unit only (not a11y-observable).
  • Share code with Android runtime #7 bar-button styling tail (2c2537e2) — titleStyle → title attributes for normal + highlighted; iOS-26 item fields hidesSharedBackground / sharesBackground / identifier / badge behind read-first property probes; and the marker-level hidesSharedBackground on ScreenStackHeader{Left,Right}View (declared but never applied) threaded to the slot's custom-view bar button item. Unit: title write decision, read-first gates (modern vs legacy barItem), marker capture.
  • Debugger Support #5 scrollEdgeEffects (iOS 26) (3dca5521, device scenario 6636c2d8) — per-edge automatic/hard/soft/hidden → UIScrollEdgeEffect on the screen's first descendant scroll view, from configureScreenController. Fast-paths out for the all-automatic descriptor default (byte-identical); read-first availability probe (topEdgeEffect == null) in try/catch; memoized per-screen by the four-edge signature. Unit: mapping / fast-path / signature / retry. Device (iOS 26 sim): native-fact probe PASStop=1 bottom=2 leftHidden=1.
  • Error Handling #6 headerBackIcon (fef52bbc) — ScreenStackHeaderBackButtonImage promoted from null stub to a real engine marker; its source is walked out of the header children, threaded onto the sanitized config, loaded async via ctx.loadImage, and installed via setBackIndicatorImage:transitionMaskImage: (the loaded-image state folded into the appearance memo signature so the async completion re-applies). Unit: marker identity, source extraction (require / uri / nested / none), source signature.
  • Investigate transpiling to ES5 for Hermes #8 onAttached / onDetached (2f5b8643) — a JS approximation of the native ScreenStackHeaderConfig attach/detach lifecycle: a useEffect in NativeScriptScreenStackItem keyed on the header config's presence (null ↔ non-null), reading handlers through the live ref. Documented as a close timing approximation, not the exact native attach moment. Unit (surface suite): onAttached on mount + onDetached on unmount; callback-less config mounts/unmounts safely.

Gates (final combined state): npm test 193/193, npm run test:rnav 19/19; itest --suite core 11/11, itest --suite parity 11/11 (baselines unchanged — the new scroll-edge-effects probe is invokable via --scenario and kept out of the auto-run suites), plus scroll-edge-effects PASS on the iOS 26 sim (BF759806). scripts/metagen.js absent from every commit.


onTransitionProgress + useTransitionProgress (task #67)

Per-frame transition-progress parity in the single-file worklet engine, without reanimated — a fake UIView animated alongside the transition + a CADisplayLink sampling its presentation-layer opacity (upstream RNSScreen.mm ~1711). Staged as four commits on branch transition-progress, merged as merge(#2).

Display-link spike (Commit 0, ca62d45c) — GO. The one unproven primitive (targeting a CADisplayLink from a worklet against a NativeScript-minted ObjC selector) ticks: on-sim (BF759806, iOS 26) the isolated display-link-spike scenario armed a link and counted armed=1, ticks=30 in 500ms (~60fps). So the full per-frame reporter landed — the start/end-only fallback was not needed. Spike kept invokable via --scenario display-link-spike, out of the auto-run suites.

Commit 1 (1f2340a7) — reporter + emits + gate + unit tests. installTransitionProgressReporter runs from the stack controller's willShow delegate, gated on a participant consuming progress; a retained fake view is animated alpha 0→1, a CADisplayLink samples presentationLayer.opacity each frame and emits onTransitionProgress {nativeEvent:{progress, closing, goingForward}} to both participants (partner's closing inverted). All native reads try/catch-guarded; the alongside/completion blocks return void. Pure helpers unit-tested via __TEST_INTERNALS__: clamp, the gate predicate, payload shape, partner closing-inversion.

Commit 2 (fb812c4a) — useTransitionProgress. ScreenStackItem provides a per-screen TransitionProgressContext (three Animated.Values), driven by the reporter's frames; the hook returns it and registers the consumer, falling back to the frozen inert triple with no provider. The controller element stays outside the freeze boundary. Surface tests: provider-less returns the inert triple; inside a ScreenStackItem returns the screen's live triple.

Commit 3 (af3dde49) — on-device hardening + itest. On-sim bring-up revealed the willShow runtime is more restricted than the runOnUI spike runtime, so the reporter was hardened into the shape that actually ticks and emits there:

  • Resolve QuartzCore classes via getClass (bracket access does not resolve CADisplayLink/NSRunLoop); mint the link target via ctx.actionTarget and create a fresh, live link per transition against a pre-warmed reused target (a paused-then-unpaused link does not resume; a target minted mid-transition does not tick that transition).
  • Drive the fake view with UIView.animateWithDuration on the nav controller's view instead of the transition coordinator (intermittently nil at willShow here).
  • Gate on a per-screen flag written by the item via a runOnUI hop (a host prop that flips after mount does not reliably re-sync; the direct UI-runtime write always lands and keeps untouched apps byte-identical).

On-sim sample trace (GO evidence): the transition-progress parity scenario asserts, via a native-fact probe of the reporter's own recorded per-frame trace, ≥3 strictly monotonic in-between frames 0→1, final===1, goingForward===true on the push — captured live: push[n=22 distinct=22 final=1.000 gF=true closing=false] (and 13–14 frames on other launches). The covered/revealed screen's Animated.Value identity drifts under react-navigation's subtree reattachment, so the engine trace (not a JS observer) is the reliable on-sim channel; the closing-inversion is unit-tested.

Gates: npm test 200/200, npm run test:rnav 19/19; itest --suite core 11/11, itest --suite parity 12/12 (adds transition-progress; all prior parity scenarios still green → byte-identical for untouched apps). scripts/metagen.js absent from every commit. Mainline codex/rn-module-fabric-turbomodule-worklets @ 82da063a.


Stacked modal chain (task #68)

Closes the last structural divergence from upstream RNS. react-navigation marks every default-presentation route after a modal as modal (getModalRoutesKeys), so navigating onward from a sheet must present a new sheet (the stacked-cards cascade). The engine previously sliced at the first modal and pushed everything after it inside ONE presented navigation controller; it now presents a chain of controllers, one presented sheet per onward modal navigation — matching upstream's swipe-down semantics and dismissCount.

Design. One state machine per stack (UIKit serialises presentations → exactly one UIKit op in flight ever, the same safety property that closed the stranding race), advancing the chain one leg-edge per drain.

  • Registry: the five modal singletons (stackModalNavigationControllers / stackModalKeys / stackModalPresentationDelegates / stackSheetInitialDetentApplied / stackSheetConfigKeys) collapse into stackModalChain: Record<stackId, ModalLeg[]>, where each leg carries its own {navController, idsKey, delegate, sheetInitialApplied, sheetConfigKey} — so stacked formSheets keep independent sheet state.
  • splitModalLegs(ids, registry) (pure): base = ids before the first modal; each leg = one modal-presentation screen + its following push screens.
  • modalMachineAction(phase, desiredDepth, presentedDepth): in presented, deeper → present-next-leg (host for leg k = chain[k-1].navController), shallower-to-0 → dismiss (the single full-teardown issue point, unchanged), shallower-to-k≥1 → dismiss-to-depth (ONE UIKit dismiss on chain[target] collapses everything above = upstream changeRoot), equal → modal-sync (the deepest leg's interior only).
  • Per-leg delegate + sheet config; presentationControllerDidDismiss (a top-sheet swipe) collapses exactly one leg. completeModalDismissalToDepth(target) truncates the chain, restores the base only at depth 0, emits onDismissed with the collapsed screen count, and trims a stale deeper desire so a boundary drain can't re-present a just-dismissed leg (the interactive-swipe race).
  • Kill switch: MODAL_CHAIN_DEPTH_CAP — set back to 1 and the machine returns to exactly today's push-inside-one behavior.

Staged (each independently green): (1) chain-of-one refactor, zero behavior change; (2) splitModalLegs + leg-based desire capped at depth 1; (3) enable depth N; (4) chain-aware imperative pop/repair; (5) itests.

Merge gate (clean-boot, lane 1 iOS 26):

gate result
jest (package unit + surface) 217/217
test:rnav (react-navigation native-stack) 19/19
itest core 11/11
itest parity (incl. new modal-chain, modal-chain-rapid) 14/14
modal-chain-rapid rapid-cadence end_home_clean 15/15 = 100%

On-sim chain evidence (modal-chain): present sheet A, navigate to B → both markers (itest modal marker + itest modalB marker) present = two separately-presented sheets; a host swipe-down dismisses only B → routes [Home, Modal] (onDismissed count 1, A stays presented); goBack → clean [Home]. modal-chain-rapid drives A→B→reset-to-Home at 250/350/450 ms ×3 ×5 reps — every cycle settled on a clean [Home] (the stranding regression guard). No destructive watchdog revived; single dismiss-issue-point and observed-state invariants preserved.

@coderabbitai

coderabbitai Bot commented Jun 29, 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: ec5d2be6-815c-4c95-aa95-e9bccd213e7b

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 codex/rn-module-fabric-turbomodule-worklets

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 30 commits July 24, 2026 05:06
The three pure, index-based detent resolvers mirror upstream
`helpers/sheet.tsx` (resolveSheetAllowedDetents /
resolveSheetLargestUndimmedDetent / resolveSheetInitialDetentIndex) verbatim
in behaviour, with one deliberate deviation: they NEVER THROW — a worklet
throw wedges the push, so every value upstream `__DEV__`-throws on returns the
upstream production fallback instead. Adds `sheetDetentIndexFromIdentifier`
(identifier -> index, mirroring RNSScreen.mm detentIndexFromDetentIdentifier)
and `sheetConfigSignature` (the modal-sync re-apply memo key).

Declared above `configureModalNavigationController` so no later consumer
worklet captures a dead closure. The engine-transformer auto-exports them to
`__TEST_INTERNALS__`; full enum-table unit tests assert the mappings and the
never-throw fallbacks. Zero native surface (nothing calls these yet).

jest 147/147 (+15), test:rnav 19/19.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…(Wave 3 #54, Stage 2)

The `configureSheetPresentation` worklet applies a presented modal's sheet /
detent configuration to its real `UISheetPresentationController`, on the
SYSTEM-detent path (iOS 15 medium/large), mirroring RNSScreen.mm
`updateFormSheetPresentationStyle` L932-967 — the same degradation RNS itself
ships when custom detents are unavailable. Gated on `formSheet` (upstream
applies sheet config ONLY for formSheet; pageSheet uses system defaults), so no
existing modal/pageSheet route is affected. Every native touch is null-guarded
and try/catch-wrapped; the controller is read AFTER the presentation style is
written (materialisation-order hazard). A `sheetConfigSignature` memo
short-circuits the modal-sync re-apply, which runs on every reconcile.

Wiring (by enclosing function):
- declared above `configureModalNavigationController` (capture order);
- registry keys `stackSheetInitialDetentApplied` / `stackSheetConfigKeys`,
  cleared in `completeModalDismissalBookkeeping`, `dropNeverTakenModal` and the
  stack host `dispose`;
- call site 1: `presentModalStack`, after
  `configureModalNavigationController`, before
  `installModalPresentationDelegate`, animate:false;
- call site 2: `drainModalStack` `'modal-sync'` branch, after
  `applyStackModel`, animate:true (no re-present — a live setOptions re-apply).

Machine safety: pure presentation-controller property writing — never issues
present/dismiss, never writes phase/epoch/queued, never touches superview or
viewControllers, so it cannot reintroduce the stranding race.

Unit tests drive it against a fake sheet controller: count-based detent
selection, grabber/corner/scrolling-expands, the initial-detent one-shot,
the signature short-circuit, the animateChanges path, and the formSheet gate.

jest 158/158 (+11), test:rnav 19/19.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…age 3)

Extends the ONE presentation-controller delegate the modal machine owns
(`installModalPresentationDelegate`) to also report detent changes. A sheet PC
is detected by null-checking `presentationController.detents` (the valid
availability probe — NOT a `typeof selector` guard); for a sheet PC the
delegate is created against `UISheetPresentationControllerDelegate`, which
inherits the adaptive protocol so the three dismissal methods keep flowing.
A try/catch retry falls back to the adaptive protocol if the sheet protocol
cannot be resolved, so the pre-Wave-3 dismissal behaviour is preserved
verbatim.

The new `sheetPresentationControllerDidChangeSelectedDetentIdentifier` handler
maps the settled identifier back to an index (auto-detecting system vs custom
identifiers) and emits `onSheetDetentChanged` ({index, isStable:true}) on the
modal root's screen context. Machine-safe: it writes no phase/epoch/queued,
issues no present/dismiss, touches no view — a swipe-collapse fires ONLY this
event, a swipe-past-smallest fires ONLY presentationControllerDidDismiss, so
the two can never masquerade.

jest 162/162 (+4 delegate mapping), test:rnav 19/19.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… detent (Wave 3 #54)

Two on-device corrections to the Wave 3 sheet path, verified against the
screens-itest `sheet-detents` scenario:

1. Capture-order hazard. The pure detent resolvers were declared BELOW
   `installModalPresentationDelegate`, whose Stage-3 detent handler is a
   worklet that calls `resolveSheetAllowedDetents` /
   `sheetDetentIndexFromIdentifier`. A worklet capturing a later-declared
   helper serializes a dead closure that throws on call, so the handler threw
   before emitting — `onSheetDetentChanged` never reached JS even though UIKit
   invoked the delegate method (confirmed with a native fire-counter). Moved
   the five pure helpers above every consumer (the delegate handler,
   `configureSheetPresentation`, `presentModalStack`, `drainModalStack`).

2. Initial detent. UIKit defaults a two-system-detent (medium+large) sheet to
   `large`, contradicting the documented `sheetInitialDetentIndex` default of 0
   (the first/smallest detent). `configureSheetPresentation` now selects the
   initial detent EXPLICITLY on the system path (index 0 -> medium), so a
   `[0.5,1.0]` form sheet opens at medium as documented and the drag to large
   is observable.

On device the `sheet-detents` scenario now passes end to end: opens at medium
(probe selDetent 0), a scroll-to-edge drag expands to large AND fires
`onSheetDetentChanged` index 1, then dismisses to a clean [Home]. jest 162/162,
test:rnav 19/19; the delegate protocol swap leaves swipe-down-syncs and the
modal machine green (core 11/11).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Promotes `sheet-detents` from an xfail stub to a real, on-device asserting
parity scenario and adds `sheet-grabber-visible` and `sheet-corner-radius`.
All three are GREEN on the simulator; the parity suite is now 8/8 and core
stays 11/11.

Harness:
- `dragY|<fromYFrac>|<toYFrac>` verb (center-x vertical drag) in
  session.js `fulfillGesture`.
- auto-gesture SEQUENCE support (an ordered set fired by elapsed time since the
  trigger appears, armed once) alongside the existing single form.

Demo (react-navigation.tsx):
- `SheetDetents` route — a real formSheet whose sheet config is driven by route
  params so one route serves all three scenarios. Content is a ScrollView the
  sheet tracks (`sheetExpandsWhenScrolledToEdge`), so a host scroll-to-edge drag
  resizes it between detents.
- native-fact probe worklet (`NativeScript.runOnUI`, result marshalled back
  through the Promise — no runOnJS) that reads the live
  `UISheetPresentationController` off the engine registry and reports grabber /
  corner radius / current detent — facts JS cannot otherwise see.
- `onSheetDetentChanged` wired via react-navigation's `sheetDetentChange` event.

Scenarios:
- sheet-detents: opens at medium (probe selDetent 0) -> scroll-drag expands to
  large, firing onSheetDetentChanged index 1 -> dismiss to clean [Home]. Verdict
  is emitted after dismissal so the covering sheet never hides it.
- sheet-grabber-visible: probe reads prefersGrabberVisible 1 (grabber:true) and
  0 for the negative twin.
- sheet-corner-radius: probe reads preferredCornerRadius 24, then a setParams
  flip to 4 re-applies on the LIVE sheet without re-presenting (the modal-sync
  drain path), route unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…h fallback (Wave 3 #54, Stage 5)

Spikes risk #1: iOS-16 `customDetentWithIdentifierResolver:resolver:` takes a
block that RETURNS a CGFloat, whereas every proven arg-block in the engine
returns void. A throwaway `sheet-detents-custom` scenario (gated on a `spike`
route param, isolated from the shipping detent scenarios) builds one [0.37]
custom detent, applies it to the live sheet via `NativeScript.runOnUI`, and
reports whether the sheet resized.

VERDICT — NOT viable on this runtime. Both spike runs HOST_TIMEOUT (~52s) with
no crash report: applying a custom detent whose resolver returns a CGFloat HANGS
the worklet bridge, and the sheet never dismisses. So the engine SHIPS the
system-detent path (medium/large), the exact degradation upstream RNS falls back
to when custom detents are unavailable — no change to the shipping
`configureSheetPresentation`.

The reproducer is kept invokable (`--scenario sheet-detents-custom`) but removed
from every auto-run suite so it never hangs a gate run; a future runtime that
fixes primitive-returning arg-block marshalling can re-verify (a pass reports a
non-medium heightFrac ~0.34-0.40). Limitation filed in the scenario comment and
suites.js.

Gates unaffected: core 11/11, parity 8/8 (incl. sheet-detents /
sheet-grabber-visible / sheet-corner-radius), jest 162/162, test:rnav 19/19.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e-for-modal + pixel gates

Stage 1 of task #65 (remaining RNS animation/transition parity):

(a) ios_from_left routing — it was misrouted to UIKit's native trailing-edge
    push. Upstream maps both slide_from_left and ios_from_left to
    RNSScreenStackAnimationSlideFromLeft (RNSConvert.mm L82-84), so add it to
    stackAnimationUsesCustomAnimator + stackAnimationIsInverted and the animator
    body's slide-from-left branch → correct inverted horizontal slide.

(b) none for MODAL present / dismiss / push-inside-modal — three argument-only
    masks with screenDisablesAnimation on the governing screen (root modal on
    present, departing top on dismiss, new-top-when-growing / departing-top-when-
    shrinking on modal-sync). Mirrors upstream's `stackAnimation != None` gate
    (RNSScreenStack.mm L473-474 / L529-537). Machine phase/epoch/issuance
    untouched — only the animated argument to UIKit changes.

(c) delete the stale modalTransitionStyle "PARITY DEBT" comment (the push
    variants have landed).

(d) anim-frames promoted to a real pixel gate: FRAME_CHECKS['anim-frames']
    (before>180 / mid∈[90,190] / settled<70), AnimFadeSlow body darkened to
    #08111f so the fade separates by luminance, and the mid-fade capture retimed
    to the fade's midpoint. This is also the first-nav-animates gate (every
    scenario cold-launches).

(e) anim-none scenario — 3 push/pop cycles, each captured frame reads EITHER
    light (Home, >180) OR dark (AnimNone, <70), never an intermediate blend.

jest 162/162, test:rnav 19/19.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…eplace window-guard

Stage 2 of task #65:

- multi-edge push: `isPush` required exactly +1, so a single commit landing >=2
  routes (a coalesced navigate, or a cold-launch deep link straight to a nested
  route) fell to the non-animated tail. Relaxed to `nextCount > previousCount`
  with prefix equality; when more than one edge lands, seat every controller
  below the new top non-animated (configuring the full target stack) and animate
  only the final push — upstream RNSScreenStack.mm L657-663. The common
  single-push path is byte-identical: the multi-edge seating is gated behind
  `nextCount > previousCount + 1`.

- replace window-guard: a push-replace now animates only when the outgoing top's
  view is in a window (`previousTop.view.window != nil`, upstream L636). Ours
  always animated, which snapshots an unmounted view into the wrong superview
  when a replace lands mid-transition.

- §H jest pack: classification mirror relaxed + multi-edge push cases (and the
  non-prefix-growth = replace guard).

jest 163/163, test:rnav 19/19.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…t probe

Stage 3 of task #65 (test-only). A new `anim-fade-modal` parity scenario proves
the modal `stackAnimation` -> `modalTransitionStyle` mapping via a native-fact
probe (the only Release channel that can see this native property):

- three full-screen (OverFullScreen) modal routes ModalFadeFull / ModalFlipFull
  / ModalPlainFull, each with fade / flip / no stackAnimation.
- probeModalTransitionStyle() runs a NativeScript.runOnUI worklet that reads the
  presented modal navigation controller's live modalTransitionStyle off the
  engine registry, published per-route (modalStyle_<route>) so the scenario reads
  a race-free, non-stale value for exactly the modal that is up.
- scenario asserts 2 (CrossDissolve=fade) / 1 (FlipHorizontal=flip) / 0
  (CoverVertical=default), plus a host content-mount check and a bonus,
  non-gating AUTO_CAPTURE mid-dissolve frame.

jest 163/163, test:rnav 19/19. Demo app/ files typecheck clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Stage 4 of task #65 (highest risk — vends an animator on a default screen's
interactive pop). All new behavior is gated behind EXPLICIT opt-in, so default
navigation stays byte-for-byte on the stock edge gesture.

- fullScreenSwipeEnabled (EXPLICIT-true only; deliberately NOT upstream's iOS26
  undefined->YES): shouldOfferCustomInteractivePop now also offers the engine
  full-width pan for a screen that opted in, even a default (non-custom-animator)
  one. A new stackFullWidthSwipeInFlight[stackId] flag, raised at pan-Began
  before the pop, makes the animator delegate vend a simple_push for that pop so
  the pan can scrub it (else a full-width swipe on a default screen pops with no
  animation). The stock edge-gesture decline is extended to hand the swipe over.
  Watchdogs (armInteractiveResumeWatchdog + the transition watchdog) bound the
  worst case to a snapped pop, never a wedge.

- gestureResponseDistance: pure port of upstream isInGestureResponseDistance
  (start/end/top/bottom, -1/omitted = no constraint, RTL x-mirror) applied in the
  pan's gestureRecognizerShouldBegin; an unset box always allows.

- swipeDirection: 'vertical' — the pan reads Y translation/velocity with
  distance = view height and no RTL flip.

- customAnimationOnSwipe — the animator-selection half only (partial), as scoped.

New helpers declared ABOVE all callers (capture-order hazard). jest 169/169,
test:rnav 19/19.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rop scenario

Stage 5 of task #65. A new `anim-swipe-props` parity scenario exercises the
Stage 4 gesture props on DEFAULT (native-slide) screens:

- FullSwipe (fullScreenGestureEnabled -> RNS fullScreenSwipeEnabled): a
  full-width `swipeWide` pops it, proving the engine offered its pan to a default
  screen and vended a simple_push animator for the interactive pop.
- GestureDistance (also gestureResponseDistance.top: 350): a swipe that BEGINS
  near the top (y≈175pt, above 350) no-ops for a full hold; the same swipe begun
  below (y≈542pt) pops.

Both screens are pushed programmatically BEFORE any swipe (from a settled stack),
and the two interactive pops are the terminal actions — no programmatic push
follows an interactive pop. That ordering matters under the custom-stack adapter:
the native stack is the source of truth, so a push issued while the previous
interactive-pop reconcile is still settling can be dropped and syncs the JS route
back (observed as a deterministic push failure in an earlier draft; GestureDistance
itself mounts fine, so gestureResponseDistance was never the cause).

Adds a `swipeAt|<xStartFrac>|<yFrac>` coordinate verb to session.js so the
scenario can place a swipe's begin-location inside / outside the response box.
Demo option names use the react-navigation spellings (fullScreenGestureEnabled)
that the fork's native-stack maps to the RNS Screen props. Demo app/ files
typecheck clean; jest 169/169, test:rnav 19/19. Verified on-sim (BF759806).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Public `ScreenStack.onFinishTransitioning` (RNS parity tail #3). Upstream
fires it from `didShowViewController:` and the modal present/dismiss
completions. This engine emits `onNativeStackFinishTransitioning` from the two
transition-settle chokepoints:

  - `finishTransition` — push / pop / interactive-cancel, AND the modal-present
    completion (which routes through `finishTransition`).
  - `completeModalDismissalBookkeeping` — modal dismiss (does not route through
    `finishTransition`).

Emitted exactly once per settle via `ctx.emit`; a no-op unless the host
forwarded the public handler, so a stack that never sets `onFinishTransitioning`
is byte-identical. The host `NativeScriptScreenStack` accepts the public
`onFinishTransitioning` prop and forwards it to the controller as
`onNativeStackFinishTransitioning`.

Unit: fake-ctx emit capture at all three semantic points (push/pop end, cancel,
modal dismiss). jest 172/172, test:rnav 19/19.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Public `ScreenStack.nativeContainerStyle` (RNS parity tail #4). iOS-only
background color for the UINavigationController's view — a separate native view
from the stack content. Threaded host → stack controller and applied in
createController + update via `applyNativeContainerStyle`, mirroring upstream
RNSScreenStack.mm L1275-1277: write when provided, reset to the platform
default on removal, and NEVER touch the container of a stack that never set it
(byte-identical). The write decision is a pure `nativeContainerBackgroundDecision`
(apply / reset / none) so the mapping is unit-testable without a UI runtime.

No itest: the container background sits behind the fully-opaque stack content
and carries no accessibility identity, so it is not a11y-observable on device.

Unit: the pure decision across provided / removed / never-set / create-time.
jest 176/176, test:rnav 19/19.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…iOS-26)

Closes the `HeaderBarButtonItem` styling holes (RNS parity tail #7):

- `titleStyle` — resolved to NSAttributedString title attributes (font + color)
  via the existing header font/attribute helpers and written for the normal +
  highlighted control states (mirrors RNSScreenStackHeaderConfig.mm
  setTitleAttibutes:forButton:). Stock react-navigation sends `titleStyle` on
  every custom bar item today, so this is the common path. `null` for a button
  without `titleStyle` keeps it byte-identical.
- iOS-26 item fields `hidesSharedBackground` / `sharesBackground` / `identifier`
  / `badge`, each behind a read-first property probe (present as its own type on
  iOS 26+, undefined otherwise) so the write never dispatches an unavailable
  selector. `badge` builds a `UIBarButtonItemBadge` inside a class probe +
  try/catch; best-effort styling.
- Marker-level `hidesSharedBackground` on `ScreenStackHeader{Left,Right}View` —
  declared on the markers but never applied. Now captured in
  `findHeaderSlotElements`, threaded through the slot host to the registry, and
  applied to the slot's custom-view bar button item behind the same read-first
  probe.

Unit: titleStyle content/attribute decision, the normal+highlighted write, the
iOS-26 read-first gates (modern vs legacy barItem), and the marker capture.
jest 182/182, test:rnav 19/19.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Public `Screen.scrollEdgeEffects` (RNS parity tail #5). Maps a screen's per-edge
`automatic`/`hard`/`soft`/`hidden` onto the UIScrollEdgeEffect of the screen's
first descendant UIScrollView, applied from `configureScreenController`:

- FAST PATH — `scrollEdgeEffectsAllAutomatic` returns for the descriptor default
  (react-navigation always sends all-`automatic`), so every current screen is
  byte-identical: no scroll-view walk, no writes, no memo.
- `firstDescendantScrollView` — bounded (depth 8) walk reusing `isNativeScrollView`.
- Availability gated by a read-first PROPERTY probe (`scrollView.topEdgeEffect
  == null`) inside try/catch — never a typeof-selector check. On older iOS the
  signature is memoized so the walk does not repeat.
- `scrollEdgeEffectValue` maps to `{ style, hidden }` (UIScrollEdgeEffectStyle
  automatic=0/hard=1/soft=2, `hidden` flag) with numeric fallbacks.
- Memoized per screen by the four-edge signature; cleared on screen dispose.

Unit: the mapping, the all-automatic fast path (proves no walk), the four-edge
signature, and the no-memo-when-no-scrollview retry contract. Device native-fact
probe scenario follows in the itest pass (CI sim is iOS 26). jest 187/187,
test:rnav 19/19.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…uttonImage)

Turns the `ScreenStackHeaderBackButtonImage` null stub into a real engine marker
(RNS parity tail #6). react-navigation renders
`<ScreenStackHeaderBackButtonImage source={backImageSource}/>` inside the
header-config children; the engine now:

- reads the `source` off it (`findBackButtonImageSource`, mirroring the
  `<SearchBar>` channel walk) and threads it onto the sanitized header config as
  `backButtonImage` (part of the per-screen configure signature);
- loads it asynchronously via `ctx.loadImage` from `configureScreenController`
  (`configureBackIndicatorImage`), guarding duplicate loads and stale callbacks
  by a per-screen source signature;
- on completion stores the `UIImage` and re-applies the screen's navigation-item
  appearance, whose memo busts because the loaded-image state (`#0`->`#1`) is
  folded into its signature, then installs it via
  `setBackIndicatorImage:transitionMaskImage:` on the UINavigationBarAppearance.

A screen without a custom back image contributes no source and is byte-identical
(no loader touch, no appearance change). Source cleared on removal; image + memo
released on screen dispose. `index.ts` re-exports the marker from the engine.

Unit: marker identity + null render, source extraction (require number / uri /
nested fragment / none), and the source signature. Device: the chevron is
unlabeled (a11y cannot assert it), so covered by unit + code review per design.
jest 191/191, test:rnav 19/19.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Public `ScreenStackHeaderConfig.onAttached` / `onDetached` (RNS parity tail #8).
Upstream fires these on the native ScreenStackHeaderConfig view attach/detach;
this engine drives the navigation bar directly and has no such view, so
`NativeScriptScreenStackItem` approximates them with a `useEffect` keyed on the
header config's presence (null <-> non-null): `onAttached` when a config first
appears, `onDetached` on cleanup (config removed or screen unmount). Handlers are
read through the live ref so react-navigation rebuilding the config every render
neither re-runs the effect nor calls a stale closure, and the attach-time config
is captured so a REMOVED config (its ref already nulled) still delivers its
`onDetached`. Documented as a close timing approximation, not the exact native
attach moment.

Unit (surface suite, component-level): onAttached on mount + onDetached on
unmount; a callback-less config mounts/unmounts without throwing. jest 193/193,
test:rnav 19/19.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds the on-simulator device scenario for scrollEdgeEffects (parity tail #5).
A `ScrollEdge` react-navigation route sets `scrollEdgeEffects: { top: hard,
bottom: soft, left: hidden, right: automatic }`; a `NativeScript.runOnUI`
native-fact probe (`probeScrollEdgeFacts`) finds the top screen's first
descendant UIScrollView off the engine registry and reads back the applied
UIScrollEdgeEffect — proving end-to-end that `applyScrollEdgeEffects` found the
RN scroll view and the properties landed on iOS 26.

Registered in the app's ALL list (EXTRA) and host `suites.js` SCENARIOS so it is
invokable via `--scenario scroll-edge-effects`, but deliberately kept OUT of the
auto-run core/parity suites so those baselines stay at 11/11 (this proves a NEW
feature, not a regression).

On the iOS 26 CI sim (BF759806): PASS — `top=1 bottom=2 leftHidden=1`.
Regression sweep on the same build: core 11/11, parity 11/11.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Closes six small in-scope RNS parity holes (task #66), one staged commit each,
each independently green on jest + test:rnav + itest core/parity:

  #3 onFinishTransitioning        stack settle event
  #4 nativeContainerStyle         iOS container background
  #7 bar-button styling tail      titleStyle + iOS-26 badge/hidesSharedBackground/
                                  sharesBackground/identifier + marker hidesSharedBackground
  #5 scrollEdgeEffects (iOS 26)   per-edge UIScrollEdgeEffect + device probe
  #6 headerBackIcon               ScreenStackHeaderBackButtonImage (async ctx.loadImage)
  #8 onAttached / onDetached      header lifecycle (JS approximation)

Gates on the final combined state: jest 193/193, test:rnav 19/19, itest core
11/11, itest parity 11/11, plus the new scroll-edge-effects device probe PASS
(top=1 bottom=2 leftHidden=1) on the iOS 26 sim. All additive and gated on props
the stock demo does not set, so normal flows are byte-identical.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The one unproven primitive behind per-frame onTransitionProgress parity —
targeting a CADisplayLink from a worklet against a NativeScript-minted ObjC
target/selector — is now proven. `display-link-spike` arms such a link (the
same NSObject.extend + exposed-selector mechanism ctx.actionTarget uses under
the hood), lets it tick ~500ms on the main runloop (default mode), counts the
callbacks, invalidates, and asserts >10.

On-sim (BF759806, iOS 26): armed=1 ticks=30 in 500ms (~60fps) → GO. The full
per-frame reporter is feasible without reanimated.

Kept out of every auto-run suite; invoke via `--scenario display-link-spike`.
All blocks return void (the Wave-3 block-return-hang constraint holds).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ess reporter

Ports upstream's own mechanism (RNSScreen.mm ~1711) to the worklet engine
WITHOUT reanimated: a retained fake UIView animated alpha 0→1 alongside the
transition coordinator, sampled every frame by a CADisplayLink reading its
layer.presentationLayer.opacity — that opacity is the transition progress. The
display-link spike (Commit 0) proved the targeting primitive ticks (~60fps).

installTransitionProgressReporter is called from the stack controller's willShow
delegate ONLY when the navigation controller has a live, animated transition
coordinator AND a gate (screenWantsTransitionProgress) passes — either
participant has onTransitionProgress != null. A stack that sets nothing arms
nothing and stays byte-identical (verified: reporter early-returns before any
native read). Per frame it emits onTransitionProgress {nativeEvent:{progress,
closing, goingForward}} to BOTH participants, the partner's `closing` inverted
(upstream's per-screen semantics). Completion invalidates/pauses the link,
removes the fake view, and emits the settled progress-1 frame.

ONE per-stack CADisplayLink + target/action is minted lazily (via ctx.actionTarget,
whose retention the context owns) and toggled `paused` per transition — so an app
using the feature does not leak a target per crossing. Both registry slots are
torn down in the stack dispose (the link is invalidated off the runloop). Every
native read is try/catch-guarded; both coordinator blocks return void (the
Wave-3 block-return-hang constraint holds); the display link is UIKit-driven
(no re-arming JS timer loop).

Pure helpers exercised via __TEST_INTERNALS__: clamp, the gate predicate, the
payload shape, and the two-participant emit incl. partner closing-inversion.

jest 198/198 (was 193, +5), test:rnav 19/19.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ransitionProgressContext

Upgrades the inert `useTransitionProgress` stub to a live, engine-backed hook.
ScreenStackItem now provides a per-screen `TransitionProgressContext` holding
three module-stable `Animated.Value`s (progress/closing/goingForward), wraps the
(freezable) screen content in the provider (the controller element stays OUTSIDE
the freeze boundary — react-freeze rule), and drives the values from the native
reporter's `onTransitionProgress` frames (coercing the boolean closing/
goingForward to the 0/1 the Animated.Values carry) while also forwarding to a
user `onTransitionProgress`.

Byte-identical lever: the item wires the native `onTransitionProgress` (which
arms the reporter's gate) ONLY when a participant consumes progress — a live
`useTransitionProgress` consumer registers via the context's `__registerConsumer`
(bumping a per-screen consumer count) OR the user sets `onTransitionProgress`. A
screen nobody consumes wires nothing and stays byte-identical; the provider-less
default is the frozen inert triple (a read outside any screen degrades to "no
progress" rather than throwing, matching this port's non-throwing policy).

`useTransitionProgress` (index.ts) reads the context and registers the consumer;
`TransitionProgressContext` + `inertTransitionProgress` are exported. Surface
tests: provider-less returns the inert triple; inside a ScreenStackItem returns
that screen's live (non-inert) triple.

jest 200/200 (+2 surface), test:rnav 19/19.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…g + transition-progress itest

On-sim bring-up (BF759806, iOS 26) hardened the per-frame reporter into the
shape that actually ticks and emits on the willShow runtime, and adds the
`transition-progress` itest that proves it.

Reporter changes (device-verified):
- Resolve QuartzCore classes via a new `nativeClass` (native-api `getClass`) —
  `nativeValue`'s bracket access does not resolve `CADisplayLink`/`NSRunLoop`.
- Mint the display-link target via `ctx.actionTarget` (the willShow runtime lacks
  the wrapped-`NSObject.extend` globals the runOnUI spike had); create a FRESH,
  LIVE `CADisplayLink` per transition against a pre-warmed reused target — a link
  created paused then unpaused does NOT resume here, and a target minted
  mid-transition does not tick that transition, so it is pre-warmed when the
  screen is flagged.
- Drive the fake view's alpha 0→1 with `UIView.animateWithDuration` on the nav
  controller's view instead of the transition coordinator (which is intermittently
  nil at willShow on this runtime); the display link samples presentation-layer
  opacity each frame. Both animation blocks return void.
- Gate on a per-screen `registry.screenWantsProgress` flag written by the item via
  a `runOnUI` hop when a `useTransitionProgress` consumer registers — a host prop
  that flips after mount does not reliably re-sync on this runtime, but the direct
  UI-runtime write always lands and keeps untouched apps byte-identical.
- The item always wires the (inert-unless-emitted) handler from mount so
  `ctx.emit` finds a real callable, and records the affected screen's per-frame
  `{ p, c, g }` into a bounded per-stack `stackProgressTrace` reset each install.

itest `transition-progress` (parity suite): the recorder screen consumes
`useTransitionProgress` (flagging it); the step cycles a plain push/pop over it
and asserts, via a native-fact probe of the reporter's own trace, ≥3 strictly
monotonic in-between frames 0→1, final===1, goingForward===true on the push. The
covered/revealed screen's Animated.Value identity drifts under react-navigation's
subtree reattachment, so the engine trace (not a JS observer) is the reliable
on-sim channel; the closing-inversion is unit-tested.

jest 200/200, test:rnav 19/19, itest core 11/11, parity 12/12 (transition-progress
GREEN: 22 monotonic per-frame samples, gF=true).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…havior change

Replace the five modal singletons
(stackModalNavigationControllers / stackModalKeys /
stackModalPresentationDelegates / stackSheetInitialDetentApplied /
stackSheetConfigKeys) with a single `stackModalChain: Record<stackId,
ModalLeg[]>`, where each leg carries its own {navController, idsKey, delegate,
sheetInitialApplied, sheetConfigKey}. This is the structural groundwork for
stacked modal chains; the chain is always length <= 1 here, so behavior is
byte-identical to today's push-inside-one presentation.

Engine:
- New pure accessors (declared above every caller, capture-order safe):
  modalChainOf / modalChainDepth / topModalLeg / topModalController /
  topModalIds / modalLegForController / modalChainContainsController /
  pushModalLeg / truncateModalChain.
- Every former `stackModalNavigationControllers[stackId]` read -> topModalController
  / modalChainDepth; the singleton writes -> pushModalLeg (presentModalStack,
  the ONE set site) and truncateModalChain(…, 0) (completeModalDismissalBookkeeping,
  dropNeverTakenModal, host dispose). modal-sync updates the top leg's idsKey.
- Per-leg sheet state: configureSheetPresentation / installModalPresentationDelegate
  address the leg via modalLegForController; stackVisibleScreenIds /
  modalChainContainsController walk all legs.

Tests: unit fakes seed stackModalChain; new "modal chain accessors" block locks
in depth / top reads / per-leg containment / push / truncate (jest 205/205).

Harness: the demo's native-fact sheet / modal-transition probes read the top leg
of stackModalChain instead of the removed singleton (behavior-neutral proof:
itest core 11/11, parity 12/12).

Gate: jest 205/205, test:rnav 19/19, itest core 11/11 + parity 12/12 (all green).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… depth capped at 1

Introduce the leg segmentation the chain machine will drive, still behind a
depth cap of 1 so behavior is byte-identical to Stage 1 (push-inside-one).

- splitModalLegs(ids, registry): pure. base = ids before the first modal; each
  leg = one modal-presentation screen + its following push screens.
- capModalLegs(legs, cap): folds overflow legs into the last kept leg. At
  MODAL_CHAIN_DEPTH_CAP === 1 all modal legs collapse into one = the whole modal
  tail in one controller (today's behavior). MODAL_CHAIN_DEPTH_MAX reserved for
  the Stage 3 flip.
- ModalDesire now carries {base, legs} (capped); recordModalDesire builds it.
  The imperative pop's wants-no-modal desire uses empty legs (desiredDepth 0).
- modalMachineAction(phase, desiredDepth, presentedDepth): the presented phase
  now branches deeper -> present-next-leg, shallower-to-0 -> dismiss (unchanged
  single issue point), shallower-to-k>=1 -> dismiss-to-depth, equal -> modal-sync.
  present-next-leg / dismiss-to-depth are UNREACHABLE at cap 1.
- drainModalStack computes desiredDepth = legs.length, presentedDepth =
  modalChainDepth, resolves the leg to present (index presentedDepth), and
  returns on the two new (inert) actions. At cap 1 only try-present / modal-sync
  / dismiss / queue / cancel / requeue fire — exactly Stage 1.

Tests: splitModalLegs / capModalLegs segmentation, the extended action truth
table (incl. present-next-leg / dismiss-to-depth / single-dismiss invariant),
and the reshaped recordModalDesire (jest 216/216).

Gate: jest 216/216, test:rnav 19/19, itest core 11/11 + parity 12/12 (all green,
behavior-neutral on device).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…d modal nav

Lift MODAL_CHAIN_DEPTH_CAP (1 -> MODAL_CHAIN_DEPTH_MAX), the single kill switch,
and implement the depth->=2 machine edges so navigating onward from a sheet
presents a NEW stacked sheet (upstream's stacked-cards cascade) instead of
pushing inside one. Reverting the cap to 1 restores today's shipped behavior.

Machine / drain:
- present-next-leg: presents leg `presentedDepth` onto chain[presentedDepth-1]'s
  controller (base for leg 0) via the shared `tryIssueLegPresent` — same
  readiness deadline + host-idle gate + presenting/fan/presented cycle as the
  first present. One UIKit op in flight ever (phase serialises).
- dismiss-to-depth: collapses every leg above a target in ONE UIKit dismiss on
  chain[target] (upstream changeRoot). `dismiss` (full teardown, target 0) stays
  the single dismiss-issue-point; dismiss-to-depth is a distinct partial collapse.
- modal-sync now syncs only the DEEPEST leg's interior (applyStackModel gains a
  `legIds` override) — never the whole tail into the top controller.
- stackModalDismissTarget records the collapse depth for the observed-gone
  finalizer; completeModalDismissalBookkeeping -> completeModalDismissalToDepth:
  truncates to target, restores the base ONLY at depth 0, keeps the base covered
  for a partial collapse, emits onDismissed with the collapsed screen count, and
  trims a stale deeper desire so a boundary drain cannot re-present a just-
  dismissed leg (the interactive-swipe race).
- presentationControllerDidDismiss (a top-sheet swipe) collapses exactly ONE leg
  (target = depth-1); dropNeverTakenModal drops only the top (never-taken) leg.

Tests: extended machine truth table already covers present-next-leg /
dismiss-to-depth; recordModalDesire now segments a multi-modal tail into a leg
per modal; completeModalDismissalToDepth signature (jest 216/216, rnav 19/19).

On device: modal-chain (present A -> navigate B = a SECOND stacked sheet; a
swipe-down dismisses ONLY B -> [Home,Modal]; goBack -> clean Home) PASS; the
single-sheet / sheet-detent / anim / statusbar flows all unchanged (itest core
11/11, parity 14/14). modal-chain-rapid end_home_clean 15/15 = 100%.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Make the exported `requestNativeScriptStackPop` chain-aware (it is not on the
stock react-navigation reconcile path today, but is part of the engine's public
surface, so it must be correct for a chain):

- Scan the chain for the leg hosting the target controller (was: top leg only),
  so a pop targeting ANY leg resolves. A presented-modal target not yet in a leg
  (the pre-present race) falls back to the top leg.
- Interior pop WITHIN a leg pops that leg's own controller.
- Dismissing a whole leg now COLLAPSES the chain to that leg's depth
  (dismiss-to-depth) instead of always tearing the whole chain down: it records a
  desire whose legs are the surviving depth and arms the drain. It records desire
  + arms only (never mutates viewControllers inline), so it does NOT assume a
  settled stack — the boundary drain runs it once the host is idle (the known
  push-after-interactive-pop reconcile race).

`repairNativeScriptStackAfterDismiss` is already chain-correct: it repairs the
base only when the chain is empty (depth 0), so a partial collapse that leaves
lower legs presented correctly short-circuits ('modal-active').

Test: a 2-leg chain [Home|A|B] popping B's leg records legs [[A]] (depth 1),
wantsModal, base [Home], suppresses the emit, and arms the drain (jest 217/217,
rnav 19/19).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ests

The on-sim proof of the stacked-modal chain, plus the rapid stranding guard.

Demo (vendored harness):
- New `ModalB` route (presentation formSheet) + `ModalBScreen` with its own
  "itest modalB marker", so navigating Modal -> ModalB stacks a SECOND sheet.
- The demo's native-fact sheet / modal-transition probes already read the top
  leg of `stackModalChain` (adapted in stage 1).

Scenarios (parity suite):
- modal-chain: present A -> navigate B (a second stacked sheet). The chain is
  proven by ROUTES — a host swipe-down dismisses ONLY the top sheet B ->
  [Home,Modal] (onDismissed count 1); under the pre-#68 push-inside-one the same
  swipe tears down the whole sheet -> [Home] (count 2). goBack -> clean Home.
  (A native runOnUI depth probe was dropped: runOnUI hangs when awaited from an
  assert while a sheet is presented — a harness limitation, not an engine one;
  the route discriminator is stronger anyway.)
- modal-chain-rapid: A->B->reset-to-Home at 250/350/450ms x3; each cycle settles
  on a clean [Home] — the 2-deep chain's full teardown must not strand a sheet.

Host side: `AUTO_GESTURE['modal-chain']` swipes the top sheet autonomously (its
AWAIT handshake is unreadable under the sheets), `HOST_CHECKS['modal-chain']`
cross-checks both markers appear, and both scenarios are registered in the
parity suite + host catalog.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Land the stacked-modal-chain (task #68), the last structural divergence from
upstream RNS. Five staged commits (each green on jest + test:rnav + itest core +
parity): chain-of-one refactor (behavior-neutral) -> splitModalLegs + leg-based
desire capped at 1 -> enable depth N -> chain-aware imperative pop -> itests.

Merge gate (clean-boot lane 1): itest core 11/11, parity 14/14 (incl. new
modal-chain + modal-chain-rapid), modal-chain-rapid end_home_clean 15/15 = 100%,
jest 217/217, test:rnav 19/19.

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