@symbiote-native/components 3.1.0 → 3.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +17 -18
  2. package/build/behaviors/activity-indicator/shared.js +29 -71
  3. package/build/behaviors/button.d.ts +0 -9
  4. package/build/behaviors/button.js +48 -234
  5. package/build/behaviors/image-background.js +21 -81
  6. package/build/behaviors/image.js +3 -10
  7. package/build/behaviors/input-accessory-view.js +10 -51
  8. package/build/behaviors/pressable.d.ts +0 -48
  9. package/build/behaviors/pressable.js +59 -149
  10. package/build/behaviors/scroll-view/index.android.js +10 -30
  11. package/build/behaviors/scroll-view/shared.js +55 -186
  12. package/build/behaviors/scroll-view/sticky.d.ts +0 -8
  13. package/build/behaviors/scroll-view/sticky.js +54 -142
  14. package/build/behaviors/text-input.d.ts +0 -8
  15. package/build/behaviors/text-input.js +57 -156
  16. package/build/behaviors/touchable-highlight.js +14 -54
  17. package/build/behaviors/touchable-native-feedback.js +9 -32
  18. package/build/behaviors/touchable-opacity.js +3 -18
  19. package/build/behaviors/touchable-without-feedback.js +6 -24
  20. package/build/bootstrap/index.d.ts +1 -0
  21. package/build/bootstrap/index.js +2 -1
  22. package/build/component-names/index.android.js +6 -8
  23. package/build/component-names/shared.js +12 -42
  24. package/build/index.js +13 -19
  25. package/build/scroll-view-commands.js +9 -17
  26. package/build/state/pressable.js +18 -43
  27. package/build/state/sticky-header-reducer.js +103 -149
  28. package/build/state/touchable.js +3 -5
  29. package/build/state/virtualized-list-reducer.js +21 -48
  30. package/build/state/virtualized-list.js +63 -148
  31. package/build/text-props.js +3 -13
  32. package/build/view/render-button.js +13 -53
  33. package/build/view/render-input-accessory-view.js +8 -24
  34. package/build/view/render-pressable/index.js +3 -4
  35. package/build/view/render-scroll-view.js +13 -23
  36. package/build/view/render-touchable-native-feedback.js +5 -14
  37. package/host-primitives.cjs +33 -207
  38. package/package.json +3 -3
@@ -31,18 +31,16 @@ const ANDROID_NAMES = {
31
31
  switch: 'AndroidSwitch',
32
32
  'activity-indicator': 'RCTView',
33
33
  'activity-indicator-spinner': 'AndroidProgressBar',
34
- // DELIBERATE DIVERGENCE FROM RN (2026-09-23). RN's `SafeAreaView.js` is a plain View on Android,
35
- // with no insets. `RCTSafeAreaView` is registered there too (`ReactSafeAreaViewManager`) and
36
- // applies the window insets, so one screen is safe on both platforms.
34
+ // DELIBERATE DIVERGENCE FROM RN: `SafeAreaView.js` is a plain View on Android, with no insets.
35
+ // `RCTSafeAreaView` applies the window insets there too (`ReactSafeAreaViewManager`), so one
36
+ // screen is safe on both platforms.
37
37
  'safe-area-view': 'RCTSafeAreaView',
38
38
  modal: 'RCTModalHostView',
39
39
  'refresh-control': 'AndroidSwipeRefreshLayout',
40
40
  'sticky-header': 'RCTView',
41
- // `InputAccessoryView.js` on Android does `console.warn(...); return null` — the WHOLE component,
42
- // children included, renders NOTHING. A plain `RCTView` container (this file's answer until
43
- // 2026-09-20) committed a real, laid-out, potentially visible node plus its whole child subtree —
44
- // a real divergence from vendor, not a harmless degrade. `VOID_COMPONENT` matches vendor exactly:
45
- // no node, no children, on this platform only.
41
+ // `InputAccessoryView.js` on Android does `console.warn(...); return null` — the WHOLE
42
+ // component, children included, renders NOTHING. `VOID_COMPONENT` matches vendor exactly: no
43
+ // node, no children, on this platform only.
46
44
  'input-accessory-view': VOID_COMPONENT,
47
45
  };
48
46
  export const COMPONENT_DESCRIPTORS = buildDescriptors(ANDROID_NAMES);
@@ -1,9 +1,6 @@
1
- // The intrinsic JSX types symbiote's host config maps to Fabric components, plus the
2
- // machinery to turn a platform name table into the descriptors the host config reads.
3
- // The Fabric NAME of a primitive is platform-specific (iOS 'Switch' vs
4
- // Android 'AndroidSwitch'), so the name tables live in component-names.ios.ts /
5
- // .android.ts and the filename selects, no Platform.OS read. The isText flag is
6
- // platform-invariant, so it lives here once and both tables share it.
1
+ // The intrinsic JSX types symbiote's host config maps to Fabric components. A Fabric name is
2
+ // platform-specific, so name tables live in component-names.ios/.android.ts, selected by
3
+ // filename; `isText` is platform-invariant and lives here once.
7
4
  // The only text-laying intrinsic; drives the RCTText / RCTVirtualText nesting choice
8
5
  // (a <Text> inside another <Text> becomes a virtual span). Platform-invariant, so it is
9
6
  // not part of the per-platform name table.
@@ -20,32 +17,18 @@ export function buildDescriptors(names) {
20
17
  }
21
18
  return descriptors;
22
19
  }
23
- // Resolve an intrinsic type to its descriptor, against the platform-selected map. The
24
- // logic is identical for every adapter (and was duplicated in React's host-config and
25
- // Vue's component-names), so it lives here once; each platform file binds it to its own
26
- // COMPONENT_DESCRIPTORS. A `symbiote-*` miss is a typo in our own code; any other string
27
- // is a raw Fabric view name from a library's codegen component and flows through untouched
28
- // (the engine derives its events/processors from the view's ViewConfig, no per-library glue).
20
+ // Resolve an intrinsic type to its descriptor against the platform-selected map, so each
21
+ // platform file binds it to its own COMPONENT_DESCRIPTORS. Any string with no descriptor is a
22
+ // raw Fabric view name from a library's codegen component and flows through untouched.
29
23
  export function makeDescriptorFor(descriptors) {
30
24
  const unrewritten = publicNamesThatAreNotViewNames(descriptors);
31
25
  return type => {
32
26
  const descriptor = descriptors[type];
33
27
  if (descriptor !== undefined)
34
28
  return descriptor;
35
- // NO "unknown tag of ours" throw any more, and the reason is the whole cost of dropping the
36
- // prefix: `symbiote-*` was a MARKER, so a miss carrying it could only be our own typo. Without
37
- // it this namespace holds three populations that a string cannot tell apart —
38
- //
39
- // view · scroll-view ours
40
- // counter-child · app-root an Angular app's OWN component selectors, kebab by convention
41
- // RCTView · RNCSlider a Fabric view name, resolving through the fallthrough below
42
- //
43
- // — and the first attempt at a replacement ("lowercase is ours") threw on the second row, which
44
- // is most of an Angular app. Measured: 222 tests, the primary failure being `counter-child`.
45
- //
46
- // What replaces it is stronger and is not runtime at all: `ISymbioteIntrinsic` is a closed
47
- // union, so a misspelled tag in our own source is a compile error at every call site that names
48
- // one. The guard was only ever a backstop for a name built dynamically.
29
+ // No "unknown tag" throw here: this namespace holds three populations no string alone can
30
+ // tell apart — our own tags, an Angular app's own kebab-case selectors, and a Fabric view
31
+ // name. `ISymbioteIntrinsic` being a closed union makes a misspelled tag a compile error.
49
32
  if (unrewritten.has(type)) {
50
33
  throw new Error(`"${type}" is a primitive's PUBLIC name, not a Fabric view name — a rewrite was missed. ` +
51
34
  `Expected the intrinsic tag (e.g. "view"). Falling through would have committed ` +
@@ -54,22 +37,9 @@ export function makeDescriptorFor(descriptors) {
54
37
  return { component: type, isText: false };
55
38
  };
56
39
  }
57
- // A primitive's public name reaching here means an adapter's rewrite missed a call site. Without
58
- // this the name falls through as a raw Fabric view name and commits a view literally called `View`
59
- // — no error at any layer, wrong only on a device.
60
- //
61
- // Both halves are DERIVED, because a hand-written list of either would be wrong within a release.
62
- // The names come from the intrinsic union (kebab -> Pascal); the exclusions come from the platform's
63
- // own table, and deriving them is not tidiness — two public names ARE real Fabric view names, so a
64
- // hand-written block list would break an adapter's thin wrapper over a third-party native view,
65
- // which resolves by view name through this same function.
66
- //
67
- // AND THE EXCLUSION IS PER PLATFORM, which is why it must be computed from the table rather than
68
- // stated. `Switch` and `SafeAreaView` are iOS view names; Android spells them `AndroidSwitch` and
69
- // `RCTSafeAreaView`, so the same public name is EXCLUDED on iOS and BLOCKED on Android. That
70
- // asymmetry is correct — nothing legitimate resolves by the bare name on Android — but a reader who
71
- // takes "these two are real view names" as platform-invariant will conclude the guard is broken on
72
- // one side or the other.
40
+ // A primitive's public name reaching here means an adapter's rewrite missed a call site —
41
+ // otherwise it commits a view literally called `View`. Both halves are DERIVED, not hand-written:
42
+ // some public names (`Switch` on iOS) ARE real view names, and which is per-platform.
73
43
  function publicNamesThatAreNotViewNames(descriptors) {
74
44
  const viewNames = new Set(Object.values(descriptors).map(descriptor => descriptor.component));
75
45
  const names = new Set();
package/build/index.js CHANGED
@@ -53,10 +53,8 @@ export { canUseNativeForeground, selectableBackground, selectableBackgroundBorde
53
53
  // element is a tag now, and a tag is a string that cannot carry `.Ripple`.
54
54
  TouchableNativeFeedback, } from './view/render-touchable-native-feedback.js';
55
55
  // Button: `resolveButtonDisabled` alone now — the `props.disabled ?? aria ?? state.disabled`
56
- // precedence, which the press MACHINE reads and which is therefore still JS. Every platform fold
57
- // Button.js performs left this barrel between 2026-09-17 and 2026-09-18 for
58
- // `SymbioteFabricProps.cpp`; the last two, `buttonViewStyle` and `resolveButtonViewStyle`, went with
59
- // the Android arm of the test host that finally made their branch assertable.
56
+ // precedence, which the press MACHINE reads and stays JS. Every platform fold Button.js performs
57
+ // is `SymbioteFabricProps.cpp`'s.
60
58
  export { resolveButtonDisabled } from './view/render-button.js';
61
59
  // TextInput: the controlled-value / event-count handshake, and ONLY that now — the machine. The
62
60
  // W3C->native prop resolution that used to sit beside it is the engine's (`foldTextInputAliases`,
@@ -80,25 +78,21 @@ export { registerPressableBehavior, PRESSABLE_TAG, } from './behaviors/pressable
80
78
  export { registerTouchableOpacityBehavior, TOUCHABLE_OPACITY_TAG, } from './behaviors/touchable-opacity.js';
81
79
  export { registerTouchableHighlightBehavior, TOUCHABLE_HIGHLIGHT_TAG, } from './behaviors/touchable-highlight.js';
82
80
  export { registerButtonBehavior, BUTTON_TAG } from './behaviors/button.js';
83
- // Registered by ALL FIVE adapters since 2026-09-09, in the same commit that deleted the five
84
- // wrappers — which is what makes it safe. While those wrappers still rendered a `Pressable` around
85
- // a feedback `View`, registering would have put a second responder on every existing
86
- // TouchableNativeFeedback. The ActivityIndicator and ScrollView blocks below are still in the
87
- // withheld state this one just left.
81
+ // Registered by ALL FIVE adapters, in the same commit that deleted the five wrappers — safe
82
+ // only because those wrappers no longer render their own Pressable, which would put a second
83
+ // responder on every TouchableNativeFeedback.
88
84
  export { registerTouchableNativeFeedbackBehavior, TOUCHABLE_NATIVE_FEEDBACK_TAG, } from './behaviors/touchable-native-feedback.js';
89
85
  // The second tag that commits no node, and registered by all five adapters in the same commit that
90
86
  // deleted their five wrappers — while those wrappers still rendered a `Pressable` around the app's
91
87
  // children, registering would have put a second responder on every TouchableWithoutFeedback.
92
88
  export { registerTouchableWithoutFeedbackBehavior, TOUCHABLE_WITHOUT_FEEDBACK_TAG, } from './behaviors/touchable-without-feedback.js';
93
- // Registered by all five adapters since 2026-09-09, in the same commit that deleted the five
94
- // wrappers — the tag builds its own spinner, so a surviving wrapper would have painted a second one.
95
- // The prop type rides along: there is no `view/render-activity-indicator.ts` any more, because the
96
- // only render this primitive had was the two nodes the behavior now builds.
89
+ // Registered by all five adapters, in the same commit that deleted the five wrappers — the tag
90
+ // builds its own spinner, so a surviving wrapper would have painted a second one. No
91
+ // `view/render-activity-indicator.ts`: the behavior now builds the only two nodes this had.
97
92
  export { ACTIVITY_INDICATOR_SPINNER_TAG, ACTIVITY_INDICATOR_TAG, registerActivityIndicatorBehavior, } from './behaviors/activity-indicator';
98
93
  export { IMAGE_TAG, registerImageBehavior } from './behaviors/image.js';
99
- // Registered by all five adapters since 2026-09-09, in the same commit that deleted the five
100
- // wrappers — the tag builds the background image itself, so a surviving wrapper would have
101
- // committed a second one under it.
94
+ // Registered by all five adapters, in the same commit that deleted the five wrappers — the tag
95
+ // builds the background image itself, so a surviving wrapper would have committed a second one.
102
96
  export { IMAGE_BACKGROUND_TAG, registerImageBackgroundBehavior, } from './behaviors/image-background.js';
103
97
  export { INPUT_ACCESSORY_VIEW_TAG, registerInputAccessoryViewBehavior, } from './behaviors/input-accessory-view.js';
104
98
  export { registerTextInputBehavior, buildTextInputHandle, TEXT_INPUT_TAG, TEXT_INPUT_MULTILINE_TAG, } from './behaviors/text-input.js';
@@ -109,7 +103,7 @@ export { registerRefreshControlBehavior, REFRESH_CONTROL_TAG, } from './behavior
109
103
  // caller of `registerScrollViewBehavior()` names it. The shape CLAUDE.md forbids is a module whose
110
104
  // evaluation alone registers.
111
105
  //
112
- // EVERY ADAPTER CALLS IT since 2026-09-11, through `@symbiote-native/components/register`. It was
113
- // withheld while the wrappers built their own content node — registering then would have
114
- // double-nested every ScrollView. They are gone; the engine is the single owner.
106
+ // EVERY ADAPTER CALLS IT, through `@symbiote-native/components/register`. Withheld while the
107
+ // wrappers built their own content node — registering then would have double-nested every
108
+ // ScrollView. They are gone; the engine is the single owner.
115
109
  export { HORIZONTAL_SCROLL_VIEW_TAG, registerScrollViewBehavior, SCROLL_VIEW_TAG, STICKY_HEADER_TAG, } from './behaviors/scroll-view';
@@ -1,20 +1,12 @@
1
- // ScrollView: the imperative + style-routing module (framework-agnostic, no 3-layer split:
2
- // ScrollView has no state machine). The imperative handle, the layout/visual style split for
3
- // the Android RefreshControl wrap, the scroll-event guard/forwarder, and the native sticky
4
- // scroll-attach are all platform- and framework-invariant, so they live here. The adapter
5
- // supplies the lifecycle (the node getter, the effect) and re-exports these.
6
- import { attachNativeEvent, dlog, flattenStyle, isSymbioteEvent, } from '@symbiote-native/engine';
7
- // THE ANDROID WRAP SPLIT LEFT THIS FILE ON 2026-09-18, and with it the last copy of RN's
8
- // `splitLayoutProps` key partition. Both halves are `SymbioteFabricProps.cpp` now
9
- // (`splitScrollViewStyle`, `foldRefreshWrapperProps`), reached off the tag and the tree rather than
10
- // from a `payloadFold` per node — see `behaviors/scroll-view/index.android.ts` for what made the
11
- // wrapper's half possible at all.
12
- //
13
- // Nothing replaced them here on purpose: a JS copy kept for a caller that no longer exists is the
14
- // mirror shape this migration keeps deleting, and the twenty-eight layout keys are exactly the kind
15
- // of list that drifts in silence. `core/engine/cpp/tests/js/scroll-view-wrap-payload.itest.ts`
16
- // asserts the split on the payload a commit actually sent, which is the only place it can now be
17
- // wrong.
1
+ // ScrollView: the imperative + event-routing module (framework-agnostic, no 3-layer split:
2
+ // ScrollView has no state machine). The imperative handle, the scroll-event guard/forwarder, and
3
+ // the native sticky scroll-attach are platform- and framework-invariant, so they live here. The
4
+ // Android RefreshControl wrap's style split moved out — see the note below. The adapter supplies
5
+ // the lifecycle (the node getter, the effect) and re-exports these.
6
+ import { attachNativeEvent, dlog, isSymbioteEvent, } from '@symbiote-native/engine';
7
+ // The Android wrap's style split (`splitScrollViewStyle`, `foldRefreshWrapperProps`) is
8
+ // `SymbioteFabricProps.cpp`'s, reached off the tag and tree rather than a `payloadFold` per node.
9
+ // Contract: `scroll-view-wrap-payload.itest.ts`, asserted on the payload a commit actually sent.
18
10
  // Re-exported so the package barrel (index.ts) can still export this guard to
19
11
  // '@symbiote-native/components' callers, now that it lives in the engine, next to ISymbioteEvent.
20
12
  export { isSymbioteEvent };
@@ -1,22 +1,14 @@
1
- // Pressable, the logic half (framework-agnostic, zero render, zero framework imports). The
2
- // press lifecycle RN's Pressability runs in JS (pressIn/pressOut/press synthesis, the
3
- // long-press timer, unstable_pressDelay deferral, and the pressRetentionOffset drift test)
4
- // lives here as a pure state machine over a mutable runtime plus an adapter-supplied host. The
5
- // adapter owns only the lifecycle wiring: React holds the runtime in a ref and flips `pressed`
6
- // via setState; Vue holds it in setup scope and flips a ref. Both call the SAME handlers.
7
- //
8
- // Framework-specific, stays in the adapter: the `pressed` state cell (it drives a
9
- // re-render, so each framework owns its reactive primitive) and the raw frame-measure (the host
10
- // node plus its measure call). The rest (the timers, the geometry, the suppression flags,
11
- // the decision of when each callback fires) is here, shared by every adapter.
1
+ // Pressable, the logic half (framework-agnostic, zero render). The press lifecycle RN's
2
+ // Pressability runs in JS lives here as a pure state machine over a mutable runtime plus an
3
+ // adapter-supplied host. Both React and Vue call the SAME handlers, differing only in lifecycle.
4
+ // Framework-specific, stays in the adapter: the `pressed` state cell (drives a re-render, so each
5
+ // framework owns its reactive primitive) and the raw frame-measure. The rest — timers, geometry,
6
+ // suppression flags, when each callback fires — is here, shared by every adapter.
12
7
  import { dlog, Platform, SoundManager, } from '@symbiote-native/engine';
13
8
  export const DEFAULT_DELAY_LONG_PRESS_MS = 500;
14
- // Pressability.js's DEFAULT_LONG_PRESS_DEACTIVATION_DISTANCE. A SEPARATE, smaller radius than
15
- // `pressRetentionOffset`/`hitSlop`: any move past it cancels a pending long press even while the
16
- // finger is still well inside the retention rect — real presses jitter a few px, long-press must
17
- // not fire mid-scroll. Vendor exposes it only as a rare static override
18
- // (`Pressability.setLongPressDeactivationDistance`, used by e.g. gesture libraries), never as a
19
- // per-component prop, so it stays a plain constant here rather than a config field.
9
+ // A SEPARATE, smaller radius than pressRetentionOffset/hitSlop: any move past it cancels a
10
+ // pending long press even while the finger is still well inside the retention rect — real presses
11
+ // jitter a few px, long-press must not fire mid-scroll.
20
12
  export const LONG_PRESS_DEACTIVATION_DISTANCE = 10;
21
13
  // Pressability's default active-visual floor for a plain Pressable. Touchable* overrides this to 0.
22
14
  export const DEFAULT_MIN_PRESS_DURATION_MS = 130;
@@ -120,10 +112,8 @@ export function disposePressRuntime(runtime) {
120
112
  runtime.delayElapsed = false;
121
113
  }
122
114
  // Measure the responder's on-screen frame and cache it as the retention region for the life of
123
- // the press (RN measures on responder grant, _measureResponderRegion). When measure is
124
- // unavailable (no node yet, an uncommitted node, or a host slot without a measure method,
125
- // headless) the region stays undefined and the move test falls back to the radius bound. The
126
- // try/catch guards that last case: a slot lacking measure throws rather than no-opping.
115
+ // the press. When measure is unavailable the region stays undefined and the move test falls back
116
+ // to the radius bound; the try/catch guards a slot lacking measure throwing rather than no-opping.
127
117
  function measureRegion(runtime, measureFn) {
128
118
  runtime.region = undefined;
129
119
  if (measureFn === undefined)
@@ -157,18 +147,9 @@ export function createPressHandlers(config, runtime, host) {
157
147
  const fallbackThreshold = maxEdge(hitSlopRect) + maxEdge(pressRectOffset);
158
148
  // True iff the touch still belongs to the active press: against the measured rect when we have
159
149
  // one (the RN-faithful path), else the symmetric radius fallback.
160
- //
161
- // TODO(rn-parity): `Pressability.js`'s `onResponderMove` (:489-493) returns right after the
162
- // app's `onPressMove`, before any drift/long-press-jitter check, whenever the region is not yet
163
- // measured — vendor treats "no measurement yet" as "do nothing until one arrives." The radius
164
- // fallback below diverges: it judges drift/jitter on synthetic geometry instead of waiting.
165
- // Confirmed unreachable on a real device (`Tree::measure` in `SymbioteTree.cpp` is a synchronous
166
- // JSI call, so the region is always populated before `handleResponderMove` can fire), so the
167
- // fallback only ever fires in this codebase's own test harness, which wires
168
- // `getMeasureFn: () => undefined` everywhere (`state/pressable.test.ts:84` and every touchable
169
- // test built on it). A real fix needs a synchronous mock-measure harness under 11+ existing
170
- // drift/retention/long-press call sites across `pressable.test.ts`, `touchable-opacity.test.ts`
171
- // and `render-pressable.test.ts`, not a one-line guard — see the audit skill for the finding.
150
+ // Known divergence: vendor does nothing until the region is measured, this judges drift/jitter
151
+ // on synthetic geometry instead. Unreachable on a real device (measure is a synchronous JSI
152
+ // call), so the fallback only ever fires in this codebase's own test harness.
172
153
  function isWithinRetention(point) {
173
154
  const region = runtime.region;
174
155
  if (region !== undefined) {
@@ -188,12 +169,8 @@ export function createPressHandlers(config, runtime, host) {
188
169
  function clearPressOut() {
189
170
  cancelRuntimeTimer(runtime, 'pressOutCancel');
190
171
  }
191
- // Pressability.js arms this ONCE, at RESPONDER_GRANT, and a drift out/back-in never re-arms it
192
- // (`onResponderMove` only ever CANCELS via `_cancelLongPressDelayTimeout`, permanently, for the
193
- // rest of the gesture). Splitting it out of `activate()` is what makes that possible: `activate`
194
- // itself runs again on every drift-back-in, and used to re-arm a fresh full-duration timer each
195
- // time, which is wrong on two counts — it can resurrect a long press the finger already
196
- // disqualified, and if it never left, it's simply the same timer restarted for no reason.
172
+ // Arms ONCE, at grant; a drift out/back-in never re-arms it, only cancels. Split out of
173
+ // activate(), which runs again on drift-back-in and would resurrect a disqualified long press.
197
174
  function armLongPress(event) {
198
175
  if (!onLongPress)
199
176
  return;
@@ -277,10 +254,8 @@ export function createPressHandlers(config, runtime, host) {
277
254
  runtime.pressOrigin = readPoint(event);
278
255
  runtime.driftedOut = false;
279
256
  measureRegion(runtime, host.getMeasureFn());
280
- // Armed at GRANT, unconditionally — before the `unstable_pressDelay` branch below, which only
281
- // defers the PRESSED VISUAL. `delayLongPress` already has `unstable_pressDelay` baked out of
282
- // its default (`configFor`), so adding it back here is what keeps the long-press threshold at
283
- // a constant time-from-touch-down regardless of how long the visual is deferred.
257
+ // Armed at GRANT, unconditionally — the unstable_pressDelay branch below only defers the
258
+ // PRESSED VISUAL, keeping the long-press threshold constant from touch-down.
284
259
  armLongPress(event);
285
260
  if (unstable_pressDelay > 0) {
286
261
  dlog(`Pressable pressIn deferred ${unstable_pressDelay}ms`);
@@ -1,19 +1,6 @@
1
- // Sticky-header effect machine: the framework-agnostic STATE MACHINE that folds every per-adapter
2
- // sticky-header effect skeleton into one place. Before this, each adapter (React useState/useEffect,
3
- // Vue refs/watchEffect, Angular fields/ngOnChanges — and, in Angular, a SECOND copy inside the
4
- // projection wrapper) re-wrote the same per-header sequence — gate the freshly-rebuilt
5
- // interpolation's spurious zero, debounce the settled translateY, rebuild the top/inverted ranges on
6
- // a layout/collision-input change — in its own reactive dialect, so the zero-swallow gate, the
7
- // debounce-delay pick, and the rebuild decision lived FOUR times and quietly drifted.
8
- //
9
- // Here the whole decision half is one pure `reduceSticky(state, action, inputs) -> {state, effects}`,
10
- // PER sticky header. The adapter keeps only what is genuinely framework-bound: translate a native
11
- // event / animated tick / timer fire into an ACTION, hold ONE state cell, and EXECUTE the returned
12
- // EFFECTS with its own primitives (build the interpolation node + wire addListener/removeListener,
13
- // hold the debounce setTimeout, trigger its own re-render, record the cross-talk y). The math leaf
14
- // (computeStickyInterpolation) and the debounce-window pick (stickyDebounceMs) still live in
15
- // ./view/render-scroll-sticky; this module composes them into the ordered transition every adapter
16
- // shares. Ported from ScrollViewStickyHeader.js's effect.
1
+ // Sticky-header effect machine: one pure `reduceSticky(state, action, inputs)` shared by every
2
+ // adapter, so the zero-swallow gate, debounce pick, and rebuild decision live once, not once per
3
+ // framework's reactive dialect. Math leaf + debounce window live in ./view/render-scroll-sticky.
17
4
  import { dlog } from '@symbiote-native/engine';
18
5
  import { computeStickyInterpolation, stickyDebounceMs, } from '../view/render-scroll-sticky.js';
19
6
  // The un-measured identity interpolation (RN: a fresh AnimatedInterpolation before the header has
@@ -43,8 +30,8 @@ function arraysEqual(a, b) {
43
30
  return false;
44
31
  return a.every((value, index) => value === b[index]);
45
32
  }
46
- // Recompute the derived interpolation ranges off the current state + inputs (wrapping the load-bearing
47
- // computeStickyInterpolation math), store them, and return them for the rebuild effect.
33
+ // Recompute the derived ranges off the current state + inputs, store them, and return them for
34
+ // the rebuild effect.
48
35
  function deriveRanges(state, inputs) {
49
36
  const { inputRange, outputRange } = computeStickyInterpolation({
50
37
  measured: state.measured,
@@ -58,14 +45,99 @@ function deriveRanges(state, inputs) {
58
45
  state.outputRange = outputRange;
59
46
  return { inputRange, outputRange };
60
47
  }
61
- // The single transition every sticky-header adapter shares. The adapter maps a native event / animated
62
- // tick / timer fire to an action, calls this, stores the returned state, and executes the effects.
63
- // DIAGNOSTIC-only, gated: identifies which header instance a log line belongs to across a
64
- // device dump without needing full state dumps at every call — layoutY doubles as a stable
65
- // per-header id once measured (0 before the first layout).
48
+ // Diagnostic-only, gated: identifies which header instance a log line belongs to — layoutY
49
+ // doubles as a stable per-header id once measured (0 before the first layout).
66
50
  function headerTag(state) {
67
51
  return `y=${state.layoutY}`;
68
52
  }
53
+ // Redundant-geometry guard: Yoga re-fires onLayout with the same y/height on an unrelated
54
+ // relayout — without this a fresh prop identity per redundant layout trips an unbounded
55
+ // rebuild loop.
56
+ function handleLayout(state, action, inputs) {
57
+ const alreadyAtThisGeometry = state.measured &&
58
+ state.layoutY === action.y &&
59
+ state.layoutHeight === action.height;
60
+ state.layoutY = action.y;
61
+ state.layoutHeight = action.height;
62
+ state.measured = true;
63
+ const effects = [];
64
+ if (inputs.index !== undefined) {
65
+ effects.push({ kind: 'record-header-y', index: inputs.index, y: action.y });
66
+ }
67
+ if (alreadyAtThisGeometry && state.rangesEmitted) {
68
+ dlog(`STICKY[reducer ${headerTag(state)}] layout: redundant geometry, skipped rebuild`);
69
+ return { state, effects, changed: effects.length > 0 };
70
+ }
71
+ const { inputRange, outputRange } = deriveRanges(state, inputs);
72
+ state.rangesEmitted = true;
73
+ dlog(`STICKY[reducer ${headerTag(state)}] layout: measured=true inputRange=${JSON.stringify(inputRange)} ` +
74
+ `outputRange=${JSON.stringify(outputRange)}`);
75
+ effects.push({ kind: 'rebuild-interpolation', inputRange, outputRange });
76
+ return { state, effects, changed: true };
77
+ }
78
+ // Redundant-ranges guard (same cause as layout's): compare the derived result, not raw
79
+ // inputs. `hadEmitted` is load-bearing: an unmeasured header derives the same identity
80
+ // ranges the initial state holds, so without it the first dispatch reads as redundant.
81
+ function handleInputsChanged(state, inputs) {
82
+ const previousInputRange = state.inputRange;
83
+ const previousOutputRange = state.outputRange;
84
+ const hadEmitted = state.rangesEmitted;
85
+ const { inputRange, outputRange } = deriveRanges(state, inputs);
86
+ state.rangesEmitted = true;
87
+ if (hadEmitted &&
88
+ arraysEqual(previousInputRange, inputRange) &&
89
+ arraysEqual(previousOutputRange, outputRange)) {
90
+ dlog(`STICKY[reducer ${headerTag(state)}] inputs-changed: ranges unchanged, skipped rebuild`);
91
+ return { state, effects: [], changed: false };
92
+ }
93
+ dlog(`STICKY[reducer ${headerTag(state)}] inputs-changed: inputRange ${JSON.stringify(previousInputRange)}->` +
94
+ `${JSON.stringify(inputRange)} outputRange ${JSON.stringify(previousOutputRange)}->` +
95
+ `${JSON.stringify(outputRange)}`);
96
+ return {
97
+ state,
98
+ effects: [{ kind: 'rebuild-interpolation', inputRange, outputRange }],
99
+ changed: true,
100
+ };
101
+ }
102
+ // A freshly-rebuilt interpolation re-emits 0 to its listeners; swallow that first zero once a
103
+ // real value has committed. Otherwise schedule the host-tuned debounce.
104
+ function handleAnimatedTick(state, action, inputs) {
105
+ if (action.value === 0 && !state.haveReceivedInitialZeroTranslateY) {
106
+ state.haveReceivedInitialZeroTranslateY = true;
107
+ dlog(`STICKY[reducer ${headerTag(state)}] animated-tick: swallowed re-emitted zero translateY`);
108
+ return { state, effects: [], changed: false };
109
+ }
110
+ dlog(`STICKY[reducer ${headerTag(state)}] animated-tick: scheduling debounce delay=${stickyDebounceMs(inputs.os)} ` +
111
+ `value=${action.value}`);
112
+ return {
113
+ state,
114
+ effects: [
115
+ {
116
+ kind: 'schedule-debounce',
117
+ delay: stickyDebounceMs(inputs.os),
118
+ value: action.value,
119
+ },
120
+ ],
121
+ changed: false,
122
+ };
123
+ }
124
+ // Already-sitting-at-this-value bails to break a re-arriving settled value's cascade
125
+ // (apply-passthrough -> fresh identity -> reconnect -> another tick).
126
+ function handleDebounceFired(state, action) {
127
+ if (state.translateY === action.value) {
128
+ dlog(`STICKY[reducer ${headerTag(state)}] debounce-fired: already at translateY=${action.value}, no-op`);
129
+ return { state, effects: [], changed: false };
130
+ }
131
+ dlog(`STICKY[reducer ${headerTag(state)}] debounce-fired: committing translateY=${action.value}`);
132
+ state.translateY = action.value;
133
+ if (action.value !== 0)
134
+ state.haveReceivedInitialZeroTranslateY = false;
135
+ return {
136
+ state,
137
+ effects: [{ kind: 'apply-passthrough', translateY: action.value }],
138
+ changed: true,
139
+ };
140
+ }
69
141
  export function reduceSticky(state, action, inputs) {
70
142
  dlog(`STICKY[reducer ${headerTag(state)}] action=${action.kind}` +
71
143
  (action.kind === 'layout'
@@ -76,131 +148,13 @@ export function reduceSticky(state, action, inputs) {
76
148
  : '') +
77
149
  ` inputs={inverted=${inputs.inverted} scrollViewHeight=${inputs.scrollViewHeight} nextHeaderLayoutY=${inputs.nextHeaderLayoutY}}`);
78
150
  switch (action.kind) {
79
- case 'layout': {
80
- // Record own y/height, mark measured, rebuild the interpolation, and (when the reducer owns the
81
- // cross-talk index) hand the parent this header's y so the PREVIOUS header learns its collision
82
- // point. Matches RN ScrollViewStickyHeader.js._onLayout.
83
- //
84
- // Redundant-geometry guard: Yoga legitimately re-fires onLayout with the SAME y/height (relayout
85
- // passes triggered by an unrelated sibling, a native-driven prop commit, ...) — every consumer
86
- // must tolerate that. React's port gets this guard for FREE: `setLayoutY(sameValue)` is a no-op
87
- // (React bails out of re-rendering on an unchanged primitive), so a redundant onLayout never
88
- // reaches the rebuild `useEffect`. This reducer has no such implicit bail-out, so it must skip
89
- // the rebuild explicitly — device-confirmed (2026-08-13) that omitting this guard lets a
90
- // native-driven rebuild (fresh AnimatedProps/AnimatedStyle graph, fresh native connect) commit a
91
- // fresh prop identity on every redundant layout, which can itself provoke another relayout pass —
92
- // an unbounded same-tick rebuild ping-pong that trips Svelte's effect_update_depth_exceeded guard.
93
- const alreadyAtThisGeometry = state.measured &&
94
- state.layoutY === action.y &&
95
- state.layoutHeight === action.height;
96
- state.layoutY = action.y;
97
- state.layoutHeight = action.height;
98
- state.measured = true;
99
- const effects = [];
100
- if (inputs.index !== undefined) {
101
- effects.push({
102
- kind: 'record-header-y',
103
- index: inputs.index,
104
- y: action.y,
105
- });
106
- }
107
- if (alreadyAtThisGeometry && state.rangesEmitted) {
108
- dlog(`STICKY[reducer ${headerTag(state)}] layout: redundant geometry, skipped rebuild`);
109
- return { state, effects, changed: effects.length > 0 };
110
- }
111
- const { inputRange, outputRange } = deriveRanges(state, inputs);
112
- state.rangesEmitted = true;
113
- dlog(`STICKY[reducer ${headerTag(state)}] layout: measured=true inputRange=${JSON.stringify(inputRange)} ` +
114
- `outputRange=${JSON.stringify(outputRange)}`);
115
- effects.push({ kind: 'rebuild-interpolation', inputRange, outputRange });
116
- return { state, effects, changed: true };
117
- }
118
- case 'inputs-changed': {
119
- // A collision/viewport input changed (RN effect deps: inverted, scrollViewHeight,
120
- // nextHeaderLayoutY): recompute the ranges and rebuild.
121
- //
122
- // Redundant-ranges guard (sibling of 'layout's `alreadyAtThisGeometry` above, same root
123
- // cause): the adapter's own mount `$effect` re-dispatches 'inputs-changed' whenever its
124
- // `inverted`/`scrollViewHeight`/`nextHeaderLayoutY` derived values re-evaluate — which can
125
- // happen on an unrelated parent re-render (e.g. VirtualizedList re-deriving
126
- // `nextHeaderLayoutYFor(cell.index)` off its cross-talk Map on every reactive pass) even when
127
- // the COMPUTED value is identical. React gets no implicit protection here either (this is a
128
- // real `useEffect` with real deps), but RN's own deps array only fires on an ACTUAL primitive
129
- // change; a framework-agnostic caller re-dispatching on every derive needs the reducer itself
130
- // to compare the RESULT (the ranges), not the raw inputs (which may recompute to the same
131
- // ranges via different intermediate values) — device-confirmed (2026-08-13) this is a second,
132
- // independent source of the same unbounded same-tick rebuild loop the 'layout' guard fixed.
133
- const previousInputRange = state.inputRange;
134
- const previousOutputRange = state.outputRange;
135
- const hadEmitted = state.rangesEmitted;
136
- const { inputRange, outputRange } = deriveRanges(state, inputs);
137
- state.rangesEmitted = true;
138
- // `hadEmitted` is load-bearing, not defensive: an unmeasured header derives exactly the
139
- // identity ranges the initial state already holds, so without it the FIRST dispatch (Angular
140
- // sends one from ngOnInit, before any layout) reads as redundant and the header never emits
141
- // a rebuild at all — its wrapper then never commits. Regression-covered in
142
- // sticky-header-reducer.test.ts.
143
- if (hadEmitted &&
144
- arraysEqual(previousInputRange, inputRange) &&
145
- arraysEqual(previousOutputRange, outputRange)) {
146
- dlog(`STICKY[reducer ${headerTag(state)}] inputs-changed: ranges unchanged, skipped rebuild`);
147
- return { state, effects: [], changed: false };
148
- }
149
- dlog(`STICKY[reducer ${headerTag(state)}] inputs-changed: inputRange ${JSON.stringify(previousInputRange)}->` +
150
- `${JSON.stringify(inputRange)} outputRange ${JSON.stringify(previousOutputRange)}->` +
151
- `${JSON.stringify(outputRange)}`);
152
- return {
153
- state,
154
- effects: [{ kind: 'rebuild-interpolation', inputRange, outputRange }],
155
- changed: true,
156
- };
157
- }
158
- case 'animated-tick': {
159
- // A freshly-rebuilt interpolation re-emits 0 to its listeners; swallow that first zero once a
160
- // real value has committed (RN). Otherwise schedule the host-tuned debounce that pushes the
161
- // settled value into the committed transform for hit-testing.
162
- if (action.value === 0 && !state.haveReceivedInitialZeroTranslateY) {
163
- state.haveReceivedInitialZeroTranslateY = true;
164
- dlog(`STICKY[reducer ${headerTag(state)}] animated-tick: swallowed re-emitted zero translateY`);
165
- return { state, effects: [], changed: false };
166
- }
167
- dlog(`STICKY[reducer ${headerTag(state)}] animated-tick: scheduling debounce delay=${stickyDebounceMs(inputs.os)} ` +
168
- `value=${action.value}`);
169
- return {
170
- state,
171
- effects: [
172
- {
173
- kind: 'schedule-debounce',
174
- delay: stickyDebounceMs(inputs.os),
175
- value: action.value,
176
- },
177
- ],
178
- changed: false,
179
- };
180
- }
181
- case 'debounce-fired': {
182
- // Already sitting at this value: emit nothing. Same bail-out the 'layout' case above spells
183
- // out, for the same reason - React's own header gets it free from setTranslateY (an unchanged
184
- // primitive bails out of the re-render), a reducer has to say it. Device-confirmed 2026-08-18:
185
- // without it a re-arriving settled value emits apply-passthrough, the adapter force-renders,
186
- // the passthrough prop gets a fresh identity, the animated graph reconnects and re-emits into
187
- // another tick -> another debounce -> another passthrough. One header survives it; a screen of
188
- // 200 trips React's "Maximum update depth exceeded" and takes the app down.
189
- if (state.translateY === action.value) {
190
- dlog(`STICKY[reducer ${headerTag(state)}] debounce-fired: already at translateY=${action.value}, no-op`);
191
- return { state, effects: [], changed: false };
192
- }
193
- // The debounce completed: commit the settled translateY. Once a NON-zero value commits, re-arm
194
- // the swallow gate so the next interpolation rebuild's spurious 0 is dropped (RN).
195
- dlog(`STICKY[reducer ${headerTag(state)}] debounce-fired: committing translateY=${action.value}`);
196
- state.translateY = action.value;
197
- if (action.value !== 0)
198
- state.haveReceivedInitialZeroTranslateY = false;
199
- return {
200
- state,
201
- effects: [{ kind: 'apply-passthrough', translateY: action.value }],
202
- changed: true,
203
- };
204
- }
151
+ case 'layout':
152
+ return handleLayout(state, action, inputs);
153
+ case 'inputs-changed':
154
+ return handleInputsChanged(state, inputs);
155
+ case 'animated-tick':
156
+ return handleAnimatedTick(state, action, inputs);
157
+ case 'debounce-fired':
158
+ return handleDebounceFired(state, action);
205
159
  }
206
160
  }
@@ -100,11 +100,9 @@ export function createTouchableFeedbackHandlers(config, runtime, callbacks) {
100
100
  },
101
101
  };
102
102
  }
103
- // ---- RN-audited additions (2026-08-19) --------------------------------------------------------
104
- //
105
- // Measured against .vendors/react-native. Everything above predates that audit and is still what
106
- // the React, Vue, Svelte and Angular adapters call; the names below are the RN-accurate forms and
107
- // are additive on purpose, so the tree stays green while each adapter migrates.
103
+ // ---- RN-audited additions ----
104
+ // Measured against .vendors/react-native. The names below are the RN-accurate forms, kept
105
+ // additive so the tree stays green while each adapter migrates off the names above.
108
106
  // RN's Touchable* family OVERRIDES Pressability's own floor with 0 — TouchableOpacity.js:195,
109
107
  // TouchableHighlight.js:203 and TouchableWithoutFeedback.js all pass `minPressDuration: 0`. So
110
108
  // Pressability's own default of 130 (Pressability.js:264) reaches a Touchable in RN NEVER: what holds the active visual there is the Animated fade's own duration,