@symbiote-native/react 0.2.8 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +5 -5
  2. package/build/bootstrap.js +5 -6
  3. package/build/components/activity-indicator/index.android.js +5 -7
  4. package/build/components/activity-indicator/shared.js +1 -1
  5. package/build/components/button.js +1 -1
  6. package/build/components/flat-list/index.js +5 -2
  7. package/build/components/input-accessory-view/index.js +6 -1
  8. package/build/components/keyboard-avoiding-view/index.js +32 -12
  9. package/build/components/modal/index.d.ts +3 -2
  10. package/build/components/modal/index.js +1 -1
  11. package/build/components/pressable/index.d.ts +1 -1
  12. package/build/components/pressable/index.js +1 -1
  13. package/build/components/scroll-view/index.android.js +16 -8
  14. package/build/components/scroll-view/index.ios.js +13 -11
  15. package/build/components/scroll-view/shared.js +58 -9
  16. package/build/components/scroll-view/sticky-header.d.ts +11 -2
  17. package/build/components/scroll-view/sticky-header.js +60 -20
  18. package/build/components/section-list/index.js +4 -6
  19. package/build/components/text-input/index.js +7 -2
  20. package/build/components/touchable/index.d.ts +2 -0
  21. package/build/components/touchable/index.js +130 -28
  22. package/build/components/virtualized-list/index.js +87 -22
  23. package/build/components/virtualized-section-list/index.d.ts +5 -0
  24. package/build/components/virtualized-section-list/index.js +24 -4
  25. package/build/components.js +2 -2
  26. package/build/create-portal/index.js +1 -1
  27. package/build/create-tunnel/index.js +15 -21
  28. package/build/hooks/use-color-scheme.d.ts +1 -1
  29. package/build/hooks/use-color-scheme.js +1 -1
  30. package/build/hooks/use-window-dimensions.d.ts +1 -1
  31. package/build/hooks/use-window-dimensions.js +1 -1
  32. package/build/host-config.js +13 -5
  33. package/build/host-instance.js +5 -6
  34. package/build/index.d.ts +12 -37
  35. package/build/index.js +16 -26
  36. package/build/modules/animated/create-animated-component.js +27 -66
  37. package/build/modules/animated/index.d.ts +1 -1
  38. package/build/modules/animated/index.js +1 -1
  39. package/build/modules/status-bar/index.ios.js +1 -1
  40. package/build/render.js +30 -2
  41. package/metro-css-parser.cjs +7 -12
  42. package/package.json +5 -5
  43. package/build/modules/action-sheet-ios.d.ts +0 -2
  44. package/build/modules/action-sheet-ios.js +0 -4
  45. package/build/modules/alert.d.ts +0 -2
  46. package/build/modules/alert.js +0 -5
  47. package/build/modules/app-state.d.ts +0 -2
  48. package/build/modules/app-state.js +0 -3
  49. package/build/modules/appearance.d.ts +0 -2
  50. package/build/modules/appearance.js +0 -3
  51. package/build/modules/back-handler.d.ts +0 -2
  52. package/build/modules/back-handler.js +0 -3
  53. package/build/modules/dimensions.d.ts +0 -2
  54. package/build/modules/dimensions.js +0 -3
  55. package/build/modules/i18n-manager.d.ts +0 -2
  56. package/build/modules/i18n-manager.js +0 -4
  57. package/build/modules/keyboard.d.ts +0 -2
  58. package/build/modules/keyboard.js +0 -3
  59. package/build/modules/layout-animation.d.ts +0 -2
  60. package/build/modules/layout-animation.js +0 -3
  61. package/build/modules/linking.d.ts +0 -2
  62. package/build/modules/linking.js +0 -4
  63. package/build/modules/permissions-android.d.ts +0 -2
  64. package/build/modules/permissions-android.js +0 -3
  65. package/build/modules/settings.d.ts +0 -1
  66. package/build/modules/settings.js +0 -4
  67. package/build/modules/share.d.ts +0 -2
  68. package/build/modules/share.js +0 -4
  69. package/build/modules/text-input-state.d.ts +0 -1
  70. package/build/modules/text-input-state.js +0 -3
  71. package/build/modules/toast-android.d.ts +0 -1
  72. package/build/modules/toast-android.js +0 -4
  73. package/build/modules/vibration.d.ts +0 -1
  74. package/build/modules/vibration.js +0 -4
@@ -1,15 +1,13 @@
1
1
  // Sticky headers: the JS layer RN implements in ScrollView.js / ScrollViewStickyHeader.js.
2
2
  //
3
- // VERDICT (source-based): RN does stickiness PURELY IN JS. ScrollView.js (render, ~line
4
- // 1690) wraps each child whose index is in `stickyHeaderIndices` in a ScrollViewStickyHeader,
5
- // fed by a single `_scrollAnimatedValue` an Animated.event drives from `onScroll`
6
- // (ScrollView.js ~line 1095). The native Fabric scroll view does NOT honor the index array on
7
- // its own. Forwarding `stickyHeaderIndices` to native is a silent no-op. So we replicate the
8
- // JS layer: subscribe each flagged child to the scroll offset and translate it to stay pinned.
9
- // The interpolation mirrors ScrollViewStickyHeader.js (non-inverted + inverted branches) and now
10
- // lives, framework-agnostic, in @symbiote-native/components (computeStickyInterpolation); this
11
- // file holds the React component shell, the layout state, and the child-wrapping.
12
- import { Children, createElement, isValidElement, useCallback, useEffect, useReducer, useRef, useState, } from 'react';
3
+ // RN does stickiness purely in JS: ScrollView.js (~line 1690) wraps each flagged child in a
4
+ // ScrollViewStickyHeader, fed by a single `_scrollAnimatedValue` that Animated.event drives
5
+ // from `onScroll` (~line 1095). The native Fabric scroll view does not honor the index array,
6
+ // so forwarding `stickyHeaderIndices` to native would be a silent no-op - we replicate the JS
7
+ // layer instead. The interpolation mirrors ScrollViewStickyHeader.js and now lives,
8
+ // framework-agnostic, in @symbiote-native/components (computeStickyInterpolation); this file
9
+ // holds only the React component shell, layout state, and child-wrapping.
10
+ import { Children, createElement, isValidElement, useCallback, useEffect, useImperativeHandle, useReducer, useRef, useState, } from 'react';
13
11
  import { AnimatedInterpolation, AnimatedValue, Platform, dlog, } from '@symbiote-native/engine';
14
12
  import { createInitialStickyState, nextStickyHeaderY, readLayoutNumber, reduceSticky, STICKY_HEADER_Z_INDEX, } from '@symbiote-native/components';
15
13
  import { Animated } from '../../modules/animated/index.js';
@@ -32,7 +30,24 @@ function firstChild(children) {
32
30
  // (@symbiote-native/components); this component supplies only the React lifecycle: the ONE folded
33
31
  // state cell, the interpolation-node + listener wiring, the debounce setTimeout, and the re-render.
34
32
  export const ScrollViewStickyHeader = props => {
35
- const { inverted, scrollViewHeight, scrollAnimatedValue, nextHeaderLayoutY, children } = props;
33
+ const { inverted, scrollViewHeight, scrollAnimatedValue, nextHeaderLayoutY, children, ref, } = props;
34
+ // The next header's y lives HERE, not in a prop the parent re-renders to update (RN keeps it in
35
+ // the child too, ScrollViewStickyHeader.js:65). The prop only seeds the first render; afterwards
36
+ // the ScrollView pushes updates through setNextHeaderY. That keeps the cost linear - a
37
+ // parent-state round trip re-renders every child on every header's layout, so N headers cost N
38
+ // re-renders of N children. At N=200 it churned enough native animated nodes to trip Fabric's view
39
+ // registry ("Attempt to dequeue already registered component") seconds after mount.
40
+ const [nextHeaderY, setNextHeaderY] = useState(nextHeaderLayoutY);
41
+ useImperativeHandle(ref, () => ({ setNextHeaderY }), []);
42
+ // The imperative push is a fast path, not the source of truth. When the parent DOES re-render
43
+ // with a different value the prop wins, because the successor may be gone: a VirtualizedList
44
+ // unmounts headers as they scroll out, and a value pushed in earlier would otherwise stick
45
+ // around as a collision point that no longer exists.
46
+ const lastNextHeaderProp = useRef(nextHeaderLayoutY);
47
+ if (lastNextHeaderProp.current !== nextHeaderLayoutY) {
48
+ lastNextHeaderProp.current = nextHeaderLayoutY;
49
+ setNextHeaderY(nextHeaderLayoutY);
50
+ }
36
51
  // The one folded state cell (RN's scattered useState/useRef collapsed into IStickyHeaderState),
37
52
  // mutated in place by reduceSticky. Lazily created once.
38
53
  const stateRef = useRef(null);
@@ -41,7 +56,10 @@ export const ScrollViewStickyHeader = props => {
41
56
  // The animated node that drives the transform (RN's animatedTranslateY), rebuilt by the
42
57
  // rebuild-interpolation effect. When the scroll value is native, this interpolation runs on the
43
58
  // UI thread: the smooth pin.
44
- const [animatedTranslateY, setAnimatedTranslateY] = useState(() => scrollAnimatedValue.interpolate({ inputRange: [-1, 0], outputRange: [0, 0] }));
59
+ const [animatedTranslateY, setAnimatedTranslateY] = useState(() => scrollAnimatedValue.interpolate({
60
+ inputRange: [-1, 0],
61
+ outputRange: [0, 0],
62
+ }));
45
63
  const debounceTimer = useRef(null);
46
64
  // The current interpolation node + its settled-value listener id, held so the next rebuild can
47
65
  // detach the old listener (engine calls the reducer does NOT own) and unmount can clean up.
@@ -51,9 +69,14 @@ export const ScrollViewStickyHeader = props => {
51
69
  os: Platform.OS,
52
70
  inverted,
53
71
  scrollViewHeight,
54
- nextHeaderLayoutY,
72
+ nextHeaderLayoutY: nextHeaderY,
55
73
  });
56
- inputsRef.current = { os: Platform.OS, inverted, scrollViewHeight, nextHeaderLayoutY };
74
+ inputsRef.current = {
75
+ os: Platform.OS,
76
+ inverted,
77
+ scrollViewHeight,
78
+ nextHeaderLayoutY: nextHeaderY,
79
+ };
57
80
  // dispatch reaches through a ref because the effect executors dispatch follow-up actions
58
81
  // (the listener -> animated-tick, the debounce timer -> debounce-fired).
59
82
  const dispatchRef = useRef(() => { });
@@ -87,7 +110,10 @@ export const ScrollViewStickyHeader = props => {
87
110
  clearTimeout(debounceTimer.current);
88
111
  debounceTimer.current = setTimeout(() => {
89
112
  debounceTimer.current = null;
90
- dispatchRef.current({ kind: 'debounce-fired', value: effect.value });
113
+ dispatchRef.current({
114
+ kind: 'debounce-fired',
115
+ value: effect.value,
116
+ });
91
117
  }, effect.delay);
92
118
  break;
93
119
  }
@@ -112,7 +138,7 @@ export const ScrollViewStickyHeader = props => {
112
138
  // dispatches 'layout' itself); also does the initial identity build on mount.
113
139
  useEffect(() => {
114
140
  dispatchRef.current({ kind: 'inputs-changed' });
115
- }, [inverted, scrollViewHeight, nextHeaderLayoutY, scrollAnimatedValue]);
141
+ }, [inverted, scrollViewHeight, nextHeaderY, scrollAnimatedValue]);
116
142
  // Detach the listener + clear the debounce on unmount.
117
143
  useEffect(() => () => {
118
144
  const previous = interpolationRef.current;
@@ -125,7 +151,11 @@ export const ScrollViewStickyHeader = props => {
125
151
  const y = readLayoutNumber(event, 'y');
126
152
  const height = readLayoutNumber(event, 'height');
127
153
  // Keep the previous value when a field is absent (RN sets state only on a defined read).
128
- dispatch({ kind: 'layout', y: y ?? state.layoutY, height: height ?? state.layoutHeight });
154
+ dispatch({
155
+ kind: 'layout',
156
+ y: y ?? state.layoutY,
157
+ height: height ?? state.layoutHeight,
158
+ });
129
159
  props.onLayout(event);
130
160
  const child = firstChild(children);
131
161
  const childOnLayout = child === undefined ? undefined : readChildOnLayout(child);
@@ -133,12 +163,17 @@ export const ScrollViewStickyHeader = props => {
133
163
  };
134
164
  // The EXPLICIT debounced translateY overrides the committed transform for hit-testing, while
135
165
  // `animatedTranslateY` does the smooth (native-driven) pin, per RN ScrollViewStickyHeader.js.
136
- const passthroughAnimatedPropExplicitValues = state.translateY !== null ? { style: { transform: [{ translateY: state.translateY }] } } : null;
166
+ const passthroughAnimatedPropExplicitValues = state.translateY !== null
167
+ ? { style: { transform: [{ translateY: state.translateY }] } }
168
+ : null;
137
169
  // collapsable:false keeps the wrapper a real Yoga node; zIndex makes the pinned header paint
138
170
  // OVER the rows scrolling under it. `style` is `unknown` on Animated.View, so the interpolation
139
171
  // transform passes with no cast.
140
172
  return createElement(Animated.View, {
141
- style: { transform: [{ translateY: animatedTranslateY }], zIndex: STICKY_HEADER_Z_INDEX },
173
+ style: {
174
+ transform: [{ translateY: animatedTranslateY }],
175
+ zIndex: STICKY_HEADER_Z_INDEX,
176
+ },
142
177
  onLayout,
143
178
  collapsable: false,
144
179
  passthroughAnimatedPropExplicitValues,
@@ -155,7 +190,8 @@ ScrollViewStickyHeader.displayName = 'ScrollViewStickyHeader';
155
190
  // header (the collision point past which it scrolls off) by looking up its successor's index in
156
191
  // `stickyHeaderIndices`. The LAST flagged header has no successor, so its `nextHeaderLayoutY`
157
192
  // stays undefined and it sticks indefinitely (correct).
158
- export function wrapStickyHeaders(children, stickyHeaderIndices, scrollAnimatedValue, invertStickyHeaders, scrollViewHeight, StickyHeaderComponent, headerLayoutYs, onHeaderLayoutY) {
193
+ export function wrapStickyHeaders(children, stickyHeaderIndices, scrollAnimatedValue, invertStickyHeaders, scrollViewHeight, StickyHeaderComponent, crossTalk) {
194
+ const { headerLayoutYs, onHeaderLayoutY, onHeaderRef } = crossTalk;
159
195
  if (stickyHeaderIndices === undefined || stickyHeaderIndices.length === 0)
160
196
  return children;
161
197
  const Wrapper = StickyHeaderComponent ?? ScrollViewStickyHeader;
@@ -170,6 +206,10 @@ export function wrapStickyHeaders(children, stickyHeaderIndices, scrollAnimatedV
170
206
  dlog(`ScrollView sticky-header wrap index=${index} next=${nextIndex} nextY=${nextHeaderLayoutY}`);
171
207
  return createElement(Wrapper, {
172
208
  key: child.key ?? `sticky-${index}`,
209
+ // Registers this header so the ScrollView can push the NEXT header's y straight in
210
+ // (RN ScrollView.js:1109). Without it a measurement would have to travel through parent
211
+ // state, re-rendering every child.
212
+ ref: (handle) => onHeaderRef(index, handle),
173
213
  nextHeaderLayoutY,
174
214
  // RN _onStickyHeaderLayout: record this header's own y, then push it to the previous
175
215
  // header as its nextHeaderLayoutY. We record into the parent map; the lookup above feeds
@@ -1,10 +1,8 @@
1
- // SectionList: the public, high-level list-of-sections component. It is a thin
2
- // wrapper over VirtualizedSectionList, mirroring RN's layering
3
- // (SectionList -> VirtualizedSectionList -> VirtualizedList). All the
1
+ // SectionList: public wrapper over VirtualizedSectionList, mirroring RN's
2
+ // SectionList -> VirtualizedSectionList -> VirtualizedList layering. All
4
3
  // section-flattening / windowing / imperative-scroll logic lives in
5
- // VirtualizedSectionList; this layer only re-exposes the same surface under the
6
- // SectionList name so existing consumers and the canary keep importing it from
7
- // here, threading the imperative ref straight through.
4
+ // VirtualizedSectionList; this re-exposes the same surface under the
5
+ // SectionList name, threading the imperative ref straight through.
8
6
  import { createElement } from 'react';
9
7
  import { VirtualizedSectionList, } from '../virtualized-section-list/index.js';
10
8
  export function SectionList(props) {
@@ -10,7 +10,7 @@
10
10
  // (setTextAndSelection), never a plain prop re-push. See @symbiote-native/components/state/text-input.
11
11
  import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState, } from 'react';
12
12
  import { dispatchViewCommand, dlog, } from '@symbiote-native/engine';
13
- import { blurTextInput, setInputBlurred, setInputFocused } from '../../modules/text-input-state.js';
13
+ import { blurTextInput, setInputBlurred, setInputFocused, } from '@symbiote-native/engine';
14
14
  import { resolveAccessibilityProps, resolveTextInputProps, renderTextInput, foldText, textFromChange, eventCountFromChange, shouldCommandText, INITIAL_EVENT_COUNT, SELECTION_NONE, } from '@symbiote-native/components';
15
15
  import { descriptorToReact } from '../../descriptor-to-react/index.js';
16
16
  export const TextInput = forwardRef((rawProps, forwardedRef) => {
@@ -132,7 +132,12 @@ export const TextInput = forwardRef((rawProps, forwardedRef) => {
132
132
  const node = ref.current;
133
133
  if (node === null)
134
134
  return;
135
- dispatchViewCommand(node, 'setTextAndSelection', [mostRecentEventCount, '', 0, 0]);
135
+ dispatchViewCommand(node, 'setTextAndSelection', [
136
+ mostRecentEventCount,
137
+ '',
138
+ 0,
139
+ 0,
140
+ ]);
136
141
  lastNativeText.current = '';
137
142
  },
138
143
  isFocused: () => focused.current,
@@ -13,6 +13,8 @@ export declare const TouchableOpacity: FC<ITouchableOpacityProps>;
13
13
  export interface ITouchableHighlightProps extends ITouchableBaseProps {
14
14
  activeOpacity?: number;
15
15
  underlayColor?: string;
16
+ onShowUnderlay?: () => void;
17
+ onHideUnderlay?: () => void;
16
18
  }
17
19
  export declare const TouchableHighlight: FC<ITouchableHighlightProps>;
18
20
  export type ITouchableWithoutFeedbackProps = ITouchableBaseProps;
@@ -1,18 +1,20 @@
1
- // The Touchable* family, all built on Pressable. RN realizes their feedback with Animated, and so
2
- // do we. The press-timing constants and the deactivation-floor math are shared with every adapter
3
- // (@symbiote-native/components/state/touchable); here React owns only the Animated wiring + the press
4
- // scheduling refs:
5
- // TouchableOpacity: animate an Animated.Value opacity toward activeOpacity on press-in and
6
- // back to 1 on press-out, driven imperatively from onPressIn/onPressOut.
7
- // TouchableHighlight: paint underlayColor + lower child opacity while pressed, via Pressable's
8
- // pressed-state style (RN drives this with setState, not Animated, faithfully).
9
- // TouchableWithoutFeedback: no visual change, just the press wiring.
10
- import { createElement, useRef } from 'react';
11
- import { createTouchableFeedbackHandlers, createTouchableFeedbackRuntime, highlightPressedStyle, DEFAULT_ACTIVE_OPACITY, DEFAULT_HIGHLIGHT_CHILD_OPACITY, DEFAULT_MIN_PRESS_DURATION_MS, DEFAULT_UNDERLAY_COLOR, OPACITY_ACTIVE_DURATION_MS, OPACITY_INACTIVE_DURATION_MS, RESTING_OPACITY, } from '@symbiote-native/components';
1
+ // The Touchable* family, all built on Pressable. Ported against RN's own sources
2
+ // (.vendors/react-native/.../Components/Touchable/*.js) after the 2026-08-19 audit found ten
3
+ // divergences; the shared halves live in @symbiote-native/components and React owns only the
4
+ // lifecycle — refs, effects, the real timers, the Animated wiring, and the child clone:
5
+ // TouchableOpacity: an Animated.Value fades toward activeOpacity on press-in and back to the
6
+ // style's own resting opacity on press-out, driven from onPressIn/onPressOut.
7
+ // TouchableHighlight: the underlay machine drives a `shown` flag (NOT `pressed` — RN holds the
8
+ // underlay for delayPressOut past the tap); the container takes the underlay color and the
9
+ // child takes the lowered opacity, RN's split.
10
+ // TouchableWithoutFeedback: the same press-timing machine with the visual half left empty —
11
+ // "without feedback" means no VISUAL, not no timing.
12
+ import { Children, cloneElement, createElement, isValidElement, useEffect, useRef, useState, } from 'react';
13
+ import { createHighlightUnderlayHandlers, createHighlightUnderlayRuntime, createTouchableFeedbackHandlers, createTouchableFeedbackRuntime, hasTouchablePressHandler, resolveHighlightExtraStyles, restingOpacityFromStyle, DEFAULT_ACTIVE_OPACITY, OPACITY_ACTIVE_GRANT_DURATION_MS, OPACITY_INACTIVE_DURATION_MS, TOUCHABLE_MIN_PRESS_DURATION_MS, } from '@symbiote-native/components';
12
14
  import { Pressable } from '../pressable/index.js';
13
15
  import { Animated } from '../../modules/animated/index.js';
14
- // The real setTimeout the shared feedback machine schedules its deferred activation/deactivation on
15
- // (core/components has no timer globals). Returns a canceller so an early release flushes the timer.
16
+ // The real setTimeout the shared machines schedule on (core/components has no timer globals).
17
+ // Returns a canceller so an early release flushes the timer.
16
18
  function scheduleTimeout(callback, ms) {
17
19
  const id = setTimeout(callback, ms);
18
20
  return () => clearTimeout(id);
@@ -22,42 +24,142 @@ export const TouchableOpacity = props => {
22
24
  // className is pulled out here, like style, and applied to the inner Animated.View below —
23
25
  // left in ...rest it would land on the outer Pressable instead, which is not where `style`
24
26
  // (the opacity-fade node) goes.
25
- className, children, onPressIn, onPressOut, delayPressIn = 0, delayPressOut = 0, minPressDuration = DEFAULT_MIN_PRESS_DURATION_MS, ...rest } = props;
26
- // One Animated.Value per mount, resting at full opacity. The Animated.View leaf commits its
27
- // current value every frame, so timing it animates the real view.
28
- const opacity = useRef(new Animated.Value(RESTING_OPACITY)).current;
27
+ className, children, disabled, onPressIn, onPressOut, delayPressIn = 0, delayPressOut = 0,
28
+ // RN's Touchables override Pressability's 130ms floor with 0 (TouchableOpacity.js:195); the
29
+ // active visual is held by the fade's own duration instead.
30
+ minPressDuration = TOUCHABLE_MIN_PRESS_DURATION_MS, ...rest } = props;
31
+ // RN's _getChildStyleOpacityWithDefault: the fade settles at the opacity the CALLER's style
32
+ // asks for, not at a hard 1 — and must START there, or the first paint jumps to fully opaque.
33
+ const restingOpacity = restingOpacityFromStyle(style);
34
+ // One Animated.Value per mount. The Animated.View leaf commits its current value every frame,
35
+ // so timing it animates the real view.
36
+ const opacity = useRef(new Animated.Value(restingOpacity)).current;
29
37
  // The shared press-scheduling cell (delayPressIn timer + activation clock), held across renders.
30
38
  const runtime = useRef(createTouchableFeedbackRuntime()).current;
31
39
  function setOpacityTo(toValue, duration) {
40
+ // useNativeDriver: true is RN's own (TouchableOpacity.js:242). opacity is natively drivable
41
+ // and the fade then survives a busy JS thread, which is the whole point of the feedback.
32
42
  Animated.timing(opacity, {
33
43
  toValue,
34
44
  duration,
35
45
  easing: Animated.Easing.inOut(Animated.Easing.quad),
36
- useNativeDriver: false,
46
+ useNativeDriver: true,
37
47
  }).start();
38
48
  }
49
+ // RN's componentDidUpdate: a changed `disabled` or a changed style opacity re-settles the view,
50
+ // so a Touchable disabled mid-press does not stay stuck at its active opacity.
51
+ const hasRendered = useRef(false);
52
+ useEffect(() => {
53
+ // RN runs this on UPDATE only. Firing at mount would animate over the value the
54
+ // Animated.Value was just constructed with.
55
+ if (!hasRendered.current) {
56
+ hasRendered.current = true;
57
+ return;
58
+ }
59
+ setOpacityTo(restingOpacity, OPACITY_INACTIVE_DURATION_MS);
60
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- setOpacityTo is re-made per render
61
+ }, [disabled, restingOpacity]);
62
+ // RN's componentWillUnmount: stop the fade so a teardown mid-animation leaves no driver running
63
+ // against a node that is gone.
64
+ useEffect(() => () => {
65
+ opacity.resetAnimation();
66
+ }, [opacity]);
39
67
  // The shared machine owns the delayPressIn/minPressDuration scheduling; the adapter supplies only
40
68
  // the native seam — the Animated opacity fade + the framework emit — as activate/deactivate.
41
- const { handlePressIn, handlePressOut } = createTouchableFeedbackHandlers({ delayPressIn, delayPressOut, minPressDuration, schedule: scheduleTimeout, now: Date.now }, runtime, {
69
+ const { handlePressIn, handlePressOut } = createTouchableFeedbackHandlers({
70
+ delayPressIn,
71
+ delayPressOut,
72
+ minPressDuration,
73
+ schedule: scheduleTimeout,
74
+ now: Date.now,
75
+ }, runtime, {
42
76
  activate(event) {
43
- setOpacityTo(activeOpacity, OPACITY_ACTIVE_DURATION_MS);
77
+ setOpacityTo(activeOpacity, OPACITY_ACTIVE_GRANT_DURATION_MS);
44
78
  onPressIn?.(event);
45
79
  },
46
80
  deactivate(event) {
47
- setOpacityTo(RESTING_OPACITY, OPACITY_INACTIVE_DURATION_MS);
81
+ setOpacityTo(restingOpacity, OPACITY_INACTIVE_DURATION_MS);
48
82
  onPressOut?.(event);
49
83
  },
50
84
  });
51
- return createElement(Pressable, { ...rest, onPressIn: handlePressIn, onPressOut: handlePressOut }, createElement(Animated.View, { style: [style, { opacity }], className }, children));
85
+ return createElement(Pressable, { ...rest, disabled, onPressIn: handlePressIn, onPressOut: handlePressOut }, createElement(Animated.View, { style: [style, { opacity }], className }, children));
52
86
  };
87
+ // RN's render: the lowered opacity is cloned onto the CHILD while the container keeps an opaque
88
+ // underlay (TouchableHighlight.js). Fold both onto the container instead and the opacity fades the
89
+ // very underlay it is meant to reveal — `underlayColor: 'black'` paints grey. React is the one
90
+ // adapter that can reach the child without a wrapper view perturbing the flex chain.
91
+ // A non-element child (text, a fragment, nothing at all) has no style prop to compose onto, so it
92
+ // passes through untouched rather than throwing the way RN's Children.only would.
93
+ function withChildStyle(children, extra) {
94
+ const child = Children.count(children) === 1 ? children : undefined;
95
+ if (!isValidElement(child))
96
+ return children;
97
+ return cloneElement(child, { style: [child.props.style, extra] });
98
+ }
53
99
  export const TouchableHighlight = props => {
54
- const { activeOpacity = DEFAULT_HIGHLIGHT_CHILD_OPACITY, underlayColor = DEFAULT_UNDERLAY_COLOR, style, children, ...rest } = props;
55
- function pressedStyle({ pressed }) {
56
- return highlightPressedStyle(pressed, style, underlayColor, activeOpacity);
100
+ const { activeOpacity, underlayColor, style, children, onPress, onPressIn, onPressOut, onShowUnderlay, onHideUnderlay, delayPressOut = 0, ...rest } = props;
101
+ // `shown` is NOT `pressed`: RN holds the underlay for delayPressOut past the tap so a press too
102
+ // fast to see still flashes, and the pressed flag is already false by then.
103
+ const [shown, setShown] = useState(false);
104
+ const runtime = useRef(createHighlightUnderlayRuntime()).current;
105
+ // RN's _hasPressHandler gate: no press handler, no underlay. onLongPress is only READ here —
106
+ // it stays in `rest` for Pressable to fire directly.
107
+ const hasPressHandler = hasTouchablePressHandler({
108
+ onPress,
109
+ onPressIn,
110
+ onPressOut,
111
+ onLongPress: rest.onLongPress,
112
+ });
113
+ const underlay = createHighlightUnderlayHandlers({ delayPressOut, hasPressHandler, schedule: scheduleTimeout }, runtime, { setShown, onShowUnderlay, onHideUnderlay });
114
+ const extraStyles = resolveHighlightExtraStyles({
115
+ shown,
116
+ hasPressHandler,
117
+ underlayColor,
118
+ activeOpacity,
119
+ });
120
+ // Visual first, then the caller's callback — RN's order in _createPressabilityConfig.
121
+ function handlePressIn(event) {
122
+ underlay.handlePressIn(event);
123
+ onPressIn?.(event);
124
+ }
125
+ function handlePress(event) {
126
+ underlay.handlePress(event);
127
+ onPress?.(event);
57
128
  }
58
- return createElement(Pressable, { ...rest, style: pressedStyle }, children);
129
+ function handlePressOut(event) {
130
+ underlay.handlePressOut(event);
131
+ onPressOut?.(event);
132
+ }
133
+ return createElement(Pressable, {
134
+ ...rest,
135
+ style: extraStyles === undefined ? style : [style, extraStyles.underlay],
136
+ onPress: handlePress,
137
+ onPressIn: handlePressIn,
138
+ onPressOut: handlePressOut,
139
+ }, extraStyles === undefined
140
+ ? children
141
+ : withChildStyle(children, extraStyles.child));
59
142
  };
60
143
  export const TouchableWithoutFeedback = props => {
61
- const { children, ...rest } = props;
62
- return createElement(Pressable, rest, children);
144
+ const { children, onPressIn, onPressOut, delayPressIn = 0, delayPressOut = 0, minPressDuration = TOUCHABLE_MIN_PRESS_DURATION_MS, ...rest } = props;
145
+ // RN's TouchableWithoutFeedback builds a FULL Pressability config with delayPressIn /
146
+ // delayPressOut / minPressDuration: 0 — the same shared machine TouchableOpacity uses, with the
147
+ // visual half left empty. Spreading the delay props onto Pressable instead did nothing: it does
148
+ // not read them, and they leaked to the host as unknown props.
149
+ const runtime = useRef(createTouchableFeedbackRuntime()).current;
150
+ const { handlePressIn, handlePressOut } = createTouchableFeedbackHandlers({
151
+ delayPressIn,
152
+ delayPressOut,
153
+ minPressDuration,
154
+ schedule: scheduleTimeout,
155
+ now: Date.now,
156
+ }, runtime, {
157
+ activate(event) {
158
+ onPressIn?.(event);
159
+ },
160
+ deactivate(event) {
161
+ onPressOut?.(event);
162
+ },
163
+ });
164
+ return createElement(Pressable, { ...rest, onPressIn: handlePressIn, onPressOut: handlePressOut }, children);
63
165
  };
@@ -18,9 +18,9 @@
18
18
  // scrollTo command via its handle ref, animated by default. The `contentOffset` prop is only a
19
19
  // fallback for the pre-mount window (handle not yet attached).
20
20
  import { createElement, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useReducer, useRef, useState, } from 'react';
21
- import { dlog } from '@symbiote-native/engine';
22
- import { DEFAULT_END_REACHED_THRESHOLD, DEFAULT_INITIAL_NUM_TO_RENDER, DEFAULT_MAX_TO_RENDER_PER_BATCH, DEFAULT_START_REACHED_THRESHOLD, DEFAULT_UPDATE_CELLS_BATCHING_PERIOD, DEFAULT_WINDOW_SIZE, EMPTY_OFFSET, FIRST_INDEX, INVERTED_X_STYLE, INVERTED_Y_STYLE, buildListPlan, buildViewabilityPairs, createInitialListState, isSeparatorGapInRange, listEffectSignature, readLayoutLength, readScrollOffset, reduceList, resolveItemKey, } from '@symbiote-native/components';
23
- import { ScrollView } from '../scroll-view';
21
+ import { dlog, } from '@symbiote-native/engine';
22
+ import { DEFAULT_END_REACHED_THRESHOLD, DEFAULT_INITIAL_NUM_TO_RENDER, DEFAULT_MAX_TO_RENDER_PER_BATCH, DEFAULT_START_REACHED_THRESHOLD, DEFAULT_UPDATE_CELLS_BATCHING_PERIOD, DEFAULT_WINDOW_SIZE, EMPTY_OFFSET, FIRST_INDEX, INVERTED_X_STYLE, INVERTED_Y_STYLE, buildListPlan, buildViewabilityPairs, createInitialListState, isSeparatorGapInRange, listEffectSignature, readLayoutLength, readLayoutOffset, readScrollOffset, reduceList, resolveItemKey, } from '@symbiote-native/components';
23
+ import { ScrollView, } from '../scroll-view';
24
24
  import { RefreshControl } from '../refresh-control/index.js';
25
25
  function resolveElement(component) {
26
26
  if (component === undefined)
@@ -88,7 +88,12 @@ export function VirtualizedList(props) {
88
88
  onScrollToIndexFailed,
89
89
  viewabilityPairs,
90
90
  });
91
- handlersRef.current = { onEndReached, onStartReached, onScrollToIndexFailed, viewabilityPairs };
91
+ handlersRef.current = {
92
+ onEndReached,
93
+ onStartReached,
94
+ onScrollToIndexFailed,
95
+ viewabilityPairs,
96
+ };
92
97
  // The one folded state cell (RN's scattered refs collapsed into IListState). Lazily created once.
93
98
  const stateRef = useRef(null);
94
99
  const state = (stateRef.current ??= createInitialListState());
@@ -109,7 +114,9 @@ export function VirtualizedList(props) {
109
114
  const scrollToPixel = useCallback((offset, animated) => {
110
115
  const clamped = Math.max(EMPTY_OFFSET, offset);
111
116
  const isHorizontal = inputsRef.current.horizontal;
112
- const target = isHorizontal ? { x: clamped, y: EMPTY_OFFSET } : { x: EMPTY_OFFSET, y: clamped };
117
+ const target = isHorizontal
118
+ ? { x: clamped, y: EMPTY_OFFSET }
119
+ : { x: EMPTY_OFFSET, y: clamped };
113
120
  if (scrollViewRef.current !== null) {
114
121
  dlog(`VirtualizedList scrollTo offset=${clamped} animated=${animated} (horizontal=${isHorizontal})`);
115
122
  scrollViewRef.current.scrollTo({ x: target.x, y: target.y, animated });
@@ -129,10 +136,14 @@ export function VirtualizedList(props) {
129
136
  scrollToPixel(effect.offset, effect.animated);
130
137
  break;
131
138
  case 'fire-end-reached':
132
- handlers.onEndReached?.({ distanceFromEnd: effect.distanceFromEnd });
139
+ handlers.onEndReached?.({
140
+ distanceFromEnd: effect.distanceFromEnd,
141
+ });
133
142
  break;
134
143
  case 'fire-start-reached':
135
- handlers.onStartReached?.({ distanceFromStart: effect.distanceFromStart });
144
+ handlers.onStartReached?.({
145
+ distanceFromStart: effect.distanceFromStart,
146
+ });
136
147
  break;
137
148
  case 'fire-scroll-to-index-failed':
138
149
  handlers.onScrollToIndexFailed?.({
@@ -217,8 +228,9 @@ export function VirtualizedList(props) {
217
228
  const length = readLayoutLength(event, horizontal);
218
229
  if (length === undefined)
219
230
  return;
220
- dlog(`VirtualizedList cell ${index} measured length=${length}`);
221
- dispatch({ kind: 'measure', index, length });
231
+ const offset = readLayoutOffset(event, horizontal);
232
+ dlog(`VirtualizedList cell ${index} measured length=${length} offset=${offset ?? 'none'}`);
233
+ dispatch({ kind: 'measure', index, length, offset });
222
234
  }, [horizontal, dispatch]);
223
235
  // Merge an override onto the separator at a given gap and request a re-render. A gap index
224
236
  // outside [0, count-2] has no separator, so the write is a no-op (RN bails the same way).
@@ -313,7 +325,9 @@ export function VirtualizedList(props) {
313
325
  // extraData) already re-renders and re-runs renderItem. Voided to mark the deliberate no-op.
314
326
  void extraData;
315
327
  const children = [];
316
- const stickySet = stickyHeaderIndices !== undefined ? new Set(stickyHeaderIndices) : undefined;
328
+ const stickySet = stickyHeaderIndices !== undefined
329
+ ? new Set(stickyHeaderIndices)
330
+ : undefined;
317
331
  let renderedStickyIndices = [];
318
332
  const header = resolveElement(ListHeaderComponent);
319
333
  if (header !== undefined) {
@@ -337,13 +351,45 @@ export function VirtualizedList(props) {
337
351
  keyFor: keyForIndex,
338
352
  stickyIndices: stickySet,
339
353
  hasHeader: header !== undefined,
340
- hasSeparators: ItemSeparatorComponent !== undefined,
341
354
  });
342
355
  renderedStickyIndices = plan.stickyChildPositions;
343
356
  if (plan.leadingExtent > EMPTY_OFFSET) {
344
357
  children.push(createElement('symbiote-view', {
345
358
  key: 'spacer-leading',
346
- style: horizontal ? { width: plan.leadingExtent } : { height: plan.leadingExtent },
359
+ style: horizontal
360
+ ? { width: plan.leadingExtent }
361
+ : { height: plan.leadingExtent },
362
+ }));
363
+ }
364
+ // The force-mounted sticky cell (RN _ensureClosestStickyHeader): the nearest sticky index
365
+ // below the window, kept alive outside [first,last] so it never gets destroyed/recreated
366
+ // as the window slides back over its origin index. Rendered exactly like an in-window
367
+ // cell — same measuring wrapper, same key format (so a transition between forced and
368
+ // in-window rendering of the SAME index keeps the same React key and never remounts).
369
+ if (plan.forcedStickyCell !== undefined) {
370
+ const forcedIndex = plan.forcedStickyCell.index;
371
+ const forcedItem = getItem(data, forcedIndex);
372
+ const forcedCell = renderItem({
373
+ item: forcedItem,
374
+ index: forcedIndex,
375
+ separators: makeSeparators(forcedIndex),
376
+ });
377
+ children.push(createElement('symbiote-view', {
378
+ key: `cell-${plan.forcedStickyCell.key}`,
379
+ onLayout: makeCellMeasure(forcedIndex),
380
+ style: inverted
381
+ ? horizontal
382
+ ? INVERTED_X_STYLE
383
+ : INVERTED_Y_STYLE
384
+ : undefined,
385
+ }, forcedCell));
386
+ }
387
+ if (plan.gapExtent > EMPTY_OFFSET) {
388
+ children.push(createElement('symbiote-view', {
389
+ key: 'spacer-gap',
390
+ style: horizontal
391
+ ? { width: plan.gapExtent }
392
+ : { height: plan.gapExtent },
347
393
  }));
348
394
  }
349
395
  for (const planCell of plan.cells) {
@@ -354,24 +400,42 @@ export function VirtualizedList(props) {
354
400
  index: planCell.index,
355
401
  separators: makeSeparators(planCell.index),
356
402
  });
403
+ // RN renders the separator INSIDE the cell's own measuring wrapper
404
+ // (VirtualizedListCellRenderer.js:218-221), and that placement is load-bearing rather than
405
+ // cosmetic. As a SIBLING it is an extra flex child, so the chrome between two cells becomes
406
+ // gap + separator + gap while a spacer collapsing that region replaces it with one gap — the
407
+ // leading spacer then lands every cell below it short by (separator + gap), and the content
408
+ // visibly jumps by that amount each time the window's first index moves. Measured at exactly
409
+ // 17px on device 2026-08-19 (a 1px divider under a 16px container gap); see
410
+ // .claude/rules/list-geometry-feedback-loop.md. Inside the wrapper it is part of the cell's
411
+ // measured length instead, and every cell stays exactly one child.
412
+ const separator =
413
+ // RN gates the separator on the last index of the DATA, not of the WINDOW
414
+ // (VirtualizedList.js:793 `const end = getItemCount(data) - 1`), and now that the separator
415
+ // lives INSIDE the measuring wrapper that distinction is load-bearing: gating on the window
416
+ // would make a cell's own measured height change as the window slides past it. Device-measured
417
+ // 2026-08-19 as a run of cells all shifting by exactly the divider's 1px.
418
+ planCell.index < count - 1
419
+ ? renderSeparatorElement(ItemSeparatorComponent, item, getItem(data, planCell.index + 1), separatorOverridesRef.current.get(planCell.index))
420
+ : undefined;
357
421
  // Wrap each cell in a measuring View. When inverted, each cell carries the counter-flip so
358
422
  // its content reads upright inside the flipped content container.
359
423
  children.push(createElement('symbiote-view', {
360
424
  key: `cell-${planCell.key}`,
361
425
  onLayout: makeCellMeasure(planCell.index),
362
- style: inverted ? (horizontal ? INVERTED_X_STYLE : INVERTED_Y_STYLE) : undefined,
363
- }, cell));
364
- const separator = planCell.index < last
365
- ? renderSeparatorElement(ItemSeparatorComponent, item, getItem(data, planCell.index + 1), separatorOverridesRef.current.get(planCell.index))
366
- : undefined;
367
- if (separator !== undefined) {
368
- children.push(createElement('symbiote-view', { key: `sep-${planCell.key}` }, separator));
369
- }
426
+ style: inverted
427
+ ? horizontal
428
+ ? INVERTED_X_STYLE
429
+ : INVERTED_Y_STYLE
430
+ : undefined,
431
+ }, cell, separator));
370
432
  }
371
433
  if (plan.trailingExtent > EMPTY_OFFSET) {
372
434
  children.push(createElement('symbiote-view', {
373
435
  key: 'spacer-trailing',
374
- style: horizontal ? { width: plan.trailingExtent } : { height: plan.trailingExtent },
436
+ style: horizontal
437
+ ? { width: plan.trailingExtent }
438
+ : { height: plan.trailingExtent },
375
439
  }));
376
440
  }
377
441
  }
@@ -423,7 +487,8 @@ export function VirtualizedList(props) {
423
487
  if (maintainVisibleContentPosition !== undefined) {
424
488
  scrollProps.maintainVisibleContentPosition = {
425
489
  ...maintainVisibleContentPosition,
426
- minIndexForVisible: maintainVisibleContentPosition.minIndexForVisible + (header !== undefined ? 1 : 0),
490
+ minIndexForVisible: maintainVisibleContentPosition.minIndexForVisible +
491
+ (header !== undefined ? 1 : 0),
427
492
  };
428
493
  }
429
494
  // Pull-to-refresh: when onRefresh is set, build a RefreshControl for the ScrollView's
@@ -22,6 +22,11 @@ export interface IVirtualizedSectionListProps<ItemT> extends IAccessibilityProps
22
22
  }) => ReactNode;
23
23
  SectionSeparatorComponent?: ComponentType<Record<string, never>> | ReactElement;
24
24
  keyExtractor?: (item: ItemT, index: number) => string;
25
+ getItemLayout?: (data: ReadonlyArray<ISection<ItemT>> | null, index: number) => {
26
+ length: number;
27
+ offset: number;
28
+ index: number;
29
+ };
25
30
  stickySectionHeadersEnabled?: boolean;
26
31
  extraData?: unknown;
27
32
  ItemSeparatorComponent?: ComponentType<ISeparatorProps<ItemT>>;