@symbiote-native/components 2.0.0 → 3.0.1

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 (79) hide show
  1. package/README.md +8 -9
  2. package/build/accessibility-props.d.ts +1 -1
  3. package/build/accessibility-props.js +2 -2
  4. package/build/behaviors/activity-indicator/shared.js +41 -70
  5. package/build/behaviors/button.d.ts +11 -0
  6. package/build/behaviors/button.js +101 -89
  7. package/build/behaviors/image-background.d.ts +1 -0
  8. package/build/behaviors/image-background.js +97 -81
  9. package/build/behaviors/image.d.ts +0 -1
  10. package/build/behaviors/image.js +24 -105
  11. package/build/behaviors/input-accessory-view.d.ts +0 -1
  12. package/build/behaviors/input-accessory-view.js +47 -53
  13. package/build/behaviors/pressable.d.ts +1 -1
  14. package/build/behaviors/pressable.js +89 -100
  15. package/build/behaviors/refresh-control.js +15 -2
  16. package/build/behaviors/scroll-view/index.android.js +25 -37
  17. package/build/behaviors/scroll-view/index.d.ts +1 -0
  18. package/build/behaviors/scroll-view/index.js +3 -0
  19. package/build/behaviors/scroll-view/responder.d.ts +4 -0
  20. package/build/behaviors/scroll-view/responder.js +202 -0
  21. package/build/behaviors/scroll-view/shared.d.ts +1 -3
  22. package/build/behaviors/scroll-view/shared.js +95 -95
  23. package/build/behaviors/scroll-view/sticky.d.ts +1 -0
  24. package/build/behaviors/scroll-view/sticky.js +62 -49
  25. package/build/behaviors/switch.js +43 -86
  26. package/build/behaviors/text-input.js +219 -107
  27. package/build/behaviors/touchable-highlight.js +74 -61
  28. package/build/behaviors/touchable-native-feedback.js +43 -122
  29. package/build/behaviors/touchable-opacity.js +71 -59
  30. package/build/behaviors/touchable-without-feedback.js +35 -100
  31. package/build/component-names/index.android.js +7 -11
  32. package/build/component-names/index.ios.js +0 -7
  33. package/build/component-names/shared.d.ts +1 -1
  34. package/build/index.d.ts +15 -21
  35. package/build/index.js +21 -28
  36. package/build/resolve-intrinsic.js +3 -9
  37. package/build/scroll-view-commands.d.ts +1 -9
  38. package/build/scroll-view-commands.js +13 -74
  39. package/build/state/flat-list.d.ts +2 -2
  40. package/build/state/flat-list.js +10 -2
  41. package/build/state/pressable.d.ts +6 -1
  42. package/build/state/pressable.js +63 -28
  43. package/build/state/section-list.d.ts +2 -0
  44. package/build/state/section-list.js +14 -7
  45. package/build/state/text-input.d.ts +7 -40
  46. package/build/state/text-input.js +17 -186
  47. package/build/state/touchable.d.ts +1 -0
  48. package/build/state/touchable.js +11 -8
  49. package/build/state/virtualized-list-reducer.d.ts +2 -2
  50. package/build/state/virtualized-list.d.ts +6 -6
  51. package/build/state/virtualized-list.js +71 -37
  52. package/build/text-props.d.ts +0 -8
  53. package/build/text-props.js +14 -25
  54. package/build/view/render-button.d.ts +1 -29
  55. package/build/view/render-button.js +44 -81
  56. package/build/view/render-image/index.d.ts +14 -1
  57. package/build/view/render-image/index.js +22 -147
  58. package/build/view/render-input-accessory-view.d.ts +1 -5
  59. package/build/view/render-input-accessory-view.js +26 -48
  60. package/build/view/render-keyboard-avoiding-view.d.ts +7 -1
  61. package/build/view/render-keyboard-avoiding-view.js +40 -1
  62. package/build/view/render-modal.d.ts +1 -1
  63. package/build/view/render-modal.js +15 -5
  64. package/build/view/render-pressable/index.d.ts +1 -0
  65. package/build/view/render-pressable/index.js +4 -0
  66. package/build/view/render-scroll-view.d.ts +0 -4
  67. package/build/view/render-scroll-view.js +3 -49
  68. package/build/view/render-switch.d.ts +0 -14
  69. package/build/view/render-switch.js +4 -42
  70. package/build/view/render-touchable-highlight.d.ts +1 -0
  71. package/build/view/render-touchable-native-feedback.d.ts +0 -1
  72. package/build/view/render-touchable-native-feedback.js +15 -9
  73. package/host-primitives.cjs +49 -203
  74. package/host-primitives.d.cts +0 -1
  75. package/package.json +3 -7
  76. package/build/fold-host-bag.d.ts +0 -15
  77. package/build/fold-host-bag.js +0 -99
  78. package/build/view/render-text-input.d.ts +0 -11
  79. package/build/view/render-text-input.js +0 -39
@@ -14,9 +14,9 @@
14
14
  // `registerPressableBehavior()`, and its entry does a bare `import './register';` that the barrel
15
15
  // does NOT re-export. A bare `import './register';` sitting NEXT TO such a re-export does not work
16
16
  // either: Babel merges the two imports of one specifier and the merged dependency stays lazy.
17
- import { appListenerFor, dlog, registerHostBehavior, requestCommitFor, setBehaviorListener, setNodePressed, } from '@symbiote-native/engine';
18
- import { createPressHandlers, createPressRuntime, disposePressRuntime, DEFAULT_DELAY_LONG_PRESS_MS, DEFAULT_MIN_PRESS_DURATION_MS, rippleProps, } from '../state/pressable.js';
19
- import { buildPressableListeners, resolveDisabledAccessibilityState, resolvePressableFocusable, } from '../view/render-pressable/index.js';
17
+ import { appListenerFor, dlog, propOf, registerHostBehavior, requestCommitFor, setBehaviorListener, setNodePressed, propsOf, } from '@symbiote-native/engine';
18
+ import { createPressHandlers, createPressRuntime, disposePressRuntime, DEFAULT_DELAY_LONG_PRESS_MS, DEFAULT_MIN_PRESS_DURATION_MS, } from '../state/pressable.js';
19
+ import { buildPressableListeners } from '../view/render-pressable/index.js';
20
20
  export const PRESSABLE_TAG = 'pressable';
21
21
  const states = new WeakMap();
22
22
  function isRecord(value) {
@@ -56,24 +56,7 @@ function asRectOffset(value) {
56
56
  function isPressHandler(value) {
57
57
  return typeof value === 'function';
58
58
  }
59
- // The props the MACHINE consumes and the host must never see. The wrapper drops them by
60
- // destructuring — they go into `createPressHandlers` / `buildPressableListeners` and are simply
61
- // absent from the object it spreads onto its View. A lowered element has no destructure, so every
62
- // one of them rode into the payload as a key no ViewConfig declares.
63
- const MACHINE_ONLY_KEYS = [
64
- // Consumed below and replaced by the resolved `nativeBackgroundAndroid` /
65
- // `nativeForegroundAndroid`; the raw config is not a native prop.
66
- 'android_ripple',
67
- 'disabled',
68
- 'cancelable',
69
- 'delayLongPress',
70
- 'minPressDuration',
71
- 'unstable_pressDelay',
72
- 'pressRetentionOffset',
73
- 'delayHoverIn',
74
- 'delayHoverOut',
75
- ];
76
- // Narrowed field by field rather than cast: the bag arrives as `unknown` off `node.props`. A local
59
+ // Narrowed field by field rather than cast: the bag arrives as `unknown` off `propOf`. A local
77
60
  // twin of the guard each adapter keeps for its own attrs (Vue's `asAccessibilityState`) — exported
78
61
  // to the sibling behaviors that fold the same bag, and deliberately NOT to the shared barrel, which
79
62
  // every adapter re-exports wholesale: a narrowing helper is not API anyone should be able to import.
@@ -93,108 +76,81 @@ export function asAccessibilityState(value) {
93
76
  state.expanded = value.expanded;
94
77
  return state;
95
78
  }
96
- // Narrowed field by field, same reason as the accessibility guard above: the config arrives as
97
- // `unknown` off `node.props`.
98
- function asRippleConfig(value) {
99
- if (!isRecord(value))
100
- return undefined;
101
- const config = {};
102
- if (typeof value.color === 'string')
103
- config.color = value.color;
104
- if (typeof value.borderless === 'boolean')
105
- config.borderless = value.borderless;
106
- if (typeof value.radius === 'number')
107
- config.radius = value.radius;
108
- if (typeof value.foreground === 'boolean')
109
- config.foreground = value.foreground;
110
- return config;
111
- }
112
- // `disabled` reaches a screen reader ONLY as `accessibilityState.disabled` — it is not a native
113
- // View prop, so the wrapper folds it (`resolveDisabledAccessibilityState`, called by all five) and
114
- // forwards the composite. Lowering dropped that fold: press suppression still worked, because the
115
- // machine reads `node.props.disabled` directly, so the button behaved correctly and announced
116
- // itself as enabled. An accessibility regression with no visual tell and no failing test.
79
+ // THE PAYLOAD FOLD MOVED TO THE ENGINE — `foldPressableProps` in `SymbioteFabricProps.cpp`, and
80
+ // its contract is `core/engine/cpp/tests/js/pressable-payload.itest.ts`. It is not re-implemented
81
+ // here in any form, which is the point: `disabled` -> `accessibilityState`, `accessible`/`focusable`
82
+ // defaulting on, the Android ripple config, and the nine machine-only keys being kept out of the
83
+ // payload are all functions of the TAG alone. That is user-agent behavior — RN does it for every
84
+ // Pressable in every app — and it belongs beside the tree, like a browser's `<button>`.
117
85
  //
118
- // Found by the wrapper-vs-behavior import audit (`.claude/rules/adapter-parity-audit.md`).
119
- function foldPayload(props) {
120
- const resolved = resolveDisabledAccessibilityState(asAccessibilityState(props.accessibilityState), typeof props.disabled === 'boolean' ? props.disabled : undefined);
121
- // The Android ripple. Our WRAPPER paints it through a dedicated inner View, mirroring
122
- // TouchableNativeFeedback — and that reading is what made this look unfixable for a lowered
123
- // element, which has no child to put it on. RN's own `Pressable` does NOT do that: it spreads
124
- // `useAndroidRippleForView`'s `viewProps` onto its own View (`Pressable.js:251`), so the ripple
125
- // background is an ordinary prop of the responder itself and a single node carries it fine.
126
- //
127
- // `rippleProps` returns undefined off Android, so this whole branch is inert on iOS.
128
- //
129
- // STILL MISSING ON BOTH PATHS, and lowering did not cause it: RN also dispatches
130
- // `Commands.hotspotUpdate(x, y)` on pressIn/pressMove and `Commands.setPressed` on
131
- // pressIn/pressOut, which is what makes the ripple originate at the touch point. Neither our
132
- // wrapper nor this behavior sends them — grep for `hotspotUpdate` returns nothing in the tree.
133
- const rippleConfig = asRippleConfig(props.android_ripple);
134
- const ripple = rippleConfig !== undefined ? rippleProps(rippleConfig) : undefined;
135
- const out = { ...props };
136
- for (const key of MACHINE_ONLY_KEYS)
137
- delete out[key];
138
- if (ripple !== undefined)
139
- Object.assign(out, ripple);
140
- // Written only when the fold produced something: an unconditional assignment would put an
141
- // `accessibilityState: undefined` key on every lowered Pressable in the tree, and `fabricProps`
142
- // skipping undefined is a coincidence to lean on, not a contract to rely on here.
143
- if (resolved !== undefined)
144
- out.accessibilityState = resolved;
145
- out.accessible = accessibleUnlessOptedOut(props);
146
- // Pressable.js:258 — the plain form, with no press-handler or disabled leg. A Touchable composing
147
- // this tag has already resolved its own three-leg formula and passed the answer in as `focusable`,
148
- // which `!== false` leaves alone.
149
- out.focusable = resolvePressableFocusable(booleanOr(props.focusable));
150
- return out;
151
- }
86
+ // It cost a trip: a `payloadFold` marshals the whole bag out and the whole bag back, ~17 us per
87
+ // pressable per commit, and a benchmark row carries two.
88
+ //
89
+ // What is still here is the MACHINE, which is where a browser keeps it too: timers, the responder
90
+ // claim, hit-slop retention, and the callbacks into app code.
91
+ //
92
+ // The one thing that did NOT move with it is `hitSlop`, and that is deliberate — it is a real
93
+ // native View prop, so it never was part of the fold.
152
94
  // RN makes every pressable accessible unless the app opts OUT — `Pressable.js:252`
153
95
  // (`accessible: accessible !== false`), and the whole Touchable family repeats it verbatim
154
96
  // (`TouchableOpacity.js:303`, `TouchableHighlight.js:337`). `!== false` rather than `?? true`, so
155
97
  // only a literal `false` opts out and an explicit `undefined` still reads as accessible.
156
98
  //
157
- // Nothing in this repo did it until 2026-09-09, on either path, so a Pressable reached a screen
158
- // reader as a plain view unless the app wrote the prop. Landing it in the fold above alone reddens
159
- // four equivalence arms — correctly, since those compare the wrapper against the lowered path — so
160
- // the behavior and every adapter's wrapper have to move in ONE change. Exported for the wrappers
161
- // that need to say it themselves, and for the tags whose behavior is their only path.
99
+ // Nothing in this repo did it until 2026-09-09, so a Pressable reached a screen reader as a plain
100
+ // view unless the app wrote the prop. Exported so anything composing this tag can say it too.
162
101
  export function accessibleUnlessOptedOut(props) {
163
102
  return props.accessible !== false;
164
103
  }
165
- // From the STASH, not from `node.props`. Every name below is in `ownedListeners`, so `routeProp`
104
+ // From the STASH, not from the props. Every name below is in `ownedListeners`, so `routeProp`
166
105
  // diverts the app's `onPress` away from `node.listeners` (where it would evict the behavior's own
167
106
  // dispatcher) and into the stash — which makes the stash the only place it exists. Reading
168
- // `node.props` here returns undefined for every callback and every press silently does nothing:
107
+ // `propOf` here returns undefined for every callback and every press silently does nothing:
169
108
  // the behavior runs, the machine runs, and it calls nobody.
170
109
  function callbackAt(node, event) {
171
110
  const value = appListenerFor(node, event);
172
111
  return isPressHandler(value) ? value : undefined;
173
112
  }
174
- // Callbacks come from `callbackAt` (the stash), scalars from `node.props`. The split is not
113
+ // Callbacks come from `callbackAt` (the stash), scalars from `propOf`. The split is not
175
114
  // cosmetic: `delayLongPress` and `hitSlop` are ordinary props that `fabricProps` drops as unknown
176
- // keys, while `onPress` and friends are OWNED event names that never reach `node.props` at all.
115
+ // keys, while `onPress` and friends are OWNED event names that never reach the props at all.
177
116
  function configFor(node) {
117
+ const unstablePressDelay = numberOr(propOf(node, 'unstable_pressDelay'), 0);
178
118
  return {
179
119
  onPress: callbackAt(node, 'press'),
180
120
  onPressIn: callbackAt(node, 'pressIn'),
181
121
  onPressOut: callbackAt(node, 'pressOut'),
182
122
  onPressMove: callbackAt(node, 'pressMove'),
183
123
  onLongPress: callbackAt(node, 'longPress'),
184
- delayLongPress: numberOr(node.props.delayLongPress, DEFAULT_DELAY_LONG_PRESS_MS),
185
- unstable_pressDelay: numberOr(node.props.unstable_pressDelay, 0),
124
+ // Pressability.js:471-474 — `normalizeDelay(authored, 10, DEFAULT_LONG_PRESS_DELAY_MS -
125
+ // delayPressIn)`. The subtraction applies only to the FALLBACK, never to an authored value —
126
+ // it exists so the long-press threshold, timed from the grant that also arms
127
+ // `unstable_pressDelay`, lands at a constant 500ms from touch-down by default, not
128
+ // 500ms + unstable_pressDelay. See `createPressHandlers`'s `handlePressIn` for the other half
129
+ // (the timer must be ARMED at grant, not after the pressDelay fires, or this compensation
130
+ // does nothing).
131
+ delayLongPress: Math.max(10, numberOr(propOf(node, 'delayLongPress'), DEFAULT_DELAY_LONG_PRESS_MS - unstablePressDelay)),
132
+ unstable_pressDelay: unstablePressDelay,
186
133
  // RN's Touchables own the deactivation floor in their OWN machine and hand Pressability
187
134
  // `minPressDuration: 0` (TouchableOpacity.js:195). While they were wrappers they passed it as
188
135
  // an internal input; on the tag there is nowhere else to say it, so the floor has to be a
189
136
  // readable prop or every Touchable holds its fade for the machine's 130 ms default.
190
- minPressDuration: numberOr(node.props.minPressDuration, DEFAULT_MIN_PRESS_DURATION_MS),
191
- hitSlop: asRectOffset(node.props.hitSlop),
192
- pressRetentionOffset: asRectOffset(node.props.pressRetentionOffset),
137
+ minPressDuration: numberOr(propOf(node, 'minPressDuration'), DEFAULT_MIN_PRESS_DURATION_MS),
138
+ hitSlop: asRectOffset(propOf(node, 'hitSlop')),
139
+ pressRetentionOffset: asRectOffset(propOf(node, 'pressRetentionOffset')),
140
+ // Pressable.js's own name is `android_disableSound`; every composed touchable (Highlight,
141
+ // NativeFeedback, WithoutFeedback, Button) instead forwards vendor's `touchSoundDisabled` to
142
+ // this same config field (`TouchableHighlight.js:205`, `TouchableWithoutFeedback.js:199`,
143
+ // `TouchableNativeFeedback.js:228`). `node` here is whichever tag AUTHORS the prop — itself for
144
+ // a plain pressable/highlight/button, the owner for a clone-onto-child touchable, since
145
+ // `configFor` is always called with that source — so reading both names off it resolves
146
+ // correctly for every composition without a per-tag override.
147
+ android_disableSound: booleanOr(propOf(node, 'android_disableSound')) ??
148
+ booleanOr(propOf(node, 'touchSoundDisabled')),
193
149
  };
194
150
  }
195
151
  // Rebuilding at GESTURE START is the whole reason for the dispatcher indirection, and skipping it
196
152
  // is a bug that looks like working code. `attach` runs inside `createElement`, before a single
197
- // prop has been routed — `node.props` is literally `{}` there — so a machine built at attach would
153
+ // prop has been routed — the node holds nothing at all there — so a machine built at attach would
198
154
  // capture no `onPress` at all and every press would silently do nothing. `createPressHandlers`
199
155
  // destructures its config eagerly, so it cannot be handed a live view either; it has to be re-made
200
156
  // once the props exist. A gesture is one interaction, so a handful of closures per press is
@@ -208,16 +164,30 @@ function rebuild(node, state) {
208
164
  state.isBuilt = true;
209
165
  // Re-read every gesture, so a tag whose resolver looks past `disabled` — `./button`, at
210
166
  // `aria-disabled` — re-enables on the next touch instead of latching at its first answer.
167
+ const sourceProps = propsOf(source);
211
168
  const disabled = state.disabledOf === undefined
212
- ? source.props.disabled
213
- : state.disabledOf(source.props);
169
+ ? sourceProps.disabled
170
+ : state.disabledOf(sourceProps);
214
171
  state.listeners = buildPressableListeners(handlers, {
215
172
  disabled: disabled === true ? true : undefined,
216
- cancelable: typeof source.props.cancelable === 'boolean'
217
- ? source.props.cancelable
218
- : undefined,
173
+ cancelable: resolveCancelable(source),
174
+ blockNativeResponder: propOf(source, 'blockNativeResponder') === true,
219
175
  });
220
176
  }
177
+ // Pressable is the only member of the family with a `cancelable` prop of its own (`Pressable.js:41`).
178
+ // TouchableOpacity/TouchableHighlight/TouchableNativeFeedback instead expose `rejectResponderTermination`
179
+ // and derive `cancelable: !this.props.rejectResponderTermination` internally
180
+ // (`TouchableOpacity.js:186`, `TouchableHighlight.js:194`, `TouchableNativeFeedback.js:217`) — every
181
+ // composed touchable shares this `rebuild()`, so without this the authored name never reached the
182
+ // machine and every Touchable silently kept the RN native default (yield the responder) regardless of
183
+ // what the app asked for. An explicit `cancelable` still wins, matching Pressable's own precedence.
184
+ function resolveCancelable(source) {
185
+ const cancelable = propOf(source, 'cancelable');
186
+ if (typeof cancelable === 'boolean')
187
+ return cancelable;
188
+ const reject = propOf(source, 'rejectResponderTermination');
189
+ return typeof reject === 'boolean' ? !reject : undefined;
190
+ }
221
191
  // WHICHEVER EVENT OPENS THE GESTURE REBUILDS, and pinning that to one name was a real bug.
222
192
  // `onStartShouldSetResponder` looks like the opener and is not: `core/engine/src/events/index.ts`
223
193
  // bubbles PRESS_IN and only THEN calls `negotiateResponder`, so `pressIn` arrives FIRST on every
@@ -254,16 +224,35 @@ function dispatch(node, state, key, args) {
254
224
  //
255
225
  // The engine event names, not the `onX` prop spellings. `buildPressableListeners` speaks the prop
256
226
  // spelling, so the two are mapped here rather than guessed at either end.
257
- const KEY_BY_EVENT = new Map([
227
+ /**
228
+ * Engine event name -> the app-facing callback key its dispatcher routes to.
229
+ *
230
+ * AN ARRAY OF PAIRS RATHER THAN A `Map`, and the reason is measured rather than stylistic.
231
+ * `installListeners` below is the only reader, and it runs once per node that carries a press
232
+ * machine — which is every `<TextInput>` on the screen, not just every `<Pressable>`. A `for…of` over
233
+ * a `Map` builds a fresh two-element array per entry for the destructuring to read back: seven
234
+ * allocations per node, for seven fixed pairs that never change. The tuples here already exist, so
235
+ * the same loop allocates nothing but its iterator.
236
+ *
237
+ * Priced on `-O` Hermes by `text-input-attach-ladder.itest.ts`, one pass over the seven pairs:
238
+ * `Map` 1.04 us, this 0.39, two parallel arrays 0.29. The parallel arrays are cheapest and give up
239
+ * the pairing, which is not worth 0.1 us on a table that a drift would silently unwire.
240
+ *
241
+ * NOT the same list as `createPressBehavior`'s `ownedListeners`, and they must not be merged: that
242
+ * one is every name the machine takes as an INPUT (`pressMove` and `longPress` included), this one
243
+ * is only the names it installs a dispatcher for.
244
+ */
245
+ const EVENT_KEY_PAIRS = [
258
246
  ['press', 'onPress'],
259
247
  ['pressIn', 'onPressIn'],
260
248
  ['pressOut', 'onPressOut'],
261
249
  ['startShouldSetResponder', 'onStartShouldSetResponder'],
262
250
  ['responderMove', 'onResponderMove'],
263
251
  ['responderTerminationRequest', 'onResponderTerminationRequest'],
264
- ]);
252
+ ['responderGrant', 'onResponderGrant'],
253
+ ];
265
254
  function installListeners(node, state) {
266
- for (const [event, key] of KEY_BY_EVENT) {
255
+ for (const [event, key] of EVENT_KEY_PAIRS) {
267
256
  setBehaviorListener(node, event, symbioteEvent => dispatch(node, state, key, [symbioteEvent]));
268
257
  }
269
258
  }
@@ -362,7 +351,6 @@ export function createPressBehavior(refine, disabledOf) {
362
351
  return {
363
352
  attach: attachWith(refine, disabledOf),
364
353
  detach,
365
- foldPayload,
366
354
  // Every name the machine needs as an INPUT. The responder pair is not optional — it is how a
367
355
  // gesture starts at all, and `RESPONDER_EVENTS` makes those listeners on any node regardless
368
356
  // of ViewConfig, so they collide exactly like `press` does.
@@ -375,6 +363,7 @@ export function createPressBehavior(refine, disabledOf) {
375
363
  'startShouldSetResponder',
376
364
  'responderMove',
377
365
  'responderTerminationRequest',
366
+ 'responderGrant',
378
367
  ],
379
368
  };
380
369
  }
@@ -29,7 +29,7 @@
29
29
  // `behaviors/switch.ts`'s module header, verbatim. Same shape, same two triggers, same reasons —
30
30
  // an ACCEPTING app's state reaches `node.props` only after its own reconciliation, and a REJECTING
31
31
  // app writes no prop at all, so the commit that `afterCommit` waits for never comes.
32
- import { appListenerFor, dispatchViewCommand, dlog, registerHostBehavior, setBehaviorListener, } from '@symbiote-native/engine';
32
+ import { appListenerFor, dispatchViewCommand, dlog, registerHostBehavior, setBehaviorListener, propOf, } from '@symbiote-native/engine';
33
33
  export const REFRESH_CONTROL_TAG = 'refresh-control';
34
34
  // RN's own name for the command, sent to whichever of the two native views the platform resolved.
35
35
  const SET_NATIVE_REFRESHING = 'setNativeRefreshing';
@@ -38,12 +38,25 @@ const SET_NATIVE_REFRESHING = 'setNativeRefreshing';
38
38
  // authoritative, and an app that drives `refreshing` on its own initiative must never be corrected
39
39
  // against a value native never claimed.
40
40
  const reported = new WeakMap();
41
+ // TODO(rn-parity, low priority, efficiency not correctness): vendor's `componentDidUpdate`
42
+ // (`RefreshControl.js:139-158`) sends `setNativeRefreshing` only when `refreshing` did NOT change
43
+ // between renders, trusting the ordinary prop diff to carry a real change to native. Ours has no
44
+ // such guard — `evaluateSnapBack` fires the command on any disagreement with `reported`, even when
45
+ // this same commit already carries the authored `refreshing` change (accept-then-finish: app agrees
46
+ // on the gesture, then sets `refreshing: false` to end it — vendor sends zero commands, we send one
47
+ // extra). Both settle on the same native value, so this is a harmless redundant call, not a bug —
48
+ // and NOT a candidate for the same fix as the "repairs a snap-back the app contradicts on a later
49
+ // commit" test below, which relies on this exact unconditional re-check for a genuine cross-
50
+ // framework microtask race. A real fix needs tracking the last-seen authored value alongside
51
+ // `reported`, so a same-commit authored change can be told apart from native drifting on its own —
52
+ // not attempted here.
53
+ //
41
54
  // Shared by both triggers — see the module header for why there are two.
42
55
  function evaluateSnapBack(node) {
43
56
  const lastNativeReport = reported.get(node);
44
57
  if (lastNativeReport === undefined)
45
58
  return; // no report yet, nothing to disagree with
46
- const refreshing = node.props.refreshing === true;
59
+ const refreshing = propOf(node, 'refreshing') === true;
47
60
  if (lastNativeReport === refreshing) {
48
61
  dlog(`RefreshControl behavior snap-back no-op refreshing=${refreshing}`);
49
62
  return;
@@ -6,46 +6,34 @@
6
6
  // wrapper's frame, visual on the scroller (`ScrollView.js:1856`). `nestedScrollEnabled` goes on the
7
7
  // inner view so it consumes the gesture before the refresh parent sees it.
8
8
  //
9
- // The two folds below are what neither node can work out alone: the wrapper is the APP's node, so
10
- // it carries whatever the app wrote on `<RefreshControl>` and knows nothing about the scroll view's
11
- // style. RN reaches the same place through `cloneElement`, which likewise OVERRIDES the refresh
12
- // control's own `style` — so replacing it here is parity, not a liberty.
13
- import {} from '@symbiote-native/engine';
14
- import { splitScrollViewStyle } from '../../scroll-view-commands.js';
15
- import { ownerFold, registerScrollViewBehaviors, } from './shared.js';
16
- // The owner under a wrap: the ordinary fold with the VISUAL half of its own style in place of the
17
- // composed one. Delegating rather than restating is what keeps `decelerationRate`, `horizontal` and
18
- // `nestedScrollEnabled` in ONE place — none of the three has anything to do with the wrap, and the
19
- // hand-written copy this replaced had already lost the first of them.
20
- function wrappedOwnerFold(base, horizontal) {
21
- const plain = ownerFold(base, horizontal);
22
- return props => ({
23
- ...plain(props),
24
- style: splitScrollViewStyle(base, props.style).inner,
25
- });
26
- }
27
- // The wrapper: the LAYOUT half of the OWNER's style, read off the owner because that is where the
28
- // app wrote it. Kept in step by `slotDerived` naming `style`, which marks the wrapper dirty on an
29
- // owner style write.
30
- function wrapperFold(owner, base) {
31
- return props => ({
32
- ...props,
33
- style: splitScrollViewStyle(base, owner.props.style).outer,
34
- });
35
- }
9
+ // THIS FILE IS A CLAIM MODE AND NOTHING ELSE NOW (2026-09-18). It carried two `payloadFold`s until
10
+ // then — the last two folds in ScrollView and the last structural blocker in the behavior
11
+ // migration — and both are `SymbioteFabricProps.cpp` now: `foldScrollViewProps` takes the visual
12
+ // half when its parent is a refresh control, and `foldRefreshWrapperProps` takes the layout half of
13
+ // the child it wraps.
14
+ //
15
+ // WHY THEY RESISTED THREE ITERATIONS, and what changed. Every seam the engine had read UP —
16
+ // `ownerProps`, `IOwner.tagName`, `IAncestorLookup` — and the wrapper is the scroll view's PARENT
17
+ // asking for the scroll view's style, the one direction none of them go. `IFirstChild` is that
18
+ // direction, and it is the same argument `ownerProps` already made rather than a new one: the tree
19
+ // lives in C++, so reading another node is a pointer hop, and RN itself builds this parent FROM its
20
+ // child (`cloneElement(refreshControl, {style: outer}, scrollView)`).
21
+ //
22
+ // `slotDerived: ['style']` STAYS AND IS NOW LOAD-BEARING FOR THE ENGINE'S RULE. A rule re-runs when
23
+ // ITS node is dirty; the wrapper derives from a node that is not itself, so an owner style write
24
+ // must mark it (`routeProp`'s `node.wrapper` branch). Without this entry the wrapper freezes at its
25
+ // mount frame while the scroller visibly restyles inside it —
26
+ // `core/engine/cpp/tests/js/scroll-view-wrap-payload.itest.ts` is the case that says so.
27
+ //
28
+ // AND NOTHING HERE IS `#ifdef ANDROID` OR `Platform.OS`, on either side: the engine's rules are
29
+ // gated on TOPOLOGY. iOS claims the refresh control BESIDE the content, so a scroll view is never
30
+ // one's child there and neither branch can fire. That is strictly better than a compile-time split
31
+ // for the reason `Switch`/`AndroidSwitch` already showed — and it is why the wrap's payload fixture
32
+ // runs on the ORDINARY test host rather than needing the Android arm.
33
+ import { registerScrollViewBehaviors } from './shared.js';
36
34
  const android = {
37
35
  claimMode: 'wrap',
38
36
  slotDerived: ['style'],
39
- onWrapChange: (base, horizontal) => (owner, wrapper) => {
40
- // Back to the ordinary composition, not to `undefined` — the plain fold carries the axis and
41
- // the gesture props, which have nothing to do with the wrap.
42
- owner.payloadFold =
43
- wrapper === undefined
44
- ? ownerFold(base, horizontal)
45
- : wrappedOwnerFold(base, horizontal);
46
- if (wrapper !== undefined)
47
- wrapper.payloadFold = wrapperFold(owner, base);
48
- },
49
37
  };
50
38
  export function registerScrollViewBehavior() {
51
39
  registerScrollViewBehaviors(android);
@@ -1,2 +1,3 @@
1
1
  export { registerScrollViewBehavior } from './index.ios';
2
2
  export { HORIZONTAL_SCROLL_VIEW_TAG, REFRESH_CONTROL, SCROLL_VIEW_TAG, } from './shared';
3
+ export { STICKY_HEADER_TAG } from './sticky';
@@ -3,3 +3,6 @@
3
3
  // `render-scroll-view`'s own `Platform.select` defaults to it.
4
4
  export { registerScrollViewBehavior } from './index.ios.js';
5
5
  export { HORIZONTAL_SCROLL_VIEW_TAG, REFRESH_CONTROL, SCROLL_VIEW_TAG, } from './shared.js';
6
+ // The tag, so a test can locate a committed sticky wrapper by what the engine was TOLD rather than
7
+ // by a key its tag rule writes — see `ILiveNode.tagName`.
8
+ export { STICKY_HEADER_TAG } from './sticky.js';
@@ -0,0 +1,4 @@
1
+ import { type ISymbioteNode } from '@symbiote-native/engine';
2
+ export declare function markScrollObserved(owner: ISymbioteNode): void;
3
+ export declare const RESPONDER_OWNED_LISTENERS: readonly string[];
4
+ export declare function installResponderPredicates(owner: ISymbioteNode): void;
@@ -0,0 +1,202 @@
1
+ // ScrollView's own participation in the responder negotiation
2
+ // (`core/engine/src/events`) — RN's `ScrollView.js` four predicates
3
+ // (`_handleStartShouldSetResponderCapture`/`_handleStartShouldSetResponder`/
4
+ // `_handleResponderTerminationRequest`) plus the grant/release/momentum bookkeeping they read.
5
+ // Ported line-for-line from `ScrollView.js:1263-1546` — the negotiation engine itself and the
6
+ // `IResponderProps` prop surface already exist and needed no change; this file is ScrollView opting
7
+ // into the same mechanism Pressable already uses (`../pressable.ts:329-384`).
8
+ import { appListenerFor, currentlyFocusedInput, blurTextInput, isRecord, isSymbioteNode, Keyboard, propOf, setBehaviorListener, } from '@symbiote-native/engine';
9
+ import { descriptorFor } from '../../component-names';
10
+ import { TEXT_INPUT_MULTILINE_TAG, TEXT_INPUT_TAG } from '../text-input.js';
11
+ // RN's `IS_ANIMATING_TOUCH_START_THRESHOLD_MS` (ScrollView.js:695) — a momentum end inside this
12
+ // window still counts as animating, so a touch that lands one frame after the list stops still
13
+ // goes to the ScrollView rather than a child it is still settling under.
14
+ const IS_ANIMATING_TOUCH_START_THRESHOLD_MS = 16;
15
+ const stateByOwner = new WeakMap();
16
+ function stateFor(owner) {
17
+ let state = stateByOwner.get(owner);
18
+ if (state === undefined) {
19
+ state = {
20
+ observedScrollSinceBecomingResponder: false,
21
+ becameResponderWhileAnimating: false,
22
+ lastMomentumScrollBeginTime: 0,
23
+ lastMomentumScrollEndTime: 0,
24
+ };
25
+ stateByOwner.set(owner, state);
26
+ }
27
+ return state;
28
+ }
29
+ // RN's `_isAnimating()` (ScrollView.js:1321-1330), unchanged: a momentum scroll in flight
30
+ // (begin > end) or one that just ended within the threshold.
31
+ function isAnimating(state) {
32
+ const sinceEnd = performance.now() - state.lastMomentumScrollEndTime;
33
+ return (sinceEnd < IS_ANIMATING_TOUCH_START_THRESHOLD_MS ||
34
+ state.lastMomentumScrollEndTime < state.lastMomentumScrollBeginTime);
35
+ }
36
+ // The one thing this repo already has that RN builds from native `Keyboard` events:
37
+ // `Keyboard.metrics()` reads the last-known metrics synchronously (`core/engine/src/keyboard`),
38
+ // the same value RN caches on `_keyboardMetrics`. Reused as-is, no new tracking.
39
+ // `node.component` is the resolved Fabric VIEW NAME (`RCTSinglelineTextInputView` on iOS, its own
40
+ // name on Android), not the tag — the tag is consumed at `createElement` and not retained
41
+ // (`core/engine/src/node.ts:466-470`'s own comment). `descriptorFor` resolves the same tag through
42
+ // the same per-platform table TextInput's own registration used, so the two stay in lockstep by
43
+ // construction rather than by a second hardcoded name list here.
44
+ function isTextInputNode(node) {
45
+ return (node.component === descriptorFor(TEXT_INPUT_TAG).component ||
46
+ node.component === descriptorFor(TEXT_INPUT_MULTILINE_TAG).component);
47
+ }
48
+ // RN's `_keyboardIsDismissible()` (ScrollView.js:1527-1541). RN also has `_keyboardEventsAreUnreliable()`
49
+ // — a pre-API-30 Android layout-polling fallback — deliberately not ported: modern targets only,
50
+ // and the omission only ever makes this return `false` more readily (never dismisses when RN
51
+ // would not have been sure either), never a false claim.
52
+ function keyboardIsDismissible() {
53
+ const focused = currentlyFocusedInput();
54
+ const hasFocusedTextInput = focused !== null && isTextInputNode(focused);
55
+ return hasFocusedTextInput && Keyboard.metrics() !== undefined;
56
+ }
57
+ // RN's `_softKeyboardIsDetached()` (ScrollView.js:1543-1546).
58
+ function softKeyboardIsDetached() {
59
+ const metrics = Keyboard.metrics();
60
+ return metrics !== undefined && metrics.height === 0;
61
+ }
62
+ // The should-set listeners run on the SCROLL VIEW's own node (`callOwnListener` sets `event.target`
63
+ // to the node being asked, not the node the touch landed on), so RN's `e.target` has to be read
64
+ // back out of the raw touch payload instead — the same place `core/engine/src/touch-history.ts`
65
+ // and `hasRemainingTouchWithin` (`events/index.ts`) already read a touch's `target` from.
66
+ function touchTargetOf(nativeEvent) {
67
+ for (const key of ['changedTouches', 'touches']) {
68
+ const touches = nativeEvent[key];
69
+ if (!Array.isArray(touches) || touches.length === 0)
70
+ continue;
71
+ const first = touches[0];
72
+ if (!isRecord(first))
73
+ continue;
74
+ const target = first.target;
75
+ if (isSymbioteNode(target))
76
+ return target;
77
+ }
78
+ return undefined;
79
+ }
80
+ function keyboardNeverPersistsTaps(persist) {
81
+ return (persist === undefined ||
82
+ persist === null ||
83
+ persist === false ||
84
+ persist === 'never');
85
+ }
86
+ // `ScrollView.js:1474-1522`. Claims the tap in the CAPTURE phase — before any child sees it —
87
+ // while animating, or (the keyboard-dismiss case) while the default is in force, a dismissible
88
+ // keyboard is up, and the touch did not land on the focused input itself.
89
+ function startShouldSetResponderCapture(owner, event) {
90
+ const state = stateFor(owner);
91
+ if (isAnimating(state))
92
+ return true;
93
+ if (propOf(owner, 'disableScrollViewPanResponder') === true)
94
+ return false;
95
+ if (softKeyboardIsDetached())
96
+ return false;
97
+ if (keyboardNeverPersistsTaps(propOf(owner, 'keyboardShouldPersistTaps')) &&
98
+ keyboardIsDismissible()) {
99
+ const target = touchTargetOf(event.nativeEvent);
100
+ if (target !== undefined && !isTextInputNode(target))
101
+ return true;
102
+ }
103
+ return false;
104
+ }
105
+ // `ScrollView.js:1444-1461`. The BUBBLE-phase counterpart — only reachable when capture declined
106
+ // — for `keyboardShouldPersistTaps: 'handled'`: claim only if the tap didn't land on the focused
107
+ // input (letting a child handle it first is the whole point of "handled").
108
+ function startShouldSetResponder(owner, event) {
109
+ if (propOf(owner, 'disableScrollViewPanResponder') === true)
110
+ return false;
111
+ if (propOf(owner, 'keyboardShouldPersistTaps') === 'handled' &&
112
+ keyboardIsDismissible()) {
113
+ const target = touchTargetOf(event.nativeEvent);
114
+ if (target !== currentlyFocusedInput())
115
+ return true;
116
+ }
117
+ return false;
118
+ }
119
+ // `ScrollView.js:1404-1406`. Once real scroll motion has been observed, refuse to give the
120
+ // responder up — matches every other adapter's "don't drop a gesture already committed" rule.
121
+ function responderTerminationRequest(owner) {
122
+ return !stateFor(owner).observedScrollSinceBecomingResponder;
123
+ }
124
+ // `ScrollView.js:1334-1341`. `onResponderGrant` is an ordinary `IResponderProps` field an app may
125
+ // also set, so — same reason `scroll`/`layout`/`contentSizeChange` are owned — it is composed
126
+ // through the stash rather than claimed outright.
127
+ function handleResponderGrant(owner, event) {
128
+ const state = stateFor(owner);
129
+ state.observedScrollSinceBecomingResponder = false;
130
+ const app = appListenerFor(owner, 'responderGrant');
131
+ if (typeof app === 'function')
132
+ app(event);
133
+ state.becameResponderWhileAnimating = isAnimating(state);
134
+ }
135
+ // `ScrollView.js:1357-1387`. On release, dismiss the keyboard iff: something is focused,
136
+ // `keyboardShouldPersistTaps` isn't `true`/`'always'`, the keyboard is dismissible, the release
137
+ // didn't land on the focused input, and nothing since becoming responder was a real scroll or an
138
+ // animation-time grant (both mean the touch was already "used" for something else).
139
+ function handleResponderRelease(owner, event) {
140
+ const state = stateFor(owner);
141
+ const app = appListenerFor(owner, 'responderRelease');
142
+ if (typeof app === 'function')
143
+ app(event);
144
+ const focused = currentlyFocusedInput();
145
+ const persist = propOf(owner, 'keyboardShouldPersistTaps');
146
+ if (focused === null ||
147
+ persist === true ||
148
+ persist === 'always' ||
149
+ !keyboardIsDismissible() ||
150
+ state.observedScrollSinceBecomingResponder ||
151
+ state.becameResponderWhileAnimating)
152
+ return;
153
+ if (touchTargetOf(event.nativeEvent) === focused)
154
+ return;
155
+ blurTextInput(focused);
156
+ }
157
+ // `ScrollView.js:1146` — unconditional, exactly as RN sets it from `_handleScroll`: nothing reads
158
+ // the flag unless this node actually holds the responder, so setting it on every scroll is safe.
159
+ export function markScrollObserved(owner) {
160
+ stateFor(owner).observedScrollSinceBecomingResponder = true;
161
+ }
162
+ // `ScrollView.js:1263-1274`. Both are ordinary `on*` props apps use for pagination, so both are
163
+ // composed through the stash exactly like `handleOwnerScroll` composes `onScroll` (`./sticky.ts`).
164
+ function handleMomentumScrollBegin(owner, event) {
165
+ stateFor(owner).lastMomentumScrollBeginTime = performance.now();
166
+ const app = appListenerFor(owner, 'momentumScrollBegin');
167
+ if (typeof app === 'function')
168
+ app(event);
169
+ }
170
+ function handleMomentumScrollEnd(owner, event) {
171
+ stateFor(owner).lastMomentumScrollEndTime = performance.now();
172
+ const app = appListenerFor(owner, 'momentumScrollEnd');
173
+ if (typeof app === 'function')
174
+ app(event);
175
+ }
176
+ export const RESPONDER_OWNED_LISTENERS = [
177
+ 'startShouldSetResponderCapture',
178
+ 'startShouldSetResponder',
179
+ 'responderTerminationRequest',
180
+ 'responderGrant',
181
+ 'responderRelease',
182
+ 'momentumScrollBegin',
183
+ 'momentumScrollEnd',
184
+ ];
185
+ // Installed once per owner, in `attach` — same beat Pressable wires its own responder pair.
186
+ export function installResponderPredicates(owner) {
187
+ setBehaviorListener(owner, 'startShouldSetResponderCapture', event => startShouldSetResponderCapture(owner, event));
188
+ setBehaviorListener(owner, 'startShouldSetResponder', event => startShouldSetResponder(owner, event));
189
+ setBehaviorListener(owner, 'responderTerminationRequest', () => responderTerminationRequest(owner));
190
+ setBehaviorListener(owner, 'responderGrant', event => {
191
+ handleResponderGrant(owner, event);
192
+ });
193
+ setBehaviorListener(owner, 'responderRelease', event => {
194
+ handleResponderRelease(owner, event);
195
+ });
196
+ setBehaviorListener(owner, 'momentumScrollBegin', event => {
197
+ handleMomentumScrollBegin(owner, event);
198
+ });
199
+ setBehaviorListener(owner, 'momentumScrollEnd', event => {
200
+ handleMomentumScrollEnd(owner, event);
201
+ });
202
+ }
@@ -1,11 +1,9 @@
1
- import { type IClaimMode, type IHostBehavior, type IPayloadFold, type IViewStyle } from '@symbiote-native/engine';
1
+ import { type IClaimMode } from '@symbiote-native/engine';
2
2
  export declare const SCROLL_VIEW_TAG = "scroll-view";
3
3
  export declare const HORIZONTAL_SCROLL_VIEW_TAG = "horizontal-scroll-view";
4
4
  export declare const REFRESH_CONTROL: string;
5
- export declare function ownerFold(base: IViewStyle, horizontal: boolean): IPayloadFold;
6
5
  export interface IScrollPlatform {
7
6
  claimMode: IClaimMode;
8
- onWrapChange?: (base: IViewStyle, horizontal: boolean) => IHostBehavior['onWrapChange'];
9
7
  slotDerived?: readonly string[];
10
8
  }
11
9
  export declare function registerScrollViewBehaviors(platform: IScrollPlatform): void;