@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.
- package/build/behaviors/activity-indicator/shared.js +29 -71
- package/build/behaviors/button.d.ts +0 -9
- package/build/behaviors/button.js +47 -233
- package/build/behaviors/image-background.js +21 -81
- package/build/behaviors/image.js +3 -10
- package/build/behaviors/input-accessory-view.js +10 -51
- package/build/behaviors/pressable.d.ts +5 -51
- package/build/behaviors/pressable.js +101 -161
- package/build/behaviors/refresh-control.js +8 -2
- package/build/behaviors/scroll-view/index.android.js +10 -30
- package/build/behaviors/scroll-view/responder.d.ts +3 -2
- package/build/behaviors/scroll-view/responder.js +19 -21
- package/build/behaviors/scroll-view/shared.js +69 -185
- package/build/behaviors/scroll-view/sticky.d.ts +0 -8
- package/build/behaviors/scroll-view/sticky.js +54 -142
- package/build/behaviors/switch.js +18 -4
- package/build/behaviors/text-input.d.ts +0 -8
- package/build/behaviors/text-input.js +77 -162
- package/build/behaviors/touchable-highlight.js +14 -54
- package/build/behaviors/touchable-native-feedback.js +9 -32
- package/build/behaviors/touchable-opacity.d.ts +0 -7
- package/build/behaviors/touchable-opacity.js +65 -81
- package/build/behaviors/touchable-without-feedback.js +25 -41
- package/build/component-names/index.android.js +6 -8
- package/build/component-names/shared.js +12 -42
- package/build/index.js +13 -19
- package/build/scroll-view-commands.js +3 -11
- package/build/state/pressable.js +18 -43
- package/build/state/sticky-header-reducer.js +103 -149
- package/build/state/touchable.js +3 -5
- package/build/state/virtualized-list-reducer.js +21 -48
- package/build/state/virtualized-list.js +63 -148
- package/build/text-props.js +3 -13
- package/build/view/render-button.js +13 -44
- package/build/view/render-input-accessory-view.js +8 -24
- package/build/view/render-pressable/index.js +3 -4
- package/build/view/render-scroll-view.js +13 -23
- package/build/view/render-touchable-native-feedback.js +5 -14
- package/host-primitives.cjs +33 -207
- package/package.json +3 -3
|
@@ -1,28 +1,13 @@
|
|
|
1
|
-
// TextInput's machine, on the engine node instead of inside a framework component
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
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
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
// the
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
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
|
-
//
|
|
129
|
-
// every native change, and only THEN the mirrors
|
|
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
|
-
//
|
|
137
|
-
//
|
|
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
|
|
179
|
-
//
|
|
180
|
-
// disagrees. Kept separate from
|
|
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
|
-
//
|
|
203
|
-
//
|
|
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
|
-
//
|
|
220
|
-
//
|
|
221
|
-
//
|
|
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
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
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
|
|
288
|
-
//
|
|
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
|
|
297
|
-
//
|
|
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
|
|
312
|
-
//
|
|
313
|
-
//
|
|
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
|
-
//
|
|
324
|
-
//
|
|
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
|
-
|
|
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
|
|
377
|
-
//
|
|
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
|
|
381
|
-
// (
|
|
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
|
-
//
|
|
419
|
-
//
|
|
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
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
//
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
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
|
|
53
|
-
// bare `import './register'
|
|
54
|
-
//
|
|
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
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
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
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
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;
|