@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.
- package/README.md +8 -9
- package/build/accessibility-props.d.ts +1 -1
- package/build/accessibility-props.js +2 -2
- package/build/behaviors/activity-indicator/shared.js +41 -70
- package/build/behaviors/button.d.ts +11 -0
- package/build/behaviors/button.js +101 -89
- package/build/behaviors/image-background.d.ts +1 -0
- package/build/behaviors/image-background.js +97 -81
- package/build/behaviors/image.d.ts +0 -1
- package/build/behaviors/image.js +24 -105
- package/build/behaviors/input-accessory-view.d.ts +0 -1
- package/build/behaviors/input-accessory-view.js +47 -53
- package/build/behaviors/pressable.d.ts +1 -1
- package/build/behaviors/pressable.js +68 -97
- package/build/behaviors/refresh-control.js +15 -2
- package/build/behaviors/scroll-view/index.android.js +25 -37
- package/build/behaviors/scroll-view/index.d.ts +1 -0
- package/build/behaviors/scroll-view/index.js +3 -0
- package/build/behaviors/scroll-view/responder.d.ts +4 -0
- package/build/behaviors/scroll-view/responder.js +202 -0
- package/build/behaviors/scroll-view/shared.d.ts +1 -3
- package/build/behaviors/scroll-view/shared.js +95 -95
- package/build/behaviors/scroll-view/sticky.d.ts +1 -0
- package/build/behaviors/scroll-view/sticky.js +62 -49
- package/build/behaviors/switch.js +43 -86
- package/build/behaviors/text-input.js +219 -107
- package/build/behaviors/touchable-highlight.js +74 -61
- package/build/behaviors/touchable-native-feedback.js +43 -122
- package/build/behaviors/touchable-opacity.js +71 -59
- package/build/behaviors/touchable-without-feedback.js +35 -100
- package/build/component-names/index.android.js +7 -11
- package/build/component-names/index.ios.js +0 -7
- package/build/component-names/shared.d.ts +1 -1
- package/build/index.d.ts +15 -21
- package/build/index.js +21 -28
- package/build/resolve-intrinsic.js +3 -9
- package/build/scroll-view-commands.d.ts +1 -9
- package/build/scroll-view-commands.js +13 -74
- package/build/state/flat-list.d.ts +2 -2
- package/build/state/flat-list.js +10 -2
- package/build/state/pressable.d.ts +6 -1
- package/build/state/pressable.js +63 -28
- package/build/state/section-list.d.ts +2 -0
- package/build/state/section-list.js +14 -7
- package/build/state/text-input.d.ts +7 -40
- package/build/state/text-input.js +17 -186
- package/build/state/touchable.d.ts +1 -0
- package/build/state/touchable.js +11 -8
- package/build/state/virtualized-list-reducer.d.ts +2 -2
- package/build/state/virtualized-list.d.ts +6 -6
- package/build/state/virtualized-list.js +71 -37
- package/build/text-props.d.ts +0 -8
- package/build/text-props.js +14 -25
- package/build/view/render-button.d.ts +1 -29
- package/build/view/render-button.js +44 -81
- package/build/view/render-image/index.d.ts +14 -1
- package/build/view/render-image/index.js +22 -147
- package/build/view/render-input-accessory-view.d.ts +1 -5
- package/build/view/render-input-accessory-view.js +26 -48
- package/build/view/render-keyboard-avoiding-view.d.ts +7 -1
- package/build/view/render-keyboard-avoiding-view.js +40 -1
- package/build/view/render-modal.d.ts +1 -1
- package/build/view/render-modal.js +15 -5
- package/build/view/render-pressable/index.d.ts +1 -0
- package/build/view/render-pressable/index.js +4 -0
- package/build/view/render-scroll-view.d.ts +0 -4
- package/build/view/render-scroll-view.js +3 -49
- package/build/view/render-switch.d.ts +0 -14
- package/build/view/render-switch.js +4 -42
- package/build/view/render-touchable-highlight.d.ts +1 -0
- package/build/view/render-touchable-native-feedback.d.ts +0 -1
- package/build/view/render-touchable-native-feedback.js +15 -9
- package/host-primitives.cjs +49 -203
- package/host-primitives.d.cts +0 -1
- package/package.json +3 -7
- package/build/fold-host-bag.d.ts +0 -15
- package/build/fold-host-bag.js +0 -99
- package/build/view/render-text-input.d.ts +0 -11
- 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,
|
|
8
|
-
//
|
|
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
|
|
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 {
|
|
24
|
-
|
|
25
|
-
//
|
|
26
|
-
//
|
|
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
|
-
|
|
34
|
-
|
|
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
|
-
//
|
|
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
|
|
58
|
-
// `fabricProps` drops it on the way to native. A
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
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.
|
|
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
|
|
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
|
|
80
|
-
// `
|
|
81
|
-
// `
|
|
82
|
-
//
|
|
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.
|
|
103
|
-
//
|
|
104
|
-
//
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
//
|
|
156
|
-
|
|
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
|
|
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.
|
|
195
|
-
// the count
|
|
196
|
-
//
|
|
197
|
-
//
|
|
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
|
-
|
|
214
|
-
|
|
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
|
|
217
|
-
//
|
|
218
|
-
// is
|
|
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
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
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(
|
|
328
|
+
`text=${JSON.stringify(text)}`);
|
|
238
329
|
dispatchViewCommand(node, 'setTextAndSelection', [
|
|
239
330
|
state.mostRecentEventCount,
|
|
240
|
-
|
|
331
|
+
text,
|
|
241
332
|
start,
|
|
242
333
|
end,
|
|
243
334
|
]);
|
|
244
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
311
|
-
//
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
}
|