@symbiote-native/react 3.2.0 → 3.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 (84) hide show
  1. package/build/batched-updates.d.ts +2 -0
  2. package/build/batched-updates.js +5 -0
  3. package/build/components/checkbox-props.d.ts +5 -0
  4. package/build/components/checkbox-props.js +1 -0
  5. package/build/components/flat-list/index.d.ts +16 -13
  6. package/build/components/flat-list/index.js +86 -72
  7. package/build/components/input-accessory-view-props.d.ts +4 -3
  8. package/build/components/keyboard-avoiding-view/index.d.ts +2 -2
  9. package/build/components/keyboard-avoiding-view/index.js +45 -84
  10. package/build/components/layout-conformance-props.d.ts +6 -0
  11. package/build/components/layout-conformance-props.js +1 -0
  12. package/build/components/modal/index.d.ts +4 -4
  13. package/build/components/modal/index.js +47 -18
  14. package/build/components/refresh-control-props.d.ts +7 -6
  15. package/build/components/scroll-view/scroll-view-props.d.ts +8 -5
  16. package/build/components/text-input/text-input-props.d.ts +4 -0
  17. package/build/components/touchable/touchable-props.d.ts +6 -5
  18. package/build/components/virtualized-list/index.d.ts +3 -70
  19. package/build/components/virtualized-list/index.js +50 -548
  20. package/build/components/virtualized-list/list-children.d.ts +13 -0
  21. package/build/components/virtualized-list/list-children.js +79 -0
  22. package/build/components/virtualized-list/list-config.d.ts +11 -0
  23. package/build/components/virtualized-list/list-config.js +14 -0
  24. package/build/components/virtualized-list/list-elements.d.ts +11 -0
  25. package/build/components/virtualized-list/list-elements.js +26 -0
  26. package/build/components/virtualized-list/list-host.d.ts +14 -0
  27. package/build/components/virtualized-list/list-host.js +24 -0
  28. package/build/components/virtualized-list/list-scroll-props.d.ts +32 -0
  29. package/build/components/virtualized-list/list-scroll-props.js +30 -0
  30. package/build/components/virtualized-list/nested-scope.d.ts +10 -0
  31. package/build/components/virtualized-list/nested-scope.js +16 -0
  32. package/build/components/virtualized-list/use-list-driver.d.ts +22 -0
  33. package/build/components/virtualized-list/use-list-driver.js +104 -0
  34. package/build/components/virtualized-list/use-list-nesting.d.ts +12 -0
  35. package/build/components/virtualized-list/use-list-nesting.js +37 -0
  36. package/build/components/virtualized-list/use-list-parts.d.ts +12 -0
  37. package/build/components/virtualized-list/use-list-parts.js +20 -0
  38. package/build/components/virtualized-list/use-scroll-handlers.d.ts +17 -0
  39. package/build/components/virtualized-list/use-scroll-handlers.js +106 -0
  40. package/build/components/virtualized-list/use-separators.d.ts +8 -0
  41. package/build/components/virtualized-list/use-separators.js +17 -0
  42. package/build/components/virtualized-list/virtualized-list-props.d.ts +85 -0
  43. package/build/components/virtualized-list/virtualized-list-props.js +2 -0
  44. package/build/components/virtualized-section-list/index.d.ts +24 -8
  45. package/build/components/virtualized-section-list/index.js +89 -93
  46. package/build/components/virtualized-section-list/section-item-cell.d.ts +23 -0
  47. package/build/components/virtualized-section-list/section-item-cell.js +48 -0
  48. package/build/components.d.ts +5 -5
  49. package/build/descriptor-to-react/with-children.d.ts +4 -0
  50. package/build/descriptor-to-react/with-children.js +6 -0
  51. package/build/hooks/use-animated-value.d.ts +7 -0
  52. package/build/hooks/use-animated-value.js +19 -0
  53. package/build/hooks/use-color-scheme.d.ts +2 -2
  54. package/build/hooks/use-color-scheme.js +4 -13
  55. package/build/hooks/use-event.d.ts +6 -0
  56. package/build/hooks/use-event.js +15 -0
  57. package/build/hooks/use-native-view-controller.d.ts +3 -0
  58. package/build/hooks/use-native-view-controller.js +15 -0
  59. package/build/hooks/use-pressability.d.ts +34 -0
  60. package/build/hooks/use-pressability.js +39 -0
  61. package/build/hooks/use-window-dimensions.js +6 -10
  62. package/build/host-config.js +6 -5
  63. package/build/index.d.ts +23 -5
  64. package/build/index.js +23 -14
  65. package/build/jsx-runtime.d.ts +17 -16
  66. package/build/modules/animated/create-animated-component.d.ts +2 -2
  67. package/build/modules/animated/create-animated-component.js +10 -22
  68. package/build/modules/animated/index.d.ts +54 -41
  69. package/build/modules/animated/index.js +2 -27
  70. package/build/modules/app-registry/index.d.ts +3 -3
  71. package/build/modules/app-registry/index.js +2 -2
  72. package/build/modules/status-bar/index.android.d.ts +1 -1
  73. package/build/modules/status-bar/index.android.js +7 -27
  74. package/build/modules/status-bar/index.ios.d.ts +1 -1
  75. package/build/modules/status-bar/index.ios.js +4 -28
  76. package/build/modules/status-bar/shared.d.ts +5 -4
  77. package/build/modules/status-bar/shared.js +33 -6
  78. package/build/native-host-tags.d.ts +2 -0
  79. package/build/native-host-tags.js +4 -0
  80. package/build/render.d.ts +1 -1
  81. package/build/render.js +3 -1
  82. package/build/root-tag-context.d.ts +1 -0
  83. package/build/root-tag-context.js +4 -0
  84. package/package.json +6 -6
@@ -1,551 +1,53 @@
1
- // VirtualizedList: real windowing over the `scroll-view` TAG. Only the cells
2
- // whose computed offset falls inside the visible window (plus a leading/trailing
3
- // buffer) are rendered; everything above and below is collapsed into two spacer
4
- // Views whose sizes sum to the off-screen extent, so the scroll thumb and total
5
- // content size stay correct without mounting all N rows.
6
- //
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.
16
- //
17
- // Imperative scrolling resolves to an offset in the reducer and rides the scroll node's native
18
- // scrollTo command, animated by default. The `contentOffset` prop is only a fallback for the
19
- // pre-mount window (the node not yet attached).
20
- //
21
- // WHAT IS STILL THIS COMPONENT'S, now that the scroll view is a tag and the engine owns its
22
- // content node, its sticky seam and its RefreshControl placement — checked rather than assumed,
23
- // because "the wrapper is gone" and "nothing is left" are different claims:
24
- //
25
- // the WINDOW `reduceList` decides which indices are in view, and this file is what
26
- // turns that into elements. The output SHAPE is decided in JS from the
27
- // app's own data through `renderItem` — the one disqualifier that no
28
- // engine seam removes (`.claude/rules/host-primitive-tier.md`, tier 3).
29
- // the CELL WRAPPERS a measuring `view` per cell with the inversion counter-flip, the two
30
- // spacers, the header/footer/empty slots, the separator INSIDE the cell.
31
- // the LIFECYCLE native events -> actions, effects run with React primitives (timers, a
32
- // forced re-render, a layout effect for MVCP), one state cell.
33
- // the HANDLE `buildScrollViewHandle` over the node a `ref` hands back, plus the list's
34
- // own imperative surface on top of it.
35
- //
36
- // What is NOT: the content node, `contentContainerStyle`'s routing, where a RefreshControl goes
37
- // per platform, and how a sticky header pins. The list names `sticky-header` on a cell and knows
38
- // nothing else about it.
39
- import { createElement, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useReducer, useRef, useState, } from 'react';
40
- import { dlog, } from '@symbiote-native/engine';
41
- import { DEFAULT_INITIAL_NUM_TO_RENDER, DEFAULT_MAX_TO_RENDER_PER_BATCH, DEFAULT_UPDATE_CELLS_BATCHING_PERIOD, DEFAULT_WINDOW_SIZE, EMPTY_OFFSET, FIRST_INDEX, INVERTED_X_STYLE, INVERTED_Y_STYLE, buildListPlan, buildScrollViewHandle, buildViewabilityPairs, createInitialListState, isSeparatorGapInRange, listEffectSignature, readLayoutLength, readLayoutOffset, readScrollOffset, reduceList, resolveItemKey, } from '@symbiote-native/components';
42
- function resolveElement(component) {
43
- if (component === undefined)
44
- return undefined;
45
- if (typeof component === 'function')
46
- return createElement(component, {});
47
- return component;
48
- }
49
- // Build the ItemSeparatorComponent element for the gap between leadingItem and trailingItem, with
50
- // the highlight flag and any handle-pushed overrides merged on top (RN renders
51
- // `<ItemSeparatorComponent {...separatorProps} />`). A bare element is returned as-is.
52
- function renderSeparatorElement(component, leadingItem, trailingItem, overrides) {
53
- if (component === undefined)
54
- return undefined;
55
- const props = {
56
- highlighted: false,
57
- leadingItem,
58
- trailingItem,
59
- ...overrides,
60
- };
61
- return createElement(component, props);
62
- }
63
- // React 19 passes `ref` as a regular prop, so a generic function component can
64
- // expose an imperative handle without forwardRef (which erases the ItemT
65
- // generic). The ref is destructured here and wired through useImperativeHandle.
1
+ // VirtualizedList over the `scroll-view` TAG, spacers stand in for the cells outside the window
2
+ // The orchestration is the shared `reduceList` machine, React supplies lifecycle and elements
3
+ import { useImperativeHandle } from 'react';
4
+ import { dlog } from '@symbiote-native/engine';
5
+ import { buildListHandle, } from '@symbiote-native/components';
6
+ import { buildListChildren } from './list-children.js';
7
+ import { listHostOf } from './list-host.js';
8
+ import { accessibilityRestOf, buildScrollProps } from './list-scroll-props.js';
9
+ import { useListParts } from './use-list-parts.js';
10
+ import { useSeparators } from './use-separators.js';
11
+ // React 19 passes `ref` as a regular prop, so a generic function component can expose an
12
+ // imperative handle without `forwardRef`, which erases the `ItemT` generic
66
13
  export function VirtualizedList(props) {
67
- const forwardedRef = props.ref;
68
- const { data, getItem, getItemCount, renderItem, keyExtractor, getItemLayout, ItemSeparatorComponent, ListHeaderComponent, ListFooterComponent, ListEmptyComponent, horizontal = false, inverted = false, extraData, onEndReached, onEndReachedThreshold, onStartReached, onStartReachedThreshold, onRefresh, refreshing, progressViewOffset, onViewableItemsChanged, viewabilityConfig, viewabilityConfigCallbackPairs, onScrollToIndexFailed, initialNumToRender = DEFAULT_INITIAL_NUM_TO_RENDER, initialScrollIndex, maxToRenderPerBatch = DEFAULT_MAX_TO_RENDER_PER_BATCH, updateCellsBatchingPeriod = DEFAULT_UPDATE_CELLS_BATCHING_PERIOD, windowSize = DEFAULT_WINDOW_SIZE, stickyHeaderIndices, maintainVisibleContentPosition, style, contentContainerStyle,
69
- // Pulled out of the rest so the user's onScroll does NOT arrive raw via
70
- // ...accessibilityRest and overwrite the internal windowing handler. Composed below.
71
- onScroll: userOnScroll, onScrollBeginDrag, onScrollEndDrag, onMomentumScrollBegin, onMomentumScrollEnd, scrollEventThrottle, keyboardShouldPersistTaps, keyboardDismissMode,
72
- // The accessibility surface rides down to the underlying ScrollView, which runs
73
- // resolveAccessibilityProps itself; ref is pulled separately above, re-listed here only
74
- // to keep it out of the forwarded rest.
75
- ref: _ref, ...accessibilityRest } = props;
76
- // The single-config and pairs forms both feed one viewability pass (RN supports either).
77
- const viewabilityPairs = useMemo(() => buildViewabilityPairs(onViewableItemsChanged, viewabilityConfig, viewabilityConfigCallbackPairs), [onViewableItemsChanged, viewabilityConfig, viewabilityConfigCallbackPairs]);
78
- // The framework-agnostic reducer inputs (props + defaults). Rebuilt each render; the handlers read
79
- // it through inputsRef so they stay stable.
80
- const inputs = {
81
- data,
82
- getItem,
83
- getItemCount,
84
- keyExtractor,
85
- getItemLayout,
86
- horizontal,
87
- windowSize,
88
- initialNumToRender,
89
- maxToRenderPerBatch,
90
- updateCellsBatchingPeriod,
91
- onEndReachedThreshold,
92
- onStartReachedThreshold,
93
- onEndReachedActive: onEndReached !== undefined,
94
- onStartReachedActive: onStartReached !== undefined,
95
- viewabilityPairs,
96
- maintainVisibleContentPosition,
97
- initialScrollIndex,
98
- };
99
- const inputsRef = useRef(inputs);
100
- inputsRef.current = inputs;
101
- // The effect executors read the latest callbacks off this ref (the reducer never sees them).
102
- const handlersRef = useRef({
103
- onEndReached,
104
- onStartReached,
105
- onScrollToIndexFailed,
106
- viewabilityPairs,
14
+ const { config, driver, nesting, handlers } = useListParts(props);
15
+ const { state, dispatch, scroll } = driver;
16
+ const metrics = state.metrics;
17
+ const separators = useSeparators(metrics.count);
18
+ useImperativeHandle(props.ref ?? null, () => buildListHandle({
19
+ dispatch,
20
+ scrollHandle: scroll.handle,
21
+ getNode: () => scroll.nodeRef.current,
22
+ }), [dispatch, scroll.handle, scroll.nodeRef]);
23
+ dlog(`VirtualizedList window [${metrics.first}, ${metrics.last}] of ${metrics.count} ` +
24
+ `(offset=${state.scrollOffset}, viewport=${state.viewportLength})`);
25
+ const accessibilityRest = accessibilityRestOf(props);
26
+ return listHostOf({
27
+ config,
28
+ nesting,
29
+ accessibilityRest,
30
+ nodeRef: scroll.nodeRef,
31
+ children: buildListChildren({
32
+ config,
33
+ metrics,
34
+ separators,
35
+ measureCell: handlers.measureCell,
36
+ focusCell: handlers.focusCell,
37
+ }),
38
+ scrollProps: buildScrollProps({
39
+ config,
40
+ accessibilityRest,
41
+ total: metrics.total,
42
+ onScroll: handlers.onScroll,
43
+ onScrollBeginDrag: handlers.onScrollBeginDrag,
44
+ onScrollEndDrag: handlers.onScrollEndDrag,
45
+ onMomentumScrollBegin: handlers.onMomentumScrollBegin,
46
+ onMomentumScrollEnd: handlers.onMomentumScrollEnd,
47
+ onContentSizeChange: handlers.onContentSizeChange,
48
+ onLayout: handlers.onViewportLayout,
49
+ commandedOffset: scroll.commandedOffset,
50
+ hasHeader: config.ListHeaderComponent !== undefined,
51
+ }),
107
52
  });
108
- handlersRef.current = {
109
- onEndReached,
110
- onStartReached,
111
- onScrollToIndexFailed,
112
- viewabilityPairs,
113
- };
114
- // The one folded state cell (RN's scattered refs collapsed into IListState). Lazily created once.
115
- const stateRef = useRef(null);
116
- const state = (stateRef.current ??= createInitialListState());
117
- const [, forceRender] = useReducer((tick) => tick + 1, 0);
118
- // The offset we are imperatively driving native to (scrollTo* before the handle attaches). Pushed
119
- // down as the ScrollView's contentOffset prop; fresh object identity each time. undefined = none.
120
- const [commandedOffset, setCommandedOffset] = useState(undefined);
121
- // The engine node of the scroll TAG. A `ref` on an intrinsic hands back the host instance, so
122
- // there is no wrapper left to run a `useImperativeHandle` — `buildScrollViewHandle` is what turns
123
- // the node into RN's scroll surface, and it is the same function every other adapter feeds from
124
- // its own host-instance accessor. Lazy getter: the node is null until commit.
125
- const scrollNodeRef = useRef(null);
126
- const scrollHandle = useMemo(() => buildScrollViewHandle(() => scrollNodeRef.current), []);
127
- // Pending minimumViewTime debounce timer / the incremental-fill timer (adapter owns the timers;
128
- // the reducer only asks for a delay).
129
- const viewableTimerRef = useRef(null);
130
- const batchTimerRef = useRef(null);
131
- // Per-gap separator overrides (keyed by the LEADING cell index of the gap) stay adapter-side: they
132
- // are render state read directly in the cell walk, not part of the windowing orchestration.
133
- const separatorOverridesRef = useRef(new Map());
134
- const [, setSeparatorVersion] = useState(EMPTY_OFFSET);
135
- // Drive a native scroll (or, before the handle attaches, the contentOffset fallback).
136
- const scrollToPixel = useCallback((offset, animated) => {
137
- const clamped = Math.max(EMPTY_OFFSET, offset);
138
- const isHorizontal = inputsRef.current.horizontal;
139
- const target = isHorizontal
140
- ? { x: clamped, y: EMPTY_OFFSET }
141
- : { x: EMPTY_OFFSET, y: clamped };
142
- if (scrollNodeRef.current !== null) {
143
- dlog(`VirtualizedList scrollTo offset=${clamped} animated=${animated} (horizontal=${isHorizontal})`);
144
- scrollHandle.scrollTo({ x: target.x, y: target.y, animated });
145
- return;
146
- }
147
- dlog(`VirtualizedList scrollTo offset=${clamped} pending-ref (horizontal=${isHorizontal})`);
148
- setCommandedOffset(target);
149
- }, [scrollHandle]);
150
- // dispatch and runEffects are mutually recursive (a schedule-refill / fire-viewable effect
151
- // dispatches a follow-up action), so runEffects reaches dispatch through a ref.
152
- const dispatchRef = useRef(() => { });
153
- const runEffects = useCallback((effects) => {
154
- const handlers = handlersRef.current;
155
- for (const effect of effects) {
156
- switch (effect.kind) {
157
- case 'scroll-to':
158
- scrollToPixel(effect.offset, effect.animated);
159
- break;
160
- case 'fire-end-reached':
161
- handlers.onEndReached?.({
162
- distanceFromEnd: effect.distanceFromEnd,
163
- });
164
- break;
165
- case 'fire-start-reached':
166
- handlers.onStartReached?.({
167
- distanceFromStart: effect.distanceFromStart,
168
- });
169
- break;
170
- case 'fire-scroll-to-index-failed':
171
- handlers.onScrollToIndexFailed?.({
172
- index: effect.index,
173
- highestMeasuredFrameIndex: effect.highestMeasuredFrameIndex,
174
- averageItemLength: effect.averageItemLength,
175
- });
176
- break;
177
- case 'schedule-refill': {
178
- if (batchTimerRef.current !== null)
179
- clearTimeout(batchTimerRef.current);
180
- batchTimerRef.current = setTimeout(() => {
181
- batchTimerRef.current = null;
182
- dispatchRef.current({ kind: 'batch-tick' });
183
- }, effect.delay);
184
- break;
185
- }
186
- case 'fire-viewable': {
187
- const pairs = handlers.viewabilityPairs;
188
- const info = effect.info;
189
- const map = effect.map;
190
- const fire = () => {
191
- for (const pair of pairs) {
192
- pair.onViewableItemsChanged({
193
- ...info,
194
- viewabilityConfig: pair.viewabilityConfig,
195
- });
196
- }
197
- dispatchRef.current({ kind: 'viewable-fired', map });
198
- };
199
- if (viewableTimerRef.current !== null) {
200
- clearTimeout(viewableTimerRef.current);
201
- viewableTimerRef.current = null;
202
- }
203
- if (effect.delay > EMPTY_OFFSET) {
204
- viewableTimerRef.current = setTimeout(() => {
205
- viewableTimerRef.current = null;
206
- fire();
207
- }, effect.delay);
208
- }
209
- else {
210
- fire();
211
- }
212
- break;
213
- }
214
- }
215
- }
216
- }, [scrollToPixel]);
217
- const dispatch = useCallback((action) => {
218
- const current = stateRef.current;
219
- if (current === null)
220
- return;
221
- const result = reduceList(current, action, inputsRef.current);
222
- runEffects(result.effects);
223
- if (result.changed)
224
- forceRender();
225
- }, [runEffects]);
226
- dispatchRef.current = dispatch;
227
- // The single derive-per-render: recompute the window off the current state before reading it.
228
- reduceList(state, { kind: 'refresh-metrics' }, inputs);
229
- const m = state.metrics;
230
- const { count, offsets, lengths, total, first, last } = m;
231
- const commitSignature = listEffectSignature(state);
232
- dlog(`VirtualizedList window [${first}, ${last}] of ${count} ` +
233
- `(offset=${state.scrollOffset}, viewport=${state.viewportLength}, rendered=${Math.max(0, last - first + 1)})`);
234
- const onScroll = useCallback((event) => {
235
- const offset = readScrollOffset(event, horizontal);
236
- if (offset === undefined)
237
- return;
238
- dlog(`VirtualizedList onScroll offset=${offset}`);
239
- // A real user/native scroll supersedes any pending commanded offset.
240
- setCommandedOffset(undefined);
241
- dispatch({ kind: 'scroll', offset });
242
- // Compose, don't clobber: internal windowing ran first, now the user's onScroll.
243
- if (userOnScroll !== undefined)
244
- userOnScroll(event);
245
- }, [horizontal, userOnScroll, dispatch]);
246
- const onViewportLayout = useCallback((event) => {
247
- const length = readLayoutLength(event, horizontal);
248
- if (length === undefined)
249
- return;
250
- dlog(`VirtualizedList onLayout viewport=${length}`);
251
- dispatch({ kind: 'layout', length });
252
- }, [horizontal, dispatch]);
253
- const makeCellMeasure = useCallback((index) => (event) => {
254
- const length = readLayoutLength(event, horizontal);
255
- if (length === undefined)
256
- return;
257
- const offset = readLayoutOffset(event, horizontal);
258
- dlog(`VirtualizedList cell ${index} measured length=${length} offset=${offset ?? 'none'}`);
259
- dispatch({ kind: 'measure', index, length, offset });
260
- }, [horizontal, dispatch]);
261
- // Merge an override onto the separator at a given gap and request a re-render. A gap index
262
- // outside [0, count-2] has no separator, so the write is a no-op (RN bails the same way).
263
- const mergeSeparator = useCallback((gapIndex, patch) => {
264
- if (!isSeparatorGapInRange(gapIndex, count))
265
- return;
266
- const overrides = separatorOverridesRef.current;
267
- overrides.set(gapIndex, { ...overrides.get(gapIndex), ...patch });
268
- setSeparatorVersion(version => version + 1);
269
- }, [count]);
270
- // The ISeparators handle for the cell at `index` (RN CellRenderer._separators).
271
- const makeSeparators = useCallback((index) => ({
272
- highlight: () => {
273
- dlog(`VirtualizedList separator highlight cell=${index}`);
274
- mergeSeparator(index - 1, { highlighted: true });
275
- mergeSeparator(index, { highlighted: true });
276
- },
277
- unhighlight: () => {
278
- dlog(`VirtualizedList separator unhighlight cell=${index}`);
279
- mergeSeparator(index - 1, { highlighted: false });
280
- mergeSeparator(index, { highlighted: false });
281
- },
282
- updateProps: (select, newProps) => {
283
- mergeSeparator(select === 'leading' ? index - 1 : index, newProps);
284
- },
285
- }), [mergeSeparator]);
286
- const keyForIndex = useCallback((index) => resolveItemKey(getItem(data, index), index, keyExtractor), [getItem, data, keyExtractor]);
287
- useImperativeHandle(forwardedRef ?? null, () => ({
288
- // RN animates every imperative scroll unless the caller passes animated: false. Each resolves
289
- // to an offset (or a scroll-to-index failure) inside the reducer, then rides scroll-to.
290
- scrollToOffset: (params) => {
291
- dispatch({
292
- kind: 'scroll-to-offset',
293
- offset: params.offset,
294
- animated: params.animated ?? true,
295
- });
296
- },
297
- scrollToIndex: (params) => {
298
- dispatch({
299
- kind: 'scroll-to-index',
300
- index: params.index,
301
- animated: params.animated ?? true,
302
- viewPosition: params.viewPosition ?? FIRST_INDEX,
303
- viewOffset: params.viewOffset ?? EMPTY_OFFSET,
304
- });
305
- },
306
- scrollToItem: (params) => {
307
- dispatch({
308
- kind: 'scroll-to-item',
309
- item: params.item,
310
- animated: params.animated ?? true,
311
- viewPosition: params.viewPosition ?? FIRST_INDEX,
312
- });
313
- },
314
- scrollToEnd: (params) => {
315
- dispatch({ kind: 'scroll-to-end', animated: params?.animated ?? true });
316
- },
317
- flashScrollIndicators: () => {
318
- scrollHandle.flashScrollIndicators?.();
319
- },
320
- // Null until the tag commits, so these keep RN's "no scroll view yet" answer rather than
321
- // handing back a handle that would silently no-op.
322
- getNativeScrollRef: () => scrollNodeRef.current !== null ? scrollHandle : null,
323
- getScrollableNode: () => scrollNodeRef.current !== null ? scrollHandle : null,
324
- getScrollResponder: () => scrollNodeRef.current !== null ? scrollHandle : null,
325
- getScrollNode: () => scrollNodeRef.current,
326
- // Manual trigger for RN's recordInteraction: flip the interaction flag so
327
- // waitForInteraction viewability configs start reporting.
328
- recordInteraction: () => {
329
- dispatch({ kind: 'record-interaction' });
330
- },
331
- }), [dispatch, scrollHandle]);
332
- // After-commit pass: run the deferred effects (batch fill, edge-reached, viewability,
333
- // initial-scroll, MVCP) in a LAYOUT effect so MVCP's shift lands before paint. Re-runs only when
334
- // the windowing signature changed — the same dedup key every adapter shares.
335
- useLayoutEffect(() => {
336
- const current = stateRef.current;
337
- if (current === null)
338
- return;
339
- const result = reduceList(current, { kind: 'commit' }, inputsRef.current);
340
- runEffects(result.effects);
341
- }, [commitSignature, runEffects]);
342
- // Clear any pending timers on unmount (RN ViewabilityHelper.dispose + the fill timer).
343
- useEffect(() => {
344
- return () => {
345
- if (viewableTimerRef.current !== null)
346
- clearTimeout(viewableTimerRef.current);
347
- if (batchTimerRef.current !== null)
348
- clearTimeout(batchTimerRef.current);
349
- };
350
- }, []);
351
- // ---- assemble the windowed child list ----------------------------------
352
- // extraData needs no wiring: this component is not memoized, so any prop change (including
353
- // extraData) already re-renders and re-runs renderItem. Voided to mark the deliberate no-op.
354
- void extraData;
355
- const children = [];
356
- const stickySet = stickyHeaderIndices !== undefined
357
- ? new Set(stickyHeaderIndices)
358
- : undefined;
359
- const header = resolveElement(ListHeaderComponent);
360
- if (header !== undefined) {
361
- children.push(createElement('view', { key: 'list-header' }, header));
362
- }
363
- if (count === FIRST_INDEX) {
364
- const empty = resolveElement(ListEmptyComponent);
365
- if (empty !== undefined) {
366
- children.push(createElement('view', { key: 'list-empty' }, empty));
367
- }
368
- }
369
- else {
370
- // The shared plan: spacer extents, in-window cell keys, and the sticky child positions.
371
- const plan = buildListPlan({
372
- count,
373
- first,
374
- last,
375
- offsets,
376
- lengths,
377
- total,
378
- keyFor: keyForIndex,
379
- stickyIndices: stickySet,
380
- hasHeader: header !== undefined,
381
- });
382
- // `plan.stickyChildPositions` is deliberately NOT read. It was the input to the wrapper's own
383
- // JS sticky pass, and the behavior honours `stickyHeaderIndices` by numbering the OWNER's paint
384
- // children — which a windowed list cannot supply: it paints a header, a spacer and a slice, so
385
- // data index 3 is almost never paint child 3, and the positions would have to be recomputed
386
- // every time the window slides. The cells that should pin carry the `sticky-header` TAG
387
- // instead, which pins by document order and survives windowing.
388
- if (plan.leadingExtent > EMPTY_OFFSET) {
389
- children.push(createElement('view', {
390
- key: 'spacer-leading',
391
- style: horizontal
392
- ? { width: plan.leadingExtent }
393
- : { height: plan.leadingExtent },
394
- }));
395
- }
396
- // The force-mounted sticky cell (RN _ensureClosestStickyHeader): the nearest sticky index
397
- // below the window, kept alive outside [first,last] so it never gets destroyed/recreated
398
- // as the window slides back over its origin index. Rendered exactly like an in-window
399
- // cell — same measuring wrapper, same key format (so a transition between forced and
400
- // in-window rendering of the SAME index keeps the same React key and never remounts).
401
- if (plan.forcedStickyCell !== undefined) {
402
- const forcedIndex = plan.forcedStickyCell.index;
403
- const forcedItem = getItem(data, forcedIndex);
404
- const forcedCell = renderItem({
405
- item: forcedItem,
406
- index: forcedIndex,
407
- separators: makeSeparators(forcedIndex),
408
- });
409
- children.push(createElement(
410
- // Sticky by construction — `forcedStickyCell` exists only for an index in `stickySet` —
411
- // and the SAME tag the in-window branch below uses, so a cell crossing between the two
412
- // keeps its element type as well as its key and never remounts.
413
- 'sticky-header', {
414
- key: `cell-${plan.forcedStickyCell.key}`,
415
- onLayout: makeCellMeasure(forcedIndex),
416
- style: inverted
417
- ? horizontal
418
- ? INVERTED_X_STYLE
419
- : INVERTED_Y_STYLE
420
- : undefined,
421
- }, forcedCell));
422
- }
423
- if (plan.gapExtent > EMPTY_OFFSET) {
424
- children.push(createElement('view', {
425
- key: 'spacer-gap',
426
- style: horizontal
427
- ? { width: plan.gapExtent }
428
- : { height: plan.gapExtent },
429
- }));
430
- }
431
- for (const planCell of plan.cells) {
432
- const item = getItem(data, planCell.index);
433
- // renderItem gets a separators handle so a row can highlight/update its own dividers.
434
- const cell = renderItem({
435
- item,
436
- index: planCell.index,
437
- separators: makeSeparators(planCell.index),
438
- });
439
- // The separator lives INSIDE the cell's own measuring wrapper, not as a sibling: a sibling
440
- // is an extra flex child, so a leading-spacer collapse would land every cell below it short
441
- // by (separator + gap). See .claude/rules/list-geometry-feedback-loop.md.
442
- const separator =
443
- // Gated on the last index of the DATA, not the WINDOW: since the separator lives inside
444
- // the measuring wrapper, gating on the window would make a cell's own measured height
445
- // change as the window slides past it.
446
- planCell.index < count - 1
447
- ? renderSeparatorElement(ItemSeparatorComponent, item, getItem(data, planCell.index + 1), separatorOverridesRef.current.get(planCell.index))
448
- : undefined;
449
- // Wrap each cell in a measuring View. When inverted, each cell carries the counter-flip so
450
- // its content reads upright inside the flipped content container.
451
- //
452
- // A cell the app flagged sticky is wrapped in the `sticky-header` TAG instead — same
453
- // position, same key, same `onLayout` (the behavior forwards it rather than replacing it),
454
- // and the engine finds this scroll view by walking up. Nothing here computes a child index:
455
- // the collision point comes from the owner's DOCUMENT order, which is the one form that
456
- // survives windowing.
457
- children.push(createElement(stickySet?.has(planCell.index) === true ? 'sticky-header' : 'view', {
458
- key: `cell-${planCell.key}`,
459
- onLayout: makeCellMeasure(planCell.index),
460
- style: inverted
461
- ? horizontal
462
- ? INVERTED_X_STYLE
463
- : INVERTED_Y_STYLE
464
- : undefined,
465
- }, cell, separator));
466
- }
467
- if (plan.trailingExtent > EMPTY_OFFSET) {
468
- children.push(createElement('view', {
469
- key: 'spacer-trailing',
470
- style: horizontal
471
- ? { width: plan.trailingExtent }
472
- : { height: plan.trailingExtent },
473
- }));
474
- }
475
- }
476
- const footer = resolveElement(ListFooterComponent);
477
- if (footer !== undefined) {
478
- children.push(createElement('view', { key: 'list-footer' }, footer));
479
- }
480
- // A horizontal list pins the content container to the full row width so the row overflows for
481
- // iOS to scroll. The inversion flip rides ONLY the outer ScrollView style and each cell, never
482
- // the content container (flipping it too would cancel the ScrollView flip).
483
- const resolvedContentContainerStyle = horizontal
484
- ? [contentContainerStyle, { width: total }]
485
- : contentContainerStyle;
486
- // VirtualizedList.js: `[inversionStyle, style]` — the app's style can override the flip.
487
- const resolvedStyle = inverted
488
- ? [horizontal ? INVERTED_X_STYLE : INVERTED_Y_STYLE, style]
489
- : style;
490
- const scrollProps = {
491
- // The list's accessibility surface rides down onto the ScrollView. Spread first so the
492
- // explicit windowing props below always win.
493
- ...accessibilityRest,
494
- style: resolvedStyle,
495
- contentContainerStyle: resolvedContentContainerStyle,
496
- onScroll,
497
- onLayout: onViewportLayout,
498
- };
499
- // `horizontal` is NOT in the bag: the axis is the TAG (`horizontal-scroll-view`), because Android
500
- // scrolls the two axes with different native ViewManagers. The behavior's own fold deletes the
501
- // prop and rewrites it from the tag, so passing it would be at best redundant and at worst a
502
- // contradiction it has to warn about.
503
- // VirtualizedList.js:1111 — Android moves the scrollbar back after the `scale: -1` flip.
504
- if (inverted)
505
- scrollProps.isInvertedVirtualizedList = true;
506
- if (onScrollBeginDrag !== undefined)
507
- scrollProps.onScrollBeginDrag = onScrollBeginDrag;
508
- if (onScrollEndDrag !== undefined)
509
- scrollProps.onScrollEndDrag = onScrollEndDrag;
510
- if (onMomentumScrollBegin !== undefined)
511
- scrollProps.onMomentumScrollBegin = onMomentumScrollBegin;
512
- if (onMomentumScrollEnd !== undefined)
513
- scrollProps.onMomentumScrollEnd = onMomentumScrollEnd;
514
- if (scrollEventThrottle !== undefined)
515
- scrollProps.scrollEventThrottle = scrollEventThrottle;
516
- if (keyboardShouldPersistTaps !== undefined)
517
- scrollProps.keyboardShouldPersistTaps = keyboardShouldPersistTaps;
518
- if (keyboardDismissMode !== undefined)
519
- scrollProps.keyboardDismissMode = keyboardDismissMode;
520
- // A pending imperative/initial scroll rides down as contentOffset (fresh identity each push).
521
- if (commandedOffset !== undefined)
522
- scrollProps.contentOffset = commandedOffset;
523
- // Forward maintainVisibleContentPosition to the native ScrollView so it anchors in-window cells.
524
- // minIndexForVisible is bumped by 1 when a ListHeaderComponent occupies child 0.
525
- if (maintainVisibleContentPosition !== undefined) {
526
- scrollProps.maintainVisibleContentPosition = {
527
- ...maintainVisibleContentPosition,
528
- minIndexForVisible: maintainVisibleContentPosition.minIndexForVisible +
529
- (header !== undefined ? 1 : 0),
530
- };
531
- }
532
- // Pull-to-refresh: when onRefresh is set, the RefreshControl is an ordinary FIRST CHILD on both
533
- // platforms rather than a prop. The scroll behavior CLAIMS it, so the engine keeps it beside the
534
- // content view on iOS and inverts the tree on Android; everything after it lands in the content
535
- // node the behavior built. refreshing is RN-required alongside onRefresh, default false.
536
- const refreshControl = onRefresh !== undefined
537
- ? createElement('refresh-control', {
538
- key: 'refresh-control',
539
- refreshing: refreshing ?? false,
540
- onRefresh,
541
- progressViewOffset,
542
- })
543
- : undefined;
544
- if (refreshControl !== undefined) {
545
- dlog('VirtualizedList wiring RefreshControl (onRefresh provided)');
546
- }
547
- // The scroll TAG, not a wrapper: the engine builds the content node, carries
548
- // `contentContainerStyle` onto it and places the refresh control. `ref` hands back the engine
549
- // node, which is what `buildScrollViewHandle` drives for the imperative scrolls.
550
- return createElement(horizontal ? 'horizontal-scroll-view' : 'scroll-view', { ...scrollProps, ref: scrollNodeRef }, refreshControl, ...children);
551
53
  }
@@ -0,0 +1,13 @@
1
+ import { type ReactNode } from 'react';
2
+ import type { ISymbioteEvent } from '@symbiote-native/engine';
3
+ import { type IListMetrics } from '@symbiote-native/components';
4
+ import type { IListConfig } from './list-config';
5
+ import type { IListSeparators } from './use-separators';
6
+ export type IChildrenArgs<ItemT> = {
7
+ config: IListConfig<ItemT>;
8
+ metrics: IListMetrics;
9
+ separators: IListSeparators<ItemT>;
10
+ measureCell: (index: number) => (event: ISymbioteEvent) => void;
11
+ focusCell: (index: number) => () => void;
12
+ };
13
+ export declare function buildListChildren<ItemT>(args: IChildrenArgs<ItemT>): ReactNode[];