@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.
- package/README.md +23 -10
- package/build/accessibility-props.d.ts +11 -0
- package/build/accessibility-props.js +30 -118
- package/build/behaviors/activity-indicator/index.android.d.ts +1 -0
- package/build/behaviors/activity-indicator/index.android.js +16 -0
- package/build/behaviors/activity-indicator/index.d.ts +3 -0
- package/build/behaviors/activity-indicator/index.ios.d.ts +1 -0
- package/build/behaviors/activity-indicator/index.ios.js +14 -0
- package/build/behaviors/activity-indicator/index.js +5 -0
- package/build/behaviors/activity-indicator/shared.d.ts +18 -0
- package/build/behaviors/activity-indicator/shared.js +149 -0
- package/build/behaviors/button.d.ts +2 -0
- package/build/behaviors/button.js +328 -0
- package/build/behaviors/image-background.d.ts +2 -0
- package/build/behaviors/image-background.js +139 -0
- package/build/behaviors/image.d.ts +3 -0
- package/build/behaviors/image.js +123 -0
- package/build/behaviors/input-accessory-view.d.ts +3 -0
- package/build/behaviors/input-accessory-view.js +70 -0
- package/build/behaviors/pressable.d.ts +60 -0
- package/build/behaviors/pressable.js +385 -0
- package/build/behaviors/refresh-control.d.ts +2 -0
- package/build/behaviors/refresh-control.js +83 -0
- package/build/behaviors/scroll-view/index.android.d.ts +1 -0
- package/build/behaviors/scroll-view/index.android.js +52 -0
- package/build/behaviors/scroll-view/index.d.ts +2 -0
- package/build/behaviors/scroll-view/index.ios.d.ts +1 -0
- package/build/behaviors/scroll-view/index.ios.js +10 -0
- package/build/behaviors/scroll-view/index.js +5 -0
- package/build/behaviors/scroll-view/shared.d.ts +11 -0
- package/build/behaviors/scroll-view/shared.js +291 -0
- package/build/behaviors/scroll-view/sticky.d.ts +17 -0
- package/build/behaviors/scroll-view/sticky.js +568 -0
- package/build/behaviors/switch.d.ts +2 -0
- package/build/behaviors/switch.js +186 -0
- package/build/behaviors/text-input.d.ts +14 -0
- package/build/behaviors/text-input.js +319 -0
- package/build/behaviors/touchable-highlight.d.ts +9 -0
- package/build/behaviors/touchable-highlight.js +192 -0
- package/build/behaviors/touchable-native-feedback.d.ts +20 -0
- package/build/behaviors/touchable-native-feedback.js +333 -0
- package/build/behaviors/touchable-opacity.d.ts +12 -0
- package/build/behaviors/touchable-opacity.js +227 -0
- package/build/behaviors/touchable-without-feedback.d.ts +2 -0
- package/build/behaviors/touchable-without-feedback.js +296 -0
- package/build/component-names/index.android.js +47 -15
- package/build/component-names/index.ios.js +35 -15
- package/build/component-names/shared.d.ts +2 -1
- package/build/component-names/shared.js +49 -3
- package/build/descriptor.js +4 -4
- package/build/fold-host-bag.d.ts +15 -0
- package/build/fold-host-bag.js +99 -0
- package/build/index.d.ts +23 -12
- package/build/index.js +50 -9
- package/build/register.d.ts +1 -0
- package/build/register.js +55 -0
- package/build/resolve-intrinsic.d.ts +7 -0
- package/build/resolve-intrinsic.js +49 -0
- package/build/scroll-view-commands.d.ts +4 -0
- package/build/scroll-view-commands.js +30 -31
- package/build/state/pressable.d.ts +9 -0
- package/build/state/pressable.js +120 -34
- package/build/state/text-input.d.ts +10 -2
- package/build/state/text-input.js +11 -0
- package/build/text-props.js +2 -1
- package/build/view/render-button.d.ts +36 -1
- package/build/view/render-button.js +101 -12
- package/build/view/render-image/index.d.ts +2 -0
- package/build/view/render-image/index.js +29 -2
- package/build/view/render-input-accessory-view.d.ts +2 -0
- package/build/view/render-input-accessory-view.js +37 -5
- package/build/view/render-modal.js +3 -3
- package/build/view/render-pressable/index.d.ts +2 -0
- package/build/view/render-pressable/index.js +24 -0
- package/build/view/render-scroll-view.d.ts +1 -0
- package/build/view/render-scroll-view.js +18 -9
- package/build/view/render-switch.d.ts +4 -1
- package/build/view/render-switch.js +8 -2
- package/build/view/render-text-input.js +6 -2
- package/build/view/render-touchable-native-feedback.d.ts +19 -0
- package/build/view/render-touchable-native-feedback.js +19 -0
- package/host-primitives.cjs +432 -0
- package/host-primitives.d.cts +33 -0
- package/package.json +33 -5
- package/build/view/render-activity-indicator.d.ts +0 -25
- package/build/view/render-activity-indicator.js +0 -62
- package/build/view/render-image-background.d.ts +0 -9
- 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;
|