@symbiote-native/vue 0.3.7 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,12 @@
1
- // VirtualizedList, the Vue lifecycle half. The windowing engine (offset table, window
2
- // compute, batch throttle, viewability, edge-reached, the child PLAN, the imperative-handle
3
- // surface) lives in @symbiote-native/components/state, shared verbatim with the React adapter. Here
4
- // Vue supplies only the reactivity: refs for scroll offset / viewport / measurement bumps, a
5
- // `computed` that runs the shared windowing math, post-flush watchers for the after-commit
6
- // work (batch fill, onEndReached/onStartReached, viewability, initialScroll, MVCP), and the
7
- // imperative handle via expose(). This is the Vue twin of the React adapter's
8
- // useState/useRef/useEffect over the same shared functions. It drives the Vue ScrollView,
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,
9
10
  // exactly as the React list drives the React ScrollView.
10
11
  //
11
12
  // Typed-emits generic component (mirrors FlatList): a GENERIC setup function
@@ -23,9 +24,10 @@
23
24
  //
24
25
  // Reactivity gotcha: the ScrollView's exposed handle is held in a shallowRef so the engine
25
26
  // node it closes over is reached by identity; a deep ref would proxy it and the imperative
26
- // scroll commands would silently no-op.
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.
27
29
  import { computed, defineComponent, getCurrentInstance, h, isVNode, onBeforeUnmount, ref, shallowRef, watch, } from '@vue/runtime-core';
28
- 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, maxMinimumViewTime, offsetForIndex, readLayoutLength, readScrollOffset, throttleWindow, } from '@symbiote-native/components';
30
+ 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';
29
31
  import { dlog, } from '@symbiote-native/engine';
30
32
  import { ScrollView } from '../scroll-view';
31
33
  import { RefreshControl } from '../refresh-control.js';
@@ -120,17 +122,13 @@ function isScrollViewHandle(value) {
120
122
  return isRecord(value) && typeof value.scrollTo === 'function';
121
123
  }
122
124
  export const VirtualizedList = defineComponent((props, { attrs, expose, emit, slots, }) => {
123
- // Reactive lifecycle state (the Vue twin of React's useState): a change in any of these
124
- // re-runs the windowing `computed` and the render fn.
125
- const scrollOffset = ref(EMPTY_OFFSET);
126
- const viewportLength = ref(EMPTY_OFFSET);
125
+ // Bumped when a transition changes render-relevant state, so the `metrics` computed and the
126
+ // render fn re-run (listState is plain, non-reactive, so mutating it triggers nothing itself).
127
+ const version = ref(EMPTY_OFFSET);
128
+ const separatorVersion = ref(EMPTY_OFFSET);
127
129
  // The offset we are imperatively driving native to. A fresh object identity each push so the
128
130
  // commit path re-applies it even when the numeric value repeats. undefined = none pending.
129
131
  const commandedOffset = ref(undefined);
130
- // Bumped when a NEW cell measurement lands or a batch tick fires, to request a re-render
131
- // without thrashing on already-known cells (React's measuredRef + setMeasureVersion).
132
- const measureVersion = ref(EMPTY_OFFSET);
133
- const separatorVersion = ref(EMPTY_OFFSET);
134
132
  // shallowRef, NOT ref: the ScrollView handle closes over the engine scroll node, reached by
135
133
  // identity through the engine's WeakMap mirror. A deep ref would proxy the object and every
136
134
  // imperative scroll (scrollToOffset/Index/…) would miss the node and silently no-op.
@@ -147,20 +145,12 @@ export const VirtualizedList = defineComponent((props, { attrs, expose, emit, sl
147
145
  const vnodeProps = instance?.vnode.props;
148
146
  return vnodeProps != null && typeof vnodeProps[onName] === 'function';
149
147
  };
150
- // Non-reactive setup-scope state (the Vue twin of React's refs that never trigger a render):
151
- const measured = new Map();
152
- // The previously committed window, so throttleWindow grows it by at most maxToRenderPerBatch
153
- // per tick instead of snapping.
154
- let committedWindow = { first: FIRST_INDEX, last: -1 };
155
- let sentEndForContentLength = NO_CONTENT_LENGTH_SENT;
156
- let sentStartForContentLength = NO_CONTENT_LENGTH_SENT;
157
- let lastViewable = new Map();
148
+ // The one folded state cell (the Vue twin of React's stateRef); plus the per-gap separator
149
+ // overrides + the adapter-owned debounce/fill timers.
150
+ const listState = createInitialListState();
151
+ const separatorOverrides = new Map();
158
152
  let viewableTimer = null;
159
153
  let batchTimer = null;
160
- let hasInteracted = false;
161
- const separatorOverrides = new Map();
162
- let appliedInitialScroll = false;
163
- let firstVisibleKey = null;
164
154
  const narrowed = computed(() => {
165
155
  // extraData has no field of its own; reading it tracks it so a change forces a re-render
166
156
  // (RN's extraData contract).
@@ -217,47 +207,34 @@ export const VirtualizedList = defineComponent((props, { attrs, expose, emit, sl
217
207
  forwarded: forwardAttrs(folded),
218
208
  };
219
209
  });
220
- const keyFor = (index) => {
210
+ // The reducer inputs, folded off `narrowed`. Rebuilt per call (cheap); the viewability pairs are
211
+ // rebuilt here so the reduce and the effect fire against the same set.
212
+ const buildInputs = () => {
221
213
  const p = narrowed.value;
222
- const item = p.getItem(p.data, index);
223
- return p.keyExtractor ? p.keyExtractor(item, index) : String(index);
224
- };
225
- // The windowing math, run through the shared @symbiote-native/components functions. It mutates the
226
- // non-reactive committedWindow as it throttles (the controlled side effect React runs during
227
- // render): committedWindow is plain state, so the write triggers no reactivity loop.
228
- const metrics = computed(() => {
229
- const p = narrowed.value;
230
- void measureVersion.value;
231
- const count = p.getItemCount(p.data);
232
- const gil = p.getItemLayout;
233
- const data = p.data;
234
- const fixedLayout = gil !== undefined
235
- ? (index) => {
236
- const layout = gil(data, index);
237
- return { length: layout.length, offset: layout.offset };
238
- }
239
- : undefined;
240
- const averageLength = fixedLayout !== undefined
241
- ? count > FIRST_INDEX
242
- ? fixedLayout(FIRST_INDEX).length
243
- : EMPTY_OFFSET
244
- : averageMeasuredLength(measured);
245
- const { offsets, lengths, total } = buildOffsets(count, measured, fixedLayout, averageLength);
246
- const target = computeWindow(count, offsets, lengths, scrollOffset.value, viewportLength.value, p.windowSize, p.initialNumToRender);
247
- const throttled = throttleWindow(target, committedWindow, p.maxToRenderPerBatch);
248
- committedWindow = throttled;
249
214
  return {
250
- count,
251
- offsets,
252
- lengths,
253
- total,
254
- first: throttled.first,
255
- last: throttled.last,
256
- target,
257
- fixedLayout,
258
- averageLength,
215
+ data: p.data,
216
+ getItem: p.getItem,
217
+ getItemCount: p.getItemCount,
218
+ keyExtractor: p.keyExtractor,
219
+ getItemLayout: p.getItemLayout,
220
+ horizontal: p.horizontal,
221
+ windowSize: p.windowSize,
222
+ initialNumToRender: p.initialNumToRender,
223
+ maxToRenderPerBatch: p.maxToRenderPerBatch,
224
+ updateCellsBatchingPeriod: p.updateCellsBatchingPeriod,
225
+ onEndReachedThreshold: p.onEndReachedThreshold,
226
+ onStartReachedThreshold: p.onStartReachedThreshold,
227
+ onEndReachedActive: p.onEndReached !== undefined,
228
+ onStartReachedActive: p.onStartReached !== undefined,
229
+ viewabilityPairs: buildViewabilityPairs(p.onViewableItemsChanged, p.viewabilityConfig, p.viewabilityConfigCallbackPairs),
230
+ maintainVisibleContentPosition: p.maintainVisibleContentPosition,
231
+ initialScrollIndex: p.initialScrollIndex,
259
232
  };
260
- });
233
+ };
234
+ const keyFor = (index) => {
235
+ const p = narrowed.value;
236
+ return resolveItemKey(p.getItem(p.data, index), index, p.keyExtractor);
237
+ };
261
238
  const scrollToPixel = (offset, animated) => {
262
239
  const p = narrowed.value;
263
240
  const clamped = Math.max(EMPTY_OFFSET, offset);
@@ -273,21 +250,92 @@ export const VirtualizedList = defineComponent((props, { attrs, expose, emit, sl
273
250
  dlog(`Vue VirtualizedList scrollTo offset=${clamped} pending-ref`);
274
251
  commandedOffset.value = targetOffset;
275
252
  };
276
- const offsetForIndexLocal = (index, viewPosition, viewOffset) => {
277
- const m = metrics.value;
278
- return offsetForIndex(index, viewPosition, viewOffset, m.count, m.offsets, m.lengths, viewportLength.value);
253
+ // Execute the reducer's effects with Vue primitives. `inputs` is threaded through so a
254
+ // fire-viewable fires against the same pairs the reduce used.
255
+ const runEffects = (effects, inputs) => {
256
+ const p = narrowed.value;
257
+ for (const effect of effects) {
258
+ switch (effect.kind) {
259
+ case 'scroll-to':
260
+ scrollToPixel(effect.offset, effect.animated);
261
+ break;
262
+ case 'fire-end-reached':
263
+ p.onEndReached?.({ distanceFromEnd: effect.distanceFromEnd });
264
+ break;
265
+ case 'fire-start-reached':
266
+ p.onStartReached?.({ distanceFromStart: effect.distanceFromStart });
267
+ break;
268
+ case 'fire-scroll-to-index-failed':
269
+ p.onScrollToIndexFailed?.({
270
+ index: effect.index,
271
+ highestMeasuredFrameIndex: effect.highestMeasuredFrameIndex,
272
+ averageItemLength: effect.averageItemLength,
273
+ });
274
+ break;
275
+ case 'schedule-refill': {
276
+ if (batchTimer !== null)
277
+ clearTimeout(batchTimer);
278
+ batchTimer = setTimeout(() => {
279
+ batchTimer = null;
280
+ dispatch({ kind: 'batch-tick' });
281
+ }, effect.delay);
282
+ break;
283
+ }
284
+ case 'fire-viewable': {
285
+ const pairs = inputs.viewabilityPairs;
286
+ const info = effect.info;
287
+ const map = effect.map;
288
+ const fire = () => {
289
+ for (const pair of pairs)
290
+ pair.onViewableItemsChanged(info);
291
+ dispatch({ kind: 'viewable-fired', map });
292
+ };
293
+ if (viewableTimer !== null) {
294
+ clearTimeout(viewableTimer);
295
+ viewableTimer = null;
296
+ }
297
+ if (effect.delay > EMPTY_OFFSET) {
298
+ viewableTimer = setTimeout(() => {
299
+ viewableTimer = null;
300
+ fire();
301
+ }, effect.delay);
302
+ }
303
+ else {
304
+ fire();
305
+ }
306
+ break;
307
+ }
308
+ }
309
+ }
310
+ };
311
+ const dispatch = (action) => {
312
+ const inputs = buildInputs();
313
+ const result = reduceList(listState, action, inputs);
314
+ runEffects(result.effects, inputs);
315
+ if (result.changed)
316
+ version.value += 1;
279
317
  };
318
+ // The single derive-per-render: the window is recomputed once here (refresh-metrics) and cached
319
+ // by the computed until `version` or the narrowed props change, so the render fn and the commit
320
+ // signature both read it without re-deriving (which would double-advance the throttle).
321
+ const metrics = computed(() => {
322
+ void version.value;
323
+ reduceList(listState, { kind: 'refresh-metrics' }, buildInputs());
324
+ return listState.metrics;
325
+ });
326
+ const commitSignature = computed(() => {
327
+ void metrics.value;
328
+ return listEffectSignature(listState);
329
+ });
280
330
  const onScroll = (event) => {
281
331
  const p = narrowed.value;
282
332
  const offset = readScrollOffset(event, p.horizontal);
283
333
  if (offset === undefined)
284
334
  return;
285
335
  dlog(`Vue VirtualizedList onScroll offset=${offset}`);
286
- // First scroll is the interaction that ungates waitForInteraction configs.
287
- hasInteracted = true;
288
- scrollOffset.value = offset;
289
336
  // A real native scroll supersedes any pending commanded offset.
290
337
  commandedOffset.value = undefined;
338
+ dispatch({ kind: 'scroll', offset });
291
339
  // Compose, don't clobber: internal windowing ran first, now the user's onScroll.
292
340
  if (p.userOnScroll !== undefined)
293
341
  p.userOnScroll(event);
@@ -297,24 +345,17 @@ export const VirtualizedList = defineComponent((props, { attrs, expose, emit, sl
297
345
  if (length === undefined)
298
346
  return;
299
347
  dlog(`Vue VirtualizedList onLayout viewport=${length}`);
300
- viewportLength.value = length;
348
+ dispatch({ kind: 'layout', length });
301
349
  };
302
350
  const makeCellMeasure = (index) => (event) => {
303
- const p = narrowed.value;
304
- if (p.getItemLayout !== undefined)
305
- return;
306
- const length = readLayoutLength(event, p.horizontal);
351
+ const length = readLayoutLength(event, narrowed.value.horizontal);
307
352
  if (length === undefined)
308
353
  return;
309
- if (measured.get(index) === length)
310
- return;
311
- measured.set(index, length);
312
354
  dlog(`Vue VirtualizedList cell ${index} measured length=${length}`);
313
- measureVersion.value += 1;
355
+ dispatch({ kind: 'measure', index, length });
314
356
  };
315
357
  const mergeSeparator = (gapIndex, patch) => {
316
- const count = metrics.value.count;
317
- if (gapIndex < FIRST_INDEX || gapIndex > count - 2)
358
+ if (!isSeparatorGapInRange(gapIndex, listState.metrics.count))
318
359
  return;
319
360
  separatorOverrides.set(gapIndex, { ...separatorOverrides.get(gapIndex), ...patch });
320
361
  separatorVersion.value += 1;
@@ -337,38 +378,31 @@ export const VirtualizedList = defineComponent((props, { attrs, expose, emit, sl
337
378
  // ---- imperative handle (the shared IVirtualizedListHandle surface) ------
338
379
  const handle = {
339
380
  scrollToOffset: (params) => {
340
- scrollToPixel(params.offset, params.animated ?? true);
381
+ dispatch({
382
+ kind: 'scroll-to-offset',
383
+ offset: params.offset,
384
+ animated: params.animated ?? true,
385
+ });
341
386
  },
342
387
  scrollToIndex: (params) => {
343
- const p = narrowed.value;
344
- const m = metrics.value;
345
- // No getItemLayout AND the target is past the last measured cell: report the failure
346
- // instead of scrolling to a fabricated estimate (RN VirtualizedList.js:179-195).
347
- if (p.getItemLayout === undefined && params.index > highestMeasuredIndex(measured)) {
348
- dlog(`Vue VirtualizedList onScrollToIndexFailed index=${params.index} ` +
349
- `highestMeasured=${highestMeasuredIndex(measured)} (no getItemLayout)`);
350
- p.onScrollToIndexFailed?.({
351
- index: params.index,
352
- highestMeasuredFrameIndex: highestMeasuredIndex(measured),
353
- averageItemLength: m.averageLength,
354
- });
355
- return;
356
- }
357
- scrollToPixel(offsetForIndexLocal(params.index, params.viewPosition ?? FIRST_INDEX, params.viewOffset ?? EMPTY_OFFSET), params.animated ?? true);
388
+ dispatch({
389
+ kind: 'scroll-to-index',
390
+ index: params.index,
391
+ animated: params.animated ?? true,
392
+ viewPosition: params.viewPosition ?? FIRST_INDEX,
393
+ viewOffset: params.viewOffset ?? EMPTY_OFFSET,
394
+ });
358
395
  },
359
396
  scrollToItem: (params) => {
360
- const p = narrowed.value;
361
- const count = metrics.value.count;
362
- for (let index = FIRST_INDEX; index < count; index += 1) {
363
- if (p.getItem(p.data, index) === params.item) {
364
- scrollToPixel(offsetForIndexLocal(index, params.viewPosition ?? FIRST_INDEX, EMPTY_OFFSET), params.animated ?? true);
365
- return;
366
- }
367
- }
368
- dlog('Vue VirtualizedList scrollToItem: item not found');
397
+ dispatch({
398
+ kind: 'scroll-to-item',
399
+ item: params.item,
400
+ animated: params.animated ?? true,
401
+ viewPosition: params.viewPosition ?? FIRST_INDEX,
402
+ });
369
403
  },
370
404
  scrollToEnd: (params) => {
371
- scrollToPixel(Math.max(EMPTY_OFFSET, metrics.value.total - viewportLength.value), params?.animated ?? true);
405
+ dispatch({ kind: 'scroll-to-end', animated: params?.animated ?? true });
372
406
  },
373
407
  flashScrollIndicators: () => {
374
408
  scrollHandle.value?.flashScrollIndicators();
@@ -378,167 +412,17 @@ export const VirtualizedList = defineComponent((props, { attrs, expose, emit, sl
378
412
  getScrollResponder: () => scrollHandle.value,
379
413
  getScrollNode: () => scrollHandle.value?.getScrollNode() ?? null,
380
414
  recordInteraction: () => {
381
- hasInteracted = true;
415
+ dispatch({ kind: 'record-interaction' });
382
416
  },
383
417
  };
384
418
  expose(handle);
385
- // ---- after-commit effects (post-flush watchers, the Vue twin of useEffect) ----
386
- // Batch fill: when the throttled window has not reached the target, schedule a re-render so
387
- // the window keeps filling toward target over successive ticks (React's batch effect).
388
- watch(metrics, m => {
389
- if (batchTimer !== null) {
390
- clearTimeout(batchTimer);
391
- batchTimer = null;
392
- }
393
- if (m.first <= m.target.first && m.last >= m.target.last)
394
- return;
395
- batchTimer = setTimeout(() => {
396
- batchTimer = null;
397
- measureVersion.value += 1;
398
- }, narrowed.value.updateCellsBatchingPeriod);
399
- }, { flush: 'post' });
400
- // onEndReached: fire only when the actual last cell is rendered AND within threshold; dedup by
401
- // content length; re-arm on scroll away from the end (RN _maybeCallOnEdgeReached).
402
- watch(() => [narrowed.value, scrollOffset.value, viewportLength.value, metrics.value], () => {
403
- const p = narrowed.value;
404
- if (p.onEndReached === undefined || viewportLength.value <= EMPTY_OFFSET)
405
- return;
406
- const m = metrics.value;
407
- const { distanceFromEnd, withinThreshold } = computeEndReached(m.total, scrollOffset.value, viewportLength.value, p.onEndReachedThreshold);
408
- const lastCellRendered = m.last === m.count - 1;
409
- if (withinThreshold && lastCellRendered && sentEndForContentLength !== m.total) {
410
- sentEndForContentLength = m.total;
411
- dlog(`Vue VirtualizedList onEndReached distanceFromEnd=${distanceFromEnd} ` +
412
- `(last=${m.last} of ${m.count}, contentLength=${m.total})`);
413
- p.onEndReached({ distanceFromEnd });
414
- }
415
- if (!withinThreshold)
416
- sentEndForContentLength = NO_CONTENT_LENGTH_SENT;
417
- }, { flush: 'post' });
418
- // onStartReached: the top-edge twin of onEndReached.
419
- watch(() => [narrowed.value, scrollOffset.value, viewportLength.value, metrics.value], () => {
420
- const p = narrowed.value;
421
- if (p.onStartReached === undefined || viewportLength.value <= EMPTY_OFFSET)
422
- return;
423
- const m = metrics.value;
424
- const { distanceFromStart, withinThreshold } = computeStartReached(scrollOffset.value, viewportLength.value, p.onStartReachedThreshold);
425
- const firstCellRendered = m.first === FIRST_INDEX;
426
- if (withinThreshold && firstCellRendered && sentStartForContentLength !== m.total) {
427
- sentStartForContentLength = m.total;
428
- dlog(`Vue VirtualizedList onStartReached distanceFromStart=${distanceFromStart} ` +
429
- `(first=${m.first}, contentLength=${m.total})`);
430
- p.onStartReached({ distanceFromStart });
431
- }
432
- if (!withinThreshold)
433
- sentStartForContentLength = NO_CONTENT_LENGTH_SENT;
434
- }, { flush: 'post' });
435
- // Viewability: recompute which rendered cells clear the threshold and, if the viewable set
436
- // changed, fire onViewableItemsChanged + every config/callback pair, honoring minimumViewTime.
437
- watch(() => [narrowed.value, scrollOffset.value, viewportLength.value, metrics.value], () => {
438
- const p = narrowed.value;
439
- const m = metrics.value;
440
- const pairs = buildViewabilityPairs(p.onViewableItemsChanged, p.viewabilityConfig, p.viewabilityConfigCallbackPairs);
441
- if (pairs.length === EMPTY_OFFSET || viewportLength.value <= EMPTY_OFFSET)
442
- return;
443
- if (m.count === FIRST_INDEX)
444
- return;
445
- const { tokens, map } = computeViewableSet({
446
- first: m.first,
447
- last: m.last,
448
- count: m.count,
449
- offsets: m.offsets,
450
- lengths: m.lengths,
451
- scrollOffset: scrollOffset.value,
452
- viewportLength: viewportLength.value,
453
- data: p.data,
454
- getItem: p.getItem,
455
- keyExtractor: p.keyExtractor,
456
- pairs,
457
- hasInteracted,
458
- });
459
- const diff = diffViewable(lastViewable, map, tokens);
460
- if (!diff.hasChanged)
461
- return;
462
- const commitAndFire = () => {
463
- lastViewable = map;
464
- dlog(`Vue VirtualizedList viewable=${tokens.length} changed=${diff.changed.length} ` +
465
- `(window [${m.first}, ${m.last}])`);
466
- const info = {
467
- viewableItems: tokens,
468
- changed: diff.changed,
469
- };
470
- for (const pair of pairs)
471
- pair.onViewableItemsChanged(info);
472
- };
473
- const minimumViewTime = maxMinimumViewTime(pairs);
474
- if (viewableTimer !== null) {
475
- clearTimeout(viewableTimer);
476
- viewableTimer = null;
477
- }
478
- if (minimumViewTime > EMPTY_OFFSET) {
479
- dlog(`Vue VirtualizedList viewability debounce ${minimumViewTime}ms (window [${m.first}, ${m.last}])`);
480
- viewableTimer = setTimeout(() => {
481
- viewableTimer = null;
482
- commitAndFire();
483
- }, minimumViewTime);
484
- return;
485
- }
486
- commitAndFire();
487
- }, { flush: 'post' });
488
- // initialScrollIndex: once the first viewport is known, jump to that index a single time.
489
- watch(() => [narrowed.value, viewportLength.value, metrics.value], () => {
490
- const p = narrowed.value;
491
- if (p.initialScrollIndex === undefined || appliedInitialScroll)
492
- return;
493
- if (viewportLength.value <= EMPTY_OFFSET || metrics.value.count === FIRST_INDEX)
494
- return;
495
- appliedInitialScroll = true;
496
- // The initial jump is instant (RN does not animate initialScrollIndex).
497
- scrollToPixel(offsetForIndexLocal(p.initialScrollIndex, FIRST_INDEX, EMPTY_OFFSET), false);
498
- }, { flush: 'post' });
499
- // maintainVisibleContentPosition JS anchor adjustment: track the key of the item at
500
- // minIndexForVisible; when a prepend moves it down, add the inserted extent in the leading
501
- // SPACER (the off-window items native MVCP cannot see) to scrollOffset so the anchored item
502
- // does not jump (RN getDerivedStateFromProps). Runs post-flush so the correction lands fast.
503
- watch(() => [narrowed.value, metrics.value, scrollOffset.value], () => {
504
- const p = narrowed.value;
505
- const m = metrics.value;
506
- if (p.maintainVisibleContentPosition === undefined || m.count === FIRST_INDEX) {
507
- firstVisibleKey = null;
508
- return;
509
- }
510
- const minIndexForVisible = p.maintainVisibleContentPosition.minIndexForVisible;
511
- const newFirstVisibleKey = m.count > minIndexForVisible ? keyFor(minIndexForVisible) : null;
512
- const prevKey = firstVisibleKey;
513
- if (prevKey !== null && newFirstVisibleKey !== null && prevKey !== newFirstVisibleKey) {
514
- let anchorIndex = -1;
515
- for (let index = minIndexForVisible; index < m.count; index += 1) {
516
- if (keyFor(index) === prevKey) {
517
- anchorIndex = index;
518
- break;
519
- }
520
- }
521
- if (anchorIndex > minIndexForVisible) {
522
- const spacerEnd = Math.min(anchorIndex, committedWindow.first);
523
- const insertedExtent = spacerEnd > minIndexForVisible
524
- ? m.offsets[spacerEnd] - m.offsets[minIndexForVisible]
525
- : EMPTY_OFFSET;
526
- if (insertedExtent > EMPTY_OFFSET) {
527
- const autoThreshold = p.maintainVisibleContentPosition.autoscrollToTopThreshold;
528
- const anchoredNearTop = autoThreshold !== undefined && scrollOffset.value <= autoThreshold;
529
- if (anchoredNearTop) {
530
- dlog(`Vue VirtualizedList MVCP autoscroll-to-top (offset=${scrollOffset.value} <= ${autoThreshold})`);
531
- scrollToPixel(EMPTY_OFFSET, true);
532
- }
533
- else {
534
- dlog(`Vue VirtualizedList MVCP adjust +${insertedExtent}px ` +
535
- `(anchor "${prevKey}" moved ${minIndexForVisible}->${anchorIndex})`);
536
- scrollToPixel(scrollOffset.value + insertedExtent, false);
537
- }
538
- }
539
- }
540
- }
541
- firstVisibleKey = newFirstVisibleKey;
419
+ // ---- after-commit pass (one post-flush watcher, the Vue twin of the layout effect) ----
420
+ // Runs the deferred effects (batch fill, edge-reached, viewability, initial-scroll, MVCP)
421
+ // whenever the windowing signature changes — the same dedup key every adapter shares.
422
+ watch(commitSignature, () => {
423
+ const inputs = buildInputs();
424
+ const result = reduceList(listState, { kind: 'commit' }, inputs);
425
+ runEffects(result.effects, inputs);
542
426
  }, { flush: 'post' });
543
427
  onBeforeUnmount(() => {
544
428
  if (viewableTimer !== null)
@@ -554,7 +438,7 @@ export const VirtualizedList = defineComponent((props, { attrs, expose, emit, sl
554
438
  if (p.renderItem === undefined)
555
439
  dlog('Vue VirtualizedList: no #item slot provided — cells render empty');
556
440
  dlog(`Vue VirtualizedList window [${m.first}, ${m.last}] of ${m.count} ` +
557
- `(offset=${scrollOffset.value}, viewport=${viewportLength.value}, rendered=${Math.max(0, m.last - m.first + 1)})`);
441
+ `(offset=${listState.scrollOffset}, viewport=${listState.viewportLength}, rendered=${Math.max(0, m.last - m.first + 1)})`);
558
442
  const children = [];
559
443
  const stickySet = p.stickyHeaderIndices !== undefined ? new Set(p.stickyHeaderIndices) : undefined;
560
444
  const header = resolveElement(p.listHeaderComponent);
@@ -593,8 +477,9 @@ export const VirtualizedList = defineComponent((props, { attrs, expose, emit, sl
593
477
  ? INVERTED_X_STYLE
594
478
  : INVERTED_Y_STYLE
595
479
  : undefined;
596
- for (let cellPos = 0; cellPos < plan.cells.length; cellPos += 1) {
597
- const cell = plan.cells[cellPos];
480
+ // Wraps one cell identically whether it's the force-mounted sticky cell ahead of the
481
+ // window or an in-window cell from the loop below — same measuring View, same key shape.
482
+ const pushCell = (cell) => {
598
483
  const item = p.getItem(p.data, cell.index);
599
484
  const content = p.renderItem?.({
600
485
  item,
@@ -606,8 +491,24 @@ export const VirtualizedList = defineComponent((props, { attrs, expose, emit, sl
606
491
  onLayout: makeCellMeasure(cell.index),
607
492
  style: cellInverted,
608
493
  }, [content]));
494
+ };
495
+ // The nearest sticky section header below the window (RN _ensureClosestStickyHeader):
496
+ // force-mounted so it never gets destroyed/recreated while pinned off-window. Rendered
497
+ // ahead of the main window loop, with its own gap spacer to the window's first cell.
498
+ if (plan.forcedStickyCell !== undefined) {
499
+ pushCell(plan.forcedStickyCell);
500
+ if (plan.gapExtent > EMPTY_OFFSET) {
501
+ children.push(h('symbiote-view', {
502
+ key: 'spacer-gap',
503
+ style: p.horizontal ? { width: plan.gapExtent } : { height: plan.gapExtent },
504
+ }));
505
+ }
506
+ }
507
+ for (let cellPos = 0; cellPos < plan.cells.length; cellPos += 1) {
508
+ const cell = plan.cells[cellPos];
509
+ pushCell(cell);
609
510
  if (cell.index < m.last) {
610
- const separator = renderSeparatorElement(p.itemSeparatorComponent, item, p.getItem(p.data, cell.index + 1), separatorOverrides.get(cell.index));
511
+ const separator = renderSeparatorElement(p.itemSeparatorComponent, p.getItem(p.data, cell.index), p.getItem(p.data, cell.index + 1), separatorOverrides.get(cell.index));
611
512
  if (separator !== undefined) {
612
513
  children.push(h('symbiote-view', { key: `sep-${cell.key}` }, [separator]));
613
514
  }
@@ -1,32 +1,26 @@
1
1
  // VirtualizedSectionList, the Vue wrapper that flattens sections into one virtualized stream
2
- // over VirtualizedList. Each section contributes a header row, its item rows, then a footer row
3
- // (RN counts 2 per section); the flattened tagged sequence is fed to VirtualizedList as one list,
4
- // so headers/items/footers are all windowed by the same machinery. The flattening, entry keying,
5
- // separator-item unwrap, and scrollToLocation mapping are shared from @symbiote-native/components; this
6
- // file wires Vue lifecycle (typed-prop inputs + handle re-expose + the per-entry render dispatch).
7
- // The Vue twin of the React adapter's VirtualizedSectionList.
2
+ // over VirtualizedList: each section contributes a header row, its item rows, then a footer row
3
+ // (RN counts 2 per section), and the flattened tagged sequence is windowed by the same
4
+ // machinery as one list. Flattening, entry keying, separator-item unwrap, and scrollToLocation
5
+ // mapping are shared from @symbiote-native/components; this file wires Vue lifecycle
6
+ // (typed-prop inputs + handle re-expose + per-entry render dispatch).
8
7
  //
9
- // Typed-emits generic component (mirrors FlatList / VirtualizedList): a GENERIC setup function
10
- // `<ItemT,>(props: IVirtualizedSectionListProps<ItemT>, ctx: ICtx<IVirtualizedSectionListEmits>)`
11
- // so the section inputs (sections/renderItem/renderSectionHeader/…) infer ItemT at the call site.
12
- // For that, those inputs are read from typed `props` (declared in the runtime `props` array); the
13
- // VirtualizedList passthrough tail (refreshing/style/raw scroll/…) rides through `$attrs` onto the
14
- // inner list. The three adapter-synthesized events (endReached/startReached/refresh — the exact set
15
- // React's section list exposes) stay GATED on listener presence: each emit bridge is wired ONLY
16
- // when the consumer listens (read off the instance vnode props), so the inner VirtualizedList keeps
17
- // building RefreshControl / computing edge-reached strictly on demand.
8
+ // Typed-emits generic component (mirrors FlatList/VirtualizedList): a GENERIC setup function so
9
+ // section inputs infer ItemT at the call site. Those inputs are read from typed `props`
10
+ // (declared in the runtime `props` array); the VirtualizedList passthrough tail rides through
11
+ // $attrs onto the inner list. The three adapter-synthesized events (endReached/startReached/
12
+ // refresh) stay GATED on listener presence so the inner VirtualizedList keeps building
13
+ // RefreshControl / computing edge-reached strictly on demand.
18
14
  import { defineComponent, getCurrentInstance, h, shallowRef, } from '@vue/runtime-core';
19
- import { flattenSections, scrollLocationToFlatIndex, sectionEntryKey, unwrapEntryItem, } from '@symbiote-native/components';
15
+ import { flattenSections, resolveStickySectionHeaders, scrollLocationToFlatIndex, sectionEntryKey, unwrapEntryItem, } from '@symbiote-native/components';
20
16
  import { Platform, dlog, } from '@symbiote-native/engine';
21
17
  import { VirtualizedList } from '../virtualized-list/index.js';
22
18
  import { normalizeVueAttrs } from '../../utils/normalize-attrs.js';
23
- // VirtualizedList is a generic component (generic construct signature), which h()'s overloads can't
24
- // resolve. Drive it through a loose functional-component handle (generic-component h() limitation).
19
+ // VirtualizedList's generic construct signature can't be resolved by h()'s overloads, so drive
20
+ // it through a loose functional-component handle instead.
25
21
  const VirtualizedListHost = VirtualizedList;
26
- // The typed inputs VSL reads off `props`; everything else (the VirtualizedList passthrough tail and
27
- // raw scroll) falls through $attrs onto the inner list. Listed for the runtime `props` declaration
28
- // (keyof can't derive it: the index signature widens keyof to `string`). The three emit events are
29
- // deliberately absent (declared as emits below).
22
+ // Listed for the runtime `props` declaration (keyof can't derive it: the index signature widens
23
+ // keyof to `string`). The three emit events are deliberately absent (declared as emits below).
30
24
  const PROP_KEYS = ['sections', 'keyExtractor', 'stickySectionHeadersEnabled'];
31
25
  const EMIT_KEYS = ['endReached', 'startReached', 'refresh'];
32
26
  function isRecord(value) {
@@ -68,10 +62,8 @@ export const VirtualizedSectionList = defineComponent((props, { attrs, expose, e
68
62
  // headerIndices change each render (sections may change); the delegate reads them lazily.
69
63
  let headerIndices = [];
70
64
  expose(buildSectionDelegate(() => inner.value, () => headerIndices));
71
- // The three section-list events are emits, so Vue strips their onX listeners from $attrs. Detect
72
- // listener presence off the instance's own vnode props (what the parent passed), exactly like
73
- // FlatList/VirtualizedList, so an emit bridge is wired ONLY when the consumer actually listens
74
- // (preserving the inner list's RefreshControl / edge-reached gating).
65
+ // Emits are stripped from $attrs by Vue; detect listener presence off the instance's own
66
+ // vnode props instead, so an emit bridge is wired ONLY when the consumer actually listens.
75
67
  const instance = getCurrentInstance();
76
68
  const listens = (onName) => {
77
69
  const vnodeProps = instance?.vnode.props;
@@ -88,9 +80,7 @@ export const VirtualizedSectionList = defineComponent((props, { attrs, expose, e
88
80
  const { entries, headerIndices: indices } = flattenSections(sections, slots.sectionSeparator !== undefined);
89
81
  headerIndices = indices;
90
82
  // RN sticks section headers by default only on iOS; Android does not unless asked.
91
- const stickyDefault = Platform.OS === 'ios';
92
- const stickyEnabled = props.stickySectionHeadersEnabled ?? stickyDefault;
93
- const stickyHeaderIndices = stickyEnabled ? indices : undefined;
83
+ const stickyHeaderIndices = resolveStickySectionHeaders(props.stickySectionHeadersEnabled, indices, Platform.OS);
94
84
  dlog(`Vue VirtualizedSectionList: ${sections.length} sections flattened to ${entries.length} entries`);
95
85
  // The inner list's #item slot: dispatch each flattened entry to the matching consumer slot,
96
86
  // returning its VNode[] (empty when the consumer left that slot off).