@symbiote-native/vue 0.3.6 → 0.3.8

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.
@@ -19,7 +19,7 @@
19
19
  // onScroll composition (native attach vs Animated.event) all live here; the per-header component and
20
20
  // the children wrap live in scroll-view-sticky-header.ts (the Vue twin of the React file).
21
21
  import { defineComponent, h, isVNode, markRaw, onBeforeUnmount, ref, shallowRef, watch, } from '@vue/runtime-core';
22
- import { attachStickyScroll, buildScrollViewHandle, didContentSizeChange, forwardScrollEvent, readLayoutDimension, resolveAccessibilityProps, resolveDecelerationRate, selectScrollIntrinsics, } from '@symbiote-native/components';
22
+ import { attachStickyScroll, buildScrollViewHandle, didContentSizeChange, forwardScrollEvent, readLayoutDimension, resolveAccessibilityProps, resolveDecelerationRate, resolveScrollForwarding, selectScrollIntrinsics, } from '@symbiote-native/components';
23
23
  import { AnimatedValue, dlog, event as animatedEvent, isClassNameValue, isNativeAnimatedAvailable, isSymbioteNode, resolveClassName, } from '@symbiote-native/engine';
24
24
  import { wrapStickyHeaders } from './sticky-header.js';
25
25
  import { normalizeVueAttrs } from '../../utils/normalize-attrs.js';
@@ -196,31 +196,37 @@ export function createScrollView(platform) {
196
196
  outerProps.decelerationRate = resolveDecelerationRate(decel);
197
197
  // onScroll: when sticky headers are active, the offset must reach the AnimatedValue. RN does
198
198
  // the same with _scrollAnimatedValueAttachment. forwardAttrs already put the user's onScroll /
199
- // onLayout / scrollEventThrottle on outerProps; here we override them for the sticky branch.
199
+ // onLayout / scrollEventThrottle on outerProps; here we override them per the shared
200
+ // resolveScrollForwarding DECISIONS (which path, the 1/16 throttle defaults, inverted capture).
200
201
  const nativeStickyAvailable = hasStickyHeaders && isNativeAnimatedAvailable();
201
202
  nativeStickyWanted = nativeStickyAvailable;
203
+ const userThrottle = typeof attrs.scrollEventThrottle === 'number' ? attrs.scrollEventThrottle : undefined;
204
+ const forwarding = resolveScrollForwarding({
205
+ hasStickyHeaders,
206
+ nativeStickyAvailable,
207
+ invertStickyHeaders,
208
+ scrollEventThrottle: userThrottle,
209
+ maintainVisibleContentPosition: attrs.maintainVisibleContentPosition,
210
+ snapToAlignment: attrs.snapToAlignment,
211
+ });
202
212
  if (hasStickyHeaders) {
203
213
  const userOnScroll = isHandler(attrs.onScroll) ? attrs.onScroll : undefined;
204
- const userThrottle = typeof attrs.scrollEventThrottle === 'number' ? attrs.scrollEventThrottle : undefined;
205
- if (nativeStickyAvailable) {
206
- // Native path (RN attachNativeEvent): the scroll value is driven on the UI thread by the
207
- // post-commit watch above, so onScroll only forwards to the user: zero JS per frame. RN
208
- // uses throttle 1 when sticky (ScrollView.js:1798); the native driver can afford it. The
209
- // user onScroll is already on outerProps via forwardAttrs.
210
- outerProps.scrollEventThrottle = userThrottle ?? 1;
211
- }
212
- else {
214
+ if (forwarding.mode === 'sticky-js') {
213
215
  // JS fallback (no native module): Animated.event drives the value each frame and forwards
214
216
  // the user's handler as the listener passthrough. Correct, but lags a frame under fast
215
- // scroll (the jitter), which the native path above removes on a real host.
217
+ // scroll (the jitter), which the native path removes on a real host.
216
218
  outerProps.onScroll = animatedEvent([{ nativeEvent: { contentOffset: { y: scrollAnimatedValue } } }], userOnScroll === undefined
217
219
  ? undefined
218
220
  : { listener: (...args) => forwardScrollEvent(userOnScroll, args) });
219
- outerProps.scrollEventThrottle = userThrottle ?? 16;
221
+ }
222
+ // Native path: the value is driven on the UI thread by the post-commit watch above, so the
223
+ // user onScroll (already on outerProps via forwardAttrs) forwards untouched, zero JS/frame.
224
+ if (forwarding.scrollEventThrottle !== undefined) {
225
+ outerProps.scrollEventThrottle = forwarding.scrollEventThrottle;
220
226
  }
221
227
  // onLayout on the scroll-view node: capture the viewport height for inverted sticky headers
222
228
  // (RN _handleLayout), then call the user's handler. Non-inverted leaves onLayout as forwarded.
223
- if (invertStickyHeaders === true) {
229
+ if (forwarding.capturesViewportHeight) {
224
230
  const userOnLayout = isHandler(attrs.onLayout) ? attrs.onLayout : undefined;
225
231
  outerProps.onLayout = (event) => {
226
232
  const height = readLayoutDimension(event, 'height');
@@ -238,8 +244,7 @@ export function createScrollView(platform) {
238
244
  // false also preserves the cell views maintainVisibleContentPosition / snapToAlignment
239
245
  // anchor against (RN preserveChildren). iOS never flattens; both are no-ops there.
240
246
  const contentProps = { style: contentStyle, collapsable: false };
241
- if (attrs.maintainVisibleContentPosition !== undefined ||
242
- attrs.snapToAlignment !== undefined) {
247
+ if (forwarding.collapsableChildren) {
243
248
  contentProps.collapsableChildren = false;
244
249
  }
245
250
  // contentSizeChange is synthesized from the content view's own onLayout (RN
@@ -11,9 +11,9 @@
11
11
  // @symbiote-native/components (computeStickyInterpolation); this file holds the Vue component
12
12
  // shell, the layout state, and the child-wrapping. Render shared verbatim with React via the math.
13
13
  // Vue supplies only the reactive lifecycle (refs/watch instead of useState/useEffect).
14
- import { defineComponent, h, isVNode, ref, shallowRef, watch, watchEffect, markRaw, } from '@vue/runtime-core';
14
+ import { defineComponent, h, isVNode, onBeforeUnmount, ref, shallowRef, watchEffect, markRaw, } from '@vue/runtime-core';
15
15
  import { AnimatedValue, AnimatedInterpolation, Platform, dlog, } from '@symbiote-native/engine';
16
- import { computeStickyInterpolation, nextStickyHeaderY, readLayoutNumber, stickyDebounceMs, STICKY_HEADER_Z_INDEX, } from '@symbiote-native/components';
16
+ import { createInitialStickyState, nextStickyHeaderY, readLayoutNumber, reduceSticky, STICKY_HEADER_Z_INDEX, } from '@symbiote-native/components';
17
17
  import { Animated } from '../../modules/animated/index.js';
18
18
  function isHandler(value) {
19
19
  return typeof value === 'function';
@@ -49,79 +49,95 @@ export const ScrollViewStickyHeader = defineComponent({
49
49
  const scrollAnimatedValue = isAnimatedValue(attrs.scrollAnimatedValue)
50
50
  ? attrs.scrollAnimatedValue
51
51
  : markRaw(new AnimatedValue(0));
52
- const measured = ref(false);
53
- const layoutY = ref(0);
54
- const layoutHeight = ref(0);
55
- // The animated node that drives the transform (RN's animatedTranslateY). When the scroll value
56
- // is native (attachStickyScroll), this interpolation runs on the UI thread: the smooth pin.
57
- // Engine node → shallowRef (held by identity, the reactivity rule); the un-measured identity
58
- // stub until the effect below rebuilds it.
52
+ // The one folded state cell, mutated in place by reduceSticky. A plain object (NOT a ref /
53
+ // reactive): the render reads it gated by the reactive `version` bump + `animatedTranslateY`
54
+ // shallowRef below, so it never needs Vue to proxy it. The DECISIONS — zero-swallow gate,
55
+ // debounce delay, rebuild ranges — all live in reduceSticky.
56
+ const state = createInitialStickyState();
57
+ // A reactive tick bumped when the reducer commits a new translateY, forcing the render to re-read
58
+ // state.translateY (the debounced committed value).
59
+ const version = ref(0);
60
+ // The animated node that drives the transform (RN's animatedTranslateY). Engine node →
61
+ // shallowRef (held by identity, the reactivity rule); the un-measured identity stub until the
62
+ // rebuild-interpolation effect below replaces it.
59
63
  const animatedTranslateY = shallowRef(scrollAnimatedValue.interpolate({ inputRange: [-1, 0], outputRange: [0, 0] }));
60
- // The debounced EXPLICIT translateY pushed to the committed transform via
61
- // passthroughAnimatedPropExplicitValues, so the Fabric ShadowTree (hit-testing) knows the pinned
62
- // position while the native driver animates. null until the listener first fires.
63
- const translateY = ref(null);
64
- let haveReceivedInitialZeroTranslateY = true;
65
64
  let debounceTimer;
66
- watch(translateY, value => {
67
- if (value !== 0 && value !== null)
68
- haveReceivedInitialZeroTranslateY = false;
65
+ // The current interpolation node + its listener id, held so the next rebuild detaches the old
66
+ // listener (an engine call the reducer does not own) and onBeforeUnmount cleans up.
67
+ let interpolation;
68
+ let listenerId;
69
+ const inputs = () => ({
70
+ os: Platform.OS,
71
+ inverted: typeof attrs.inverted === 'boolean' ? attrs.inverted : undefined,
72
+ scrollViewHeight: typeof attrs.scrollViewHeight === 'number' ? attrs.scrollViewHeight : undefined,
73
+ nextHeaderLayoutY: typeof attrs.nextHeaderLayoutY === 'number' ? attrs.nextHeaderLayoutY : undefined,
69
74
  });
70
- // The animated value updates several times per frame during scroll; debounce it and push the
71
- // settled value into the committed transform so hit detection stays current (RN: a Fabric-only
72
- // issue; symbiote is always Fabric, and worse on Android).
73
- const animatedValueListener = ({ value }) => {
74
- if (typeof value !== 'number')
75
- return;
76
- const timeout = stickyDebounceMs(Platform.OS);
77
- // A freshly-rebuilt interpolation re-emits 0 to its listeners; swallow that first zero (RN).
78
- if (value === 0 && !haveReceivedInitialZeroTranslateY) {
79
- haveReceivedInitialZeroTranslateY = true;
80
- return;
75
+ const runEffects = (effects) => {
76
+ for (const effect of effects) {
77
+ switch (effect.kind) {
78
+ case 'rebuild-interpolation': {
79
+ // Detach the old listener, build a fresh interpolation onto the shared scroll value, and
80
+ // wire the settled-value listener (symbiote is always Fabric; RN attaches it only there).
81
+ if (interpolation !== undefined && listenerId !== undefined) {
82
+ interpolation.removeListener(listenerId);
83
+ listenerId = undefined;
84
+ }
85
+ const next = scrollAnimatedValue.interpolate({
86
+ inputRange: effect.inputRange,
87
+ outputRange: effect.outputRange,
88
+ });
89
+ listenerId = next.addListener(({ value }) => {
90
+ if (typeof value === 'number')
91
+ dispatch({ kind: 'animated-tick', value });
92
+ });
93
+ interpolation = next;
94
+ animatedTranslateY.value = next;
95
+ break;
96
+ }
97
+ case 'schedule-debounce':
98
+ // The animated value updates several times per frame; debounce the settled value into the
99
+ // committed transform so hit detection stays current (a Fabric issue, worse on Android).
100
+ if (debounceTimer !== undefined)
101
+ clearTimeout(debounceTimer);
102
+ debounceTimer = setTimeout(() => {
103
+ debounceTimer = undefined;
104
+ dispatch({ kind: 'debounce-fired', value: effect.value });
105
+ }, effect.delay);
106
+ break;
107
+ case 'apply-passthrough':
108
+ version.value += 1;
109
+ break;
110
+ case 'record-header-y':
111
+ // Vue records through attrs.onLayout (the wrapper closure), which honors the public
112
+ // IStickyHeaderProps contract; the reducer emits no index for it.
113
+ break;
114
+ }
81
115
  }
82
- if (debounceTimer !== undefined)
83
- clearTimeout(debounceTimer);
84
- debounceTimer = setTimeout(() => {
85
- translateY.value = value;
86
- }, timeout);
87
116
  };
88
- // Rebuild the interpolation whenever the layout / collision inputs change (the Vue twin of RN's
89
- // effect, deps [measured, layoutY, layoutHeight, scrollViewHeight, nextHeaderLayoutY, inverted]).
90
- // symbiote is always Fabric: listen to the settled value to keep the ShadowTree transform current
91
- // for hit-testing (RN attaches this listener only under Fabric). scrollAnimatedValue is the stable
92
- // const (RN lists it in deps; it never changes for one ScrollView, so the const is faithful).
93
- watchEffect(onCleanup => {
94
- const inverted = typeof attrs.inverted === 'boolean' ? attrs.inverted : undefined;
95
- const scrollViewHeight = typeof attrs.scrollViewHeight === 'number' ? attrs.scrollViewHeight : undefined;
96
- const nextHeaderLayoutY = typeof attrs.nextHeaderLayoutY === 'number' ? attrs.nextHeaderLayoutY : undefined;
97
- const { inputRange, outputRange } = computeStickyInterpolation({
98
- measured: measured.value,
99
- inverted,
100
- scrollViewHeight,
101
- layoutY: layoutY.value,
102
- layoutHeight: layoutHeight.value,
103
- nextHeaderLayoutY,
104
- });
105
- const interpolation = scrollAnimatedValue.interpolate({ inputRange, outputRange });
106
- const listenerId = interpolation.addListener(animatedValueListener);
107
- animatedTranslateY.value = interpolation;
108
- onCleanup(() => {
117
+ const dispatch = (action) => {
118
+ runEffects(reduceSticky(state, action, inputs()).effects);
119
+ };
120
+ // Rebuild whenever the collision/viewport inputs change (the Vue twin of React's inputs-changed
121
+ // effect, tracking [inverted, scrollViewHeight, nextHeaderLayoutY] via inputs()); also the initial
122
+ // build on mount. scrollAnimatedValue is the stable const (never changes for one ScrollView).
123
+ watchEffect(() => {
124
+ dispatch({ kind: 'inputs-changed' });
125
+ });
126
+ onBeforeUnmount(() => {
127
+ if (interpolation !== undefined && listenerId !== undefined) {
109
128
  interpolation.removeListener(listenerId);
110
- if (debounceTimer !== undefined)
111
- clearTimeout(debounceTimer);
112
- });
129
+ }
130
+ if (debounceTimer !== undefined)
131
+ clearTimeout(debounceTimer);
113
132
  });
114
- // Stable function ref (defined once, not re-created per render): record own y/height, mark
115
- // measured, fire the wrapper's recorder (onHeaderLayoutY, in attrs.onLayout), then the child's
116
- // own onLayout. Matches RN ScrollViewStickyHeader.js._onLayout order.
133
+ // Stable function ref (defined once, not re-created per render): dispatch the layout (record
134
+ // own y/height, mark measured, rebuild), fire the wrapper's recorder (onHeaderLayoutY, in
135
+ // attrs.onLayout), then the child's own onLayout. Matches RN ScrollViewStickyHeader.js._onLayout.
117
136
  const onLayout = (event) => {
118
137
  const y = readLayoutNumber(event, 'y');
119
138
  const height = readLayoutNumber(event, 'height');
120
- if (y !== undefined)
121
- layoutY.value = y;
122
- if (height !== undefined)
123
- layoutHeight.value = height;
124
- measured.value = true;
139
+ // Keep the previous value when a field is absent (RN sets state only on a defined read).
140
+ dispatch({ kind: 'layout', y: y ?? state.layoutY, height: height ?? state.layoutHeight });
125
141
  const recorder = attrs.onLayout;
126
142
  if (isHandler(recorder))
127
143
  recorder(event);
@@ -132,10 +148,12 @@ export const ScrollViewStickyHeader = defineComponent({
132
148
  childOnLayout(event);
133
149
  };
134
150
  return () => {
151
+ // Read the version bump so a committed translateY re-runs render.
152
+ void version.value;
135
153
  // The EXPLICIT debounced translateY overrides the committed transform for hit-testing, while
136
154
  // `animatedTranslateY` does the smooth (native-driven) pin. See RN ScrollViewStickyHeader.js.
137
- const passthroughAnimatedPropExplicitValues = translateY.value !== null
138
- ? { style: { transform: [{ translateY: translateY.value }] } }
155
+ const passthroughAnimatedPropExplicitValues = state.translateY !== null
156
+ ? { style: { transform: [{ translateY: state.translateY }] } }
139
157
  : null;
140
158
  // collapsable:false keeps the wrapper a real Yoga node; zIndex makes the pinned header paint
141
159
  // OVER the rows scrolling under it. The interpolation node passes inside the transform; the
@@ -11,14 +11,20 @@
11
11
  // Inputs arrive as attrs (untyped), narrowed with runtime guards. The handlers read attrs LIVE
12
12
  // (they fire on events, not render) so a re-supplied callback / timing is always honored.
13
13
  import { defineComponent, h } from '@vue/runtime-core';
14
- import { computePressOutWait, DEFAULT_ACTIVE_OPACITY, DEFAULT_HIGHLIGHT_CHILD_OPACITY, DEFAULT_MIN_PRESS_DURATION_MS, DEFAULT_UNDERLAY_COLOR, OPACITY_ACTIVE_DURATION_MS, OPACITY_INACTIVE_DURATION_MS, RESTING_OPACITY, } from '@symbiote-native/components';
15
- import { dlog, } from '@symbiote-native/engine';
14
+ import { createTouchableFeedbackHandlers, createTouchableFeedbackRuntime, highlightPressedStyle, DEFAULT_ACTIVE_OPACITY, DEFAULT_HIGHLIGHT_CHILD_OPACITY, DEFAULT_MIN_PRESS_DURATION_MS, DEFAULT_UNDERLAY_COLOR, OPACITY_ACTIVE_DURATION_MS, OPACITY_INACTIVE_DURATION_MS, RESTING_OPACITY, } from '@symbiote-native/components';
15
+ import {} from '@symbiote-native/engine';
16
16
  import { Pressable, emitPressableEvents, PRESSABLE_EMITS, } from './pressable.js';
17
17
  import { Animated } from '../modules/animated/index.js';
18
18
  import { normalizeVueAttrs } from '../utils/normalize-attrs.js';
19
19
  function numberOr(value, fallback) {
20
20
  return typeof value === 'number' ? value : fallback;
21
21
  }
22
+ // The real setTimeout the shared feedback machine schedules its deferred activation/deactivation on
23
+ // (core/components has no timer globals). Returns a canceller so an early release flushes the timer.
24
+ function scheduleTimeout(callback, ms) {
25
+ const id = setTimeout(callback, ms);
26
+ return () => clearTimeout(id);
27
+ }
22
28
  function forwardExcept(attrs, handled) {
23
29
  const result = {};
24
30
  for (const key of Object.keys(attrs)) {
@@ -41,8 +47,9 @@ export const TouchableOpacity = defineComponent((_props, { slots, attrs: rawAttr
41
47
  // One Animated.Value per mount, resting at full opacity. Held by identity in setup scope (an
42
48
  // engine object, never a reactive ref). The Animated.View leaf commits it every frame.
43
49
  const opacity = new Animated.Value(RESTING_OPACITY);
44
- let pressInTimer;
45
- let activatedAt;
50
+ // The shared press-scheduling cell (delayPressIn timer + activation clock), persisted across
51
+ // renders in setup scope; the handlers are rebuilt each render over live attrs.
52
+ const runtime = createTouchableFeedbackRuntime();
46
53
  function setOpacityTo(toValue, duration) {
47
54
  Animated.timing(opacity, {
48
55
  toValue,
@@ -51,51 +58,27 @@ export const TouchableOpacity = defineComponent((_props, { slots, attrs: rawAttr
51
58
  useNativeDriver: false,
52
59
  }).start();
53
60
  }
54
- function clearPressInTimer() {
55
- if (pressInTimer !== undefined) {
56
- clearTimeout(pressInTimer);
57
- pressInTimer = undefined;
58
- }
59
- }
60
- function activate(event) {
61
- activatedAt = Date.now();
62
- setOpacityTo(numberOr(rawAttrs.activeOpacity, DEFAULT_ACTIVE_OPACITY), OPACITY_ACTIVE_DURATION_MS);
63
- emit('pressIn', event);
64
- }
65
- function deactivate(event) {
66
- activatedAt = undefined;
67
- setOpacityTo(RESTING_OPACITY, OPACITY_INACTIVE_DURATION_MS);
68
- emit('pressOut', event);
69
- }
70
- function handlePressIn(event) {
71
- const delayPressIn = numberOr(rawAttrs.delayPressIn, 0);
72
- if (delayPressIn > 0) {
73
- dlog(`TouchableOpacity pressIn deferred ${delayPressIn}ms`);
74
- pressInTimer = setTimeout(() => {
75
- pressInTimer = undefined;
76
- activate(event);
77
- }, delayPressIn);
78
- return;
79
- }
80
- activate(event);
81
- }
82
- function handlePressOut(event) {
83
- if (pressInTimer !== undefined) {
84
- clearPressInTimer();
85
- activate(event);
86
- }
87
- const heldFor = activatedAt === undefined ? 0 : Date.now() - activatedAt;
88
- const wait = computePressOutWait(heldFor, numberOr(rawAttrs.minPressDuration, DEFAULT_MIN_PRESS_DURATION_MS), numberOr(rawAttrs.delayPressOut, 0));
89
- if (wait > 0) {
90
- dlog(`TouchableOpacity pressOut deferred ${wait}ms`);
91
- setTimeout(() => deactivate(event), wait);
92
- return;
93
- }
94
- deactivate(event);
95
- }
96
61
  return () => {
97
62
  const attrs = normalizeVueAttrs(rawAttrs);
98
63
  const style = attrs.style;
64
+ // Rebuilt each render so a re-supplied delay/opacity is honored (Vue's live-attr idiom); the
65
+ // shared machine owns the scheduling, the adapter supplies the Animated fade + emit.
66
+ const { handlePressIn, handlePressOut } = createTouchableFeedbackHandlers({
67
+ delayPressIn: numberOr(attrs.delayPressIn, 0),
68
+ delayPressOut: numberOr(attrs.delayPressOut, 0),
69
+ minPressDuration: numberOr(attrs.minPressDuration, DEFAULT_MIN_PRESS_DURATION_MS),
70
+ schedule: scheduleTimeout,
71
+ now: Date.now,
72
+ }, runtime, {
73
+ activate(event) {
74
+ setOpacityTo(numberOr(attrs.activeOpacity, DEFAULT_ACTIVE_OPACITY), OPACITY_ACTIVE_DURATION_MS);
75
+ emit('pressIn', event);
76
+ },
77
+ deactivate(event) {
78
+ setOpacityTo(RESTING_OPACITY, OPACITY_INACTIVE_DURATION_MS);
79
+ emit('pressOut', event);
80
+ },
81
+ });
99
82
  const pressableProps = {
100
83
  ...forwardExcept(attrs, TOUCHABLE_OPACITY_HANDLED),
101
84
  ...emitPressableEvents(emit),
@@ -119,10 +102,7 @@ export const TouchableHighlight = defineComponent((_props, { slots, attrs: rawAt
119
102
  const underlayColor = typeof attrs.underlayColor === 'string' ? attrs.underlayColor : DEFAULT_UNDERLAY_COLOR;
120
103
  const style = attrs.style;
121
104
  function pressedStyle({ pressed }) {
122
- if (!pressed)
123
- return style;
124
- const overlay = { backgroundColor: underlayColor, opacity: activeOpacity };
125
- return [style, overlay];
105
+ return highlightPressedStyle(pressed, style, underlayColor, activeOpacity);
126
106
  }
127
107
  const pressableProps = {
128
108
  ...forwardExcept(attrs, TOUCHABLE_HIGHLIGHT_HANDLED),
@@ -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) => {
221
- 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(() => {
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 = () => {
229
213
  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);
@@ -16,7 +16,7 @@
16
16
  // when the consumer listens (read off the instance vnode props), so the inner VirtualizedList keeps
17
17
  // building RefreshControl / computing edge-reached strictly on demand.
18
18
  import { defineComponent, getCurrentInstance, h, shallowRef, } from '@vue/runtime-core';
19
- import { flattenSections, scrollLocationToFlatIndex, sectionEntryKey, unwrapEntryItem, } from '@symbiote-native/components';
19
+ import { flattenSections, resolveStickySectionHeaders, scrollLocationToFlatIndex, sectionEntryKey, unwrapEntryItem, } from '@symbiote-native/components';
20
20
  import { Platform, dlog, } from '@symbiote-native/engine';
21
21
  import { VirtualizedList } from '../virtualized-list/index.js';
22
22
  import { normalizeVueAttrs } from '../../utils/normalize-attrs.js';
@@ -88,9 +88,7 @@ export const VirtualizedSectionList = defineComponent((props, { attrs, expose, e
88
88
  const { entries, headerIndices: indices } = flattenSections(sections, slots.sectionSeparator !== undefined);
89
89
  headerIndices = indices;
90
90
  // 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;
91
+ const stickyHeaderIndices = resolveStickySectionHeaders(props.stickySectionHeadersEnabled, indices, Platform.OS);
94
92
  dlog(`Vue VirtualizedSectionList: ${sections.length} sections flattened to ${entries.length} entries`);
95
93
  // The inner list's #item slot: dispatch each flattened entry to the matching consumer slot,
96
94
  // returning its VNode[] (empty when the consumer left that slot off).
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "@symbiote-native/vue",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
4
4
  "description": "SymbioteNative's Vue adapter — a @vue/runtime-core createRenderer driving real native iOS/Android views through the same engine as the React adapter.",
5
+ "license": "MIT",
5
6
  "repository": {
6
7
  "type": "git",
7
8
  "url": "git+https://github.com/OneEyed1366/symbiote-native.git",
@@ -45,17 +46,17 @@
45
46
  "@vue/compiler-sfc": "^3.5.13",
46
47
  "@vue/runtime-core": "^3.5.13",
47
48
  "typescript": "~6.0.0",
48
- "@symbiote-native/css-parser": "0.2.2",
49
- "@symbiote-native/components": "0.2.5"
49
+ "@symbiote-native/components": "0.3.0",
50
+ "@symbiote-native/css-parser": "0.2.3"
50
51
  },
51
52
  "peerDependencies": {
52
- "@symbiote-native/engine": ">=0.1.6",
53
+ "@symbiote-native/engine": ">=0.1.7",
53
54
  "vue": ">=3.5.0",
54
55
  "react-native": ">=0.86"
55
56
  },
56
57
  "devDependencies": {
57
- "@symbiote-native/engine": "0.1.6",
58
- "@symbiote-native/test-utils": "0.1.5"
58
+ "@symbiote-native/engine": "0.1.7",
59
+ "@symbiote-native/test-utils": "0.1.6"
59
60
  },
60
61
  "scripts": {
61
62
  "typecheck": "tsc --build",