@symbiote-native/components 2.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 (79) hide show
  1. package/README.md +8 -9
  2. package/build/accessibility-props.d.ts +1 -1
  3. package/build/accessibility-props.js +2 -2
  4. package/build/behaviors/activity-indicator/shared.js +41 -70
  5. package/build/behaviors/button.d.ts +11 -0
  6. package/build/behaviors/button.js +101 -89
  7. package/build/behaviors/image-background.d.ts +1 -0
  8. package/build/behaviors/image-background.js +97 -81
  9. package/build/behaviors/image.d.ts +0 -1
  10. package/build/behaviors/image.js +24 -105
  11. package/build/behaviors/input-accessory-view.d.ts +0 -1
  12. package/build/behaviors/input-accessory-view.js +47 -53
  13. package/build/behaviors/pressable.d.ts +1 -1
  14. package/build/behaviors/pressable.js +68 -97
  15. package/build/behaviors/refresh-control.js +15 -2
  16. package/build/behaviors/scroll-view/index.android.js +25 -37
  17. package/build/behaviors/scroll-view/index.d.ts +1 -0
  18. package/build/behaviors/scroll-view/index.js +3 -0
  19. package/build/behaviors/scroll-view/responder.d.ts +4 -0
  20. package/build/behaviors/scroll-view/responder.js +202 -0
  21. package/build/behaviors/scroll-view/shared.d.ts +1 -3
  22. package/build/behaviors/scroll-view/shared.js +95 -95
  23. package/build/behaviors/scroll-view/sticky.d.ts +1 -0
  24. package/build/behaviors/scroll-view/sticky.js +62 -49
  25. package/build/behaviors/switch.js +43 -86
  26. package/build/behaviors/text-input.js +219 -107
  27. package/build/behaviors/touchable-highlight.js +74 -61
  28. package/build/behaviors/touchable-native-feedback.js +43 -122
  29. package/build/behaviors/touchable-opacity.js +71 -59
  30. package/build/behaviors/touchable-without-feedback.js +35 -100
  31. package/build/component-names/index.android.js +7 -11
  32. package/build/component-names/index.ios.js +0 -7
  33. package/build/component-names/shared.d.ts +1 -1
  34. package/build/index.d.ts +15 -21
  35. package/build/index.js +21 -28
  36. package/build/resolve-intrinsic.js +3 -9
  37. package/build/scroll-view-commands.d.ts +1 -9
  38. package/build/scroll-view-commands.js +13 -74
  39. package/build/state/flat-list.d.ts +2 -2
  40. package/build/state/flat-list.js +10 -2
  41. package/build/state/pressable.d.ts +6 -1
  42. package/build/state/pressable.js +63 -28
  43. package/build/state/section-list.d.ts +2 -0
  44. package/build/state/section-list.js +14 -7
  45. package/build/state/text-input.d.ts +7 -40
  46. package/build/state/text-input.js +17 -186
  47. package/build/state/touchable.d.ts +1 -0
  48. package/build/state/touchable.js +11 -8
  49. package/build/state/virtualized-list-reducer.d.ts +2 -2
  50. package/build/state/virtualized-list.d.ts +6 -6
  51. package/build/state/virtualized-list.js +71 -37
  52. package/build/text-props.d.ts +0 -8
  53. package/build/text-props.js +14 -25
  54. package/build/view/render-button.d.ts +1 -29
  55. package/build/view/render-button.js +44 -81
  56. package/build/view/render-image/index.d.ts +14 -1
  57. package/build/view/render-image/index.js +22 -147
  58. package/build/view/render-input-accessory-view.d.ts +1 -5
  59. package/build/view/render-input-accessory-view.js +26 -48
  60. package/build/view/render-keyboard-avoiding-view.d.ts +7 -1
  61. package/build/view/render-keyboard-avoiding-view.js +40 -1
  62. package/build/view/render-modal.d.ts +1 -1
  63. package/build/view/render-modal.js +15 -5
  64. package/build/view/render-pressable/index.d.ts +1 -0
  65. package/build/view/render-pressable/index.js +4 -0
  66. package/build/view/render-scroll-view.d.ts +0 -4
  67. package/build/view/render-scroll-view.js +3 -49
  68. package/build/view/render-switch.d.ts +0 -14
  69. package/build/view/render-switch.js +4 -42
  70. package/build/view/render-touchable-highlight.d.ts +1 -0
  71. package/build/view/render-touchable-native-feedback.d.ts +0 -1
  72. package/build/view/render-touchable-native-feedback.js +15 -9
  73. package/host-primitives.cjs +49 -203
  74. package/host-primitives.d.cts +0 -1
  75. package/package.json +3 -7
  76. package/build/fold-host-bag.d.ts +0 -15
  77. package/build/fold-host-bag.js +0 -99
  78. package/build/view/render-text-input.d.ts +0 -11
  79. package/build/view/render-text-input.js +0 -39
@@ -4,14 +4,16 @@
4
4
  // WHAT A `TextInput` COMPONENT ACTUALLY DOES, and why none of it needs a framework. It holds three
5
5
  // mirrors of native state (the acknowledged event count, the last text native reported, whether the
6
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, and it exposes five imperative methods.
8
- // The TEMPLATE reads none of it — which is the whole tier-2 test. Every framework was paying an
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
9
11
  // instance for a machine that only ever needed a per-node home.
10
12
  //
11
13
  // WHY IT NEEDED A NEW ENGINE HOOK AND `Pressable` DID NOT. A press machine is driven entirely by
12
14
  // events, which arrive long after commit. The controlled handshake is driven by a PROP: `value`
13
15
  // changing is what must re-run the divergence check, and in a component the render is what does
14
- // that. A lowered element has no render, so `IHostBehavior.afterCommit` is the equivalent beat —
16
+ // that. A tag has no render, so `IHostBehavior.afterCommit` is the equivalent beat —
15
17
  // see that interface for why it is not a hook on `setProp`.
16
18
  //
17
19
  // THE ORDER OF THE TWO COMMIT HOOKS IS LOAD-BEARING HERE, which is why the engine pins it with a
@@ -19,19 +21,19 @@
19
21
  // compares against that seed. Reversed, the very first beat would see an empty mirror, decide the
20
22
  // app's value had diverged, and command a redundant `setTextAndSelection` down to native on every
21
23
  // input in the tree.
22
- import { appListenerFor, blurTextInput, dispatchViewCommand, dlog, registerHostBehavior, requestCommitFor, setBehaviorListener, setInputBlurred, setInputFocused, setProp, } from '@symbiote-native/engine';
23
- import { eventCountFromChange, foldText, INITIAL_EVENT_COUNT, SELECTION_NONE, resolveTextInputProps, shouldCommandText, textFromChange, } from '../state/text-input.js';
24
- // Both spellings, because `multiline` picks between two Fabric views and a lowering transform
25
- // resolves that statically. The behavior is registered for both so it does not care which one the
26
- // transform emitted.
24
+ import { appListenerFor, blurTextInput, dispatchViewCommand, dlog, focusTextInput, propOf, propsOf, registerHostBehavior, requestCommitFor, setBehaviorListener, setInputBlurred, setInputFocused, setProp, } from '@symbiote-native/engine';
25
+ import { attachPressMachine, detachPressMachine, } from './pressable.js';
26
+ import { eventCountFromChange, foldText, INITIAL_EVENT_COUNT, SELECTION_NONE, shouldCommandText, textFromChange, } from '../state/text-input.js';
27
+ // Both spellings, because `multiline` picks between two Fabric views and the TAG is what decides.
28
+ // The behavior is registered for both so it does not care which one the app wrote.
27
29
  export const TEXT_INPUT_TAG = 'text-input';
28
30
  export const TEXT_INPUT_MULTILINE_TAG = 'text-input-multiline';
29
31
  const states = new WeakMap();
30
32
  function stateOf(node) {
31
33
  return states.get(node);
32
34
  }
33
- function stringProp(node, key) {
34
- const value = node.props[key];
35
+ /** The same narrowing, for a value already in hand — see `attachAfterCommit`'s single read. */
36
+ function stringFrom(value) {
35
37
  return typeof value === 'string' ? value : undefined;
36
38
  }
37
39
  // `{ start, end? }` narrowed at runtime. `end` defaults to `start` — a caret, RN's own reading when
@@ -46,7 +48,7 @@ function selectionOf(value) {
46
48
  return { start, end };
47
49
  }
48
50
  // The app's own callback for an owned event name, read from the STASH rather than from
49
- // `node.props`: every name below is in `ownedListeners`, so `routeProp` parks the app's handler
51
+ // the props: every name below is in `ownedListeners`, so `routeProp` parks the app's handler
50
52
  // beside the machine's instead of overwriting it.
51
53
  function callAppListener(node, name, event) {
52
54
  const listener = appListenerFor(node, name);
@@ -54,55 +56,34 @@ function callAppListener(node, name, event) {
54
56
  listener(event);
55
57
  }
56
58
  // `onValueChange(event)` is NOT a Fabric event — it is a fold the component wrapper used to do
57
- // over the raw `change` payload, so it lives in `node.props` as a plain function key and
58
- // `fabricProps` drops it on the way to native. A lowered element has no wrapper to run that fold, so
59
- // before this the callback was simply never called: the field echoed keystrokes natively (native
60
- // owns its own text) while every value the app derived from it stayed frozen. Device-found
61
- // 2026-08-31 in examples/solid's canary — the greeting never left "Hello, stranger".
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".
62
64
  //
63
65
  // Same class as `value -> text` (`core/engine/src/fabric-props.ts`) and the same repair: below the
64
- // fork, where all five adapters inherit it. Refusing to lower an element carrying the prop was the
65
- // other candidate and is strictly worse — it makes the optimisation opt out of the idiom the
66
- // ecosystem actually writes, to avoid a fold the runtime can do in three lines.
66
+ // fork, where all five adapters inherit it.
67
67
  //
68
68
  // The listener takes ONE argument, `text` carried on the event itself (`ITextInputChangeEvent`),
69
69
  // not `(text, event)` — Svelte's compiler forces every individual `on*` attribute through a native
70
70
  // listener wrapper that calls with exactly one argument, always a real object, so a second
71
71
  // argument is silently dropped and a bare string as the sole argument crashes.
72
72
  function callValueChange(node, text, event) {
73
- const listener = node.props.onValueChange;
73
+ const listener = propOf(node, 'onValueChange');
74
74
  if (typeof listener !== 'function')
75
75
  return;
76
76
  const changeEvent = Object.assign(event, { text });
77
77
  listener(changeEvent);
78
78
  }
79
- // The W3C/legacy alias fold the WRAPPER runs in its component body — `inputMode` -> `keyboardType`,
80
- // `enterKeyHint` -> `returnKeyType`, `readOnly` -> inverted `editable`, `blurOnSubmit` ->
81
- // `submitBehavior`, plus the `underlineColorAndroid: 'transparent'` default that hides the Material
82
- // bar. A lowered element has no body, so before this every one of them was dropped: the raw alias
83
- // reached Fabric as a key no ViewConfig declares, which throws nothing and renders nothing, so
84
- // `inputMode="numeric"` simply produced the default keyboard on a device while the whole headless
85
- // suite stayed green.
86
- //
87
- // Found by the wrapper-vs-behavior import audit rather than by hand
88
- // (`.claude/rules/adapter-parity-audit.md`) — the same audit that found Pressable's two.
89
- const ALIAS_ONLY_KEYS = [
90
- 'inputMode',
91
- 'enterKeyHint',
92
- 'readOnly',
93
- 'blurOnSubmit',
94
- ];
95
- function stringOf(value) {
96
- return typeof value === 'string' ? value : undefined;
97
- }
98
- function booleanOf(value) {
99
- return typeof value === 'boolean' ? value : undefined;
100
- }
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.
101
83
  // `multiline` picks between TWO Fabric views, so the TAG decides it and no later prop write moves a
102
- // node between them. Two of the three paths that build the node resolve it before the engine ever
103
- // sees the prop — the wrapper CONSUMES it to pick its intrinsic, a lowering transform reads a
104
- // literal at compile time — and the third, an author writing the tag by hand, has neither. That
105
- // leaves two silent, device-only divergences, measured on the committed payload:
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:
106
87
  //
107
88
  // <text-input-multiline value="a" /> RCTMultilineTextInputView, folded as SINGLE-line:
108
89
  // submitBehavior 'blurAndSubmit', so Return blurs instead
@@ -120,44 +101,54 @@ function booleanOf(value) {
120
101
  // the error. Refusing a contradicting prop therefore stays in each adapter's own prop-write path,
121
102
  // where the author's stack still exists (Solid's `renderer.ts` is the reference); this file only
122
103
  // guarantees that whatever the props say, the payload matches the TAG.
123
- function foldPayload(props, isMultilineTag) {
124
- const folded = resolveTextInputProps({
125
- inputMode: stringOf(props.inputMode),
126
- keyboardType: stringOf(props.keyboardType),
127
- enterKeyHint: stringOf(props.enterKeyHint),
128
- returnKeyType: stringOf(props.returnKeyType),
129
- readOnly: booleanOf(props.readOnly),
130
- editable: booleanOf(props.editable),
131
- submitBehavior: stringOf(props.submitBehavior),
132
- blurOnSubmit: booleanOf(props.blurOnSubmit),
133
- multiline: isMultilineTag,
134
- cursorColor: stringOf(props.cursorColor),
135
- selectionColor: stringOf(props.selectionColor),
136
- selectionHandleColor: stringOf(props.selectionHandleColor),
137
- autoComplete: stringOf(props.autoComplete),
138
- textContentType: stringOf(props.textContentType),
139
- showSoftInputOnFocus: booleanOf(props.showSoftInputOnFocus),
140
- underlineColorAndroid: stringOf(props.underlineColorAndroid),
141
- });
142
- const out = { ...props, ...folded };
143
- // The aliases themselves must NOT ride along: they are inert at native, and leaving them in the
144
- // payload is how a reader concludes the fold ran when it did not.
145
- for (const key of ALIAS_ONLY_KEYS)
146
- delete out[key];
147
- return out;
148
- }
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.
149
124
  function onChange(node, event) {
150
125
  const state = stateOf(node);
151
126
  if (state === undefined)
152
127
  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.
133
+ callAppListener(node, 'change', event);
153
134
  const text = textFromChange(event);
154
135
  if (text !== undefined) {
155
- // Ordering matches the component path exactly: record the mirror, then hand the app its text.
156
- state.lastNativeText = text;
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.
140
+ const onChangeText = propOf(node, 'onChangeText');
141
+ if (typeof onChangeText === 'function') {
142
+ onChangeText(Object.assign(event, { text }));
143
+ }
144
+ // `onValueChange` has no vendor counterpart — it is our own repair for the fold the component
145
+ // wrapper used to do — so its position relative to `onChangeText` carries no vendor constraint.
157
146
  callValueChange(node, text, event);
147
+ // The mirror, after both callbacks, matching vendor's own ordering.
148
+ state.lastNativeText = text;
158
149
  }
159
- // Ordering: record the text first, then the count, so the acknowledged count never runs ahead of
160
- // the text it stands for. A count without its text makes the next controlled write echo an
150
+ // Ordering: record the text mirror before the count, so the acknowledged count never runs ahead
151
+ // of the text it stands for. A count without its text makes the next controlled write echo an
161
152
  // acknowledgement native has not actually given.
162
153
  const count = eventCountFromChange(event);
163
154
  if (count !== undefined) {
@@ -168,7 +159,6 @@ function onChange(node, event) {
168
159
  setProp(node, 'mostRecentEventCount', count);
169
160
  requestCommitFor(node);
170
161
  }
171
- callAppListener(node, 'change', event);
172
162
  }
173
163
  function onFocus(node, event) {
174
164
  const state = stateOf(node);
@@ -185,17 +175,88 @@ function onBlur(node, event) {
185
175
  setInputBlurred(node);
186
176
  callAppListener(node, 'blur', event);
187
177
  }
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.
183
+ function correctSelectionIfNeeded(node, state) {
184
+ const props = propsOf(node);
185
+ const { start, end } = selectionOf(props.selection);
186
+ const selectionAuthored = start !== SELECTION_NONE || end !== SELECTION_NONE;
187
+ if (!selectionAuthored ||
188
+ (state.lastNativeSelection.start === start &&
189
+ state.lastNativeSelection.end === end)) {
190
+ return;
191
+ }
192
+ const text = foldText(stringFrom(props.value), stringFrom(props.defaultValue));
193
+ dlog(`TextInput behavior: setTextAndSelection (selection-change snap-back) count=${state.mostRecentEventCount}`);
194
+ dispatchViewCommand(node, 'setTextAndSelection', [
195
+ state.mostRecentEventCount,
196
+ text,
197
+ start,
198
+ end,
199
+ ]);
200
+ state.lastNativeSelection = { start, end };
201
+ }
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.
208
+ function onSelectionChange(node, event) {
209
+ callAppListener(node, 'selectionChange', event);
210
+ const state = stateOf(node);
211
+ if (state === undefined)
212
+ return;
213
+ const native = selectionOf(event.nativeEvent.selection);
214
+ if (native.start === SELECTION_NONE && native.end === SELECTION_NONE)
215
+ return;
216
+ state.lastNativeSelection = native;
217
+ correctSelectionIfNeeded(node, state);
218
+ }
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).
225
+ const focusOnPress = (node, config) => ({
226
+ ...config,
227
+ onPress(event) {
228
+ config.onPress?.(event);
229
+ if (propOf(node, 'editable') !== false) {
230
+ dlog('TextInput behavior: press -> focus command');
231
+ dispatchViewCommand(node, 'focus', []);
232
+ }
233
+ },
234
+ });
235
+ // One node holds exactly one press machine (`./pressable`'s own constraint), so these join
236
+ // TextInput's existing 'change'/'focus'/'blur' owned names rather than replacing them — none of
237
+ // the six collide with those three.
238
+ const PRESS_LISTENERS = [
239
+ 'press',
240
+ 'pressIn',
241
+ 'pressOut',
242
+ 'pressMove',
243
+ 'longPress',
244
+ 'startShouldSetResponder',
245
+ 'responderMove',
246
+ 'responderTerminationRequest',
247
+ ];
188
248
  function attach(node) {
189
249
  states.set(node, {
190
250
  mostRecentEventCount: INITIAL_EVENT_COUNT,
191
251
  lastNativeText: undefined,
192
252
  isFocused: false,
253
+ isMirrorFreshlySeeded: false,
254
+ lastNativeSelection: { start: SELECTION_NONE, end: SELECTION_NONE },
193
255
  });
194
- // The mirror's seed has to reach the PAYLOAD too, not just this state object. Every wrapper hands
195
- // the count to `renderTextInput` on every render, so a component-path input commits the key at
196
- // create; the behavior used to write it only inside the change handshake, so a lowered input
197
- // carried no such key until the user typed. Found independently by three adapters' equivalence
198
- // arms, 2026-09-01 — a divergence between the two paths of ONE adapter, not between adapters.
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.
199
260
  //
200
261
  // No `requestCommitFor` here: at create the renderer commits anyway, and on a re-attach the key is
201
262
  // already standing at this same value, so `setProp`'s identity guard makes the write a no-op.
@@ -203,6 +264,8 @@ function attach(node) {
203
264
  setBehaviorListener(node, 'change', event => onChange(node, event));
204
265
  setBehaviorListener(node, 'focus', event => onFocus(node, event));
205
266
  setBehaviorListener(node, 'blur', event => onBlur(node, event));
267
+ setBehaviorListener(node, 'selectionChange', event => onSelectionChange(node, event));
268
+ attachPressMachine(node, { refine: focusOnPress });
206
269
  }
207
270
  // The first commit is the earliest point where the node has BOTH its props and a Fabric tag. The
208
271
  // mirror needs the first, `autoFocus` needs the second.
@@ -210,14 +273,22 @@ function attachAfterCommit(node) {
210
273
  const state = stateOf(node);
211
274
  if (state === undefined)
212
275
  return;
213
- state.lastNativeText = foldText(stringProp(node, 'value'), stringProp(node, 'defaultValue'));
214
- if (node.props.autoFocus !== true)
276
+ // ONE question, not three. `propOf` crosses the host boundary per call — `flushOps()` plus a JSI
277
+ // read — and this runs once per input on the commit that lands it, so three reads of the same
278
+ // bag were three crossings per `<text-input>` on every create. `propsOf` fetches it whole and
279
+ // hands back the host's own object when nothing is stashed, which is every node here.
280
+ const props = propsOf(node);
281
+ state.lastNativeText = foldText(stringFrom(props.value), stringFrom(props.defaultValue));
282
+ state.isMirrorFreshlySeeded = true;
283
+ if (props.autoFocus !== true)
215
284
  return;
216
- // Driven in JS rather than as a native prop, exactly as RN does it
217
- // (TextInput.js:538 -> TextInputState.focusInput). The native command is idempotent if the input
218
- // is already focused.
285
+ // Driven in JS rather than as a native `autoFocus` prop (RN's own ViewConfigs DO declare one —
286
+ // `RCTTextInputViewConfig.js`/`AndroidTextInputNativeComponent.js` — but we don't forward it, so
287
+ // this is the one mechanism that focuses the input). Routed through `focusTextInput`, not a raw
288
+ // command, so an autoFocused input also updates the app-wide tracker `Keyboard.dismiss()` reads —
289
+ // a raw command left it unset until the native focus event round-tripped back.
219
290
  dlog('TextInput behavior: autoFocus -> focus command');
220
- dispatchViewCommand(node, 'focus', []);
291
+ focusTextInput(node);
221
292
  }
222
293
  // The controlled handshake. A plain prop re-push would race the user's keystrokes — native may have
223
294
  // text JS has not seen yet — so the command carrying the acknowledged count is the only stale-safe
@@ -226,25 +297,53 @@ function afterCommit(node) {
226
297
  const state = stateOf(node);
227
298
  if (state === undefined)
228
299
  return;
229
- const value = stringProp(node, 'value');
230
- if (!shouldCommandText(state.lastNativeText, value))
231
- return;
300
+ // The seed ran on this same commit, so the TEXT comparison below is already decided — see
301
+ // `isMirrorFreshlySeeded`. SELECTION is not seeded by anything, so it is checked regardless: an
302
+ // authored `selection` must move the caret on this very commit, matching `TextInput.js`'s own
303
+ // sentinel-seeded `lastNativeSelection`.
304
+ const freshlySeeded = state.isMirrorFreshlySeeded;
305
+ if (freshlySeeded)
306
+ state.isMirrorFreshlySeeded = false;
307
+ // ONE crossing for the whole bag, not three — `attachAfterCommit`'s own reasoning: `propOf` per
308
+ // key is a JSI read per key, and this runs on every commit a text input is dirty in.
309
+ const props = propsOf(node);
310
+ const value = stringFrom(props.value);
311
+ const textDiverged = !freshlySeeded && shouldCommandText(state.lastNativeText, value);
232
312
  // `selection` is `{ start, end? }` when present. SELECTION_NONE (-1) is RN's "leave the cursor
233
313
  // where native put it" sentinel, so an absent selection must not be read as position 0 — that
234
- // would jump the caret to the front of the field on every controlled write.
235
- const { start, end } = selectionOf(node.props.selection);
314
+ // would jump the caret to the front of the field on every controlled write. `lastNativeSelection`
315
+ // is seeded at the same sentinel, so a real selection always "diverges" from it until this behavior
316
+ // has actually sent one.
317
+ const { start, end } = selectionOf(props.selection);
318
+ const selectionAuthored = start !== SELECTION_NONE || end !== SELECTION_NONE;
319
+ const selectionDiverged = selectionAuthored &&
320
+ (state.lastNativeSelection.start !== start ||
321
+ state.lastNativeSelection.end !== end);
322
+ if (!textDiverged && !selectionDiverged)
323
+ return;
324
+ // `TextInput.js`'s own `text` — `value ?? defaultValue`, sent verbatim whichever half diverged,
325
+ // never the raw `value` alone: an uncontrolled input moving only its caret has no `value` to send.
326
+ const text = foldText(value, stringFrom(props.defaultValue));
236
327
  dlog(`TextInput behavior: setTextAndSelection count=${state.mostRecentEventCount} ` +
237
- `text=${JSON.stringify(value)}`);
328
+ `text=${JSON.stringify(text)}`);
238
329
  dispatchViewCommand(node, 'setTextAndSelection', [
239
330
  state.mostRecentEventCount,
240
- value,
331
+ text,
241
332
  start,
242
333
  end,
243
334
  ]);
244
- state.lastNativeText = value;
335
+ if (textDiverged)
336
+ state.lastNativeText = value;
337
+ if (selectionDiverged)
338
+ state.lastNativeSelection = { start, end };
245
339
  }
246
340
  function detach(node) {
341
+ // RN blurs a focused input on unmount (TextInput.js's useLayoutEffect cleanup) so native
342
+ // and the app-wide focus tracker don't outlive a node that's gone. `blurTextInput` already
343
+ // no-ops when this node isn't the currently-focused one.
344
+ blurTextInput(node);
247
345
  states.delete(node);
346
+ detachPressMachine(node);
248
347
  }
249
348
  /**
250
349
  * The imperative API RN exposes on a TextInput ref, built over the engine node. Reached through
@@ -263,7 +362,10 @@ export function buildTextInputHandle(node) {
263
362
  measureInWindow: callback => node.measureInWindow(callback),
264
363
  measureLayout: (relativeTo, onSuccess, onFail) => node.measureLayout(relativeTo, onSuccess, onFail),
265
364
  setNativeProps: nativeProps => node.setNativeProps(nativeProps),
266
- focus: () => dispatchViewCommand(node, 'focus', []),
365
+ // Through TextInputState, NOT a raw command — RN's `ReactNativeElement.focus()` routes a text
366
+ // input through `TextInputState.focusTextInput` for the same reason blur below does: app-wide
367
+ // tracking, plus the already-focused/`editable: false` no-op RN's own guard carries.
368
+ focus: () => focusTextInput(node),
267
369
  // Through TextInputState, NOT a raw command — the same route the component path takes
268
370
  // (`react/.../text-input/index.ts`, "so the app-wide focus tracking clears too"). The native
269
371
  // `blur` event also clears the tracking via this behavior's own listener, so a raw command
@@ -302,18 +404,28 @@ export function buildTextInputHandle(node) {
302
404
  // Idempotent: an adapter entry may be imported more than once in a bundle, and re-registering the
303
405
  // same tag with an equivalent behavior must not double-install anything.
304
406
  export function registerTextInputBehavior() {
305
- const behaviorFor = (isMultilineTag) => ({
407
+ // THE TWO TAGS NOW SHARE ONE BEHAVIOR OBJECT, and that is the port showing up in the shape of the
408
+ // code. `multiline` was the only thing the two registrations did not share: each closed over its
409
+ // own answer to feed `foldPayload`. With the fold gone the machine is identical for both, and the
410
+ // engine answers `multiline` from the component name it already holds
411
+ // (`foldTextInputAliases`'s `isMultiline` argument, `SymbioteFabricProps.cpp`) — which is the
412
+ // better place for it anyway, since the component name is what Fabric actually keys the view on.
413
+ const behavior = {
306
414
  attach,
307
415
  attachAfterCommit,
308
416
  afterCommit,
309
417
  detach,
310
- // The one thing the two registrations do NOT share: the tag is what answers `multiline`, so
311
- // each closes over its own answer. Everything else is the same machine.
312
- foldPayload: (props) => foldPayload(props, isMultilineTag),
313
- // The three the machine needs as INPUTS. Without the stash the app's own `onChange` would
314
- // evict the machine from the very event the controlled handshake runs on.
315
- ownedListeners: ['change', 'focus', 'blur'],
316
- });
317
- registerHostBehavior(TEXT_INPUT_TAG, behaviorFor(false));
318
- registerHostBehavior(TEXT_INPUT_MULTILINE_TAG, behaviorFor(true));
418
+ // The three the change/focus/blur machine needs as INPUTS, plus the press family the
419
+ // tap-to-focus machine composes. Without the stash the app's own `onChange` would evict the
420
+ // machine from the very event the controlled handshake runs on.
421
+ ownedListeners: [
422
+ 'change',
423
+ 'focus',
424
+ 'blur',
425
+ 'selectionChange',
426
+ ...PRESS_LISTENERS,
427
+ ],
428
+ };
429
+ registerHostBehavior(TEXT_INPUT_TAG, behavior);
430
+ registerHostBehavior(TEXT_INPUT_MULTILINE_TAG, behavior);
319
431
  }