feat: add React Native UIKit runtime primitives#46
Draft
DjDeveloperr wants to merge 120 commits into
Draft
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 |
Two upstream-parity gaps in the package's public surface, both of which
crashed stock consumers at the use site rather than degrading:
ITEM 2 — missing exports. `InnerScreen`, `ScreenContext`,
`ScreenStackHeaderSubview`, `ScreenContentWrapper` and `useTransitionProgress`
were absent, so `import { X } from 'react-native-screens'` resolved to
`undefined` and threw when used. Added as documented stubs consistent with the
existing stub style: pass-throughs for the screen wrappers, a null renderer for
the header subview, `React.createContext(InnerScreen)` for `ScreenContext`
(matching upstream components/Screen.tsx L395), and a `useTransitionProgress`
that returns a stable inert `{ progress, closing, goingForward }` of
Animated.Values — upstream's exact TransitionProgressContext shape — instead of
throwing the way upstream does outside a native-stack screen.
ITEM 3 — flag shapes. `featureFlags` had no `stable` section, so any
`featureFlags.stable.<x>` read threw; `compatibilityFlags` was empty. Both now
mirror upstream src/flags.ts verbatim in key names and defaults (experiment:
synchronous screen/header-config/header-subview updates, the two Android flags,
and the three deprecated always-true iOS flags; stable: debugLogging;
compatibility: isNewBackTitleImplementation, usesHeaderFlexboxImplementation,
usesNewAndroidHeaderHeightImplementation, usesStableTabsApi). Flags are
readable and writable — stock expo-router assigns to `experiment.*` during
init. Upstream's duplicate-write console.error diagnostic is omitted.
Verified in Release on the custom-stack demo (sim BF759806): an on-screen probe
read every new export and flag back as defined with upstream's values, across
an 8-cycle push/pop/present/dismiss loop with 0 new crash reports.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`enableFreeze`/`freezeEnabled` were no-ops, so inactive screens kept re-rendering — a pure-JS gap against upstream, which freezes them via react-freeze's `Freeze` wrapped in `components/helpers/DelayedFreeze.tsx` (see upstream components/Screen.tsx L215-277). react-freeze is not a dependency of this package, so its mechanism is vendored into the engine rather than added as one: a child that throws a never-settling thenable suspends its Suspense boundary, which keeps the subtree mounted (state survives) while React stops rendering it. `DelayedFreeze` mirrors upstream by allowing one more render before freezing, so the screen's final `activityState` reaches the native side first; unfreezing stays immediate. `ScreenStackItem` now resolves upstream's semantics — `shouldFreeze` overrides the activity-state heuristic, otherwise a screen freezes once `activityState` is 0, all gated by `freezeOnBlur`, which defaults to the module-level `enableFreeze()` toggle. Both props are consumed here and never forwarded to the native controller; they are render-gating, not screen attributes. The toggle state lives in a new src/core.ts (mirroring upstream's own core.ts) so the engine can read `freezeEnabled()` without importing index.ts, which re-exports the engine. Only the screen's React content sits inside the boundary. The controller element stays outside it, so neither controller registration, the Fabric commit that delivers `hostId` (what actually mounts content), nor the pop snapshot can be suspended. Verified in Release on the custom-stack demo (sim BF759806). With freezing armed, an on-screen probe ticking Home every 250ms showed Home's max inter-render gap jump from 271ms idle to 1066-1116ms — matching the Detail dwell — while pushed, popped and modal screens all still rendered their content and the header items survived. Shipped default is off (upstream's `ENABLE_FREEZE = false`); an 8-cycle push/pop/present/dismiss loop at that default was clean, with 0 openurl timeouts and 0 new crash reports. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Four gaps in how `stackPresentation` reached UIKit, all of which made a modal route behave unlike upstream react-native-screens. ITEM 1 -- `modal` had no branch at all and fell through to `UIModalPresentationOverFullScreen`: edge-to-edge, no sheet inset, and not interactively dismissible. Upstream maps it to `UIModalPresentationAutomatic` (RNSScreen.mm L231), which resolves to a page sheet on iPhone. It now does the same, and mirrors upstream's iOS 18 `sheetPresentationController .prefersPageSizing` (RNSScreen.mm L233-243), guarded by a typeof read so an OS without the property is left alone rather than throwing through the interop bridge. ITEM 2 -- `pageSheet` had no branch either; it now maps to `UIModalPresentationPageSheet`. ITEM 4 -- no `UIAdaptivePresentationControllerDelegate` was installed anywhere, so a user-initiated dismissal was invisible to JS. That was latent only because `OverFullScreen` cannot be swiped away; making `modal` a page sheet turns it into a hard requirement, since UIKit would otherwise remove the sheet while react-navigation kept the route and every later present ran against state that still believed the modal was open. `installModalPresentationDelegate` now installs one on the presented navigation controller's presentation controller (after the style is set -- reading `presentationController` materialises it for the current style and it cannot be swapped afterwards). `presentationControllerDidDismiss:` routes through the same bookkeeping as a programmatic dismiss, so `onDismissed` -> `StackActions.pop` fires exactly as it does for the Dismiss button. UIKit does not send that selector for a programmatic `dismissViewControllerAnimated:`, so it cannot double-fire. `presentationControllerShouldDismiss:` returns true and carries the documented seam where Wave 1's `preventNativeDismiss` will hook in. To keep the two dismissal paths honest, the tail of `dismissModalStack`'s completion -- registry teardown, base-stack restoration, transition and dismissed emits -- moved verbatim into `completeModalDismissalBookkeeping`, which both callers share. The delegate additionally sweeps any tagged modal view before restoring, since UIKit has already torn the presented view down by the time it runs. ITEM 5 -- `transparentModal` mapped to `OverFullScreen` correctly but `configureScreenController` then forced `systemBackgroundColor` onto the screen's view, so nothing behind it could show. Transparent presentations now default to `clearColor` (an explicit `backgroundColor` is still honoured) and the presented navigation controller's own view is made clear and non-opaque. ITEM 3 -- NOT taken. Upstream maps `fullScreenModal` to `UIModalPresentationFullScreen` (RNSScreen.mm L253); this engine cannot yet. Under true `FullScreen` UIKit removes the presenting view controller's view from the window once the transition settles, and that view is the React Native root surface. The modal's content is reparented into the presented controller so it still renders, but `RCTSurfaceTouchHandler` lives on the now-detached root: the presented screen took no touches at all in Release on a clean launch, while the identical screen under `pageSheet` did. `OverFullScreen` is visually indistinguishable and keeps the presenting view attached, so the mapping stays there with the reason recorded at the branch. Verified in Release on the custom-stack demo (sim BF759806), against a control build of the same demo at HEAD. Control: `presentation:'modal'` renders edge-to-edge full screen. This build: it renders as an inset page sheet with rounded corners and a swipe-down dismissal that syncs JS state -- swiping the sheet down returned the on-screen route readout to `depth=1 Home`, left no orphaned route, and a subsequent present worked immediately. `pageSheet` and `fullScreenModal` render and dismiss correctly and distinctly. An 8-cycle push/pop/present/dismiss loop was clean: Home settled with content and header items, 0 `GO_BACK was not handled`, 0 new crash reports. Known remaining gap, pre-existing and out of scope: the base stack does not render while any modal is presented, so the area above a page sheet is UIKit's dimming view over an empty base rather than the previous screen, and `transparentModal` reveals the window rather than the screen beneath it. The opaque-paint fix above is necessary for that parity but not sufficient on its own. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three RNS parity gaps in the NativeScript stack engine, all in
NativeScriptScreenStack.tsx.
ITEM A — two-sided lifecycle events + initial mount.
The transition carried a single screenId, so react-navigation only ever
saw one side of a transition: a push emitted appear events for the
incoming screen but never told the covered screen it disappeared, a pop
told the outgoing screen it disappeared but never told the revealed
screen it appeared, and the first (animated:false) reconcile emitted no
lifecycle events at all. Now the transition carries BOTH participants
(incoming + outgoing) via a `partnerScreenId`, and `handleNativeTransition`
delivers to each screen its correct onWillAppear/onAppear /
onWillDisappear/onDisappear. `syncAppearedScreen` emits the missing appear
pair for a screen that reaches the top without an animated transition —
the initial mount above all — guarded by a per-stack appeared-screen
marker so it fires exactly once per change of top screen. Upstream emits
from both transition participants (RNSScreen.mm viewWill/DidAppear/Disappear).
ITEM B — onHeaderHeightChange delivery.
react-navigation passes an `Animated.event(..., {useNativeDriver:true})`
OBJECT, not a function: RN's Animated.event returns the AnimatedEvent
instance (not a callable) whenever __isNative is true, so the previous
`typeof === 'function'` gate dropped it and the event never fired.
Delivery now accepts both shapes — calls a function handler directly,
otherwise unwraps the AnimatedEvent via `__getHandler()` (RN's own
unwrap path) and applies the arg-mapping to the driven Animated.Value in
JS. The worklet-side emit gate now tests presence, not callability.
ITEM C — preventNativeDismiss / gestureEnabled + cancel events.
`presentationControllerShouldDismiss:` returns false when the top modal
screen sets preventNativeDismiss, else the top screen's gestureEnabled,
and `presentationControllerDidAttemptToDismiss:` emits onGestureCancel
plus onNativeDismissCancelled — mirroring RNSScreen.mm. Modals set
`modalInPresentation = preventNativeDismiss || !gestureEnabled` so UIKit
refuses the interactive sheet dismissal outright (RNSScreen.mm L314-319).
The interactive pop gesture is declined for a gestureEnabled:false top
screen, and for a preventNativeDismiss top screen it is declined while
emitting the cancel pair (declining keeps native and JS in step rather
than letting UIKit detach the screen and reattaching it). The native back
button is intercepted for preventNativeDismiss screens via
UINavigationItem.backAction, which fires the cancel pair instead of
popping. Without this, usePreventRemove/beforeRemove silently failed.
Verified in a Release build on the custom-stack demo: initial screen
receives appear events at mount; a push delivers disappear to the covered
screen and a pop delivers appear to the revealed screen; onHeaderHeightChange
delivers the native-measured height (react-navigation passed the
AnimatedEvent object, confirming the inference); modal presents with
content and the preventNativeDismiss mechanisms (modalInPresentation=true,
backAction installed) are wired. Non-regression: normal push/pop leaves a
clean root, modal present/dismiss matches HEAD, zero new crashes. The
pre-existing fast present->dismiss blank-modal reproduces identically on
stock HEAD.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ds the modal `60924dac` mapped `modal` to `UIModalPresentationAutomatic` and `pageSheet` to `UIModalPresentationPageSheet`, matching upstream RNS (RNSScreen.mm L231/L245). Under this engine's present/dismiss race a *sheet* style leaves the modal stuck on screen permanently, so both fall back to `OverFullScreen` again. Bisected frame-accurately on the custom-stack demo (Release, sim BF759806), `end_home_clean` = the app is back on a clean Home when the cycle ends: ca7018e 100% (45/45) cbf946c 100% (10/10) 60924da 0% (0/7) <- first bad commit 8e36c81 38% (17/45) The race: the modal is presented from the tab-bar host while the base navigation controller is still mid-transition, so UIKit defers attaching the presented view ("A transition was started while preempting previous transition") by 530-1780ms. The route's dismissal lands at ~1.9s, frequently inside that gap, and UIKit refuses `dismissViewControllerAnimated:` outright while a presentation is in flight. `dismissModalStack`'s 420/700ms watchdogs then fire anyway: they pull the navigation controller's view out of UIKit's presentation container while the container itself stays presented, and clear `stackModalNavigationControllers[stackId]` so no later dismissal can reach it. Under a page sheet that is a blank sheet that can never be dismissed (`end_cdark` still 0.0007 at 16.9s with `REC_SECS=24` — permanent, not slow). Under `OverFullScreen` the same race is survivable: the modal is merely invisible and is then torn down, so the app always returns Home. A parked-dismiss hand-off was tried first and rejected on measurement: park a dismissal that arrives while `isBeingPresented` and replay it from `presentViewController`'s own completion block (single hand-off, no re-arming timer). It drives UIKit's dismiss rejections to zero, but `end_home_clean` only reached 40% (10/25) on top of `8e36c817`, because the presentation often never completes within the route's lifetime at all. Deferred presentation is the thing that has to be fixed; refusing to give up the sheet before then just trades one stuck state for another. Kept from `60924dac`: the `UIAdaptivePresentationControllerDelegate` (ITEM 4), `completeModalDismissalBookkeeping`, and the transparent-presentation background work (ITEM 5). `8e36c817`'s ITEM C — `preventNativeDismiss` / `gestureEnabled` / `onGestureCancel` / `onNativeDismissCancelled`, `modalInPresentation`, `UINavigationItem.backAction` — builds on that delegate and is unchanged. `presentationControllerShouldDismiss` and `modalInPresentation` are inert for `modal` now that it is not interactively dismissible, but still apply to `formSheet` / `containedModal`, and `backAction` is unaffected. Forfeited parity, to retry once the presentation deferral is fixed: `modal` as an inset swipe-dismissible page sheet, `pageSheet` as a real page sheet, and the iOS 18 `sheetPresentationController.prefersPageSizing` write. Measured, 25 graded cold Release cycles per build after a 10-cycle host-warmup skip, both orders on freshly rebooted sims, healthy batches (splash ~1.0-1.3s, video 5-7MB): end_home_clean this commit 100% (25/25) control-first, 100% (25/25) revert-first | 8e36c81 28% (7/25) | ca7018e 100% modal presented this commit 32-36% | 8e36c81 28% | ca7018e 20-28% Separately noted, NOT addressed here and NOT caused by this commit: cumulative Detail-blank (`blank_total_ms` >= 200ms) is 68% on unmodified `8e36c817` and 80-84% here, against 12-20% on `ca7018e5`, consistently in both run orders on a clean host. An earlier A/B concluded there was no Detail-render regression, but that run measured the control at 56-60% on a loaded host; on a clean host the control is 12-20% and every HEAD-derived build is 56-84%. That points at a real content-mount regression somewhere in `cbf946cc..8e36c81` (react-freeze is the first suspect) and wants its own bisect. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…Animation `stackAnimation` was read nowhere in the engine -- it rode through `...rest` into `registry.screenProps` and every one of the 11 variants coerced to the UIKit default. This lands the subset that does not need a custom `UIViewControllerAnimatedTransitioning`: - `none` -> non-animated push/pop. Upstream expresses this as a zero-duration animator (RNSScreenStackAnimator.mm L60-62); `animated: false` is visually identical and cannot leave a frame of movement behind. The pop-path snapshot is skipped when non-animated, since it only exists to cover an animation. Measured against 3ff911a interleaved: the control slides across ~6 frames at 30fps, this build swaps Home -> Detail in a single frame. - modal `fade` / `flip` -> `modalTransitionStyle` (CrossDissolve / FlipHorizontal), mirroring `-[RNSScreenView setStackAnimation:]` (RNSScreen.mm L290-312). Verified on video: the control covers vertically, this build cross-dissolves in place. - `replaceAnimation` -> the replace is now animated in both directions, synthesised the way upstream does (RNSScreenStack.mm L623-654): `push` is a plain animated `setViewControllers:`, `pop` seats the new stack with the outgoing controller still on top and then pops it, hiding the back button when the root is being replaced. Previously every replace took the non-animated `setViewControllers` fall-through. NOT landed, and deliberately so: the five variants that require the stack's `UINavigationControllerDelegate` to vend an animator from `navigationController:animationControllerForOperation:...` -- `simple_push`, `slide_from_left`, `fade`, `slide_from_bottom`, `fade_from_bottom` -- plus `transitionDuration`, which can only reach UIKit through that animator. A worklet implementation of `UIViewControllerAnimatedTransitioning` was built against the upstream curves and measured twice (fully exception-guarded, then additionally retaining the in-flight `UIViewPropertyAnimator`s the way upstream's `_inFlightAnimator` does) and both times left the navigation controller wedged -- blank content, empty navigation bar, no recovery -- whenever the animator's completion failed to reach `completeTransition:`. The rationale and the two candidate routes forward are recorded above `modalTransitionStyle`. `ios_from_right` / `ios_from_left` / `slide_from_right` / `default` correctly resolve to the platform default on iOS and need no code. Screens that set no `stackAnimation` are unaffected: `screenDisablesAnimation` returns false, the push/pop stay animated, and the replace branch cannot fire on a push or a pop. No `UINavigationControllerDelegate` method was added and neither `installNativeBackGestureDelegate` nor `updateNativeBackGesture` was touched, so the interactive pop is unchanged by construction -- confirmed on device: a full edge swipe pops, a short edge swipe cancels and stays put (proving UIKit is still tracking the finger), and a cancel followed by a full swipe pops cleanly with transition events still firing. Non-regression, Release, interleaved against 3ff911a on a splash-matched batch: end_home_clean 26/26 across three A/B runs (10/10 on the final pristine run, splash 1146-1161ms vs control 1128-1190ms), 0 new .ips. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…, blur, direction, UI style
react-navigation's `useHeaderConfigProps` flattens the navigation theme's
`fonts.bold` / `fonts.heavy` into the header config, so `titleColor`,
`titleFontFamily`, `titleFontSize`, `titleFontWeight` and
`experimental_userInterfaceStyle` arrive on EVERY screen, themed app or not
(native-stack useHeaderConfigProps.tsx L551-554, L560). The engine consumed
none of them, so `headerTitleStyle` and the theme's header fonts were dropped
silently on every app. Closes that gap by mirroring
`+[RNSScreenStackHeaderConfig buildAppearance:withConfig:]`
(react-native-screens ios/RNSScreenStackHeaderConfig.mm L352-455).
Fields closed:
- titleColor / titleFontFamily / titleFontSize / titleFontWeight
-> `titleTextAttributes`, gated exactly as upstream so an unstyled screen
still gets no write at all.
- largeTitleColor / largeTitleFontFamily / largeTitleFontSize /
largeTitleFontWeight -> `largeTitleTextAttributes` (kept separate from the
title attributes, falling back to `titleColor`, as upstream does).
- largeTitleBackgroundColor / largeTitleHideShadow -> a separate
`scrollEdgeAppearance`, built only when one of the two is set so screens
without them keep sharing the one appearance object.
- backTitleFontFamily / backTitleFontSize -> back bar button title
attributes across every control state, including upstream's two quirks
('System' is not a customised family; a size with no family means BOLD).
- blurEffect -> `UIBlurEffect` on the appearance.
- disableBackButtonMenu -> a lazily built `UIBarButtonItem` subclass that
swallows UIKit's back-menu assignment, the same mechanism as upstream's
RNSBackBarButtonItem.
- direction -> `semanticContentAttribute` on the navigation controller's
view and bar.
- experimental_userInterfaceStyle -> `overrideUserInterfaceStyle`.
Supporting changes:
- `nativeColor` now parses `rgb()` / `rgba()`. This is the form
react-navigation's own themes are written in, so without it every themed
app's `titleColor` (and `color`, and `backgroundColor`) collapsed onto the
fallback colour.
- A named family plus an explicit weight resolves through the family's
members by closest weight trait, mirroring `+[RCTFont updateFont:...]`
(RCTFont.mm L478-491). Asking a font descriptor for the weight instead
silently returned the regular face -- verified against upstream, which
renders Georgia + '700' bold.
- The appearance block is memoised on a signature of the fields it reads,
keyed by the navigation controller's native hash. It runs many times per
crossing, and rebuilding it every call cost ~400ms of cold-launch time and
cut measured modal dwell from ~860ms to ~170ms. With the memo, splash is
1195.8ms vs 1181.9ms for the control (n=8 interleaved pairs), modal dwell
median 841.6ms vs 859.1ms, end_home_clean 8/8 both, stalled 0/8 both.
Verified in Release on sim BF759806 against nativescript-uikit-demo-original
(upstream RNS 4.25.2) driving the same temporary probe routes: the header
bands for the styled-title, large-title, blur and back-menu screens are
pixel-identical to upstream apart from the status-bar clock (~0.62% of
subpixels, constant across all four). Non-regression: descriptor header items
render and fire (Ping 2, Menu action 1), push/pop/present/dismiss clean, back
swipe pops and a short cancel swipe does not, pop shows outgoing content,
0 new .ips.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…gine-owned detached container (Wave 0 keystone) A screen's React `headerRight` element now renders in the real UIKit navigation bar, at its own size, and receives taps. The element arrives on `headerConfig.children` (stock `useHeaderConfigProps` wraps it in `ScreenStackHeaderRightView`). It is pulled out on the JS side and mounted, by Fabric, into an engine-owned `UIView` that is never attached to the view hierarchy. Once that container reports it has children, it is wrapped in a `UIBarButtonItem` customView and prepended to `rightBarButtonItems`. Only the engine-owned container is ever moved, so no Fabric-managed view is reparented. Notes on the two things that actually made this work: - `UIBarButtonItem` lays its custom view out with Auto Layout, and a plain `UIView` reports no intrinsic content size, so the bar collapsed the container to 36x0 and nothing rendered even though the item was installed. Real width/height constraints (kept in the registry, not as expandos on the native proxy) are what survive that layout pass; `onLayout` updates their constants so the item tracks the element's measured size. - The bar-button signature memo now includes the slot revision. `hostReady` fires well after the controller is first configured, so without it the memo swallowed the install. `headerConfig` is forwarded to the controller with `children` stripped and memoized on a serializable signature, so React elements never reach the host-props path and the identity churn react-navigation produces every render no longer re-runs the native configure pass. Verified in Release on the custom-stack demo: element renders at its own size beside the descriptor Ping/Menu items, taps increment the React counter (three taps, three increments), the item widens as its content grows, it installs on cold launch without retries, and push / back-swipe / modal present / dismiss stay clean with no crash reports. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…(Wave 0 slots 2 and 3) Generalizes the Wave 0 slot-hosting primitive from the single headerRight slot to all three iOS header positions. A screen's `headerLeft`, `headerTitle` and `headerRight` React elements are each mounted by Fabric into their own engine-owned detached `UIView`, which the engine then installs as a `UIBarButtonItem` custom view (left/right) or as `navigationItem.titleView` (center). As before, only the engine-owned container is ever moved — no Fabric-managed view is reparented. - Slot storage is now keyed per (screen, side) via `headerSlotKey()`; the revision counter stays per screen so any slot change invalidates the bar-button memo. - `configureHeaderTitleView` installs/clears the center container and tracks the last-installed view by CONTROLLER hash, since that map describes the native object's state rather than a screen's. It is called immediately before the `navigationItem.title` write in `configureScreenController`, and `applyHeaderSlots` re-writes the title after calling it — a title view assigned after the scalar title is not laid out on iOS 16, which is the same ordering upstream RNSScreenStackHeaderConfig.mm enforces. - Left slots compose with the existing descriptor items and back-button logic: the hosted container leads `leftBarButtonItems` and descriptor items follow inboard of it, matching upstream's subviews-then-configs order. Screens with neither keep UIKit's native back button untouched. - `findHeaderSlotElements` collects all three sides in one pass and rows up multiple elements per side, since `useHeaderConfigProps` emits one marker per `custom` entry in `unstable_header*Items`. - `ScreenStackHeaderLeftView` / `ScreenStackHeaderCenterView` are now real marker components rather than null stubs. Verified in Release on the simulator against the upstream RNS reference app: the custom title renders centered at its own size with a header band that is layout-identical to upstream; left and right hosted elements route taps and resize live; screens without a left slot keep the native back button and back-swipe. The blank-content-after-modal-cycles symptom observed during the loop reproduces identically on the parent commit and is pre-existing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ll pass `-[NativeScriptUIView layoutDetachedChildrenViewSubviewsAndReturnMutation]` stamps `subview.frame = bounds` and a flexible autoresizing mask onto every direct child of `_childrenView`. For a child under Auto Layout ownership (`translatesAutoresizingMaskIntoConstraints == NO`) this both fights the constraint-driven size on first layout AND — once the flexible mask is set — makes UIKit's own `-[UIView(Geometry) _resizeWithOldSuperviewSize:]` re-stretch the child on every subsequent superview bounds change (the "decays after a few interactions" symptom). The existing guards miss this: `preserveDetachedChildrenLayout` protects a container's CHILDREN, not the container itself, and the direct-children loop never consults `NativeScriptSubviewShouldFillParent`, so the `|origin| >= 1` exemption never applies to a direct child. Constraint-pinning alone did not help — the pass still overwrote the frame and the container oscillated. Fix: in that loop, skip any subview whose `translatesAutoresizingMaskIntoConstraints == NO` — write neither its frame nor its mask. This is a minimal early-continue placed after the sentinel skip and before both the `preserveDetachedChildrenLayout` guard and the frame/mask writes, so behavior for TAMIC == YES subviews (all normal hosted content) is byte-identical. Engine-owned slot containers set TAMIC = NO precisely to claim this exemption. Verified non-regression on the shared runtime: react-native-screens port jest unchanged (409/411; the 2 failures are pre-existing flakes on an untouched package), and the main-port-demo pop-wedge gate is identical between a control at 8779df6 (25/25 completed, 0 wedges, median 102ms) and this fix (25/25 completed, 0 wedges, median 98.9ms) on the same warmed host. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…lots
Replaces the `Passthrough` stubs for `ScreenFooter` and `FullWindowOverlay`
with real engine-owned UIKit slot hosting, built on the same `defineUIKit-
Container` + `attachController={false} attachNativeView={false}
emitOffWindowHostReady preserveDetachedChildrenLayout` primitive as the Wave 0
header slots. Both containers set `translatesAutoresizingMaskIntoConstraints
= NO` to claim the detached-children fill-pass exemption the TAMIC-skip
runtime fix creates, which is what keeps them from being stretched.
ScreenFooter (react-navigation `unstable_sheetFooter` / `FooterComponent`):
- The footer subtree is mounted into an engine-owned container installed as a
bottom-pinned subview of the screen controller's own `view`, pinned
leading/trailing/`safeAreaLayoutGuide.bottomAnchor` with a height constraint
driven from the footer's measured `onLayout` height. Constraints are kept in
the registry (never as expandos on NS proxies).
- `unstable_sheetFooter` is destructured out of `NativeScriptScreenStackItem`
so the render function never reaches the serializable host-props path; the
owning `screenId` reaches `ScreenFooter` through a React context that the
stack item provides. Install is driven from BOTH the footer host's
`hostReady` and `configureScreenController`, so either arrival order attaches.
FullWindowOverlay:
- The overlay's children are hosted in an engine-owned container added to the
key window and pinned to its bounds with constraints; JS sizes the hosted
subtree to the window dimensions. Pass-through is achieved by overriding
`pointInside:withEvent:` via the runtime `__extendClass` (a plain, non-worklet
nested function so it keeps `this`) so the container answers YES only over a
hosted subview, combined with `pointerEvents="box-none"`.
- DOCUMENTED LIMITATION (not fixed): the overlay is hidden for the duration of
a UIKit modal present and does not return after dismissal until it is
remounted — upstream RNSFullWindowOverlay shares this; the real fix is a
dedicated higher-level UIWindow this API cannot yet vend.
All new worklets keep helper-above-caller capture order and are fully
exception-safe in host-ready/dispose bodies.
Verified in a Release build of the custom-stack demo, driven via agent-device:
footer renders pinned at its own height and its taps route; the overlay covers
the whole window incl. the nav bar, its taps route, and empty-area taps pass
through to the content beneath; the detached-children stretch does NOT recur
across >10 push/pop interactions with the footer mounted; header slots, back-
swipe pop and modal present/dismiss stay clean; 0 new crash logs. The
react-native-screens port jest baseline (409/411, 2 pre-existing flakes) is
untouched, and the runtime TAMIC fix this builds on is committed separately.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ck + package unit/surface suites Stand up host-side jest (no simulator/device) for @nativescript/react-native-screens, borrowing the react-navigation repo's installed toolchain (NativeScriptRuntime ships no jest). Three green suites: - jest/native-stack.config.js — React Navigation's own native-stack suite with react-native-screens aliased to OUR package (19/19). - jest/package.config.js — package unit suite (pure engine logic via a test-only __TEST_INTERNALS__ transform) + a react-native-screens public-surface behavioral suite (52/52). Test-only mock for @nativescript/react-native (defineUIViewController / defineUIKitContainer render children; runOnUI runs inline) plus a tabs stub and an engine transformer that drops the worklets plugin and appends __TEST_INTERNALS__. The engine source (src/NativeScriptScreenStack.tsx) is NOT modified — no runtime behavior change. Commands (from packages/react-native-screens, RN_NAV_REPO overrides the repo): npm test # unit + surface npm run test:rnav # React Navigation native-stack Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implement the five animated `stackAnimation` variants that until now fell back to the UIKit default (`simple_push`, `slide_from_left`, `fade`, `slide_from_bottom`, `fade_from_bottom`), driven by the `transitionDuration` prop, via a single engine-owned `UIViewControllerAnimatedTransitioning` vended from the stack's existing navigation delegate. Plan A - upstream-shaped and wedge-proof: - Extend (do NOT replace) the willShow/didShow nav delegate with `animationControllerForOperation:` (returns the custom animator ONLY for the five variants; nil for default/slide_from_right/ios_from_*/none/flip so every native path is byte-for-byte untouched) and `interactionControllerForAnimationController:`. - The animator runs its motion through `UIView.animateWithDurationAnimationsCompletion` (the BOOL-arg block channel proven in this engine), NOT `UIViewPropertyAnimator` (its enum-arg completion is the marshalling failure that wedged prior attempts). Transforms / subview ordering / final frame mirror RNSScreenStackAnimator.mm; `fade_from_bottom` keeps the two-phase slide+fade with upstream's proportions. - ONCE-guarded completion + a single bounded, never-re-arming watchdog (duration + 250ms) so a completion that never arrives snaps the final state and force-completes: worst case is a snapped frame, never a wedged stack. - Engine-owned full-width `UIPanGestureRecognizer` + `UIPercentDrivenInteractiveTransition` for back-swipe survival under a custom animation, delegate-gated to (custom-animation AND gestureEnabled AND depth>1). The stock edge gesture yields to it for custom screens and is otherwise untouched. New pure helpers (`stackAnimationUsesCustomAnimator`, `customAnimatorDurationSeconds`, `stackAnimationIsInverted`, `shouldOfferCustomInteractivePop`) are surfaced via `__TEST_INTERNALS__` and covered by 10 new package unit tests (variant->animator selection, ms->seconds duration mapping + fallback, interactive-controller gating). Host-side only: `npm test` 62/62, `npm run test:rnav` 19/19. On-device verification of the animations, completion firing, and interactive back-swipe is pending (orchestrator). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…s (statusBar / orientation / homeIndicator / hideKeyboardOnSwipe) Previously these RNS props rode through `...rest` into `screenProps` and were never consumed. Enforce them package-side, mirroring upstream ios/RNSScreenWindowTraits.mm + RNSScreen.mm: - Screens declaring a controller-level window trait (statusBarStyle / statusBarHidden / statusBarAnimation / screenOrientation / homeIndicatorHidden) are hosted by a UIViewController SUBCLASS built once via the runtime's `__extendClass` (`windowTraitScreenControllerClass`). Its getters — preferredStatusBarStyle / prefersStatusBarHidden / preferredStatusBarUpdateAnimation / supportedInterfaceOrientations / prefersHomeIndicatorAutoHidden — read the screen's live props from the registry (keyed by `restorationIdentifier`, which round-trips unlike a JS expando) and return the base UIKit default when a trait is unset. Screens declaring none keep the plain controller and are byte-identical. - `updateWindowTraits` fires the UIKit re-query (setNeedsStatusBarAppearance Update / setNeedsUpdateOfHomeIndicatorAutoHidden / setNeedsUpdateOfSupportedInterfaceOrientations on the top-most presented controller) whenever the top screen is (re)configured. A stack is only "armed" once it has hosted a trait screen, so a trait-free stack issues no extra calls. - `hideKeyboardOnSwipe` calls endEditing on the disappearing screen's view from the navigation-controller willShow closing branch (mirrors -[RNSScreen notifyWillDisappear]). Pure, unit-tested mapping tables encode the RCTConvert(RNSScreen) / RNSScreenWindowTraits raw-value tables (UIStatusBarStyle / UIStatusBar Animation / UIInterfaceOrientationMask) plus the unset→base-default and extend-on-demand predicates. Feasibility: PACKAGE-ONLY. `defineUIViewController.createController` lets the engine control screen-controller allocation, and `__extendClass` (napi ClassBuilder) already overrides UIViewController property getters against the metadata descriptors — no runtime change required. Extended the unit suite with the mapping tables (18 tests). Host-side jest: package unit+surface 70/70, React Nav native-stack 19/19. On-device verification PENDING (status bar style/hide, orientation lock, home indicator hide, keyboard dismiss on swipe). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…SearchController) Implement react-native-screens SearchBar support in the thin NativeScript stack adapter. `UISearchController` is a native object, not a hosted React view, so this takes the descriptor path (like the header appearance / bar-button items) rather than the header-slot element-hosting path. Engine (src/NativeScriptScreenStack.tsx): - New search-bar worklet section above `configureScreenController`: `buildScreenSearchController` creates the `UISearchController` once per screen and wires a `UISearchBarDelegate` (via `ctx.delegate`) that routes begin/end-editing, text-change, search-button and cancel to `ctx.emit`; `applySearchBarProps` maps placeholder / autoCapitalize / barTint|tint|text colours (gated on presence so no accidental fallback) / cancelButtonText / obscureBackground / hideNavigationBar; `applySearchBarPlacement` sets `navigationItem.searchBarPlacement`, typeof-guarding the iOS 26 `integrated*` variants; `configureSearchBar` (called from `configureScreenController`) installs the controller, sets `hidesSearchBarWhenScrolling` from `hideWhenScrolling`, and is memoized by controller-hash + config signature (folded into the existing per-screen configure pass). Command hops (focus/blur/clearText/setText/cancelSearch/toggleCancelButton) resolve the controller from the registry by screen id. - Registry gains `screenSearchControllers` / `screenSearchBarDelegates` / `screenSearchBarSignatures`; `clearScreenRecord` tears them down. - Real `SearchBar` (forwardRef exposing the `SearchBarCommands` ref as `runOnUI` hops) and `ScreenStackHeaderSearchBarView` components; `findSearchBarElement` pulls the `<SearchBar>` out of the header children and `searchBarConfigFromProps` extracts the serializable subset. The item threads that config onto the sanitized header config, keeps live props in a ref for event dispatch, and mounts the element under a screen-id context so its command ref wires up. Search-free screens are left byte-identical (stable null / same config object). Surface (src/index.ts): - Export the real `SearchBar` / `ScreenStackHeaderSearchBarView` (were null stubs) and flip `isSearchBarAvailableForCurrentPlatform` to `true` — the switch that makes react-navigation send `headerSearchBarOptions`. Tests: package suite 52 -> 69 (search config extraction, native configure + memo + teardown, delegate->emit routing, command hops, colour gating, placement guard, plus end-to-end mount + command-ref surface); native-stack suite stays 19/19 with the flag now true (a `UISearchController` build no-ops under jest since worklets never run there). On-device verification PENDING (orchestrator): search bar renders, typing fires onChangeText, focus/blur/cancel events, the six ref commands, and hideWhenScrolling. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… suite GREEN) Adds `itest`, a self-asserting on-device net for @nativescript/react-native-screens that drives the real custom-stack demo on the iOS Simulator and fails on regressions (no more screenshot-only verification). Three layers: - L1 in-app driver (vendored under demo/app/itest/): a scenario registry that drives navigation via a shared navigationRef and publishes a machine-readable verdict into a ROOT-LEVEL ProbeOverlay accessibilityLabel. Root-level RN elements ARE exposed to `agent-device snapshot -i` (spike-confirmed). - L2 host runner (lib/session.js): cold-launch per scenario via deep link, poll the probe label, fulfil AWAIT gestures (tapRef/tapXY/swipeBack), cross-check native/visual facts from the a11y tree. - L3 aggregation (run.js): fresh-marker Release build + Hermes-bundle freshness proof, sim shutdown/boot to shed degradation, warm-up, bounded retries, per-scenario JSON + summary, non-zero exit on any FAIL. core suite (already-landed behavior, all GREEN, exit 0): nav-stack, modal, header-native, header-react, footer, overlay, tabs, back-swipe. Not-yet-landed features are xfail stubs (anim-variants, sheet-detents, searchbar). Determinism: >=700ms cadence, 3.5s initial settle for the cold-launch content pop-in, transition-idle gating + stable-state asserts (kills the rapid same-route re-push reconcile flake), fresh cold launch per scenario, sim reset per run. agent-device is pinned to the simulator UDID. Demo-side files live in the non-git demo; vendored under demo/ with sync-demo.sh (apply/capture/diff). See README.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…aits, search bar Promotes the `anim-variants` / `searchbar` xfail stubs into real asserting scenarios and adds the demo-side probe screens they drive, plus two new scenarios (`anim-interactive`, `anim-frames`) and a reworked `statusbar`. Demo (vendored under demo/, applied with sync-demo.sh): - AnimFade / AnimFadeSlow / AnimNone / AnimSlideBottom routes, one per `stackAnimation` under test, each with an accessibility marker so the host can prove the pushed screen's CONTENT mounted (not just that route state flipped). - Search route with `headerSearchBarOptions` (placeholder + onChange/onFocus publishing itest signals). - StatusLight / StatusDark / StatusHidden, all sharing one dark full-bleed background so a screenshot of the top strip isolates the status bar itself. - runner: a PASSING step's `detail` now rides out on the terminal DONE label, so a green run can report the numbers it asserted on. Scenarios: - anim-variants asserts BEHAVIORALLY, not by timing. 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). What IS crisp is which gesture path each variant gets — the engine offers its own full-width pan only for screens it hands to a custom animator — so the suite uses that as a decision procedure: a mid-screen drag pops `fade`, does nothing on `none` or on the default screen, and the stock EDGE drag still pops the default. The last two are the regression guard that default navigation never entered the custom-animator path. - anim-frames captures in-flight frames of a 6s fade push: 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. - statusbar pushes each variant FROM HOME and pops back. Chaining them was measured to leave the first variant's appearance in place for the rest, so a chained run cannot tell "trait applied" from "previous trait never replaced". - searchbar is asserting but `xfail`: the search bar renders, but focusing it aborts the app (`setShowsCancelButton` selector unavailable). Promote it to the parity suite when that is fixed. Host runner: - snapshot parsing tolerates trailing state flags (`[editable]`, `[selected]`, ...). The old regex was anchored right after the quoted label and silently dropped every stateful node — including a UISearchBar's field. - snapshotFull()/`full: true` host checks read the whole tree, for roles the `-i` interactive filter drops (a search field is role `search`). - new gestures: `swipeWide` (full-width drag), `fillRef` (focus + type, falling back to the full tree), `screenshot|<name>` (capture a frame to results/shots). - primeSnapshots(): after a fresh boot + rebind the first ~18s of snapshots come back without the app's tree, which left the FIRST scenario of every run blind for its opening steps. Poll until the tree is readable before scenario 0. - sync-demo.sh: also turns on UIViewControllerBasedStatusBarAppearance (iOS ignores per-controller status-bar traits without it), and its capture no longer renames the exported ITEST_BUILD_MARKER binding while scrubbing the marker value. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…n confirms On-device verification of the three post-core features found two of them broken and one animation variant broken, so the asserting scenarios for those move to `xfail` (still fully written, so they go green the moment the behavior is fixed) and the `parity` suite keeps only what is confirmed working. - anim-slide-bottom (NEW, xfail): `slide_from_bottom` pushes a screen whose content never mounts — route stack correct, nav-bar title correct, content area blank, marker never in the a11y tree (3/3 attempts, frame captured). `fade` and `none` mount the same probe screen fine, so it is specific to that variant. Split out of anim-variants so the working variants still gate. - statusbar -> xfail: none of the window traits reach UIKit. Tested with each style paired against a CONTRADICTING background so iOS's own contrast adaptation cannot explain it, and with UIViewControllerBasedStatusBarAppearance confirmed YES in the installed app's Info.plist: 'dark' over dark stayed white, 'light' over light stayed dark, and statusBarHidden left the bar up. The appearance tracked the CONTENT, never the trait. - searchbar -> xfail (already): crashes on focus. Harness: a `screenshot|...` AWAIT is never re-fulfilled. The AWAIT deliberately outlives the capture, so the retry was overwriting the correctly-timed frame with one taken after the step had already advanced — which is what made the first status-bar readings ambiguous. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tors + on-simulator parity gate Lands the six custom `stackAnimation` variants (a nav-delegate-vended UIViewControllerAnimatedTransitioning with a force-complete watchdog and a full-width interactive pan) and extends the on-simulator itest harness with a `parity` suite that gates them. Verified on simulator (iPhone 17, iOS 26, Release build, agent-device): itest core 8/8 (no regression) itest parity 3/3 (anim-variants, anim-interactive, anim-frames) npm test 62/62, npm run test:rnav 19/19 NOT included, and why (each has a fully-written asserting scenario parked in the itest `xfail` suite so it goes green the moment it is fixed): - searchbar: the search bar renders, but focusing it aborts the app — 'Objective-C selector is not available: setShowsCancelButton' from searchBarTextDidBeginEditing. The guard is a typeof check that a NativeScript proxy satisfies for a selector it cannot dispatch; the real selector is setShowsCancelButton:animated:. - statusbar (window traits): none of statusBarStyle/statusBarHidden reach UIKit. Tested with each style over a CONTRADICTING background so iOS's own contrast adaptation cannot explain it, with UIViewControllerBasedStatusBarAppearance confirmed YES in the installed app: the appearance tracked the CONTENT, never the declared trait. - slide_from_bottom (a variant of the animator work that IS merged): pushes a screen whose content never mounts. Split into its own xfail scenario; fade / none / default are unaffected and gate normally.
…tent, not variant-specific Re-ran `anim-slide-bottom` as the FIRST push of a cold launch (its own scenario, rather than its old position inside anim-variants behind a fade push and pop): the content mounts correctly and the host check passes, with the frame captured. So the blank content is order-dependent, not a defect of that variant's animator: it reproduced 3/3 only when the push followed an earlier push/pop in the same launch, which points at the port's known content-mount timing (`fade` and `none` drive the same probe screen). Stays `xfail` because a scenario that depends on push order cannot gate; the comment now says what was actually observed on both sides. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… real bridged name
Focusing a header search bar aborted the app:
Objective-C selector is not available: setShowsCancelButton
at setSearchBarCancelButtonShown_… at searchBarTextDidBeginEditing
`setSearchBarCancelButtonShown` called `searchBar.setShowsCancelButton(flag,
true)` behind a `typeof searchBar.setShowsCancelButton === 'function'` guard.
That guard is worthless on a live native object — a NativeScript proxy answers
`typeof … === 'function'` for a selector it cannot dispatch — and the name+arity
pair it guarded does not exist: the real selector is
`-[UISearchBar setShowsCancelButton:animated:]`, bridged as
`setShowsCancelButtonAnimated` (packages/ios/types/UIKit.d.ts:19479).
Now calls `setShowsCancelButtonAnimated(flag, true)`, falling back to the
`showsCancelButton` property assignment (equivalent to animated:NO) for plain-JS
doubles/older bridges.
Audit of the other `typeof <nsProxy>.<selector> === 'function'` guards in the
engine (63 distinct selectors, each checked against the UIKit/Foundation
metadata typings): only one other name has no real bridged counterpart — the
`subview.pointInside(point, event)` fallback in the FullWindowOverlay hit-test
override (the real name is `pointInsideWithEvent`). It was unreachable on device
only because the preceding guard is always true on a proxy; removed so it cannot
resurface. Every other guarded name resolves.
Test double now answers only to `setShowsCancelButtonAnimated` and throws the
runtime's own error for the one-argument name, so a regression fails jest.
# Conflicts: # packages/react-native-screens/src/NativeScriptScreenStack.tsx
…porary NSLog diagnostic)
# Conflicts: # packages/react-native-screens/__tests__/unit/engine-pure.test.ts
…+ promote both itest scenarios)
Supersedes the WIP commit on this branch (temporary NSLog/pasteboard tracing,
removed here). Two independent defects kept every window trait from taking
effect; both are fixed and both are now gated on the simulator.
1. The overrides were never installed. `preferredStatusBarStyle`,
`prefersStatusBarHidden`, `preferredStatusBarUpdateAnimation`,
`supportedInterfaceOrientations` and `prefersHomeIndicatorAutoHidden` are
metadata PROPERTIES, and `__extendClass` binds a property override only from
an ACCESSOR descriptor — a plain function value is matched against method
members only (ClassBuilder.mm `methodOverridesForName` skips every
`member.property`). The screen-controller class was therefore extended with
nothing at all. They are declared with `Object.defineProperty(…, { get })`
now (the same shape the menuless back button's `menu` setter already used,
for the same worklets-plugin reason).
2. Nothing carried UIKit's query down to the screen. With
`UIViewControllerBasedStatusBarAppearance = YES`, iOS asks the KEY WINDOW'S
ROOT controller and then only walks `childViewControllerForStatusBar{Style,
Hidden}` / `childViewControllerForHomeIndicatorAutoHidden`. None of the
containers answer those: not `UINavigationController`, and not React
Native's root, which is a bare `[UIViewController new]`
(RCTDefaultReactNativeFactoryDelegate.mm). Upstream solves this natively by
swizzling those getters on `UIViewController` process-wide
(ios/UIViewController+RNScreens.mm). This adapter is JS-only, so it builds a
forwarding subclass with `__extendClass` and re-classes the controllers on
the path with `object_setClass` (the mechanism KVO uses; the subclass adds no
ivars — `objc_allocateClassPair(base, name, 0)` and ClassBuilder never calls
`class_addIvar` — so the instance layout is unchanged).
Engine-owned navigation controllers (the stack's and a presented modal's) are
built from that subclass directly, so a modal screen's traits apply the same
way. The forwarder hands UIKit the registry's current trait OWNER rather than
the next hop down, because the measured chain is
root(UIViewController) -> RNSTabsHostNativeScriptController
-> RNSTabsScreenNativeScriptController
-> …WindowTraitForwarder_UINavigationController
-> …WindowTraitScreenController
and the two tabs controllers are already `__extendClass`-extended, which the
runtime refuses to extend twice; bridging the root alone is sufficient
because UIKit recurses on whatever `childViewControllerFor…` returns. When no
trait screen is visible the forwarder answers null — exactly what an
untouched controller answers — so a trait-free app is unchanged.
App-level requirement (documented in the engine and the itest README):
`UIViewControllerBasedStatusBarAppearance` must be `YES`;
`test/screens-itest/sync-demo.sh apply` patches the demo's Info.plist.
itest: `searchbar` and `statusbar` move from `xfail` into the asserting `parity`
suite. The status bar is system-drawn (invisible to both JS and the a11y tree),
so the harness gained FRAME CHECKS: `lib/png.js` decodes the captured frames and
`FRAME_CHECKS` asserts the clock band — light glyphs over a LIGHT background,
dark glyphs over a DARK background, an empty band when hidden, and the default
restored after the pop. Each style is paired with a contradicting background so
iOS's own contrast adaptation cannot produce a pass; the frames are deleted
before every attempt so a stale capture cannot satisfy a check.
`fillRef` no longer uses `agent-device fill`: tapping a UISearchBar activates
its UISearchController and re-numbers the tree, so the immediate type landed
before the field was first responder and delivered 0 `onChangeText` events. It
taps, lets the activation settle, then types.
On device (sim BF759806, Release, fresh-marker bundle, no Metro):
parity 5/5 — searchbar `changes:7,text:nsrocks,focus:1`, statusbar all 5 frame
checks ok; core 8/8; xfail 2 informational. jest 97, test:rnav 19.
…xed, both now gated
Two features that were held back after on-device verification now land, each
with an ASSERTING itest scenario promoted out of `xfail` into `parity`.
searchbar — focusing the header search bar aborted the app:
'Objective-C selector is not available: setShowsCancelButton
at setSearchBarCancelButtonShown_... at searchBarTextDidBeginEditing'
The engine called `setShowsCancelButton(flag, animated)` behind a
`typeof searchBar.setShowsCancelButton === 'function'` guard, which is worthless
on a live native object (a NativeScript proxy answers 'function' 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 the dead `subview.pointInside(...)`
fallback in the FullWindowOverlay hit-test (real name `pointInsideWithEvent`),
removed so it cannot resurface.
statusbar — no window trait reached UIKit, for two independent reasons: the
overrides were passed as plain functions where `__extendClass` only binds
property overrides from accessor descriptors (so nothing was installed at all),
and nothing forwarded UIKit's root-down query to the screen (RN's root is a bare
`[UIViewController new]`; neither it nor a `UINavigationController` answers
`childViewControllerFor...`). The engine now installs accessor overrides and
re-classes the window root into a forwarding subclass — the JS-only analogue of
upstream's `UIViewController+RNScreens` swizzle — with engine-owned navigation
controllers (stack and presented modal) built from that subclass.
Gates on this merge (sim BF759806, Release, fresh-marker bundle, no Metro):
./itest run --suite parity 5/5 (anim-variants, anim-interactive, anim-frames,
searchbar, statusbar)
./itest run --suite core 8/8
./itest run --suite xfail 2 informational (anim-slide-bottom, sheet-detents)
npm test 97/97 . npm run test:rnav 19/19
The statusbar scenario is gated on PIXELS (new `FRAME_CHECKS` + `lib/png.js`):
the status bar is system-drawn and invisible to both JS and the accessibility
tree, so each style is asserted from the captured frame over a CONTRADICTING
background — white glyphs over a light background, dark glyphs over a dark one,
an empty band when hidden, and the default restored after the pop.
…ld push no longer lost)
`shouldDelayPushUntilControllerHasContent` held every animated push back for
TWO reconciles unconditionally before it ever consulted its probe, then for up
to two more when the probe reported no content. Both halves are now measured to
be wrong:
* The ordering it assumed does not exist. Instrumented over 58 cold cycles,
`t(contentReady) - t(push)` was NEGATIVE on all 8 measurable pushes
(-33ms..-264ms): a screen's content is hosted BEFORE the push is classified,
not ~270ms after it.
* The probe cannot decide anything on the push path. `viewHasHostedReactContent`
answers true for any non-hidden subview at depth > 0, and a pushed
controller's chain (`controller.view -> children container -> ActivityView ->
contentView`) always has one, so it has never returned false for a pushed
screen.
What the wait did cost, measured on lane 1 as JS `navigate()` -> UIKit
`viewControllers` mutation over 12 interleaved cold pairs of the same demo
build:
with the wait : 2/12 cycles installed the push at all (median 775ms);
on the other 10 the push was DROPPED outright
without the wait : 12/12 installed (median 635ms), all via the animated
push branch
The drop is the interesting half. On a cold-launch first navigation the window
in which the pushed controller is resolvable is often exactly ONE reconcile
wide -- the very next reconcile already reports `req=2 avail=1`. Burning that
one reconcile on an unconditional wait loses the navigation completely, which
is also why most first navigations were observed arriving through the
non-animated `setViewControllers` tail rather than the animated push.
No blank appeared in its place: 6 interleaved pairs scored with the frame-exact
rig read CUM detail_blank = 0.0 on BOTH builds, and the pushed Detail screen is
fully rendered in the push animation's own frames.
`controllerHasReactContent` / `viewHasHostedReactContent` are KEPT --
`reconcilePresentedModalStack` still uses the readiness notion for the
modal-present path, where content genuinely does land after presentation.
Gates: npm test 97/97, test:rnav 19/19, itest core 8/8, itest parity 5/5.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…pplyStackModel
All mutation of a stack's `viewControllers`, and all model bookkeeping that
describes it, now flows through one worklet:
applyStackModel(stackId, registry, ctx, targetIds, { animated, reason, token })
Before this the engine had SEVEN install sites (reconcile, the dismiss-repair
runOnUI hop, the transition fallback, the modal-dismiss base restore, the modal
base-sync, the modal-sync, and the did-show fallback). Each carried its own
hand-rolled push/pop/replace classification, its own ~13 scattered
`stackNativeKeys`/`stackNativeCounts` writes, and its own copy of the same
`layoutNavigationStackViews -> configureStackControllers ->
configureNavigationAppearance -> updateNativeBackGesture` tail (repeated ~5x).
Consolidation:
* `applyStackModel` owns the classification. `reconcile` is the only reason
that classifies (push / pop / replace / plain-set + same-model no-op); the
other six reasons are already-decided repairs that take the plain-set path
and differ only in their tail. `animateStackPush/Pop/Replace` and
`setNavigationControllerViewControllers` are now private primitives of the
funnel; the sole exemption is `createNavigationController`, which seeds a
brand-new (not-yet-presented) controller at construction time.
* `recordStackModel` is the SINGLE writer of `stackNativeKeys`/`Counts`,
replacing the ~13 scattered pairs.
* `configureStackAfterInstall` is the one post-install tail.
* New `registry.stackModelVersions`: `recordStackModel` bumps it on every
accepted apply. Timer-scheduled callers (transition fallback, did-show
fallback) capture the version when armed and pass it as `token`; the funnel
DROPS a stale-token call unless native ids still disagree with the target AND
no newer model superseded it. The blind re-install timers become no-ops once
a newer model has landed.
Behavior-preserving: every path taken today issues byte-identical UIKit calls.
The animated push/pop/replace branches keep their exact `markTransition` /
`scheduleTransitionFallback` bookkeeping (the funnel returns the transition
descriptor and reconcileStack arms the fallback, so the funnel does not have to
capture a helper declared below it).
The engine file grows ~73 code lines net (+169 total, most of it the funnel's
doc block and the stale-token machinery) -- the win is structural: 7 install
sites -> 1, ~13 model writers -> 1, ~5 duplicated tails -> 1.
Also: fix a pre-existing itest-harness eagerness bug the funnel work surfaced.
`fulfillGesture`'s `fillRef` tapped a search-field ref whose stored rect came
from the poll snapshot at the instant the AWAIT label appeared -- before the
UISearchBar's accessibility frame settled -- so the tap landed at the bar's
origin corner (38,84) instead of the field centre (201,139) and nothing was
typed (0 onChangeText). It now re-resolves the ref from a fresh snapshot right
before the tap. This is unrelated to the engine change (the searchbar itest
failed identically on the parent commit) and restores parity 5/5.
50/58 finding: the earlier "most first navigations never take the animated push
branch" was the dead content-wait removed in the previous commit, not a
classification gap. Instrumented A/B: with the wait gone the first push-detail
installs via the animated `pushViewControllerAnimated` branch on 12/12 cold
cycles; the only non-animated tail on cold start is the initial single-screen
Home seed (previousKey null), which is correct and byte-identical.
Adds applyStackModel unit tests (classification, stale-token drop, single-writer
bookkeeping).
Gates: npm test 106/106, test:rnav 19/19, itest core 8/8, itest parity 5/5.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Generic React Native UIKit / Fabric / worklet runtime primitives in
@nativescript/react-native, plus the@nativescript/react-native-screenspackage built on top of them — a thin (~7k-line, single-file) NativeScript-interop engine that drives a real UIKitUINavigationControllerand 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)
react-native-screensport (device-buggy). Replaced by the thinpackages/react-native-screensadapter in this repo, consumed by unmodified react-navigation (the fork is gone; only a one-line peer-dep is added to native-stack).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).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).stackAnimationanimators (new): the animated variants are served by an engine-ownedUIViewControllerAnimatedTransitioningvended 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 fordefault/slide_from_right/ios_from_*/none/flip, so default navigation and its stock edge back-swipe are untouched — asserted, see below.headerSearchBarOptions/<SearchBar>build a realUISearchControlleronnavigationItem.searchController(descriptor path, mirroring upstreamupdateSearchController), 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.statusBarStyle/statusBarHidden/statusBarAnimation/screenOrientation/homeIndicatorHidden/hideKeyboardOnSwipeon the visible screen. RequiresUIViewControllerBasedStatusBarAppearance = YESin the app's Info.plist (same as upstream).Test net (host-side jest)
react-native-screens@4.25.2, i.e. the native-stack contract is satisfied with zero regression vs upstream.packages/react-native-screens):npm test,npm run test:rnav.Verification
for f in packages/react-native/test/*.test.js; do node "$f" || exit 1; done;npm run check:ffi-boundariesitest) + 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 core— 8/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 parity— 5/5 green, gating the animator work plus the two featuresthat were previously parked as
xfail:anim-variants— a mid-screen (non-edge) drag pops afadescreen but does nothing on anonescreen or on a default-animation screen, and the stock edge drag still pops thedefault 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 6sfadepush in flight. A mid-fade frame (incomingscreen 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
animationDurationfeeds it.searchbar— pushes the search screen, proves a realUISearchControllerwas assigned (thesearchnode exists in the FULL accessibility tree), taps the field and types: the app mustobserve
onChangeTextwith 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 thepixels of the status bar (
FRAME_CHECKS+ a minimal PNG reader inlib/png.js): the baris 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
animationDurationdoes not work here — react-navigation'stransitionStart/transitionEndpair is delivered through the engine's reconcile and JS hops, and measured on-device those come
out indistinguishable (default 833ms,
fade(1800) 621ms,none708ms,slide_from_bottom(900) 734ms).Fixed since the last review — both features now merged and gated
Objective-C selector is not available: setShowsCancelButton(fromsearchBarTextDidBeginEditing). The engine calledsetShowsCancelButton(flag, animated)behind atypeofguard that a NativeScript proxysatisfies for a selector it cannot dispatch; the real selector is
-[UISearchBar setShowsCancelButton:animated:], bridged assetShowsCancelButtonAnimated.All 63
typeof-guarded selectors in the engine were audited against the UIKit/Foundationmetadata — 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
searchbarscenario in--suite parity.__extendClassas plain function values, but every trait is a metadata property, and onlyan accessor descriptor binds a property override (
methodOverridesForNameskips everymember.property) — so the extended class carried no overrides at all. (2) Nothing forwardedUIKit's query down to the screen: with
UIViewControllerBasedStatusBarAppearance = YESiOSasks the key window's root controller and walks only
childViewControllerFor…, whichneither a
UINavigationControllernor RN's root (a bare[UIViewController new]) answers.Upstream fixes this natively by swizzling those getters on
UIViewControllerprocess-wide(
ios/UIViewController+RNScreens.mm); this JS-only adapter builds a forwarding subclass with__extendClassand re-classes the window root withobject_setClass(the subclass adds noivars, 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
statusbarscenario in--suite parity.Known-broken, parked as
xfailEach of these has a fully written asserting scenario in
./itest run --suite xfail, so itturns green the moment the behavior is fixed:
slide_from_bottom(a variant of the animator work that is merged) — a pushsometimes 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/nonedrive the same probe screen). Parked as
xfailbecause an order-dependent scenario cannotgate.
Update — push-latency win + install-path consolidation (
f32d99d2,53c16b30)Two follow-ups, each measurement-grounded and gate-clean (unit 106/106,
test:rnav19/19, itest core 8/8, parity 5/5):Dropped the dead push content-wait (
f32d99d2).shouldDelayPushUntilControllerHasContentheld 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 itnever once gated a real push. Instrumented over cold cycles,
t(contentReady) − t(push)wasnegative 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()→ UIKitviewControllersmutation, same demo build, 12 cold pairs): with thewait 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
pushViewControllerAnimatedbranch (median ~635ms navigate→install). No blank replaced it —frame-exact scoring read CUM detail-blank 0.0 on both builds.
controllerHasReactContentiskept 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.
applyStackModelinstall funnel (53c16b30). EveryviewControllersmutation and allmodel 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;
recordStackModelis the sole writer ofstackNativeKeys/Counts(replacing ~13 scattered pairs) and the post-installlayout → configureStackControllers → configureNavigationAppearance → updateNativeBackGesturetail (repeated ~5×) collapses to one helper. A new
stackModelVersionscounter letstimer-scheduled installs carry the model version they were armed against as a
token; thefunnel 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
itestharness eagerness bug the funnel work surfaced:fillReftappeda 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
searchbarscenario failed identically on the parentcommit.