@symbiote-native/components 0.5.0 → 2.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 (88) hide show
  1. package/README.md +23 -10
  2. package/build/accessibility-props.d.ts +11 -0
  3. package/build/accessibility-props.js +30 -118
  4. package/build/behaviors/activity-indicator/index.android.d.ts +1 -0
  5. package/build/behaviors/activity-indicator/index.android.js +16 -0
  6. package/build/behaviors/activity-indicator/index.d.ts +3 -0
  7. package/build/behaviors/activity-indicator/index.ios.d.ts +1 -0
  8. package/build/behaviors/activity-indicator/index.ios.js +14 -0
  9. package/build/behaviors/activity-indicator/index.js +5 -0
  10. package/build/behaviors/activity-indicator/shared.d.ts +18 -0
  11. package/build/behaviors/activity-indicator/shared.js +149 -0
  12. package/build/behaviors/button.d.ts +2 -0
  13. package/build/behaviors/button.js +328 -0
  14. package/build/behaviors/image-background.d.ts +2 -0
  15. package/build/behaviors/image-background.js +139 -0
  16. package/build/behaviors/image.d.ts +3 -0
  17. package/build/behaviors/image.js +123 -0
  18. package/build/behaviors/input-accessory-view.d.ts +3 -0
  19. package/build/behaviors/input-accessory-view.js +70 -0
  20. package/build/behaviors/pressable.d.ts +60 -0
  21. package/build/behaviors/pressable.js +385 -0
  22. package/build/behaviors/refresh-control.d.ts +2 -0
  23. package/build/behaviors/refresh-control.js +83 -0
  24. package/build/behaviors/scroll-view/index.android.d.ts +1 -0
  25. package/build/behaviors/scroll-view/index.android.js +52 -0
  26. package/build/behaviors/scroll-view/index.d.ts +2 -0
  27. package/build/behaviors/scroll-view/index.ios.d.ts +1 -0
  28. package/build/behaviors/scroll-view/index.ios.js +10 -0
  29. package/build/behaviors/scroll-view/index.js +5 -0
  30. package/build/behaviors/scroll-view/shared.d.ts +11 -0
  31. package/build/behaviors/scroll-view/shared.js +291 -0
  32. package/build/behaviors/scroll-view/sticky.d.ts +17 -0
  33. package/build/behaviors/scroll-view/sticky.js +568 -0
  34. package/build/behaviors/switch.d.ts +2 -0
  35. package/build/behaviors/switch.js +186 -0
  36. package/build/behaviors/text-input.d.ts +14 -0
  37. package/build/behaviors/text-input.js +319 -0
  38. package/build/behaviors/touchable-highlight.d.ts +9 -0
  39. package/build/behaviors/touchable-highlight.js +192 -0
  40. package/build/behaviors/touchable-native-feedback.d.ts +20 -0
  41. package/build/behaviors/touchable-native-feedback.js +333 -0
  42. package/build/behaviors/touchable-opacity.d.ts +12 -0
  43. package/build/behaviors/touchable-opacity.js +227 -0
  44. package/build/behaviors/touchable-without-feedback.d.ts +2 -0
  45. package/build/behaviors/touchable-without-feedback.js +296 -0
  46. package/build/component-names/index.android.js +47 -15
  47. package/build/component-names/index.ios.js +35 -15
  48. package/build/component-names/shared.d.ts +2 -1
  49. package/build/component-names/shared.js +49 -3
  50. package/build/descriptor.js +4 -4
  51. package/build/fold-host-bag.d.ts +15 -0
  52. package/build/fold-host-bag.js +99 -0
  53. package/build/index.d.ts +23 -12
  54. package/build/index.js +50 -9
  55. package/build/register.d.ts +1 -0
  56. package/build/register.js +55 -0
  57. package/build/resolve-intrinsic.d.ts +7 -0
  58. package/build/resolve-intrinsic.js +49 -0
  59. package/build/scroll-view-commands.d.ts +4 -0
  60. package/build/scroll-view-commands.js +30 -31
  61. package/build/state/pressable.d.ts +9 -0
  62. package/build/state/pressable.js +120 -34
  63. package/build/state/text-input.d.ts +10 -2
  64. package/build/state/text-input.js +11 -0
  65. package/build/text-props.js +2 -1
  66. package/build/view/render-button.d.ts +36 -1
  67. package/build/view/render-button.js +101 -12
  68. package/build/view/render-image/index.d.ts +2 -0
  69. package/build/view/render-image/index.js +29 -2
  70. package/build/view/render-input-accessory-view.d.ts +2 -0
  71. package/build/view/render-input-accessory-view.js +37 -5
  72. package/build/view/render-modal.js +3 -3
  73. package/build/view/render-pressable/index.d.ts +2 -0
  74. package/build/view/render-pressable/index.js +24 -0
  75. package/build/view/render-scroll-view.d.ts +1 -0
  76. package/build/view/render-scroll-view.js +18 -9
  77. package/build/view/render-switch.d.ts +4 -1
  78. package/build/view/render-switch.js +8 -2
  79. package/build/view/render-text-input.js +6 -2
  80. package/build/view/render-touchable-native-feedback.d.ts +19 -0
  81. package/build/view/render-touchable-native-feedback.js +19 -0
  82. package/host-primitives.cjs +432 -0
  83. package/host-primitives.d.cts +33 -0
  84. package/package.json +33 -5
  85. package/build/view/render-activity-indicator.d.ts +0 -25
  86. package/build/view/render-activity-indicator.js +0 -62
  87. package/build/view/render-image-background.d.ts +0 -9
  88. package/build/view/render-image-background.js +0 -48
@@ -0,0 +1,186 @@
1
+ // Switch's machine, on the engine node instead of inside a framework component — the second
2
+ // STATEFUL primitive after TextInput (`.claude/rules/host-primitive-tier.md`, "cheapest: tag
3
+ // exists, no handle. lastNativeReport -> dispatchViewCommand").
4
+ //
5
+ // WHAT A `Switch` COMPONENT ACTUALLY DOES, and why none of it needs a framework. It mirrors the
6
+ // value native LAST REPORTED (not the value the app authors), and when the two disagree — the app
7
+ // rejected the toggle, or its handler is a no-op — it commands native back down with the
8
+ // platform's snap-back command (Switch.js:221-225: iOS `setValue`, Android `setNativeValue`). The
9
+ // TEMPLATE reads none of it.
10
+ //
11
+ // WHY THE DIVERGENCE CHECK IS DEFERRED A MICROTASK, NOT RUN SYNCHRONOUSLY INSIDE `onChange`. The
12
+ // obvious place to compare "what native just reported" against "what the app currently authors" is
13
+ // right where the report arrives. It is wrong: an ACCEPTED toggle updates the app's own state, and
14
+ // that state reaches `node.props.value` only once the app's OWN reconciliation runs — which, for
15
+ // every adapter here, happens strictly after `onChange` returns, never during it. Checking
16
+ // synchronously would read the STALE pre-accept value and send a spurious snap-back on every
17
+ // accepted toggle. The wrapper avoids this by running its own check from an effect that fires AFTER
18
+ // the app's state update has flowed into a new render (`useLayoutEffect`, `$effect`, a post-flush
19
+ // `watch`); Angular's OWN switch component (`adapters/angular/src/components/switch/shared.ts`,
20
+ // `snapBackIfNeeded`) already solves the identical problem for a node with no render the same way —
21
+ // `queueMicrotask` then a plain read of the current value — and this mirrors it rather than routing
22
+ // through `IHostBehavior.afterCommit`.
23
+ //
24
+ // `afterCommit` was the first design and it does NOT work here: it requires a commit that actually
25
+ // changes something to reach `runDeferredAttaches` at all (`commit.ts`'s `!result.changed` early
26
+ // return — the same gate `text-input.test.ts` documents), and this check on its own writes no prop.
27
+ // Pairing it with `requestCommitFor` — which schedules a commit but writes nothing either — hits
28
+ // exactly that early return and never fires; proven wrong by this file's own test before it shipped.
29
+ // `queueMicrotask` sidesteps the whole question: it needs no commit, only a later turn of the
30
+ // microtask queue, and `dispatchViewCommand` is an imperative call to native independent of Fabric's
31
+ // prop-commit pipeline anyway.
32
+ //
33
+ // The residual gap this leaves, and it is why `afterCommit` is STILL registered below: a check
34
+ // scheduled only from `onChange` never re-runs for a prop change with no preceding native event —
35
+ // e.g. the app moves `value` on its own initiative while a PAST toggle's disagreement is still
36
+ // unresolved. `afterCommit` costs nothing extra (it fires only on a commit that already changed
37
+ // something) and closes that one case; the microtask path is what the no-op-handler case actually
38
+ // needs.
39
+ import { appListenerFor, dispatchViewCommand, dlog, Platform, registerHostBehavior, setBehaviorListener, } from '@symbiote-native/engine';
40
+ import { createInitialSwitchState, shouldSnapBack, switchReducer, valueFromChange, } from '../state/switch.js';
41
+ // The LOWERED tag — NOT the wrapper's `switch-managed` (`render-switch.ts`). One owner
42
+ // per node: the wrapper already runs this same machine in its own lifecycle, so registering here
43
+ // under the tag it emits would attach a second, redundant copy.
44
+ export const SWITCH_TAG = 'switch';
45
+ const states = new WeakMap();
46
+ function stateOf(node) {
47
+ return states.get(node);
48
+ }
49
+ function stringOf(value) {
50
+ return typeof value === 'string' ? value : undefined;
51
+ }
52
+ function booleanOf(value) {
53
+ return typeof value === 'boolean' ? value : undefined;
54
+ }
55
+ // `{ false?, true? }` narrowed at runtime — an authored object arriving through an untyped bag
56
+ // cannot be trusted to the type system without reading it field by field, the same idiom
57
+ // `text-input.ts`'s `selectionOf` uses for `{ start, end? }`.
58
+ function trackColorOf(value) {
59
+ if (typeof value !== 'object' || value === null)
60
+ return undefined;
61
+ const bag = { ...value };
62
+ const falseColor = stringOf(bag.false);
63
+ const trueColor = stringOf(bag.true);
64
+ if (falseColor === undefined && trueColor === undefined)
65
+ return undefined;
66
+ return { false: falseColor, true: trueColor };
67
+ }
68
+ // The wrapper-body prop fold this primitive owes its lowered form: `trackColor` / `thumbColor` /
69
+ // `ios_backgroundColor` are AUTHORED names, none of them a real Fabric prop — RN's Switch view
70
+ // declares `onTintColor`/`tintColor` (iOS) or `trackColorFor*`/`trackTintColor` (Android), plus
71
+ // `thumbTintColor`. `render-switch.ts` folds them for the wrapper path via an adapter-supplied
72
+ // `ISwitchPlatform`; a lowered node has no adapter to supply one, so this reads `Platform.OS`
73
+ // directly — the same fact every adapter's own index.ios.ts/index.android.ts already encodes as a
74
+ // literal, just read once here instead of five times.
75
+ function trackColorPropsFor(value, trackColor) {
76
+ if (Platform.OS === 'android') {
77
+ return {
78
+ trackColorForFalse: trackColor?.false,
79
+ trackColorForTrue: trackColor?.true,
80
+ trackTintColor: value ? trackColor?.true : trackColor?.false,
81
+ };
82
+ }
83
+ return {
84
+ onTintColor: trackColor?.true,
85
+ tintColor: trackColor?.false,
86
+ };
87
+ }
88
+ // RN rounds the iOS background pill to this radius when `ios_backgroundColor` is set — the same
89
+ // constant `render-switch.ts` uses, kept independent rather than exported+imported for one
90
+ // primitive-local literal (see that file for the upstream fact it encodes).
91
+ const IOS_BACKGROUND_BORDER_RADIUS = 16;
92
+ // The platform-specific imperative command RN's own Switch sends to correct a rejected toggle
93
+ // (Switch.js:221-225) — read off Platform.OS for the same reason `trackColorPropsFor` is.
94
+ function snapBackCommand() {
95
+ return Platform.OS === 'android' ? 'setNativeValue' : 'setValue';
96
+ }
97
+ function foldPayload(props) {
98
+ const value = props.value === true;
99
+ const trackColor = trackColorOf(props.trackColor);
100
+ const iosBackground = stringOf(props.ios_backgroundColor);
101
+ const out = {
102
+ ...props,
103
+ value,
104
+ disabled: booleanOf(props.disabled),
105
+ ...trackColorPropsFor(value, trackColor),
106
+ thumbTintColor: stringOf(props.thumbColor),
107
+ style: iosBackground === undefined
108
+ ? props.style
109
+ : [
110
+ props.style,
111
+ {
112
+ backgroundColor: iosBackground,
113
+ borderRadius: IOS_BACKGROUND_BORDER_RADIUS,
114
+ },
115
+ ],
116
+ };
117
+ // The authored names themselves must NOT ride along: none is a Fabric prop, and leaving them in
118
+ // the payload is how a reader concludes the fold ran when it did not.
119
+ delete out.trackColor;
120
+ delete out.thumbColor;
121
+ delete out.ios_backgroundColor;
122
+ return out;
123
+ }
124
+ // Shared by both triggers — see the module header for why there are two.
125
+ function evaluateSnapBack(node) {
126
+ const state = stateOf(node);
127
+ if (state === undefined)
128
+ return; // detached before this ran
129
+ const fabricValue = node.props.value === true;
130
+ if (!shouldSnapBack(state, fabricValue)) {
131
+ dlog(`Switch behavior snap-back no-op reported=${String(state.lastNativeReport)} value=${fabricValue}`);
132
+ return;
133
+ }
134
+ dlog(`Switch behavior ${snapBackCommand()} snap-back reported=${String(state.lastNativeReport)} value=${fabricValue}`);
135
+ dispatchViewCommand(node, snapBackCommand(), [fabricValue]);
136
+ }
137
+ function onChange(node, event) {
138
+ const state = stateOf(node);
139
+ if (state === undefined)
140
+ return;
141
+ const value = valueFromChange(event);
142
+ if (value === undefined)
143
+ return;
144
+ dlog(`Switch behavior onChange value=${String(value)} eventCount=${String(event.nativeEvent.eventCount)}`);
145
+ states.set(node, switchReducer(state, { type: 'native-reported', value }));
146
+ // `onValueChange` is not a Fabric event — it is a fold the component wrapper does over the raw
147
+ // `change` payload (same class as TextInput's `onValueChange`, `text-input.ts`'s
148
+ // `callValueChange`), so it lands in `node.props` as a plain function key rather than through
149
+ // `ownedListeners`. ONE argument, `value` carried on the event (`ISwitchChangeEvent`) — same
150
+ // reason as TextInput: Svelte's compiler forces an individual `on*` attribute through a native
151
+ // listener wrapper that calls with exactly one argument, always a real object.
152
+ const listener = node.props.onValueChange;
153
+ if (typeof listener === 'function') {
154
+ const changeEvent = Object.assign(event, { value });
155
+ listener(changeEvent);
156
+ }
157
+ // A raw `change` listener authored directly on the bare tag — not part of any adapter's public
158
+ // surface today, but `change` is the name this behavior's own dispatcher owns
159
+ // (`ownedListeners` below), so one is stashed rather than silently evicting the machine.
160
+ const rawListener = appListenerFor(node, 'change');
161
+ if (typeof rawListener === 'function')
162
+ rawListener(event);
163
+ // See the module header: deferred so an ACCEPTING app's own state update has a turn of the
164
+ // microtask queue to reach `node.props.value` first.
165
+ queueMicrotask(() => evaluateSnapBack(node));
166
+ }
167
+ function attach(node) {
168
+ states.set(node, createInitialSwitchState());
169
+ setBehaviorListener(node, 'change', event => onChange(node, event));
170
+ }
171
+ function detach(node) {
172
+ states.delete(node);
173
+ }
174
+ // Idempotent: an adapter entry may be imported more than once in a bundle, and re-registering the
175
+ // same tag with an equivalent behavior must not double-install anything.
176
+ export function registerSwitchBehavior() {
177
+ registerHostBehavior(SWITCH_TAG, {
178
+ attach,
179
+ // See the module header: closes the residual case a microtask scheduled only from `onChange`
180
+ // cannot — a prop change with no preceding native event.
181
+ afterCommit: evaluateSnapBack,
182
+ detach,
183
+ foldPayload,
184
+ ownedListeners: ['change'],
185
+ });
186
+ }
@@ -0,0 +1,14 @@
1
+ import { type ISymbioteNode } from '@symbiote-native/engine';
2
+ import { type ITextInputHandle } from '../state/text-input';
3
+ export declare const TEXT_INPUT_TAG = "text-input";
4
+ export declare const TEXT_INPUT_MULTILINE_TAG = "text-input-multiline";
5
+ /**
6
+ * The imperative API RN exposes on a TextInput ref, built over the engine node. Reached through
7
+ * each adapter's own `host-instance` accessor — the capability, not a shape
8
+ * (`.claude/rules/adapter-parity-audit.md`).
9
+ *
10
+ * `focus`/`blur` are native view commands; `clear` and `setSelection` reuse `setTextAndSelection`,
11
+ * the same stale-safe path a controlled write takes, so they cannot race a keystroke either.
12
+ */
13
+ export declare function buildTextInputHandle(node: ISymbioteNode): ITextInputHandle;
14
+ export declare function registerTextInputBehavior(): void;
@@ -0,0 +1,319 @@
1
+ // TextInput's machine, on the engine node instead of inside a framework component — the tier-2
2
+ // half of `.claude/rules/host-primitive-tier.md` for the second primitive to get one.
3
+ //
4
+ // WHAT A `TextInput` COMPONENT ACTUALLY DOES, and why none of it needs a framework. It holds three
5
+ // mirrors of native state (the acknowledged event count, the last text native reported, whether the
6
+ // input is focused), it commands text back down when the app's `value` diverges from that mirror,
7
+ // it fires `focus` once at mount when `autoFocus` is set, 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
9
+ // instance for a machine that only ever needed a per-node home.
10
+ //
11
+ // WHY IT NEEDED A NEW ENGINE HOOK AND `Pressable` DID NOT. A press machine is driven entirely by
12
+ // events, which arrive long after commit. The controlled handshake is driven by a PROP: `value`
13
+ // 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 —
15
+ // see that interface for why it is not a hook on `setProp`.
16
+ //
17
+ // THE ORDER OF THE TWO COMMIT HOOKS IS LOAD-BEARING HERE, which is why the engine pins it with a
18
+ // test: `attachAfterCommit` seeds `lastNativeText` from the mount-time props, and `afterCommit`
19
+ // compares against that seed. Reversed, the very first beat would see an empty mirror, decide the
20
+ // app's value had diverged, and command a redundant `setTextAndSelection` down to native on every
21
+ // 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.
27
+ export const TEXT_INPUT_TAG = 'text-input';
28
+ export const TEXT_INPUT_MULTILINE_TAG = 'text-input-multiline';
29
+ const states = new WeakMap();
30
+ function stateOf(node) {
31
+ return states.get(node);
32
+ }
33
+ function stringProp(node, key) {
34
+ const value = node.props[key];
35
+ return typeof value === 'string' ? value : undefined;
36
+ }
37
+ // `{ start, end? }` narrowed at runtime. `end` defaults to `start` — a caret, RN's own reading when
38
+ // only one bound is given — and both fall back to SELECTION_NONE when the prop is absent.
39
+ function selectionOf(value) {
40
+ if (typeof value !== 'object' || value === null) {
41
+ return { start: SELECTION_NONE, end: SELECTION_NONE };
42
+ }
43
+ const bag = { ...value };
44
+ const start = typeof bag.start === 'number' ? bag.start : SELECTION_NONE;
45
+ const end = typeof bag.end === 'number' ? bag.end : start;
46
+ return { start, end };
47
+ }
48
+ // 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
50
+ // beside the machine's instead of overwriting it.
51
+ function callAppListener(node, name, event) {
52
+ const listener = appListenerFor(node, name);
53
+ if (typeof listener === 'function')
54
+ listener(event);
55
+ }
56
+ // `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".
62
+ //
63
+ // 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.
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.
72
+ function callValueChange(node, text, event) {
73
+ const listener = node.props.onValueChange;
74
+ if (typeof listener !== 'function')
75
+ return;
76
+ const changeEvent = Object.assign(event, { text });
77
+ listener(changeEvent);
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
+ }
101
+ // `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:
106
+ //
107
+ // <text-input-multiline value="a" /> RCTMultilineTextInputView, folded as SINGLE-line:
108
+ // submitBehavior 'blurAndSubmit', so Return blurs instead
109
+ // of inserting a newline
110
+ // <text-input multiline value="b" /> RCTSinglelineTextInputView carrying the multiline fold
111
+ //
112
+ // So the tag is the authority here, and a prop that contradicts it throws rather than being
113
+ // quietly overridden — an ignored `multiline` is a wrong native view with nothing to read.
114
+ // Found on Solid, fixed here because all five adapters produce the same two divergences: a
115
+ // decision the wrapper used to make by CONSUMING a prop has no owner once the author writes the
116
+ // tag directly.
117
+ // The COMPLAINT cannot live here, only the correction. `foldPayload` runs inside the commit, so a
118
+ // throw from it surfaces as an uncaught exception a tick after the author's write with no frame
119
+ // naming the call site — measured: a test awaiting the mount sees `nothing committed` instead of
120
+ // the error. Refusing a contradicting prop therefore stays in each adapter's own prop-write path,
121
+ // where the author's stack still exists (Solid's `renderer.ts` is the reference); this file only
122
+ // 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
+ }
149
+ function onChange(node, event) {
150
+ const state = stateOf(node);
151
+ if (state === undefined)
152
+ return;
153
+ const text = textFromChange(event);
154
+ if (text !== undefined) {
155
+ // Ordering matches the component path exactly: record the mirror, then hand the app its text.
156
+ state.lastNativeText = text;
157
+ callValueChange(node, text, event);
158
+ }
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
161
+ // acknowledgement native has not actually given.
162
+ const count = eventCountFromChange(event);
163
+ if (count !== undefined) {
164
+ state.mostRecentEventCount = count;
165
+ // Native READS this prop, so the mirror is not enough — it has to reach the payload. Same shape
166
+ // as the press machine's `setNodePressed` + `requestCommitFor`: a behavior writing a prop owes
167
+ // the commit, because nothing else is going to ask for one.
168
+ setProp(node, 'mostRecentEventCount', count);
169
+ requestCommitFor(node);
170
+ }
171
+ callAppListener(node, 'change', event);
172
+ }
173
+ function onFocus(node, event) {
174
+ const state = stateOf(node);
175
+ if (state !== undefined)
176
+ state.isFocused = true;
177
+ // App-wide, so `Keyboard.dismiss()` can blur this input without holding a ref to it.
178
+ setInputFocused(node);
179
+ callAppListener(node, 'focus', event);
180
+ }
181
+ function onBlur(node, event) {
182
+ const state = stateOf(node);
183
+ if (state !== undefined)
184
+ state.isFocused = false;
185
+ setInputBlurred(node);
186
+ callAppListener(node, 'blur', event);
187
+ }
188
+ function attach(node) {
189
+ states.set(node, {
190
+ mostRecentEventCount: INITIAL_EVENT_COUNT,
191
+ lastNativeText: undefined,
192
+ isFocused: false,
193
+ });
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.
199
+ //
200
+ // No `requestCommitFor` here: at create the renderer commits anyway, and on a re-attach the key is
201
+ // already standing at this same value, so `setProp`'s identity guard makes the write a no-op.
202
+ setProp(node, 'mostRecentEventCount', INITIAL_EVENT_COUNT);
203
+ setBehaviorListener(node, 'change', event => onChange(node, event));
204
+ setBehaviorListener(node, 'focus', event => onFocus(node, event));
205
+ setBehaviorListener(node, 'blur', event => onBlur(node, event));
206
+ }
207
+ // The first commit is the earliest point where the node has BOTH its props and a Fabric tag. The
208
+ // mirror needs the first, `autoFocus` needs the second.
209
+ function attachAfterCommit(node) {
210
+ const state = stateOf(node);
211
+ if (state === undefined)
212
+ return;
213
+ state.lastNativeText = foldText(stringProp(node, 'value'), stringProp(node, 'defaultValue'));
214
+ if (node.props.autoFocus !== true)
215
+ 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.
219
+ dlog('TextInput behavior: autoFocus -> focus command');
220
+ dispatchViewCommand(node, 'focus', []);
221
+ }
222
+ // The controlled handshake. A plain prop re-push would race the user's keystrokes — native may have
223
+ // text JS has not seen yet — so the command carrying the acknowledged count is the only stale-safe
224
+ // path, and `shouldCommandText` is what keeps it a no-op unless the value genuinely diverged.
225
+ function afterCommit(node) {
226
+ const state = stateOf(node);
227
+ if (state === undefined)
228
+ return;
229
+ const value = stringProp(node, 'value');
230
+ if (!shouldCommandText(state.lastNativeText, value))
231
+ return;
232
+ // `selection` is `{ start, end? }` when present. SELECTION_NONE (-1) is RN's "leave the cursor
233
+ // 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);
236
+ dlog(`TextInput behavior: setTextAndSelection count=${state.mostRecentEventCount} ` +
237
+ `text=${JSON.stringify(value)}`);
238
+ dispatchViewCommand(node, 'setTextAndSelection', [
239
+ state.mostRecentEventCount,
240
+ value,
241
+ start,
242
+ end,
243
+ ]);
244
+ state.lastNativeText = value;
245
+ }
246
+ function detach(node) {
247
+ states.delete(node);
248
+ }
249
+ /**
250
+ * The imperative API RN exposes on a TextInput ref, built over the engine node. Reached through
251
+ * each adapter's own `host-instance` accessor — the capability, not a shape
252
+ * (`.claude/rules/adapter-parity-audit.md`).
253
+ *
254
+ * `focus`/`blur` are native view commands; `clear` and `setSelection` reuse `setTextAndSelection`,
255
+ * the same stale-safe path a controlled write takes, so they cannot race a keystroke either.
256
+ */
257
+ export function buildTextInputHandle(node) {
258
+ return {
259
+ // Forwarded, not re-implemented: these are the engine node's own prototype methods, and a
260
+ // TextInput ref that lacks them is poorer than every other host ref for no reason. See
261
+ // `ITextInputHandle` for why the handle is a UNION rather than the five below.
262
+ measure: callback => node.measure(callback),
263
+ measureInWindow: callback => node.measureInWindow(callback),
264
+ measureLayout: (relativeTo, onSuccess, onFail) => node.measureLayout(relativeTo, onSuccess, onFail),
265
+ setNativeProps: nativeProps => node.setNativeProps(nativeProps),
266
+ focus: () => dispatchViewCommand(node, 'focus', []),
267
+ // Through TextInputState, NOT a raw command — the same route the component path takes
268
+ // (`react/.../text-input/index.ts`, "so the app-wide focus tracking clears too"). The native
269
+ // `blur` event also clears the tracking via this behavior's own listener, so a raw command
270
+ // looks equivalent and is not: the event is the NATIVE side's, and it does not arrive when the
271
+ // input was already blurred. `Keyboard.dismiss()` reads `currentlyFocusedInput()`, so a stale
272
+ // entry there aims a blur at a node that no longer holds focus.
273
+ blur: () => blurTextInput(node),
274
+ isFocused: () => stateOf(node)?.isFocused === true,
275
+ clear: () => {
276
+ const state = stateOf(node);
277
+ if (state === undefined)
278
+ return;
279
+ dispatchViewCommand(node, 'setTextAndSelection', [
280
+ state.mostRecentEventCount,
281
+ '',
282
+ 0,
283
+ 0,
284
+ ]);
285
+ state.lastNativeText = '';
286
+ },
287
+ setSelection: (start, end) => {
288
+ const state = stateOf(node);
289
+ if (state === undefined)
290
+ return;
291
+ // The CURRENT text, not the app's `value`: a selection move must not also rewrite the text,
292
+ // and native discards a command whose text disagrees with what it holds.
293
+ dispatchViewCommand(node, 'setTextAndSelection', [
294
+ state.mostRecentEventCount,
295
+ state.lastNativeText,
296
+ start,
297
+ end,
298
+ ]);
299
+ },
300
+ };
301
+ }
302
+ // Idempotent: an adapter entry may be imported more than once in a bundle, and re-registering the
303
+ // same tag with an equivalent behavior must not double-install anything.
304
+ export function registerTextInputBehavior() {
305
+ const behaviorFor = (isMultilineTag) => ({
306
+ attach,
307
+ attachAfterCommit,
308
+ afterCommit,
309
+ 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));
319
+ }
@@ -0,0 +1,9 @@
1
+ import { type IHostBehavior } from '@symbiote-native/engine';
2
+ import { type IDisabledResolver } from './pressable';
3
+ export declare const TOUCHABLE_HIGHLIGHT_TAG = "touchable-highlight";
4
+ /**
5
+ * The behavior as PARTS, mirroring `./touchable-opacity`'s shape — a tag that is a
6
+ * TouchableHighlight plus something composes this instead of re-implementing the underlay.
7
+ */
8
+ export declare function createTouchableHighlightBehavior(disabledOf?: IDisabledResolver): IHostBehavior;
9
+ export declare function registerTouchableHighlightBehavior(): void;