@symbiote-native/components 1.0.0 → 3.0.0

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 (110) hide show
  1. package/README.md +11 -13
  2. package/build/accessibility-props.d.ts +1 -1
  3. package/build/accessibility-props.js +2 -2
  4. package/build/behaviors/activity-indicator/index.android.d.ts +1 -0
  5. package/build/behaviors/activity-indicator/index.android.js +16 -0
  6. package/build/behaviors/activity-indicator/index.d.ts +3 -0
  7. package/build/behaviors/activity-indicator/index.ios.d.ts +1 -0
  8. package/build/behaviors/activity-indicator/index.ios.js +14 -0
  9. package/build/behaviors/activity-indicator/index.js +5 -0
  10. package/build/behaviors/activity-indicator/shared.d.ts +18 -0
  11. package/build/behaviors/activity-indicator/shared.js +120 -0
  12. package/build/behaviors/button.d.ts +13 -0
  13. package/build/behaviors/button.js +340 -0
  14. package/build/behaviors/image-background.d.ts +3 -0
  15. package/build/behaviors/image-background.js +155 -0
  16. package/build/behaviors/image.d.ts +1 -2
  17. package/build/behaviors/image.js +25 -106
  18. package/build/behaviors/input-accessory-view.d.ts +1 -2
  19. package/build/behaviors/input-accessory-view.js +49 -55
  20. package/build/behaviors/pressable.d.ts +59 -1
  21. package/build/behaviors/pressable.js +142 -96
  22. package/build/behaviors/refresh-control.d.ts +2 -0
  23. package/build/behaviors/refresh-control.js +96 -0
  24. package/build/behaviors/scroll-view/index.android.d.ts +1 -0
  25. package/build/behaviors/scroll-view/index.android.js +40 -0
  26. package/build/behaviors/scroll-view/index.d.ts +3 -0
  27. package/build/behaviors/scroll-view/index.ios.d.ts +1 -0
  28. package/build/behaviors/scroll-view/index.ios.js +10 -0
  29. package/build/behaviors/scroll-view/index.js +8 -0
  30. package/build/behaviors/scroll-view/responder.d.ts +4 -0
  31. package/build/behaviors/scroll-view/responder.js +202 -0
  32. package/build/behaviors/scroll-view/shared.d.ts +9 -0
  33. package/build/behaviors/scroll-view/shared.js +291 -0
  34. package/build/behaviors/scroll-view/sticky.d.ts +18 -0
  35. package/build/behaviors/scroll-view/sticky.js +581 -0
  36. package/build/behaviors/switch.d.ts +1 -1
  37. package/build/behaviors/switch.js +49 -88
  38. package/build/behaviors/text-input.d.ts +2 -2
  39. package/build/behaviors/text-input.js +244 -104
  40. package/build/behaviors/touchable-highlight.d.ts +9 -0
  41. package/build/behaviors/touchable-highlight.js +205 -0
  42. package/build/behaviors/touchable-native-feedback.d.ts +20 -0
  43. package/build/behaviors/touchable-native-feedback.js +254 -0
  44. package/build/behaviors/touchable-opacity.d.ts +12 -0
  45. package/build/behaviors/touchable-opacity.js +239 -0
  46. package/build/behaviors/touchable-without-feedback.d.ts +2 -0
  47. package/build/behaviors/touchable-without-feedback.js +231 -0
  48. package/build/component-names/index.android.js +31 -25
  49. package/build/component-names/index.ios.js +28 -23
  50. package/build/component-names/shared.d.ts +2 -1
  51. package/build/component-names/shared.js +16 -6
  52. package/build/descriptor.js +4 -4
  53. package/build/index.d.ts +26 -26
  54. package/build/index.js +56 -27
  55. package/build/register.d.ts +1 -0
  56. package/build/register.js +55 -0
  57. package/build/resolve-intrinsic.js +3 -9
  58. package/build/scroll-view-commands.d.ts +1 -5
  59. package/build/scroll-view-commands.js +23 -85
  60. package/build/state/flat-list.d.ts +2 -2
  61. package/build/state/flat-list.js +10 -2
  62. package/build/state/pressable.d.ts +6 -1
  63. package/build/state/pressable.js +63 -28
  64. package/build/state/section-list.d.ts +2 -0
  65. package/build/state/section-list.js +14 -7
  66. package/build/state/text-input.d.ts +10 -40
  67. package/build/state/text-input.js +17 -186
  68. package/build/state/touchable.d.ts +1 -0
  69. package/build/state/touchable.js +11 -8
  70. package/build/state/virtualized-list-reducer.d.ts +2 -2
  71. package/build/state/virtualized-list.d.ts +6 -6
  72. package/build/state/virtualized-list.js +71 -37
  73. package/build/text-props.d.ts +0 -8
  74. package/build/text-props.js +14 -25
  75. package/build/view/render-button.d.ts +11 -4
  76. package/build/view/render-button.js +74 -22
  77. package/build/view/render-image/index.d.ts +14 -1
  78. package/build/view/render-image/index.js +22 -147
  79. package/build/view/render-input-accessory-view.d.ts +1 -5
  80. package/build/view/render-input-accessory-view.js +26 -48
  81. package/build/view/render-keyboard-avoiding-view.d.ts +7 -1
  82. package/build/view/render-keyboard-avoiding-view.js +40 -1
  83. package/build/view/render-modal.d.ts +1 -1
  84. package/build/view/render-modal.js +18 -8
  85. package/build/view/render-pressable/index.d.ts +3 -0
  86. package/build/view/render-pressable/index.js +28 -0
  87. package/build/view/render-scroll-view.d.ts +1 -4
  88. package/build/view/render-scroll-view.js +17 -54
  89. package/build/view/render-switch.d.ts +4 -15
  90. package/build/view/render-switch.js +4 -42
  91. package/build/view/render-touchable-highlight.d.ts +1 -0
  92. package/build/view/render-touchable-native-feedback.d.ts +19 -1
  93. package/build/view/render-touchable-native-feedback.js +34 -9
  94. package/host-primitives.cjs +178 -280
  95. package/host-primitives.d.cts +0 -3
  96. package/package.json +8 -21
  97. package/build/fold-host-bag.d.ts +0 -15
  98. package/build/fold-host-bag.js +0 -99
  99. package/build/state-style.d.ts +0 -15
  100. package/build/state-style.js +0 -47
  101. package/build/view/render-activity-indicator.d.ts +0 -25
  102. package/build/view/render-activity-indicator.js +0 -88
  103. package/build/view/render-image-background.d.ts +0 -9
  104. package/build/view/render-image-background.js +0 -48
  105. package/build/view/render-text-input.d.ts +0 -11
  106. package/build/view/render-text-input.js +0 -39
  107. package/lowering-fixtures.cjs +0 -259
  108. package/lowering-fixtures.d.cts +0 -17
  109. package/specialize-state-style.cjs +0 -219
  110. package/specialize-state-style.d.cts +0 -15
@@ -14,10 +14,10 @@
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, rippleProps, } from '../state/pressable.js';
19
- import { buildPressableListeners, resolveDisabledAccessibilityState, } from '../view/render-pressable/index.js';
20
- export const PRESSABLE_TAG = 'symbiote-pressable';
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
+ export const PRESSABLE_TAG = 'pressable';
21
21
  const states = new WeakMap();
22
22
  function isRecord(value) {
23
23
  return typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -25,6 +25,12 @@ function isRecord(value) {
25
25
  function numberOr(value, fallback) {
26
26
  return typeof value === 'number' ? value : fallback;
27
27
  }
28
+ // The bag arrives as `unknown` off `node.props`; every RN default below is written against
29
+ // `boolean | undefined`. Exported to the sibling behaviors folding the same bag, and deliberately
30
+ // NOT to the shared barrel — same reasoning as `asAccessibilityState`.
31
+ export function booleanOr(value) {
32
+ return typeof value === 'boolean' ? value : undefined;
33
+ }
28
34
  // A scalar offset or the per-edge object; anything else reads as "no offset", which the machine
29
35
  // turns into RN's defaults. Same narrowing the component path does — kept here rather than shared
30
36
  // because the component's version narrows Vue attrs, and this one narrows engine props.
@@ -50,27 +56,11 @@ function asRectOffset(value) {
50
56
  function isPressHandler(value) {
51
57
  return typeof value === 'function';
52
58
  }
53
- // The props the MACHINE consumes and the host must never see. The wrapper drops them by
54
- // destructuring — they go into `createPressHandlers` / `buildPressableListeners` and are simply
55
- // absent from the object it spreads onto its View. A lowered element has no destructure, so every
56
- // one of them rode into the payload as a key no ViewConfig declares.
57
- const MACHINE_ONLY_KEYS = [
58
- // Consumed below and replaced by the resolved `nativeBackgroundAndroid` /
59
- // `nativeForegroundAndroid`; the raw config is not a native prop.
60
- 'android_ripple',
61
- 'disabled',
62
- 'cancelable',
63
- 'delayLongPress',
64
- 'unstable_pressDelay',
65
- 'pressRetentionOffset',
66
- 'delayHoverIn',
67
- 'delayHoverOut',
68
- ];
69
- // Narrowed field by field rather than cast: the bag arrives as `unknown` off `node.props`. A local
70
- // twin of the guard each adapter keeps for its own attrs (Vue's `asAccessibilityState`) — not
71
- // hoisted to the shared barrel, because every adapter re-exports that barrel wholesale and a
72
- // narrowing helper is not API anyone should be able to import.
73
- function asAccessibilityState(value) {
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.
63
+ export function asAccessibilityState(value) {
74
64
  if (!isRecord(value))
75
65
  return undefined;
76
66
  const state = {};
@@ -86,99 +76,118 @@ function asAccessibilityState(value) {
86
76
  state.expanded = value.expanded;
87
77
  return state;
88
78
  }
89
- // Narrowed field by field, same reason as the accessibility guard above: the config arrives as
90
- // `unknown` off `node.props`.
91
- function asRippleConfig(value) {
92
- if (!isRecord(value))
93
- return undefined;
94
- const config = {};
95
- if (typeof value.color === 'string')
96
- config.color = value.color;
97
- if (typeof value.borderless === 'boolean')
98
- config.borderless = value.borderless;
99
- if (typeof value.radius === 'number')
100
- config.radius = value.radius;
101
- if (typeof value.foreground === 'boolean')
102
- config.foreground = value.foreground;
103
- return config;
104
- }
105
- // `disabled` reaches a screen reader ONLY as `accessibilityState.disabled` — it is not a native
106
- // View prop, so the wrapper folds it (`resolveDisabledAccessibilityState`, called by all five) and
107
- // forwards the composite. Lowering dropped that fold: press suppression still worked, because the
108
- // machine reads `node.props.disabled` directly, so the button behaved correctly and announced
109
- // 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>`.
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.
110
91
  //
111
- // Found by the wrapper-vs-behavior import audit (`.claude/rules/adapter-parity-audit.md`).
112
- function foldPayload(props) {
113
- const resolved = resolveDisabledAccessibilityState(asAccessibilityState(props.accessibilityState), typeof props.disabled === 'boolean' ? props.disabled : undefined);
114
- // The Android ripple. Our WRAPPER paints it through a dedicated inner View, mirroring
115
- // TouchableNativeFeedback — and that reading is what made this look unfixable for a lowered
116
- // element, which has no child to put it on. RN's own `Pressable` does NOT do that: it spreads
117
- // `useAndroidRippleForView`'s `viewProps` onto its own View (`Pressable.js:251`), so the ripple
118
- // background is an ordinary prop of the responder itself and a single node carries it fine.
119
- //
120
- // `rippleProps` returns undefined off Android, so this whole branch is inert on iOS.
121
- //
122
- // STILL MISSING ON BOTH PATHS, and lowering did not cause it: RN also dispatches
123
- // `Commands.hotspotUpdate(x, y)` on pressIn/pressMove and `Commands.setPressed` on
124
- // pressIn/pressOut, which is what makes the ripple originate at the touch point. Neither our
125
- // wrapper nor this behavior sends them — grep for `hotspotUpdate` returns nothing in the tree.
126
- const rippleConfig = asRippleConfig(props.android_ripple);
127
- const ripple = rippleConfig !== undefined ? rippleProps(rippleConfig) : undefined;
128
- const out = { ...props };
129
- for (const key of MACHINE_ONLY_KEYS)
130
- delete out[key];
131
- if (ripple !== undefined)
132
- Object.assign(out, ripple);
133
- // Written only when the fold produced something: an unconditional assignment would put an
134
- // `accessibilityState: undefined` key on every lowered Pressable in the tree, and `fabricProps`
135
- // skipping undefined is a coincidence to lean on, not a contract to rely on here.
136
- if (resolved !== undefined)
137
- out.accessibilityState = resolved;
138
- return out;
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.
101
+ export function accessibleUnlessOptedOut(props) {
102
+ return props.accessible !== false;
139
103
  }
140
- // 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`
141
105
  // diverts the app's `onPress` away from `node.listeners` (where it would evict the behavior's own
142
106
  // dispatcher) and into the stash — which makes the stash the only place it exists. Reading
143
- // `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:
144
108
  // the behavior runs, the machine runs, and it calls nobody.
145
109
  function callbackAt(node, event) {
146
110
  const value = appListenerFor(node, event);
147
111
  return isPressHandler(value) ? value : undefined;
148
112
  }
149
- // 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
150
114
  // cosmetic: `delayLongPress` and `hitSlop` are ordinary props that `fabricProps` drops as unknown
151
- // 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.
152
116
  function configFor(node) {
117
+ const unstablePressDelay = numberOr(propOf(node, 'unstable_pressDelay'), 0);
153
118
  return {
154
119
  onPress: callbackAt(node, 'press'),
155
120
  onPressIn: callbackAt(node, 'pressIn'),
156
121
  onPressOut: callbackAt(node, 'pressOut'),
157
122
  onPressMove: callbackAt(node, 'pressMove'),
158
123
  onLongPress: callbackAt(node, 'longPress'),
159
- delayLongPress: numberOr(node.props.delayLongPress, DEFAULT_DELAY_LONG_PRESS_MS),
160
- unstable_pressDelay: numberOr(node.props.unstable_pressDelay, 0),
161
- hitSlop: asRectOffset(node.props.hitSlop),
162
- pressRetentionOffset: asRectOffset(node.props.pressRetentionOffset),
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,
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.
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')),
163
149
  };
164
150
  }
165
151
  // Rebuilding at GESTURE START is the whole reason for the dispatcher indirection, and skipping it
166
152
  // is a bug that looks like working code. `attach` runs inside `createElement`, before a single
167
- // 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
168
154
  // capture no `onPress` at all and every press would silently do nothing. `createPressHandlers`
169
155
  // destructures its config eagerly, so it cannot be handed a live view either; it has to be re-made
170
156
  // once the props exist. A gesture is one interaction, so a handful of closures per press is
171
157
  // invisible — unlike doing it per prop write, which is the cost this whole tier exists to remove.
172
158
  function rebuild(node, state) {
173
- const handlers = createPressHandlers(configFor(node), state.runtime, state.host);
159
+ // `state.source` for everything READ, `node` for the refinement, which acts on the responder
160
+ // (dispatching a view command needs the committed node, not the one holding the props).
161
+ const source = state.source;
162
+ const base = configFor(source);
163
+ const handlers = createPressHandlers(state.refine === undefined ? base : state.refine(node, base), state.runtime, state.host);
174
164
  state.isBuilt = true;
165
+ // Re-read every gesture, so a tag whose resolver looks past `disabled` — `./button`, at
166
+ // `aria-disabled` — re-enables on the next touch instead of latching at its first answer.
167
+ const sourceProps = propsOf(source);
168
+ const disabled = state.disabledOf === undefined
169
+ ? sourceProps.disabled
170
+ : state.disabledOf(sourceProps);
175
171
  state.listeners = buildPressableListeners(handlers, {
176
- disabled: node.props.disabled === true ? true : undefined,
177
- cancelable: typeof node.props.cancelable === 'boolean'
178
- ? node.props.cancelable
179
- : undefined,
172
+ disabled: disabled === true ? true : undefined,
173
+ cancelable: resolveCancelable(source),
174
+ blockNativeResponder: propOf(source, 'blockNativeResponder') === true,
180
175
  });
181
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
+ }
182
191
  // WHICHEVER EVENT OPENS THE GESTURE REBUILDS, and pinning that to one name was a real bug.
183
192
  // `onStartShouldSetResponder` looks like the opener and is not: `core/engine/src/events/index.ts`
184
193
  // bubbles PRESS_IN and only THEN calls `negotiateResponder`, so `pressIn` arrives FIRST on every
@@ -222,13 +231,31 @@ const KEY_BY_EVENT = new Map([
222
231
  ['startShouldSetResponder', 'onStartShouldSetResponder'],
223
232
  ['responderMove', 'onResponderMove'],
224
233
  ['responderTerminationRequest', 'onResponderTerminationRequest'],
234
+ ['responderGrant', 'onResponderGrant'],
225
235
  ]);
226
236
  function installListeners(node, state) {
227
237
  for (const [event, key] of KEY_BY_EVENT) {
228
238
  setBehaviorListener(node, event, symbioteEvent => dispatch(node, state, key, [symbioteEvent]));
229
239
  }
230
240
  }
231
- function attach(node) {
241
+ function attachWith(refine, disabledOf) {
242
+ return node => attach(node, { refine, disabledOf });
243
+ }
244
+ /**
245
+ * The machine on `node`, reading its props and the app's callbacks off `options.source` when that
246
+ * is a different node.
247
+ *
248
+ * Exported for a behavior whose responder is not its own node — `./touchable-native-feedback`,
249
+ * whose tag commits nothing and adopts the app's single child as the responder. Every other caller
250
+ * goes through `createPressBehavior`, where source and node are the same.
251
+ *
252
+ * Re-callable on the same node: a second call replaces the state and the dispatchers, which is what
253
+ * a re-arm after `detachPressMachine` needs.
254
+ */
255
+ export function attachPressMachine(node, options = {}) {
256
+ attach(node, options);
257
+ }
258
+ function attach(node, options) {
232
259
  const timers = new Set();
233
260
  const runtime = createPressRuntime();
234
261
  const host = {
@@ -264,6 +291,9 @@ function attach(node) {
264
291
  const state = {
265
292
  runtime,
266
293
  host,
294
+ refine: options.refine,
295
+ disabledOf: options.disabledOf,
296
+ source: options.source ?? node,
267
297
  timers,
268
298
  listeners: {},
269
299
  isBuilt: false,
@@ -271,6 +301,10 @@ function attach(node) {
271
301
  states.set(node, state);
272
302
  installListeners(node, state);
273
303
  }
304
+ /** See `attachPressMachine`: the same teardown `createPressBehavior` registers as its `detach`. */
305
+ export function detachPressMachine(node) {
306
+ detach(node);
307
+ }
274
308
  function detach(node) {
275
309
  const state = states.get(node);
276
310
  if (state === undefined)
@@ -286,13 +320,19 @@ function detach(node) {
286
320
  states.delete(node);
287
321
  dlog('pressable behavior detached');
288
322
  }
289
- // Idempotent: an adapter entry may be imported more than once in a bundle, and re-registering the
290
- // same tag with an equivalent behavior must not double-install anything.
291
- export function registerPressableBehavior() {
292
- registerHostBehavior(PRESSABLE_TAG, {
293
- attach,
323
+ /**
324
+ * The press machine as behavior parts, so a tag that is a pressable PLUS something can compose it
325
+ * instead of re-implementing it.
326
+ *
327
+ * Spread into the caller's own behavior and wrap `attach`/`detach` around these — the touchable
328
+ * family needs a per-node Animated value opened before the machine and closed after it. The
329
+ * WeakMap holding the machine's own state is keyed by node, so one node may hold exactly one of
330
+ * these; a tag composing it therefore must not also register the plain `pressable` behavior.
331
+ */
332
+ export function createPressBehavior(refine, disabledOf) {
333
+ return {
334
+ attach: attachWith(refine, disabledOf),
294
335
  detach,
295
- foldPayload,
296
336
  // Every name the machine needs as an INPUT. The responder pair is not optional — it is how a
297
337
  // gesture starts at all, and `RESPONDER_EVENTS` makes those listeners on any node regardless
298
338
  // of ViewConfig, so they collide exactly like `press` does.
@@ -305,6 +345,12 @@ export function registerPressableBehavior() {
305
345
  'startShouldSetResponder',
306
346
  'responderMove',
307
347
  'responderTerminationRequest',
348
+ 'responderGrant',
308
349
  ],
309
- });
350
+ };
351
+ }
352
+ // Idempotent: an adapter entry may be imported more than once in a bundle, and re-registering the
353
+ // same tag with an equivalent behavior must not double-install anything.
354
+ export function registerPressableBehavior() {
355
+ registerHostBehavior(PRESSABLE_TAG, createPressBehavior());
310
356
  }
@@ -0,0 +1,2 @@
1
+ export declare const REFRESH_CONTROL_TAG = "refresh-control";
2
+ export declare function registerRefreshControlBehavior(): void;
@@ -0,0 +1,96 @@
1
+ // RefreshControl's machine, on the engine node instead of inside a framework component.
2
+ //
3
+ // WHAT THE FIVE WRAPPERS ACTUALLY DO, counted before writing this — the audit rule's instruction to
4
+ // grep the fold's OUTPUT rather than trust one wrapper. Four of the five (react, vue, solid,
5
+ // svelte) fold exactly `resolveAccessibilityProps` and forward, and that fold ALREADY runs in the
6
+ // engine at `fabricProps` on every path (the aria fold `SafeAreaView`'s spec entry cites). So there
7
+ // is nothing left for a `foldPayload` here to do, and this behavior deliberately declares none.
8
+ //
9
+ // The fifth is Angular, and it is the whole reason this file exists: it alone reproduces RN's
10
+ // CONTROLLED handshake (`RefreshControl.js:145-166`) — mirror what native last reported, and when
11
+ // the app's `refreshing` disagrees, command native back with `setNativeRefreshing`. React, Vue,
12
+ // Solid and Svelte have never had it, so a pull whose handler leaves `refreshing` false spins
13
+ // forever on four of five adapters. Moving it here closes that as a P0 parity gap rather than
14
+ // porting it four more times.
15
+ //
16
+ // ONE COMMAND NAME ON BOTH PLATFORMS — no `Platform.OS` branch, unlike Switch's snap-back
17
+ // (`setValue` / `setNativeValue`). RN sends `setNativeRefreshing` through both
18
+ // `PullToRefreshCommands` and `AndroidSwipeRefreshLayoutCommands` (`RefreshControl.js:152,157`).
19
+ //
20
+ // PLACEMENT IS NOT THIS FILE'S PROBLEM, though the tier audit once filed the primitive as
21
+ // impossible over it. iOS puts the control BESIDE the scroll view's content and Android makes it
22
+ // the scroll view's PARENT, and that decision belongs to the ScrollView, which now states it as
23
+ // data — `claimedChildren: { [REFRESH_CONTROL]: platform.claimMode }` in
24
+ // `behaviors/scroll-view/shared.ts`, honoured by the engine's `appendChild`. A claim is keyed on
25
+ // the child's FABRIC name and needs nothing from the child's own behavior, so the two are
26
+ // independent; `refresh-control-placement.test.ts` pins that both ways round.
27
+ //
28
+ // WHY THE DIVERGENCE CHECK IS DEFERRED A MICROTASK, and why `afterCommit` alone is not enough:
29
+ // `behaviors/switch.ts`'s module header, verbatim. Same shape, same two triggers, same reasons —
30
+ // an ACCEPTING app's state reaches `node.props` only after its own reconciliation, and a REJECTING
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';
33
+ export const REFRESH_CONTROL_TAG = 'refresh-control';
34
+ // RN's own name for the command, sent to whichever of the two native views the platform resolved.
35
+ const SET_NATIVE_REFRESHING = 'setNativeRefreshing';
36
+ // What native LAST reported, absent until it has reported at all. Absent is not `false`: native is
37
+ // optimistic — it spins on the gesture before JS approves — so only a report can make the mirror
38
+ // authoritative, and an app that drives `refreshing` on its own initiative must never be corrected
39
+ // against a value native never claimed.
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
+ //
54
+ // Shared by both triggers — see the module header for why there are two.
55
+ function evaluateSnapBack(node) {
56
+ const lastNativeReport = reported.get(node);
57
+ if (lastNativeReport === undefined)
58
+ return; // no report yet, nothing to disagree with
59
+ const refreshing = propOf(node, 'refreshing') === true;
60
+ if (lastNativeReport === refreshing) {
61
+ dlog(`RefreshControl behavior snap-back no-op refreshing=${refreshing}`);
62
+ return;
63
+ }
64
+ dlog(`RefreshControl behavior ${SET_NATIVE_REFRESHING} reported=${lastNativeReport} refreshing=${refreshing}`);
65
+ dispatchViewCommand(node, SET_NATIVE_REFRESHING, [refreshing]);
66
+ reported.set(node, refreshing);
67
+ }
68
+ function onRefresh(node, event) {
69
+ // Native has already started spinning by the time this arrives (RefreshControl.js:180), so the
70
+ // mirror moves BEFORE the app's handler runs — a handler that flips `refreshing` to true then
71
+ // agrees with it, and one that does nothing is what the deferred check corrects.
72
+ reported.set(node, true);
73
+ const listener = appListenerFor(node, 'refresh');
74
+ if (typeof listener === 'function')
75
+ listener(event);
76
+ queueMicrotask(() => evaluateSnapBack(node));
77
+ }
78
+ function attach(node) {
79
+ setBehaviorListener(node, 'refresh', event => onRefresh(node, event));
80
+ }
81
+ function detach(node) {
82
+ reported.delete(node);
83
+ }
84
+ // Idempotent: an adapter entry may be imported more than once in a bundle, and re-registering the
85
+ // same tag with an equivalent behavior must not double-install anything.
86
+ export function registerRefreshControlBehavior() {
87
+ registerHostBehavior(REFRESH_CONTROL_TAG, {
88
+ attach,
89
+ // Closes the case a microtask scheduled from `onRefresh` cannot: the app moves `refreshing` on
90
+ // its own while a past report is still unresolved. Costs nothing extra — it fires only on a
91
+ // commit that already changed something.
92
+ afterCommit: evaluateSnapBack,
93
+ detach,
94
+ ownedListeners: ['refresh'],
95
+ });
96
+ }
@@ -0,0 +1 @@
1
+ export declare function registerScrollViewBehavior(): void;
@@ -0,0 +1,40 @@
1
+ // ScrollView's behavior on Android, where a RefreshControl is not a child at all.
2
+ //
3
+ // 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.
33
+ import { registerScrollViewBehaviors } from './shared.js';
34
+ const android = {
35
+ claimMode: 'wrap',
36
+ slotDerived: ['style'],
37
+ };
38
+ export function registerScrollViewBehavior() {
39
+ registerScrollViewBehaviors(android);
40
+ }
@@ -0,0 +1,3 @@
1
+ export { registerScrollViewBehavior } from './index.ios';
2
+ export { HORIZONTAL_SCROLL_VIEW_TAG, REFRESH_CONTROL, SCROLL_VIEW_TAG, } from './shared';
3
+ export { STICKY_HEADER_TAG } from './sticky';
@@ -0,0 +1 @@
1
+ export declare function registerScrollViewBehavior(): void;
@@ -0,0 +1,10 @@
1
+ // ScrollView's behavior on iOS: a RefreshControl is a SIBLING of the content view, rendered before
2
+ // it (`ScrollView.js:1844`). That is the whole platform half — a claim in `beside` mode, and the
3
+ // engine's placement rule already puts a claimed child before the slot.
4
+ //
5
+ // This file is also the base the folder's `index.ts` re-exports for headless, matching
6
+ // `render-scroll-view`'s own choice to make iOS the default for anything platform-branched.
7
+ import { registerScrollViewBehaviors } from './shared.js';
8
+ export function registerScrollViewBehavior() {
9
+ registerScrollViewBehaviors({ claimMode: 'beside' });
10
+ }
@@ -0,0 +1,8 @@
1
+ // The base of the folder-as-module group: Metro picks `index.ios` / `index.android` per platform,
2
+ // and everything else (tsx, vitest, headless) lands here. iOS is the default for the same reason
3
+ // `render-scroll-view`'s own `Platform.select` defaults to it.
4
+ export { registerScrollViewBehavior } from './index.ios.js';
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;