@symbiote-native/components 0.4.0 → 1.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 (67) hide show
  1. package/README.md +30 -8
  2. package/build/accessibility-props.d.ts +11 -0
  3. package/build/accessibility-props.js +30 -117
  4. package/build/behaviors/image.d.ts +3 -0
  5. package/build/behaviors/image.js +123 -0
  6. package/build/behaviors/input-accessory-view.d.ts +3 -0
  7. package/build/behaviors/input-accessory-view.js +70 -0
  8. package/build/behaviors/pressable.d.ts +2 -0
  9. package/build/behaviors/pressable.js +310 -0
  10. package/build/behaviors/switch.d.ts +2 -0
  11. package/build/behaviors/switch.js +182 -0
  12. package/build/behaviors/text-input.d.ts +14 -0
  13. package/build/behaviors/text-input.js +291 -0
  14. package/build/bootstrap/index.js +1 -1
  15. package/build/component-names/index.android.js +23 -1
  16. package/build/component-names/index.ios.js +9 -1
  17. package/build/component-names/shared.d.ts +1 -1
  18. package/build/component-names/shared.js +40 -1
  19. package/build/descriptor.d.ts +8 -0
  20. package/build/descriptor.js +27 -0
  21. package/build/fold-host-bag.d.ts +15 -0
  22. package/build/fold-host-bag.js +99 -0
  23. package/build/index.d.ts +25 -12
  24. package/build/index.js +18 -6
  25. package/build/resolve-intrinsic.d.ts +7 -0
  26. package/build/resolve-intrinsic.js +49 -0
  27. package/build/state/pressable.d.ts +9 -0
  28. package/build/state/pressable.js +133 -37
  29. package/build/state/sticky-header-reducer.js +28 -5
  30. package/build/state/switch.js +1 -1
  31. package/build/state/text-input.d.ts +7 -1
  32. package/build/state/text-input.js +46 -8
  33. package/build/state/touchable.d.ts +30 -1
  34. package/build/state/touchable.js +94 -5
  35. package/build/state/virtualized-list-diagnostics.d.ts +31 -0
  36. package/build/state/virtualized-list-diagnostics.js +33 -0
  37. package/build/state/virtualized-list-reducer.d.ts +14 -0
  38. package/build/state/virtualized-list-reducer.js +179 -45
  39. package/build/state/virtualized-list.d.ts +5 -2
  40. package/build/state/virtualized-list.js +143 -34
  41. package/build/state-style.d.ts +15 -0
  42. package/build/state-style.js +47 -0
  43. package/build/text-props.d.ts +9 -0
  44. package/build/text-props.js +25 -0
  45. package/build/view/render-activity-indicator.js +37 -3
  46. package/build/view/render-image/index.d.ts +2 -0
  47. package/build/view/render-image/index.js +42 -5
  48. package/build/view/render-input-accessory-view.d.ts +2 -0
  49. package/build/view/render-input-accessory-view.js +41 -6
  50. package/build/view/render-keyboard-avoiding-view.d.ts +14 -2
  51. package/build/view/render-keyboard-avoiding-view.js +52 -6
  52. package/build/view/render-modal.js +4 -2
  53. package/build/view/render-pressable/index.js +3 -1
  54. package/build/view/render-scroll-sticky.js +1 -1
  55. package/build/view/render-scroll-view.js +9 -3
  56. package/build/view/render-switch.js +11 -2
  57. package/build/view/render-text-input.js +6 -2
  58. package/build/view/render-touchable-highlight.d.ts +11 -1
  59. package/build/view/render-touchable-highlight.js +11 -10
  60. package/build/view/render-touchable-native-feedback.js +5 -1
  61. package/host-primitives.cjs +380 -0
  62. package/host-primitives.d.cts +35 -0
  63. package/lowering-fixtures.cjs +259 -0
  64. package/lowering-fixtures.d.cts +17 -0
  65. package/package.json +42 -5
  66. package/specialize-state-style.cjs +219 -0
  67. package/specialize-state-style.d.cts +15 -0
@@ -0,0 +1,310 @@
1
+ // The press machine as an ENGINE-NODE behavior, so a pressable can be an intrinsic tag instead of
2
+ // a framework component (`.claude/rules/host-primitive-tier.md`, tier 2). Written once; every
3
+ // adapter inherits it by registering, and none re-implements it.
4
+ //
5
+ // The machine itself is unchanged and still shared with the component path — `createPressRuntime`
6
+ // / `createPressHandlers` in `../state/pressable`. What is new is only WHERE its lifecycle lives:
7
+ // on the engine node rather than in a component instance.
8
+ //
9
+ // REGISTRATION IS THE HAZARD, not the machine. Metro enables `inlineRequires` in production only,
10
+ // moving a `require` to the first place its binding is used as a VALUE, and a barrel's
11
+ // `export { X } from './x'` compiles to a lazy getter. A module whose only job is a side effect is
12
+ // never named as a value, so re-exporting it means it NEVER RUNS in a release build — dev perfect,
13
+ // release silently pressless. Each adapter therefore keeps its own `src/register.ts` calling
14
+ // `registerPressableBehavior()`, and its entry does a bare `import './register';` that the barrel
15
+ // does NOT re-export. A bare `import './register';` sitting NEXT TO such a re-export does not work
16
+ // either: Babel merges the two imports of one specifier and the merged dependency stays lazy.
17
+ import { appListenerFor, dlog, registerHostBehavior, requestCommitFor, setBehaviorListener, setNodePressed, } from '@symbiote-native/engine';
18
+ import { createPressHandlers, createPressRuntime, disposePressRuntime, DEFAULT_DELAY_LONG_PRESS_MS, rippleProps, } from '../state/pressable.js';
19
+ import { buildPressableListeners, resolveDisabledAccessibilityState, } from '../view/render-pressable/index.js';
20
+ export const PRESSABLE_TAG = 'symbiote-pressable';
21
+ const states = new WeakMap();
22
+ function isRecord(value) {
23
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
24
+ }
25
+ function numberOr(value, fallback) {
26
+ return typeof value === 'number' ? value : fallback;
27
+ }
28
+ // A scalar offset or the per-edge object; anything else reads as "no offset", which the machine
29
+ // turns into RN's defaults. Same narrowing the component path does — kept here rather than shared
30
+ // because the component's version narrows Vue attrs, and this one narrows engine props.
31
+ function asRectOffset(value) {
32
+ if (typeof value === 'number')
33
+ return value;
34
+ if (!isRecord(value))
35
+ return undefined;
36
+ const rect = {};
37
+ if (typeof value.top === 'number')
38
+ rect.top = value.top;
39
+ if (typeof value.left === 'number')
40
+ rect.left = value.left;
41
+ if (typeof value.bottom === 'number')
42
+ rect.bottom = value.bottom;
43
+ if (typeof value.right === 'number')
44
+ rect.right = value.right;
45
+ return rect;
46
+ }
47
+ // A predicate rather than a bare `typeof`: `typeof x === 'function'` narrows to `Function`, which
48
+ // carries no call signature and cannot satisfy IPressHandler. Guard, never cast. Also used for the
49
+ // listener bag, whose values are `unknown` for the same reason.
50
+ function isPressHandler(value) {
51
+ return typeof value === 'function';
52
+ }
53
+ // The props the MACHINE consumes and the host must never see. The wrapper drops them by
54
+ // destructuring — they go into `createPressHandlers` / `buildPressableListeners` and are simply
55
+ // absent from the object it spreads onto its View. A lowered element has no destructure, so every
56
+ // one of them rode into the payload as a key no ViewConfig declares.
57
+ const MACHINE_ONLY_KEYS = [
58
+ // Consumed below and replaced by the resolved `nativeBackgroundAndroid` /
59
+ // `nativeForegroundAndroid`; the raw config is not a native prop.
60
+ 'android_ripple',
61
+ 'disabled',
62
+ 'cancelable',
63
+ 'delayLongPress',
64
+ 'unstable_pressDelay',
65
+ 'pressRetentionOffset',
66
+ 'delayHoverIn',
67
+ 'delayHoverOut',
68
+ ];
69
+ // Narrowed field by field rather than cast: the bag arrives as `unknown` off `node.props`. A local
70
+ // twin of the guard each adapter keeps for its own attrs (Vue's `asAccessibilityState`) — not
71
+ // hoisted to the shared barrel, because every adapter re-exports that barrel wholesale and a
72
+ // narrowing helper is not API anyone should be able to import.
73
+ function asAccessibilityState(value) {
74
+ if (!isRecord(value))
75
+ return undefined;
76
+ const state = {};
77
+ if (typeof value.disabled === 'boolean')
78
+ state.disabled = value.disabled;
79
+ if (typeof value.selected === 'boolean')
80
+ state.selected = value.selected;
81
+ if (value.checked === 'mixed' || typeof value.checked === 'boolean')
82
+ state.checked = value.checked;
83
+ if (typeof value.busy === 'boolean')
84
+ state.busy = value.busy;
85
+ if (typeof value.expanded === 'boolean')
86
+ state.expanded = value.expanded;
87
+ return state;
88
+ }
89
+ // Narrowed field by field, same reason as the accessibility guard above: the config arrives as
90
+ // `unknown` off `node.props`.
91
+ function asRippleConfig(value) {
92
+ if (!isRecord(value))
93
+ return undefined;
94
+ const config = {};
95
+ if (typeof value.color === 'string')
96
+ config.color = value.color;
97
+ if (typeof value.borderless === 'boolean')
98
+ config.borderless = value.borderless;
99
+ if (typeof value.radius === 'number')
100
+ config.radius = value.radius;
101
+ if (typeof value.foreground === 'boolean')
102
+ config.foreground = value.foreground;
103
+ return config;
104
+ }
105
+ // `disabled` reaches a screen reader ONLY as `accessibilityState.disabled` — it is not a native
106
+ // View prop, so the wrapper folds it (`resolveDisabledAccessibilityState`, called by all five) and
107
+ // forwards the composite. Lowering dropped that fold: press suppression still worked, because the
108
+ // machine reads `node.props.disabled` directly, so the button behaved correctly and announced
109
+ // itself as enabled. An accessibility regression with no visual tell and no failing test.
110
+ //
111
+ // Found by the wrapper-vs-behavior import audit (`.claude/rules/adapter-parity-audit.md`).
112
+ function foldPayload(props) {
113
+ const resolved = resolveDisabledAccessibilityState(asAccessibilityState(props.accessibilityState), typeof props.disabled === 'boolean' ? props.disabled : undefined);
114
+ // The Android ripple. Our WRAPPER paints it through a dedicated inner View, mirroring
115
+ // TouchableNativeFeedback — and that reading is what made this look unfixable for a lowered
116
+ // element, which has no child to put it on. RN's own `Pressable` does NOT do that: it spreads
117
+ // `useAndroidRippleForView`'s `viewProps` onto its own View (`Pressable.js:251`), so the ripple
118
+ // background is an ordinary prop of the responder itself and a single node carries it fine.
119
+ //
120
+ // `rippleProps` returns undefined off Android, so this whole branch is inert on iOS.
121
+ //
122
+ // STILL MISSING ON BOTH PATHS, and lowering did not cause it: RN also dispatches
123
+ // `Commands.hotspotUpdate(x, y)` on pressIn/pressMove and `Commands.setPressed` on
124
+ // pressIn/pressOut, which is what makes the ripple originate at the touch point. Neither our
125
+ // wrapper nor this behavior sends them — grep for `hotspotUpdate` returns nothing in the tree.
126
+ const rippleConfig = asRippleConfig(props.android_ripple);
127
+ const ripple = rippleConfig !== undefined ? rippleProps(rippleConfig) : undefined;
128
+ const out = { ...props };
129
+ for (const key of MACHINE_ONLY_KEYS)
130
+ delete out[key];
131
+ if (ripple !== undefined)
132
+ Object.assign(out, ripple);
133
+ // Written only when the fold produced something: an unconditional assignment would put an
134
+ // `accessibilityState: undefined` key on every lowered Pressable in the tree, and `fabricProps`
135
+ // skipping undefined is a coincidence to lean on, not a contract to rely on here.
136
+ if (resolved !== undefined)
137
+ out.accessibilityState = resolved;
138
+ return out;
139
+ }
140
+ // From the STASH, not from `node.props`. Every name below is in `ownedListeners`, so `routeProp`
141
+ // diverts the app's `onPress` away from `node.listeners` (where it would evict the behavior's own
142
+ // dispatcher) and into the stash — which makes the stash the only place it exists. Reading
143
+ // `node.props` here returns undefined for every callback and every press silently does nothing:
144
+ // the behavior runs, the machine runs, and it calls nobody.
145
+ function callbackAt(node, event) {
146
+ const value = appListenerFor(node, event);
147
+ return isPressHandler(value) ? value : undefined;
148
+ }
149
+ // Callbacks come from `callbackAt` (the stash), scalars from `node.props`. The split is not
150
+ // cosmetic: `delayLongPress` and `hitSlop` are ordinary props that `fabricProps` drops as unknown
151
+ // keys, while `onPress` and friends are OWNED event names that never reach `node.props` at all.
152
+ function configFor(node) {
153
+ return {
154
+ onPress: callbackAt(node, 'press'),
155
+ onPressIn: callbackAt(node, 'pressIn'),
156
+ onPressOut: callbackAt(node, 'pressOut'),
157
+ onPressMove: callbackAt(node, 'pressMove'),
158
+ onLongPress: callbackAt(node, 'longPress'),
159
+ delayLongPress: numberOr(node.props.delayLongPress, DEFAULT_DELAY_LONG_PRESS_MS),
160
+ unstable_pressDelay: numberOr(node.props.unstable_pressDelay, 0),
161
+ hitSlop: asRectOffset(node.props.hitSlop),
162
+ pressRetentionOffset: asRectOffset(node.props.pressRetentionOffset),
163
+ };
164
+ }
165
+ // Rebuilding at GESTURE START is the whole reason for the dispatcher indirection, and skipping it
166
+ // is a bug that looks like working code. `attach` runs inside `createElement`, before a single
167
+ // prop has been routed — `node.props` is literally `{}` there — so a machine built at attach would
168
+ // capture no `onPress` at all and every press would silently do nothing. `createPressHandlers`
169
+ // destructures its config eagerly, so it cannot be handed a live view either; it has to be re-made
170
+ // once the props exist. A gesture is one interaction, so a handful of closures per press is
171
+ // invisible — unlike doing it per prop write, which is the cost this whole tier exists to remove.
172
+ function rebuild(node, state) {
173
+ const handlers = createPressHandlers(configFor(node), state.runtime, state.host);
174
+ state.isBuilt = true;
175
+ state.listeners = buildPressableListeners(handlers, {
176
+ disabled: node.props.disabled === true ? true : undefined,
177
+ cancelable: typeof node.props.cancelable === 'boolean'
178
+ ? node.props.cancelable
179
+ : undefined,
180
+ });
181
+ }
182
+ // WHICHEVER EVENT OPENS THE GESTURE REBUILDS, and pinning that to one name was a real bug.
183
+ // `onStartShouldSetResponder` looks like the opener and is not: `core/engine/src/events/index.ts`
184
+ // bubbles PRESS_IN and only THEN calls `negotiateResponder`, so `pressIn` arrives FIRST on every
185
+ // gesture. Rebuilding only on the responder claim therefore handed the first `pressIn` an empty
186
+ // listener bag — the press-in half of every press was dropped, the pressed style never reached
187
+ // Fabric, and `onPress` still fired because by then the machine existed. That is exactly the
188
+ // device report: the callback works, the button does not light up.
189
+ //
190
+ // So the trigger is a FLAG, not a name: build if this gesture has not built yet, and clear it when
191
+ // the gesture ends. Order-independent, and it survives the engine reordering its own events.
192
+ //
193
+ // A key `buildPressableListeners` omitted — every one of them when `disabled` is true — resolves to
194
+ // undefined here and the dispatcher returns undefined, which is what an absent listener would do.
195
+ const GESTURE_END_KEYS = new Set([
196
+ 'onPressOut',
197
+ 'onResponderTerminationRequest',
198
+ ]);
199
+ function dispatch(node, state, key, args) {
200
+ if (!state.isBuilt)
201
+ rebuild(node, state);
202
+ const listener = state.listeners[key];
203
+ const result = isPressHandler(listener)
204
+ ? Reflect.apply(listener, undefined, args)
205
+ : undefined;
206
+ // AFTER the handler, not before: `handlePressOut` is what settles the machine, and clearing the
207
+ // flag first would let a re-entrant dispatch rebuild mid-gesture.
208
+ if (GESTURE_END_KEYS.has(key))
209
+ state.isBuilt = false;
210
+ return result;
211
+ }
212
+ // Installed straight into the listener slot rather than through `routeProp`: the behavior OWNS
213
+ // these names, and `setEventListener` diverts an owned name into the app stash — routing its own
214
+ // dispatcher through there would stash it and leave the slot empty.
215
+ //
216
+ // The engine event names, not the `onX` prop spellings. `buildPressableListeners` speaks the prop
217
+ // spelling, so the two are mapped here rather than guessed at either end.
218
+ const KEY_BY_EVENT = new Map([
219
+ ['press', 'onPress'],
220
+ ['pressIn', 'onPressIn'],
221
+ ['pressOut', 'onPressOut'],
222
+ ['startShouldSetResponder', 'onStartShouldSetResponder'],
223
+ ['responderMove', 'onResponderMove'],
224
+ ['responderTerminationRequest', 'onResponderTerminationRequest'],
225
+ ]);
226
+ function installListeners(node, state) {
227
+ for (const [event, key] of KEY_BY_EVENT) {
228
+ setBehaviorListener(node, event, symbioteEvent => dispatch(node, state, key, [symbioteEvent]));
229
+ }
230
+ }
231
+ function attach(node) {
232
+ const timers = new Set();
233
+ const runtime = createPressRuntime();
234
+ const host = {
235
+ // The whole reason this tier is possible: the pressed state resolves BELOW the framework,
236
+ // through the style registry's `:active` variant, and never crosses into it.
237
+ setPressed: pressed => {
238
+ setNodePressed(node, pressed);
239
+ // Dirtying is not publishing. A press arrives from a native event, outside every renderer
240
+ // mutation path, so nothing schedules a commit — `native-events.ts` requests none, and no
241
+ // adapter does either. `setNodeHidden`'s React twin never hit this because the reconciler is
242
+ // already in its commit phase when it calls.
243
+ requestCommitFor(node);
244
+ },
245
+ // `measure` needs a committed Fabric tag, which a node has by the time a human can touch it.
246
+ // Returning undefined before then makes the machine fall back to its radius test rather than
247
+ // throwing, which is the behaviour the component path already relies on.
248
+ getMeasureFn: () => callback => node.measure(callback),
249
+ // Tracked so `detach` can cancel them. An un-cancelled long-press timer outlives the tree that
250
+ // owned it, and `removeChild` visits only a subtree ROOT — a pressable is normally nested.
251
+ schedule: (callback, ms) => {
252
+ const id = setTimeout(() => {
253
+ timers.delete(id);
254
+ callback();
255
+ }, ms);
256
+ timers.add(id);
257
+ return () => {
258
+ clearTimeout(id);
259
+ timers.delete(id);
260
+ };
261
+ },
262
+ now: Date.now,
263
+ };
264
+ const state = {
265
+ runtime,
266
+ host,
267
+ timers,
268
+ listeners: {},
269
+ isBuilt: false,
270
+ };
271
+ states.set(node, state);
272
+ installListeners(node, state);
273
+ }
274
+ function detach(node) {
275
+ const state = states.get(node);
276
+ if (state === undefined)
277
+ return;
278
+ // The machine's own teardown, which every wrapper calls from its destroy hook. Not load-bearing
279
+ // here and no test can make it so: `host.schedule` puts every timer the machine arms into
280
+ // `state.timers` — the 130ms floor's deferred `pressOut` included — so the loop below already
281
+ // cancels them. Kept as the contract, and for a timer armed by some future route.
282
+ disposePressRuntime(state.runtime);
283
+ for (const id of state.timers)
284
+ clearTimeout(id);
285
+ state.timers.clear();
286
+ states.delete(node);
287
+ dlog('pressable behavior detached');
288
+ }
289
+ // Idempotent: an adapter entry may be imported more than once in a bundle, and re-registering the
290
+ // same tag with an equivalent behavior must not double-install anything.
291
+ export function registerPressableBehavior() {
292
+ registerHostBehavior(PRESSABLE_TAG, {
293
+ attach,
294
+ detach,
295
+ foldPayload,
296
+ // Every name the machine needs as an INPUT. The responder pair is not optional — it is how a
297
+ // gesture starts at all, and `RESPONDER_EVENTS` makes those listeners on any node regardless
298
+ // of ViewConfig, so they collide exactly like `press` does.
299
+ ownedListeners: [
300
+ 'press',
301
+ 'pressIn',
302
+ 'pressOut',
303
+ 'pressMove',
304
+ 'longPress',
305
+ 'startShouldSetResponder',
306
+ 'responderMove',
307
+ 'responderTerminationRequest',
308
+ ],
309
+ });
310
+ }
@@ -0,0 +1,2 @@
1
+ export declare const SWITCH_TAG = "symbiote-switch";
2
+ export declare function registerSwitchBehavior(): void;
@@ -0,0 +1,182 @@
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 `symbiote-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 = 'symbiote-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`.
150
+ const listener = node.props.onValueChange;
151
+ if (typeof listener === 'function')
152
+ listener(value, event);
153
+ // A raw `change` listener authored directly on the bare tag — not part of any adapter's public
154
+ // surface today, but `change` is the name this behavior's own dispatcher owns
155
+ // (`ownedListeners` below), so one is stashed rather than silently evicting the machine.
156
+ const rawListener = appListenerFor(node, 'change');
157
+ if (typeof rawListener === 'function')
158
+ rawListener(event);
159
+ // See the module header: deferred so an ACCEPTING app's own state update has a turn of the
160
+ // microtask queue to reach `node.props.value` first.
161
+ queueMicrotask(() => evaluateSnapBack(node));
162
+ }
163
+ function attach(node) {
164
+ states.set(node, createInitialSwitchState());
165
+ setBehaviorListener(node, 'change', event => onChange(node, event));
166
+ }
167
+ function detach(node) {
168
+ states.delete(node);
169
+ }
170
+ // Idempotent: an adapter entry may be imported more than once in a bundle, and re-registering the
171
+ // same tag with an equivalent behavior must not double-install anything.
172
+ export function registerSwitchBehavior() {
173
+ registerHostBehavior(SWITCH_TAG, {
174
+ attach,
175
+ // See the module header: closes the residual case a microtask scheduled only from `onChange`
176
+ // cannot — a prop change with no preceding native event.
177
+ afterCommit: evaluateSnapBack,
178
+ detach,
179
+ foldPayload,
180
+ ownedListeners: ['change'],
181
+ });
182
+ }
@@ -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 = "symbiote-text-input";
4
+ export declare const TEXT_INPUT_MULTILINE_TAG = "symbiote-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;