@symbiote-native/components 0.4.0 → 0.5.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 (39) hide show
  1. package/README.md +10 -2
  2. package/build/accessibility-props.js +2 -1
  3. package/build/bootstrap/index.js +1 -1
  4. package/build/component-names/index.android.js +1 -1
  5. package/build/component-names/index.ios.js +1 -1
  6. package/build/component-names/shared.js +4 -1
  7. package/build/descriptor.d.ts +8 -0
  8. package/build/descriptor.js +27 -0
  9. package/build/index.d.ts +19 -11
  10. package/build/index.js +12 -5
  11. package/build/state/pressable.js +13 -3
  12. package/build/state/sticky-header-reducer.js +28 -5
  13. package/build/state/switch.js +1 -1
  14. package/build/state/text-input.d.ts +2 -1
  15. package/build/state/text-input.js +35 -8
  16. package/build/state/touchable.d.ts +30 -1
  17. package/build/state/touchable.js +94 -5
  18. package/build/state/virtualized-list-diagnostics.d.ts +31 -0
  19. package/build/state/virtualized-list-diagnostics.js +33 -0
  20. package/build/state/virtualized-list-reducer.d.ts +14 -0
  21. package/build/state/virtualized-list-reducer.js +179 -45
  22. package/build/state/virtualized-list.d.ts +5 -2
  23. package/build/state/virtualized-list.js +143 -34
  24. package/build/text-props.d.ts +9 -0
  25. package/build/text-props.js +24 -0
  26. package/build/view/render-activity-indicator.js +11 -3
  27. package/build/view/render-image/index.js +13 -3
  28. package/build/view/render-input-accessory-view.js +5 -2
  29. package/build/view/render-keyboard-avoiding-view.d.ts +14 -2
  30. package/build/view/render-keyboard-avoiding-view.js +52 -6
  31. package/build/view/render-modal.js +4 -2
  32. package/build/view/render-pressable/index.js +3 -1
  33. package/build/view/render-scroll-sticky.js +1 -1
  34. package/build/view/render-scroll-view.js +9 -3
  35. package/build/view/render-switch.js +4 -1
  36. package/build/view/render-touchable-highlight.d.ts +11 -1
  37. package/build/view/render-touchable-highlight.js +11 -10
  38. package/build/view/render-touchable-native-feedback.js +5 -1
  39. package/package.json +2 -2
@@ -3,18 +3,35 @@
3
3
  // (delayPressIn/delayPressOut/minPressDuration) and the deactivation floor math. The Animated
4
4
  // feedback itself is framework (each adapter's Animated namespace), so it stays in the adapter;
5
5
  // only the timing constants + the pure wait computation live here.
6
- import { dlog } from '@symbiote-native/engine';
7
- // TouchableOpacity.js: _opacityActive(150)/_opacityInactive(250), activeOpacity 0.2.
6
+ import { dlog, flattenStyle, } from '@symbiote-native/engine';
7
+ // TouchableOpacity.js: _opacityActive(0 | 150)/_opacityInactive(250), activeOpacity 0.2.
8
8
  export const DEFAULT_ACTIVE_OPACITY = 0.2;
9
+ // RN picks the press-in fade duration from WHERE the press-in came from
10
+ // (`TouchableOpacity.js:215-220`):
11
+ //
12
+ // event.dispatchConfig.registrationName === 'onResponderGrant' ? 0 : 150
13
+ //
14
+ // The grant branch is an ordinary tap and it is INSTANT. `Pressability.js:453-468` shows why:
15
+ // `onResponderGrant` calls `_receiveSignal('RESPONDER_GRANT')` and then, when delayPressIn is 0,
16
+ // `_receiveSignal('DELAY', event)` SYNCHRONOUSLY with that same grant event — so the event that
17
+ // reaches onPressIn still carries `onResponderGrant`. The 150 branch is a RE-activation: the
18
+ // finger drifted outside and came back (RESPONDER_INACTIVE_PRESS_OUT -> RESPONDER_ACTIVE_PRESS_IN,
19
+ // driven by onResponderMove).
20
+ //
21
+ // Our engine dispatches pressIn from exactly ONE place — `core/engine/src/events/index.ts:391`,
22
+ // on topTouchStart, ahead of negotiateResponder — and has no drift-back-in re-activation at all.
23
+ // So every pressIn we produce is the grant-equivalent, and 0 is the duration that applies. Using
24
+ // 150 (which all five adapters did until 2026-08-19) makes every tap fade in visibly slower than
25
+ // RN.
26
+ export const OPACITY_ACTIVE_GRANT_DURATION_MS = 0;
27
+ // The re-activation branch. Unreachable today — kept so the constant exists when the press
28
+ // machine grows drift-back-in, and because it is the value RN uses there.
9
29
  export const OPACITY_ACTIVE_DURATION_MS = 150;
10
30
  export const OPACITY_INACTIVE_DURATION_MS = 250;
11
31
  export const RESTING_OPACITY = 1;
12
32
  // TouchableHighlight.js: child opacity 0.85, underlay 'black' when unset.
13
33
  export const DEFAULT_HIGHLIGHT_CHILD_OPACITY = 0.85;
14
34
  export const DEFAULT_UNDERLAY_COLOR = 'black';
15
- // RN's Pressability DEFAULT_MIN_PRESS_DURATION, the floor a press visual is held, so a very fast
16
- // tap still flashes the active feedback (Pressability.js).
17
- export const DEFAULT_MIN_PRESS_DURATION_MS = 130;
18
35
  // RN's _deactivate floor: the press-out waits at least minPressDuration past activation (so a fast
19
36
  // tap holds the active visual) and at least delayPressOut, whichever is longer. `heldFor` is how
20
37
  // long the visual has already been active (0 when it never activated).
@@ -82,3 +99,75 @@ export function createTouchableFeedbackHandlers(config, runtime, callbacks) {
82
99
  },
83
100
  };
84
101
  }
102
+ // ---- RN-audited additions (2026-08-19) --------------------------------------------------------
103
+ //
104
+ // Measured against .vendors/react-native. Everything above predates that audit and is still what
105
+ // the React, Vue, Svelte and Angular adapters call; the names below are the RN-accurate forms and
106
+ // are additive on purpose, so the tree stays green while each adapter migrates.
107
+ // RN's Touchable* family OVERRIDES Pressability's own floor with 0 — TouchableOpacity.js:195,
108
+ // TouchableHighlight.js:203 and TouchableWithoutFeedback.js all pass `minPressDuration: 0`. So
109
+ // Pressability's own default of 130 (Pressability.js:264) reaches a Touchable in RN NEVER: what holds the active visual there is the Animated fade's own duration,
110
+ // not a press-duration floor. Adapters defaulting minPressDuration to 130 delay every press-out by
111
+ // an eighth of a second RN does not.
112
+ export const TOUCHABLE_MIN_PRESS_DURATION_MS = 0;
113
+ // RN's _getChildStyleOpacityWithDefault (TouchableOpacity.js): the fade returns to the opacity the
114
+ // CALLER's style asks for, not to a hard 1 — a Touchable styled `opacity: 0.6` must settle back at
115
+ // 0.6, and its Animated.Value must START there or the first paint jumps to fully opaque. `unknown`
116
+ // in, because a style prop is an arbitrarily nested array the engine's flattenStyle resolves.
117
+ export function restingOpacityFromStyle(style) {
118
+ const opacity = flattenStyle(style).opacity;
119
+ return typeof opacity === 'number' ? opacity : RESTING_OPACITY;
120
+ }
121
+ export function hasTouchablePressHandler(props) {
122
+ return (props.onPress != null ||
123
+ props.onPressIn != null ||
124
+ props.onPressOut != null ||
125
+ props.onLongPress != null);
126
+ }
127
+ export function createHighlightUnderlayRuntime() {
128
+ return { hideTimerCancel: undefined };
129
+ }
130
+ export function createHighlightUnderlayHandlers(config, runtime, callbacks) {
131
+ const { delayPressOut, hasPressHandler, schedule } = config;
132
+ const { setShown, onShowUnderlay, onHideUnderlay } = callbacks;
133
+ function clearHideTimer() {
134
+ if (runtime.hideTimerCancel !== undefined) {
135
+ runtime.hideTimerCancel();
136
+ runtime.hideTimerCancel = undefined;
137
+ }
138
+ }
139
+ function show() {
140
+ if (!hasPressHandler)
141
+ return;
142
+ setShown(true);
143
+ onShowUnderlay?.();
144
+ }
145
+ function hide() {
146
+ clearHideTimer();
147
+ if (!hasPressHandler)
148
+ return;
149
+ setShown(false);
150
+ onHideUnderlay?.();
151
+ }
152
+ return {
153
+ handlePressIn() {
154
+ clearHideTimer();
155
+ show();
156
+ },
157
+ // RN holds the underlay for delayPressOut past the tap, so a press too fast to see still
158
+ // flashes. The timer is what onPressOut below reads to know it must not hide yet.
159
+ handlePress() {
160
+ clearHideTimer();
161
+ show();
162
+ dlog(`TouchableHighlight underlay held ${delayPressOut}ms after press`);
163
+ runtime.hideTimerCancel = schedule(() => {
164
+ runtime.hideTimerCancel = undefined;
165
+ hide();
166
+ }, delayPressOut);
167
+ },
168
+ handlePressOut() {
169
+ if (runtime.hideTimerCancel === undefined)
170
+ hide();
171
+ },
172
+ };
173
+ }
@@ -0,0 +1,31 @@
1
+ export interface IListDiagnosticFrame {
2
+ scrollOffset: number;
3
+ viewportLength: number;
4
+ first: number;
5
+ last: number;
6
+ targetFirst: number;
7
+ targetLast: number;
8
+ count: number;
9
+ measuredCount: number;
10
+ averageLength: number;
11
+ averageStride: number;
12
+ firstOffset: number;
13
+ firstRaw: number | undefined;
14
+ total: number;
15
+ leadingExtent: number;
16
+ trailingExtent: number;
17
+ }
18
+ export interface IListDiagnosticMove {
19
+ kind: 'moved' | 'sized';
20
+ index: number;
21
+ from: number;
22
+ to: number;
23
+ }
24
+ interface IListDiagnosticHandlers {
25
+ onFrame?: (frame: IListDiagnosticFrame) => void;
26
+ onMove?: (move: IListDiagnosticMove) => void;
27
+ }
28
+ export declare function subscribeListDiagnostics(handlers: IListDiagnosticHandlers): () => void;
29
+ export declare function recordListFrame(build: () => IListDiagnosticFrame): void;
30
+ export declare function recordCellMove(kind: 'moved' | 'sized', index: number, from: number, to: number): void;
31
+ export {};
@@ -0,0 +1,33 @@
1
+ // A diagnostic tap on the list's windowing state, for reading on a DEVICE. The whole class of bugs
2
+ // this exists for — a spacer that disagrees with where the host actually laid the cells out — is
3
+ // invisible to a headless test, because the fake reports back exactly what the model asked for. The
4
+ // residual only exists where a real Yoga does the layout.
5
+ //
6
+ // Off unless something subscribes: `sink` is undefined and every record is one property read, the
7
+ // same cost model as dlog. Nothing here is gated behind DEBUG, because the canary's HUD subscribes
8
+ // explicitly rather than relying on an env flag surviving a Metro cache.
9
+ // A SET, not one slot. Two subscribers overlap for real: swapping between two screens that each
10
+ // mount a readout disposes the outgoing one AFTER the incoming one mounts, so a single slot would
11
+ // leave the survivor unsubscribed and the readout permanently blank.
12
+ const subscribers = new Set();
13
+ export function subscribeListDiagnostics(handlers) {
14
+ subscribers.add(handlers);
15
+ return () => {
16
+ subscribers.delete(handlers);
17
+ };
18
+ }
19
+ // The frame is built through a callback so nothing is assembled while nobody is listening — the
20
+ // snapshot reads a dozen fields off the state and would otherwise cost that on every recompute.
21
+ export function recordListFrame(build) {
22
+ if (subscribers.size === 0)
23
+ return;
24
+ const frame = build();
25
+ for (const handlers of subscribers)
26
+ handlers.onFrame?.(frame);
27
+ }
28
+ export function recordCellMove(kind, index, from, to) {
29
+ if (subscribers.size === 0)
30
+ return;
31
+ for (const handlers of subscribers)
32
+ handlers.onMove?.({ kind, index, from, to });
33
+ }
@@ -17,6 +17,19 @@ export interface IListState<ItemT> {
17
17
  scrollOffset: number;
18
18
  viewportLength: number;
19
19
  measured: Map<number, number>;
20
+ measuredOffsets: Map<number, number>;
21
+ measureVersion: number;
22
+ offsetsCache: {
23
+ count: number;
24
+ data: unknown;
25
+ getItemLayout: unknown;
26
+ averageLength: number;
27
+ averageStride: number;
28
+ measureVersion: number;
29
+ offsets: number[];
30
+ lengths: number[];
31
+ total: number;
32
+ } | null;
20
33
  committedWindow: {
21
34
  first: number;
22
35
  last: number;
@@ -65,6 +78,7 @@ export type IListAction<ItemT> = {
65
78
  kind: 'measure';
66
79
  index: number;
67
80
  length: number;
81
+ offset?: number;
68
82
  } | {
69
83
  kind: 'refresh-metrics';
70
84
  } | {
@@ -18,12 +18,16 @@
18
18
  // here, so a windowing / edge / viewability / MVCP bug — and the drift between three copies of it —
19
19
  // is fixed once for all adapters.
20
20
  import { dlog } from '@symbiote-native/engine';
21
- import { EMPTY_OFFSET, FIRST_INDEX, NO_CONTENT_LENGTH_SENT, NO_INDEX, buildOffsets, computeEndReached, computeMvcpAdjustment, computeStartReached, computeViewableSet, computeWindow, decideEdgeReached, diffViewable, highestMeasuredIndex, indexOfItem, maxMinimumViewTime, offsetForEnd, offsetForIndex, resolveAverageLength, resolveItemKey, throttleWindow, wrapFixedLayout, } from './virtualized-list.js';
21
+ import { EMPTY_OFFSET, FIRST_INDEX, NO_CONTENT_LENGTH_SENT, NO_INDEX, averageMeasuredStride, buildOffsets, computeEndReached, computeMvcpAdjustment, computeStartReached, computeViewableSet, computeWindow, decideEdgeReached, diffViewable, highestMeasuredIndex, indexOfItem, isSettledLayout, maxMinimumViewTime, offsetForEnd, offsetForIndex, resolveAverageLength, resolveItemKey, throttleWindow, wrapFixedLayout, } from './virtualized-list.js';
22
+ import { recordCellMove, recordListFrame, } from './virtualized-list-diagnostics.js';
22
23
  export function createInitialListState() {
23
24
  return {
24
25
  scrollOffset: EMPTY_OFFSET,
25
26
  viewportLength: EMPTY_OFFSET,
26
27
  measured: new Map(),
28
+ measuredOffsets: new Map(),
29
+ measureVersion: 0,
30
+ offsetsCache: null,
27
31
  committedWindow: { first: FIRST_INDEX, last: NO_INDEX },
28
32
  sentEndForContentLength: NO_CONTENT_LENGTH_SENT,
29
33
  sentStartForContentLength: NO_CONTENT_LENGTH_SENT,
@@ -58,7 +62,34 @@ function deriveMetrics(state, inputs) {
58
62
  const count = inputs.getItemCount(inputs.data);
59
63
  const fixedLayout = wrapFixedLayout(inputs.data, inputs.getItemLayout);
60
64
  const averageLength = resolveAverageLength(fixedLayout, count, state.measured);
61
- const { offsets, lengths, total } = buildOffsets(count, state.measured, fixedLayout, averageLength);
65
+ const averageStride = averageMeasuredStride(state.measuredOffsets, averageLength);
66
+ // Every input buildOffsets reads, compared before paying for it again. `data` and
67
+ // `getItemLayout` stand in for `fixedLayout`, which wrapFixedLayout mints fresh on every call
68
+ // and whose identity therefore means nothing.
69
+ const cached = state.offsetsCache;
70
+ const reusable = cached !== null &&
71
+ cached.count === count &&
72
+ cached.data === inputs.data &&
73
+ cached.getItemLayout === inputs.getItemLayout &&
74
+ cached.averageLength === averageLength &&
75
+ cached.averageStride === averageStride &&
76
+ cached.measureVersion === state.measureVersion;
77
+ const { offsets, lengths, total } = reusable
78
+ ? cached
79
+ : buildOffsets(count, state.measured, state.measuredOffsets, fixedLayout, averageLength, averageStride);
80
+ if (!reusable) {
81
+ state.offsetsCache = {
82
+ count,
83
+ data: inputs.data,
84
+ getItemLayout: inputs.getItemLayout,
85
+ averageLength,
86
+ averageStride,
87
+ measureVersion: state.measureVersion,
88
+ offsets,
89
+ lengths,
90
+ total,
91
+ };
92
+ }
62
93
  const target = computeWindow(count, offsets, lengths, state.scrollOffset, state.viewportLength, inputs.windowSize, inputs.initialNumToRender);
63
94
  const throttled = throttleWindow(target, state.committedWindow, inputs.maxToRenderPerBatch);
64
95
  state.committedWindow = throttled;
@@ -73,6 +104,29 @@ function deriveMetrics(state, inputs) {
73
104
  averageLength,
74
105
  fixedLayout,
75
106
  };
107
+ recordListFrame(() => ({
108
+ scrollOffset: state.scrollOffset,
109
+ viewportLength: state.viewportLength,
110
+ first: throttled.first,
111
+ last: throttled.last,
112
+ targetFirst: target.first,
113
+ targetLast: target.last,
114
+ count,
115
+ measuredCount: state.measured.size,
116
+ averageLength,
117
+ averageStride: averageMeasuredStride(state.measuredOffsets, averageLength),
118
+ firstOffset: offsets[throttled.first] ?? EMPTY_OFFSET,
119
+ firstRaw: state.measuredOffsets.get(throttled.first),
120
+ total,
121
+ leadingExtent: throttled.first > FIRST_INDEX
122
+ ? offsets[throttled.first - 1] +
123
+ lengths[throttled.first - 1] -
124
+ offsets[FIRST_INDEX]
125
+ : EMPTY_OFFSET,
126
+ trailingExtent: throttled.last < count - 1
127
+ ? total - offsets[throttled.last + 1]
128
+ : EMPTY_OFFSET,
129
+ }));
76
130
  return state;
77
131
  }
78
132
  function keyForOf(inputs) {
@@ -82,13 +136,61 @@ function keyForOf(inputs) {
82
136
  // (batch-fill -> end -> start -> viewability -> initial-scroll -> MVCP). Each is guarded by its own
83
137
  // dedup state (sent*ForContentLength, lastViewable, appliedInitialScroll, firstVisibleKey), so
84
138
  // running commit on every render is safe — the guards prevent a redundant fire.
139
+ // Reclassify the rendered cells and, if the viewable set changed, hand the adapter an info payload
140
+ // to fire (after minimumViewTime, if any). lastViewable is folded back only when the fire actually
141
+ // lands (the 'viewable-fired' action), so a debounce superseded mid-flight still diffs against the
142
+ // last COMMITTED set.
143
+ //
144
+ // Extracted from commitList because 'record-interaction' needs the same pass: RN's
145
+ // recordInteraction() ungates waitForInteraction and calls _updateViewableItems immediately
146
+ // (VirtualizedList.js ~288-296). Leaving the pass inline meant a list that fits its viewport and is
147
+ // never scrolled reported nothing at all after the interaction — the next commit that would have
148
+ // carried the report never came.
149
+ function viewabilityEffects(state, inputs) {
150
+ const m = state.metrics;
151
+ if (inputs.viewabilityPairs.length === EMPTY_OFFSET ||
152
+ state.viewportLength === EMPTY_OFFSET ||
153
+ m.count === FIRST_INDEX) {
154
+ return [];
155
+ }
156
+ const { tokens, map } = computeViewableSet({
157
+ first: m.first,
158
+ last: m.last,
159
+ count: m.count,
160
+ offsets: m.offsets,
161
+ lengths: m.lengths,
162
+ scrollOffset: state.scrollOffset,
163
+ viewportLength: state.viewportLength,
164
+ data: inputs.data,
165
+ getItem: inputs.getItem,
166
+ keyExtractor: inputs.keyExtractor,
167
+ pairs: inputs.viewabilityPairs,
168
+ hasInteracted: state.hasInteracted,
169
+ });
170
+ const diff = diffViewable(state.lastViewable, map, tokens);
171
+ if (!diff.hasChanged)
172
+ return [];
173
+ dlog(`VirtualizedList viewable=${tokens.length} changed=${diff.changed.length} ` +
174
+ `(window [${m.first}, ${m.last}])`);
175
+ return [
176
+ {
177
+ kind: 'fire-viewable',
178
+ info: { viewableItems: tokens, changed: diff.changed },
179
+ delay: maxMinimumViewTime(inputs.viewabilityPairs),
180
+ map,
181
+ },
182
+ ];
183
+ }
85
184
  function commitList(state, inputs) {
86
185
  const effects = [];
87
186
  const m = state.metrics;
88
187
  // Batch fill: when the throttled window has not reached the target, ask for another render tick so
89
188
  // it keeps filling toward target (RN's incremental fill).
90
189
  if (!(m.first <= m.target.first && m.last >= m.target.last)) {
91
- effects.push({ kind: 'schedule-refill', delay: inputs.updateCellsBatchingPeriod });
190
+ effects.push({
191
+ kind: 'schedule-refill',
192
+ delay: inputs.updateCellsBatchingPeriod,
193
+ });
92
194
  }
93
195
  // onEndReached: fire only when the actual last cell is rendered AND within threshold; dedup by
94
196
  // content length; re-arm on scroll away from the end (RN _maybeCallOnEdgeReached).
@@ -123,39 +225,7 @@ function commitList(state, inputs) {
123
225
  effects.push({ kind: 'fire-start-reached', distanceFromStart });
124
226
  }
125
227
  }
126
- // Viewability: reclassify the rendered cells; if the viewable set changed, hand the adapter an
127
- // info payload to fire (after minimumViewTime, if any). lastViewable is folded back only when the
128
- // fire actually lands (the 'viewable-fired' action), so a debounce that is superseded mid-flight
129
- // still diffs against the last COMMITTED set.
130
- if (inputs.viewabilityPairs.length > EMPTY_OFFSET &&
131
- state.viewportLength > EMPTY_OFFSET &&
132
- m.count !== FIRST_INDEX) {
133
- const { tokens, map } = computeViewableSet({
134
- first: m.first,
135
- last: m.last,
136
- count: m.count,
137
- offsets: m.offsets,
138
- lengths: m.lengths,
139
- scrollOffset: state.scrollOffset,
140
- viewportLength: state.viewportLength,
141
- data: inputs.data,
142
- getItem: inputs.getItem,
143
- keyExtractor: inputs.keyExtractor,
144
- pairs: inputs.viewabilityPairs,
145
- hasInteracted: state.hasInteracted,
146
- });
147
- const diff = diffViewable(state.lastViewable, map, tokens);
148
- if (diff.hasChanged) {
149
- dlog(`VirtualizedList viewable=${tokens.length} changed=${diff.changed.length} ` +
150
- `(window [${m.first}, ${m.last}])`);
151
- effects.push({
152
- kind: 'fire-viewable',
153
- info: { viewableItems: tokens, changed: diff.changed },
154
- delay: maxMinimumViewTime(inputs.viewabilityPairs),
155
- map,
156
- });
157
- }
158
- }
228
+ effects.push(...viewabilityEffects(state, inputs));
159
229
  // initialScrollIndex: once the first viewport is known, jump to that index a single time.
160
230
  if (inputs.initialScrollIndex !== undefined &&
161
231
  !state.appliedInitialScroll &&
@@ -183,7 +253,11 @@ function commitList(state, inputs) {
183
253
  effects.push({ kind: 'scroll-to', offset: EMPTY_OFFSET, animated: true });
184
254
  }
185
255
  else if (mvcp.action.kind === 'shift') {
186
- effects.push({ kind: 'scroll-to', offset: mvcp.action.offset, animated: false });
256
+ effects.push({
257
+ kind: 'scroll-to',
258
+ offset: mvcp.action.offset,
259
+ animated: false,
260
+ });
187
261
  }
188
262
  state.firstVisibleKey = mvcp.firstVisibleKey;
189
263
  return { state, effects, changed: false };
@@ -192,6 +266,25 @@ function commitList(state, inputs) {
192
266
  // target is past the last measured cell (RN VirtualizedList.js), else scroll to the resolved offset.
193
267
  function resolveScrollToIndex(state, inputs, action) {
194
268
  const m = state.metrics;
269
+ // Range check FIRST, mirroring RN VirtualizedList.js (~165-178) invariant-for-invariant. Three
270
+ // separate messages rather than one, because an empty list and an index past the end are
271
+ // different diagnoses. It has to precede the onScrollToIndexFailed branch below, or an
272
+ // out-of-range index on a list without getItemLayout would be reported as a MEASUREMENT problem
273
+ // and send the reader to inspect cell layout for what is a caller bug.
274
+ //
275
+ // Note this is the one place the range is enforced: offsetForIndex() still CLAMPS, and must, since
276
+ // scrollToEnd and initialScrollIndex resolve through it with indices that are legitimately at or
277
+ // past the edge.
278
+ const itemCount = inputs.getItemCount(inputs.data);
279
+ if (action.index < FIRST_INDEX) {
280
+ throw new Error(`scrollToIndex out of range: requested index ${action.index} but minimum is 0`);
281
+ }
282
+ if (itemCount < 1) {
283
+ throw new Error(`scrollToIndex out of range: item length ${itemCount} but minimum is 1`);
284
+ }
285
+ if (action.index >= itemCount) {
286
+ throw new Error(`scrollToIndex out of range: requested index ${action.index} is out of 0 to ${itemCount - 1}`);
287
+ }
195
288
  const measuredCeiling = highestMeasuredIndex(state.measured);
196
289
  if (inputs.getItemLayout === undefined && action.index > measuredCeiling) {
197
290
  dlog(`VirtualizedList onScrollToIndexFailed index=${action.index} ` +
@@ -230,26 +323,61 @@ export function reduceList(state, action, inputs) {
230
323
  state.scrollOffset = action.offset;
231
324
  return { state, effects: [], changed: true };
232
325
  case 'layout':
326
+ // Same settling rule as a cell measurement: the scroll host re-reports its own size after
327
+ // every relayout, and a re-derive off noise in its last bits drives the same loop.
328
+ if (isSettledLayout(state.viewportLength, action.length))
329
+ return { state, effects: [], changed: false };
233
330
  state.viewportLength = action.length;
234
331
  return { state, effects: [], changed: true };
235
- case 'measure':
236
- // A fixed getItemLayout owns cell sizes, so a measured length is ignored; a repeat of a known
237
- // length changes nothing. Both guards keep an idle onLayout from forcing a render.
332
+ case 'measure': {
333
+ // A fixed getItemLayout owns cell sizes, so a measured length is ignored.
238
334
  if (inputs.getItemLayout !== undefined)
239
335
  return { state, effects: [], changed: false };
240
- if (state.measured.get(action.index) === action.length) {
336
+ const knownLength = state.measured.get(action.index);
337
+ const knownOffset = state.measuredOffsets.get(action.index);
338
+ const lengthSettled = isSettledLayout(knownLength, action.length);
339
+ const offsetSettled = action.offset === undefined ||
340
+ isSettledLayout(knownOffset, action.offset);
341
+ // Settled means "the same measurement, re-reported" — an idle onLayout, or the float noise a
342
+ // relayout leaves behind. Bail WITHOUT storing: keeping the settled value byte-identical is
343
+ // the half that matters, because the spacer derived from it then stops moving too and the
344
+ // relayout loop has nothing left to feed on (see LAYOUT_EPSILON).
345
+ if (lengthSettled && offsetSettled)
241
346
  return { state, effects: [], changed: false };
347
+ // Each half is stored only if IT moved: a cell that slid without resizing must not have its
348
+ // length rewritten with a noisier reading of the same number.
349
+ if (!lengthSettled) {
350
+ if (knownLength !== undefined)
351
+ recordCellMove('sized', action.index, knownLength, action.length);
352
+ state.measured.set(action.index, action.length);
353
+ state.measureVersion += 1;
354
+ }
355
+ if (action.offset !== undefined && !offsetSettled) {
356
+ if (knownOffset !== undefined)
357
+ recordCellMove('moved', action.index, knownOffset, action.offset);
358
+ state.measuredOffsets.set(action.index, action.offset);
359
+ state.measureVersion += 1;
242
360
  }
243
- state.measured.set(action.index, action.length);
244
361
  return { state, effects: [], changed: true };
362
+ }
245
363
  case 'batch-tick':
246
364
  // The refill timer fired: ask for a render, whose refresh-metrics grows the window one step.
247
365
  return { state, effects: [], changed: true };
248
366
  case 'refresh-metrics':
249
- return { state: deriveMetrics(state, inputs), effects: [], changed: true };
367
+ return {
368
+ state: deriveMetrics(state, inputs),
369
+ effects: [],
370
+ changed: true,
371
+ };
250
372
  case 'record-interaction':
373
+ // The flag flip must land BEFORE the pass — computeViewableSet reads hasInteracted to decide
374
+ // whether a waitForInteraction pair is still gated.
251
375
  state.hasInteracted = true;
252
- return { state, effects: [], changed: false };
376
+ return {
377
+ state,
378
+ effects: viewabilityEffects(state, inputs),
379
+ changed: false,
380
+ };
253
381
  case 'viewable-fired':
254
382
  state.lastViewable = action.map;
255
383
  return { state, effects: [], changed: false };
@@ -258,7 +386,13 @@ export function reduceList(state, action, inputs) {
258
386
  case 'scroll-to-offset':
259
387
  return {
260
388
  state,
261
- effects: [{ kind: 'scroll-to', offset: action.offset, animated: action.animated }],
389
+ effects: [
390
+ {
391
+ kind: 'scroll-to',
392
+ offset: action.offset,
393
+ animated: action.animated,
394
+ },
395
+ ],
262
396
  changed: false,
263
397
  };
264
398
  case 'scroll-to-index':
@@ -73,8 +73,9 @@ export interface IVirtualizedListHandle extends IScrollRoutingHandle {
73
73
  }): void;
74
74
  }
75
75
  export declare function readScrollOffset(event: ISymbioteEvent, horizontal: boolean): number | undefined;
76
+ export declare function readLayoutOffset(event: ISymbioteEvent, horizontal: boolean): number | undefined;
76
77
  export declare function readLayoutLength(event: ISymbioteEvent, horizontal: boolean): number | undefined;
77
- export declare function buildOffsets(count: number, measured: Map<number, number>, fixedLayout: ((index: number) => ICellLayout) | undefined, averageLength: number): {
78
+ export declare function buildOffsets(count: number, measured: Map<number, number>, measuredOffsets: Map<number, number>, fixedLayout: ((index: number) => ICellLayout) | undefined, averageLength: number, averageStride: number): {
78
79
  offsets: number[];
79
80
  lengths: number[];
80
81
  total: number;
@@ -96,7 +97,10 @@ export declare function throttleWindow(target: {
96
97
  export declare function visiblePercent(cellOffset: number, cellLength: number, scrollOffset: number, viewportLength: number): number;
97
98
  export declare function isCellViewable(percent: number, config: IViewabilityConfig): boolean;
98
99
  export declare function offsetForIndex(index: number, viewPosition: number, viewOffset: number, count: number, offsets: number[], lengths: number[], viewportLength: number): number;
100
+ export declare const LAYOUT_EPSILON = 0.01;
101
+ export declare function isSettledLayout(known: number | undefined, reported: number): boolean;
99
102
  export declare function averageMeasuredLength(measured: Map<number, number>): number;
103
+ export declare function averageMeasuredStride(measuredOffsets: Map<number, number>, fallback: number): number;
100
104
  export declare function highestMeasuredIndex(measured: Map<number, number>): number;
101
105
  export declare function computeEndReached(total: number, scrollOffset: number, viewportLength: number, thresholdMultiplier: number): {
102
106
  distanceFromEnd: number;
@@ -152,7 +156,6 @@ export interface IListPlanParams {
152
156
  keyFor: (index: number) => string;
153
157
  stickyIndices?: ReadonlySet<number>;
154
158
  hasHeader: boolean;
155
- hasSeparators: boolean;
156
159
  }
157
160
  export declare function buildListPlan(params: IListPlanParams): IListPlan;
158
161
  export type IMvcpAction = {