@symbiote-native/components 2.0.0 → 3.0.1

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 (79) hide show
  1. package/README.md +8 -9
  2. package/build/accessibility-props.d.ts +1 -1
  3. package/build/accessibility-props.js +2 -2
  4. package/build/behaviors/activity-indicator/shared.js +41 -70
  5. package/build/behaviors/button.d.ts +11 -0
  6. package/build/behaviors/button.js +101 -89
  7. package/build/behaviors/image-background.d.ts +1 -0
  8. package/build/behaviors/image-background.js +97 -81
  9. package/build/behaviors/image.d.ts +0 -1
  10. package/build/behaviors/image.js +24 -105
  11. package/build/behaviors/input-accessory-view.d.ts +0 -1
  12. package/build/behaviors/input-accessory-view.js +47 -53
  13. package/build/behaviors/pressable.d.ts +1 -1
  14. package/build/behaviors/pressable.js +89 -100
  15. package/build/behaviors/refresh-control.js +15 -2
  16. package/build/behaviors/scroll-view/index.android.js +25 -37
  17. package/build/behaviors/scroll-view/index.d.ts +1 -0
  18. package/build/behaviors/scroll-view/index.js +3 -0
  19. package/build/behaviors/scroll-view/responder.d.ts +4 -0
  20. package/build/behaviors/scroll-view/responder.js +202 -0
  21. package/build/behaviors/scroll-view/shared.d.ts +1 -3
  22. package/build/behaviors/scroll-view/shared.js +95 -95
  23. package/build/behaviors/scroll-view/sticky.d.ts +1 -0
  24. package/build/behaviors/scroll-view/sticky.js +62 -49
  25. package/build/behaviors/switch.js +43 -86
  26. package/build/behaviors/text-input.js +219 -107
  27. package/build/behaviors/touchable-highlight.js +74 -61
  28. package/build/behaviors/touchable-native-feedback.js +43 -122
  29. package/build/behaviors/touchable-opacity.js +71 -59
  30. package/build/behaviors/touchable-without-feedback.js +35 -100
  31. package/build/component-names/index.android.js +7 -11
  32. package/build/component-names/index.ios.js +0 -7
  33. package/build/component-names/shared.d.ts +1 -1
  34. package/build/index.d.ts +15 -21
  35. package/build/index.js +21 -28
  36. package/build/resolve-intrinsic.js +3 -9
  37. package/build/scroll-view-commands.d.ts +1 -9
  38. package/build/scroll-view-commands.js +13 -74
  39. package/build/state/flat-list.d.ts +2 -2
  40. package/build/state/flat-list.js +10 -2
  41. package/build/state/pressable.d.ts +6 -1
  42. package/build/state/pressable.js +63 -28
  43. package/build/state/section-list.d.ts +2 -0
  44. package/build/state/section-list.js +14 -7
  45. package/build/state/text-input.d.ts +7 -40
  46. package/build/state/text-input.js +17 -186
  47. package/build/state/touchable.d.ts +1 -0
  48. package/build/state/touchable.js +11 -8
  49. package/build/state/virtualized-list-reducer.d.ts +2 -2
  50. package/build/state/virtualized-list.d.ts +6 -6
  51. package/build/state/virtualized-list.js +71 -37
  52. package/build/text-props.d.ts +0 -8
  53. package/build/text-props.js +14 -25
  54. package/build/view/render-button.d.ts +1 -29
  55. package/build/view/render-button.js +44 -81
  56. package/build/view/render-image/index.d.ts +14 -1
  57. package/build/view/render-image/index.js +22 -147
  58. package/build/view/render-input-accessory-view.d.ts +1 -5
  59. package/build/view/render-input-accessory-view.js +26 -48
  60. package/build/view/render-keyboard-avoiding-view.d.ts +7 -1
  61. package/build/view/render-keyboard-avoiding-view.js +40 -1
  62. package/build/view/render-modal.d.ts +1 -1
  63. package/build/view/render-modal.js +15 -5
  64. package/build/view/render-pressable/index.d.ts +1 -0
  65. package/build/view/render-pressable/index.js +4 -0
  66. package/build/view/render-scroll-view.d.ts +0 -4
  67. package/build/view/render-scroll-view.js +3 -49
  68. package/build/view/render-switch.d.ts +0 -14
  69. package/build/view/render-switch.js +4 -42
  70. package/build/view/render-touchable-highlight.d.ts +1 -0
  71. package/build/view/render-touchable-native-feedback.d.ts +0 -1
  72. package/build/view/render-touchable-native-feedback.js +15 -9
  73. package/host-primitives.cjs +49 -203
  74. package/host-primitives.d.cts +0 -1
  75. package/package.json +3 -7
  76. package/build/fold-host-bag.d.ts +0 -15
  77. package/build/fold-host-bag.js +0 -99
  78. package/build/view/render-text-input.d.ts +0 -11
  79. package/build/view/render-text-input.js +0 -39
@@ -1,17 +1,32 @@
1
1
  // TouchableHighlight as an ENGINE-NODE behavior, so it can be an intrinsic tag instead of a
2
2
  // framework component (`.claude/rules/host-primitive-tier.md`, tier 2).
3
3
  //
4
- // ONE NODE, THE SAME SIMPLIFICATION EVERY WRAPPER ALREADY SHIPS. RN's own TouchableHighlight
4
+ // ONE NODE, THE SAME SIMPLIFICATION EVERY WRAPPER ALREADY SHIPPED. RN's own TouchableHighlight
5
5
  // renders a container View (the responder, the underlay backgroundColor, the whole accessibility
6
6
  // fold) and CLONES an extra opacity style onto its single child (TouchableHighlight.js:281-320,
7
- // `_createExtraStyles` + `cloneElement`) — closer to TouchableNativeFeedback's clone-onto-child
8
- // shape than to TouchableOpacity's true single node. Every wrapper (Svelte's own comment: "ITEM 7
9
- // IS DELIBERATELY NOT FIXED HERE... exactly as Solid and Angular decided") folds BOTH the underlay
10
- // and the child opacity onto the ONE node instead, because splitting them needs a child to target
11
- // and a framework component holding an opaque children snippet/slot cannot reach one safely. This
12
- // port keeps that already-shipped, already-cross-adapter simplification rather than reopening it —
13
- // `render-touchable-highlight.ts`'s own header says the shared layer "takes no position on where
14
- // they land", so this is a legitimate placement choice, not a new shortcut.
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.
15
30
  //
16
31
  // WHAT IS SHARED AND WHAT IS NEW. The underlay show/hide state machine
17
32
  // (createHighlightUnderlayHandlers/createHighlightUnderlayRuntime, `../state/touchable`) is already
@@ -27,19 +42,26 @@
27
42
  //
28
43
  // REGISTRATION IS THE HAZARD, not the machine — see `./pressable` for why each adapter's entry does
29
44
  // a bare `import './register';` that the barrel does not re-export.
30
- import { appListenerFor, markPropsDirty, registerHostBehavior, requestCommitFor, } from '@symbiote-native/engine';
31
- import { resolveTouchableFocusable } from '../view/render-pressable/index.js';
32
- import { accessibleUnlessOptedOut, booleanOr, createPressBehavior, } from './pressable.js';
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';
33
54
  import { createHighlightUnderlayHandlers, createHighlightUnderlayRuntime, hasTouchablePressHandler, } from '../state/touchable.js';
34
- import { resolveHighlightExtraStyles } from '../view/render-touchable-highlight.js';
35
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));
36
61
  const states = new WeakMap();
37
62
  function numberOr(value, fallback) {
38
63
  return typeof value === 'number' ? value : fallback;
39
64
  }
40
- function stringOr(value, fallback) {
41
- return typeof value === 'string' ? value : fallback;
42
- }
43
65
  // Runs once per GESTURE, matching `./touchable-opacity`'s `refine` — the config it reads is
44
66
  // whatever the props hold when a finger lands, and the underlay handlers it builds read `shown`
45
67
  // through the closure below rather than owning it, so a re-arm reads the current props.
@@ -54,8 +76,9 @@ const refine = (node, config) => {
54
76
  onLongPress: config.onLongPress,
55
77
  });
56
78
  const underlay = createHighlightUnderlayHandlers({
57
- delayPressOut: numberOr(node.props.delayPressOut, 0),
79
+ delayPressOut: numberOr(propOf(node, 'delayPressOut'), 0),
58
80
  hasPressHandler,
81
+ testOnlyPressed: propOf(node, 'testOnly_pressed') === true,
59
82
  schedule: (callback, ms) => {
60
83
  const id = setTimeout(() => {
61
84
  state.timers.delete(id);
@@ -72,16 +95,21 @@ const refine = (node, config) => {
72
95
  if (state.shown === shown)
73
96
  return;
74
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);
75
103
  markPropsDirty(node);
76
104
  requestCommitFor(node);
77
105
  },
78
106
  onShowUnderlay: () => {
79
- const onShowUnderlay = node.props.onShowUnderlay;
107
+ const onShowUnderlay = propOf(node, 'onShowUnderlay');
80
108
  if (typeof onShowUnderlay === 'function')
81
109
  onShowUnderlay();
82
110
  },
83
111
  onHideUnderlay: () => {
84
- const onHideUnderlay = node.props.onHideUnderlay;
112
+ const onHideUnderlay = propOf(node, 'onHideUnderlay');
85
113
  if (typeof onHideUnderlay === 'function')
86
114
  onHideUnderlay();
87
115
  },
@@ -105,41 +133,30 @@ const refine = (node, config) => {
105
133
  },
106
134
  };
107
135
  };
108
- // `id -> nativeID`, the fold every un-lowered wrapper's `foldHostBag` already applies. Not read off
109
- // `HOST_PRIMITIVES`, matching `./touchable-opacity`'s own inline check — that spec entry is
110
- // deliberately withheld until this primitive's wrapper collapses to one node everywhere.
111
- const foldPayload = props => {
112
- const next = { ...props };
113
- if (Object.hasOwn(next, 'id')) {
114
- next.nativeID = next.id;
115
- delete next.id;
116
- }
117
- next.accessible = accessibleUnlessOptedOut(props);
118
- return next;
119
- };
120
- function tagFold(node) {
121
- return props => {
122
- const next = foldPayload(props);
123
- const state = states.get(node);
124
- const hasPressHandler = appListenerFor(node, 'press') !== undefined ||
125
- appListenerFor(node, 'pressIn') !== undefined ||
126
- appListenerFor(node, 'pressOut') !== undefined ||
127
- appListenerFor(node, 'longPress') !== undefined;
128
- const extra = state === undefined
129
- ? undefined
130
- : resolveHighlightExtraStyles({
131
- shown: state.shown,
132
- hasPressHandler,
133
- underlayColor: stringOr(props.underlayColor, undefined),
134
- activeOpacity: numberOr(props.activeOpacity, NaN) || undefined,
135
- });
136
- if (extra !== undefined) {
137
- next.style = [props.style, extra.underlay, extra.child];
138
- }
139
- next.focusable = resolveTouchableFocusable(booleanOr(props.focusable), appListenerFor(node, 'press') !== undefined, booleanOr(props.disabled));
140
- return next;
141
- };
142
- }
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`.
143
160
  // A listener flip changes no payload by itself, so the commit after it is a no-op and no fold
144
161
  // re-runs (`IHostBehavior.onOwnedListenerChange`) — same reason `./touchable-opacity` carries this.
145
162
  function onOwnedListenerChange(node, name) {
@@ -156,7 +173,6 @@ export function createTouchableHighlightBehavior(disabledOf) {
156
173
  const machine = createPressBehavior(refine, disabledOf);
157
174
  return {
158
175
  ...machine,
159
- foldPayload,
160
176
  attach(node) {
161
177
  states.set(node, {
162
178
  shown: false,
@@ -179,14 +195,11 @@ export function createTouchableHighlightBehavior(disabledOf) {
179
195
  }
180
196
  // Idempotent: an adapter entry may be imported more than once in a bundle.
181
197
  export function registerTouchableHighlightBehavior() {
182
- const touchable = createTouchableHighlightBehavior();
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.
183
201
  registerHostBehavior(TOUCHABLE_HIGHLIGHT_TAG, {
184
202
  ...touchable,
185
- // Only the TAG binds a per-node fold — see `./touchable-opacity`'s identical comment.
186
- attach(node) {
187
- touchable.attach(node);
188
- node.payloadFold = tagFold(node);
189
- },
190
203
  onOwnedListenerChange,
191
204
  });
192
205
  }
@@ -53,61 +53,33 @@
53
53
  // bare `import './register';` that the barrel does not re-export. Registered by ALL FIVE adapters
54
54
  // since 2026-09-09, in the same commit that deleted the five wrappers, which is what makes it safe:
55
55
  // while a wrapper still built its own Pressable + feedback View, registering would have left every
56
- // TouchableNativeFeedback with two responders. There is no `-managed` twin and none is possible —
57
- // this tag commits no node, so a second spelling would have nothing to name.
58
- import { ARIA_ALIAS_KEYS, appListenerFor, dispatchViewCommand, foldAriaProps, markPropsDirty, Platform, registerHostBehavior, requestCommitFor, setBehaviorListener, } from '@symbiote-native/engine';
59
- import { resolveDisabledAccessibilityState, resolveTouchableFocusable, } from '../view/render-pressable/index.js';
60
- import { backgroundProps, selectableBackground, } from '../view/render-touchable-native-feedback.js';
61
- import { accessibleUnlessOptedOut, asAccessibilityState, attachPressMachine, booleanOr, detachPressMachine, } from './pressable.js';
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';
62
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));
63
67
  // Read once, like `./button`'s: the platform cannot change under a running app.
64
68
  const IS_ANDROID = Platform.OS === 'android';
65
- // TouchableNativeFeedback.js:349-390, verbatim and in RN's own order. A CLOSED list, not a
66
- // passthrough: RN clones exactly these and nothing else, so a prop it does not name stays behind.
67
- // `accessibilityState`, `accessible`, `focusable` and `nativeID` are cloned too and are computed
68
- // below; `onLayout` and `onAccessibilityAction` are cloned as LISTENERS and forwarded by
69
- // `FORWARDED_LISTENERS`.
70
- const CLONED_PROPS = [
71
- 'accessibilityHint',
72
- 'accessibilityLanguage',
73
- 'accessibilityLabel',
74
- 'accessibilityRole',
75
- 'accessibilityActions',
76
- 'accessibilityValue',
77
- 'importantForAccessibility',
78
- 'accessibilityViewIsModal',
79
- 'accessibilityLiveRegion',
80
- 'accessibilityElementsHidden',
81
- 'hasTVPreferredFocus',
82
- 'hitSlop',
83
- 'nextFocusDown',
84
- 'nextFocusForward',
85
- 'nextFocusLeft',
86
- 'nextFocusRight',
87
- 'nextFocusUp',
88
- 'testID',
89
- ];
90
- // Owner props the cloned payload is COMPUTED from, on top of the verbatim list above. `focusable`
91
- // also derives from the `press` LISTENER, which is not a prop and is handled by
92
- // `onOwnedListenerChange` — a listener flip dirties no payload by itself.
93
- const DERIVED_FROM = [
94
- 'background',
95
- 'useForeground',
96
- 'accessible',
97
- 'accessibilityState',
98
- 'disabled',
99
- 'focusable',
100
- 'id',
101
- 'nativeID',
102
- ];
103
- // Every owner name the child's payload reads. Without it the clone is correct at mount and frozen
104
- // forever after: `markPropsDirty` bubbles UP, so an owner write never reaches the child on its own.
105
- // The aria half comes off the engine's own list rather than a second copy of it.
106
- const SLOT_DERIVED = [
107
- ...CLONED_PROPS,
108
- ...DERIVED_FROM,
109
- ...ARIA_ALIAS_KEYS,
110
- ];
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];
111
83
  // The two RN clones as EVENTS rather than props (:386-387). Owned, so the app's callback stashes on
112
84
  // the owner and a trampoline installed on the child reads it at dispatch time — which keeps a fresh
113
85
  // closure per render free, exactly as `ownedListeners` intends.
@@ -129,33 +101,11 @@ const PRESS_LISTENERS = [
129
101
  'responderMove',
130
102
  'responderTerminationRequest',
131
103
  ];
132
- function stringOr(value) {
133
- return typeof value === 'string' ? value : undefined;
134
- }
135
- // The background dicts our own factories produce, narrowed on the discriminant rather than cast.
136
- // Anything else reads as "not supplied", which is RN's `background === undefined` branch.
137
- function asFeedbackBackground(value) {
138
- if (typeof value !== 'object' || value === null)
139
- return undefined;
140
- const type = Reflect.get(value, 'type');
141
- if (type !== 'ThemeAttrAndroid' && type !== 'RippleAndroid')
142
- return undefined;
143
- const attribute = Reflect.get(value, 'attribute');
144
- const rippleRadius = Reflect.get(value, 'rippleRadius');
145
- const radius = typeof rippleRadius === 'number' ? rippleRadius : undefined;
146
- if (type === 'ThemeAttrAndroid') {
147
- return attribute === 'selectableItemBackgroundBorderless'
148
- ? { type, attribute, rippleRadius: radius }
149
- : { type, attribute: 'selectableItemBackground', rippleRadius: radius };
150
- }
151
- const color = Reflect.get(value, 'color');
152
- return {
153
- type,
154
- color: typeof color === 'string' ? color : null,
155
- borderless: Reflect.get(value, 'borderless') === true,
156
- rippleRadius: radius,
157
- };
158
- }
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.
159
109
  // TouchableNativeFeedback.js:280 — `locationX ?? 0`. The bag is raw Fabric payload, so guard.
160
110
  function hotspotAt(nativeEvent, key) {
161
111
  const value = nativeEvent[key];
@@ -207,47 +157,16 @@ export const nativeFeedbackRefinement = (node, config) => {
207
157
  },
208
158
  };
209
159
  };
210
- /**
211
- * `cloneElement(element, {…})` as a payload fold: the child's own props first, the owner's clone
212
- * list over them.
213
- *
214
- * OVERWRITING WITH `undefined` IS DELIBERATE and is RN's behaviour, not an accident of the spread.
215
- * React's `cloneElement` assigns every key of its config, so a TNF with no `accessibilityLabel`
216
- * clears the child's; `fabricProps` skips undefined values, which reproduces that exactly.
217
- *
218
- * Pure, as `IPayloadFold` requires — the owner is read, never written.
219
- */
220
- function cloneFold(owner, inner) {
221
- return (props) => {
222
- const next = {
223
- ...(inner === undefined ? props : inner(props)),
224
- };
225
- // The owner's aria props are on the OWNER, so the engine's own fold at `fabricProps` — which
226
- // reads the node being committed — never sees them. Run it here over the source bag instead of
227
- // restating any of `:349-390`'s aria half; `hasAriaAlias` is the same sticky gate `fabricProps`
228
- // uses, and the fold returns its input by identity when there is nothing to do.
229
- const source = owner.hasAriaAlias
230
- ? foldAriaProps(owner.props)
231
- : owner.props;
232
- for (const key of CLONED_PROPS)
233
- next[key] = source[key];
234
- // :369-372. `onPress` is an OWNED name, so it lives in the stash and never in `props`.
235
- const disabled = booleanOr(source.disabled);
236
- next.accessible = accessibleUnlessOptedOut(source);
237
- next.focusable = resolveTouchableFocusable(booleanOr(source.focusable), appListenerFor(owner, 'press') !== undefined, disabled);
238
- // :373. RN gives `id` unconditional priority over `nativeID`.
239
- next.nativeID = stringOr(source.id) ?? stringOr(source.nativeID);
240
- // :318-323 folded by the engine above, then :324-330: an explicit `disabled` overrides the
241
- // aria/accessibilityState answer. Same helper every wrapper calls, so both paths agree.
242
- next.accessibilityState = resolveDisabledAccessibilityState(asAccessibilityState(source.accessibilityState), disabled);
243
- // :343-348 + :402. `getBackgroundProp` returns null off Android, so nothing is spread there.
244
- if (IS_ANDROID) {
245
- Object.assign(next, backgroundProps(asFeedbackBackground(source.background) ?? selectableBackground(), source.useForeground === true));
246
- }
247
- return next;
248
- };
249
- }
250
- // Not RN's own list: RN drops these by never cloning them, and a lowered tag has no clone to omit
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
251
170
  // them from — they would ride into the child's payload as keys no ViewConfig declares. Same strip
252
171
  // `./pressable`'s fold does for the machine-only half, applied to the owner's bag instead.
253
172
  //
@@ -272,6 +191,7 @@ function arm(owner, child) {
272
191
  attachPressMachine(child, {
273
192
  source: owner,
274
193
  refine: nativeFeedbackRefinement,
194
+ disabledOf: touchableNativeFeedbackDisabled,
275
195
  });
276
196
  for (const name of FORWARDED_LISTENERS) {
277
197
  forwardListener(owner, child, name, appListenerFor(owner, name) !== undefined);
@@ -296,9 +216,10 @@ function onChildInserted(node, child) {
296
216
  if (previous !== undefined)
297
217
  detachPressMachine(previous);
298
218
  node.childHost = child;
299
- child.payloadFold = cloneFold(node, child.payloadFold);
300
219
  // The owner's props were very likely written BEFORE this child existed (React and Solid set props
301
- // at createInstance), so the fold owes a run even though nothing was written since.
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.
302
223
  markPropsDirty(child);
303
224
  arm(node, child);
304
225
  }
@@ -18,10 +18,17 @@
18
18
  //
19
19
  // REGISTRATION IS THE HAZARD, not the machine — see `./pressable` for why each adapter's entry
20
20
  // does a bare `import './register';` that the barrel does not re-export.
21
- import { AnimatedMock, AnimatedValue, Easing, Platform, appListenerFor, dlog, getExplicitStyle, markPropsDirty, registerHostBehavior, requestCommitFor, setAnimatedBehaviorStyle, timing, } from '@symbiote-native/engine';
22
- import { resolveTouchableFocusable } from '../view/render-pressable/index.js';
23
- import { accessibleUnlessOptedOut, booleanOr, createPressBehavior, } from './pressable.js';
24
- import { createTouchableFeedbackHandlers, createTouchableFeedbackRuntime, DEFAULT_ACTIVE_OPACITY, OPACITY_ACTIVE_GRANT_DURATION_MS, OPACITY_INACTIVE_DURATION_MS, RESTING_OPACITY, restingOpacityFromStyle, TOUCHABLE_MIN_PRESS_DURATION_MS, } from '../state/touchable.js';
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';
25
32
  export const TOUCHABLE_OPACITY_TAG = 'touchable-opacity';
26
33
  const states = new WeakMap();
27
34
  // `modules/animated`'s barrel swaps the whole driver namespace for the mock when the host reports
@@ -58,13 +65,13 @@ const refine = (node, config) => {
58
65
  if (state === undefined)
59
66
  return config;
60
67
  const resting = restingOpacityOf(node);
61
- const activeOpacity = numberOr(node.props.activeOpacity, DEFAULT_ACTIVE_OPACITY);
68
+ const activeOpacity = numberOr(propOf(node, 'activeOpacity'), DEFAULT_ACTIVE_OPACITY);
62
69
  const handlers = createTouchableFeedbackHandlers({
63
- delayPressIn: numberOr(node.props.delayPressIn, 0),
64
- delayPressOut: numberOr(node.props.delayPressOut, 0),
70
+ delayPressIn: numberOr(propOf(node, 'delayPressIn'), 0),
71
+ delayPressOut: numberOr(propOf(node, 'delayPressOut'), 0),
65
72
  // Read raw rather than off `config`, which has already defaulted the absent case to the press
66
73
  // machine's own 130 ms — a floor RN's Touchables never see (TOUCHABLE_MIN_PRESS_DURATION_MS).
67
- minPressDuration: numberOr(node.props.minPressDuration, TOUCHABLE_MIN_PRESS_DURATION_MS),
74
+ minPressDuration: numberOr(propOf(node, 'minPressDuration'), TOUCHABLE_MIN_PRESS_DURATION_MS),
68
75
  schedule: (callback, ms) => {
69
76
  const id = setTimeout(() => {
70
77
  state.timers.delete(id);
@@ -79,9 +86,14 @@ const refine = (node, config) => {
79
86
  now: Date.now,
80
87
  }, state.runtime, {
81
88
  activate(event) {
82
- // 0, not 150 (TouchableOpacity.js:215-220): the duration is chosen by where the press-in
83
- // came from, and every pressIn our engine produces is the grant-equivalent.
84
- fadeTo(state, activeOpacity, OPACITY_ACTIVE_GRANT_DURATION_MS);
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);
85
97
  config.onPressIn?.(event);
86
98
  },
87
99
  deactivate(event) {
@@ -98,25 +110,11 @@ const refine = (node, config) => {
98
110
  onPressOut: handlers.handlePressOut,
99
111
  };
100
112
  };
101
- const press = createPressBehavior(refine);
102
- // The `id -> nativeID` alias every primitive's spec entry declares, applied here because
103
- // `HOST_PRIMITIVES` deliberately withholds this primitive's entry until the other adapters'
104
- // wrappers collapse to one node (the note at its Pressable neighbour says why). A raw `id` is a key
105
- // no ViewConfig declares, so Fabric drops it and the nativeID is lost on device with nothing red.
106
- //
107
- // Unconditional priority when both are set, matching RN (`View.js:77-79`) and `foldHostBag`.
108
- const foldPayload = props => {
109
- const folded = press.foldPayload === undefined ? props : press.foldPayload(props);
110
- const next = { ...folded };
111
- // TouchableOpacity.js:303. The tag is this primitive's only path, so unlike `pressable` there is
112
- // no wrapper for it to disagree with.
113
- next.accessible = accessibleUnlessOptedOut(props);
114
- if (Object.hasOwn(next, 'id')) {
115
- next.nativeID = next.id;
116
- delete next.id;
117
- }
118
- return next;
119
- };
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));
120
118
  /**
121
119
  * The behavior as PARTS, so a tag that is a TouchableOpacity plus something — `button`, which RN
122
120
  * builds as exactly that (Button.js:283) — composes the fade instead of re-implementing it.
@@ -126,11 +124,11 @@ const foldPayload = props => {
126
124
  */
127
125
  export function createTouchableOpacityBehavior(disabledOf) {
128
126
  // Its own machine rather than the module-level `press`, so a composing tag can say what
129
- // `disabled` MEANS to it. `foldPayload` below is the same function either way.
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.
130
129
  const machine = createPressBehavior(refine, disabledOf);
131
130
  return {
132
131
  ...machine,
133
- foldPayload,
134
132
  attach(node) {
135
133
  const state = {
136
134
  opacity: new AnimatedValue(RESTING_OPACITY),
@@ -157,14 +155,28 @@ export function createTouchableOpacityBehavior(disabledOf) {
157
155
  // (Button.js:331,337), so reading the prop here left an aria-only flip un-settled — the view
158
156
  // stayed at its active opacity while the press was already suppressed. RN's Button has no
159
157
  // such gap because it passes the resolved value DOWN as its touchable's prop.
160
- const disabled = disabledOf?.(node.props) ?? node.props.disabled;
158
+ const props = propsOf(node);
159
+ const disabled = disabledOf?.(props) ?? props.disabled;
161
160
  const previous = state.settled;
162
161
  state.settled = { disabled, resting };
163
162
  if (previous === undefined) {
164
- // RN's `Animated.View` carries `{opacity: anim}` in its style from the FIRST render, so a
165
- // resting Touchable commits the key too — a tag that published nothing until the first
166
- // press would differ from every wrapper on mount. Set rather than animate: this is the
167
- // mount, and RN re-settles on componentDidUpdate only.
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).
168
180
  state.opacity.setValue(resting);
169
181
  return;
170
182
  }
@@ -189,21 +201,28 @@ export function createTouchableOpacityBehavior(disabledOf) {
189
201
  },
190
202
  };
191
203
  }
192
- // `focusable` is NOT in `foldPayload` above, because its middle leg — `onPress !== undefined`
193
- // (TouchableOpacity.js:338) — is an OWNED name and therefore lives in the stash, which a props-only
194
- // fold cannot reach. `./button` composes this behavior and resolves its own; the tag resolves it
195
- // here, over the node.
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.
196
207
  //
197
- // `props.disabled`, not `next.disabled`: the press fold strips it (MACHINE_ONLY_KEYS).
198
- function tagFold(node) {
199
- return props => {
200
- const next = foldPayload(props);
201
- next.focusable = resolveTouchableFocusable(booleanOr(props.focusable), appListenerFor(node, 'press') !== undefined, booleanOr(props.disabled));
202
- return next;
203
- };
204
- }
205
- // A listener flip changes no payload by itself, so the commit after it is a no-op and no fold
206
- // re-runs (`IHostBehavior.onOwnedListenerChange`). `./button` carries the twin of this.
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.
207
226
  function onOwnedListenerChange(node, name) {
208
227
  if (name !== 'press')
209
228
  return;
@@ -212,16 +231,9 @@ function onOwnedListenerChange(node, name) {
212
231
  }
213
232
  // Idempotent: an adapter entry may be imported more than once in a bundle.
214
233
  export function registerTouchableOpacityBehavior() {
215
- const touchable = createTouchableOpacityBehavior();
234
+ const touchable = createTouchableOpacityBehavior(touchableOpacityDisabled);
216
235
  registerHostBehavior(TOUCHABLE_OPACITY_TAG, {
217
236
  ...touchable,
218
- // `attachHostBehavior` writes `behavior.foldPayload` into the field one line BEFORE it calls
219
- // `attach`, so binding here is what stands. Only the TAG binds it — a composing behavior owns
220
- // its own node's fold (`./button` sets one in `buildStructure`, which runs later still).
221
- attach(node) {
222
- touchable.attach(node);
223
- node.payloadFold = tagFold(node);
224
- },
225
237
  onOwnedListenerChange,
226
238
  });
227
239
  }