@symbiote-native/components 3.1.1 → 3.1.3

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 (40) hide show
  1. package/build/behaviors/activity-indicator/shared.js +29 -71
  2. package/build/behaviors/button.d.ts +0 -9
  3. package/build/behaviors/button.js +47 -233
  4. package/build/behaviors/image-background.js +21 -81
  5. package/build/behaviors/image.js +3 -10
  6. package/build/behaviors/input-accessory-view.js +10 -51
  7. package/build/behaviors/pressable.d.ts +5 -51
  8. package/build/behaviors/pressable.js +101 -161
  9. package/build/behaviors/refresh-control.js +8 -2
  10. package/build/behaviors/scroll-view/index.android.js +10 -30
  11. package/build/behaviors/scroll-view/responder.d.ts +3 -2
  12. package/build/behaviors/scroll-view/responder.js +19 -21
  13. package/build/behaviors/scroll-view/shared.js +69 -185
  14. package/build/behaviors/scroll-view/sticky.d.ts +0 -8
  15. package/build/behaviors/scroll-view/sticky.js +54 -142
  16. package/build/behaviors/switch.js +18 -4
  17. package/build/behaviors/text-input.d.ts +0 -8
  18. package/build/behaviors/text-input.js +77 -162
  19. package/build/behaviors/touchable-highlight.js +14 -54
  20. package/build/behaviors/touchable-native-feedback.js +9 -32
  21. package/build/behaviors/touchable-opacity.d.ts +0 -7
  22. package/build/behaviors/touchable-opacity.js +65 -81
  23. package/build/behaviors/touchable-without-feedback.js +25 -41
  24. package/build/component-names/index.android.js +6 -8
  25. package/build/component-names/shared.js +12 -42
  26. package/build/index.js +13 -19
  27. package/build/scroll-view-commands.js +3 -11
  28. package/build/state/pressable.js +18 -43
  29. package/build/state/sticky-header-reducer.js +103 -149
  30. package/build/state/touchable.js +3 -5
  31. package/build/state/virtualized-list-reducer.js +21 -48
  32. package/build/state/virtualized-list.js +63 -148
  33. package/build/text-props.js +3 -13
  34. package/build/view/render-button.js +13 -44
  35. package/build/view/render-input-accessory-view.js +8 -24
  36. package/build/view/render-pressable/index.js +3 -4
  37. package/build/view/render-scroll-view.js +13 -23
  38. package/build/view/render-touchable-native-feedback.js +5 -14
  39. package/host-primitives.cjs +33 -207
  40. package/package.json +3 -3
@@ -1,20 +1,10 @@
1
1
  // The press machine as an ENGINE-NODE behavior, so a pressable can be an intrinsic tag instead of
2
- // a framework component (`.claude/rules/host-primitive-tier.md`, tier 2). Written once; every
3
- // adapter inherits it by registering, and none re-implements it.
4
- //
5
- // The machine itself is unchanged and still shared with the component path — `createPressRuntime`
6
- // / `createPressHandlers` in `../state/pressable`. What is new is only WHERE its lifecycle lives:
7
- // on the engine node rather than in a component instance.
8
- //
9
- // REGISTRATION IS THE HAZARD, not the machine. Metro enables `inlineRequires` in production only,
10
- // moving a `require` to the first place its binding is used as a VALUE, and a barrel's
11
- // `export { X } from './x'` compiles to a lazy getter. A module whose only job is a side effect is
12
- // never named as a value, so re-exporting it means it NEVER RUNS in a release build — dev perfect,
13
- // release silently pressless. Each adapter therefore keeps its own `src/register.ts` calling
14
- // `registerPressableBehavior()`, and its entry does a bare `import './register';` that the barrel
15
- // does NOT re-export. A bare `import './register';` sitting NEXT TO such a re-export does not work
16
- // either: Babel merges the two imports of one specifier and the merged dependency stays lazy.
17
- import { appListenerFor, dispatchViewCommand, dlog, Platform, propOf, registerHostBehavior, requestCommitFor, setBehaviorListener, setNodePressed, propsOf, } from '@symbiote-native/engine';
2
+ // a framework component. Written once; every adapter inherits it by registering, none re-implements
3
+ // it. The machine itself is unchanged and still shared with the component path.
4
+ // Registration, not the machine, is the hazard: Metro's inlineRequires makes a barrel re-export of
5
+ // a side-effect-only module never run in a release build. Each adapter keeps its own
6
+ // `src/register.ts` calling registerPressableBehavior(), reached only by a bare side-effect import.
7
+ import { appListenerFor, dispatchViewCommand, dlog, Platform, propOf, registerHostBehavior, requestCommitFor, setNodeDispatch, setNodePressed, propsOf, } from '@symbiote-native/engine';
18
8
  import { createPressHandlers, createPressRuntime, disposePressRuntime, DEFAULT_DELAY_LONG_PRESS_MS, DEFAULT_MIN_PRESS_DURATION_MS, } from '../state/pressable.js';
19
9
  import { buildPressableListeners } from '../view/render-pressable/index.js';
20
10
  export const PRESSABLE_TAG = 'pressable';
@@ -56,10 +46,9 @@ function asRectOffset(value) {
56
46
  function isPressHandler(value) {
57
47
  return typeof value === 'function';
58
48
  }
59
- // Narrowed field by field rather than cast: the bag arrives as `unknown` off `propOf`. A local
60
- // twin of the guard each adapter keeps for its own attrs (Vue's `asAccessibilityState`) — exported
61
- // to the sibling behaviors that fold the same bag, and deliberately NOT to the shared barrel, which
62
- // every adapter re-exports wholesale: a narrowing helper is not API anyone should be able to import.
49
+ // Narrowed field by field rather than cast: the bag arrives as `unknown` off propOf. Exported to
50
+ // sibling behaviors folding the same bag, deliberately NOT to the shared barrel — a narrowing
51
+ // helper isn't API anyone should import.
63
52
  export function asAccessibilityState(value) {
64
53
  if (!isRecord(value))
65
54
  return undefined;
@@ -76,36 +65,19 @@ export function asAccessibilityState(value) {
76
65
  state.expanded = value.expanded;
77
66
  return state;
78
67
  }
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>`.
85
- //
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.
94
- // RN makes every pressable accessible unless the app opts OUT — `Pressable.js:252`
95
- // (`accessible: accessible !== false`), and the whole Touchable family repeats it verbatim
96
- // (`TouchableOpacity.js:303`, `TouchableHighlight.js:337`). `!== false` rather than `?? true`, so
97
- // only a literal `false` opts out and an explicit `undefined` still reads as accessible.
98
- //
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.
68
+ // The payload fold moved to the engine (foldPressableProps in SymbioteFabricProps.cpp), not
69
+ // re-implemented here: disabled -> accessibilityState, accessible/focusable defaulting on, the
70
+ // ripple config and the machine-only keys are all functions of the TAG alone, user-agent behavior.
71
+ // What's still here is the MACHINE: timers, the responder claim, hit-slop retention, callbacks
72
+ // into app code. `hitSlop` did NOT move with the fold — it's a real native View prop.
73
+ // RN makes every pressable accessible unless the app opts OUT (`accessible !== false`), and the
74
+ // whole Touchable family repeats it. `!== false` not `?? true`, so only a literal `false` opts out.
101
75
  export function accessibleUnlessOptedOut(props) {
102
76
  return props.accessible !== false;
103
77
  }
104
- // From the STASH, not from the props. Every name below is in `ownedListeners`, so `routeProp`
105
- // diverts the app's `onPress` away from `node.listeners` (where it would evict the behavior's own
106
- // dispatcher) and into the stash — which makes the stash the only place it exists. Reading
107
- // `propOf` here returns undefined for every callback and every press silently does nothing:
108
- // the behavior runs, the machine runs, and it calls nobody.
78
+ // From the STASH, not the props: every name below is in ownedListeners, so routeProp diverts the
79
+ // app's onPress away from node.listeners into the stash. Reading propOf here would return
80
+ // undefined for every callback and every press would silently call nobody.
109
81
  function callbackAt(node, event) {
110
82
  const value = appListenerFor(node, event);
111
83
  return isPressHandler(value) ? value : undefined;
@@ -121,29 +93,19 @@ function configFor(node) {
121
93
  onPressOut: callbackAt(node, 'pressOut'),
122
94
  onPressMove: callbackAt(node, 'pressMove'),
123
95
  onLongPress: callbackAt(node, 'longPress'),
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).
96
+ // The subtraction applies only to the FALLBACK, never an authored value — it keeps the
97
+ // long-press threshold at a constant 500ms from touch-down by default, not
98
+ // 500ms + unstable_pressDelay (see createPressHandlers.handlePressIn for the other half).
131
99
  delayLongPress: Math.max(10, numberOr(propOf(node, 'delayLongPress'), DEFAULT_DELAY_LONG_PRESS_MS - unstablePressDelay)),
132
100
  unstable_pressDelay: unstablePressDelay,
133
- // RN's Touchables own the deactivation floor in their OWN machine and hand Pressability
134
- // `minPressDuration: 0` (TouchableOpacity.js:195). While they were wrappers they passed it as
135
- // an internal input; on the tag there is nowhere else to say it, so the floor has to be a
136
- // readable prop or every Touchable holds its fade for the machine's 130 ms default.
101
+ // RN's Touchables own the deactivation floor in their own machine, handing Pressability
102
+ // `minPressDuration: 0`. On the tag there's nowhere else to say it, so it's a readable prop.
137
103
  minPressDuration: numberOr(propOf(node, 'minPressDuration'), DEFAULT_MIN_PRESS_DURATION_MS),
138
104
  hitSlop: asRectOffset(propOf(node, 'hitSlop')),
139
105
  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.
106
+ // Pressable's own name is `android_disableSound`; every composed touchable instead forwards
107
+ // `touchSoundDisabled` to this same field. `node` here is whichever tag AUTHORS the prop, so
108
+ // reading both names resolves correctly for every composition without a per-tag override.
147
109
  android_disableSound: booleanOr(propOf(node, 'android_disableSound')) ??
148
110
  booleanOr(propOf(node, 'touchSoundDisabled')),
149
111
  };
@@ -164,12 +126,8 @@ function hotspotAt(nativeEvent, key) {
164
126
  const value = nativeEvent[key];
165
127
  return typeof value === 'number' ? value : 0;
166
128
  }
167
- /**
168
- * The Android ripple's three view commands around the app's callbacks (TNF :230-252,
169
- * useAndroidRippleForView.js:77-104). The JS responder takes the touch before Android's own
170
- * pressed handling, so without them the ripple never animates. Hotspot first: it starts under
171
- * the finger.
172
- */
129
+ // The Android ripple's three view commands around the app's callbacks. The JS responder takes
130
+ // the touch before Android's own pressed handling, so without them the ripple never animates.
173
131
  export function withNativeFeedbackCommands(node, config) {
174
132
  const hotspot = (event) => {
175
133
  dispatchViewCommand(node, 'hotspotUpdate', [
@@ -194,13 +152,9 @@ export function withNativeFeedbackCommands(node, config) {
194
152
  },
195
153
  };
196
154
  }
197
- // Rebuilding at GESTURE START is the whole reason for the dispatcher indirection, and skipping it
198
- // is a bug that looks like working code. `attach` runs inside `createElement`, before a single
199
- // prop has been routed — the node holds nothing at all there — so a machine built at attach would
200
- // capture no `onPress` at all and every press would silently do nothing. `createPressHandlers`
201
- // destructures its config eagerly, so it cannot be handed a live view either; it has to be re-made
202
- // once the props exist. A gesture is one interaction, so a handful of closures per press is
203
- // invisible — unlike doing it per prop write, which is the cost this whole tier exists to remove.
155
+ // Rebuilding at GESTURE START is the whole reason for the dispatcher indirection: `attach` runs
156
+ // inside createElement before a single prop is routed, so a machine built at attach would capture
157
+ // no onPress at all. A gesture is one interaction, so a handful of closures per press is invisible.
204
158
  function rebuild(node, state) {
205
159
  // `state.source` for everything READ, `node` for the refinement, which acts on the responder
206
160
  // (dispatching a view command needs the committed node, not the one holding the props).
@@ -223,13 +177,9 @@ function rebuild(node, state) {
223
177
  blockNativeResponder: propOf(source, 'blockNativeResponder') === true,
224
178
  });
225
179
  }
226
- // Pressable is the only member of the family with a `cancelable` prop of its own (`Pressable.js:41`).
227
- // TouchableOpacity/TouchableHighlight/TouchableNativeFeedback instead expose `rejectResponderTermination`
228
- // and derive `cancelable: !this.props.rejectResponderTermination` internally
229
- // (`TouchableOpacity.js:186`, `TouchableHighlight.js:194`, `TouchableNativeFeedback.js:217`) — every
230
- // composed touchable shares this `rebuild()`, so without this the authored name never reached the
231
- // machine and every Touchable silently kept the RN native default (yield the responder) regardless of
232
- // what the app asked for. An explicit `cancelable` still wins, matching Pressable's own precedence.
180
+ // Pressable is the only family member with its own `cancelable` prop; the Touchables instead
181
+ // expose `rejectResponderTermination` and derive cancelable internally. Without this the authored
182
+ // name never reaches the machine. An explicit `cancelable` still wins, matching Pressable's rule.
233
183
  function resolveCancelable(source) {
234
184
  const cancelable = propOf(source, 'cancelable');
235
185
  if (typeof cancelable === 'boolean')
@@ -237,24 +187,17 @@ function resolveCancelable(source) {
237
187
  const reject = propOf(source, 'rejectResponderTermination');
238
188
  return typeof reject === 'boolean' ? !reject : undefined;
239
189
  }
240
- // WHICHEVER EVENT OPENS THE GESTURE REBUILDS, and pinning that to one name was a real bug.
241
- // `onStartShouldSetResponder` looks like the opener and is not: `core/engine/src/events/index.ts`
242
- // bubbles PRESS_IN and only THEN calls `negotiateResponder`, so `pressIn` arrives FIRST on every
243
- // gesture. Rebuilding only on the responder claim therefore handed the first `pressIn` an empty
244
- // listener bag — the press-in half of every press was dropped, the pressed style never reached
245
- // Fabric, and `onPress` still fired because by then the machine existed. That is exactly the
246
- // device report: the callback works, the button does not light up.
247
- //
248
- // So the trigger is a FLAG, not a name: build if this gesture has not built yet, and clear it when
249
- // the gesture ends. Order-independent, and it survives the engine reordering its own events.
250
- //
251
- // A key `buildPressableListeners` omitted — every one of them when `disabled` is true — resolves to
252
- // undefined here and the dispatcher returns undefined, which is what an absent listener would do.
190
+ // WHICHEVER EVENT OPENS THE GESTURE REBUILDS: `onStartShouldSetResponder` looks like the opener
191
+ // and isn't, since the engine bubbles PRESS_IN and only then negotiates the responder, so
192
+ // `pressIn` arrives first. Rebuilding only on the responder claim handed it an empty listener bag.
193
+ // The trigger is a FLAG, not a name: build if this gesture hasn't built yet, clear on gesture end
194
+ // — order-independent, survives the engine reordering its own events.
253
195
  const GESTURE_END_KEYS = new Set([
254
196
  'onPressOut',
255
197
  'onResponderTerminationRequest',
256
198
  ]);
257
- function dispatch(node, state, key, args) {
199
+ function dispatch(node, options, key, args) {
200
+ const state = stateOf(node, options);
258
201
  if (!state.isBuilt)
259
202
  rebuild(node, state);
260
203
  const listener = state.listeners[key];
@@ -267,31 +210,10 @@ function dispatch(node, state, key, args) {
267
210
  state.isBuilt = false;
268
211
  return result;
269
212
  }
270
- // Installed straight into the listener slot rather than through `routeProp`: the behavior OWNS
271
- // these names, and `setEventListener` diverts an owned name into the app stash — routing its own
272
- // dispatcher through there would stash it and leave the slot empty.
273
- //
274
- // The engine event names, not the `onX` prop spellings. `buildPressableListeners` speaks the prop
275
- // spelling, so the two are mapped here rather than guessed at either end.
276
- /**
277
- * Engine event name -> the app-facing callback key its dispatcher routes to.
278
- *
279
- * AN ARRAY OF PAIRS RATHER THAN A `Map`, and the reason is measured rather than stylistic.
280
- * `installListeners` below is the only reader, and it runs once per node that carries a press
281
- * machine — which is every `<TextInput>` on the screen, not just every `<Pressable>`. A `for…of` over
282
- * a `Map` builds a fresh two-element array per entry for the destructuring to read back: seven
283
- * allocations per node, for seven fixed pairs that never change. The tuples here already exist, so
284
- * the same loop allocates nothing but its iterator.
285
- *
286
- * Priced on `-O` Hermes by `text-input-attach-ladder.itest.ts`, one pass over the seven pairs:
287
- * `Map` 1.04 us, this 0.39, two parallel arrays 0.29. The parallel arrays are cheapest and give up
288
- * the pairing, which is not worth 0.1 us on a table that a drift would silently unwire.
289
- *
290
- * NOT the same list as `createPressBehavior`'s `ownedListeners`, and they must not be merged: that
291
- * one is every name the machine takes as an INPUT (`pressMove` and `longPress` included), this one
292
- * is only the names it installs a dispatcher for.
293
- */
294
- const EVENT_KEY_PAIRS = [
213
+ // Engine event name -> the app-facing callback key it routes to. NOT the same list as
214
+ // `createPressBehavior`'s `ownedListeners`, which is every name the machine takes as an INPUT;
215
+ // this is only what it answers when Fabric fires
216
+ const DISPATCH_KEYS = new Map([
295
217
  ['press', 'onPress'],
296
218
  ['pressIn', 'onPressIn'],
297
219
  ['pressOut', 'onPressOut'],
@@ -299,30 +221,55 @@ const EVENT_KEY_PAIRS = [
299
221
  ['responderMove', 'onResponderMove'],
300
222
  ['responderTerminationRequest', 'onResponderTerminationRequest'],
301
223
  ['responderGrant', 'onResponderGrant'],
302
- ];
303
- function installListeners(node, state) {
304
- for (const [event, key] of EVENT_KEY_PAIRS) {
305
- setBehaviorListener(node, event, symbioteEvent => dispatch(node, state, key, [symbioteEvent]));
306
- }
307
- }
224
+ ]);
225
+ // The per-node half of what the seven dispatcher closures used to capture. A behavior that
226
+ // registered no refinement leaves its nodes out of here entirely
227
+ const attached = new WeakMap();
228
+ // ONE object for every pressable in the app, where seven closures plus the `Map` holding them used
229
+ // to sit per node. `attach` runs inside `createElement`, so what it allocates every list item pays
230
+ // Exported for a tag that is a pressable PLUS something with names of its own (`./text-input`): it
231
+ // composes a union and delegates here, since a node holds exactly one dispatch
232
+ export const PRESS_DISPATCH = {
233
+ names: new Set(DISPATCH_KEYS.keys()),
234
+ deliver(node, name, event) {
235
+ const key = DISPATCH_KEYS.get(name);
236
+ if (key === undefined)
237
+ return undefined;
238
+ return dispatch(node, attached.get(node) ?? NO_OPTIONS, key, [event]);
239
+ },
240
+ };
308
241
  function attachWith(refine, disabledOf) {
309
- return node => attach(node, { refine, disabledOf });
242
+ // One bag per BEHAVIOR, not per node: `refine` and `disabledOf` are fixed at registration, and a
243
+ // behavior with neither shares the empty one so a plain `<pressable>` stores nothing
244
+ const options = refine === undefined && disabledOf === undefined
245
+ ? NO_OPTIONS
246
+ : { refine, disabledOf };
247
+ return node => attach(node, options);
310
248
  }
311
- /**
312
- * The machine on `node`, reading its props and the app's callbacks off `options.source` when that
313
- * is a different node.
314
- *
315
- * Exported for a behavior whose responder is not its own node — `./touchable-native-feedback`,
316
- * whose tag commits nothing and adopts the app's single child as the responder. Every other caller
317
- * goes through `createPressBehavior`, where source and node are the same.
318
- *
319
- * Re-callable on the same node: a second call replaces the state and the dispatchers, which is what
320
- * a re-arm after `detachPressMachine` needs.
321
- */
322
- export function attachPressMachine(node, options = {}) {
249
+ const NO_OPTIONS = {};
250
+ // The machine on `node`, reading props and callbacks off `options.source` when different.
251
+ // Exported for touchable-native-feedback, whose tag commits nothing and adopts the app's single
252
+ // child as the responder; every other caller goes through createPressBehavior where they're equal.
253
+ // Re-callable on the same node: a second call replaces the state and dispatchers, which is what
254
+ // re-arming after detachPressMachine needs.
255
+ export function attachPressMachine(node, options = NO_OPTIONS) {
323
256
  attach(node, options);
324
257
  }
258
+ // One pointer, plus one map slot for a refined behavior: everything below is read from `dispatch`
259
+ // alone, so a pressable nobody touches never builds a gesture runtime
260
+ // A re-attach drops the old state so the new options take effect on the next gesture
325
261
  function attach(node, options) {
262
+ states.delete(node);
263
+ if (options === NO_OPTIONS)
264
+ attached.delete(node);
265
+ else
266
+ attached.set(node, options);
267
+ setNodeDispatch(node, PRESS_DISPATCH);
268
+ }
269
+ function stateOf(node, options) {
270
+ const existing = states.get(node);
271
+ if (existing !== undefined)
272
+ return existing;
326
273
  const timers = new Set();
327
274
  const runtime = createPressRuntime();
328
275
  const host = {
@@ -330,10 +277,8 @@ function attach(node, options) {
330
277
  // through the style registry's `:active` variant, and never crosses into it.
331
278
  setPressed: pressed => {
332
279
  setNodePressed(node, pressed);
333
- // Dirtying is not publishing. A press arrives from a native event, outside every renderer
334
- // mutation path, so nothing schedules a commit — `native-events.ts` requests none, and no
335
- // adapter does either. `setNodeHidden`'s React twin never hit this because the reconciler is
336
- // already in its commit phase when it calls.
280
+ // Dirtying is not publishing: a press arrives outside every renderer mutation path, so
281
+ // nothing else schedules a commit.
337
282
  requestCommitFor(node);
338
283
  },
339
284
  // `measure` needs a committed Fabric tag, which a node has by the time a human can touch it.
@@ -367,20 +312,21 @@ function attach(node, options) {
367
312
  isBuilt: false,
368
313
  };
369
314
  states.set(node, state);
370
- installListeners(node, state);
315
+ return state;
371
316
  }
372
317
  /** See `attachPressMachine`: the same teardown `createPressBehavior` registers as its `detach`. */
373
318
  export function detachPressMachine(node) {
374
319
  detach(node);
375
320
  }
376
321
  function detach(node) {
322
+ setNodeDispatch(node, undefined);
323
+ attached.delete(node);
377
324
  const state = states.get(node);
325
+ // A pressable nobody touched has no state at all, and the two releases above are all it owes
378
326
  if (state === undefined)
379
327
  return;
380
- // The machine's own teardown, which every wrapper calls from its destroy hook. Not load-bearing
381
- // here and no test can make it so: `host.schedule` puts every timer the machine arms into
382
- // `state.timers` — the 130ms floor's deferred `pressOut` included — so the loop below already
383
- // cancels them. Kept as the contract, and for a timer armed by some future route.
328
+ // The machine's own teardown. Not load-bearing here: host.schedule puts every timer the machine
329
+ // arms into state.timers, so the loop below already cancels them — kept as the contract.
384
330
  disposePressRuntime(state.runtime);
385
331
  for (const id of state.timers)
386
332
  clearTimeout(id);
@@ -388,15 +334,9 @@ function detach(node) {
388
334
  states.delete(node);
389
335
  dlog('pressable behavior detached');
390
336
  }
391
- /**
392
- * The press machine as behavior parts, so a tag that is a pressable PLUS something can compose it
393
- * instead of re-implementing it.
394
- *
395
- * Spread into the caller's own behavior and wrap `attach`/`detach` around these — the touchable
396
- * family needs a per-node Animated value opened before the machine and closed after it. The
397
- * WeakMap holding the machine's own state is keyed by node, so one node may hold exactly one of
398
- * these; a tag composing it therefore must not also register the plain `pressable` behavior.
399
- */
337
+ // The press machine as behavior parts, so a tag that is a pressable PLUS something can compose
338
+ // it instead of re-implementing it — the touchable family wraps attach/detach for its own
339
+ // per-node Animated value. One node may hold exactly one of these; don't also register `pressable`.
400
340
  export function createPressBehavior(refine, disabledOf) {
401
341
  return {
402
342
  attach: attachWith(refine, disabledOf),
@@ -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, propOf, } from '@symbiote-native/engine';
32
+ import { appListenerFor, dispatchViewCommand, dlog, registerHostBehavior, setNodeDispatch, 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';
@@ -75,10 +75,16 @@ function onRefresh(node, event) {
75
75
  listener(event);
76
76
  queueMicrotask(() => evaluateSnapBack(node));
77
77
  }
78
+ // ONE object for every RefreshControl in the app, where a closure per node used to be
79
+ const REFRESH_DISPATCH = {
80
+ names: new Set(['refresh']),
81
+ deliver: (node, _name, event) => onRefresh(node, event),
82
+ };
78
83
  function attach(node) {
79
- setBehaviorListener(node, 'refresh', event => onRefresh(node, event));
84
+ setNodeDispatch(node, REFRESH_DISPATCH);
80
85
  }
81
86
  function detach(node) {
87
+ setNodeDispatch(node, undefined);
82
88
  reported.delete(node);
83
89
  }
84
90
  // Idempotent: an adapter entry may be imported more than once in a bundle, and re-registering the
@@ -1,35 +1,15 @@
1
1
  // ScrollView's behavior on Android, where a RefreshControl is not a child at all.
2
- //
3
2
  // An Android ScrollView holds exactly ONE child, so a sibling refresh control is an `addViewAt`
4
- // crash rather than a layout mistake. RN inverts the tree instead: `AndroidSwipeRefreshLayout`
5
- // WRAPS the scroll view, and the scroll view's style is split across the two boxes — layout on the
6
- // wrapper's frame, visual on the scroller (`ScrollView.js:1856`). `nestedScrollEnabled` goes on the
7
- // inner view so it consumes the gesture before the refresh parent sees it.
8
- //
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.
3
+ // crash. RN inverts the tree instead: `AndroidSwipeRefreshLayout` WRAPS the scroll view
4
+ // (`ScrollView.js:1856`) — layout style on the wrapper, visual style on the scroller.
5
+ // This file is a CLAIM MODE and nothing else: both prop folds are `SymbioteFabricProps.cpp` now —
6
+ // `foldScrollViewProps` takes the visual half when its parent is a refresh control,
7
+ // `foldRefreshWrapperProps` the layout half of the child it wraps.
8
+ // `slotDerived: ['style']` is load-bearing: the wrapper derives from a node that is not itself, so
9
+ // an owner style write must mark IT dirty (`routeProp`'s `node.wrapper` branch) or it freezes at
10
+ // its mount frame while the scroller visibly restyles (`scroll-view-wrap-payload.itest.ts`).
11
+ // Gated on TOPOLOGY, not `#ifdef ANDROID`/`Platform.OS`: iOS claims the refresh control BESIDE
12
+ // the content, so a scroll view is never one's child there and neither branch can fire.
33
13
  import { registerScrollViewBehaviors } from './shared.js';
34
14
  const android = {
35
15
  claimMode: 'wrap',
@@ -1,4 +1,5 @@
1
- import { type ISymbioteNode } from '@symbiote-native/engine';
1
+ import { type ISymbioteEvent, type ISymbioteNode } from '@symbiote-native/engine';
2
2
  export declare function markScrollObserved(owner: ISymbioteNode): void;
3
3
  export declare const RESPONDER_OWNED_LISTENERS: readonly string[];
4
- export declare function installResponderPredicates(owner: ISymbioteNode): void;
4
+ export declare const RESPONDER_DISPATCHED_NAMES: ReadonlySet<string>;
5
+ export declare function deliverResponderEvent(owner: ISymbioteNode, name: string, event: ISymbioteEvent): unknown;
@@ -5,7 +5,7 @@
5
5
  // Ported line-for-line from `ScrollView.js:1263-1546` — the negotiation engine itself and the
6
6
  // `IResponderProps` prop surface already exist and needed no change; this file is ScrollView opting
7
7
  // into the same mechanism Pressable already uses (`../pressable.ts:329-384`).
8
- import { appListenerFor, currentlyFocusedInput, blurTextInput, dlog, isDebug, isRecord, isSymbioteNode, Keyboard, Platform, propOf, setBehaviorListener, } from '@symbiote-native/engine';
8
+ import { appListenerFor, currentlyFocusedInput, blurTextInput, dlog, isDebug, isRecord, isSymbioteNode, Keyboard, Platform, propOf, } from '@symbiote-native/engine';
9
9
  import { descriptorFor } from '../../component-names';
10
10
  import { TEXT_INPUT_MULTILINE_TAG, TEXT_INPUT_TAG } from '../text-input.js';
11
11
  // RN's `IS_ANIMATING_TOUCH_START_THRESHOLD_MS` (ScrollView.js:695) — a momentum end inside this
@@ -208,24 +208,22 @@ export const RESPONDER_OWNED_LISTENERS = [
208
208
  'momentumScrollBegin',
209
209
  'momentumScrollEnd',
210
210
  ];
211
- // Installed once per owner, in `attach` — same beat Pressable wires its own responder pair.
212
- export function installResponderPredicates(owner) {
213
- setBehaviorListener(owner, 'startShouldSetResponderCapture', event => startShouldSetResponderCapture(owner, event));
214
- setBehaviorListener(owner, 'startShouldSetResponder', event => startShouldSetResponder(owner, event));
215
- setBehaviorListener(owner, 'responderTerminationRequest', () => responderTerminationRequest(owner));
216
- setBehaviorListener(owner, 'responderGrant', event => {
217
- handleResponderGrant(owner, event);
218
- });
219
- setBehaviorListener(owner, 'responderRelease', event => {
220
- handleResponderRelease(owner, event);
221
- });
222
- setBehaviorListener(owner, 'momentumScrollBegin', event => {
223
- handleMomentumScrollBegin(owner, event);
224
- });
225
- setBehaviorListener(owner, 'scrollBeginDrag', event => {
226
- handleScrollBeginDrag(owner, event);
227
- });
228
- setBehaviorListener(owner, 'momentumScrollEnd', event => {
229
- handleMomentumScrollEnd(owner, event);
230
- });
211
+ // The eight names an owner answers, as ONE module-level table where eight closures per node used to
212
+ // be. `./shared` composes it with its own `scroll` into the owner's `IEventDispatch`
213
+ const HANDLERS = new Map([
214
+ ['startShouldSetResponderCapture', startShouldSetResponderCapture],
215
+ ['startShouldSetResponder', startShouldSetResponder],
216
+ [
217
+ 'responderTerminationRequest',
218
+ (owner) => responderTerminationRequest(owner),
219
+ ],
220
+ ['responderGrant', handleResponderGrant],
221
+ ['responderRelease', handleResponderRelease],
222
+ ['momentumScrollBegin', handleMomentumScrollBegin],
223
+ ['scrollBeginDrag', handleScrollBeginDrag],
224
+ ['momentumScrollEnd', handleMomentumScrollEnd],
225
+ ]);
226
+ export const RESPONDER_DISPATCHED_NAMES = new Set(HANDLERS.keys());
227
+ export function deliverResponderEvent(owner, name, event) {
228
+ return HANDLERS.get(name)?.(owner, event);
231
229
  }