@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,28 +1,13 @@
1
- // TextInput's machine, on the engine node instead of inside a framework component — the tier-2
2
- // half of `.claude/rules/host-primitive-tier.md` for the second primitive to get one.
3
- //
4
- // WHAT A `TextInput` COMPONENT ACTUALLY DOES, and why none of it needs a framework. It holds three
5
- // mirrors of native state (the acknowledged event count, the last text native reported, whether the
6
- // input is focused), it commands text back down when the app's `value` diverges from that mirror,
7
- // it fires `focus` once at mount when `autoFocus` is set, it composes a small press machine so a
8
- // tap focuses the input (`TextInput.js`'s own `usePressability` — FOUND MISSING 2026-09-20, since
9
- // nothing here wired ANY press listener at all), and it exposes five imperative methods. The
10
- // TEMPLATE reads none of it — which is the whole tier-2 test. Every framework was paying an
11
- // instance for a machine that only ever needed a per-node home.
12
- //
13
- // WHY IT NEEDED A NEW ENGINE HOOK AND `Pressable` DID NOT. A press machine is driven entirely by
14
- // events, which arrive long after commit. The controlled handshake is driven by a PROP: `value`
15
- // changing is what must re-run the divergence check, and in a component the render is what does
16
- // that. A tag has no render, so `IHostBehavior.afterCommit` is the equivalent beat —
17
- // see that interface for why it is not a hook on `setProp`.
18
- //
19
- // THE ORDER OF THE TWO COMMIT HOOKS IS LOAD-BEARING HERE, which is why the engine pins it with a
20
- // test: `attachAfterCommit` seeds `lastNativeText` from the mount-time props, and `afterCommit`
21
- // compares against that seed. Reversed, the very first beat would see an empty mirror, decide the
22
- // app's value had diverged, and command a redundant `setTextAndSelection` down to native on every
23
- // input in the tree.
24
- import { appListenerFor, blurTextInput, dispatchViewCommand, dlog, focusTextInput, Platform, propOf, propsOf, registerHostBehavior, requestCommitFor, setBehaviorListener, setInputBlurred, setInputFocused, setProp, } from '@symbiote-native/engine';
25
- import { attachPressMachine, detachPressMachine, } from './pressable.js';
1
+ // TextInput's machine, on the engine node instead of inside a framework component. It holds three
2
+ // native-state mirrors (event count, last native text, focus), commands text back down on
3
+ // divergence, fires focus once at mount for autoFocus, composes a press machine for tap-to-focus.
4
+ // Needed a new engine hook where Pressable did not: the controlled handshake is driven by a PROP
5
+ // (`value`), and a tag has no render to re-run the divergence check, so afterCommit is the beat.
6
+ // The order of the two commit hooks is load-bearing, pinned by a test: attachAfterCommit seeds
7
+ // lastNativeText from mount-time props, afterCommit compares against that seed. Reversed, the
8
+ // first beat would see an empty mirror and command a redundant write on every input.
9
+ import { appListenerFor, blurTextInput, dispatchViewCommand, dlog, focusTextInput, Platform, propOf, propsOf, registerHostBehavior, requestCommitFor, setInputBlurred, setInputFocused, setNodeDispatch, setProp, } from '@symbiote-native/engine';
10
+ import { attachPressMachine, detachPressMachine, PRESS_DISPATCH, } from './pressable.js';
26
11
  import { eventCountFromChange, foldText, INITIAL_EVENT_COUNT, SELECTION_NONE, shouldCommandText, textFromChange, } from '../state/text-input.js';
27
12
  // Both spellings, because `multiline` picks between two Fabric views and the TAG is what decides.
28
13
  // The behavior is registered for both so it does not care which one the app wrote.
@@ -55,20 +40,11 @@ function callAppListener(node, name, event) {
55
40
  if (typeof listener === 'function')
56
41
  listener(event);
57
42
  }
58
- // `onValueChange(event)` is NOT a Fabric event — it is a fold the component wrapper used to do
59
- // over the raw `change` payload, so it lives on the node as a plain prop key and
60
- // `fabricProps` drops it on the way to native. A tag has no wrapper to run that fold, so before
61
- // this the callback was simply never called: the field echoed keystrokes natively (native owns its
62
- // own text) while every value the app derived from it stayed frozen. Device-found 2026-08-31 in
63
- // examples/solid's canary — the greeting never left "Hello, stranger".
64
- //
65
- // Same class as `value -> text` (`core/engine/src/fabric-props.ts`) and the same repair: below the
66
- // fork, where all five adapters inherit it.
67
- //
68
- // The listener takes ONE argument, `text` carried on the event itself (`ITextInputChangeEvent`),
69
- // not `(text, event)` — Svelte's compiler forces every individual `on*` attribute through a native
70
- // listener wrapper that calls with exactly one argument, always a real object, so a second
71
- // argument is silently dropped and a bare string as the sole argument crashes.
43
+ // `onValueChange(event)` is NOT a Fabric event — it lives on the node as a plain prop key, and
44
+ // fabricProps drops it on the way to native.
45
+ // The listener takes ONE argument, `text` carried on the event itself, not `(text, event)` —
46
+ // Svelte's compiler forces every `on*` attribute through a wrapper that calls with exactly one
47
+ // argument, always a real object, so a bare string as the sole argument crashes.
72
48
  function callValueChange(node, text, event) {
73
49
  const listener = propOf(node, 'onValueChange');
74
50
  if (typeof listener !== 'function')
@@ -76,67 +52,28 @@ function callValueChange(node, text, event) {
76
52
  const changeEvent = Object.assign(event, { text });
77
53
  listener(changeEvent);
78
54
  }
79
- // The alias list and its two narrowing helpers went with the fold. They existed only to feed
80
- // `resolveTextInputProps`, and that resolution is `foldTextInputAliases` in
81
- // `SymbioteFabricProps.cpp` now — keeping a copy of the names here would be a second statement of
82
- // the same rule, which is the thing the move was for.
83
- // `multiline` picks between TWO Fabric views, so the TAG decides it and no later prop write moves a
84
- // node between them. The wrapper that used to stand here CONSUMED the prop to pick its intrinsic;
85
- // an author writing the tag can spell the two apart, which leaves two silent, device-only
86
- // divergences, measured on the committed payload:
87
- //
88
- // <text-input-multiline value="a" /> RCTMultilineTextInputView, folded as SINGLE-line:
89
- // submitBehavior 'blurAndSubmit', so Return blurs instead
90
- // of inserting a newline
91
- // <text-input multiline value="b" /> RCTSinglelineTextInputView carrying the multiline fold
92
- //
93
- // So the tag is the authority here, and a prop that contradicts it throws rather than being
94
- // quietly overridden — an ignored `multiline` is a wrong native view with nothing to read.
95
- // Found on Solid, fixed here because all five adapters produce the same two divergences: a
96
- // decision the wrapper used to make by CONSUMING a prop has no owner once the author writes the
97
- // tag directly.
98
- // The COMPLAINT cannot live here, only the correction. `foldPayload` runs inside the commit, so a
99
- // throw from it surfaces as an uncaught exception a tick after the author's write with no frame
100
- // naming the call site — measured: a test awaiting the mount sees `nothing committed` instead of
101
- // the error. Refusing a contradicting prop therefore stays in each adapter's own prop-write path,
102
- // where the author's stack still exists (Solid's `renderer.ts` is the reference); this file only
103
- // guarantees that whatever the props say, the payload matches the TAG.
104
- // THE FOLD IS GONE — the rule lives in the engine now, `foldTextInputAliases` in
105
- // `core/engine/cpp/SymbioteFabricProps.cpp`, beside the tree it writes into.
106
- //
107
- // It is UA behavior in the browser sense: mapping the web-facing spelling (`inputMode`,
108
- // `enterKeyHint`, `readOnly`, the W3C `autoComplete` token) onto React Native's own is a property of
109
- // the PLATFORM, not of any app, framework or component instance. Blink resolves `<input>`'s
110
- // attributes in the engine and every framework on top pays nothing for it; this is the same move.
111
- //
112
- // And it had a price. A `payloadFold` is a JS closure the C++ walk calls per node per commit, which
113
- // means converting the whole props bag to a `jsi::Value` and the result back again — ~17 us apiece,
114
- // and the entire gap between React's walk (24-27 ms, no folds) and every other adapter's (41-44 ms,
115
- // `foldsFound=1000`) on a byte-identical benchmark tree.
116
- //
117
- // There is deliberately NO TypeScript twin. `core/engine/cpp/tests/js/text-input-payload.itest.ts`
118
- // is the contract, and it reads the payload the commit actually sent rather than a second copy of
119
- // the rule.
120
- //
121
- // What stays here is the MACHINE: the controlled-value handshake, the event-count acknowledgement,
122
- // autofocus. Those run at gesture and lifecycle rate and call back into app code — which is exactly
123
- // what a browser keeps above the engine too.
55
+ // `multiline` picks between TWO Fabric views, so the TAG decides it and no later prop write moves
56
+ // a node between them. An author writing the tag can still spell a contradicting `multiline`
57
+ // prop, which silently commits the wrong view with nothing to read.
58
+ // So the tag is the authority: a contradicting prop throws rather than being quietly overridden.
59
+ // The throw stays in each adapter's own prop-write path, not here, since a throw from foldPayload
60
+ // inside the commit surfaces a tick later with no frame naming the call site.
61
+ // The alias/fold rule lives in the engine now (foldTextInputAliases in SymbioteFabricProps.cpp),
62
+ // UA behavior mapping the web-facing spelling onto RN's own — a property of the platform, paid
63
+ // once, not per app or framework. No TypeScript twin: the itest reads the payload directly.
64
+ // What stays here is the MACHINE: the controlled-value handshake, event-count acknowledgement,
65
+ // autofocus — running at gesture/lifecycle rate and calling back into app code.
124
66
  function onChange(node, event) {
125
67
  const state = stateOf(node);
126
68
  if (state === undefined)
127
69
  return;
128
- // TextInput.js:502-518's `_onChange` — `onChange` first, THEN `onChangeText`, UNCONDITIONALLY on
129
- // every native change, and only THEN the mirrors ("This must happen last", vendor's own comment
130
- // on the count). Ours used to run backwards: the mirror first, then `onChangeText`, with the real
131
- // `onChange` called dead last — an app with side effects observable across `onChange` and
132
- // `onChangeText` saw them in the opposite order from a real device.
70
+ // Mirrors vendor's own ordering: `onChange` first, then `onChangeText`, unconditionally on
71
+ // every native change, and only THEN the mirrors update.
133
72
  callAppListener(node, 'change', event);
134
73
  const text = textFromChange(event);
135
74
  if (text !== undefined) {
136
- // TextInput.js:506 — fired right alongside `onChange`, off the same event, `text` riding on it
137
- // as a field for the same reason `onValueChange` does. Read directly rather than owned/stashed:
138
- // like `onValueChange`, it is not a Fabric event name, so nothing routes it and nothing native
139
- // could overwrite it.
75
+ // Read directly, not owned/stashed: like onValueChange, onChangeText isn't a Fabric event
76
+ // name, so nothing routes it and nothing native could overwrite it.
140
77
  const onChangeText = propOf(node, 'onChangeText');
141
78
  if (typeof onChangeText === 'function') {
142
79
  onChangeText(Object.assign(event, { text }));
@@ -175,11 +112,9 @@ function onBlur(node, event) {
175
112
  setInputBlurred(node);
176
113
  callAppListener(node, 'blur', event);
177
114
  }
178
- // The out-of-commit half of the same check `afterCommit` runs on every commit: given the mirror
179
- // already updated from a real native report, send the authored `selection` back down if it still
180
- // disagrees. Kept separate from `afterCommit`'s own combined text+selection dispatch rather than
181
- // shared with it — this path has no `freshlySeeded`/text-divergence concept, and folding the two
182
- // would risk a double command on a commit where both diverge at once.
115
+ // The out-of-commit half of the check afterCommit runs on every commit: given the mirror already
116
+ // updated from a real native report, send the authored `selection` back down if it still
117
+ // disagrees. Kept separate from afterCommit's own dispatch to avoid a double command.
183
118
  function correctSelectionIfNeeded(node, state) {
184
119
  const props = propsOf(node);
185
120
  const { start, end } = selectionOf(props.selection);
@@ -199,12 +134,8 @@ function correctSelectionIfNeeded(node, state) {
199
134
  ]);
200
135
  state.lastNativeSelection = { start, end };
201
136
  }
202
- // TextInput.js:522-533 — `_onSelectionChange` forwards to the app FIRST, then folds the REAL native
203
- // position into `lastNativeSelection`, whichever way the caret moved (a controlled write we sent
204
- // ourselves, or the user dragging it). That update alone is what schedules React's next render,
205
- // which is what re-runs the divergence check with the freshly-updated mirror. We have no render to
206
- // ride, so `correctSelectionIfNeeded` is called directly, right after the mirror moves — same shape
207
- // as `refresh-control.ts`'s own re-check after a native report.
137
+ // Forwards to the app FIRST, then folds the real native position into lastNativeSelection. With
138
+ // no render to ride the mirror update, correctSelectionIfNeeded is called directly right after.
208
139
  function onSelectionChange(node, event) {
209
140
  callAppListener(node, 'selectionChange', event);
210
141
  const state = stateOf(node);
@@ -216,12 +147,9 @@ function onSelectionChange(node, event) {
216
147
  state.lastNativeSelection = native;
217
148
  correctSelectionIfNeeded(node, state);
218
149
  }
219
- // `TextInput.js`'s own `usePressability(config)` — the same Pressability class every Touchable
220
- // uses, wired for exactly one reason: `onPress` calls `inputRef.current.focus()` when
221
- // `editable !== false`, so a tap landing inside an authored `hitSlop` but outside the native
222
- // view's own focus zone still focuses the input. `onPressIn`/`onPressOut` need NO wrapping here —
223
- // `configFor`'s defaults already forward them raw, which is exactly what upstream does
224
- // (`onPressIn, onPressOut` destructured straight into the config with no wrapper function).
150
+ // The same Pressability class every Touchable uses, wired so a tap inside an authored `hitSlop`
151
+ // but outside the native view's own focus zone still focuses the input. onPressIn/onPressOut need
152
+ // no wrapping — configFor's defaults already forward them raw.
225
153
  const focusOnPress = (node, config) => ({
226
154
  ...config,
227
155
  onPress(event) {
@@ -253,23 +181,34 @@ function attach(node) {
253
181
  isMirrorFreshlySeeded: false,
254
182
  lastNativeSelection: { start: SELECTION_NONE, end: SELECTION_NONE },
255
183
  });
256
- // The mirror's seed has to reach the PAYLOAD too, not just this state object. The wrappers handed
257
- // the count over on every render, so an input committed the key at create; the behavior used to
258
- // write it only inside the change handshake, so the tag carried no such key until the user typed.
259
- // Found independently by three adapters, 2026-09-01.
260
- //
261
- // No `requestCommitFor` here: at create the renderer commits anyway, and on a re-attach the key is
262
- // already standing at this same value, so `setProp`'s identity guard makes the write a no-op.
184
+ // The mirror's seed must reach the PAYLOAD too, not just this state object — otherwise the tag
185
+ // carries no `mostRecentEventCount` key until the user types.
186
+ // No `requestCommitFor` here: at create the renderer commits anyway, and on a re-attach the key
187
+ // is already at this value, so `setProp`'s identity guard makes the write a no-op.
263
188
  setProp(node, 'mostRecentEventCount', INITIAL_EVENT_COUNT);
264
- setBehaviorListener(node, 'change', event => onChange(node, event));
265
- setBehaviorListener(node, 'focus', event => onFocus(node, event));
266
- setBehaviorListener(node, 'blur', event => onBlur(node, event));
267
- setBehaviorListener(node, 'selectionChange', event => onSelectionChange(node, event));
268
189
  attachPressMachine(node, {
269
190
  refine: focusOnPress,
270
191
  cancelableOf: textInputCancelable,
271
192
  });
193
+ // AFTER the machine, which points the node at its own: a node holds exactly one dispatch, and
194
+ // `TEXT_INPUT_DISPATCH` is the union that delegates the machine's seven back to it
195
+ setNodeDispatch(node, TEXT_INPUT_DISPATCH);
272
196
  }
197
+ const OWN_HANDLERS = new Map([
198
+ ['change', onChange],
199
+ ['focus', onFocus],
200
+ ['blur', onBlur],
201
+ ['selectionChange', onSelectionChange],
202
+ ]);
203
+ const TEXT_INPUT_DISPATCH = {
204
+ names: new Set([...OWN_HANDLERS.keys(), ...PRESS_DISPATCH.names]),
205
+ deliver(node, name, event) {
206
+ const own = OWN_HANDLERS.get(name);
207
+ if (own !== undefined)
208
+ return own(node, event);
209
+ return PRESS_DISPATCH.deliver(node, name, event);
210
+ },
211
+ };
273
212
  // TextInput.js:597 with its `rejectResponderTermination = true` default (:905): iOS keeps the
274
213
  // gesture unless the app opts in; Android hands Pressability `null`, i.e. its own default (yield).
275
214
  function textInputCancelable(source) {
@@ -284,20 +223,15 @@ function attachAfterCommit(node) {
284
223
  const state = stateOf(node);
285
224
  if (state === undefined)
286
225
  return;
287
- // ONE question, not three. `propOf` crosses the host boundary per call — `flushOps()` plus a JSI
288
- // read — and this runs once per input on the commit that lands it, so three reads of the same
289
- // bag were three crossings per `<text-input>` on every create. `propsOf` fetches it whole and
290
- // hands back the host's own object when nothing is stashed, which is every node here.
226
+ // ONE question, not three: propOf crosses the host boundary per call, so propsOf fetches the
227
+ // whole bag instead, handing back the host's own object when nothing is stashed.
291
228
  const props = propsOf(node);
292
229
  state.lastNativeText = foldText(stringFrom(props.value), stringFrom(props.defaultValue));
293
230
  state.isMirrorFreshlySeeded = true;
294
231
  if (props.autoFocus !== true)
295
232
  return;
296
- // Driven in JS rather than as a native `autoFocus` prop (RN's own ViewConfigs DO declare one —
297
- // `RCTTextInputViewConfig.js`/`AndroidTextInputNativeComponent.js` — but we don't forward it, so
298
- // this is the one mechanism that focuses the input). Routed through `focusTextInput`, not a raw
299
- // command, so an autoFocused input also updates the app-wide tracker `Keyboard.dismiss()` reads —
300
- // a raw command left it unset until the native focus event round-tripped back.
233
+ // Driven in JS, not a native `autoFocus` prop (RN declares one but we don't forward it). Routed
234
+ // through focusTextInput, not a raw command, so it also updates the app-wide focus tracker.
301
235
  dlog('TextInput behavior: autoFocus -> focus command');
302
236
  focusTextInput(node);
303
237
  }
@@ -308,10 +242,9 @@ function afterCommit(node) {
308
242
  const state = stateOf(node);
309
243
  if (state === undefined)
310
244
  return;
311
- // The seed ran on this same commit, so the TEXT comparison below is already decided — see
312
- // `isMirrorFreshlySeeded`. SELECTION is not seeded by anything, so it is checked regardless: an
313
- // authored `selection` must move the caret on this very commit, matching `TextInput.js`'s own
314
- // sentinel-seeded `lastNativeSelection`.
245
+ // The seed ran on this same commit, so the TEXT comparison below is already decided. SELECTION
246
+ // is not seeded by anything, so it's checked regardless — an authored `selection` must move the
247
+ // caret on this very commit.
315
248
  const freshlySeeded = state.isMirrorFreshlySeeded;
316
249
  if (freshlySeeded)
317
250
  state.isMirrorFreshlySeeded = false;
@@ -320,11 +253,8 @@ function afterCommit(node) {
320
253
  const props = propsOf(node);
321
254
  const value = stringFrom(props.value);
322
255
  const textDiverged = !freshlySeeded && shouldCommandText(state.lastNativeText, value);
323
- // `selection` is `{ start, end? }` when present. SELECTION_NONE (-1) is RN's "leave the cursor
324
- // where native put it" sentinel, so an absent selection must not be read as position 0 — that
325
- // would jump the caret to the front of the field on every controlled write. `lastNativeSelection`
326
- // is seeded at the same sentinel, so a real selection always "diverges" from it until this behavior
327
- // has actually sent one.
256
+ // SELECTION_NONE (-1) is RN's "leave the cursor where native put it" sentinel, so an absent
257
+ // selection must not read as position 0, or every controlled write would jump the caret to front.
328
258
  const { start, end } = selectionOf(props.selection);
329
259
  const selectionAuthored = start !== SELECTION_NONE || end !== SELECTION_NONE;
330
260
  const selectionDiverged = selectionAuthored &&
@@ -356,14 +286,8 @@ function detach(node) {
356
286
  states.delete(node);
357
287
  detachPressMachine(node);
358
288
  }
359
- /**
360
- * The imperative API RN exposes on a TextInput ref, built over the engine node. Reached through
361
- * each adapter's own `host-instance` accessor — the capability, not a shape
362
- * (`.claude/rules/adapter-parity-audit.md`).
363
- *
364
- * `focus`/`blur` are native view commands; `clear` and `setSelection` reuse `setTextAndSelection`,
365
- * the same stale-safe path a controlled write takes, so they cannot race a keystroke either.
366
- */
289
+ // The imperative API RN exposes on a TextInput ref, built over the engine node. `clear` and
290
+ // `setSelection` reuse setTextAndSelection, the same stale-safe path a controlled write takes.
367
291
  export function buildTextInputHandle(node) {
368
292
  return {
369
293
  // Forwarded, not re-implemented: these are the engine node's own prototype methods, and a
@@ -373,16 +297,11 @@ export function buildTextInputHandle(node) {
373
297
  measureInWindow: callback => node.measureInWindow(callback),
374
298
  measureLayout: (relativeTo, onSuccess, onFail) => node.measureLayout(relativeTo, onSuccess, onFail),
375
299
  setNativeProps: nativeProps => node.setNativeProps(nativeProps),
376
- // Through TextInputState, NOT a raw command — RN's `ReactNativeElement.focus()` routes a text
377
- // input through `TextInputState.focusTextInput` for the same reason blur below does: app-wide
378
- // tracking, plus the already-focused/`editable: false` no-op RN's own guard carries.
300
+ // Through TextInputState, NOT a raw command: app-wide tracking, plus the
301
+ // already-focused/editable:false no-op RN's own guard carries.
379
302
  focus: () => focusTextInput(node),
380
- // Through TextInputState, NOT a raw command — the same route the component path takes
381
- // (`react/.../text-input/index.ts`, "so the app-wide focus tracking clears too"). The native
382
- // `blur` event also clears the tracking via this behavior's own listener, so a raw command
383
- // looks equivalent and is not: the event is the NATIVE side's, and it does not arrive when the
384
- // input was already blurred. `Keyboard.dismiss()` reads `currentlyFocusedInput()`, so a stale
385
- // entry there aims a blur at a node that no longer holds focus.
303
+ // Through TextInputState, NOT a raw command: a raw command looks equivalent and isn't, since
304
+ // Keyboard.dismiss() reads currentlyFocusedInput() and a stale entry blurs the wrong node.
386
305
  blur: () => blurTextInput(node),
387
306
  isFocused: () => stateOf(node)?.isFocused === true,
388
307
  clear: () => {
@@ -415,12 +334,8 @@ export function buildTextInputHandle(node) {
415
334
  // Idempotent: an adapter entry may be imported more than once in a bundle, and re-registering the
416
335
  // same tag with an equivalent behavior must not double-install anything.
417
336
  export function registerTextInputBehavior() {
418
- // THE TWO TAGS NOW SHARE ONE BEHAVIOR OBJECT, and that is the port showing up in the shape of the
419
- // code. `multiline` was the only thing the two registrations did not share: each closed over its
420
- // own answer to feed `foldPayload`. With the fold gone the machine is identical for both, and the
421
- // engine answers `multiline` from the component name it already holds
422
- // (`foldTextInputAliases`'s `isMultiline` argument, `SymbioteFabricProps.cpp`) — which is the
423
- // better place for it anyway, since the component name is what Fabric actually keys the view on.
337
+ // The two tags share one behavior object: with the fold gone the machine is identical for both,
338
+ // and the engine answers `multiline` from the component name it already holds.
424
339
  const behavior = {
425
340
  attach,
426
341
  attachAfterCommit,
@@ -1,33 +1,9 @@
1
- // TouchableHighlight as an ENGINE-NODE behavior, so it can be an intrinsic tag instead of a
2
- // framework component (`.claude/rules/host-primitive-tier.md`, tier 2).
3
- //
4
- // ONE NODE, THE SAME SIMPLIFICATION EVERY WRAPPER ALREADY SHIPPED. RN's own TouchableHighlight
5
- // renders a container View (the responder, the underlay backgroundColor, the whole accessibility
6
- // fold) and CLONES an extra opacity style onto its single child (TouchableHighlight.js:281-320,
7
- // `_createExtraStyles` + `cloneElement`). This tag folds both onto the ONE node instead, in
8
- // `foldTouchableHighlightUnderlay` (`SymbioteFabricProps.cpp`) now — see that rule's own header.
9
- //
10
- // KNOWN GAP, and it predates both this port and the fix it reverts. Composing `opacity` onto the
11
- // SAME node as the underlay's `backgroundColor` fades the underlay itself, so `underlayColor:
12
- // 'black'` paints grey rather than black — every adapter's wrapper shipped this, and the rule's own
13
- // header above says so on purpose ("The port keeps that, it does not reopen it"). A 2026-09-15 fix
14
- // (fd39750b) closed it pointwise, in JS, on this one tag; this revert returns to the shared
15
- // (buggy) behavior every adapter already had, which is correct for THIS merge — a merge that also
16
- // changes behavior is unattributable.
17
- //
18
- // The reason the old header gave for not splitting it — "needs a child to target and a framework
19
- // component holding an opaque children slot cannot reach one safely" — no longer holds: that was
20
- // true of JS wrappers, not of the engine. The DESCENDANT seam this needs already exists: a rule
21
- // keyed on `IOwner.tagName`, the same shape `foldCloneOntoChild` uses to reach a
22
- // TouchableNativeFeedback child's own `fabricProps()` call and write onto ITS payload — the write
23
- // `IFirstChild` cannot do, since that seam only reads a child, from the PARENT's own call.
24
- //
25
- // TODO: what actually blocks it is one missing field. `IOwner` carries `{ props, tagName,
26
- // hasPressListener }`; the child would need the owner's `underlayShown` too (currently only on
27
- // `ISelf`, the node's own state) to decide whether to paint at all. Closing this is a new
28
- // descendant-keyed rule plus that one field on `IOwner`, not a `foldTouchableHighlightUnderlay`
29
- // rewrite.
30
- //
1
+ // TouchableHighlight as an ENGINE-NODE behavior: RN's own version renders a container View plus
2
+ // clones an opacity style onto its child (TouchableHighlight.js:281-320); this tag folds both
3
+ // onto the ONE node instead (`foldTouchableHighlightUnderlay` in `SymbioteFabricProps.cpp`).
4
+ // KNOWN GAP: composing `opacity` onto the SAME node as the underlay's `backgroundColor` fades the
5
+ // underlay itself, so `underlayColor: 'black'` paints grey, not black. TODO: needs a
6
+ // descendant-keyed rule plus `underlayShown` added to `IOwner`.
31
7
  // WHAT IS SHARED AND WHAT IS NEW. The underlay show/hide state machine
32
8
  // (createHighlightUnderlayHandlers/createHighlightUnderlayRuntime, `../state/touchable`) is already
33
9
  // framework-agnostic — every wrapper already calls it. New here is only WHERE `shown` lives (a
@@ -133,30 +109,14 @@ const refine = (node, config) => {
133
109
  },
134
110
  };
135
111
  };
136
- // THE UNDERLAY FOLD LEFT THIS FILE ON 2026-09-18, and with it the last `payloadFold` on this tag —
137
- // this behavior now costs ZERO trips into JS per commit, down from one on every commit it was dirty
138
- // in (which for a touchable is FIVE at mount alone, because the opacity settle re-commits it).
139
- //
140
- // THE NOTE IT REPLACES SAID THE UNDERLAY WAS "the genuine unportable article" BECAUSE IT IS BUILT
141
- // FROM LIVE PRESS STATE. Half right, and the half it got wrong is the reusable part: `shown` really
142
- // does flip mid-gesture and really is JS's, but the RULE was never made of it. Of four inputs, three
143
- // were already portable — `underlayColor` and `activeOpacity` are ordinary props (ones the engine
144
- // ALREADY strips), and `_hasPressHandler` is listener EXISTENCE, which has crossed since
145
- // `OP_SET_OWNED_LISTENER`. The fourth is one bit. "JS holds it" was never the same claim as "only JS
146
- // can compute it", and this is the third time that distinction has moved a rule.
147
- //
148
- // WHAT CROSSES AND WHAT DOES NOT. `setNodeUnderlayShown` sends the bit on a flip — twice a tap. The
149
- // hold timer, the `press`-then-`pressOut` ordering, the re-arm on a second tap and the
150
- // `onShowUnderlay` / `onHideUnderlay` callbacks all stay here, where Pressability is, because they
151
- // run at gesture rate and call into app code. That is the browser's line too: a UA paints `:active`,
152
- // the page decides what a click means.
153
- //
154
- // `focusable` left earlier the same day and carried a bug out with it — it read `props.disabled` off
155
- // the BAG, which the engine's pressable rule strips, so every DISABLED highlight stayed in the focus
156
- // order. One rule serves both touchable tags now (`foldPressableProps`), so there is no second copy
157
- // to drift; pinned in `core/engine/cpp/tests/js/touchable-focusable-payload.itest.ts`.
158
- //
159
- // The underlay's own contract: `core/engine/cpp/tests/js/touchable-highlight-underlay.itest.ts`.
112
+ // This behavior binds NO payload fold: `underlayColor`/`activeOpacity` are ordinary props the
113
+ // engine already strips, listener existence crosses via `OP_SET_OWNED_LISTENER`, and the one
114
+ // live bit crosses via `setNodeUnderlayShown` on a flip — twice a tap.
115
+ // What stays in JS: the hold timer, press-then-pressOut ordering, the re-arm on a second tap, and
116
+ // the `onShowUnderlay`/`onHideUnderlay` callbacks — they run at gesture rate and call app code.
117
+ // `focusable` is `foldPressableProps` now, shared with the other touchable tag so there is no
118
+ // second copy to drift — pinned in `touchable-focusable-payload.itest.ts`. Underlay's own
119
+ // contract: `touchable-highlight-underlay.itest.ts`.
160
120
  // A listener flip changes no payload by itself, so the commit after it is a no-op and no fold
161
121
  // re-runs (`IHostBehavior.onOwnedListenerChange`) — same reason `./touchable-opacity` carries this.
162
122
  function onOwnedListenerChange(node, name) {
@@ -49,11 +49,9 @@
49
49
  // 3. `style` is NOT cloned, matching RN — its clone list (:342-390) is closed, and a `style` on a
50
50
  // TNF stays on a node that never commits. RN's TNF declares no style prop either.
51
51
  //
52
- // REGISTRATION IS THE HAZARD, not the machine — see `./pressable` for why each adapter entry does a
53
- // bare `import './register';` that the barrel does not re-export. Registered by ALL FIVE adapters
54
- // since 2026-09-09, in the same commit that deleted the five wrappers, which is what makes it safe:
55
- // while a wrapper still built its own Pressable + feedback View, registering would have left every
56
- // TouchableNativeFeedback with two responders.
52
+ // REGISTRATION IS THE HAZARD, not the machine — see `./pressable` for why each adapter entry does
53
+ // a bare `import './register';`. Registering while a wrapper still built its own Pressable +
54
+ // feedback View would leave every TouchableNativeFeedback with two responders.
57
55
  import { SLOT_DERIVED_ALL, appListenerFor, markPropsDirty, Platform, registerHostBehavior, requestCommitFor, setBehaviorListener, } from '@symbiote-native/engine';
58
56
  import { resolveButtonDisabled } from '../view/render-button.js';
59
57
  import { asAccessibilityState, attachPressMachine, booleanOr, detachPressMachine, withNativeFeedbackCommands, } from './pressable.js';
@@ -66,19 +64,9 @@ export const TOUCHABLE_NATIVE_FEEDBACK_TAG = 'touchable-native-feedback';
66
64
  const touchableNativeFeedbackDisabled = props => resolveButtonDisabled(booleanOr(props.disabled), booleanOr(props['aria-disabled']), asAccessibilityState(props.accessibilityState));
67
65
  // Read once, like `./button`'s: the platform cannot change under a running app.
68
66
  const IS_ANDROID = Platform.OS === 'android';
69
- // WHAT THE OWNER'S WRITES DIRTY, and it is EVERY name rather than a list of thirty.
70
- //
71
- // RN's clone list (`TouchableNativeFeedback.js:349-390`) lived here until 2026-09-18 as
72
- // `CLONED_PROPS` + `DERIVED_FROM` + `ARIA_ALIAS_KEYS`, feeding `slotDerived`. The clone itself is
73
- // `foldCloneOntoChild` in `SymbioteFabricProps.cpp`, so the list had stopped being the rule and
74
- // become a MIRROR of `kNativeFeedbackClonedKeys` — two lists that must agree, failing silently (the
75
- // clone goes stale on the one prop a list forgot) when they drift.
76
- //
77
- // `SLOT_DERIVED_ALL` is both the honest spelling and the cheaper one to keep: a `cloneElement` owner
78
- // re-clones on every render whatever changed, so it never derived its slot from a NAMED set in the
79
- // first place. The cost is a false dirty on an owner prop the clone does not carry, and for this tag
80
- // that is nearly empty — its owner is an anchor whose props reach Fabric nowhere else, so every name
81
- // it holds is either cloned or consumed by the press machine.
67
+ // Dirties EVERY owner prop rather than a named list: `cloneElement` re-clones whatever changed
68
+ // each render, so the C++ fold never worked from a fixed set either. The cost is a false dirty on
69
+ // an uncloned prop — negligible, since this owner's props reach Fabric only via the clone.
82
70
  const SLOT_DERIVED = [SLOT_DERIVED_ALL];
83
71
  // The two RN clones as EVENTS rather than props (:386-387). Owned, so the app's callback stashes on
84
72
  // the owner and a trampoline installed on the child reads it at dispatch time — which keeps a fresh
@@ -101,11 +89,6 @@ const PRESS_LISTENERS = [
101
89
  'responderMove',
102
90
  'responderTerminationRequest',
103
91
  ];
104
- // `asFeedbackBackground` went with the fold, and `backgroundProps` — the slot pick it fed — followed
105
- // on 2026-09-18 as the orphan it had become. The first narrowed the app's `background` dict on its
106
- // discriminant; the C++ rule asks only whether the value is an OBJECT and copies it into the slot,
107
- // because the four factories that produce it (`render-touchable-native-feedback.ts`) are ours and
108
- // the payload is not a place to re-validate what a typed factory already built.
109
92
  /**
110
93
  * TNF's own Pressability config, applied to whatever node carries the responder.
111
94
  *
@@ -126,15 +109,9 @@ export const nativeFeedbackRefinement = (node, config) => {
126
109
  const floorless = { ...config, minPressDuration: 0 };
127
110
  return IS_ANDROID ? withNativeFeedbackCommands(node, floorless) : floorless;
128
111
  };
129
- // `cloneFold` LEFT THIS FILE ON 2026-09-18 — it is `foldCloneOntoChild` in
130
- // `SymbioteFabricProps.cpp`, and the seam it needed is the first rule keyed on the PARENT'S tag
131
- // rather than on the node's own (`IOwner`). The child of a TNF is whatever the app wrote, usually a
132
- // plain `<view>` with no tag at all, so nothing self-keyed could ever have reached it.
133
- //
134
- // What it cost to have had here: one JSI round trip per touchable per commit over an eighteen-key
135
- // bag, and `tag-rule-cost.itest.ts` prices a fold by what it MARSHALS rather than by what it does.
136
- //
137
- // Contract: `core/engine/cpp/tests/js/clone-onto-child-payload.itest.ts`.
112
+ // The clone runs in C++ (`foldCloneOntoChild`, `SymbioteFabricProps.cpp`), keyed on the PARENT'S
113
+ // tag rather than the node's own — the child is whatever the app wrote, usually untagged, so
114
+ // nothing self-keyed could ever reach it. Contract: `clone-onto-child-payload.itest.ts`.
138
115
  // Not RN's own list: RN drops these by never cloning them, and a tag has no clone to omit
139
116
  // them from — they would ride into the child's payload as keys no ViewConfig declares. Same strip
140
117
  // `./pressable`'s fold does for the machine-only half, applied to the owner's bag instead.
@@ -1,12 +1,5 @@
1
1
  import { type IHostBehavior } from '@symbiote-native/engine';
2
2
  import { type IDisabledResolver } from './pressable';
3
3
  export declare const TOUCHABLE_OPACITY_TAG = "touchable-opacity";
4
- /**
5
- * The behavior as PARTS, so a tag that is a TouchableOpacity plus something — `button`, which RN
6
- * builds as exactly that (Button.js:283) — composes the fade instead of re-implementing it.
7
- *
8
- * Safe to hand to two tags: every piece of runtime is keyed by NODE (`states`), and `press` is
9
- * itself already shared that way.
10
- */
11
4
  export declare function createTouchableOpacityBehavior(disabledOf?: IDisabledResolver): IHostBehavior;
12
5
  export declare function registerTouchableOpacityBehavior(): void;