@symbiote-native/components 1.0.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/README.md +11 -13
  2. package/build/accessibility-props.d.ts +1 -1
  3. package/build/accessibility-props.js +2 -2
  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 +120 -0
  12. package/build/behaviors/button.d.ts +13 -0
  13. package/build/behaviors/button.js +340 -0
  14. package/build/behaviors/image-background.d.ts +3 -0
  15. package/build/behaviors/image-background.js +155 -0
  16. package/build/behaviors/image.d.ts +1 -2
  17. package/build/behaviors/image.js +25 -106
  18. package/build/behaviors/input-accessory-view.d.ts +1 -2
  19. package/build/behaviors/input-accessory-view.js +49 -55
  20. package/build/behaviors/pressable.d.ts +59 -1
  21. package/build/behaviors/pressable.js +142 -96
  22. package/build/behaviors/refresh-control.d.ts +2 -0
  23. package/build/behaviors/refresh-control.js +96 -0
  24. package/build/behaviors/scroll-view/index.android.d.ts +1 -0
  25. package/build/behaviors/scroll-view/index.android.js +40 -0
  26. package/build/behaviors/scroll-view/index.d.ts +3 -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 +8 -0
  30. package/build/behaviors/scroll-view/responder.d.ts +4 -0
  31. package/build/behaviors/scroll-view/responder.js +202 -0
  32. package/build/behaviors/scroll-view/shared.d.ts +9 -0
  33. package/build/behaviors/scroll-view/shared.js +291 -0
  34. package/build/behaviors/scroll-view/sticky.d.ts +18 -0
  35. package/build/behaviors/scroll-view/sticky.js +581 -0
  36. package/build/behaviors/switch.d.ts +1 -1
  37. package/build/behaviors/switch.js +49 -88
  38. package/build/behaviors/text-input.d.ts +2 -2
  39. package/build/behaviors/text-input.js +244 -104
  40. package/build/behaviors/touchable-highlight.d.ts +9 -0
  41. package/build/behaviors/touchable-highlight.js +205 -0
  42. package/build/behaviors/touchable-native-feedback.d.ts +20 -0
  43. package/build/behaviors/touchable-native-feedback.js +254 -0
  44. package/build/behaviors/touchable-opacity.d.ts +12 -0
  45. package/build/behaviors/touchable-opacity.js +239 -0
  46. package/build/behaviors/touchable-without-feedback.d.ts +2 -0
  47. package/build/behaviors/touchable-without-feedback.js +231 -0
  48. package/build/component-names/index.android.js +31 -25
  49. package/build/component-names/index.ios.js +28 -23
  50. package/build/component-names/shared.d.ts +2 -1
  51. package/build/component-names/shared.js +16 -6
  52. package/build/descriptor.js +4 -4
  53. package/build/index.d.ts +26 -26
  54. package/build/index.js +56 -27
  55. package/build/register.d.ts +1 -0
  56. package/build/register.js +55 -0
  57. package/build/resolve-intrinsic.js +3 -9
  58. package/build/scroll-view-commands.d.ts +1 -5
  59. package/build/scroll-view-commands.js +23 -85
  60. package/build/state/flat-list.d.ts +2 -2
  61. package/build/state/flat-list.js +10 -2
  62. package/build/state/pressable.d.ts +6 -1
  63. package/build/state/pressable.js +63 -28
  64. package/build/state/section-list.d.ts +2 -0
  65. package/build/state/section-list.js +14 -7
  66. package/build/state/text-input.d.ts +10 -40
  67. package/build/state/text-input.js +17 -186
  68. package/build/state/touchable.d.ts +1 -0
  69. package/build/state/touchable.js +11 -8
  70. package/build/state/virtualized-list-reducer.d.ts +2 -2
  71. package/build/state/virtualized-list.d.ts +6 -6
  72. package/build/state/virtualized-list.js +71 -37
  73. package/build/text-props.d.ts +0 -8
  74. package/build/text-props.js +14 -25
  75. package/build/view/render-button.d.ts +11 -4
  76. package/build/view/render-button.js +74 -22
  77. package/build/view/render-image/index.d.ts +14 -1
  78. package/build/view/render-image/index.js +22 -147
  79. package/build/view/render-input-accessory-view.d.ts +1 -5
  80. package/build/view/render-input-accessory-view.js +26 -48
  81. package/build/view/render-keyboard-avoiding-view.d.ts +7 -1
  82. package/build/view/render-keyboard-avoiding-view.js +40 -1
  83. package/build/view/render-modal.d.ts +1 -1
  84. package/build/view/render-modal.js +18 -8
  85. package/build/view/render-pressable/index.d.ts +3 -0
  86. package/build/view/render-pressable/index.js +28 -0
  87. package/build/view/render-scroll-view.d.ts +1 -4
  88. package/build/view/render-scroll-view.js +17 -54
  89. package/build/view/render-switch.d.ts +4 -15
  90. package/build/view/render-switch.js +4 -42
  91. package/build/view/render-touchable-highlight.d.ts +1 -0
  92. package/build/view/render-touchable-native-feedback.d.ts +19 -1
  93. package/build/view/render-touchable-native-feedback.js +34 -9
  94. package/host-primitives.cjs +178 -280
  95. package/host-primitives.d.cts +0 -3
  96. package/package.json +8 -21
  97. package/build/fold-host-bag.d.ts +0 -15
  98. package/build/fold-host-bag.js +0 -99
  99. package/build/state-style.d.ts +0 -15
  100. package/build/state-style.js +0 -47
  101. package/build/view/render-activity-indicator.d.ts +0 -25
  102. package/build/view/render-activity-indicator.js +0 -88
  103. package/build/view/render-image-background.d.ts +0 -9
  104. package/build/view/render-image-background.js +0 -48
  105. package/build/view/render-text-input.d.ts +0 -11
  106. package/build/view/render-text-input.js +0 -39
  107. package/lowering-fixtures.cjs +0 -259
  108. package/lowering-fixtures.d.cts +0 -17
  109. package/specialize-state-style.cjs +0 -219
  110. package/specialize-state-style.d.cts +0 -15
@@ -0,0 +1,239 @@
1
+ // TouchableOpacity as an ENGINE-NODE behavior, so it can be an intrinsic tag instead of a
2
+ // framework component (`.claude/rules/host-primitive-tier.md`, tier 2).
3
+ //
4
+ // ONE NODE, and that is a correction rather than a shortcut. Every adapter's wrapper builds two —
5
+ // a `pressable` holding a faded `view` — and RN builds one: `TouchableOpacity.js:302` renders a
6
+ // single `<Animated.View>` carrying the responder handlers AND
7
+ // `style={[props.style, {opacity: anim}]}`. The two-node shape is copied from
8
+ // TouchableNativeFeedback, which genuinely does clone onto a child; the same misreading is on
9
+ // record for `android_ripple` (`.claude/rules/adapter-parity-audit.md`). So the tag collapses the
10
+ // pair and lands on RN's own tree.
11
+ //
12
+ // WHAT IS SHARED AND WHAT IS NEW. The press machine is `./pressable`'s, composed through
13
+ // `createPressBehavior` — one node may hold exactly one press machine, so this tag must not also
14
+ // register the plain `pressable` behavior. The press-scheduling half (delayPressIn defer,
15
+ // flush-on-early-release, the minPressDuration hold) is `../state/touchable`, unchanged and still
16
+ // shared with the wrappers. New here is only WHERE the fade lives: on the engine node, through
17
+ // `setAnimatedBehaviorStyle`, instead of in a component's own Animated wiring.
18
+ //
19
+ // REGISTRATION IS THE HAZARD, not the machine — see `./pressable` for why each adapter's entry
20
+ // does a bare `import './register';` that the barrel does not re-export.
21
+ //
22
+ // TODO(rn-parity, low priority): `TouchableOpacity.js:197-220` gates the active/inactive opacity
23
+ // fade on `onFocus`/`onBlur` too (TV remote focus fires the same fade `onPress` triggers). Neither
24
+ // name is wired into the feedback machine here; they pass through as ordinary, un-intercepted
25
+ // event props. Deliberately not implemented — dead on every device this project targets (no tvOS
26
+ // build, no example app; iOS/Android are the only targets). Audit skill, "Found, NOT fixed: TV
27
+ // (Platform.isTV) focus/blur feedback on TouchableOpacity/Highlight".
28
+ import { AnimatedMock, AnimatedValue, Easing, Platform, dlog, getExplicitStyle, markPropsDirty, registerHostBehavior, requestCommitFor, setAnimatedBehaviorStyle, timing, propOf, propsOf, } from '@symbiote-native/engine';
29
+ import { resolveButtonDisabled } from '../view/render-button.js';
30
+ import { asAccessibilityState, booleanOr, createPressBehavior, } from './pressable.js';
31
+ import { createTouchableFeedbackHandlers, createTouchableFeedbackRuntime, DEFAULT_ACTIVE_OPACITY, OPACITY_ACTIVE_DURATION_MS, OPACITY_ACTIVE_GRANT_DURATION_MS, OPACITY_INACTIVE_DURATION_MS, RESTING_OPACITY, restingOpacityFromStyle, TOUCHABLE_MIN_PRESS_DURATION_MS, } from '../state/touchable.js';
32
+ export const TOUCHABLE_OPACITY_TAG = 'touchable-opacity';
33
+ const states = new WeakMap();
34
+ // `modules/animated`'s barrel swaps the whole driver namespace for the mock when the host reports
35
+ // reduced motion; the one driver used here is swapped on the same flag.
36
+ const startTiming = Platform.isDisableAnimations ? AnimatedMock.timing : timing;
37
+ function numberOr(value, fallback) {
38
+ return typeof value === 'number' ? value : fallback;
39
+ }
40
+ // From `parts.explicitStyle`, never `node.props.style`. A fade frame lands as `setNativeProps`,
41
+ // which merges its partial onto `node.props.style` in place — so reading the published slot would
42
+ // hand the resting-opacity math the fade's own output and `afterCommit` would chase itself.
43
+ //
44
+ // It therefore sees the AUTHOR's style and not a class-derived `opacity`, which is exactly RN's
45
+ // scope: `_getChildStyleOpacityWithDefault` reads `this.props.style` and nothing else.
46
+ function restingOpacityOf(node) {
47
+ return restingOpacityFromStyle(getExplicitStyle(node));
48
+ }
49
+ function fadeTo(state, toValue, ms) {
50
+ dlog(`touchable-opacity opacity -> ${toValue} over ${ms}ms`);
51
+ // useNativeDriver is RN's own (TouchableOpacity.js:242): opacity is natively drivable, so the
52
+ // fade survives a busy JS thread, which is the whole point of press feedback.
53
+ startTiming(state.opacity, {
54
+ toValue,
55
+ duration: ms,
56
+ easing: Easing.inOut(Easing.quad),
57
+ useNativeDriver: true,
58
+ }).start();
59
+ }
60
+ // Runs once per GESTURE (see IPressConfigRefinement), so the config it reads is whatever the props
61
+ // hold when a finger lands, and the handlers it builds are discarded with the gesture. The runtime
62
+ // that must outlive one — an in-flight delayPressIn timer, the activation clock — is on the node.
63
+ const refine = (node, config) => {
64
+ const state = states.get(node);
65
+ if (state === undefined)
66
+ return config;
67
+ const resting = restingOpacityOf(node);
68
+ const activeOpacity = numberOr(propOf(node, 'activeOpacity'), DEFAULT_ACTIVE_OPACITY);
69
+ const handlers = createTouchableFeedbackHandlers({
70
+ delayPressIn: numberOr(propOf(node, 'delayPressIn'), 0),
71
+ delayPressOut: numberOr(propOf(node, 'delayPressOut'), 0),
72
+ // Read raw rather than off `config`, which has already defaulted the absent case to the press
73
+ // machine's own 130 ms — a floor RN's Touchables never see (TOUCHABLE_MIN_PRESS_DURATION_MS).
74
+ minPressDuration: numberOr(propOf(node, 'minPressDuration'), TOUCHABLE_MIN_PRESS_DURATION_MS),
75
+ schedule: (callback, ms) => {
76
+ const id = setTimeout(() => {
77
+ state.timers.delete(id);
78
+ callback();
79
+ }, ms);
80
+ state.timers.add(id);
81
+ return () => {
82
+ clearTimeout(id);
83
+ state.timers.delete(id);
84
+ };
85
+ },
86
+ now: Date.now,
87
+ }, state.runtime, {
88
+ activate(event) {
89
+ // TouchableOpacity.js:215-220 picks the duration from WHERE the press-in came from: our
90
+ // engine's own 'pressIn' event (an ordinary grant, or its delayed replay) is the 0ms leg;
91
+ // a drift-out/drift-back-in reactivation arrives as 'responderMove'
92
+ // (`state/pressable.ts`'s `handleResponderMove`) and gets the 150ms ease-back-in instead.
93
+ const duration = event.type === 'responderMove'
94
+ ? OPACITY_ACTIVE_DURATION_MS
95
+ : OPACITY_ACTIVE_GRANT_DURATION_MS;
96
+ fadeTo(state, activeOpacity, duration);
97
+ config.onPressIn?.(event);
98
+ },
99
+ deactivate(event) {
100
+ fadeTo(state, resting, OPACITY_INACTIVE_DURATION_MS);
101
+ config.onPressOut?.(event);
102
+ },
103
+ });
104
+ return {
105
+ ...config,
106
+ // RN hands Pressability 0 (TouchableOpacity.js:195): the deactivation floor belongs to the
107
+ // feedback machine above, and a second one holds the fade for the press machine's default.
108
+ minPressDuration: 0,
109
+ onPressIn: handlers.handlePressIn,
110
+ onPressOut: handlers.handlePressOut,
111
+ };
112
+ };
113
+ // TouchableOpacity.js:186-189 — `disabled ?? aria-disabled ?? accessibilityState.disabled`, the
114
+ // same three-way answer Button resolves, just never wired to this tag's OWN registration before.
115
+ // Without it, a caller who sets only `accessibilityState={{disabled: true}}` (no `disabled` prop)
116
+ // gets the greyed-out RN look but a press that still fires here — RN suppresses it.
117
+ const touchableOpacityDisabled = props => resolveButtonDisabled(booleanOr(props.disabled), booleanOr(props['aria-disabled']), asAccessibilityState(props.accessibilityState));
118
+ /**
119
+ * The behavior as PARTS, so a tag that is a TouchableOpacity plus something — `button`, which RN
120
+ * builds as exactly that (Button.js:283) — composes the fade instead of re-implementing it.
121
+ *
122
+ * Safe to hand to two tags: every piece of runtime is keyed by NODE (`states`), and `press` is
123
+ * itself already shared that way.
124
+ */
125
+ export function createTouchableOpacityBehavior(disabledOf) {
126
+ // Its own machine rather than the module-level `press`, so a composing tag can say what
127
+ // `disabled` MEANS to it — the answer feeds the machine, not a payload: the props half of that
128
+ // question is `foldPressableProps` in `SymbioteFabricProps.cpp` and reads the authored bag.
129
+ const machine = createPressBehavior(refine, disabledOf);
130
+ return {
131
+ ...machine,
132
+ attach(node) {
133
+ const state = {
134
+ opacity: new AnimatedValue(RESTING_OPACITY),
135
+ runtime: createTouchableFeedbackRuntime(),
136
+ timers: new Set(),
137
+ settled: undefined,
138
+ };
139
+ states.set(node, state);
140
+ // Bound at ATTACH rather than at the first press, matching RN: the binding is what forces
141
+ // `collapsable: false`, and a view Fabric un-flattens mid-gesture would lose the responder.
142
+ // Nothing is published until a fade runs — the leaf writes only when its value moves.
143
+ setAnimatedBehaviorStyle(node, { opacity: state.opacity });
144
+ machine.attach(node);
145
+ },
146
+ // RN's componentDidUpdate: a changed `disabled` or a changed style opacity re-settles the view,
147
+ // so a Touchable disabled mid-press does not stay stuck at its active opacity.
148
+ afterCommit(node) {
149
+ const state = states.get(node);
150
+ if (state === undefined)
151
+ return;
152
+ const resting = restingOpacityOf(node);
153
+ // Through the SAME resolver the press machine uses, not the raw prop: on Button `disabled` is
154
+ // the three-way answer of `props.disabled ?? aria-disabled ?? accessibilityState.disabled`
155
+ // (Button.js:331,337), so reading the prop here left an aria-only flip un-settled — the view
156
+ // stayed at its active opacity while the press was already suppressed. RN's Button has no
157
+ // such gap because it passes the resolved value DOWN as its touchable's prop.
158
+ const props = propsOf(node);
159
+ const disabled = disabledOf?.(props) ?? props.disabled;
160
+ const previous = state.settled;
161
+ state.settled = { disabled, resting };
162
+ if (previous === undefined) {
163
+ // NOT vendor parity, despite this comment's old claim: `TouchableOpacity-itest.js` ("does
164
+ // not render explicit opacity when using default") proves a resting, untouched
165
+ // TouchableOpacity commits no `opacity` key at all. Vendor's native-animated leaf is a
166
+ // separate channel from the ordinary style diff, so forcing non-flattening never implies
167
+ // publishing a value. Ours publishes anyway: `setAnimatedBehaviorStyle`/`bindAnimatedValue`
168
+ // (`host-binding.ts`) force `collapsable: false` and resolve+write the leaf's value in the
169
+ // SAME call. Untangling that is a change to shared animation infrastructure, not a one-line
170
+ // fix here — deferred, see the audit skill. Set rather than animate: this is the mount, and
171
+ // RN re-settles on componentDidUpdate only.
172
+ //
173
+ // TODO(rn-parity): skip this initial publish when `resting === RESTING_OPACITY` (1, Fabric's
174
+ // own opacity default) and no authored `style.opacity` needs another route to Fabric, so a
175
+ // resting default TouchableOpacity commits no `opacity` key — matching vendor. Needs
176
+ // `bindAnimatedValue`/`host-binding.ts` to separate "force collapsable:false" from "publish
177
+ // the leaf's value" (shared by every animated-behavior consumer, not just this tag), plus a
178
+ // new test for the untouched-default case (no existing case in touchable-opacity.test.ts
179
+ // covers it — every case authors an explicit style.opacity first).
180
+ state.opacity.setValue(resting);
181
+ return;
182
+ }
183
+ if (previous.disabled === disabled && previous.resting === resting)
184
+ return;
185
+ fadeTo(state, resting, OPACITY_INACTIVE_DURATION_MS);
186
+ },
187
+ detach(node) {
188
+ machine.detach(node);
189
+ const state = states.get(node);
190
+ if (state === undefined)
191
+ return;
192
+ for (const id of state.timers)
193
+ clearTimeout(id);
194
+ state.timers.clear();
195
+ // RN's componentWillUnmount: stop the animation so a teardown mid-fade leaves no driver
196
+ // running against a node that is gone.
197
+ state.opacity.resetAnimation();
198
+ setAnimatedBehaviorStyle(node, undefined);
199
+ states.delete(node);
200
+ dlog('touchable-opacity behavior detached');
201
+ },
202
+ };
203
+ }
204
+ // THIS TAG NOW COSTS ZERO TRIPS INTO JS (2026-09-18). `focusable` was the last thing here, and it
205
+ // held out because its middle leg — `onPress !== undefined` (TouchableOpacity.js:338) — is an OWNED
206
+ // name that lives in the stash and never becomes a prop, so a props-only rule could not see it.
207
+ //
208
+ // What moved was not the closure but ONE BIT. `setEventListener` already knew the flip (it computes
209
+ // `wasWired !== isHandler` to fire `onOwnedListenerChange`); it now also records
210
+ // `OP_SET_OWNED_LISTENER`, and `foldPressableProps` resolves the whole three-leg expression off the
211
+ // node. The callback itself never left JS and never will — that is the split a browser draws too,
212
+ // where the UA knows which elements carry a click handler and the handler's body stays the page's.
213
+ //
214
+ // So "a rule cannot read an owned listener" was two claims wearing one sentence, and only the one
215
+ // about the FUNCTION was true. Contract:
216
+ // `core/engine/cpp/tests/js/touchable-focusable-payload.itest.ts`.
217
+ //
218
+ // `./button` keeps its own fold and its own resolution, deliberately: it resolves `disabled` three
219
+ // ways through the projection its derived children share, and it folds an Android view style and
220
+ // ripple regardless — so moving only its `focusable` would duplicate that precedence and buy back
221
+ // no crossing.
222
+ // STILL NEEDED, and for a reason the wire did not remove: `markDirty` on the host marks the node
223
+ // whose payload must be rebuilt, but the COMMIT still has to be asked for. A listener flip changes
224
+ // no prop on this side, so nothing else would schedule one and the new answer would sit in the host
225
+ // until some unrelated write happened to dirty the node.
226
+ function onOwnedListenerChange(node, name) {
227
+ if (name !== 'press')
228
+ return;
229
+ markPropsDirty(node);
230
+ requestCommitFor(node);
231
+ }
232
+ // Idempotent: an adapter entry may be imported more than once in a bundle.
233
+ export function registerTouchableOpacityBehavior() {
234
+ const touchable = createTouchableOpacityBehavior(touchableOpacityDisabled);
235
+ registerHostBehavior(TOUCHABLE_OPACITY_TAG, {
236
+ ...touchable,
237
+ onOwnedListenerChange,
238
+ });
239
+ }
@@ -0,0 +1,2 @@
1
+ export declare const TOUCHABLE_WITHOUT_FEEDBACK_TAG = "touchable-without-feedback";
2
+ export declare function registerTouchableWithoutFeedbackBehavior(): void;
@@ -0,0 +1,231 @@
1
+ // TouchableWithoutFeedback as an ENGINE-NODE behavior — the second primitive that commits NO NODE
2
+ // OF ITS OWN (`.claude/rules/host-primitive-tier.md`, tier 2), after `./touchable-native-feedback`.
3
+ //
4
+ // RN'S TWF RENDERS NOTHING, exactly like TNF: `React.Children.only(props.children)`
5
+ // (TouchableWithoutFeedback.js:229) then `cloneElement(element, elementProps, ...children)` (:286).
6
+ // All five of our adapters wrapped a `Pressable` around the children instead, so every TWF in every
7
+ // app committed a node RN does not.
8
+ //
9
+ // The SHAPE — anchor tag, `onChildInserted` adoption, a `cloneElement`-shaped `payloadFold` on the
10
+ // child, the press machine on the CHILD reading the OWNER — is `./touchable-native-feedback`'s and
11
+ // is documented there. Its three KNOWN LIMITS (one child only, a child removed from a live owner
12
+ // keeps its machine, `style` is not cloned) hold here verbatim: RN's TWF declares a `style` prop
13
+ // (:122, tagged "FIXME: not in doc") and then never puts it in `elementProps`, so it is dead
14
+ // upstream too.
15
+ //
16
+ // WHAT DIFFERS FROM TNF, since the two look like one primitive with a different name:
17
+ //
18
+ // 1. THE CLONE IS CONDITIONAL. TNF assigns its whole list unconditionally, so an absent prop CLEARS
19
+ // the child's (`cloneElement` copies undefined keys). TWF splits: eight names are computed and
20
+ // assigned unconditionally, and `PASSTHROUGH_PROPS` (:130-153) is copied only
21
+ // `if (props[prop] !== undefined)` (:281). So a TWF with no `testID` LEAVES the child's alone.
22
+ // 2. NO ANDROID RIPPLE. `background` / `useForeground` / `getBackgroundProp` and the
23
+ // `setPressed`/`hotspotUpdate` view commands are TNF's alone — they exist to drive the drawable
24
+ // TNF installs, and TWF installs none. `nativeFeedbackRefinement` is therefore NOT reused.
25
+ // 3. `onBlur` / `onFocus` ARE cloned (:152-153, in the passthrough list), where TNF drops them.
26
+ // Both are `BASE_EVENTS`, so they route to the stash and trampoline like the other two.
27
+ // 4. `accessibilityIgnoresInvertColors` is cloned; `hasTVPreferredFocus` and the five `nextFocus*`
28
+ // are NOT — the reverse of TNF on both counts. Read RN's two lists side by side rather than
29
+ // inheriting either.
30
+ // 5. TWF FORWARDS `delayPressIn` / `delayPressOut` to Pressability (:186-188), which the press
31
+ // machine does not own — so the tag layers `../state/touchable`'s scheduler on top, the same
32
+ // machine `./touchable-opacity` uses with the fade replaced by nothing. That is what "without
33
+ // feedback" means: no VISUAL, not no TIMING.
34
+ //
35
+ // REGISTRATION IS THE HAZARD, not the machine — see `./pressable`. Registered in the same commit
36
+ // that deletes the five wrappers, because the registry is keyed by TAG and a wrapper still emitting
37
+ // its own `pressable` would put two press machines on one tree.
38
+ import { SLOT_DERIVED_ALL, appListenerFor, markPropsDirty, registerHostBehavior, requestCommitFor, setBehaviorListener, propOf, } from '@symbiote-native/engine';
39
+ import { attachPressMachine, detachPressMachine, } from './pressable.js';
40
+ import { createTouchableFeedbackHandlers, createTouchableFeedbackRuntime, TOUCHABLE_MIN_PRESS_DURATION_MS, } from '../state/touchable.js';
41
+ export const TOUCHABLE_WITHOUT_FEEDBACK_TAG = 'touchable-without-feedback';
42
+ // WHAT THE OWNER'S WRITES DIRTY, and it is EVERY name rather than a list of thirty.
43
+ //
44
+ // RN's two clone lists (`TouchableWithoutFeedback.js:130-153` when-set, `:253-276` always) lived
45
+ // here until 2026-09-18, feeding `slotDerived` alongside the six computed names and the three
46
+ // scheduler ones. The clone is `foldCloneOntoChild` in `SymbioteFabricProps.cpp` now, which turned
47
+ // the list from the RULE into a MIRROR of `kWithoutFeedbackWhenSetKeys` +
48
+ // `kWithoutFeedbackAlwaysKeys` — two lists that must agree, failing silently when they drift.
49
+ //
50
+ // See `touchable-native-feedback.ts` for the same note and what the wildcard costs. The three
51
+ // scheduler names (`delayPressIn`, `delayPressOut`, `minPressDuration`) were the one entry here that
52
+ // was NEVER about the clone — they configure the press machine, which reads them live at gesture
53
+ // start — and a wildcard covers them for free where a list had to remember them.
54
+ const SLOT_DERIVED = [SLOT_DERIVED_ALL];
55
+ // :148-153. Owned, so the app's callback stashes on the owner and a trampoline on the child reads it
56
+ // at dispatch time. `layout` and `accessibilityAction` are Fabric BOOLEAN-GATED events
57
+ // (`.claude/rules/fabric-boolean-event-gates.md`), so the trampoline goes in only while the app has
58
+ // one wired; `blur` and `focus` are not gated but ride the same wiring, since the question — is
59
+ // there a callback to forward — is identical.
60
+ const FORWARDED_LISTENERS = [
61
+ 'layout',
62
+ 'accessibilityAction',
63
+ 'blur',
64
+ 'focus',
65
+ ];
66
+ const PRESS_LISTENERS = [
67
+ 'press',
68
+ 'pressIn',
69
+ 'pressOut',
70
+ 'pressMove',
71
+ 'longPress',
72
+ 'startShouldSetResponder',
73
+ 'responderMove',
74
+ 'responderTerminationRequest',
75
+ ];
76
+ const states = new WeakMap();
77
+ function numberOr(value, fallback) {
78
+ return typeof value === 'number' ? value : fallback;
79
+ }
80
+ // `cloneFold` LEFT THIS FILE ON 2026-09-18 — `foldCloneOntoChild` in `SymbioteFabricProps.cpp`,
81
+ // reached through the first rule keyed on the PARENT'S tag (`IOwner`). TWF's two lists are NOT
82
+ // TNF's: the passthrough half is copied only when SET (`:281`), and the C++ rule keeps that split
83
+ // rather than collapsing the two into one.
84
+ //
85
+ // One quirk of upstream's that IS reproduced, and it is NOT free: `:280-284`'s passthrough loop
86
+ // re-assigns a set `nativeID` over the `id` it just resolved, so an explicit `nativeID` wins here
87
+ // where TNF's `id` does. `routeProp` settles the two names into one value before any rule runs, so
88
+ // getting this right needed the collapse itself to know which of the two components it is running
89
+ // for — `node.nativeIdWinsOverId`, set only by this behavior's registration, flips it.
90
+ //
91
+ // Contract: `core/engine/cpp/tests/js/clone-onto-child-payload.itest.ts`.
92
+ /**
93
+ * `delayPressIn` / `delayPressOut` (:186-188) plus RN's unconditional `minPressDuration: 0` (:190).
94
+ *
95
+ * The scheduler is `../state/touchable`'s — the one `./touchable-opacity` drives its fade with,
96
+ * here with `activate` / `deactivate` forwarding and painting nothing. It reads the OWNER, so the
97
+ * owner is closed over rather than taken from the refinement's argument, which is the CHILD.
98
+ */
99
+ function refinementFor(owner, state) {
100
+ return (_node, config) => {
101
+ const handlers = createTouchableFeedbackHandlers({
102
+ delayPressIn: numberOr(propOf(owner, 'delayPressIn'), 0),
103
+ delayPressOut: numberOr(propOf(owner, 'delayPressOut'), 0),
104
+ // Read raw rather than off `config`, which has already defaulted the absent case to the
105
+ // press machine's own 130 ms — a floor RN's Touchables never see.
106
+ minPressDuration: numberOr(propOf(owner, 'minPressDuration'), TOUCHABLE_MIN_PRESS_DURATION_MS),
107
+ schedule: (callback, ms) => {
108
+ const id = setTimeout(() => {
109
+ state.timers.delete(id);
110
+ callback();
111
+ }, ms);
112
+ state.timers.add(id);
113
+ return () => {
114
+ clearTimeout(id);
115
+ state.timers.delete(id);
116
+ };
117
+ },
118
+ now: Date.now,
119
+ }, state.runtime, {
120
+ activate(event) {
121
+ config.onPressIn?.(event);
122
+ },
123
+ deactivate(event) {
124
+ config.onPressOut?.(event);
125
+ },
126
+ });
127
+ return {
128
+ ...config,
129
+ // :190, unconditional. The deactivation floor belongs to the scheduler above, and a second
130
+ // one would defer `onPressOut` by the press machine's 130 ms default on top of it.
131
+ minPressDuration: 0,
132
+ onPressIn: handlers.handlePressIn,
133
+ onPressOut: handlers.handlePressOut,
134
+ };
135
+ };
136
+ }
137
+ // The app's callback, read at dispatch time so a fresh closure per render costs nothing.
138
+ function trampolineFor(owner, name) {
139
+ return (event) => {
140
+ const listener = appListenerFor(owner, name);
141
+ if (typeof listener === 'function')
142
+ listener(event);
143
+ };
144
+ }
145
+ function forwardListener(owner, child, name, wired) {
146
+ setBehaviorListener(child, name, wired ? trampolineFor(owner, name) : undefined);
147
+ }
148
+ // The machine plus the forwarded listeners. Split from adoption because a parked subtree comes back
149
+ // with its child and its fold intact but its machine torn down — `attach` re-runs this, and must
150
+ // NOT re-chain the fold.
151
+ function arm(owner, child) {
152
+ let state = states.get(owner);
153
+ if (state === undefined) {
154
+ state = { runtime: createTouchableFeedbackRuntime(), timers: new Set() };
155
+ states.set(owner, state);
156
+ }
157
+ attachPressMachine(child, {
158
+ source: owner,
159
+ refine: refinementFor(owner, state),
160
+ });
161
+ for (const name of FORWARDED_LISTENERS) {
162
+ forwardListener(owner, child, name, appListenerFor(owner, name) !== undefined);
163
+ }
164
+ }
165
+ function attach(node) {
166
+ // First attach has no child yet; a re-attach after the sweep does. See `arm`, which is also what
167
+ // seeds the scheduling cell — a TWF that never adopts a child never needs one.
168
+ if (node.childHost !== undefined)
169
+ arm(node, node.childHost);
170
+ }
171
+ function detach(node) {
172
+ const child = node.childHost;
173
+ if (child !== undefined)
174
+ detachPressMachine(child);
175
+ const state = states.get(node);
176
+ if (state === undefined)
177
+ return;
178
+ for (const id of state.timers)
179
+ clearTimeout(id);
180
+ state.timers.clear();
181
+ states.delete(node);
182
+ }
183
+ function onChildInserted(node, child) {
184
+ const previous = node.childHost;
185
+ if (previous === child)
186
+ return;
187
+ // A replaced child: release the machine still armed on the one that left. `removeChild` clears
188
+ // `childHost` (node.ts), so this only runs when a framework inserts without removing first.
189
+ if (previous !== undefined)
190
+ detachPressMachine(previous);
191
+ node.childHost = child;
192
+ // The owner's props were very likely written BEFORE this child existed (React and Solid set props
193
+ // at createInstance), so the CLONE owes a run even though nothing was written since. Witnessed by
194
+ // the MOVED-child case, not by a fresh one — `appendChild` already dirties a node it created.
195
+ // Still owed now that the rule is in C++: it runs on the child's commit, and a child nothing
196
+ // dirtied has no commit.
197
+ markPropsDirty(child);
198
+ arm(node, child);
199
+ }
200
+ // `focusable` is a function of a LISTENER, and a listener flip changes no payload by itself — so
201
+ // the commit after it is a no-op and no fold re-runs (`IHostBehavior.onOwnedListenerChange`).
202
+ function onOwnedListenerChange(node, name, wired) {
203
+ const child = node.childHost;
204
+ if (child === undefined)
205
+ return;
206
+ if (FORWARDED_LISTENERS.includes(name)) {
207
+ forwardListener(node, child, name, wired);
208
+ return;
209
+ }
210
+ if (name !== 'press')
211
+ return;
212
+ markPropsDirty(child);
213
+ requestCommitFor(child);
214
+ }
215
+ // Idempotent: an adapter entry may be imported more than once in a bundle.
216
+ export function registerTouchableWithoutFeedbackBehavior() {
217
+ const behavior = {
218
+ attach,
219
+ detach,
220
+ onChildInserted,
221
+ onOwnedListenerChange,
222
+ // Owned on the OWNER, which is where the app writes them, and read from there by the machine on
223
+ // the child (`attachPressMachine`'s `source`) and by the trampolines above.
224
+ ownedListeners: [...PRESS_LISTENERS, ...FORWARDED_LISTENERS],
225
+ slotDerived: SLOT_DERIVED,
226
+ // TouchableWithoutFeedback.js's clone overwrites `nativeID` with the raw authored value
227
+ // (`:279-282`), the reverse of every other component's `id ?? nativeID` — see `routeIdAlias`.
228
+ nativeIdWinsOverId: true,
229
+ };
230
+ registerHostBehavior(TOUCHABLE_WITHOUT_FEEDBACK_TAG, behavior);
231
+ }
@@ -2,33 +2,35 @@
2
2
  // Each name is the ViewManager's REACT_CLASS in react-native/ReactAndroid/.../views/**.
3
3
  // device-verify-pending: source-confirmed from RN's Android ViewManagers, proven on a
4
4
  // real host by the absence of a "Can't find ViewManager '<name>'" red box.
5
+ import { ANCHOR_COMPONENT, VOID_COMPONENT } from '@symbiote-native/engine';
5
6
  import { buildDescriptors, makeDescriptorFor, } from './shared.js';
6
7
  const ANDROID_NAMES = {
7
- 'symbiote-view': 'RCTView',
8
- 'symbiote-pressable': 'RCTView',
9
- 'symbiote-text': 'RCTText',
10
- 'symbiote-image': 'RCTImageView',
11
- 'symbiote-scroll-view': 'RCTScrollView',
8
+ view: 'RCTView',
9
+ pressable: 'RCTView',
10
+ 'touchable-opacity': 'RCTView',
11
+ // NOT a view name — TNF commits nothing at all, on either platform. See `shared.ts`.
12
+ 'touchable-native-feedback': ANCHOR_COMPONENT,
13
+ 'touchable-without-feedback': ANCHOR_COMPONENT,
14
+ 'touchable-highlight': 'RCTView',
15
+ button: 'RCTView',
16
+ text: 'RCTText',
17
+ image: 'RCTImageView',
18
+ 'image-background': 'RCTView',
19
+ 'scroll-view': 'RCTScrollView',
12
20
  // RN's VScrollContentViewNativeComponent is `Platform.OS === 'android' ? View : …`,
13
21
  // so a vertical scroll's content is a plain RCTView on Android, not RCTScrollContentView.
14
- 'symbiote-scroll-content': 'RCTView',
22
+ 'scroll-content': 'RCTView',
15
23
  // Horizontal scroll on Android is its own ViewManager; RCTScrollView is vertical-only and
16
24
  // ignores `horizontal`. RN routes it to AndroidHorizontalScrollView with a dedicated content
17
25
  // view (HScrollViewNativeComponents.js: `Platform.OS === 'android' ? AndroidHorizontal… : …`).
18
- 'symbiote-horizontal-scroll-view': 'AndroidHorizontalScrollView',
19
- 'symbiote-horizontal-scroll-content': 'AndroidHorizontalScrollContentView',
26
+ 'horizontal-scroll-view': 'AndroidHorizontalScrollView',
27
+ 'horizontal-scroll-content': 'AndroidHorizontalScrollContentView',
20
28
  // Android has one text-input ViewManager for both single- and multiline.
21
- 'symbiote-text-input': 'AndroidTextInput',
22
- 'symbiote-text-input-multiline': 'AndroidTextInput',
23
- // The component path's pair — same native views, a tag the behavior registry does not
24
- // carry. See `shared.ts` for why the wrapper may not share the lowered tag.
25
- 'symbiote-text-input-managed': 'AndroidTextInput',
26
- 'symbiote-text-input-multiline-managed': 'AndroidTextInput',
27
- 'symbiote-switch': 'AndroidSwitch',
28
- // The wrapper's tag — same native view, a tag the behavior registry does not carry. See
29
- // `shared.ts` for why the wrapper may not share the lowered tag.
30
- 'symbiote-switch-managed': 'AndroidSwitch',
31
- 'symbiote-activity-indicator': 'AndroidProgressBar',
29
+ 'text-input': 'AndroidTextInput',
30
+ 'text-input-multiline': 'AndroidTextInput',
31
+ switch: 'AndroidSwitch',
32
+ 'activity-indicator': 'RCTView',
33
+ 'activity-indicator-spinner': 'AndroidProgressBar',
32
34
  // KNOWN DIVERGENCE FROM REACT NATIVE, and it is in our favour — recorded 2026-09-01 because it
33
35
  // was arrived at by accident, not decided. Upstream `SafeAreaView.js` is
34
36
  // `Platform.select({ ios: RCTSafeAreaViewNativeComponent, default: View })`, so RN's own JS
@@ -43,12 +45,16 @@ const ANDROID_NAMES = {
43
45
  // react-native-safe-area-context. Invisible to every audit we have, because both of our paths
44
46
  // agree with each other and only disagree with RN — the boundary
45
47
  // `.claude/rules/adapter-parity-audit.md` states about itself.
46
- 'symbiote-safe-area-view': 'RCTSafeAreaView',
47
- 'symbiote-modal': 'RCTModalHostView',
48
- 'symbiote-refresh-control': 'AndroidSwipeRefreshLayout',
49
- // iOS-only primitive; RN ships no Android InputAccessoryView. Degrade to a plain
50
- // container so an iOS-targeted usage doesn't red-box on Android.
51
- 'symbiote-input-accessory-view': 'RCTView',
48
+ 'safe-area-view': 'RCTSafeAreaView',
49
+ modal: 'RCTModalHostView',
50
+ 'refresh-control': 'AndroidSwipeRefreshLayout',
51
+ 'sticky-header': 'RCTView',
52
+ // `InputAccessoryView.js` on Android does `console.warn(...); return null` — the WHOLE component,
53
+ // children included, renders NOTHING. A plain `RCTView` container (this file's answer until
54
+ // 2026-09-20) committed a real, laid-out, potentially visible node plus its whole child subtree —
55
+ // a real divergence from vendor, not a harmless degrade. `VOID_COMPONENT` matches vendor exactly:
56
+ // no node, no children, on this platform only.
57
+ 'input-accessory-view': VOID_COMPONENT,
52
58
  };
53
59
  export const COMPONENT_DESCRIPTORS = buildDescriptors(ANDROID_NAMES);
54
60
  export const descriptorFor = makeDescriptorFor(COMPONENT_DESCRIPTORS);
@@ -2,33 +2,38 @@
2
2
  // (component-names.ts re-exports it) for headless tsx / tsc / web fallback.
3
3
  // Fabric names are the codegen spec's registered name (the new-arch name), not the legacy
4
4
  // paperComponentName (RCTSwitch, …).
5
+ import { ANCHOR_COMPONENT } from '@symbiote-native/engine';
5
6
  import { buildDescriptors, makeDescriptorFor, } from './shared.js';
6
7
  const IOS_NAMES = {
7
- 'symbiote-view': 'RCTView',
8
- 'symbiote-pressable': 'RCTView',
9
- 'symbiote-text': 'RCTText',
10
- 'symbiote-image': 'RCTImageView',
11
- 'symbiote-scroll-view': 'RCTScrollView',
12
- 'symbiote-scroll-content': 'RCTScrollContentView',
8
+ view: 'RCTView',
9
+ pressable: 'RCTView',
10
+ 'touchable-opacity': 'RCTView',
11
+ // NOT a view name — TNF commits nothing at all. See `shared.ts`. Platform-invariant, unlike
12
+ // every other entry here: RN's TNF renders no view on either platform, it only stops CLONING a
13
+ // background off Android (TouchableNativeFeedback.js:402).
14
+ 'touchable-native-feedback': ANCHOR_COMPONENT,
15
+ 'touchable-without-feedback': ANCHOR_COMPONENT,
16
+ 'touchable-highlight': 'RCTView',
17
+ button: 'RCTView',
18
+ text: 'RCTText',
19
+ image: 'RCTImageView',
20
+ 'image-background': 'RCTView',
21
+ 'scroll-view': 'RCTScrollView',
22
+ 'scroll-content': 'RCTScrollContentView',
13
23
  // iOS uses one scroll view for both axes; horizontal is RCTScrollView with the
14
24
  // `horizontal` prop set, so these resolve identically to the vertical pair.
15
- 'symbiote-horizontal-scroll-view': 'RCTScrollView',
16
- 'symbiote-horizontal-scroll-content': 'RCTScrollContentView',
17
- 'symbiote-text-input': 'RCTSinglelineTextInputView',
18
- 'symbiote-text-input-multiline': 'RCTMultilineTextInputView',
19
- // The component path's pair — same native views, a tag the behavior registry does not
20
- // carry. See `shared.ts` for why the wrapper may not share the lowered tag.
21
- 'symbiote-text-input-managed': 'RCTSinglelineTextInputView',
22
- 'symbiote-text-input-multiline-managed': 'RCTMultilineTextInputView',
23
- 'symbiote-switch': 'Switch',
24
- // The wrapper's tag — same native view, a tag the behavior registry does not carry. See
25
- // `shared.ts` for why the wrapper may not share the lowered tag.
26
- 'symbiote-switch-managed': 'Switch',
27
- 'symbiote-activity-indicator': 'ActivityIndicatorView',
28
- 'symbiote-safe-area-view': 'SafeAreaView',
29
- 'symbiote-modal': 'ModalHostView',
30
- 'symbiote-refresh-control': 'PullToRefreshView',
31
- 'symbiote-input-accessory-view': 'RCTInputAccessoryView',
25
+ 'horizontal-scroll-view': 'RCTScrollView',
26
+ 'horizontal-scroll-content': 'RCTScrollContentView',
27
+ 'text-input': 'RCTSinglelineTextInputView',
28
+ 'text-input-multiline': 'RCTMultilineTextInputView',
29
+ switch: 'Switch',
30
+ 'activity-indicator': 'RCTView',
31
+ 'activity-indicator-spinner': 'ActivityIndicatorView',
32
+ 'safe-area-view': 'SafeAreaView',
33
+ modal: 'ModalHostView',
34
+ 'refresh-control': 'PullToRefreshView',
35
+ 'sticky-header': 'RCTView',
36
+ 'input-accessory-view': 'RCTInputAccessoryView',
32
37
  };
33
38
  export const COMPONENT_DESCRIPTORS = buildDescriptors(IOS_NAMES);
34
39
  export const descriptorFor = makeDescriptorFor(COMPONENT_DESCRIPTORS);
@@ -1,4 +1,5 @@
1
- export type ISymbioteIntrinsic = 'symbiote-view' | 'symbiote-pressable' | 'symbiote-text' | 'symbiote-image' | 'symbiote-scroll-view' | 'symbiote-scroll-content' | 'symbiote-horizontal-scroll-view' | 'symbiote-horizontal-scroll-content' | 'symbiote-text-input' | 'symbiote-text-input-multiline' | 'symbiote-text-input-managed' | 'symbiote-text-input-multiline-managed' | 'symbiote-switch' | 'symbiote-switch-managed' | 'symbiote-activity-indicator' | 'symbiote-safe-area-view' | 'symbiote-modal' | 'symbiote-refresh-control' | 'symbiote-input-accessory-view';
1
+ export type ISymbioteIntrinsic = 'view' | 'pressable' | 'touchable-opacity' | 'touchable-native-feedback' | 'touchable-without-feedback' | 'touchable-highlight' | 'button' | 'text' | 'image' | 'image-background' | 'scroll-view' | 'scroll-content' | 'horizontal-scroll-view' | 'horizontal-scroll-content' | 'text-input' | 'text-input-multiline' | 'switch' | 'activity-indicator' | 'activity-indicator-spinner' | 'safe-area-view' | 'modal' | 'refresh-control' | 'sticky-header' | 'input-accessory-view';
2
+ export type ICrossTypedIntrinsics<LooseProps, Crossed extends Partial<Record<ISymbioteIntrinsic, unknown>>> = Omit<Record<ISymbioteIntrinsic, LooseProps>, keyof Crossed> & Crossed;
2
3
  export interface IComponentDescriptor {
3
4
  component: string;
4
5
  isText: boolean;