@symbiote-native/components 3.0.0 → 3.1.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.
@@ -14,7 +14,7 @@
14
14
  // iOS button RCTView TouchableOpacity's Animated.View — the responder + the fade
15
15
  // └ view RCTView no style at all, and no fold
16
16
  // └ text RCTText `button-label-text` — foldButtonLabelStyle + RN's Text defaults
17
- // └ raw RCTRawText `button-label` — foldButtonLabel FOUR nodes
17
+ // └ raw RCTRawText `button-label` — uppercaseTitle (Android) FOUR nodes
18
18
  //
19
19
  // Android button RCTView the styled button view, CLONED onto: the responder, the ripple
20
20
  // │ background, the whole a11y fold. No fade, no wrapper.
@@ -192,15 +192,8 @@ const buttonDisabled = props => projectionOf(props).disabled;
192
192
  // was the thing this fold was waiting for: `ownerProps` answers "my parent" and this node's parent
193
193
  // is the wrapping view, which knows none of it.
194
194
  export const BUTTON_LABEL_TEXT_TAG = 'button-label-text';
195
- // The label's own tag. A raw text carrying one looks odd and is not: it has no props an app can
196
- // write, but its CONTENT is the platform's decision here — RN renders a button's title uppercased on
197
- // Android and verbatim elsewhere (`Button.js:352-353`), which is a user-agent choice about a control
198
- // rather than anything the app asked for.
199
- //
200
- // That is what the fold here used to do, and it is `foldButtonLabel` in `SymbioteFabricProps.cpp`
201
- // now, reached off this tag. `button-payload.itest.ts` recorded "a raw text carries no tag at all,
202
- // so there is nothing for a tag-keyed rule to key on" — true of `createRawText`'s old signature, not
203
- // of raw texts, and it takes a tag now for exactly this.
195
+ // The label's own tag, on the raw text that holds the title. Its Android uppercase (`Button.js:352`)
196
+ // is `uppercaseTitle` below, in JS: the C++ tag rule it briefly was could only do ASCII.
204
197
  export const BUTTON_LABEL_TAG = 'button-label';
205
198
  // ---- the Android touchable -------------------------------------------------------------------
206
199
  // Button.js:281-284. Not two variants of one component: see the tree diagram at the top for what
@@ -313,6 +306,14 @@ function onOwnedListenerChange(node, name) {
313
306
  markPropsDirty(node);
314
307
  requestCommitFor(node);
315
308
  }
309
+ // Button.js:352-353 — Android renders `title.toUpperCase()`. Here in JS rather than in the C++ tag
310
+ // rules because only JavaScript's uppercase is full Unicode (Cyrillic, `ß` -> `SS`); the host has
311
+ // no ICU, and an ASCII port left every non-Latin title in its authored case.
312
+ function uppercaseTitle(slotKey, value) {
313
+ return slotKey === 'text' && typeof value === 'string'
314
+ ? value.toUpperCase()
315
+ : value;
316
+ }
316
317
  // Idempotent: an adapter entry may be imported more than once in a bundle.
317
318
  export function registerButtonBehavior() {
318
319
  // `attach`/`detach` come from the touchable unwrapped: the internal nodes are ordinary children
@@ -325,6 +326,7 @@ export function registerButtonBehavior() {
325
326
  onOwnedListenerChange,
326
327
  slotProps: SLOT_PROPS,
327
328
  slotDerived: SLOT_DERIVED,
329
+ ...(IS_ANDROID ? { slotValueFor: uppercaseTitle } : {}),
328
330
  };
329
331
  // The two DERIVED nodes' tags, registered with no runtime at all. A tag reaches C++ only through
330
332
  // `recordSetTag`, which `attachHostBehavior` emits, so a tag nobody registered carries an empty
@@ -31,8 +31,8 @@
31
31
  // tagged node rather than per primitive.
32
32
  //
33
33
  // PLATFORM. This is the only primitive in its group that is not platform-invariant in what it
34
- // COMMITS TO: `input-accessory-view` resolves to `RCTInputAccessoryView` on iOS and to a
35
- // plain `RCTView` on Android. The fold itself is platform-invariant on purpose.
34
+ // COMMITS TO: `input-accessory-view` resolves to `RCTInputAccessoryView` on iOS and to the void
35
+ // component on Android. The fold itself is platform-invariant on purpose.
36
36
  //
37
37
  // FIXED (2026-09-20). `InputAccessoryView.js` on Android does `console.warn('<InputAccessoryView>
38
38
  // is only supported on iOS.'); return null` — the WHOLE component, children included, renders
@@ -49,12 +49,14 @@
49
49
  // contributing neither itself nor its children. Wired purely through
50
50
  // `core/components/src/component-names/index.android.ts`'s per-platform table, the same seam
51
51
  // `ANCHOR_COMPONENT` already used — this behavior file needed no change at all.
52
- import { registerHostBehavior, } from '@symbiote-native/engine';
52
+ import { Platform, registerHostBehavior, } from '@symbiote-native/engine';
53
53
  export const INPUT_ACCESSORY_VIEW_TAG = 'input-accessory-view';
54
- // Required by IHostBehavior and deliberately empty: this primitive owns no per-node runtime.
55
- // Written out rather than shared with a `noop` so the emptiness reads as a decision.
54
+ // `InputAccessoryView.js:110-113` — off iOS it warns and renders nothing (the void component).
55
+ // RN warns per render; once per node is the nearest beat a tag has.
56
56
  function attach(_node) {
57
- // nothing to set up
57
+ if (Platform.OS !== 'ios') {
58
+ console.warn('<InputAccessoryView> is only supported on iOS.');
59
+ }
58
60
  }
59
61
  function detach(_node) {
60
62
  // nothing to release
@@ -26,9 +26,17 @@ export type IPressConfigRefinement = (node: ISymbioteNode, config: IPressMachine
26
26
  * would answer the NEXT resolution as if the app had written it and the tag could never re-enable.
27
27
  */
28
28
  export type IDisabledResolver = (props: Readonly<Record<string, unknown>>) => boolean | undefined;
29
+ export type ICancelableResolver = (source: ISymbioteNode) => boolean | undefined;
29
30
  export declare function booleanOr(value: unknown): boolean | undefined;
30
31
  export declare function asAccessibilityState(value: unknown): IAccessibilityStateValue | undefined;
31
32
  export declare function accessibleUnlessOptedOut(props: Readonly<Record<string, unknown>>): boolean;
33
+ /**
34
+ * The Android ripple's three view commands around the app's callbacks (TNF :230-252,
35
+ * useAndroidRippleForView.js:77-104). The JS responder takes the touch before Android's own
36
+ * pressed handling, so without them the ripple never animates. Hotspot first: it starts under
37
+ * the finger.
38
+ */
39
+ export declare function withNativeFeedbackCommands(node: ISymbioteNode, config: IPressMachineConfig): IPressMachineConfig;
32
40
  /**
33
41
  * The machine on `node`, reading its props and the app's callbacks off `options.source` when that
34
42
  * is a different node.
@@ -43,6 +51,7 @@ export declare function accessibleUnlessOptedOut(props: Readonly<Record<string,
43
51
  export declare function attachPressMachine(node: ISymbioteNode, options?: {
44
52
  readonly refine?: IPressConfigRefinement;
45
53
  readonly disabledOf?: IDisabledResolver;
54
+ readonly cancelableOf?: ICancelableResolver;
46
55
  readonly source?: ISymbioteNode;
47
56
  }): void;
48
57
  /** See `attachPressMachine`: the same teardown `createPressBehavior` registers as its `detach`. */
@@ -14,7 +14,7 @@
14
14
  // `registerPressableBehavior()`, and its entry does a bare `import './register';` that the barrel
15
15
  // does NOT re-export. A bare `import './register';` sitting NEXT TO such a re-export does not work
16
16
  // either: Babel merges the two imports of one specifier and the merged dependency stays lazy.
17
- import { appListenerFor, dlog, propOf, registerHostBehavior, requestCommitFor, setBehaviorListener, setNodePressed, propsOf, } from '@symbiote-native/engine';
17
+ import { appListenerFor, dispatchViewCommand, dlog, Platform, propOf, registerHostBehavior, requestCommitFor, setBehaviorListener, setNodePressed, propsOf, } from '@symbiote-native/engine';
18
18
  import { createPressHandlers, createPressRuntime, disposePressRuntime, DEFAULT_DELAY_LONG_PRESS_MS, DEFAULT_MIN_PRESS_DURATION_MS, } from '../state/pressable.js';
19
19
  import { buildPressableListeners } from '../view/render-pressable/index.js';
20
20
  export const PRESSABLE_TAG = 'pressable';
@@ -148,6 +148,52 @@ function configFor(node) {
148
148
  booleanOr(propOf(node, 'touchSoundDisabled')),
149
149
  };
150
150
  }
151
+ // Read once: the platform cannot change under a running app.
152
+ const IS_ANDROID = Platform.OS === 'android';
153
+ // useAndroidRippleForView.js:57 — a ripple exists only when color, borderless or radius is set.
154
+ function hasAndroidRipple(config) {
155
+ if (typeof config !== 'object' || config === null)
156
+ return false;
157
+ return (Reflect.get(config, 'color') != null ||
158
+ Reflect.get(config, 'borderless') != null ||
159
+ Reflect.get(config, 'radius') != null);
160
+ }
161
+ // `locationX ?? 0` (TouchableNativeFeedback.js:280, useAndroidRippleForView.js:83). The bag is raw
162
+ // Fabric payload, so guard.
163
+ function hotspotAt(nativeEvent, key) {
164
+ const value = nativeEvent[key];
165
+ return typeof value === 'number' ? value : 0;
166
+ }
167
+ /**
168
+ * The Android ripple's three view commands around the app's callbacks (TNF :230-252,
169
+ * useAndroidRippleForView.js:77-104). The JS responder takes the touch before Android's own
170
+ * pressed handling, so without them the ripple never animates. Hotspot first: it starts under
171
+ * the finger.
172
+ */
173
+ export function withNativeFeedbackCommands(node, config) {
174
+ const hotspot = (event) => {
175
+ dispatchViewCommand(node, 'hotspotUpdate', [
176
+ hotspotAt(event.nativeEvent, 'locationX'),
177
+ hotspotAt(event.nativeEvent, 'locationY'),
178
+ ]);
179
+ };
180
+ return {
181
+ ...config,
182
+ onPressIn(event) {
183
+ hotspot(event);
184
+ dispatchViewCommand(node, 'setPressed', [true]);
185
+ config.onPressIn?.(event);
186
+ },
187
+ onPressMove(event) {
188
+ hotspot(event);
189
+ config.onPressMove?.(event);
190
+ },
191
+ onPressOut(event) {
192
+ dispatchViewCommand(node, 'setPressed', [false]);
193
+ config.onPressOut?.(event);
194
+ },
195
+ };
196
+ }
151
197
  // Rebuilding at GESTURE START is the whole reason for the dispatcher indirection, and skipping it
152
198
  // is a bug that looks like working code. `attach` runs inside `createElement`, before a single
153
199
  // prop has been routed — the node holds nothing at all there — so a machine built at attach would
@@ -160,7 +206,10 @@ function rebuild(node, state) {
160
206
  // (dispatching a view command needs the committed node, not the one holding the props).
161
207
  const source = state.source;
162
208
  const base = configFor(source);
163
- const handlers = createPressHandlers(state.refine === undefined ? base : state.refine(node, base), state.runtime, state.host);
209
+ const refined = state.refine === undefined ? base : state.refine(node, base);
210
+ const handlers = createPressHandlers(IS_ANDROID && hasAndroidRipple(propOf(source, 'android_ripple'))
211
+ ? withNativeFeedbackCommands(node, refined)
212
+ : refined, state.runtime, state.host);
164
213
  state.isBuilt = true;
165
214
  // Re-read every gesture, so a tag whose resolver looks past `disabled` — `./button`, at
166
215
  // `aria-disabled` — re-enables on the next touch instead of latching at its first answer.
@@ -170,7 +219,7 @@ function rebuild(node, state) {
170
219
  : state.disabledOf(sourceProps);
171
220
  state.listeners = buildPressableListeners(handlers, {
172
221
  disabled: disabled === true ? true : undefined,
173
- cancelable: resolveCancelable(source),
222
+ cancelable: (state.cancelableOf ?? resolveCancelable)(source),
174
223
  blockNativeResponder: propOf(source, 'blockNativeResponder') === true,
175
224
  });
176
225
  }
@@ -224,7 +273,25 @@ function dispatch(node, state, key, args) {
224
273
  //
225
274
  // The engine event names, not the `onX` prop spellings. `buildPressableListeners` speaks the prop
226
275
  // spelling, so the two are mapped here rather than guessed at either end.
227
- const KEY_BY_EVENT = new Map([
276
+ /**
277
+ * Engine event name -> the app-facing callback key its dispatcher routes to.
278
+ *
279
+ * AN ARRAY OF PAIRS RATHER THAN A `Map`, and the reason is measured rather than stylistic.
280
+ * `installListeners` below is the only reader, and it runs once per node that carries a press
281
+ * machine — which is every `<TextInput>` on the screen, not just every `<Pressable>`. A `for…of` over
282
+ * a `Map` builds a fresh two-element array per entry for the destructuring to read back: seven
283
+ * allocations per node, for seven fixed pairs that never change. The tuples here already exist, so
284
+ * the same loop allocates nothing but its iterator.
285
+ *
286
+ * Priced on `-O` Hermes by `text-input-attach-ladder.itest.ts`, one pass over the seven pairs:
287
+ * `Map` 1.04 us, this 0.39, two parallel arrays 0.29. The parallel arrays are cheapest and give up
288
+ * the pairing, which is not worth 0.1 us on a table that a drift would silently unwire.
289
+ *
290
+ * NOT the same list as `createPressBehavior`'s `ownedListeners`, and they must not be merged: that
291
+ * one is every name the machine takes as an INPUT (`pressMove` and `longPress` included), this one
292
+ * is only the names it installs a dispatcher for.
293
+ */
294
+ const EVENT_KEY_PAIRS = [
228
295
  ['press', 'onPress'],
229
296
  ['pressIn', 'onPressIn'],
230
297
  ['pressOut', 'onPressOut'],
@@ -232,9 +299,9 @@ const KEY_BY_EVENT = new Map([
232
299
  ['responderMove', 'onResponderMove'],
233
300
  ['responderTerminationRequest', 'onResponderTerminationRequest'],
234
301
  ['responderGrant', 'onResponderGrant'],
235
- ]);
302
+ ];
236
303
  function installListeners(node, state) {
237
- for (const [event, key] of KEY_BY_EVENT) {
304
+ for (const [event, key] of EVENT_KEY_PAIRS) {
238
305
  setBehaviorListener(node, event, symbioteEvent => dispatch(node, state, key, [symbioteEvent]));
239
306
  }
240
307
  }
@@ -293,6 +360,7 @@ function attach(node, options) {
293
360
  host,
294
361
  refine: options.refine,
295
362
  disabledOf: options.disabledOf,
363
+ cancelableOf: options.cancelableOf,
296
364
  source: options.source ?? node,
297
365
  timers,
298
366
  listeners: {},
@@ -33,7 +33,9 @@
33
33
  import { registerScrollViewBehaviors } from './shared.js';
34
34
  const android = {
35
35
  claimMode: 'wrap',
36
- slotDerived: ['style'],
36
+ // `stickyHeaderIndices`: the content view's clipping is forced off under sticky headers on
37
+ // Android only (`ScrollView.js:1740-1745`), so only here does it dirty the slot.
38
+ slotDerived: ['style', 'stickyHeaderIndices'],
37
39
  };
38
40
  export function registerScrollViewBehavior() {
39
41
  registerScrollViewBehaviors(android);
@@ -5,7 +5,7 @@
5
5
  // Ported line-for-line from `ScrollView.js:1263-1546` — the negotiation engine itself and the
6
6
  // `IResponderProps` prop surface already exist and needed no change; this file is ScrollView opting
7
7
  // into the same mechanism Pressable already uses (`../pressable.ts:329-384`).
8
- import { appListenerFor, currentlyFocusedInput, blurTextInput, isRecord, isSymbioteNode, Keyboard, propOf, setBehaviorListener, } from '@symbiote-native/engine';
8
+ import { appListenerFor, currentlyFocusedInput, blurTextInput, dlog, isDebug, isRecord, isSymbioteNode, Keyboard, Platform, propOf, setBehaviorListener, } from '@symbiote-native/engine';
9
9
  import { descriptorFor } from '../../component-names';
10
10
  import { TEXT_INPUT_MULTILINE_TAG, TEXT_INPUT_TAG } from '../text-input.js';
11
11
  // RN's `IS_ANIMATING_TOUCH_START_THRESHOLD_MS` (ScrollView.js:695) — a momentum end inside this
@@ -45,14 +45,20 @@ function isTextInputNode(node) {
45
45
  return (node.component === descriptorFor(TEXT_INPUT_TAG).component ||
46
46
  node.component === descriptorFor(TEXT_INPUT_MULTILINE_TAG).component);
47
47
  }
48
- // RN's `_keyboardIsDismissible()` (ScrollView.js:1527-1541). RN also has `_keyboardEventsAreUnreliable()`
49
- // — a pre-API-30 Android layout-polling fallback — deliberately not ported: modern targets only,
50
- // and the omission only ever makes this return `false` more readily (never dismisses when RN
51
- // would not have been sure either), never a false claim.
48
+ // RN's `_keyboardEventsAreUnreliable()` (ScrollView.js:1558-1561): before API 30 Android reports
49
+ // the keyboard only through layout, so its events cannot be trusted.
50
+ const ANDROID_RELIABLE_KEYBOARD_API = 30;
51
+ function keyboardEventsAreUnreliable() {
52
+ return (Platform.OS === 'android' &&
53
+ typeof Platform.Version === 'number' &&
54
+ Platform.Version < ANDROID_RELIABLE_KEYBOARD_API);
55
+ }
56
+ // RN's `_keyboardIsDismissible()` (ScrollView.js:1527-1541).
52
57
  function keyboardIsDismissible() {
53
58
  const focused = currentlyFocusedInput();
54
59
  const hasFocusedTextInput = focused !== null && isTextInputNode(focused);
55
- return hasFocusedTextInput && Keyboard.metrics() !== undefined;
60
+ const softKeyboardMayBeOpen = Keyboard.metrics() !== undefined || keyboardEventsAreUnreliable();
61
+ return hasFocusedTextInput && softKeyboardMayBeOpen;
56
62
  }
57
63
  // RN's `_softKeyboardIsDetached()` (ScrollView.js:1543-1546).
58
64
  function softKeyboardIsDetached() {
@@ -88,8 +94,13 @@ function keyboardNeverPersistsTaps(persist) {
88
94
  // keyboard is up, and the touch did not land on the focused input itself.
89
95
  function startShouldSetResponderCapture(owner, event) {
90
96
  const state = stateFor(owner);
91
- if (isAnimating(state))
97
+ // Both claims below take the tap away from the button under the finger, so each one says so.
98
+ if (isAnimating(state)) {
99
+ if (isDebug()) {
100
+ dlog(`ScrollView claims the tap (animating: momentum begin=${state.lastMomentumScrollBeginTime} end=${state.lastMomentumScrollEndTime})`);
101
+ }
92
102
  return true;
103
+ }
93
104
  if (propOf(owner, 'disableScrollViewPanResponder') === true)
94
105
  return false;
95
106
  if (softKeyboardIsDetached())
@@ -97,8 +108,11 @@ function startShouldSetResponderCapture(owner, event) {
97
108
  if (keyboardNeverPersistsTaps(propOf(owner, 'keyboardShouldPersistTaps')) &&
98
109
  keyboardIsDismissible()) {
99
110
  const target = touchTargetOf(event.nativeEvent);
100
- if (target !== undefined && !isTextInputNode(target))
111
+ if (target !== undefined && !isTextInputNode(target)) {
112
+ if (isDebug())
113
+ dlog('ScrollView claims the tap (keyboard up, dismissing it)');
101
114
  return true;
115
+ }
102
116
  }
103
117
  return false;
104
118
  }
@@ -173,7 +187,19 @@ function handleMomentumScrollEnd(owner, event) {
173
187
  if (typeof app === 'function')
174
188
  app(event);
175
189
  }
190
+ // `ScrollView.js:1284-1295`. Android's native scroller has no `keyboardDismissMode`, so `on-drag`
191
+ // is JS dismissing the keyboard (`dismissKeyboard()`) before the app's own handler runs.
192
+ function handleScrollBeginDrag(owner, event) {
193
+ if (Platform.OS === 'android' &&
194
+ propOf(owner, 'keyboardDismissMode') === 'on-drag') {
195
+ blurTextInput(currentlyFocusedInput());
196
+ }
197
+ const app = appListenerFor(owner, 'scrollBeginDrag');
198
+ if (typeof app === 'function')
199
+ app(event);
200
+ }
176
201
  export const RESPONDER_OWNED_LISTENERS = [
202
+ 'scrollBeginDrag',
177
203
  'startShouldSetResponderCapture',
178
204
  'startShouldSetResponder',
179
205
  'responderTerminationRequest',
@@ -196,6 +222,9 @@ export function installResponderPredicates(owner) {
196
222
  setBehaviorListener(owner, 'momentumScrollBegin', event => {
197
223
  handleMomentumScrollBegin(owner, event);
198
224
  });
225
+ setBehaviorListener(owner, 'scrollBeginDrag', event => {
226
+ handleScrollBeginDrag(owner, event);
227
+ });
199
228
  setBehaviorListener(owner, 'momentumScrollEnd', event => {
200
229
  handleMomentumScrollEnd(owner, event);
201
230
  });
@@ -95,7 +95,11 @@ const SLOT_PROPS = {
95
95
  };
96
96
  // Owner props the SLOT's payload reads. Declared so a write to one dirties the slot — see
97
97
  // `IHostBehavior.slotDerived` for why nothing else makes that happen.
98
- const SLOT_DERIVED = ['maintainVisibleContentPosition', 'snapToAlignment'];
98
+ const SLOT_DERIVED = [
99
+ 'maintainVisibleContentPosition',
100
+ 'snapToAlignment',
101
+ 'removeClippedSubviews',
102
+ ];
99
103
  // A `<RefreshControl>` written among the app's children is claimed, and WHAT the owner does with
100
104
  // it is the one thing that genuinely differs per platform — see the platform files. Resolved
101
105
  // through `descriptorFor`, so this is `PullToRefreshView` on iOS and `AndroidSwipeRefreshLayout`
@@ -113,9 +117,8 @@ export const REFRESH_CONTROL = descriptorFor('refresh-control').component;
113
117
  // intrinsic from; an app that also writes `horizontal` on the vertical tag is contradicting the
114
118
  // element it chose, and the tag wins.
115
119
  //
116
- // `nestedScrollEnabled` defaults ON because every wrapper writes it on every ScrollView, both
117
- // platforms. RN itself only defaults it on the Android RefreshControl WRAP path
118
- // (`ScrollView.js:1862`) — parity here is with the wrapper this behavior replaced.
120
+ // `nestedScrollEnabled` defaults ON only on the Android RefreshControl WRAP path, as RN does
121
+ // (`ScrollView.js:1862`); elsewhere only the authored value is sent.
119
122
  // THE OWNER'S FOLD IS GONE (2026-09-18) — `foldScrollViewProps` in `SymbioteFabricProps.cpp`. Every
120
123
  // input it had was the node's own bag plus the AXIS, and the axis is the tag (`scroll-view` vs
121
124
  // `horizontal-scroll-view`), so it was a tag rule by every criterion: the base style composition,
@@ -21,7 +21,7 @@
21
21
  // compares against that seed. Reversed, the very first beat would see an empty mirror, decide the
22
22
  // app's value had diverged, and command a redundant `setTextAndSelection` down to native on every
23
23
  // input in the tree.
24
- import { appListenerFor, blurTextInput, dispatchViewCommand, dlog, focusTextInput, propOf, propsOf, registerHostBehavior, requestCommitFor, setBehaviorListener, setInputBlurred, setInputFocused, setProp, } from '@symbiote-native/engine';
24
+ import { appListenerFor, blurTextInput, dispatchViewCommand, dlog, focusTextInput, Platform, propOf, propsOf, registerHostBehavior, requestCommitFor, setBehaviorListener, setInputBlurred, setInputFocused, setProp, } from '@symbiote-native/engine';
25
25
  import { attachPressMachine, detachPressMachine, } from './pressable.js';
26
26
  import { eventCountFromChange, foldText, INITIAL_EVENT_COUNT, SELECTION_NONE, shouldCommandText, textFromChange, } from '../state/text-input.js';
27
27
  // Both spellings, because `multiline` picks between two Fabric views and the TAG is what decides.
@@ -265,7 +265,18 @@ function attach(node) {
265
265
  setBehaviorListener(node, 'focus', event => onFocus(node, event));
266
266
  setBehaviorListener(node, 'blur', event => onBlur(node, event));
267
267
  setBehaviorListener(node, 'selectionChange', event => onSelectionChange(node, event));
268
- attachPressMachine(node, { refine: focusOnPress });
268
+ attachPressMachine(node, {
269
+ refine: focusOnPress,
270
+ cancelableOf: textInputCancelable,
271
+ });
272
+ }
273
+ // TextInput.js:597 with its `rejectResponderTermination = true` default (:905): iOS keeps the
274
+ // gesture unless the app opts in; Android hands Pressability `null`, i.e. its own default (yield).
275
+ function textInputCancelable(source) {
276
+ if (Platform.OS !== 'ios')
277
+ return undefined;
278
+ const reject = propOf(source, 'rejectResponderTermination');
279
+ return !(reject === undefined || Boolean(reject));
269
280
  }
270
281
  // The first commit is the earliest point where the node has BOTH its props and a Fabric tag. The
271
282
  // mirror needs the first, `autoFocus` needs the second.
@@ -54,9 +54,9 @@
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
56
  // TouchableNativeFeedback with two responders.
57
- import { SLOT_DERIVED_ALL, appListenerFor, dispatchViewCommand, markPropsDirty, Platform, registerHostBehavior, requestCommitFor, setBehaviorListener, } from '@symbiote-native/engine';
57
+ import { SLOT_DERIVED_ALL, appListenerFor, markPropsDirty, Platform, registerHostBehavior, requestCommitFor, setBehaviorListener, } from '@symbiote-native/engine';
58
58
  import { resolveButtonDisabled } from '../view/render-button.js';
59
- import { asAccessibilityState, attachPressMachine, booleanOr, detachPressMachine, } from './pressable.js';
59
+ import { asAccessibilityState, attachPressMachine, booleanOr, detachPressMachine, withNativeFeedbackCommands, } from './pressable.js';
60
60
  export const TOUCHABLE_NATIVE_FEEDBACK_TAG = 'touchable-native-feedback';
61
61
  // TouchableNativeFeedback.js:214-221 — `disabled != null ? disabled : (aria-disabled ??
62
62
  // accessibilityState.disabled)`, read by the OWNER's Pressability config (:213), same three-way
@@ -106,11 +106,6 @@ const PRESS_LISTENERS = [
106
106
  // discriminant; the C++ rule asks only whether the value is an OBJECT and copies it into the slot,
107
107
  // because the four factories that produce it (`render-touchable-native-feedback.ts`) are ours and
108
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
109
  /**
115
110
  * TNF's own Pressability config, applied to whatever node carries the responder.
116
111
  *
@@ -128,34 +123,8 @@ function hotspotAt(nativeEvent, key) {
128
123
  * moves.
129
124
  */
130
125
  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
- };
126
+ const floorless = { ...config, minPressDuration: 0 };
127
+ return IS_ANDROID ? withNativeFeedbackCommands(node, floorless) : floorless;
159
128
  };
160
129
  // `cloneFold` LEFT THIS FILE ON 2026-09-18 — it is `foldCloneOntoChild` in
161
130
  // `SymbioteFabricProps.cpp`, and the seam it needed is the first rule keyed on the PARENT'S tag
@@ -5,7 +5,7 @@
5
5
  // export): react-native's own source is Flow syntax Vitest's transform can't parse, so anything
6
6
  // importing it directly must stay unreachable from the tested main index.ts.
7
7
  import { processColor, DeviceEventEmitter, Image, } from 'react-native';
8
- import { setColorProcessor, setDeviceEventSource, setImageSourceResolver, setNativeViewConfigSource, } from '@symbiote-native/engine';
8
+ import { installBackHandler, setColorProcessor, setDeviceEventSource, setImageSourceResolver, setNativeViewConfigSource, } from '@symbiote-native/engine';
9
9
  // @ts-expect-error react-native ships no types for this internal path (plain .js) - the
10
10
  // try/catch below is what actually proves the shape, not TS.
11
11
  import * as ReactNativeViewConfigRegistry from 'react-native/Libraries/Renderer/shims/ReactNativeViewConfigRegistry';
@@ -38,5 +38,7 @@ export function bootstrapHost(options = {}) {
38
38
  setColorProcessor(options.colorProcessor ?? defaultColorProcessor);
39
39
  setImageSourceResolver(options.imageSourceResolver ?? defaultImageSourceResolver);
40
40
  setDeviceEventSource(options.deviceEventSource ?? DeviceEventEmitter);
41
+ // After the event source: the back button subscribes through it (see installBackHandler).
42
+ installBackHandler();
41
43
  setNativeViewConfigSource(options.nativeViewConfigSource ?? defaultNativeViewConfigSource);
42
44
  }
@@ -31,20 +31,9 @@ const ANDROID_NAMES = {
31
31
  switch: 'AndroidSwitch',
32
32
  'activity-indicator': 'RCTView',
33
33
  'activity-indicator-spinner': 'AndroidProgressBar',
34
- // KNOWN DIVERGENCE FROM REACT NATIVE, and it is in our favour — recorded 2026-09-01 because it
35
- // was arrived at by accident, not decided. Upstream `SafeAreaView.js` is
36
- // `Platform.select({ ios: RCTSafeAreaViewNativeComponent, default: View })`, so RN's own JS
37
- // renders a plain View on Android and applies NO insets. `RCTSafeAreaView` does exist and is
38
- // registered (`ReactSafeAreaViewManager.REACT_CLASS`, wired in `MainReactPackage.kt:149`) and
39
- // does real window-inset math — so we inset where RN does not, and an app ported from RN gets
40
- // different Android layout with nothing to explain why.
41
- //
42
- // This table's rule is "the name is the ViewManager's REACT_CLASS", and by that rule the entry is
43
- // correct. What nobody checked is whether RN's JS ROUTES there. Left as-is deliberately: insetting
44
- // is the better behaviour and RN has deprecated its own component in favour of
45
- // react-native-safe-area-context. Invisible to every audit we have, because both of our paths
46
- // agree with each other and only disagree with RN — the boundary
47
- // `.claude/rules/adapter-parity-audit.md` states about itself.
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.
48
37
  'safe-area-view': 'RCTSafeAreaView',
49
38
  modal: 'RCTModalHostView',
50
39
  'refresh-control': 'AndroidSwipeRefreshLayout',
package/build/index.d.ts CHANGED
@@ -27,7 +27,7 @@ export { imageStatics, setImageSourceResolver } from '@symbiote-native/engine';
27
27
  export type { IInputAccessoryViewViewProps } from './view/render-input-accessory-view';
28
28
  export { renderModal } from './view/render-modal';
29
29
  export type { IModalViewProps, IModalAnimationType, IModalPresentationStyle, IModalOrientation, IModalOrientationChangeEvent, } from './view/render-modal';
30
- export { modalReducer, createInitialModalState, shouldRenderModal, } from './state/modal';
30
+ export { modalReducer, createInitialModalState, isModalVisible, modalVisibilityAction, shouldRenderModal, } from './state/modal';
31
31
  export type { IModalState, IModalAction } from './state/modal';
32
32
  export { computeInset, configureKeyboardAvoidingAnimation, keyboardAvoidingEventNamesFor, readKeyboardAnimationTiming, readPrefersCrossFadeTransitions, readKeyboardFrame, readLayoutFrame, resolveKeyboardAvoidingLayout, DEFAULT_VERTICAL_OFFSET, } from './view/render-keyboard-avoiding-view';
33
33
  export type { IComputeInsetOptions, IKeyboardAnimationTiming, IKeyboardAvoidingBehavior, IKeyboardAvoidingEventNames, IMeasuredFrame, IKeyboardFrame, IKeyboardAvoidingLayout, IResolveKeyboardAvoidingLayoutParams, } from './view/render-keyboard-avoiding-view';
@@ -50,7 +50,7 @@ export { reduceList, createInitialListState, listEffectSignature, } from './stat
50
50
  export type { IListState, IListMetrics, IListReducerInputs, IListAction, IListEffect, IListReduceResult, } from './state/virtualized-list-reducer';
51
51
  export { subscribeListDiagnostics } from './state/virtualized-list-diagnostics';
52
52
  export type { IListDiagnosticFrame, IListDiagnosticMove, } from './state/virtualized-list-diagnostics';
53
- export { SINGLE_COLUMN, chunkIntoRows, rowKeyExtractor, expandRowToken, expandRowViewability, lastItemOfRow, firstItemOfRow, } from './state/flat-list';
53
+ export { SINGLE_COLUMN, chunkIntoRows, rowKeyExtractor, expandRowToken, expandRowViewability, lastItemOfRow, firstItemOfRow, removeClippedSubviewsOrDefault, } from './state/flat-list';
54
54
  export type { IRow } from './state/flat-list';
55
55
  export { flattenSections, unwrapEntryItem, sectionEntryKey, scrollLocationToFlatIndex, } from './state/section-list';
56
56
  export type { ISection, ISectionEntry, IVirtualizedSectionListHandle, } from './state/section-list';
package/build/index.js CHANGED
@@ -35,7 +35,7 @@ export { renderImage } from './view/render-image/index.js';
35
35
  export { imageStatics, setImageSourceResolver } from '@symbiote-native/engine';
36
36
  // Modal: full 3-layer split (state machine gates the iOS keep-alive frame).
37
37
  export { renderModal } from './view/render-modal.js';
38
- export { modalReducer, createInitialModalState, shouldRenderModal, } from './state/modal.js';
38
+ export { modalReducer, createInitialModalState, isModalVisible, modalVisibilityAction, shouldRenderModal, } from './state/modal.js';
39
39
  // KeyboardAvoidingView: pure inset/behavior math; the adapter owns the Keyboard subscription.
40
40
  export { computeInset, configureKeyboardAvoidingAnimation, keyboardAvoidingEventNamesFor, readKeyboardAnimationTiming, readPrefersCrossFadeTransitions, readKeyboardFrame, readLayoutFrame, resolveKeyboardAvoidingLayout, DEFAULT_VERTICAL_OFFSET, } from './view/render-keyboard-avoiding-view.js';
41
41
  // Pressable: the press state machine (logic) + render decisions. The pure lifecycle (timers,
@@ -74,7 +74,7 @@ export { DEFAULT_WINDOW_SIZE, DEFAULT_INITIAL_NUM_TO_RENDER, DEFAULT_MAX_TO_REND
74
74
  // out of each adapter's reactive glue. The adapter maps events to actions and runs the effects.
75
75
  export { reduceList, createInitialListState, listEffectSignature, } from './state/virtualized-list-reducer.js';
76
76
  export { subscribeListDiagnostics } from './state/virtualized-list-diagnostics.js';
77
- export { SINGLE_COLUMN, chunkIntoRows, rowKeyExtractor, expandRowToken, expandRowViewability, lastItemOfRow, firstItemOfRow, } from './state/flat-list.js';
77
+ export { SINGLE_COLUMN, chunkIntoRows, rowKeyExtractor, expandRowToken, expandRowViewability, lastItemOfRow, firstItemOfRow, removeClippedSubviewsOrDefault, } from './state/flat-list.js';
78
78
  export { flattenSections, unwrapEntryItem, sectionEntryKey, scrollLocationToFlatIndex, } from './state/section-list.js';
79
79
  export { registerPressableBehavior, PRESSABLE_TAG, } from './behaviors/pressable.js';
80
80
  export { registerTouchableOpacityBehavior, TOUCHABLE_OPACITY_TAG, } from './behaviors/touchable-opacity.js';
@@ -1,5 +1,6 @@
1
1
  import { type IViewableItemsChangedInfo, type IViewToken } from './virtualized-list';
2
2
  export declare const SINGLE_COLUMN = 1;
3
+ export declare function removeClippedSubviewsOrDefault<TValue>(removeClippedSubviews: TValue | null | undefined, os: string): TValue | boolean;
3
4
  export interface IRow<ItemT> {
4
5
  items: ItemT[];
5
6
  startIndex: number;
@@ -6,6 +6,11 @@
6
6
  // only the element creation (createElement / h) for a row and the ref wiring.
7
7
  import { defaultKeyExtractor, } from './virtualized-list.js';
8
8
  export const SINGLE_COLUMN = 1;
9
+ // FlatList.js's `removeClippedSubviewsOrDefault`: off-screen rows are clipped by default on
10
+ // Android only (RN's `shouldUseRemoveClippedSubviewsAsDefaultOnIOS` is off in 0.86).
11
+ export function removeClippedSubviewsOrDefault(removeClippedSubviews, os) {
12
+ return removeClippedSubviews ?? os === 'android';
13
+ }
9
14
  export function chunkIntoRows(data, columns) {
10
15
  const rows = [];
11
16
  for (let start = 0; start < data.length; start += columns) {
@@ -1,3 +1,4 @@
1
+ export declare function isModalVisible(visible: unknown): boolean;
1
2
  export type IModalState = {
2
3
  isRendered: boolean;
3
4
  };
@@ -8,4 +9,5 @@ export type IModalAction = {
8
9
  type: 'hide';
9
10
  };
10
11
  export declare function modalReducer(state: IModalState, action: IModalAction): IModalState;
11
- export declare function shouldRenderModal(isVisible: boolean, state: IModalState): boolean;
12
+ export declare function shouldRenderModal(isVisible: boolean, state: IModalState, os?: string): boolean;
13
+ export declare function modalVisibilityAction(isVisible: boolean): IModalAction | undefined;
@@ -1,10 +1,12 @@
1
- // Modal: the logic half (framework-agnostic). RN keeps the modal mounted through its exit
2
- // animation (Modal.js _shouldShowModal: visible===true || state.isRendered===true) so the
3
- // native onDismiss event can arrive before the node unmounts. `isRendered` is PURELY that
4
- // keep-alive; it never itself fires onDismiss (on Fabric onDismiss is a real native
5
- // DirectEvent delivered via the host's onDismiss prop). The reducer mirrors RN's guarded
6
- // transitions: arm the keep-alive on show, drop it on hide. The adapter drives the transition
7
- // AFTER its render (React useEffect / Vue post-flush watch) so one keep-alive frame survives.
1
+ // Modal logic, framework-agnostic. On iOS RN keeps the modal mounted through its exit animation
2
+ // until native onDismiss arrives (Modal.js _shouldShowModal); only that event drops the
3
+ // keep-alive, then the app's onDismiss runs. Android has no dismiss event: `visible` alone.
4
+ import { Platform } from '@symbiote-native/engine';
5
+ // Modal.js `defaultProps.visible = true`: defaultProps fill only `undefined`, so an unset
6
+ // `visible` shows and an explicit `false`/`null` hides.
7
+ export function isModalVisible(visible) {
8
+ return visible === undefined || visible === true;
9
+ }
8
10
  // On first render the keep-alive matches `visible`: a modal that starts visible is rendered,
9
11
  // one that starts hidden contributes no node (the render gate returns null).
10
12
  export function createInitialModalState(isVisible) {
@@ -20,9 +22,13 @@ export function modalReducer(state, action) {
20
22
  return state.isRendered ? { isRendered: false } : state;
21
23
  }
22
24
  }
23
- // The visible gate with the keep-alive: a fully hidden modal (not visible AND no longer
24
- // rendered) contributes no node, exactly as RN's render returns null when _shouldShowModal()
25
- // is false.
26
- export function shouldRenderModal(isVisible, state) {
27
- return isVisible || state.isRendered;
25
+ // Modal.js `_shouldShowModal`: iOS keeps the node through its exit animation until the native
26
+ // dismiss drops the keep-alive; Android shows on `visible` alone.
27
+ export function shouldRenderModal(isVisible, state, os = Platform.OS) {
28
+ return os === 'ios' ? isVisible || state.isRendered : isVisible;
29
+ }
30
+ // Modal.js componentDidUpdate: false->true arms the keep-alive; true->false does NOTHING — only the
31
+ // native `onDismiss` (iOS) drops it, via `{type: 'hide'}` from the adapter's dismiss handler.
32
+ export function modalVisibilityAction(isVisible) {
33
+ return isVisible ? { type: 'show' } : undefined;
28
34
  }
@@ -18,7 +18,7 @@
18
18
  // here, so a windowing / edge / viewability / MVCP bug — and the drift between three copies of it —
19
19
  // is fixed once for all adapters.
20
20
  import { dlog } from '@symbiote-native/engine';
21
- import { EMPTY_OFFSET, FIRST_INDEX, NO_CONTENT_LENGTH_SENT, NO_INDEX, averageMeasuredStride, buildOffsets, computeEndReached, computeMvcpAdjustment, computeStartReached, computeViewableSet, computeWindow, decideEdgeReached, diffViewable, highestMeasuredIndex, indexOfItem, isSettledLayout, maxMinimumViewTime, offsetForEnd, offsetForIndex, resolveAverageLength, resolveItemKey, throttleWindow, wrapFixedLayout, } from './virtualized-list.js';
21
+ import { EMPTY_OFFSET, FIRST_INDEX, NO_CONTENT_LENGTH_SENT, NO_INDEX, averageMeasuredStride, buildOffsets, computeEndReached, computeMvcpAdjustment, computeStartReached, computeViewableSet, computeWindow, decideEdgeReached, diffViewable, highestMeasuredIndex, indexOfItem, isSettledLayout, maxMinimumViewTime, offsetForEnd, offsetForIndex, resolveAverageLength, resolveItemKey, throttleWindow, initialRenderRegion, wrapFixedLayout, } from './virtualized-list.js';
22
22
  import { recordCellMove, recordListFrame, } from './virtualized-list-diagnostics.js';
23
23
  export function createInitialListState() {
24
24
  return {
@@ -91,7 +91,7 @@ function deriveMetrics(state, inputs) {
91
91
  };
92
92
  }
93
93
  const target = computeWindow(count, offsets, lengths, state.scrollOffset, state.viewportLength, inputs.windowSize, inputs.initialNumToRender);
94
- const throttled = throttleWindow(target, state.committedWindow, inputs.maxToRenderPerBatch);
94
+ const throttled = throttleWindow(target, state.committedWindow, inputs.maxToRenderPerBatch, initialRenderRegion(count, inputs.initialScrollIndex, inputs.initialNumToRender));
95
95
  state.committedWindow = throttled;
96
96
  state.metrics = {
97
97
  count,
@@ -13,6 +13,7 @@ export declare const NO_INDEX = -1;
13
13
  export declare const FULLY_VISIBLE_PERCENT = 100;
14
14
  export declare const ON_EDGE_REACHED_EPSILON = 0.001;
15
15
  export declare const NO_CONTENT_LENGTH_SENT = -1;
16
+ export declare function invertedYStyleFor(os: string): IViewStyle;
16
17
  export declare const INVERTED_Y_STYLE: IViewStyle;
17
18
  export declare const INVERTED_X_STYLE: IViewStyle;
18
19
  export interface ICellLayout {
@@ -84,13 +85,20 @@ export declare function computeWindow(count: number, offsets: number[], lengths:
84
85
  first: number;
85
86
  last: number;
86
87
  };
88
+ export declare function initialRenderRegion(count: number, initialScrollIndex: number | undefined, initialNumToRender: number): {
89
+ first: number;
90
+ last: number;
91
+ };
87
92
  export declare function throttleWindow(target: {
88
93
  first: number;
89
94
  last: number;
90
95
  }, previous: {
91
96
  first: number;
92
97
  last: number;
93
- }, maxToRenderPerBatch: number): {
98
+ }, maxToRenderPerBatch: number, initialRegion: {
99
+ first: number;
100
+ last: number;
101
+ }): {
94
102
  first: number;
95
103
  last: number;
96
104
  };
@@ -19,7 +19,7 @@
19
19
  // framework's own children (renderItem -> ReactNode / VNode), so there is no
20
20
  // Descriptor render fn for a list - the shared layer for lists is this STATE/logic
21
21
  // module, not a view/render-*.ts.
22
- import { dlog } from '@symbiote-native/engine';
22
+ import { dlog, Platform } from '@symbiote-native/engine';
23
23
  // Defaults match RN. windowSize is measured in viewport-lengths (21 => ten screens
24
24
  // of buffer on each side of the visible region). initialNumToRender bounds the first
25
25
  // paint before any layout is measured. maxToRenderPerBatch / batching period mirror
@@ -46,8 +46,15 @@ export const ON_EDGE_REACHED_EPSILON = 0.001;
46
46
  // yet". Real content lengths are >= 0, so -1 can never collide with one.
47
47
  export const NO_CONTENT_LENGTH_SENT = -1;
48
48
  // Inversion flips the content container along the scroll axis; each cell re-flips so
49
- // its own content stays upright (RN does the same with a scale(-1) transform).
50
- export const INVERTED_Y_STYLE = { transform: [{ scaleY: -1 }] };
49
+ // its own content stays upright. VirtualizedList.js `styles.verticallyInverted`: Android flips
50
+ // with `scale: -1` because `scaleY: -1` can ANR on API 33+ (react-native#35350); the native side
51
+ // then moves the scrollbar back via `isInvertedVirtualizedList`.
52
+ export function invertedYStyleFor(os) {
53
+ return os === 'android'
54
+ ? { transform: [{ scale: -1 }] }
55
+ : { transform: [{ scaleY: -1 }] };
56
+ }
57
+ export const INVERTED_Y_STYLE = invertedYStyleFor(Platform.OS);
51
58
  export const INVERTED_X_STYLE = { transform: [{ scaleX: -1 }] };
52
59
  // nativeEvent payload guards. The payloads arrive as `unknown` off the wire, so they
53
60
  // are narrowed with runtime checks rather than cast.
@@ -166,13 +173,24 @@ export function computeWindow(count, offsets, lengths, scrollOffset, viewportLen
166
173
  }
167
174
  return { first, last };
168
175
  }
176
+ // RN's `VirtualizedList._initialRenderRegion`: `initialNumToRender` cells from
177
+ // `initialScrollIndex`, clamped to the data. What a list paints before it has a window of its own.
178
+ export function initialRenderRegion(count, initialScrollIndex, initialNumToRender) {
179
+ const first = Math.max(FIRST_INDEX, Math.min(count - 1, Math.floor(initialScrollIndex ?? FIRST_INDEX)));
180
+ return { first, last: Math.min(count, first + initialNumToRender) - 1 };
181
+ }
169
182
  // Clamp a freshly computed window against the previously-committed one so at most
170
183
  // maxToRenderPerBatch new cells are added on each side per tick (RN's incremental fill).
171
184
  // The window grows toward the target over successive batch ticks rather than snapping in
172
185
  // one render: cheaper first paint on a big jump.
173
- export function throttleWindow(target, previous, maxToRenderPerBatch) {
186
+ //
187
+ // With NO previous window - the list just received data - RN paints its initial region and grows
188
+ // from there (`_createRenderMask` adds `_initialRenderRegion` to a window constrained from empty),
189
+ // even when the viewport is already known. Snapping to the target instead mounted ~125 rows where
190
+ // RN mounts 10 on a 420pt viewport (`stock-virtualized-suite.itest.tsx`).
191
+ export function throttleWindow(target, previous, maxToRenderPerBatch, initialRegion) {
174
192
  if (previous.last < previous.first)
175
- return target;
193
+ return initialRegion.last < initialRegion.first ? target : initialRegion;
176
194
  const first = Math.max(target.first, previous.first - maxToRenderPerBatch);
177
195
  const last = Math.min(target.last, previous.last + maxToRenderPerBatch);
178
196
  // Never present an empty window when the target is non-empty.
@@ -73,10 +73,11 @@ export function renderModal(view, isRTL = I18nManager.isRTL) {
73
73
  // supportedOrientations/allowSwipeDismissal, Android hardwareAccelerated/
74
74
  // statusBarTranslucent/navigationBarTranslucent.
75
75
  supportedOrientations: view.supportedOrientations,
76
- hardwareAccelerated: view.hardwareAccelerated,
76
+ // Modal.js `defaultProps = {hardwareAccelerated: false, visible: true}`.
77
+ hardwareAccelerated: view.hardwareAccelerated ?? false,
77
78
  statusBarTranslucent: view.statusBarTranslucent,
78
79
  navigationBarTranslucent: view.navigationBarTranslucent,
79
80
  allowSwipeDismissal: view.allowSwipeDismissal,
80
- visible: view.visible,
81
+ visible: view.visible ?? true,
81
82
  }, [container]);
82
83
  }
@@ -4,14 +4,9 @@
4
4
  // both live here so every adapter inherits the exact same surface. The adapter only attaches these
5
5
  // statics onto its component value and nests the feedback View under its Pressable.
6
6
  import { Platform } from '@symbiote-native/engine';
7
- // Native foreground ripple is Android-only (API 23+). RN gates this on Platform.OS === 'android'
8
- // && Platform.Version >= 23. Version is a string on iOS (where the gate is irrelevant) and a
9
- // number on Android, so guard the type at runtime before the numeric compare, no cast.
10
- const ANDROID_FOREGROUND_MIN_VERSION = 23;
7
+ // TouchableNativeFeedback.js:202 — the OS alone; RN's minSdk is past the old API-23 gate.
11
8
  export function canUseNativeForeground() {
12
- return (Platform.OS === 'android' &&
13
- typeof Platform.Version === 'number' &&
14
- Platform.Version >= ANDROID_FOREGROUND_MIN_VERSION);
9
+ return Platform.OS === 'android';
15
10
  }
16
11
  export function selectableBackground(rippleRadius) {
17
12
  return {
@@ -27,15 +22,14 @@ export function selectableBackgroundBorderless(rippleRadius) {
27
22
  rippleRadius,
28
23
  };
29
24
  }
30
- // RN runs the color string through processColor (→ a native int); we have no native bridge here,
31
- // so we keep the string and let Android resolve it. A null color is the documented "no tint".
25
+ // RN runs the color through processColor here; we keep the string and the engine's Android rule
26
+ // converts it (Java reads it with getInt, a string would fail). A null color is "no tint".
32
27
  export function rippleBackground(color, borderless, rippleRadius) {
33
28
  return { type: 'RippleAndroid', color, borderless, rippleRadius };
34
29
  }
35
30
  // `backgroundProps` WAS HERE AND IS GONE (2026-09-18). It mapped the resolved background plus
36
31
  // `useForeground` onto the native slot Android reads, and that is the `#ifdef ANDROID` tail of
37
- // `foldCloneOntoChild` in `SymbioteFabricProps.cpp` — api-level gate included, since
38
- // `Platform.Version` on Android IS the api level and `androidApiLevel()` reads the same number.
32
+ // `foldCloneOntoChild` in `SymbioteFabricProps.cpp`.
39
33
  //
40
34
  // A JS COPY OF A RULE THAT RUNS IN C++, with no runtime caller and no test, reachable only through
41
35
  // the package barrel. Found by asking what each `Platform.OS` branch still left in
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@symbiote-native/components",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "Framework-agnostic component logic (state machines + render functions) for SymbioteNative — written once, inherited by every adapter (React, Vue, Angular, ...).",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -59,11 +59,11 @@
59
59
  },
60
60
  "peerDependencies": {
61
61
  "react-native": ">=0.86",
62
- "@symbiote-native/engine": "^1.0.0"
62
+ "@symbiote-native/engine": "^1.2.0"
63
63
  },
64
64
  "devDependencies": {
65
65
  "@types/node": "^26.0.0",
66
- "@symbiote-native/engine": "1.0.0"
66
+ "@symbiote-native/engine": "1.2.0"
67
67
  },
68
68
  "scripts": {
69
69
  "typecheck": "tsc --build",