@symbiote-native/react 0.2.7 → 0.3.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 (68) hide show
  1. package/build/bootstrap.js +4 -5
  2. package/build/components/activity-indicator/index.android.d.ts +2 -1
  3. package/build/components/activity-indicator/index.android.js +14 -10
  4. package/build/components/activity-indicator/index.ios.d.ts +2 -1
  5. package/build/components/activity-indicator/index.ios.js +9 -3
  6. package/build/components/activity-indicator/shared.d.ts +1 -2
  7. package/build/components/activity-indicator/shared.js +13 -11
  8. package/build/components/keyboard-avoiding-view/index.js +1 -1
  9. package/build/components/modal/index.d.ts +3 -2
  10. package/build/components/scroll-view/index.android.js +0 -2
  11. package/build/components/scroll-view/index.ios.js +10 -10
  12. package/build/components/scroll-view/shared.js +28 -32
  13. package/build/components/scroll-view/sticky-header.js +98 -74
  14. package/build/components/section-list/index.js +4 -6
  15. package/build/components/switch/index.android.d.ts +2 -1
  16. package/build/components/switch/index.android.js +9 -3
  17. package/build/components/switch/index.ios.d.ts +2 -1
  18. package/build/components/switch/index.ios.js +9 -3
  19. package/build/components/switch/shared.d.ts +2 -3
  20. package/build/components/switch/shared.js +44 -43
  21. package/build/components/text-input/index.js +1 -1
  22. package/build/components/touchable/index.js +22 -54
  23. package/build/components/virtualized-list/index.js +219 -305
  24. package/build/components/virtualized-section-list/index.js +2 -4
  25. package/build/create-tunnel/index.js +14 -20
  26. package/build/hooks/use-color-scheme.d.ts +1 -1
  27. package/build/hooks/use-color-scheme.js +1 -1
  28. package/build/hooks/use-window-dimensions.d.ts +1 -1
  29. package/build/hooks/use-window-dimensions.js +1 -1
  30. package/build/host-instance.js +5 -6
  31. package/build/index.d.ts +6 -31
  32. package/build/index.js +15 -25
  33. package/build/modules/animated/create-animated-component.js +24 -65
  34. package/build/render.js +28 -2
  35. package/metro-css-parser.cjs +7 -12
  36. package/package.json +4 -4
  37. package/build/modules/action-sheet-ios.d.ts +0 -2
  38. package/build/modules/action-sheet-ios.js +0 -4
  39. package/build/modules/alert.d.ts +0 -2
  40. package/build/modules/alert.js +0 -5
  41. package/build/modules/app-state.d.ts +0 -2
  42. package/build/modules/app-state.js +0 -3
  43. package/build/modules/appearance.d.ts +0 -2
  44. package/build/modules/appearance.js +0 -3
  45. package/build/modules/back-handler.d.ts +0 -2
  46. package/build/modules/back-handler.js +0 -3
  47. package/build/modules/dimensions.d.ts +0 -2
  48. package/build/modules/dimensions.js +0 -3
  49. package/build/modules/i18n-manager.d.ts +0 -2
  50. package/build/modules/i18n-manager.js +0 -4
  51. package/build/modules/keyboard.d.ts +0 -2
  52. package/build/modules/keyboard.js +0 -3
  53. package/build/modules/layout-animation.d.ts +0 -2
  54. package/build/modules/layout-animation.js +0 -3
  55. package/build/modules/linking.d.ts +0 -2
  56. package/build/modules/linking.js +0 -4
  57. package/build/modules/permissions-android.d.ts +0 -2
  58. package/build/modules/permissions-android.js +0 -3
  59. package/build/modules/settings.d.ts +0 -1
  60. package/build/modules/settings.js +0 -4
  61. package/build/modules/share.d.ts +0 -2
  62. package/build/modules/share.js +0 -4
  63. package/build/modules/text-input-state.d.ts +0 -1
  64. package/build/modules/text-input-state.js +0 -3
  65. package/build/modules/toast-android.d.ts +0 -1
  66. package/build/modules/toast-android.js +0 -4
  67. package/build/modules/vibration.d.ts +0 -1
  68. package/build/modules/vibration.js +0 -4
@@ -4,21 +4,22 @@
4
4
  // Views whose sizes sum to the off-screen extent, so the scroll thumb and total
5
5
  // content size stay correct without mounting all N rows.
6
6
  //
7
- // The windowing engine (offset table, window compute, batch throttle, viewability,
8
- // edge-reached, the child PLAN, the imperative-handle surface) lives in
9
- // @symbiote-native/components/state, shared verbatim with the Vue adapter — a windowing or
10
- // viewability bug is fixed once for all adapters (<adapters_reach_full_feature_parity>).
11
- // React supplies only its lifecycle (state/refs/effects), the imperative-handle wiring,
12
- // and the per-cell element creation (createElement). Lists have no Descriptor render fn
13
- // (the cell content is React's own children).
7
+ // The orchestration — window recompute, edge-reached, viewability, batch fill, MVCP, the
8
+ // imperative scrolls — is the framework-agnostic `reduceList` state machine in
9
+ // @symbiote-native/components (state/virtualized-list-reducer), shared verbatim with Vue and Angular.
10
+ // React supplies ONLY its lifecycle: it turns native events into ACTIONS, holds ONE state cell,
11
+ // runs the returned EFFECTS with React primitives (a native scrollTo, a callback, a setTimeout, a
12
+ // forced re-render), and builds the per-cell elements (createElement). The single derive-per-render
13
+ // invariant is a `refresh-metrics` dispatched from the render body; the after-commit effects come
14
+ // from a `commit` dispatched in a layout effect (before paint, so MVCP's shift lands without a
15
+ // visible jump). Lists have no Descriptor render fn — the cell content is React's own children.
14
16
  //
15
- // Imperative scrolling (scrollToIndex / scrollToOffset / scrollToItem / scrollToEnd)
16
- // resolves to an offset and rides the ScrollView's native scrollTo command via its handle
17
- // ref, animated by default. The `contentOffset` prop is only a fallback for the pre-mount
18
- // window (handle not yet attached).
19
- import { createElement, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from 'react';
17
+ // Imperative scrolling resolves to an offset in the reducer and rides the ScrollView's native
18
+ // scrollTo command via its handle ref, animated by default. The `contentOffset` prop is only a
19
+ // fallback for the pre-mount window (handle not yet attached).
20
+ import { createElement, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useReducer, useRef, useState, } from 'react';
20
21
  import { dlog } from '@symbiote-native/engine';
21
- 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, NO_CONTENT_LENGTH_SENT, averageMeasuredLength, buildListPlan, buildOffsets, buildViewabilityPairs, computeEndReached, computeStartReached, computeViewableSet, computeWindow, diffViewable, highestMeasuredIndex as findHighestMeasuredIndex, maxMinimumViewTime, offsetForIndex as resolveOffsetForIndex, readLayoutLength, readScrollOffset, throttleWindow, } from '@symbiote-native/components';
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';
22
23
  import { ScrollView } from '../scroll-view';
23
24
  import { RefreshControl } from '../refresh-control/index.js';
24
25
  function resolveElement(component) {
@@ -55,215 +56,174 @@ export function VirtualizedList(props) {
55
56
  // resolveAccessibilityProps itself; ref is pulled separately above, re-listed here only
56
57
  // to keep it out of the forwarded rest.
57
58
  ref: _ref, ...accessibilityRest } = props;
58
- const count = getItemCount(data);
59
- const [scrollOffset, setScrollOffset] = useState(EMPTY_OFFSET);
60
- const [viewportLength, setViewportLength] = useState(EMPTY_OFFSET);
61
- // The offset we are imperatively driving native to (scrollTo*). Pushed down as the
62
- // ScrollView's contentOffset prop; fresh object identity each time. undefined = none pending.
59
+ // The single-config and pairs forms both feed one viewability pass (RN supports either).
60
+ const viewabilityPairs = useMemo(() => buildViewabilityPairs(onViewableItemsChanged, viewabilityConfig, viewabilityConfigCallbackPairs), [onViewableItemsChanged, viewabilityConfig, viewabilityConfigCallbackPairs]);
61
+ // The framework-agnostic reducer inputs (props + defaults). Rebuilt each render; the handlers read
62
+ // it through inputsRef so they stay stable.
63
+ const inputs = {
64
+ data,
65
+ getItem,
66
+ getItemCount,
67
+ keyExtractor,
68
+ getItemLayout,
69
+ horizontal,
70
+ windowSize,
71
+ initialNumToRender,
72
+ maxToRenderPerBatch,
73
+ updateCellsBatchingPeriod,
74
+ onEndReachedThreshold,
75
+ onStartReachedThreshold,
76
+ onEndReachedActive: onEndReached !== undefined,
77
+ onStartReachedActive: onStartReached !== undefined,
78
+ viewabilityPairs,
79
+ maintainVisibleContentPosition,
80
+ initialScrollIndex,
81
+ };
82
+ const inputsRef = useRef(inputs);
83
+ inputsRef.current = inputs;
84
+ // The effect executors read the latest callbacks off this ref (the reducer never sees them).
85
+ const handlersRef = useRef({
86
+ onEndReached,
87
+ onStartReached,
88
+ onScrollToIndexFailed,
89
+ viewabilityPairs,
90
+ });
91
+ handlersRef.current = { onEndReached, onStartReached, onScrollToIndexFailed, viewabilityPairs };
92
+ // The one folded state cell (RN's scattered refs collapsed into IListState). Lazily created once.
93
+ const stateRef = useRef(null);
94
+ const state = (stateRef.current ??= createInitialListState());
95
+ const [, forceRender] = useReducer((tick) => tick + 1, 0);
96
+ // The offset we are imperatively driving native to (scrollTo* before the handle attaches). Pushed
97
+ // down as the ScrollView's contentOffset prop; fresh object identity each time. undefined = none.
63
98
  const [commandedOffset, setCommandedOffset] = useState(undefined);
64
99
  const scrollViewRef = useRef(null);
65
- // Measured cell lengths by index. A ref-backed Map mutated in place plus a version counter to
66
- // request a re-render only when a NEW measurement lands.
67
- const measuredRef = useRef(new Map());
68
- const [, setMeasureVersion] = useState(EMPTY_OFFSET);
69
- // The content length we last fired onEndReached / onStartReached for (RN's
70
- // _sentEndForContentLength / _sentStartForContentLength): dedup by content length, re-armed
71
- // on scroll away from the edge.
72
- const sentEndForContentLengthRef = useRef(NO_CONTENT_LENGTH_SENT);
73
- const sentStartForContentLengthRef = useRef(NO_CONTENT_LENGTH_SENT);
74
- // The previously committed window, so throttleWindow can grow it by at most
75
- // maxToRenderPerBatch per batch tick instead of snapping.
76
- const committedWindowRef = useRef({
77
- first: FIRST_INDEX,
78
- last: -1,
79
- });
80
- // The tokens reported viewable on the last onViewableItemsChanged, keyed by cell key.
81
- const lastViewableRef = useRef(new Map());
82
- // Pending minimumViewTime debounce timer (RN ViewabilityHelper._timers). null = no timer.
100
+ // Pending minimumViewTime debounce timer / the incremental-fill timer (adapter owns the timers;
101
+ // the reducer only asks for a delay).
83
102
  const viewableTimerRef = useRef(null);
84
- // Flips true on the first scroll (RN's ViewabilityHelper._hasInteracted).
85
- const hasInteractedRef = useRef(false);
86
- // Per-gap separator overrides, keyed by the LEADING cell index of the gap.
103
+ const batchTimerRef = useRef(null);
104
+ // Per-gap separator overrides (keyed by the LEADING cell index of the gap) stay adapter-side: they
105
+ // are render state read directly in the cell walk, not part of the windowing orchestration.
87
106
  const separatorOverridesRef = useRef(new Map());
88
107
  const [, setSeparatorVersion] = useState(EMPTY_OFFSET);
89
- // initialScrollIndex is applied once, after the first layout gives a viewport.
90
- const appliedInitialScrollRef = useRef(false);
91
- // maintainVisibleContentPosition anchor tracking (RN's State.firstVisibleItemKey).
92
- const firstVisibleKeyRef = useRef(null);
93
- const fixedLayout = useMemo(() => {
94
- if (getItemLayout === undefined)
95
- return undefined;
96
- return (index) => {
97
- const layout = getItemLayout(data, index);
98
- return { length: layout.length, offset: layout.offset };
99
- };
100
- }, [getItemLayout, data]);
101
- // Running average of known cell lengths, used to size not-yet-measured cells and the trailing
102
- // spacer so the total is plausible before full measurement.
103
- const averageLength = useMemo(() => {
104
- if (fixedLayout)
105
- return fixedLayout(FIRST_INDEX).length;
106
- return averageMeasuredLength(measuredRef.current);
107
- }, [fixedLayout, scrollOffset, viewportLength]);
108
- const { offsets, lengths, total } = buildOffsets(count, measuredRef.current, fixedLayout, averageLength);
109
- const targetWindow = computeWindow(count, offsets, lengths, scrollOffset, viewportLength, windowSize, initialNumToRender);
110
- const { first, last } = throttleWindow(targetWindow, committedWindowRef.current, maxToRenderPerBatch);
111
- committedWindowRef.current = { first, last };
112
- dlog(`VirtualizedList window [${first}, ${last}] of ${count} ` +
113
- `(offset=${scrollOffset}, viewport=${viewportLength}, rendered=${Math.max(0, last - first + 1)})`);
114
- // When the throttled window has not yet reached the target, schedule another render after the
115
- // batching period so the window keeps filling toward target.
116
- useEffect(() => {
117
- if (first <= targetWindow.first && last >= targetWindow.last)
108
+ // Drive a native scroll (or, before the handle attaches, the contentOffset fallback).
109
+ const scrollToPixel = useCallback((offset, animated) => {
110
+ const clamped = Math.max(EMPTY_OFFSET, offset);
111
+ const isHorizontal = inputsRef.current.horizontal;
112
+ const target = isHorizontal ? { x: clamped, y: EMPTY_OFFSET } : { x: EMPTY_OFFSET, y: clamped };
113
+ if (scrollViewRef.current !== null) {
114
+ dlog(`VirtualizedList scrollTo offset=${clamped} animated=${animated} (horizontal=${isHorizontal})`);
115
+ scrollViewRef.current.scrollTo({ x: target.x, y: target.y, animated });
116
+ return;
117
+ }
118
+ dlog(`VirtualizedList scrollTo offset=${clamped} pending-ref (horizontal=${isHorizontal})`);
119
+ setCommandedOffset(target);
120
+ }, []);
121
+ // dispatch and runEffects are mutually recursive (a schedule-refill / fire-viewable effect
122
+ // dispatches a follow-up action), so runEffects reaches dispatch through a ref.
123
+ const dispatchRef = useRef(() => { });
124
+ const runEffects = useCallback((effects) => {
125
+ const handlers = handlersRef.current;
126
+ for (const effect of effects) {
127
+ switch (effect.kind) {
128
+ case 'scroll-to':
129
+ scrollToPixel(effect.offset, effect.animated);
130
+ break;
131
+ case 'fire-end-reached':
132
+ handlers.onEndReached?.({ distanceFromEnd: effect.distanceFromEnd });
133
+ break;
134
+ case 'fire-start-reached':
135
+ handlers.onStartReached?.({ distanceFromStart: effect.distanceFromStart });
136
+ break;
137
+ case 'fire-scroll-to-index-failed':
138
+ handlers.onScrollToIndexFailed?.({
139
+ index: effect.index,
140
+ highestMeasuredFrameIndex: effect.highestMeasuredFrameIndex,
141
+ averageItemLength: effect.averageItemLength,
142
+ });
143
+ break;
144
+ case 'schedule-refill': {
145
+ if (batchTimerRef.current !== null)
146
+ clearTimeout(batchTimerRef.current);
147
+ batchTimerRef.current = setTimeout(() => {
148
+ batchTimerRef.current = null;
149
+ dispatchRef.current({ kind: 'batch-tick' });
150
+ }, effect.delay);
151
+ break;
152
+ }
153
+ case 'fire-viewable': {
154
+ const pairs = handlers.viewabilityPairs;
155
+ const info = effect.info;
156
+ const map = effect.map;
157
+ const fire = () => {
158
+ for (const pair of pairs)
159
+ pair.onViewableItemsChanged(info);
160
+ dispatchRef.current({ kind: 'viewable-fired', map });
161
+ };
162
+ if (viewableTimerRef.current !== null) {
163
+ clearTimeout(viewableTimerRef.current);
164
+ viewableTimerRef.current = null;
165
+ }
166
+ if (effect.delay > EMPTY_OFFSET) {
167
+ viewableTimerRef.current = setTimeout(() => {
168
+ viewableTimerRef.current = null;
169
+ fire();
170
+ }, effect.delay);
171
+ }
172
+ else {
173
+ fire();
174
+ }
175
+ break;
176
+ }
177
+ }
178
+ }
179
+ }, [scrollToPixel]);
180
+ const dispatch = useCallback((action) => {
181
+ const current = stateRef.current;
182
+ if (current === null)
118
183
  return;
119
- const timer = setTimeout(() => {
120
- setMeasureVersion(version => version + 1);
121
- }, updateCellsBatchingPeriod);
122
- return () => clearTimeout(timer);
123
- }, [first, last, targetWindow.first, targetWindow.last, updateCellsBatchingPeriod]);
184
+ const result = reduceList(current, action, inputsRef.current);
185
+ runEffects(result.effects);
186
+ if (result.changed)
187
+ forceRender();
188
+ }, [runEffects]);
189
+ dispatchRef.current = dispatch;
190
+ // The single derive-per-render: recompute the window off the current state before reading it.
191
+ reduceList(state, { kind: 'refresh-metrics' }, inputs);
192
+ const m = state.metrics;
193
+ const { count, offsets, lengths, total, first, last } = m;
194
+ const commitSignature = listEffectSignature(state);
195
+ dlog(`VirtualizedList window [${first}, ${last}] of ${count} ` +
196
+ `(offset=${state.scrollOffset}, viewport=${state.viewportLength}, rendered=${Math.max(0, last - first + 1)})`);
124
197
  const onScroll = useCallback((event) => {
125
198
  const offset = readScrollOffset(event, horizontal);
126
199
  if (offset === undefined)
127
200
  return;
128
201
  dlog(`VirtualizedList onScroll offset=${offset}`);
129
- // First scroll is the interaction that ungates waitForInteraction configs.
130
- hasInteractedRef.current = true;
131
- setScrollOffset(offset);
132
202
  // A real user/native scroll supersedes any pending commanded offset.
133
203
  setCommandedOffset(undefined);
204
+ dispatch({ kind: 'scroll', offset });
134
205
  // Compose, don't clobber: internal windowing ran first, now the user's onScroll.
135
206
  if (userOnScroll !== undefined)
136
207
  userOnScroll(event);
137
- }, [horizontal, userOnScroll]);
138
- // onEndReached gating (RN _maybeCallOnEdgeReached). Run against the COMMITTED window. Fire only
139
- // when the last cell is rendered AND within threshold; dedup by content length; re-arm on
140
- // scroll away from the end.
141
- useEffect(() => {
142
- if (onEndReached === undefined || viewportLength <= EMPTY_OFFSET)
143
- return;
144
- const { distanceFromEnd, withinThreshold } = computeEndReached(total, scrollOffset, viewportLength, onEndReachedThreshold);
145
- const lastCellRendered = last === count - 1;
146
- if (withinThreshold && lastCellRendered && sentEndForContentLengthRef.current !== total) {
147
- sentEndForContentLengthRef.current = total;
148
- dlog(`VirtualizedList onEndReached distanceFromEnd=${distanceFromEnd} ` +
149
- `(last=${last} of ${count}, contentLength=${total})`);
150
- onEndReached({ distanceFromEnd });
151
- }
152
- if (!withinThreshold) {
153
- sentEndForContentLengthRef.current = NO_CONTENT_LENGTH_SENT;
154
- }
155
- }, [onEndReached, onEndReachedThreshold, viewportLength, scrollOffset, total, last, count]);
156
- // onStartReached gating: the top-edge twin of the onEndReached effect.
157
- useEffect(() => {
158
- if (onStartReached === undefined || viewportLength <= EMPTY_OFFSET)
159
- return;
160
- const { distanceFromStart, withinThreshold } = computeStartReached(scrollOffset, viewportLength, onStartReachedThreshold);
161
- const firstCellRendered = first === FIRST_INDEX;
162
- if (withinThreshold && firstCellRendered && sentStartForContentLengthRef.current !== total) {
163
- sentStartForContentLengthRef.current = total;
164
- dlog(`VirtualizedList onStartReached distanceFromStart=${distanceFromStart} ` +
165
- `(first=${first}, contentLength=${total})`);
166
- onStartReached({ distanceFromStart });
167
- }
168
- if (!withinThreshold) {
169
- sentStartForContentLengthRef.current = NO_CONTENT_LENGTH_SENT;
170
- }
171
- }, [onStartReached, onStartReachedThreshold, viewportLength, scrollOffset, total, first]);
172
- // The single-config and pairs forms both feed one viewability pass (RN supports either).
173
- const viewabilityPairs = useMemo(() => buildViewabilityPairs(onViewableItemsChanged, viewabilityConfig, viewabilityConfigCallbackPairs), [onViewableItemsChanged, viewabilityConfig, viewabilityConfigCallbackPairs]);
174
- // Viewability detection: after each scroll/window change, recompute which rendered cells clear
175
- // the threshold and, if the viewable set changed, fire onViewableItemsChanged + every pair.
176
- useEffect(() => {
177
- if (viewabilityPairs.length === EMPTY_OFFSET || viewportLength <= EMPTY_OFFSET)
178
- return;
179
- if (count === FIRST_INDEX)
180
- return;
181
- const { tokens, map } = computeViewableSet({
182
- first,
183
- last,
184
- count,
185
- offsets,
186
- lengths,
187
- scrollOffset,
188
- viewportLength,
189
- data,
190
- getItem,
191
- keyExtractor,
192
- pairs: viewabilityPairs,
193
- hasInteracted: hasInteractedRef.current,
194
- });
195
- const diff = diffViewable(lastViewableRef.current, map, tokens);
196
- if (!diff.hasChanged)
197
- return;
198
- const commitAndFire = () => {
199
- lastViewableRef.current = map;
200
- dlog(`VirtualizedList viewable=${tokens.length} changed=${diff.changed.length} ` +
201
- `(window [${first}, ${last}])`);
202
- const info = {
203
- viewableItems: tokens,
204
- changed: diff.changed,
205
- };
206
- for (const pair of viewabilityPairs)
207
- pair.onViewableItemsChanged(info);
208
- };
209
- const minimumViewTime = maxMinimumViewTime(viewabilityPairs);
210
- if (viewableTimerRef.current !== null) {
211
- clearTimeout(viewableTimerRef.current);
212
- viewableTimerRef.current = null;
213
- }
214
- if (minimumViewTime > EMPTY_OFFSET) {
215
- dlog(`VirtualizedList viewability debounce ${minimumViewTime}ms (window [${first}, ${last}])`);
216
- viewableTimerRef.current = setTimeout(() => {
217
- viewableTimerRef.current = null;
218
- commitAndFire();
219
- }, minimumViewTime);
220
- return;
221
- }
222
- commitAndFire();
223
- }, [
224
- viewabilityPairs,
225
- viewportLength,
226
- scrollOffset,
227
- first,
228
- last,
229
- count,
230
- data,
231
- getItem,
232
- keyExtractor,
233
- offsets,
234
- lengths,
235
- ]);
236
- // Clear any pending minimumViewTime debounce on unmount (RN ViewabilityHelper.dispose).
237
- useEffect(() => {
238
- return () => {
239
- if (viewableTimerRef.current !== null)
240
- clearTimeout(viewableTimerRef.current);
241
- };
242
- }, []);
208
+ }, [horizontal, userOnScroll, dispatch]);
243
209
  const onViewportLayout = useCallback((event) => {
244
210
  const length = readLayoutLength(event, horizontal);
245
211
  if (length === undefined)
246
212
  return;
247
213
  dlog(`VirtualizedList onLayout viewport=${length}`);
248
- setViewportLength(length);
249
- }, [horizontal]);
214
+ dispatch({ kind: 'layout', length });
215
+ }, [horizontal, dispatch]);
250
216
  const makeCellMeasure = useCallback((index) => (event) => {
251
- if (fixedLayout)
252
- return;
253
217
  const length = readLayoutLength(event, horizontal);
254
218
  if (length === undefined)
255
219
  return;
256
- const measured = measuredRef.current;
257
- if (measured.get(index) === length)
258
- return;
259
- measured.set(index, length);
260
220
  dlog(`VirtualizedList cell ${index} measured length=${length}`);
261
- setMeasureVersion(version => version + 1);
262
- }, [fixedLayout, horizontal]);
221
+ dispatch({ kind: 'measure', index, length });
222
+ }, [horizontal, dispatch]);
263
223
  // Merge an override onto the separator at a given gap and request a re-render. A gap index
264
224
  // outside [0, count-2] has no separator, so the write is a no-op (RN bails the same way).
265
225
  const mergeSeparator = useCallback((gapIndex, patch) => {
266
- if (gapIndex < FIRST_INDEX || gapIndex > count - 2)
226
+ if (!isSeparatorGapInRange(gapIndex, count))
267
227
  return;
268
228
  const overrides = separatorOverridesRef.current;
269
229
  overrides.set(gapIndex, { ...overrides.get(gapIndex), ...patch });
@@ -285,55 +245,36 @@ export function VirtualizedList(props) {
285
245
  mergeSeparator(select === 'leading' ? index - 1 : index, newProps);
286
246
  },
287
247
  }), [mergeSeparator]);
288
- // Resolve an index to a pixel offset (RN's scrollToIndex options), via the shared math.
289
- const offsetForIndex = useCallback((index, viewPosition, viewOffset) => resolveOffsetForIndex(index, viewPosition, viewOffset, count, offsets, lengths, viewportLength), [count, offsets, lengths, viewportLength]);
290
- const scrollToPixel = useCallback((offset, animated) => {
291
- const clamped = Math.max(EMPTY_OFFSET, offset);
292
- const target = horizontal ? { x: clamped, y: EMPTY_OFFSET } : { x: EMPTY_OFFSET, y: clamped };
293
- // Both animated and instant scrolls ride the ScrollView's native scrollTo command, exactly
294
- // like RN. The contentOffset prop path stays as a fallback for the pre-mount window.
295
- if (scrollViewRef.current !== null) {
296
- dlog(`VirtualizedList scrollTo offset=${clamped} animated=${animated} (horizontal=${horizontal})`);
297
- scrollViewRef.current.scrollTo({ x: target.x, y: target.y, animated });
298
- return;
299
- }
300
- dlog(`VirtualizedList scrollTo offset=${clamped} pending-ref (horizontal=${horizontal})`);
301
- setCommandedOffset(target);
302
- }, [horizontal]);
303
- // The largest index whose length we have actually measured (RN
304
- // ListMetricsAggregator.getHighestMeasuredCellIndex). With getItemLayout this is irrelevant.
305
- const highestMeasuredIndex = useCallback(() => findHighestMeasuredIndex(measuredRef.current), []);
248
+ const keyForIndex = useCallback((index) => resolveItemKey(getItem(data, index), index, keyExtractor), [getItem, data, keyExtractor]);
306
249
  useImperativeHandle(forwardedRef ?? null, () => ({
307
- // RN animates every imperative scroll unless the caller passes animated: false.
250
+ // RN animates every imperative scroll unless the caller passes animated: false. Each resolves
251
+ // to an offset (or a scroll-to-index failure) inside the reducer, then rides scroll-to.
308
252
  scrollToOffset: (params) => {
309
- scrollToPixel(params.offset, params.animated ?? true);
253
+ dispatch({
254
+ kind: 'scroll-to-offset',
255
+ offset: params.offset,
256
+ animated: params.animated ?? true,
257
+ });
310
258
  },
311
259
  scrollToIndex: (params) => {
312
- // No getItemLayout AND the target is past the last measured cell: report the failure
313
- // instead of scrolling to a fabricated estimate (RN VirtualizedList.js:179-195).
314
- if (fixedLayout === undefined && params.index > highestMeasuredIndex()) {
315
- dlog(`VirtualizedList onScrollToIndexFailed index=${params.index} ` +
316
- `highestMeasured=${highestMeasuredIndex()} (no getItemLayout)`);
317
- onScrollToIndexFailed?.({
318
- index: params.index,
319
- highestMeasuredFrameIndex: highestMeasuredIndex(),
320
- averageItemLength: averageLength,
321
- });
322
- return;
323
- }
324
- scrollToPixel(offsetForIndex(params.index, params.viewPosition ?? FIRST_INDEX, params.viewOffset ?? EMPTY_OFFSET), params.animated ?? true);
260
+ dispatch({
261
+ kind: 'scroll-to-index',
262
+ index: params.index,
263
+ animated: params.animated ?? true,
264
+ viewPosition: params.viewPosition ?? FIRST_INDEX,
265
+ viewOffset: params.viewOffset ?? EMPTY_OFFSET,
266
+ });
325
267
  },
326
268
  scrollToItem: (params) => {
327
- for (let index = FIRST_INDEX; index < count; index += 1) {
328
- if (getItem(data, index) === params.item) {
329
- scrollToPixel(offsetForIndex(index, params.viewPosition ?? FIRST_INDEX, EMPTY_OFFSET), params.animated ?? true);
330
- return;
331
- }
332
- }
333
- dlog('VirtualizedList scrollToItem: item not found');
269
+ dispatch({
270
+ kind: 'scroll-to-item',
271
+ item: params.item,
272
+ animated: params.animated ?? true,
273
+ viewPosition: params.viewPosition ?? FIRST_INDEX,
274
+ });
334
275
  },
335
276
  scrollToEnd: (params) => {
336
- scrollToPixel(Math.max(EMPTY_OFFSET, total - viewportLength), params?.animated ?? true);
277
+ dispatch({ kind: 'scroll-to-end', animated: params?.animated ?? true });
337
278
  },
338
279
  flashScrollIndicators: () => {
339
280
  scrollViewRef.current?.flashScrollIndicators?.();
@@ -345,80 +286,28 @@ export function VirtualizedList(props) {
345
286
  // Manual trigger for RN's recordInteraction: flip the interaction flag so
346
287
  // waitForInteraction viewability configs start reporting.
347
288
  recordInteraction: () => {
348
- hasInteractedRef.current = true;
289
+ dispatch({ kind: 'record-interaction' });
349
290
  },
350
- }), [
351
- scrollToPixel,
352
- offsetForIndex,
353
- count,
354
- data,
355
- getItem,
356
- total,
357
- viewportLength,
358
- fixedLayout,
359
- highestMeasuredIndex,
360
- onScrollToIndexFailed,
361
- averageLength,
362
- ]);
363
- // initialScrollIndex: once the first viewport is known, jump to that index a single time.
364
- useEffect(() => {
365
- if (initialScrollIndex === undefined || appliedInitialScrollRef.current)
366
- return;
367
- if (viewportLength <= EMPTY_OFFSET || count === FIRST_INDEX)
368
- return;
369
- appliedInitialScrollRef.current = true;
370
- // The initial jump is instant (RN doesn't animate initialScrollIndex).
371
- scrollToPixel(offsetForIndex(initialScrollIndex, FIRST_INDEX, EMPTY_OFFSET), false);
372
- }, [initialScrollIndex, viewportLength, count, scrollToPixel, offsetForIndex]);
373
- const keyForIndex = useCallback((index) => {
374
- const item = getItem(data, index);
375
- return keyExtractor ? keyExtractor(item, index) : String(index);
376
- }, [getItem, data, keyExtractor]);
377
- // maintainVisibleContentPosition JS anchor adjustment (RN getDerivedStateFromProps:715-768): the
378
- // native MVCP cannot see prepended items above the window (they are collapsed into the leading
379
- // SPACER), so we replicate the JS shift for exactly those. Runs in a layout effect so the
380
- // correction lands before paint.
291
+ }), [dispatch]);
292
+ // After-commit pass: run the deferred effects (batch fill, edge-reached, viewability,
293
+ // initial-scroll, MVCP) in a LAYOUT effect so MVCP's shift lands before paint. Re-runs only when
294
+ // the windowing signature changed — the same dedup key every adapter shares.
381
295
  useLayoutEffect(() => {
382
- if (maintainVisibleContentPosition === undefined || count === FIRST_INDEX) {
383
- firstVisibleKeyRef.current = null;
296
+ const current = stateRef.current;
297
+ if (current === null)
384
298
  return;
385
- }
386
- const minIndexForVisible = maintainVisibleContentPosition.minIndexForVisible;
387
- const newFirstVisibleKey = count > minIndexForVisible ? keyForIndex(minIndexForVisible) : null;
388
- const prevKey = firstVisibleKeyRef.current;
389
- if (prevKey !== null && newFirstVisibleKey !== null && prevKey !== newFirstVisibleKey) {
390
- let anchorIndex = -1;
391
- for (let index = minIndexForVisible; index < count; index += 1) {
392
- if (keyForIndex(index) === prevKey) {
393
- anchorIndex = index;
394
- break;
395
- }
396
- }
397
- if (anchorIndex > minIndexForVisible) {
398
- // Native MVCP shifts in-window cells itself; the JS shift covers ONLY the inserted items
399
- // in the leading SPACER (above the first rendered index). Counting the full inserted extent
400
- // here would double-correct.
401
- const spacerEnd = Math.min(anchorIndex, committedWindowRef.current.first);
402
- const insertedExtent = spacerEnd > minIndexForVisible
403
- ? offsets[spacerEnd] - offsets[minIndexForVisible]
404
- : EMPTY_OFFSET;
405
- if (insertedExtent > EMPTY_OFFSET) {
406
- const autoThreshold = maintainVisibleContentPosition.autoscrollToTopThreshold;
407
- const anchoredNearTop = autoThreshold !== undefined && scrollOffset <= autoThreshold;
408
- if (anchoredNearTop) {
409
- dlog(`VirtualizedList MVCP autoscroll-to-top (offset=${scrollOffset} <= ${autoThreshold})`);
410
- scrollToPixel(EMPTY_OFFSET, true);
411
- }
412
- else {
413
- dlog(`VirtualizedList MVCP adjust +${insertedExtent}px ` +
414
- `(anchor "${prevKey}" moved ${minIndexForVisible}->${anchorIndex})`);
415
- scrollToPixel(scrollOffset + insertedExtent, false);
416
- }
417
- }
418
- }
419
- }
420
- firstVisibleKeyRef.current = newFirstVisibleKey;
421
- }, [maintainVisibleContentPosition, count, keyForIndex, offsets, scrollOffset, scrollToPixel]);
299
+ const result = reduceList(current, { kind: 'commit' }, inputsRef.current);
300
+ runEffects(result.effects);
301
+ }, [commitSignature, runEffects]);
302
+ // Clear any pending timers on unmount (RN ViewabilityHelper.dispose + the fill timer).
303
+ useEffect(() => {
304
+ return () => {
305
+ if (viewableTimerRef.current !== null)
306
+ clearTimeout(viewableTimerRef.current);
307
+ if (batchTimerRef.current !== null)
308
+ clearTimeout(batchTimerRef.current);
309
+ };
310
+ }, []);
422
311
  // ---- assemble the windowed child list ----------------------------------
423
312
  // extraData needs no wiring: this component is not memoized, so any prop change (including
424
313
  // extraData) already re-renders and re-runs renderItem. Voided to mark the deliberate no-op.
@@ -457,6 +346,31 @@ export function VirtualizedList(props) {
457
346
  style: horizontal ? { width: plan.leadingExtent } : { height: plan.leadingExtent },
458
347
  }));
459
348
  }
349
+ // The force-mounted sticky cell (RN _ensureClosestStickyHeader): the nearest sticky index
350
+ // below the window, kept alive outside [first,last] so it never gets destroyed/recreated
351
+ // as the window slides back over its origin index. Rendered exactly like an in-window
352
+ // cell — same measuring wrapper, same key format (so a transition between forced and
353
+ // in-window rendering of the SAME index keeps the same React key and never remounts).
354
+ if (plan.forcedStickyCell !== undefined) {
355
+ const forcedIndex = plan.forcedStickyCell.index;
356
+ const forcedItem = getItem(data, forcedIndex);
357
+ const forcedCell = renderItem({
358
+ item: forcedItem,
359
+ index: forcedIndex,
360
+ separators: makeSeparators(forcedIndex),
361
+ });
362
+ children.push(createElement('symbiote-view', {
363
+ key: `cell-${plan.forcedStickyCell.key}`,
364
+ onLayout: makeCellMeasure(forcedIndex),
365
+ style: inverted ? (horizontal ? INVERTED_X_STYLE : INVERTED_Y_STYLE) : undefined,
366
+ }, forcedCell));
367
+ }
368
+ if (plan.gapExtent > EMPTY_OFFSET) {
369
+ children.push(createElement('symbiote-view', {
370
+ key: 'spacer-gap',
371
+ style: horizontal ? { width: plan.gapExtent } : { height: plan.gapExtent },
372
+ }));
373
+ }
460
374
  for (const planCell of plan.cells) {
461
375
  const item = getItem(data, planCell.index);
462
376
  // renderItem gets a separators handle so a row can highlight/update its own dividers.
@@ -7,7 +7,7 @@
7
7
  // SectionList -> VirtualizedSectionList -> VirtualizedList stack.
8
8
  import { createElement, useImperativeHandle, useRef, } from 'react';
9
9
  import { dlog, Platform } from '@symbiote-native/engine';
10
- import { flattenSections, scrollLocationToFlatIndex, sectionEntryKey, unwrapEntryItem, } from '@symbiote-native/components';
10
+ import { flattenSections, resolveStickySectionHeaders, scrollLocationToFlatIndex, sectionEntryKey, unwrapEntryItem, } from '@symbiote-native/components';
11
11
  import { VirtualizedList, } from '../virtualized-list/index.js';
12
12
  function resolveSeparator(component) {
13
13
  if (component === undefined)
@@ -24,9 +24,7 @@ export function VirtualizedSectionList(props) {
24
24
  const { entries, headerIndices } = flattenSections(sections, SectionSeparatorComponent !== undefined);
25
25
  // RN sticks section headers by default only on iOS; Android does not unless asked. headerIndices
26
26
  // are the flat positions of every section header; VirtualizedList forwards the in-window ones.
27
- const stickyDefault = Platform.OS === 'ios';
28
- const stickyEnabled = stickySectionHeadersEnabled ?? stickyDefault;
29
- const stickyHeaderIndices = stickyEnabled ? headerIndices : undefined;
27
+ const stickyHeaderIndices = resolveStickySectionHeaders(stickySectionHeadersEnabled, headerIndices, Platform.OS);
30
28
  // The handle reaches into the inner VirtualizedList to drive scrollToIndex.
31
29
  const listRef = useRef(null);
32
30
  dlog(`VirtualizedSectionList: ${sections.length} sections flattened to ${entries.length} entries`);