@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,205 @@
1
+ // TouchableHighlight 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, THE SAME SIMPLIFICATION EVERY WRAPPER ALREADY SHIPPED. RN's own TouchableHighlight
5
+ // renders a container View (the responder, the underlay backgroundColor, the whole accessibility
6
+ // fold) and CLONES an extra opacity style onto its single child (TouchableHighlight.js:281-320,
7
+ // `_createExtraStyles` + `cloneElement`). This tag folds both onto the ONE node instead, in
8
+ // `foldTouchableHighlightUnderlay` (`SymbioteFabricProps.cpp`) now — see that rule's own header.
9
+ //
10
+ // KNOWN GAP, and it predates both this port and the fix it reverts. Composing `opacity` onto the
11
+ // SAME node as the underlay's `backgroundColor` fades the underlay itself, so `underlayColor:
12
+ // 'black'` paints grey rather than black — every adapter's wrapper shipped this, and the rule's own
13
+ // header above says so on purpose ("The port keeps that, it does not reopen it"). A 2026-09-15 fix
14
+ // (fd39750b) closed it pointwise, in JS, on this one tag; this revert returns to the shared
15
+ // (buggy) behavior every adapter already had, which is correct for THIS merge — a merge that also
16
+ // changes behavior is unattributable.
17
+ //
18
+ // The reason the old header gave for not splitting it — "needs a child to target and a framework
19
+ // component holding an opaque children slot cannot reach one safely" — no longer holds: that was
20
+ // true of JS wrappers, not of the engine. The DESCENDANT seam this needs already exists: a rule
21
+ // keyed on `IOwner.tagName`, the same shape `foldCloneOntoChild` uses to reach a
22
+ // TouchableNativeFeedback child's own `fabricProps()` call and write onto ITS payload — the write
23
+ // `IFirstChild` cannot do, since that seam only reads a child, from the PARENT's own call.
24
+ //
25
+ // TODO: what actually blocks it is one missing field. `IOwner` carries `{ props, tagName,
26
+ // hasPressListener }`; the child would need the owner's `underlayShown` too (currently only on
27
+ // `ISelf`, the node's own state) to decide whether to paint at all. Closing this is a new
28
+ // descendant-keyed rule plus that one field on `IOwner`, not a `foldTouchableHighlightUnderlay`
29
+ // rewrite.
30
+ //
31
+ // WHAT IS SHARED AND WHAT IS NEW. The underlay show/hide state machine
32
+ // (createHighlightUnderlayHandlers/createHighlightUnderlayRuntime, `../state/touchable`) is already
33
+ // framework-agnostic — every wrapper already calls it. New here is only WHERE `shown` lives (a
34
+ // WeakMap keyed by node, the same shape `./touchable-opacity`'s `states` map uses) and how a flip
35
+ // reaches Fabric: `markPropsDirty` + `requestCommitFor`, the same primitives `./pressable`'s
36
+ // `setPressed` composer uses to re-fold after an internal state change — NOT
37
+ // `setAnimatedBehaviorStyle`, which exists for a continuously-driven `AnimatedValue` and would be
38
+ // the wrong tool for a discrete boolean swap with no easing.
39
+ //
40
+ // The press machine is `./pressable`'s, composed through `createPressBehavior` — one node may hold
41
+ // exactly one press machine, so this tag must not also register the plain `pressable` behavior.
42
+ //
43
+ // REGISTRATION IS THE HAZARD, not the machine — see `./pressable` for why each adapter's entry does
44
+ // a bare `import './register';` that the barrel does not re-export.
45
+ //
46
+ // TODO(rn-parity, low priority): `TouchableHighlight.js:206-232` also gates the underlay show/hide
47
+ // on `onFocus`/`onBlur` (TV remote) and skips its own press-triggered show/hide-after-delay
48
+ // entirely when `Platform.isTV`. Neither is wired here. Deliberately not implemented — dead on
49
+ // every device this project targets (no tvOS build). Audit skill, "Found, NOT fixed: TV
50
+ // (Platform.isTV) focus/blur feedback on TouchableOpacity/Highlight".
51
+ import { markPropsDirty, registerHostBehavior, requestCommitFor, setNodeUnderlayShown, propOf, } from '@symbiote-native/engine';
52
+ import { resolveButtonDisabled } from '../view/render-button.js';
53
+ import { asAccessibilityState, booleanOr, createPressBehavior, } from './pressable.js';
54
+ import { createHighlightUnderlayHandlers, createHighlightUnderlayRuntime, hasTouchablePressHandler, } from '../state/touchable.js';
55
+ export const TOUCHABLE_HIGHLIGHT_TAG = 'touchable-highlight';
56
+ // TouchableHighlight.js:194-197 — `disabled ?? accessibilityState.disabled` (RN omits aria-disabled
57
+ // here, unlike Opacity/Button/NativeFeedback — an upstream inconsistency this matches rather than
58
+ // "fixes"). Without it, `accessibilityState={{disabled: true}}` alone greys the label but a press
59
+ // still fires here.
60
+ const touchableHighlightDisabled = props => resolveButtonDisabled(booleanOr(props.disabled), undefined, asAccessibilityState(props.accessibilityState));
61
+ const states = new WeakMap();
62
+ function numberOr(value, fallback) {
63
+ return typeof value === 'number' ? value : fallback;
64
+ }
65
+ // Runs once per GESTURE, matching `./touchable-opacity`'s `refine` — the config it reads is
66
+ // whatever the props hold when a finger lands, and the underlay handlers it builds read `shown`
67
+ // through the closure below rather than owning it, so a re-arm reads the current props.
68
+ const refine = (node, config) => {
69
+ const state = states.get(node);
70
+ if (state === undefined)
71
+ return config;
72
+ const hasPressHandler = hasTouchablePressHandler({
73
+ onPress: config.onPress,
74
+ onPressIn: config.onPressIn,
75
+ onPressOut: config.onPressOut,
76
+ onLongPress: config.onLongPress,
77
+ });
78
+ const underlay = createHighlightUnderlayHandlers({
79
+ delayPressOut: numberOr(propOf(node, 'delayPressOut'), 0),
80
+ hasPressHandler,
81
+ testOnlyPressed: propOf(node, 'testOnly_pressed') === true,
82
+ schedule: (callback, ms) => {
83
+ const id = setTimeout(() => {
84
+ state.timers.delete(id);
85
+ callback();
86
+ }, ms);
87
+ state.timers.add(id);
88
+ return () => {
89
+ clearTimeout(id);
90
+ state.timers.delete(id);
91
+ };
92
+ },
93
+ }, state.runtime, {
94
+ setShown: (shown) => {
95
+ if (state.shown === shown)
96
+ return;
97
+ state.shown = shown;
98
+ // THE BIT, and nothing else. No style is resolved here: what a showing underlay looks like
99
+ // is `foldTouchableHighlightUnderlay` in the engine, which reads the same two props it
100
+ // already strips from the payload. `markPropsDirty` is still owed — the op marks the host's
101
+ // node, and this marks the JS side's so the commit walk visits it.
102
+ setNodeUnderlayShown(node, shown);
103
+ markPropsDirty(node);
104
+ requestCommitFor(node);
105
+ },
106
+ onShowUnderlay: () => {
107
+ const onShowUnderlay = propOf(node, 'onShowUnderlay');
108
+ if (typeof onShowUnderlay === 'function')
109
+ onShowUnderlay();
110
+ },
111
+ onHideUnderlay: () => {
112
+ const onHideUnderlay = propOf(node, 'onHideUnderlay');
113
+ if (typeof onHideUnderlay === 'function')
114
+ onHideUnderlay();
115
+ },
116
+ });
117
+ return {
118
+ ...config,
119
+ // TouchableHighlight.js:203, verbatim: RN hands Pressability a 0 floor here too — what holds
120
+ // the underlay visible is the hold TIMER above, not a press-duration floor.
121
+ minPressDuration: 0,
122
+ onPress: (event) => {
123
+ underlay.handlePress(event);
124
+ config.onPress?.(event);
125
+ },
126
+ onPressIn: (event) => {
127
+ underlay.handlePressIn(event);
128
+ config.onPressIn?.(event);
129
+ },
130
+ onPressOut: (event) => {
131
+ underlay.handlePressOut(event);
132
+ config.onPressOut?.(event);
133
+ },
134
+ };
135
+ };
136
+ // THE UNDERLAY FOLD LEFT THIS FILE ON 2026-09-18, and with it the last `payloadFold` on this tag —
137
+ // this behavior now costs ZERO trips into JS per commit, down from one on every commit it was dirty
138
+ // in (which for a touchable is FIVE at mount alone, because the opacity settle re-commits it).
139
+ //
140
+ // THE NOTE IT REPLACES SAID THE UNDERLAY WAS "the genuine unportable article" BECAUSE IT IS BUILT
141
+ // FROM LIVE PRESS STATE. Half right, and the half it got wrong is the reusable part: `shown` really
142
+ // does flip mid-gesture and really is JS's, but the RULE was never made of it. Of four inputs, three
143
+ // were already portable — `underlayColor` and `activeOpacity` are ordinary props (ones the engine
144
+ // ALREADY strips), and `_hasPressHandler` is listener EXISTENCE, which has crossed since
145
+ // `OP_SET_OWNED_LISTENER`. The fourth is one bit. "JS holds it" was never the same claim as "only JS
146
+ // can compute it", and this is the third time that distinction has moved a rule.
147
+ //
148
+ // WHAT CROSSES AND WHAT DOES NOT. `setNodeUnderlayShown` sends the bit on a flip — twice a tap. The
149
+ // hold timer, the `press`-then-`pressOut` ordering, the re-arm on a second tap and the
150
+ // `onShowUnderlay` / `onHideUnderlay` callbacks all stay here, where Pressability is, because they
151
+ // run at gesture rate and call into app code. That is the browser's line too: a UA paints `:active`,
152
+ // the page decides what a click means.
153
+ //
154
+ // `focusable` left earlier the same day and carried a bug out with it — it read `props.disabled` off
155
+ // the BAG, which the engine's pressable rule strips, so every DISABLED highlight stayed in the focus
156
+ // order. One rule serves both touchable tags now (`foldPressableProps`), so there is no second copy
157
+ // to drift; pinned in `core/engine/cpp/tests/js/touchable-focusable-payload.itest.ts`.
158
+ //
159
+ // The underlay's own contract: `core/engine/cpp/tests/js/touchable-highlight-underlay.itest.ts`.
160
+ // A listener flip changes no payload by itself, so the commit after it is a no-op and no fold
161
+ // re-runs (`IHostBehavior.onOwnedListenerChange`) — same reason `./touchable-opacity` carries this.
162
+ function onOwnedListenerChange(node, name) {
163
+ if (name !== 'press')
164
+ return;
165
+ markPropsDirty(node);
166
+ requestCommitFor(node);
167
+ }
168
+ /**
169
+ * The behavior as PARTS, mirroring `./touchable-opacity`'s shape — a tag that is a
170
+ * TouchableHighlight plus something composes this instead of re-implementing the underlay.
171
+ */
172
+ export function createTouchableHighlightBehavior(disabledOf) {
173
+ const machine = createPressBehavior(refine, disabledOf);
174
+ return {
175
+ ...machine,
176
+ attach(node) {
177
+ states.set(node, {
178
+ shown: false,
179
+ runtime: createHighlightUnderlayRuntime(),
180
+ timers: new Set(),
181
+ });
182
+ machine.attach(node);
183
+ },
184
+ detach(node) {
185
+ machine.detach(node);
186
+ const state = states.get(node);
187
+ if (state === undefined)
188
+ return;
189
+ for (const id of state.timers)
190
+ clearTimeout(id);
191
+ state.timers.clear();
192
+ states.delete(node);
193
+ },
194
+ };
195
+ }
196
+ // Idempotent: an adapter entry may be imported more than once in a bundle.
197
+ export function registerTouchableHighlightBehavior() {
198
+ const touchable = createTouchableHighlightBehavior(touchableHighlightDisabled);
199
+ // Spread whole: the tag used to override `attach` purely to bind a per-node `payloadFold`, and
200
+ // with the underlay rule in the engine there is nothing left to add.
201
+ registerHostBehavior(TOUCHABLE_HIGHLIGHT_TAG, {
202
+ ...touchable,
203
+ onOwnedListenerChange,
204
+ });
205
+ }
@@ -0,0 +1,20 @@
1
+ import { type IPressConfigRefinement } from './pressable';
2
+ export declare const TOUCHABLE_NATIVE_FEEDBACK_TAG = "touchable-native-feedback";
3
+ /**
4
+ * TNF's own Pressability config, applied to whatever node carries the responder.
5
+ *
6
+ * SHARED WITH `./button`, which is what this file's arrival makes possible. Button's Android arm
7
+ * held a private copy with a comment saying a future `touchable-native-feedback` tag could not
8
+ * reuse it "while the wrappers wrap" — the tag is here now and the responder node is a parameter
9
+ * either way, so the copy is gone.
10
+ *
11
+ * `minPressDuration: 0` is UNCONDITIONAL in RN (:226), not Android-gated: TNF has no feedback
12
+ * machine of its own to hold, and the press machine's 130 ms deactivation floor would only defer
13
+ * `onPressOut` — which on Android leaves `setPressed(false)` ~130 ms late and the ripple lit after
14
+ * the finger is gone. The two view commands ARE Android-gated (:257,:276): the ripple is a drawable
15
+ * Android animates off the view's pressed state, and the JS responder consumes the touch before
16
+ * Android's own pressed handling ever runs, so without them the background is installed and never
17
+ * moves.
18
+ */
19
+ export declare const nativeFeedbackRefinement: IPressConfigRefinement;
20
+ export declare function registerTouchableNativeFeedbackBehavior(): void;
@@ -0,0 +1,254 @@
1
+ // TouchableNativeFeedback as an ENGINE-NODE behavior — the one primitive that must commit NO NODE
2
+ // OF ITS OWN (`.claude/rules/host-primitive-tier.md`, tier 2).
3
+ //
4
+ // RN'S TNF RENDERS NOTHING. `render()` takes `React.Children.only(this.props.children)`
5
+ // (TouchableNativeFeedback.js:289) and returns `cloneElement(element, {…}, ...children)` (:339) —
6
+ // the child IS the responder, carries the ripple background, and takes the whole accessibility
7
+ // fold. All five of our adapters render a `Pressable` around a feedback `View` instead, so every
8
+ // TNF in every app commits a node RN does not, and the mistake has already spread: our Pressable
9
+ // wrapper and our TouchableOpacity wrapper both copied that inner view from here
10
+ // (`.claude/rules/adapter-parity-audit.md`, "the OTHER half turned up a gap on BOTH paths").
11
+ //
12
+ // WHY THE FIX CANNOT BE PER ADAPTER. At the FRAMEWORK level React, Vue and Solid can clone a child
13
+ // element; Svelte's children arrive as a snippet and Angular's through `<ng-content>`, and neither
14
+ // can have props written onto them there. At the ENGINE level all five can reach the child node.
15
+ // So the clone belongs below the frameworks, once, or it is five copies of one fold.
16
+ //
17
+ // ---------------------------------------------------------------------------------------------
18
+ // THE SHAPE, and which existing engine seam does each half:
19
+ //
20
+ // commits nothing the tag resolves to ANCHOR_COMPONENT (component-names), so the node is
21
+ // BORN skipped: `renderableChildren` (commit.ts) drops it and flattens its
22
+ // child into the grandparent's renderable list. Not a new concept — that is
23
+ // precisely what an anchor is, and TNF is its third instance after Vue's
24
+ // fragment placeholders and Angular's non-painting component hosts.
25
+ // finds its child `onChildInserted`, the one hook this needed. `buildStructure` cannot do it:
26
+ // it runs at `attach`, inside `createElement`, and the child is the
27
+ // FRAMEWORK'S and does not exist yet.
28
+ // clones the props a `payloadFold` on the CHILD reading the owner's bag, re-run because
29
+ // `slotDerived` marks the child's props dirty on every owner write. The
30
+ // alternative — redirecting each prop onto the child through `slotPropsExcept`
31
+ // — was rejected: adapters disagree about ORDER (React writes props before
32
+ // children, Vue mounts children before props), so a redirect silently drops
33
+ // every prop written before the child arrives, on some adapters only.
34
+ // presses the press machine on the CHILD, reading the OWNER (`attachPressMachine`'s
35
+ // `source`). It cannot live on the owner: `bubble` (events/index.ts) skips
36
+ // anchors for listener lookup, and `handOverNativeResponder` has no Fabric
37
+ // handle to hand native for an uncommitted node. So a responder on this tag
38
+ // could never fire — checked before the design, not after.
39
+ // ---------------------------------------------------------------------------------------------
40
+ // KNOWN LIMITS, stated rather than left to be found:
41
+ //
42
+ // 1. EXACTLY ONE CHILD, as RN enforces with `React.Children.only`. A second child lands INSIDE the
43
+ // first (the ordinary `childHost` redirect) rather than throwing. Nothing in RN's surface
44
+ // produces that shape.
45
+ // 2. A child REMOVED from a still-mounted TNF leaves its press machine armed until the TNF itself
46
+ // unmounts (`detach` releases whatever `childHost` then holds). Unreachable through the
47
+ // frameworks we drive: Solid's remove-then-reinsert move and Svelte's parking both move the TNF,
48
+ // never its single child, and there is no sibling to reorder it against.
49
+ // 3. `style` is NOT cloned, matching RN — its clone list (:342-390) is closed, and a `style` on a
50
+ // TNF stays on a node that never commits. RN's TNF declares no style prop either.
51
+ //
52
+ // REGISTRATION IS THE HAZARD, not the machine — see `./pressable` for why each adapter entry does a
53
+ // bare `import './register';` that the barrel does not re-export. Registered by ALL FIVE adapters
54
+ // since 2026-09-09, in the same commit that deleted the five wrappers, which is what makes it safe:
55
+ // while a wrapper still built its own Pressable + feedback View, registering would have left every
56
+ // TouchableNativeFeedback with two responders.
57
+ import { SLOT_DERIVED_ALL, appListenerFor, dispatchViewCommand, markPropsDirty, Platform, registerHostBehavior, requestCommitFor, setBehaviorListener, } from '@symbiote-native/engine';
58
+ import { resolveButtonDisabled } from '../view/render-button.js';
59
+ import { asAccessibilityState, attachPressMachine, booleanOr, detachPressMachine, } from './pressable.js';
60
+ export const TOUCHABLE_NATIVE_FEEDBACK_TAG = 'touchable-native-feedback';
61
+ // TouchableNativeFeedback.js:214-221 — `disabled != null ? disabled : (aria-disabled ??
62
+ // accessibilityState.disabled)`, read by the OWNER's Pressability config (:213), same three-way
63
+ // answer Button/Opacity resolve. The clone's own `accessibilityState`/`focusable` already use the
64
+ // RAW `disabled` prop correctly (RN does too, :311-321,:369-371) — only the press machine, which
65
+ // lives on the CHILD and reads the OWNER, was missing this.
66
+ const touchableNativeFeedbackDisabled = props => resolveButtonDisabled(booleanOr(props.disabled), booleanOr(props['aria-disabled']), asAccessibilityState(props.accessibilityState));
67
+ // Read once, like `./button`'s: the platform cannot change under a running app.
68
+ const IS_ANDROID = Platform.OS === 'android';
69
+ // WHAT THE OWNER'S WRITES DIRTY, and it is EVERY name rather than a list of thirty.
70
+ //
71
+ // RN's clone list (`TouchableNativeFeedback.js:349-390`) lived here until 2026-09-18 as
72
+ // `CLONED_PROPS` + `DERIVED_FROM` + `ARIA_ALIAS_KEYS`, feeding `slotDerived`. The clone itself is
73
+ // `foldCloneOntoChild` in `SymbioteFabricProps.cpp`, so the list had stopped being the rule and
74
+ // become a MIRROR of `kNativeFeedbackClonedKeys` — two lists that must agree, failing silently (the
75
+ // clone goes stale on the one prop a list forgot) when they drift.
76
+ //
77
+ // `SLOT_DERIVED_ALL` is both the honest spelling and the cheaper one to keep: a `cloneElement` owner
78
+ // re-clones on every render whatever changed, so it never derived its slot from a NAMED set in the
79
+ // first place. The cost is a false dirty on an owner prop the clone does not carry, and for this tag
80
+ // that is nearly empty — its owner is an anchor whose props reach Fabric nowhere else, so every name
81
+ // it holds is either cloned or consumed by the press machine.
82
+ const SLOT_DERIVED = [SLOT_DERIVED_ALL];
83
+ // The two RN clones as EVENTS rather than props (:386-387). Owned, so the app's callback stashes on
84
+ // the owner and a trampoline installed on the child reads it at dispatch time — which keeps a fresh
85
+ // closure per render free, exactly as `ownedListeners` intends.
86
+ //
87
+ // Both are Fabric BOOLEAN-GATED events (`.claude/rules/fabric-boolean-event-gates.md`), so the
88
+ // trampoline goes in only while the app has one wired: `setBehaviorListener` writes and clears the
89
+ // flag with it, and installing eagerly would light the gate on every TNF child in the tree.
90
+ const FORWARDED_LISTENERS = [
91
+ 'layout',
92
+ 'accessibilityAction',
93
+ ];
94
+ const PRESS_LISTENERS = [
95
+ 'press',
96
+ 'pressIn',
97
+ 'pressOut',
98
+ 'pressMove',
99
+ 'longPress',
100
+ 'startShouldSetResponder',
101
+ 'responderMove',
102
+ 'responderTerminationRequest',
103
+ ];
104
+ // `asFeedbackBackground` went with the fold, and `backgroundProps` — the slot pick it fed — followed
105
+ // on 2026-09-18 as the orphan it had become. The first narrowed the app's `background` dict on its
106
+ // discriminant; the C++ rule asks only whether the value is an OBJECT and copies it into the slot,
107
+ // because the four factories that produce it (`render-touchable-native-feedback.ts`) are ours and
108
+ // the payload is not a place to re-validate what a typed factory already built.
109
+ // TouchableNativeFeedback.js:280 — `locationX ?? 0`. The bag is raw Fabric payload, so guard.
110
+ function hotspotAt(nativeEvent, key) {
111
+ const value = nativeEvent[key];
112
+ return typeof value === 'number' ? value : 0;
113
+ }
114
+ /**
115
+ * TNF's own Pressability config, applied to whatever node carries the responder.
116
+ *
117
+ * SHARED WITH `./button`, which is what this file's arrival makes possible. Button's Android arm
118
+ * held a private copy with a comment saying a future `touchable-native-feedback` tag could not
119
+ * reuse it "while the wrappers wrap" — the tag is here now and the responder node is a parameter
120
+ * either way, so the copy is gone.
121
+ *
122
+ * `minPressDuration: 0` is UNCONDITIONAL in RN (:226), not Android-gated: TNF has no feedback
123
+ * machine of its own to hold, and the press machine's 130 ms deactivation floor would only defer
124
+ * `onPressOut` — which on Android leaves `setPressed(false)` ~130 ms late and the ripple lit after
125
+ * the finger is gone. The two view commands ARE Android-gated (:257,:276): the ripple is a drawable
126
+ * Android animates off the view's pressed state, and the JS responder consumes the touch before
127
+ * Android's own pressed handling ever runs, so without them the background is installed and never
128
+ * moves.
129
+ */
130
+ export const nativeFeedbackRefinement = (node, config) => {
131
+ if (!IS_ANDROID)
132
+ return { ...config, minPressDuration: 0 };
133
+ const hotspot = (event) => {
134
+ dispatchViewCommand(node, 'hotspotUpdate', [
135
+ hotspotAt(event.nativeEvent, 'locationX'),
136
+ hotspotAt(event.nativeEvent, 'locationY'),
137
+ ]);
138
+ };
139
+ return {
140
+ ...config,
141
+ minPressDuration: 0,
142
+ onPressIn(event) {
143
+ // RN's order: hotspot first, so the ripple starts where the finger is rather than at centre.
144
+ hotspot(event);
145
+ dispatchViewCommand(node, 'setPressed', [true]);
146
+ config.onPressIn?.(event);
147
+ },
148
+ onPressMove(event) {
149
+ hotspot(event);
150
+ // RN drops the app's own onPressMove here; ours forwards it, because on a tag that callback
151
+ // is the app's and iOS already delivers it.
152
+ config.onPressMove?.(event);
153
+ },
154
+ onPressOut(event) {
155
+ dispatchViewCommand(node, 'setPressed', [false]);
156
+ config.onPressOut?.(event);
157
+ },
158
+ };
159
+ };
160
+ // `cloneFold` LEFT THIS FILE ON 2026-09-18 — it is `foldCloneOntoChild` in
161
+ // `SymbioteFabricProps.cpp`, and the seam it needed is the first rule keyed on the PARENT'S tag
162
+ // rather than on the node's own (`IOwner`). The child of a TNF is whatever the app wrote, usually a
163
+ // plain `<view>` with no tag at all, so nothing self-keyed could ever have reached it.
164
+ //
165
+ // What it cost to have had here: one JSI round trip per touchable per commit over an eighteen-key
166
+ // bag, and `tag-rule-cost.itest.ts` prices a fold by what it MARSHALS rather than by what it does.
167
+ //
168
+ // Contract: `core/engine/cpp/tests/js/clone-onto-child-payload.itest.ts`.
169
+ // Not RN's own list: RN drops these by never cloning them, and a tag has no clone to omit
170
+ // them from — they would ride into the child's payload as keys no ViewConfig declares. Same strip
171
+ // `./pressable`'s fold does for the machine-only half, applied to the owner's bag instead.
172
+ //
173
+ // (`background` / `useForeground` / `disabled` / `hitSlop` never reach the child's props at all —
174
+ // the fold only ever ADDS to the child's own bag — so nothing needs stripping here. Kept as a
175
+ // comment rather than an empty constant so the next reader does not go looking for the strip.)
176
+ // The app's callback, read at dispatch time so a fresh closure per render costs nothing.
177
+ function trampolineFor(owner, name) {
178
+ return (event) => {
179
+ const listener = appListenerFor(owner, name);
180
+ if (typeof listener === 'function')
181
+ listener(event);
182
+ };
183
+ }
184
+ function forwardListener(owner, child, name, wired) {
185
+ setBehaviorListener(child, name, wired ? trampolineFor(owner, name) : undefined);
186
+ }
187
+ // The machine plus the forwarded listeners. Split from adoption because a parked subtree comes back
188
+ // with its child and its fold intact but its machine torn down — `attach` re-runs this, and must
189
+ // NOT re-chain the fold.
190
+ function arm(owner, child) {
191
+ attachPressMachine(child, {
192
+ source: owner,
193
+ refine: nativeFeedbackRefinement,
194
+ disabledOf: touchableNativeFeedbackDisabled,
195
+ });
196
+ for (const name of FORWARDED_LISTENERS) {
197
+ forwardListener(owner, child, name, appListenerFor(owner, name) !== undefined);
198
+ }
199
+ }
200
+ function attach(node) {
201
+ // First attach has no child yet; a re-attach after the sweep does. See `arm`.
202
+ if (node.childHost !== undefined)
203
+ arm(node, node.childHost);
204
+ }
205
+ function detach(node) {
206
+ const child = node.childHost;
207
+ if (child !== undefined)
208
+ detachPressMachine(child);
209
+ }
210
+ function onChildInserted(node, child) {
211
+ const previous = node.childHost;
212
+ if (previous === child)
213
+ return;
214
+ // A replaced child: release the machine still armed on the one that left. `removeChild` clears
215
+ // `childHost` (node.ts), so this only runs when a framework inserts without removing first.
216
+ if (previous !== undefined)
217
+ detachPressMachine(previous);
218
+ node.childHost = child;
219
+ // The owner's props were very likely written BEFORE this child existed (React and Solid set props
220
+ // at createInstance), so the CLONE owes a run even though nothing was written since. Still owed
221
+ // now that the rule is in C++ and no fold is chained here: the rule runs on the child's commit,
222
+ // and a child nothing dirtied has no commit.
223
+ markPropsDirty(child);
224
+ arm(node, child);
225
+ }
226
+ // `focusable` is a function of a LISTENER, and a listener flip changes no payload by itself — so
227
+ // the commit after it is a no-op and no fold re-runs (`IHostBehavior.onOwnedListenerChange`).
228
+ function onOwnedListenerChange(node, name, wired) {
229
+ const child = node.childHost;
230
+ if (child === undefined)
231
+ return;
232
+ if (FORWARDED_LISTENERS.includes(name)) {
233
+ forwardListener(node, child, name, wired);
234
+ return;
235
+ }
236
+ if (name !== 'press')
237
+ return;
238
+ markPropsDirty(child);
239
+ requestCommitFor(child);
240
+ }
241
+ // Idempotent: an adapter entry may be imported more than once in a bundle.
242
+ export function registerTouchableNativeFeedbackBehavior() {
243
+ const behavior = {
244
+ attach,
245
+ detach,
246
+ onChildInserted,
247
+ onOwnedListenerChange,
248
+ // Owned on the OWNER, which is where the app writes them, and read from there by the machine
249
+ // on the child (`attachPressMachine`'s `source`) and by the trampolines above.
250
+ ownedListeners: [...PRESS_LISTENERS, ...FORWARDED_LISTENERS],
251
+ slotDerived: SLOT_DERIVED,
252
+ };
253
+ registerHostBehavior(TOUCHABLE_NATIVE_FEEDBACK_TAG, behavior);
254
+ }
@@ -0,0 +1,12 @@
1
+ import { type IHostBehavior } from '@symbiote-native/engine';
2
+ import { type IDisabledResolver } from './pressable';
3
+ export declare const TOUCHABLE_OPACITY_TAG = "touchable-opacity";
4
+ /**
5
+ * The behavior as PARTS, so a tag that is a TouchableOpacity plus something — `button`, which RN
6
+ * builds as exactly that (Button.js:283) — composes the fade instead of re-implementing it.
7
+ *
8
+ * Safe to hand to two tags: every piece of runtime is keyed by NODE (`states`), and `press` is
9
+ * itself already shared that way.
10
+ */
11
+ export declare function createTouchableOpacityBehavior(disabledOf?: IDisabledResolver): IHostBehavior;
12
+ export declare function registerTouchableOpacityBehavior(): void;