@symbiote-native/components 3.1.1 → 3.1.3

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 (40) hide show
  1. package/build/behaviors/activity-indicator/shared.js +29 -71
  2. package/build/behaviors/button.d.ts +0 -9
  3. package/build/behaviors/button.js +47 -233
  4. package/build/behaviors/image-background.js +21 -81
  5. package/build/behaviors/image.js +3 -10
  6. package/build/behaviors/input-accessory-view.js +10 -51
  7. package/build/behaviors/pressable.d.ts +5 -51
  8. package/build/behaviors/pressable.js +101 -161
  9. package/build/behaviors/refresh-control.js +8 -2
  10. package/build/behaviors/scroll-view/index.android.js +10 -30
  11. package/build/behaviors/scroll-view/responder.d.ts +3 -2
  12. package/build/behaviors/scroll-view/responder.js +19 -21
  13. package/build/behaviors/scroll-view/shared.js +69 -185
  14. package/build/behaviors/scroll-view/sticky.d.ts +0 -8
  15. package/build/behaviors/scroll-view/sticky.js +54 -142
  16. package/build/behaviors/switch.js +18 -4
  17. package/build/behaviors/text-input.d.ts +0 -8
  18. package/build/behaviors/text-input.js +77 -162
  19. package/build/behaviors/touchable-highlight.js +14 -54
  20. package/build/behaviors/touchable-native-feedback.js +9 -32
  21. package/build/behaviors/touchable-opacity.d.ts +0 -7
  22. package/build/behaviors/touchable-opacity.js +65 -81
  23. package/build/behaviors/touchable-without-feedback.js +25 -41
  24. package/build/component-names/index.android.js +6 -8
  25. package/build/component-names/shared.js +12 -42
  26. package/build/index.js +13 -19
  27. package/build/scroll-view-commands.js +3 -11
  28. package/build/state/pressable.js +18 -43
  29. package/build/state/sticky-header-reducer.js +103 -149
  30. package/build/state/touchable.js +3 -5
  31. package/build/state/virtualized-list-reducer.js +21 -48
  32. package/build/state/virtualized-list.js +63 -148
  33. package/build/text-props.js +3 -13
  34. package/build/view/render-button.js +13 -44
  35. package/build/view/render-input-accessory-view.js +8 -24
  36. package/build/view/render-pressable/index.js +3 -4
  37. package/build/view/render-scroll-view.js +13 -23
  38. package/build/view/render-touchable-native-feedback.js +5 -14
  39. package/host-primitives.cjs +33 -207
  40. package/package.json +3 -3
@@ -25,12 +25,32 @@
25
25
  // event props. Deliberately not implemented — dead on every device this project targets (no tvOS
26
26
  // build, no example app; iOS/Android are the only targets). Audit skill, "Found, NOT fixed: TV
27
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';
28
+ import { AnimatedMock, AnimatedValue, Easing, Platform, dlog, getExplicitStyle, markPropsDirty, registerHostBehavior, requestCommitFor, setAnimatedBehaviorStyle, setProp, timing, propOf, propsOf, } from '@symbiote-native/engine';
29
29
  import { resolveButtonDisabled } from '../view/render-button.js';
30
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';
31
+ import { createTouchableFeedbackHandlers, createTouchableFeedbackRuntime, DEFAULT_ACTIVE_OPACITY, OPACITY_ACTIVE_DURATION_MS, OPACITY_ACTIVE_GRANT_DURATION_MS, OPACITY_INACTIVE_DURATION_MS, restingOpacityFromStyle, TOUCHABLE_MIN_PRESS_DURATION_MS, } from '../state/touchable.js';
32
32
  export const TOUCHABLE_OPACITY_TAG = 'touchable-opacity';
33
33
  const states = new WeakMap();
34
+ // Built on the FIRST FADE, not at attach: the three together read 1 343 B per node
35
+ // (`touchable-attach-cost.itest.ts`) and nothing reads them until a finger lands
36
+ function stateOf(node) {
37
+ const existing = states.get(node);
38
+ if (existing !== undefined)
39
+ return existing;
40
+ // Seeded at the CURRENT resting opacity, which the first `afterCommit` used to set
41
+ const state = {
42
+ opacity: new AnimatedValue(restingOpacityOf(node)),
43
+ runtime: createTouchableFeedbackRuntime(),
44
+ timers: new Set(),
45
+ isBound: false,
46
+ };
47
+ states.set(node, state);
48
+ return state;
49
+ }
50
+ // What the last commit settled at, apart from the state above т.к. `afterCommit` runs for every
51
+ // mounted node while that state is owed only by one that actually fades
52
+ // `undefined` is the mount, where RN re-settles nothing (it is `componentDidUpdate` only)
53
+ const settledAt = new WeakMap();
34
54
  // `modules/animated`'s barrel swaps the whole driver namespace for the mock when the host reports
35
55
  // reduced motion; the one driver used here is swapped on the same flag.
36
56
  const startTiming = Platform.isDisableAnimations ? AnimatedMock.timing : timing;
@@ -46,7 +66,16 @@ function numberOr(value, fallback) {
46
66
  function restingOpacityOf(node) {
47
67
  return restingOpacityFromStyle(getExplicitStyle(node));
48
68
  }
49
- function fadeTo(state, toValue, ms) {
69
+ // Registered on FIRST FADE, not at attach: the leaf costs 9.8 us and 5.9 KB per node
70
+ // (`touchable-attach-cost.itest.ts`) and a list nobody presses pays it for a value that never moves
71
+ function ensureLayer(node, state) {
72
+ if (state.isBound)
73
+ return;
74
+ state.isBound = true;
75
+ setAnimatedBehaviorStyle(node, { opacity: state.opacity });
76
+ }
77
+ function fadeTo(node, state, toValue, ms) {
78
+ ensureLayer(node, state);
50
79
  dlog(`touchable-opacity opacity -> ${toValue} over ${ms}ms`);
51
80
  // useNativeDriver is RN's own (TouchableOpacity.js:242): opacity is natively drivable, so the
52
81
  // fade survives a busy JS thread, which is the whole point of press feedback.
@@ -61,9 +90,7 @@ function fadeTo(state, toValue, ms) {
61
90
  // hold when a finger lands, and the handlers it builds are discarded with the gesture. The runtime
62
91
  // that must outlive one — an in-flight delayPressIn timer, the activation clock — is on the node.
63
92
  const refine = (node, config) => {
64
- const state = states.get(node);
65
- if (state === undefined)
66
- return config;
93
+ const state = stateOf(node);
67
94
  const resting = restingOpacityOf(node);
68
95
  const activeOpacity = numberOr(propOf(node, 'activeOpacity'), DEFAULT_ACTIVE_OPACITY);
69
96
  const handlers = createTouchableFeedbackHandlers({
@@ -86,18 +113,16 @@ const refine = (node, config) => {
86
113
  now: Date.now,
87
114
  }, state.runtime, {
88
115
  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.
116
+ // TouchableOpacity.js:215-220 picks the duration from WHERE the press-in came from: a
117
+ // plain grant fades instantly, a drift-back-in arrives as `responderMove` and eases
93
118
  const duration = event.type === 'responderMove'
94
119
  ? OPACITY_ACTIVE_DURATION_MS
95
120
  : OPACITY_ACTIVE_GRANT_DURATION_MS;
96
- fadeTo(state, activeOpacity, duration);
121
+ fadeTo(node, state, activeOpacity, duration);
97
122
  config.onPressIn?.(event);
98
123
  },
99
124
  deactivate(event) {
100
- fadeTo(state, resting, OPACITY_INACTIVE_DURATION_MS);
125
+ fadeTo(node, state, resting, OPACITY_INACTIVE_DURATION_MS);
101
126
  config.onPressOut?.(event);
102
127
  },
103
128
  });
@@ -115,13 +140,9 @@ const refine = (node, config) => {
115
140
  // Without it, a caller who sets only `accessibilityState={{disabled: true}}` (no `disabled` prop)
116
141
  // gets the greyed-out RN look but a press that still fires here — RN suppresses it.
117
142
  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
- */
143
+ // The behavior as PARTS, so a tag that is a TouchableOpacity plus something composes the fade
144
+ // instead of re-implementing it (`button` is exactly that, Button.js:283)
145
+ // Safe to hand to two tags: every piece of runtime is keyed by NODE, `press` included
125
146
  export function createTouchableOpacityBehavior(disabledOf) {
126
147
  // Its own machine rather than the module-level `press`, so a composing tag can say what
127
148
  // `disabled` MEANS to it — the answer feeds the machine, not a payload: the props half of that
@@ -130,95 +151,58 @@ export function createTouchableOpacityBehavior(disabledOf) {
130
151
  return {
131
152
  ...machine,
132
153
  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 });
154
+ // The half of the binding that CANNOT wait for the first press: a view Fabric flattens
155
+ // mid-gesture loses the tag the responder lands on. Everything else follows in `stateOf`
156
+ setProp(node, 'collapsable', false);
144
157
  machine.attach(node);
145
158
  },
146
159
  // RN's componentDidUpdate: a changed `disabled` or a changed style opacity re-settles the view,
147
160
  // so a Touchable disabled mid-press does not stay stuck at its active opacity.
148
161
  afterCommit(node) {
149
- const state = states.get(node);
150
- if (state === undefined)
151
- return;
152
162
  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.
163
+ // Through the SAME resolver the press machine uses, not the raw prop: on `button` `disabled`
164
+ // also answers to `aria-disabled` / `accessibilityState` (Button.js:331,337), and reading the
165
+ // prop alone left an aria-only flip un-settled at its active opacity
158
166
  const props = propsOf(node);
159
167
  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);
168
+ const previous = settledAt.get(node);
169
+ settledAt.set(node, { disabled, resting });
170
+ // The mount, where RN re-settles nothing. An untouched node holds no `AnimatedValue` yet, and
171
+ // `stateOf` seeds the one it eventually builds at whatever `resting` is by then
172
+ if (previous === undefined)
181
173
  return;
182
- }
183
174
  if (previous.disabled === disabled && previous.resting === resting)
184
175
  return;
185
- fadeTo(state, resting, OPACITY_INACTIVE_DURATION_MS);
176
+ fadeTo(node, stateOf(node), resting, OPACITY_INACTIVE_DURATION_MS);
186
177
  },
187
178
  detach(node) {
188
179
  machine.detach(node);
180
+ settledAt.delete(node);
189
181
  const state = states.get(node);
190
- if (state === undefined)
182
+ // Never faded, so the flatten guard `attach` wrote is the only thing owed back
183
+ if (state === undefined) {
184
+ setProp(node, 'collapsable', undefined);
191
185
  return;
186
+ }
192
187
  for (const id of state.timers)
193
188
  clearTimeout(id);
194
189
  state.timers.clear();
195
190
  // RN's componentWillUnmount: stop the animation so a teardown mid-fade leaves no driver
196
191
  // running against a node that is gone.
197
192
  state.opacity.resetAnimation();
198
- setAnimatedBehaviorStyle(node, undefined);
193
+ // Dropping the layer clears `collapsable` on its way out, so only an unbound node still owes
194
+ if (state.isBound)
195
+ setAnimatedBehaviorStyle(node, undefined);
196
+ else
197
+ setProp(node, 'collapsable', undefined);
199
198
  states.delete(node);
200
199
  dlog('touchable-opacity behavior detached');
201
200
  },
202
201
  };
203
202
  }
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.
203
+ // `focusable`'s three-leg check (disabled, aria-disabled/accessibilityState, `onPress !==
204
+ // undefined`) is `foldPressableProps` in C++, reading `OP_SET_OWNED_LISTENER` for the last leg.
205
+ // Contract: `touchable-focusable-payload.itest.ts`. `./button` keeps its own copy regardless.
222
206
  // STILL NEEDED, and for a reason the wire did not remove: `markDirty` on the host marks the node
223
207
  // whose payload must be rebuilt, but the COMMIT still has to be asked for. A listener flip changes
224
208
  // no prop on this side, so nothing else would schedule one and the new answer would sit in the host
@@ -39,18 +39,9 @@ import { SLOT_DERIVED_ALL, appListenerFor, markPropsDirty, registerHostBehavior,
39
39
  import { attachPressMachine, detachPressMachine, } from './pressable.js';
40
40
  import { createTouchableFeedbackHandlers, createTouchableFeedbackRuntime, TOUCHABLE_MIN_PRESS_DURATION_MS, } from '../state/touchable.js';
41
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.
42
+ // Dirties EVERY owner prop rather than a list: the clone is `foldCloneOntoChild` in C++ now, so a
43
+ // wildcard covers it for free. The three scheduler names (`delayPressIn`, `delayPressOut`,
44
+ // `minPressDuration`) were never about the clone — they configure the press machine, read live.
54
45
  const SLOT_DERIVED = [SLOT_DERIVED_ALL];
55
46
  // :148-153. Owned, so the app's callback stashes on the owner and a trampoline on the child reads it
56
47
  // at dispatch time. `layout` and `accessibilityAction` are Fabric BOOLEAN-GATED events
@@ -77,27 +68,25 @@ const states = new WeakMap();
77
68
  function numberOr(value, fallback) {
78
69
  return typeof value === 'number' ? value : fallback;
79
70
  }
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) {
71
+ // The clone is `foldCloneOntoChild` in C++, keyed on the PARENT'S tag; TWF's passthrough half
72
+ // copies only when SET, unlike TNF's unconditional list. One upstream quirk it reproduces:
73
+ // `nativeID` wins over `id` here (`node.nativeIdWinsOverId`, `clone-onto-child-payload.itest.ts`).
74
+ // Built on the FIRST GESTURE, not at arm: nothing reads the runtime or the timer set until a
75
+ // finger lands, and a list of touchables nobody presses paid ~500 B per item for them
76
+ function stateOf(owner) {
77
+ const existing = states.get(owner);
78
+ if (existing !== undefined)
79
+ return existing;
80
+ const state = {
81
+ runtime: createTouchableFeedbackRuntime(),
82
+ timers: new Set(),
83
+ };
84
+ states.set(owner, state);
85
+ return state;
86
+ }
87
+ function refinementFor(owner) {
100
88
  return (_node, config) => {
89
+ const state = stateOf(owner);
101
90
  const handlers = createTouchableFeedbackHandlers({
102
91
  delayPressIn: numberOr(propOf(owner, 'delayPressIn'), 0),
103
92
  delayPressOut: numberOr(propOf(owner, 'delayPressOut'), 0),
@@ -145,18 +134,13 @@ function trampolineFor(owner, name) {
145
134
  function forwardListener(owner, child, name, wired) {
146
135
  setBehaviorListener(child, name, wired ? trampolineFor(owner, name) : undefined);
147
136
  }
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.
137
+ // The machine plus the forwarded listeners. Split from adoption т.к. a parked subtree comes back
138
+ // with its child and its fold intact but its machine torn down, so `attach` re-runs this and must
139
+ // NOT re-chain the fold
151
140
  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
141
  attachPressMachine(child, {
158
142
  source: owner,
159
- refine: refinementFor(owner, state),
143
+ refine: refinementFor(owner),
160
144
  });
161
145
  for (const name of FORWARDED_LISTENERS) {
162
146
  forwardListener(owner, child, name, appListenerFor(owner, name) !== undefined);
@@ -31,18 +31,16 @@ const ANDROID_NAMES = {
31
31
  switch: 'AndroidSwitch',
32
32
  'activity-indicator': 'RCTView',
33
33
  'activity-indicator-spinner': 'AndroidProgressBar',
34
- // DELIBERATE DIVERGENCE FROM RN (2026-09-23). RN's `SafeAreaView.js` is a plain View on Android,
35
- // with no insets. `RCTSafeAreaView` is registered there too (`ReactSafeAreaViewManager`) and
36
- // applies the window insets, so one screen is safe on both platforms.
34
+ // DELIBERATE DIVERGENCE FROM RN: `SafeAreaView.js` is a plain View on Android, with no insets.
35
+ // `RCTSafeAreaView` applies the window insets there too (`ReactSafeAreaViewManager`), so one
36
+ // screen is safe on both platforms.
37
37
  'safe-area-view': 'RCTSafeAreaView',
38
38
  modal: 'RCTModalHostView',
39
39
  'refresh-control': 'AndroidSwipeRefreshLayout',
40
40
  'sticky-header': 'RCTView',
41
- // `InputAccessoryView.js` on Android does `console.warn(...); return null` — the WHOLE component,
42
- // children included, renders NOTHING. A plain `RCTView` container (this file's answer until
43
- // 2026-09-20) committed a real, laid-out, potentially visible node plus its whole child subtree —
44
- // a real divergence from vendor, not a harmless degrade. `VOID_COMPONENT` matches vendor exactly:
45
- // no node, no children, on this platform only.
41
+ // `InputAccessoryView.js` on Android does `console.warn(...); return null` — the WHOLE
42
+ // component, children included, renders NOTHING. `VOID_COMPONENT` matches vendor exactly: no
43
+ // node, no children, on this platform only.
46
44
  'input-accessory-view': VOID_COMPONENT,
47
45
  };
48
46
  export const COMPONENT_DESCRIPTORS = buildDescriptors(ANDROID_NAMES);
@@ -1,9 +1,6 @@
1
- // The intrinsic JSX types symbiote's host config maps to Fabric components, plus the
2
- // machinery to turn a platform name table into the descriptors the host config reads.
3
- // The Fabric NAME of a primitive is platform-specific (iOS 'Switch' vs
4
- // Android 'AndroidSwitch'), so the name tables live in component-names.ios.ts /
5
- // .android.ts and the filename selects, no Platform.OS read. The isText flag is
6
- // platform-invariant, so it lives here once and both tables share it.
1
+ // The intrinsic JSX types symbiote's host config maps to Fabric components. A Fabric name is
2
+ // platform-specific, so name tables live in component-names.ios/.android.ts, selected by
3
+ // filename; `isText` is platform-invariant and lives here once.
7
4
  // The only text-laying intrinsic; drives the RCTText / RCTVirtualText nesting choice
8
5
  // (a <Text> inside another <Text> becomes a virtual span). Platform-invariant, so it is
9
6
  // not part of the per-platform name table.
@@ -20,32 +17,18 @@ export function buildDescriptors(names) {
20
17
  }
21
18
  return descriptors;
22
19
  }
23
- // Resolve an intrinsic type to its descriptor, against the platform-selected map. The
24
- // logic is identical for every adapter (and was duplicated in React's host-config and
25
- // Vue's component-names), so it lives here once; each platform file binds it to its own
26
- // COMPONENT_DESCRIPTORS. A `symbiote-*` miss is a typo in our own code; any other string
27
- // is a raw Fabric view name from a library's codegen component and flows through untouched
28
- // (the engine derives its events/processors from the view's ViewConfig, no per-library glue).
20
+ // Resolve an intrinsic type to its descriptor against the platform-selected map, so each
21
+ // platform file binds it to its own COMPONENT_DESCRIPTORS. Any string with no descriptor is a
22
+ // raw Fabric view name from a library's codegen component and flows through untouched.
29
23
  export function makeDescriptorFor(descriptors) {
30
24
  const unrewritten = publicNamesThatAreNotViewNames(descriptors);
31
25
  return type => {
32
26
  const descriptor = descriptors[type];
33
27
  if (descriptor !== undefined)
34
28
  return descriptor;
35
- // NO "unknown tag of ours" throw any more, and the reason is the whole cost of dropping the
36
- // prefix: `symbiote-*` was a MARKER, so a miss carrying it could only be our own typo. Without
37
- // it this namespace holds three populations that a string cannot tell apart —
38
- //
39
- // view · scroll-view ours
40
- // counter-child · app-root an Angular app's OWN component selectors, kebab by convention
41
- // RCTView · RNCSlider a Fabric view name, resolving through the fallthrough below
42
- //
43
- // — and the first attempt at a replacement ("lowercase is ours") threw on the second row, which
44
- // is most of an Angular app. Measured: 222 tests, the primary failure being `counter-child`.
45
- //
46
- // What replaces it is stronger and is not runtime at all: `ISymbioteIntrinsic` is a closed
47
- // union, so a misspelled tag in our own source is a compile error at every call site that names
48
- // one. The guard was only ever a backstop for a name built dynamically.
29
+ // No "unknown tag" throw here: this namespace holds three populations no string alone can
30
+ // tell apart — our own tags, an Angular app's own kebab-case selectors, and a Fabric view
31
+ // name. `ISymbioteIntrinsic` being a closed union makes a misspelled tag a compile error.
49
32
  if (unrewritten.has(type)) {
50
33
  throw new Error(`"${type}" is a primitive's PUBLIC name, not a Fabric view name — a rewrite was missed. ` +
51
34
  `Expected the intrinsic tag (e.g. "view"). Falling through would have committed ` +
@@ -54,22 +37,9 @@ export function makeDescriptorFor(descriptors) {
54
37
  return { component: type, isText: false };
55
38
  };
56
39
  }
57
- // A primitive's public name reaching here means an adapter's rewrite missed a call site. Without
58
- // this the name falls through as a raw Fabric view name and commits a view literally called `View`
59
- // — no error at any layer, wrong only on a device.
60
- //
61
- // Both halves are DERIVED, because a hand-written list of either would be wrong within a release.
62
- // The names come from the intrinsic union (kebab -> Pascal); the exclusions come from the platform's
63
- // own table, and deriving them is not tidiness — two public names ARE real Fabric view names, so a
64
- // hand-written block list would break an adapter's thin wrapper over a third-party native view,
65
- // which resolves by view name through this same function.
66
- //
67
- // AND THE EXCLUSION IS PER PLATFORM, which is why it must be computed from the table rather than
68
- // stated. `Switch` and `SafeAreaView` are iOS view names; Android spells them `AndroidSwitch` and
69
- // `RCTSafeAreaView`, so the same public name is EXCLUDED on iOS and BLOCKED on Android. That
70
- // asymmetry is correct — nothing legitimate resolves by the bare name on Android — but a reader who
71
- // takes "these two are real view names" as platform-invariant will conclude the guard is broken on
72
- // one side or the other.
40
+ // A primitive's public name reaching here means an adapter's rewrite missed a call site —
41
+ // otherwise it commits a view literally called `View`. Both halves are DERIVED, not hand-written:
42
+ // some public names (`Switch` on iOS) ARE real view names, and which is per-platform.
73
43
  function publicNamesThatAreNotViewNames(descriptors) {
74
44
  const viewNames = new Set(Object.values(descriptors).map(descriptor => descriptor.component));
75
45
  const names = new Set();
package/build/index.js CHANGED
@@ -53,10 +53,8 @@ export { canUseNativeForeground, selectableBackground, selectableBackgroundBorde
53
53
  // element is a tag now, and a tag is a string that cannot carry `.Ripple`.
54
54
  TouchableNativeFeedback, } from './view/render-touchable-native-feedback.js';
55
55
  // Button: `resolveButtonDisabled` alone now — the `props.disabled ?? aria ?? state.disabled`
56
- // precedence, which the press MACHINE reads and which is therefore still JS. Every platform fold
57
- // Button.js performs left this barrel between 2026-09-17 and 2026-09-18 for
58
- // `SymbioteFabricProps.cpp`; the last two, `buttonViewStyle` and `resolveButtonViewStyle`, went with
59
- // the Android arm of the test host that finally made their branch assertable.
56
+ // precedence, which the press MACHINE reads and stays JS. Every platform fold Button.js performs
57
+ // is `SymbioteFabricProps.cpp`'s.
60
58
  export { resolveButtonDisabled } from './view/render-button.js';
61
59
  // TextInput: the controlled-value / event-count handshake, and ONLY that now — the machine. The
62
60
  // W3C->native prop resolution that used to sit beside it is the engine's (`foldTextInputAliases`,
@@ -80,25 +78,21 @@ export { registerPressableBehavior, PRESSABLE_TAG, } from './behaviors/pressable
80
78
  export { registerTouchableOpacityBehavior, TOUCHABLE_OPACITY_TAG, } from './behaviors/touchable-opacity.js';
81
79
  export { registerTouchableHighlightBehavior, TOUCHABLE_HIGHLIGHT_TAG, } from './behaviors/touchable-highlight.js';
82
80
  export { registerButtonBehavior, BUTTON_TAG } from './behaviors/button.js';
83
- // Registered by ALL FIVE adapters since 2026-09-09, in the same commit that deleted the five
84
- // wrappers — which is what makes it safe. While those wrappers still rendered a `Pressable` around
85
- // a feedback `View`, registering would have put a second responder on every existing
86
- // TouchableNativeFeedback. The ActivityIndicator and ScrollView blocks below are still in the
87
- // withheld state this one just left.
81
+ // Registered by ALL FIVE adapters, in the same commit that deleted the five wrappers — safe
82
+ // only because those wrappers no longer render their own Pressable, which would put a second
83
+ // responder on every TouchableNativeFeedback.
88
84
  export { registerTouchableNativeFeedbackBehavior, TOUCHABLE_NATIVE_FEEDBACK_TAG, } from './behaviors/touchable-native-feedback.js';
89
85
  // The second tag that commits no node, and registered by all five adapters in the same commit that
90
86
  // deleted their five wrappers — while those wrappers still rendered a `Pressable` around the app's
91
87
  // children, registering would have put a second responder on every TouchableWithoutFeedback.
92
88
  export { registerTouchableWithoutFeedbackBehavior, TOUCHABLE_WITHOUT_FEEDBACK_TAG, } from './behaviors/touchable-without-feedback.js';
93
- // Registered by all five adapters since 2026-09-09, in the same commit that deleted the five
94
- // wrappers — the tag builds its own spinner, so a surviving wrapper would have painted a second one.
95
- // The prop type rides along: there is no `view/render-activity-indicator.ts` any more, because the
96
- // only render this primitive had was the two nodes the behavior now builds.
89
+ // Registered by all five adapters, in the same commit that deleted the five wrappers — the tag
90
+ // builds its own spinner, so a surviving wrapper would have painted a second one. No
91
+ // `view/render-activity-indicator.ts`: the behavior now builds the only two nodes this had.
97
92
  export { ACTIVITY_INDICATOR_SPINNER_TAG, ACTIVITY_INDICATOR_TAG, registerActivityIndicatorBehavior, } from './behaviors/activity-indicator';
98
93
  export { IMAGE_TAG, registerImageBehavior } from './behaviors/image.js';
99
- // Registered by all five adapters since 2026-09-09, in the same commit that deleted the five
100
- // wrappers — the tag builds the background image itself, so a surviving wrapper would have
101
- // committed a second one under it.
94
+ // Registered by all five adapters, in the same commit that deleted the five wrappers — the tag
95
+ // builds the background image itself, so a surviving wrapper would have committed a second one.
102
96
  export { IMAGE_BACKGROUND_TAG, registerImageBackgroundBehavior, } from './behaviors/image-background.js';
103
97
  export { INPUT_ACCESSORY_VIEW_TAG, registerInputAccessoryViewBehavior, } from './behaviors/input-accessory-view.js';
104
98
  export { registerTextInputBehavior, buildTextInputHandle, TEXT_INPUT_TAG, TEXT_INPUT_MULTILINE_TAG, } from './behaviors/text-input.js';
@@ -109,7 +103,7 @@ export { registerRefreshControlBehavior, REFRESH_CONTROL_TAG, } from './behavior
109
103
  // caller of `registerScrollViewBehavior()` names it. The shape CLAUDE.md forbids is a module whose
110
104
  // evaluation alone registers.
111
105
  //
112
- // EVERY ADAPTER CALLS IT since 2026-09-11, through `@symbiote-native/components/register`. It was
113
- // withheld while the wrappers built their own content node — registering then would have
114
- // double-nested every ScrollView. They are gone; the engine is the single owner.
106
+ // EVERY ADAPTER CALLS IT, through `@symbiote-native/components/register`. Withheld while the
107
+ // wrappers built their own content node — registering then would have double-nested every
108
+ // ScrollView. They are gone; the engine is the single owner.
115
109
  export { HORIZONTAL_SCROLL_VIEW_TAG, registerScrollViewBehavior, SCROLL_VIEW_TAG, STICKY_HEADER_TAG, } from './behaviors/scroll-view';
@@ -4,17 +4,9 @@
4
4
  // Android RefreshControl wrap's style split moved out — see the note below. The adapter supplies
5
5
  // the lifecycle (the node getter, the effect) and re-exports these.
6
6
  import { attachNativeEvent, dlog, isSymbioteEvent, } from '@symbiote-native/engine';
7
- // THE ANDROID WRAP SPLIT LEFT THIS FILE ON 2026-09-18, and with it the last copy of RN's
8
- // `splitLayoutProps` key partition. Both halves are `SymbioteFabricProps.cpp` now
9
- // (`splitScrollViewStyle`, `foldRefreshWrapperProps`), reached off the tag and the tree rather than
10
- // from a `payloadFold` per node — see `behaviors/scroll-view/index.android.ts` for what made the
11
- // wrapper's half possible at all.
12
- //
13
- // Nothing replaced them here on purpose: a JS copy kept for a caller that no longer exists is the
14
- // mirror shape this migration keeps deleting, and the twenty-eight layout keys are exactly the kind
15
- // of list that drifts in silence. `core/engine/cpp/tests/js/scroll-view-wrap-payload.itest.ts`
16
- // asserts the split on the payload a commit actually sent, which is the only place it can now be
17
- // wrong.
7
+ // The Android wrap's style split (`splitScrollViewStyle`, `foldRefreshWrapperProps`) is
8
+ // `SymbioteFabricProps.cpp`'s, reached off the tag and tree rather than a `payloadFold` per node.
9
+ // Contract: `scroll-view-wrap-payload.itest.ts`, asserted on the payload a commit actually sent.
18
10
  // Re-exported so the package barrel (index.ts) can still export this guard to
19
11
  // '@symbiote-native/components' callers, now that it lives in the engine, next to ISymbioteEvent.
20
12
  export { isSymbioteEvent };
@@ -1,22 +1,14 @@
1
- // Pressable, the logic half (framework-agnostic, zero render, zero framework imports). The
2
- // press lifecycle RN's Pressability runs in JS (pressIn/pressOut/press synthesis, the
3
- // long-press timer, unstable_pressDelay deferral, and the pressRetentionOffset drift test)
4
- // lives here as a pure state machine over a mutable runtime plus an adapter-supplied host. The
5
- // adapter owns only the lifecycle wiring: React holds the runtime in a ref and flips `pressed`
6
- // via setState; Vue holds it in setup scope and flips a ref. Both call the SAME handlers.
7
- //
8
- // Framework-specific, stays in the adapter: the `pressed` state cell (it drives a
9
- // re-render, so each framework owns its reactive primitive) and the raw frame-measure (the host
10
- // node plus its measure call). The rest (the timers, the geometry, the suppression flags,
11
- // the decision of when each callback fires) is here, shared by every adapter.
1
+ // Pressable, the logic half (framework-agnostic, zero render). The press lifecycle RN's
2
+ // Pressability runs in JS lives here as a pure state machine over a mutable runtime plus an
3
+ // adapter-supplied host. Both React and Vue call the SAME handlers, differing only in lifecycle.
4
+ // Framework-specific, stays in the adapter: the `pressed` state cell (drives a re-render, so each
5
+ // framework owns its reactive primitive) and the raw frame-measure. The rest — timers, geometry,
6
+ // suppression flags, when each callback fires — is here, shared by every adapter.
12
7
  import { dlog, Platform, SoundManager, } from '@symbiote-native/engine';
13
8
  export const DEFAULT_DELAY_LONG_PRESS_MS = 500;
14
- // Pressability.js's DEFAULT_LONG_PRESS_DEACTIVATION_DISTANCE. A SEPARATE, smaller radius than
15
- // `pressRetentionOffset`/`hitSlop`: any move past it cancels a pending long press even while the
16
- // finger is still well inside the retention rect — real presses jitter a few px, long-press must
17
- // not fire mid-scroll. Vendor exposes it only as a rare static override
18
- // (`Pressability.setLongPressDeactivationDistance`, used by e.g. gesture libraries), never as a
19
- // per-component prop, so it stays a plain constant here rather than a config field.
9
+ // A SEPARATE, smaller radius than pressRetentionOffset/hitSlop: any move past it cancels a
10
+ // pending long press even while the finger is still well inside the retention rect — real presses
11
+ // jitter a few px, long-press must not fire mid-scroll.
20
12
  export const LONG_PRESS_DEACTIVATION_DISTANCE = 10;
21
13
  // Pressability's default active-visual floor for a plain Pressable. Touchable* overrides this to 0.
22
14
  export const DEFAULT_MIN_PRESS_DURATION_MS = 130;
@@ -120,10 +112,8 @@ export function disposePressRuntime(runtime) {
120
112
  runtime.delayElapsed = false;
121
113
  }
122
114
  // Measure the responder's on-screen frame and cache it as the retention region for the life of
123
- // the press (RN measures on responder grant, _measureResponderRegion). When measure is
124
- // unavailable (no node yet, an uncommitted node, or a host slot without a measure method,
125
- // headless) the region stays undefined and the move test falls back to the radius bound. The
126
- // try/catch guards that last case: a slot lacking measure throws rather than no-opping.
115
+ // the press. When measure is unavailable the region stays undefined and the move test falls back
116
+ // to the radius bound; the try/catch guards a slot lacking measure throwing rather than no-opping.
127
117
  function measureRegion(runtime, measureFn) {
128
118
  runtime.region = undefined;
129
119
  if (measureFn === undefined)
@@ -157,18 +147,9 @@ export function createPressHandlers(config, runtime, host) {
157
147
  const fallbackThreshold = maxEdge(hitSlopRect) + maxEdge(pressRectOffset);
158
148
  // True iff the touch still belongs to the active press: against the measured rect when we have
159
149
  // one (the RN-faithful path), else the symmetric radius fallback.
160
- //
161
- // TODO(rn-parity): `Pressability.js`'s `onResponderMove` (:489-493) returns right after the
162
- // app's `onPressMove`, before any drift/long-press-jitter check, whenever the region is not yet
163
- // measured — vendor treats "no measurement yet" as "do nothing until one arrives." The radius
164
- // fallback below diverges: it judges drift/jitter on synthetic geometry instead of waiting.
165
- // Confirmed unreachable on a real device (`Tree::measure` in `SymbioteTree.cpp` is a synchronous
166
- // JSI call, so the region is always populated before `handleResponderMove` can fire), so the
167
- // fallback only ever fires in this codebase's own test harness, which wires
168
- // `getMeasureFn: () => undefined` everywhere (`state/pressable.test.ts:84` and every touchable
169
- // test built on it). A real fix needs a synchronous mock-measure harness under 11+ existing
170
- // drift/retention/long-press call sites across `pressable.test.ts`, `touchable-opacity.test.ts`
171
- // and `render-pressable.test.ts`, not a one-line guard — see the audit skill for the finding.
150
+ // Known divergence: vendor does nothing until the region is measured, this judges drift/jitter
151
+ // on synthetic geometry instead. Unreachable on a real device (measure is a synchronous JSI
152
+ // call), so the fallback only ever fires in this codebase's own test harness.
172
153
  function isWithinRetention(point) {
173
154
  const region = runtime.region;
174
155
  if (region !== undefined) {
@@ -188,12 +169,8 @@ export function createPressHandlers(config, runtime, host) {
188
169
  function clearPressOut() {
189
170
  cancelRuntimeTimer(runtime, 'pressOutCancel');
190
171
  }
191
- // Pressability.js arms this ONCE, at RESPONDER_GRANT, and a drift out/back-in never re-arms it
192
- // (`onResponderMove` only ever CANCELS via `_cancelLongPressDelayTimeout`, permanently, for the
193
- // rest of the gesture). Splitting it out of `activate()` is what makes that possible: `activate`
194
- // itself runs again on every drift-back-in, and used to re-arm a fresh full-duration timer each
195
- // time, which is wrong on two counts — it can resurrect a long press the finger already
196
- // disqualified, and if it never left, it's simply the same timer restarted for no reason.
172
+ // Arms ONCE, at grant; a drift out/back-in never re-arms it, only cancels. Split out of
173
+ // activate(), which runs again on drift-back-in and would resurrect a disqualified long press.
197
174
  function armLongPress(event) {
198
175
  if (!onLongPress)
199
176
  return;
@@ -277,10 +254,8 @@ export function createPressHandlers(config, runtime, host) {
277
254
  runtime.pressOrigin = readPoint(event);
278
255
  runtime.driftedOut = false;
279
256
  measureRegion(runtime, host.getMeasureFn());
280
- // Armed at GRANT, unconditionally — before the `unstable_pressDelay` branch below, which only
281
- // defers the PRESSED VISUAL. `delayLongPress` already has `unstable_pressDelay` baked out of
282
- // its default (`configFor`), so adding it back here is what keeps the long-press threshold at
283
- // a constant time-from-touch-down regardless of how long the visual is deferred.
257
+ // Armed at GRANT, unconditionally — the unstable_pressDelay branch below only defers the
258
+ // PRESSED VISUAL, keeping the long-press threshold constant from touch-down.
284
259
  armLongPress(event);
285
260
  if (unstable_pressDelay > 0) {
286
261
  dlog(`Pressable pressIn deferred ${unstable_pressDelay}ms`);