@symbiote-native/vue 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 (59) hide show
  1. package/build/components/checkbox-props.d.ts +3 -0
  2. package/build/components/checkbox-props.js +1 -0
  3. package/build/components/flat-list/index.d.ts +4 -3
  4. package/build/components/flat-list/index.js +18 -21
  5. package/build/components/input-accessory-view-props.d.ts +4 -4
  6. package/build/components/keyboard-avoiding-view.d.ts +11 -3
  7. package/build/components/keyboard-avoiding-view.js +38 -69
  8. package/build/components/layout-conformance-props.d.ts +4 -0
  9. package/build/components/layout-conformance-props.js +1 -0
  10. package/build/components/modal/index.d.ts +18 -5
  11. package/build/components/modal/index.js +53 -24
  12. package/build/components/refresh-control-props.d.ts +7 -7
  13. package/build/components/scroll-view/scroll-view-props.d.ts +8 -5
  14. package/build/components/section-list/index.d.ts +1 -1
  15. package/build/components/section-list/index.js +2 -6
  16. package/build/components/text-props.d.ts +3 -3
  17. package/build/components/touchable-props.d.ts +6 -6
  18. package/build/components/virtualized-list/index.d.ts +5 -75
  19. package/build/components/virtualized-list/index.js +130 -558
  20. package/build/components/virtualized-list/list-children.d.ts +18 -0
  21. package/build/components/virtualized-list/list-children.js +81 -0
  22. package/build/components/virtualized-list/list-elements.d.ts +7 -0
  23. package/build/components/virtualized-list/list-elements.js +29 -0
  24. package/build/components/virtualized-list/list-scroll-props.d.ts +20 -0
  25. package/build/components/virtualized-list/list-scroll-props.js +9 -0
  26. package/build/components/virtualized-list/narrow-props.d.ts +79 -0
  27. package/build/components/virtualized-list/narrow-props.js +89 -0
  28. package/build/components/virtualized-list/nested-scope.d.ts +3 -0
  29. package/build/components/virtualized-list/nested-scope.js +12 -0
  30. package/build/components/virtualized-list/user-handlers.d.ts +3 -0
  31. package/build/components/virtualized-list/user-handlers.js +24 -0
  32. package/build/components/virtualized-list/virtualized-list-props.d.ts +82 -0
  33. package/build/components/virtualized-list/virtualized-list-props.js +41 -0
  34. package/build/components/virtualized-section-list/index.d.ts +20 -5
  35. package/build/components/virtualized-section-list/index.js +81 -95
  36. package/build/components/virtualized-section-list/section-item-cell.d.ts +18 -0
  37. package/build/components/virtualized-section-list/section-item-cell.js +52 -0
  38. package/build/composables/define-native-view-component.d.ts +3 -0
  39. package/build/composables/define-native-view-component.js +23 -0
  40. package/build/composables/use-animated-value.d.ts +7 -0
  41. package/build/composables/use-animated-value.js +14 -0
  42. package/build/composables/use-color-scheme.d.ts +1 -1
  43. package/build/composables/use-event.d.ts +6 -0
  44. package/build/composables/use-event.js +22 -0
  45. package/build/create-transition/index.d.ts +2 -2
  46. package/build/create-transition/index.js +1 -1
  47. package/build/descriptor-to-vue.d.ts +1 -1
  48. package/build/descriptor-to-vue.js +6 -2
  49. package/build/index.d.ts +16 -4
  50. package/build/index.js +18 -31
  51. package/build/jsx-runtime.d.ts +2 -0
  52. package/build/modules/animated/index.d.ts +54 -41
  53. package/build/modules/animated/index.js +2 -26
  54. package/build/modules/app-registry/index.d.ts +3 -3
  55. package/build/modules/app-registry/index.js +2 -2
  56. package/build/modules/status-bar.js +22 -24
  57. package/build/utils/normalize-attrs.js +10 -4
  58. package/metro-vue-transformer.cjs +165 -224
  59. package/package.json +6 -6
@@ -1,369 +1,109 @@
1
- // VirtualizedList, the Vue lifecycle half. The orchestration — window recompute, batch throttle,
2
- // viewability, edge-reached, initial-scroll, MVCP, the imperative scrolls — is the framework-agnostic
3
- // `reduceList` state machine in @symbiote-native/components (state/virtualized-list-reducer), shared verbatim
4
- // with React and Angular. Here Vue supplies only the reactivity: it turns native events into ACTIONS,
5
- // holds ONE plain state cell (listState), runs the returned EFFECTS with Vue primitives (a native
6
- // scrollTo, an emit, a setTimeout, a version bump), and builds the cell/spacer VNodes off the plan.
7
- // A single `metrics` computed derives the window once per render (refresh-metrics); a single
8
- // post-flush watcher on the windowing signature runs the after-commit `commit` pass. This is the Vue
9
- // twin of the React adapter's one-state-cell + dispatch + runEffects. It drives the Vue ScrollView,
10
- // exactly as the React list drives the React ScrollView.
11
- //
12
- // Typed-emits generic component (mirrors FlatList): a GENERIC setup function
13
- // `<ItemT,>(props: IVirtualizedListProps<ItemT>, ctx: ICtx<IVirtualizedListEmits<ItemT>>)` so the
14
- // five adapter-synthesized events emit with ItemT-typed payloads (`@viewable-items-changed` carries
15
- // ItemT, not unknown). For that to infer at the call site, the generic INPUTS (data/getItem/
16
- // renderItem/…) are read from typed `props`, declared in the runtime `props` array; the raw native
17
- // scroll events (onScroll/onScrollBeginDrag/…) + any unknown attrs ride through `$attrs` onto the
18
- // inner ScrollView. RefreshControl + viewability stay GATED on listener presence, so each emit
19
- // bridge is wired ONLY when the consumer actually listens (read off the instance vnode props),
20
- // keeping behavior identical to the prop-callback era.
21
- //
22
- // Lists have no Descriptor render fn (the cell content is the framework's own children).
23
- // Cells/spacers are built with h() directly off the plan.
24
- //
25
- // Reactivity gotcha: the ScrollView's exposed handle is held in a shallowRef so the engine
26
- // node it closes over is reached by identity; a deep ref would proxy it and the imperative
27
- // scroll commands would silently no-op. The folded listState is a PLAIN object (not reactive) —
28
- // mutating it never triggers Vue; the `version` ref is bumped when a transition changes render state.
29
- import { computed, defineComponent, getCurrentInstance, h, isVNode, onBeforeUnmount, ref, shallowRef, watch, } from '@vue/runtime-core';
30
- 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';
31
- import { dlog, isSymbioteNode, } from '@symbiote-native/engine';
32
- import { normalizeVueAttrs } from '../../utils/normalize-attrs.js';
33
- import { componentFromSlot } from '../../utils/slots-to-render-props.js';
34
- // The typed inputs VirtualizedList reads off `props`; everything else (raw scroll, keyboard*,
35
- // accessibility, testID, …) falls through $attrs onto the inner ScrollView. Listed for the runtime
36
- // `props` declaration (keyof can't derive it: the index signature widens keyof to `string`). The
37
- // five raw-scroll events and the five emit events are deliberately absent.
38
- const PROP_KEYS = [
39
- 'data',
40
- 'getItem',
41
- 'getItemCount',
42
- 'keyExtractor',
43
- 'getItemLayout',
44
- 'viewabilityConfig',
45
- 'viewabilityConfigCallbackPairs',
46
- 'extraData',
47
- 'horizontal',
48
- 'inverted',
49
- 'onEndReachedThreshold',
50
- 'onStartReachedThreshold',
51
- 'refreshing',
52
- 'progressViewOffset',
53
- 'initialNumToRender',
54
- 'initialScrollIndex',
55
- 'maxToRenderPerBatch',
56
- 'updateCellsBatchingPeriod',
57
- 'windowSize',
58
- 'stickyHeaderIndices',
59
- 'maintainVisibleContentPosition',
60
- 'style',
61
- 'contentContainerStyle',
62
- 'keyboardShouldPersistTaps',
63
- 'keyboardDismissMode',
64
- ];
65
- const EMIT_KEYS = [
66
- 'viewableItemsChanged',
67
- 'endReached',
68
- 'startReached',
69
- 'refresh',
70
- 'scrollToIndexFailed',
71
- ];
72
- function isHandler(value) {
73
- return typeof value === 'function';
74
- }
75
- function isComponent(value) {
76
- return (typeof value === 'function' || (typeof value === 'object' && value !== null));
77
- }
78
- function asNumber(value, fallback) {
79
- return typeof value === 'number' ? value : fallback;
80
- }
81
- // The attrs that arrive via $attrs but must NOT be forwarded raw onto the inner ScrollView: VL
82
- // composes its own onScroll (windowing offset + the user's onScroll) and its own onLayout (viewport
83
- // measure), so a consumer-passed one is dropped here and re-set explicitly on the scroll props.
84
- const HANDLED_ATTRS = ['onScroll', 'onLayout'];
85
- function forwardAttrs(attrs) {
86
- const result = {};
87
- for (const key of Object.keys(attrs)) {
88
- if (!HANDLED_ATTRS.includes(key))
89
- result[key] = attrs[key];
90
- }
91
- return result;
92
- }
93
- // A list component element (ListHeaderComponent / ListEmptyComponent / ListFooterComponent) is
94
- // either a Vue component (invoked via h) or a ready VNode (passed through). RN resolves the same
95
- // `Component | element` union; we narrow with isVNode before isComponent (a VNode is an object).
96
- function resolveElement(value) {
97
- if (value === undefined || value === null)
98
- return undefined;
99
- if (isVNode(value))
100
- return value;
101
- if (isComponent(value))
102
- return h(value);
103
- return undefined;
104
- }
105
- function renderSeparatorElement(component, leadingItem, trailingItem, overrides) {
106
- if (component === undefined)
107
- return undefined;
108
- const props = {
109
- highlighted: false,
110
- leadingItem,
111
- trailingItem,
112
- ...overrides,
113
- };
114
- return h(component, props);
115
- }
1
+ // VirtualizedList, the Vue lifecycle half over the shared `reduceList` machine
2
+ // A generic setup keeps the five emits typed by `ItemT`, raw native scroll events ride `$attrs`
3
+ // `listState` is a plain object, a `version` ref is bumped when a transition changes render state
4
+ import { computed, defineComponent, getCurrentInstance, h, onBeforeUnmount, ref, shallowRef, watch, } from '@vue/runtime-core';
5
+ import { EMPTY_OFFSET, LIST_ACTION_KIND, buildListHandle, buildListReducerInputs, buildScrollViewHandle, buildSeparatorHandles, buildViewabilityPairs, clearTimers, createInitialListState, createListHandlers, createListNesting, isSeparatorGapInRange, listEffectSignature, listHasMore, reduceList, resolveItemKey, runListEffects, scrollTargetOf, viewPropsOf, } from '@symbiote-native/components';
6
+ import { dlog, isDebug, isSymbioteNode, } from '@symbiote-native/engine';
7
+ import { buildListChildren } from './list-children.js';
8
+ import { buildScrollProps } from './list-scroll-props.js';
9
+ import { narrowProps } from './narrow-props.js';
10
+ import { provideVirtualizedListScope, useVirtualizedListScope, } from './nested-scope.js';
11
+ import { userHandlersOf } from './user-handlers.js';
12
+ import { EMIT_KEYS, PROP_KEYS, } from './virtualized-list-props.js';
116
13
  export const VirtualizedList = defineComponent((props, { attrs, expose, emit, slots, }) => {
117
- // Bumped when a transition changes render-relevant state, so the `metrics` computed and the
118
- // render fn re-run (listState is plain, non-reactive, so mutating it triggers nothing itself).
119
14
  const version = ref(EMPTY_OFFSET);
120
15
  const separatorVersion = ref(EMPTY_OFFSET);
121
- // The offset we are imperatively driving native to. A fresh object identity each push so the
122
- // commit path re-applies it even when the numeric value repeats. undefined = none pending.
16
+ // A fresh object each push so the commit path re-applies a repeated value
123
17
  const commandedOffset = ref(undefined);
124
- // shallowRef, NOT ref: `<scroll-view>` is a tag now, so its `ref` hands back the raw engine
125
- // node directly, reached by identity through the engine's WeakMap mirror. A deep ref would
126
- // proxy the object and every imperative scroll (scrollToOffset/Index/…) would miss the node
127
- // and silently no-op.
18
+ // `<scroll-view>` is a tag, so its `ref` hands back the raw engine node, held by identity
128
19
  const scrollNodeRef = shallowRef(null);
129
20
  const setScrollNodeRef = (el) => {
130
21
  scrollNodeRef.value = isSymbioteNode(el) ? el : null;
131
22
  };
132
- // Built once: `getNode` is lazy, read on every call, so this stays valid across the ref
133
- // transitioning from null to the committed node.
134
23
  const scrollHandle = buildScrollViewHandle(() => scrollNodeRef.value);
135
- // The five list events are emits, so Vue strips their onX listeners from $attrs. Detect listener
136
- // presence off the instance's own vnode props (what the parent passed), exactly like FlatList, so
137
- // an emit bridge is wired ONLY when the consumer actually listens (preserving the RefreshControl /
138
- // viewability gating of the prop-callback era).
24
+ // The parent's own vnode props tell whether it listens, Vue strips emits from `$attrs`
139
25
  const instance = getCurrentInstance();
140
26
  const listens = (onName) => {
141
27
  const vnodeProps = instance?.vnode.props;
142
28
  return vnodeProps != null && typeof vnodeProps[onName] === 'function';
143
29
  };
144
- // The one folded state cell (the Vue twin of React's stateRef); plus the per-gap separator
145
- // overrides + the adapter-owned debounce/fill timers.
146
30
  const listState = createInitialListState();
147
31
  const separatorOverrides = new Map();
148
- let viewableTimer = null;
149
- let batchTimer = null;
150
- const narrowed = computed(() => {
151
- // extraData has no field of its own; reading it tracks it so a change forces a re-render
152
- // (RN's extraData contract).
153
- void props.extraData;
154
- const folded = normalizeVueAttrs(attrs);
155
- return {
156
- data: props.data,
157
- getItem: props.getItem,
158
- getItemCount: props.getItemCount,
159
- // Cell + chrome come from scoped slots. #item IS the render fn (a slot fn returning
160
- // VNode|VNode[], which the cell wrapper accepts directly). #separator carries scope props
161
- // (h(component, props)) so it goes through componentFromSlot; #header/#footer/#empty are
162
- // scopeless, so the bare slot fn is handed to resolveElement (its isComponent branch).
163
- renderItem: slots.item,
164
- keyExtractor: props.keyExtractor,
165
- getItemLayout: props.getItemLayout,
166
- itemSeparatorComponent: componentFromSlot(slots.separator),
167
- listHeaderComponent: slots.header,
168
- listFooterComponent: slots.footer,
169
- listEmptyComponent: slots.empty,
170
- horizontal: props.horizontal === true,
171
- inverted: props.inverted === true,
172
- onEndReached: listens('onEndReached')
173
- ? (info) => emit('endReached', info)
174
- : undefined,
175
- onEndReachedThreshold: typeof props.onEndReachedThreshold === 'number'
176
- ? props.onEndReachedThreshold
177
- : undefined,
178
- onStartReached: listens('onStartReached')
179
- ? (info) => emit('startReached', info)
180
- : undefined,
181
- onStartReachedThreshold: typeof props.onStartReachedThreshold === 'number'
182
- ? props.onStartReachedThreshold
183
- : undefined,
184
- onRefresh: listens('onRefresh')
185
- ? () => emit('refresh')
186
- : undefined,
187
- refreshing: props.refreshing === true,
188
- progressViewOffset: props.progressViewOffset,
189
- onViewableItemsChanged: listens('onViewableItemsChanged')
190
- ? (info) => emit('viewableItemsChanged', info)
191
- : undefined,
192
- viewabilityConfig: props.viewabilityConfig,
193
- viewabilityConfigCallbackPairs: props.viewabilityConfigCallbackPairs,
194
- onScrollToIndexFailed: listens('onScrollToIndexFailed')
195
- ? (info) => emit('scrollToIndexFailed', info)
196
- : undefined,
197
- initialNumToRender: asNumber(props.initialNumToRender, DEFAULT_INITIAL_NUM_TO_RENDER),
198
- initialScrollIndex: props.initialScrollIndex,
199
- maxToRenderPerBatch: asNumber(props.maxToRenderPerBatch, DEFAULT_MAX_TO_RENDER_PER_BATCH),
200
- updateCellsBatchingPeriod: asNumber(props.updateCellsBatchingPeriod, DEFAULT_UPDATE_CELLS_BATCHING_PERIOD),
201
- windowSize: asNumber(props.windowSize, DEFAULT_WINDOW_SIZE),
202
- stickyHeaderIndices: props.stickyHeaderIndices,
203
- maintainVisibleContentPosition: props.maintainVisibleContentPosition,
204
- userOnScroll: isHandler(folded.onScroll) ? folded.onScroll : undefined,
205
- style: props.style,
206
- contentContainerStyle: props.contentContainerStyle,
207
- keyboardShouldPersistTaps: props.keyboardShouldPersistTaps,
208
- keyboardDismissMode: props.keyboardDismissMode,
209
- forwarded: forwardAttrs(folded),
210
- };
211
- });
212
- // The reducer inputs, folded off `narrowed`. Rebuilt per call (cheap); the viewability pairs are
213
- // rebuilt here so the reduce and the effect fire against the same set.
214
- const buildInputs = () => {
215
- const p = narrowed.value;
216
- return {
217
- data: p.data,
218
- getItem: p.getItem,
219
- getItemCount: p.getItemCount,
220
- keyExtractor: p.keyExtractor,
221
- getItemLayout: p.getItemLayout,
222
- horizontal: p.horizontal,
223
- windowSize: p.windowSize,
224
- initialNumToRender: p.initialNumToRender,
225
- maxToRenderPerBatch: p.maxToRenderPerBatch,
226
- updateCellsBatchingPeriod: p.updateCellsBatchingPeriod,
227
- onEndReachedThreshold: p.onEndReachedThreshold,
228
- onStartReachedThreshold: p.onStartReachedThreshold,
229
- onEndReachedActive: p.onEndReached !== undefined,
230
- onStartReachedActive: p.onStartReached !== undefined,
231
- viewabilityPairs: buildViewabilityPairs(p.onViewableItemsChanged, p.viewabilityConfig, p.viewabilityConfigCallbackPairs),
232
- maintainVisibleContentPosition: p.maintainVisibleContentPosition,
233
- initialScrollIndex: p.initialScrollIndex,
234
- };
235
- };
32
+ const viewableTimer = { current: null };
33
+ const batchTimer = { current: null };
34
+ const narrowed = computed(() => narrowProps({ props, attrs, slots, emit, listens }));
35
+ const viewabilityPairs = computed(() => buildViewabilityPairs(narrowed.value.onViewableItemsChanged, narrowed.value.viewabilityConfig, narrowed.value.viewabilityConfigCallbackPairs));
36
+ const buildInputs = () => buildListReducerInputs({
37
+ ...narrowed.value,
38
+ findFirstChildWithMore: (first, last) => nesting.findFirstChildWithMore(first, last),
39
+ }, viewabilityPairs.value);
236
40
  const keyFor = (index) => {
237
41
  const p = narrowed.value;
238
42
  return resolveItemKey(p.getItem(p.data, index), index, p.keyExtractor);
239
43
  };
240
44
  const scrollToPixel = (offset, animated) => {
241
- const p = narrowed.value;
242
- const clamped = Math.max(EMPTY_OFFSET, offset);
243
- const targetOffset = p.horizontal
244
- ? { x: clamped, y: EMPTY_OFFSET }
245
- : { x: EMPTY_OFFSET, y: clamped };
246
- if (scrollNodeRef.value !== null) {
247
- dlog(`Vue VirtualizedList scrollTo offset=${clamped} animated=${animated} (horizontal=${p.horizontal})`);
248
- scrollHandle.scrollTo({
249
- x: targetOffset.x,
250
- y: targetOffset.y,
251
- animated,
252
- });
45
+ const target = scrollTargetOf(offset, narrowed.value.horizontal);
46
+ if (scrollNodeRef.value === null) {
47
+ dlog(`Vue VirtualizedList scrollTo offset=${offset} pending-ref`);
48
+ commandedOffset.value = target;
253
49
  return;
254
50
  }
255
- dlog(`Vue VirtualizedList scrollTo offset=${clamped} pending-ref`);
256
- commandedOffset.value = targetOffset;
51
+ dlog(`Vue VirtualizedList scrollTo offset=${offset} animated=${animated}`);
52
+ scrollHandle.scrollTo({ x: target.x, y: target.y, animated });
257
53
  };
258
- // Execute the reducer's effects with Vue primitives. `inputs` is threaded through so a
259
- // fire-viewable fires against the same pairs the reduce used.
260
- const runEffects = (effects, inputs) => {
261
- const p = narrowed.value;
262
- for (const effect of effects) {
263
- switch (effect.kind) {
264
- case 'scroll-to':
265
- scrollToPixel(effect.offset, effect.animated);
266
- break;
267
- case 'fire-end-reached':
268
- p.onEndReached?.({ distanceFromEnd: effect.distanceFromEnd });
269
- break;
270
- case 'fire-start-reached':
271
- p.onStartReached?.({ distanceFromStart: effect.distanceFromStart });
272
- break;
273
- case 'fire-scroll-to-index-failed':
274
- p.onScrollToIndexFailed?.({
275
- index: effect.index,
276
- highestMeasuredFrameIndex: effect.highestMeasuredFrameIndex,
277
- averageItemLength: effect.averageItemLength,
278
- });
279
- break;
280
- case 'schedule-refill': {
281
- if (batchTimer !== null)
282
- clearTimeout(batchTimer);
283
- batchTimer = setTimeout(() => {
284
- batchTimer = null;
285
- dispatch({ kind: 'batch-tick' });
286
- }, effect.delay);
287
- break;
288
- }
289
- case 'fire-viewable': {
290
- const pairs = inputs.viewabilityPairs;
291
- const info = effect.info;
292
- const map = effect.map;
293
- const fire = () => {
294
- for (const pair of pairs) {
295
- pair.onViewableItemsChanged({
296
- ...info,
297
- viewabilityConfig: pair.viewabilityConfig,
298
- });
299
- }
300
- dispatch({ kind: 'viewable-fired', map });
301
- };
302
- if (viewableTimer !== null) {
303
- clearTimeout(viewableTimer);
304
- viewableTimer = null;
305
- }
306
- if (effect.delay > EMPTY_OFFSET) {
307
- viewableTimer = setTimeout(() => {
308
- viewableTimer = null;
309
- fire();
310
- }, effect.delay);
311
- }
312
- else {
313
- fire();
314
- }
315
- break;
316
- }
317
- }
318
- }
54
+ const effectHost = {
55
+ callbacks: () => ({
56
+ onEndReached: narrowed.value.onEndReached,
57
+ onStartReached: narrowed.value.onStartReached,
58
+ onScrollToIndexFailed: narrowed.value.onScrollToIndexFailed,
59
+ viewabilityPairs: viewabilityPairs.value,
60
+ }),
61
+ scrollToPixel,
62
+ dispatch: () => dispatch,
63
+ viewableTimer,
64
+ batchTimer,
319
65
  };
320
66
  const dispatch = (action) => {
321
- const inputs = buildInputs();
322
- const result = reduceList(listState, action, inputs);
323
- runEffects(result.effects, inputs);
67
+ const result = reduceList(listState, action, buildInputs());
68
+ runListEffects(result.effects, effectHost);
324
69
  if (result.changed)
325
70
  version.value += 1;
326
71
  };
327
- // The single derive-per-render: the window is recomputed once here (refresh-metrics) and cached
328
- // by the computed until `version` or the narrowed props change, so the render fn and the commit
329
- // signature both read it without re-deriving (which would double-advance the throttle).
72
+ // The one window recompute, cached so the render and the commit signature both read it
73
+ // without re-deriving (which would double-advance the throttle)
330
74
  const metrics = computed(() => {
331
75
  void version.value;
332
- reduceList(listState, { kind: 'refresh-metrics' }, buildInputs());
76
+ reduceList(listState, { kind: LIST_ACTION_KIND.refreshMetrics }, buildInputs());
333
77
  return listState.metrics;
334
78
  });
335
79
  const commitSignature = computed(() => {
336
80
  void metrics.value;
337
81
  return listEffectSignature(listState);
338
82
  });
339
- const onScroll = (event) => {
340
- const p = narrowed.value;
341
- const offset = readScrollOffset(event, p.horizontal);
342
- if (offset === undefined)
343
- return;
344
- dlog(`Vue VirtualizedList onScroll offset=${offset}`);
345
- // A real native scroll supersedes any pending commanded offset.
346
- commandedOffset.value = undefined;
347
- dispatch({ kind: 'scroll', offset });
348
- // Compose, don't clobber: internal windowing ran first, now the user's onScroll.
349
- if (p.userOnScroll !== undefined)
350
- p.userOnScroll(event);
351
- };
352
- const onViewportLayout = (event) => {
353
- const length = readLayoutLength(event, narrowed.value.horizontal);
354
- if (length === undefined)
355
- return;
356
- dlog(`Vue VirtualizedList onLayout viewport=${length}`);
357
- dispatch({ kind: 'layout', length });
358
- };
359
- const makeCellMeasure = (index) => (event) => {
360
- const length = readLayoutLength(event, narrowed.value.horizontal);
361
- if (length === undefined)
362
- return;
363
- const offset = readLayoutOffset(event, narrowed.value.horizontal);
364
- dlog(`Vue VirtualizedList cell ${index} measured length=${length} offset=${offset ?? 'none'}`);
365
- dispatch({ kind: 'measure', index, length, offset });
366
- };
83
+ // The list above hands its scope down, this list hands its own to the lists in its cells
84
+ const nesting = createListNesting({
85
+ parent: useVirtualizedListScope(),
86
+ horizontal: narrowed.value.horizontal,
87
+ getState: () => listState,
88
+ dispatch,
89
+ getContainerNode: () => scrollNodeRef.value,
90
+ getHasMore: () => listHasMore(listState),
91
+ keyFor,
92
+ handlers: () => handlers,
93
+ });
94
+ provideVirtualizedListScope(nesting.scope);
95
+ const handlers = createListHandlers({
96
+ isHorizontal: () => narrowed.value.horizontal,
97
+ user: () => userHandlersOf(narrowed.value),
98
+ nesting: () => nesting,
99
+ dispatch,
100
+ clearCommandedOffset: () => {
101
+ commandedOffset.value = undefined;
102
+ },
103
+ getNode: () => scrollNodeRef.value,
104
+ keyFor,
105
+ });
106
+ const makeCellFocus = (index) => () => dispatch({ kind: LIST_ACTION_KIND.cellFocused, index });
367
107
  const mergeSeparator = (gapIndex, patch) => {
368
108
  if (!isSeparatorGapInRange(gapIndex, listState.metrics.count))
369
109
  return;
@@ -373,246 +113,78 @@ export const VirtualizedList = defineComponent((props, { attrs, expose, emit, sl
373
113
  });
374
114
  separatorVersion.value += 1;
375
115
  };
376
- const makeSeparators = (index) => ({
377
- highlight: () => {
378
- dlog(`Vue VirtualizedList separator highlight cell=${index}`);
379
- mergeSeparator(index - 1, { highlighted: true });
380
- mergeSeparator(index, { highlighted: true });
381
- },
382
- unhighlight: () => {
383
- dlog(`Vue VirtualizedList separator unhighlight cell=${index}`);
384
- mergeSeparator(index - 1, { highlighted: false });
385
- mergeSeparator(index, { highlighted: false });
386
- },
387
- updateProps: (select, newProps) => {
388
- mergeSeparator(select === 'leading' ? index - 1 : index, newProps);
389
- },
390
- });
391
- // ---- imperative handle (the shared IVirtualizedListHandle surface) ------
392
- const handle = {
393
- scrollToOffset: (params) => {
394
- dispatch({
395
- kind: 'scroll-to-offset',
396
- offset: params.offset,
397
- animated: params.animated ?? true,
398
- });
399
- },
400
- scrollToIndex: (params) => {
401
- dispatch({
402
- kind: 'scroll-to-index',
403
- index: params.index,
404
- animated: params.animated ?? true,
405
- viewPosition: params.viewPosition ?? FIRST_INDEX,
406
- viewOffset: params.viewOffset ?? EMPTY_OFFSET,
407
- });
408
- },
409
- scrollToItem: (params) => {
410
- dispatch({
411
- kind: 'scroll-to-item',
412
- item: params.item,
413
- animated: params.animated ?? true,
414
- viewPosition: params.viewPosition ?? FIRST_INDEX,
415
- });
416
- },
417
- scrollToEnd: (params) => {
418
- dispatch({ kind: 'scroll-to-end', animated: params?.animated ?? true });
419
- },
420
- flashScrollIndicators: () => {
421
- scrollHandle.flashScrollIndicators();
422
- },
423
- getNativeScrollRef: () => scrollNodeRef.value !== null ? scrollHandle : null,
424
- getScrollableNode: () => scrollNodeRef.value !== null ? scrollHandle : null,
425
- getScrollResponder: () => scrollNodeRef.value !== null ? scrollHandle : null,
426
- getScrollNode: () => scrollNodeRef.value,
427
- recordInteraction: () => {
428
- dispatch({ kind: 'record-interaction' });
429
- },
430
- };
431
- expose(handle);
432
- // ---- after-commit pass (one post-flush watcher, the Vue twin of the layout effect) ----
433
- // Runs the deferred effects (batch fill, edge-reached, viewability, initial-scroll, MVCP)
434
- // whenever the windowing signature changes — the same dedup key every adapter shares.
116
+ const makeSeparators = (index) => buildSeparatorHandles(index, mergeSeparator);
117
+ expose(buildListHandle({
118
+ dispatch,
119
+ scrollHandle,
120
+ getNode: () => scrollNodeRef.value,
121
+ }));
122
+ // The after-commit pass, a post-flush watcher on the windowing signature
435
123
  watch(commitSignature, () => {
436
- const inputs = buildInputs();
437
- const result = reduceList(listState, { kind: 'commit' }, inputs);
438
- runEffects(result.effects, inputs);
124
+ const result = reduceList(listState, { kind: LIST_ACTION_KIND.commit }, buildInputs());
125
+ runListEffects(result.effects, effectHost);
439
126
  }, { flush: 'post' });
440
127
  onBeforeUnmount(() => {
441
- if (viewableTimer !== null)
442
- clearTimeout(viewableTimer);
443
- if (batchTimer !== null)
444
- clearTimeout(batchTimer);
128
+ clearTimers([viewableTimer, batchTimer]);
129
+ nesting.detach();
445
130
  });
446
131
  return () => {
447
132
  const p = narrowed.value;
448
133
  const m = metrics.value;
449
- // Read the version refs so a separator/measurement bump re-renders.
134
+ // Reading the version makes a separator bump re-render
450
135
  void separatorVersion.value;
451
- if (p.renderItem === undefined)
452
- dlog('Vue VirtualizedList: no #item slot provided — cells render empty');
453
- dlog(`Vue VirtualizedList window [${m.first}, ${m.last}] of ${m.count} ` +
454
- `(offset=${listState.scrollOffset}, viewport=${listState.viewportLength}, rendered=${Math.max(0, m.last - m.first + 1)})`);
455
- const children = [];
456
- const stickySet = p.stickyHeaderIndices !== undefined
457
- ? new Set(p.stickyHeaderIndices)
458
- : undefined;
459
- const header = resolveElement(p.listHeaderComponent);
460
- if (header !== undefined) {
461
- children.push(h('view', { key: 'list-header' }, [header]));
462
- }
463
- if (m.count === FIRST_INDEX) {
464
- const empty = resolveElement(p.listEmptyComponent);
465
- if (empty !== undefined) {
466
- children.push(h('view', { key: 'list-empty' }, [empty]));
467
- }
468
- }
469
- else {
470
- const plan = buildListPlan({
471
- count: m.count,
472
- first: m.first,
473
- last: m.last,
474
- offsets: m.offsets,
475
- lengths: m.lengths,
476
- total: m.total,
477
- keyFor,
478
- stickyIndices: stickySet,
479
- hasHeader: header !== undefined,
480
- });
481
- // `plan.stickyChildPositions` is deliberately NOT read — it was the input to the index form.
482
- if (plan.leadingExtent > EMPTY_OFFSET) {
483
- children.push(h('view', {
484
- key: 'spacer-leading',
485
- style: p.horizontal
486
- ? { width: plan.leadingExtent }
487
- : { height: plan.leadingExtent },
488
- }));
489
- }
490
- const cellInverted = p.inverted
491
- ? p.horizontal
492
- ? INVERTED_X_STYLE
493
- : INVERTED_Y_STYLE
494
- : undefined;
495
- // The separator lives INSIDE the cell's own measuring wrapper, not as a sibling: a sibling
496
- // is an extra flex child, so a leading-spacer collapse would land every cell below it
497
- // short by (separator + gap). See .claude/rules/list-geometry-feedback-loop.md.
498
- const pushCell = (cell) => {
499
- const item = p.getItem(p.data, cell.index);
500
- const content = p.renderItem?.({
501
- item,
502
- index: cell.index,
503
- separators: makeSeparators(cell.index),
504
- });
505
- const separator =
506
- // Gated on the last index of the DATA, not the WINDOW: since the separator lives
507
- // inside the measuring wrapper, gating on the window would make a cell's own
508
- // measured height change as the window slides past it.
509
- cell.index < m.count - 1
510
- ? renderSeparatorElement(p.itemSeparatorComponent, item, p.getItem(p.data, cell.index + 1), separatorOverrides.get(cell.index))
511
- : undefined;
512
- children.push(h(
513
- // A cell the app flagged sticky IS the `sticky-header` tag — same position, same key,
514
- // same `onLayout` (the behavior forwards it rather than replacing it). The collision
515
- // point then comes from the owner's DOCUMENT order, which is the one form that
516
- // survives windowing; `stickyHeaderIndices` numbers paint children and does not.
517
- stickySet?.has(cell.index) === true ? 'sticky-header' : 'view', {
518
- key: `cell-${cell.key}`,
519
- onLayout: makeCellMeasure(cell.index),
520
- style: cellInverted,
521
- }, separator === undefined ? [content] : [content, separator]));
522
- };
523
- // The nearest sticky section header below the window (RN _ensureClosestStickyHeader):
524
- // force-mounted so it never gets destroyed/recreated while pinned off-window. Rendered
525
- // ahead of the main window loop, with its own gap spacer to the window's first cell.
526
- if (plan.forcedStickyCell !== undefined) {
527
- pushCell(plan.forcedStickyCell);
528
- if (plan.gapExtent > EMPTY_OFFSET) {
529
- children.push(h('view', {
530
- key: 'spacer-gap',
531
- style: p.horizontal
532
- ? { width: plan.gapExtent }
533
- : { height: plan.gapExtent },
534
- }));
535
- }
536
- }
537
- for (let cellPos = 0; cellPos < plan.cells.length; cellPos += 1) {
538
- pushCell(plan.cells[cellPos]);
539
- }
540
- if (plan.trailingExtent > EMPTY_OFFSET) {
541
- children.push(h('view', {
542
- key: 'spacer-trailing',
543
- style: p.horizontal
544
- ? { width: plan.trailingExtent }
545
- : { height: plan.trailingExtent },
546
- }));
547
- }
548
- }
549
- const footer = resolveElement(p.listFooterComponent);
550
- if (footer !== undefined) {
551
- children.push(h('view', { key: 'list-footer' }, [footer]));
136
+ if (p.renderItem === undefined && p.listItemComponent === undefined) {
137
+ dlog('Vue VirtualizedList: no #item slot provided, cells render empty');
552
138
  }
553
- const resolvedContentContainerStyle = p.horizontal
554
- ? [p.contentContainerStyle, { width: m.total }]
555
- : p.contentContainerStyle;
556
- // VirtualizedList.js: `[inversionStyle, style]` — the app's style can override the flip.
557
- const resolvedStyle = p.inverted
558
- ? [p.horizontal ? INVERTED_X_STYLE : INVERTED_Y_STYLE, p.style]
559
- : p.style;
560
- // `horizontal` is NOT in the bag: the axis is the TAG (`horizontal-scroll-view`), so passing
561
- // it would be redundant at best and a contradiction the behavior has to warn about at worst.
562
- const scrollProps = {
563
- ...p.forwarded,
564
- style: resolvedStyle,
565
- contentContainerStyle: resolvedContentContainerStyle,
566
- onScroll,
567
- onLayout: onViewportLayout,
568
- ref: setScrollNodeRef,
569
- };
570
- // The raw scroll-lifecycle callbacks (onScrollBeginDrag/…) and scrollEventThrottle are NOT in
571
- // PROP_KEYS, so they already ride through via ...p.forwarded. The keyboard props come from
572
- // typed props (PROP_KEYS), so re-set them explicitly here (only when provided).
573
- // VirtualizedList.js:1111 — Android moves the scrollbar back after the `scale: -1` flip.
574
- if (p.inverted)
575
- scrollProps.isInvertedVirtualizedList = true;
576
- if (p.keyboardShouldPersistTaps !== undefined) {
577
- scrollProps.keyboardShouldPersistTaps = p.keyboardShouldPersistTaps;
139
+ if (isDebug()) {
140
+ dlog(`Vue VirtualizedList window [${m.first}, ${m.last}] of ${m.count} ` +
141
+ `(offset=${listState.scrollOffset}, viewport=${listState.viewportLength})`);
578
142
  }
579
- if (p.keyboardDismissMode !== undefined) {
580
- scrollProps.keyboardDismissMode = p.keyboardDismissMode;
581
- }
582
- // A pending imperative/initial scroll rides down as contentOffset (fallback for the
583
- // pre-mount window before the handle attaches).
584
- if (commandedOffset.value !== undefined)
585
- scrollProps.contentOffset = commandedOffset.value;
586
- // `stickyHeaderIndices` is deliberately NOT forwarded — see `pushCell`. It numbers the scroll
587
- // view's PAINT children, and a windowed list paints a header, a spacer and a slice, so the
588
- // positions move every time the window slides and the behavior re-wraps a different child
589
- // each pass. The cells carry the tag instead.
590
- // Forward maintainVisibleContentPosition to the ScrollView so it anchors the in-window cells.
591
- // minIndexForVisible is bumped by 1 when a ListHeaderComponent occupies child 0.
592
- if (p.maintainVisibleContentPosition !== undefined) {
593
- scrollProps.maintainVisibleContentPosition = {
594
- ...p.maintainVisibleContentPosition,
595
- minIndexForVisible: p.maintainVisibleContentPosition.minIndexForVisible +
596
- (header !== undefined ? 1 : 0),
597
- };
598
- }
599
- // Pull-to-refresh: when a @refresh listener is present, the RefreshControl is an ordinary
600
- // FIRST CHILD on both platforms rather than a prop — the scroll behavior CLAIMS it, so the
601
- // engine keeps it beside the content view on iOS and inverts the tree on Android. The
602
- // onRefresh bridge is gated on the listener, so an unlistened list builds no control.
603
- let refreshControl;
604
- if (p.onRefresh !== undefined) {
605
- dlog('Vue VirtualizedList wiring RefreshControl (@refresh listened)');
606
- refreshControl = h('refresh-control', {
607
- refreshing: p.refreshing,
608
- onRefresh: p.onRefresh,
609
- progressViewOffset: p.progressViewOffset,
610
- });
143
+ const { children, hasHeader } = buildListChildren({
144
+ props: p,
145
+ metrics: m,
146
+ keyFor,
147
+ separatorOverrides,
148
+ makeSeparators,
149
+ makeCellMeasure: handlers.makeCellMeasure,
150
+ makeCellFocus,
151
+ });
152
+ const scrollProps = buildScrollProps({
153
+ props: p,
154
+ total: m.total,
155
+ hasHeader,
156
+ onScroll: handlers.onScroll,
157
+ onScrollBeginDrag: handlers.onScrollBeginDrag,
158
+ onScrollEndDrag: handlers.onScrollEndDrag,
159
+ onMomentumScrollBegin: handlers.onMomentumScrollBegin,
160
+ onMomentumScrollEnd: handlers.onMomentumScrollEnd,
161
+ onContentSizeChange: handlers.onContentSizeChange,
162
+ onLayout: handlers.onViewportLayout,
163
+ setRef: setScrollNodeRef,
164
+ commandedOffset: commandedOffset.value,
165
+ });
166
+ // The list above scrolls, so RN renders a `View` with no content container
167
+ if (nesting.isNested) {
168
+ return h('view', {
169
+ ...viewPropsOf(p.forwarded),
170
+ style: p.style,
171
+ onLayout: handlers.onViewportLayout,
172
+ ref: setScrollNodeRef,
173
+ }, children);
611
174
  }
612
- // The scroll TAG, not a wrapper: the engine builds the content node, carries
613
- // `contentContainerStyle` onto it and places the refresh control. `ref` hands back the
614
- // engine node, which is what `buildScrollViewHandle` drives for the imperative scrolls.
615
- return h(p.horizontal ? 'horizontal-scroll-view' : 'scroll-view', scrollProps, refreshControl === undefined ? children : [refreshControl, ...children]);
175
+ // With a `@refresh` listener the RefreshControl is an ordinary first child, the scroll
176
+ // behavior claims it and places it per platform
177
+ const refreshControl = p.onRefresh === undefined
178
+ ? []
179
+ : [
180
+ h('refresh-control', {
181
+ refreshing: p.refreshing,
182
+ onRefresh: p.onRefresh,
183
+ progressViewOffset: p.progressViewOffset,
184
+ }),
185
+ ];
186
+ // The scroll TAG, not a wrapper: the engine builds the content node and places the control
187
+ return h(p.horizontal ? 'horizontal-scroll-view' : 'scroll-view', scrollProps, [...refreshControl, ...children]);
616
188
  };
617
189
  }, {
618
190
  name: 'VirtualizedList',