@symbiote-native/components 3.1.1 → 3.1.3

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 (40) hide show
  1. package/build/behaviors/activity-indicator/shared.js +29 -71
  2. package/build/behaviors/button.d.ts +0 -9
  3. package/build/behaviors/button.js +47 -233
  4. package/build/behaviors/image-background.js +21 -81
  5. package/build/behaviors/image.js +3 -10
  6. package/build/behaviors/input-accessory-view.js +10 -51
  7. package/build/behaviors/pressable.d.ts +5 -51
  8. package/build/behaviors/pressable.js +101 -161
  9. package/build/behaviors/refresh-control.js +8 -2
  10. package/build/behaviors/scroll-view/index.android.js +10 -30
  11. package/build/behaviors/scroll-view/responder.d.ts +3 -2
  12. package/build/behaviors/scroll-view/responder.js +19 -21
  13. package/build/behaviors/scroll-view/shared.js +69 -185
  14. package/build/behaviors/scroll-view/sticky.d.ts +0 -8
  15. package/build/behaviors/scroll-view/sticky.js +54 -142
  16. package/build/behaviors/switch.js +18 -4
  17. package/build/behaviors/text-input.d.ts +0 -8
  18. package/build/behaviors/text-input.js +77 -162
  19. package/build/behaviors/touchable-highlight.js +14 -54
  20. package/build/behaviors/touchable-native-feedback.js +9 -32
  21. package/build/behaviors/touchable-opacity.d.ts +0 -7
  22. package/build/behaviors/touchable-opacity.js +65 -81
  23. package/build/behaviors/touchable-without-feedback.js +25 -41
  24. package/build/component-names/index.android.js +6 -8
  25. package/build/component-names/shared.js +12 -42
  26. package/build/index.js +13 -19
  27. package/build/scroll-view-commands.js +3 -11
  28. package/build/state/pressable.js +18 -43
  29. package/build/state/sticky-header-reducer.js +103 -149
  30. package/build/state/touchable.js +3 -5
  31. package/build/state/virtualized-list-reducer.js +21 -48
  32. package/build/state/virtualized-list.js +63 -148
  33. package/build/text-props.js +3 -13
  34. package/build/view/render-button.js +13 -44
  35. package/build/view/render-input-accessory-view.js +8 -24
  36. package/build/view/render-pressable/index.js +3 -4
  37. package/build/view/render-scroll-view.js +13 -23
  38. package/build/view/render-touchable-native-feedback.js +5 -14
  39. package/host-primitives.cjs +33 -207
  40. package/package.json +3 -3
@@ -1,19 +1,6 @@
1
- // Sticky-header effect machine: the framework-agnostic STATE MACHINE that folds every per-adapter
2
- // sticky-header effect skeleton into one place. Before this, each adapter (React useState/useEffect,
3
- // Vue refs/watchEffect, Angular fields/ngOnChanges — and, in Angular, a SECOND copy inside the
4
- // projection wrapper) re-wrote the same per-header sequence — gate the freshly-rebuilt
5
- // interpolation's spurious zero, debounce the settled translateY, rebuild the top/inverted ranges on
6
- // a layout/collision-input change — in its own reactive dialect, so the zero-swallow gate, the
7
- // debounce-delay pick, and the rebuild decision lived FOUR times and quietly drifted.
8
- //
9
- // Here the whole decision half is one pure `reduceSticky(state, action, inputs) -> {state, effects}`,
10
- // PER sticky header. The adapter keeps only what is genuinely framework-bound: translate a native
11
- // event / animated tick / timer fire into an ACTION, hold ONE state cell, and EXECUTE the returned
12
- // EFFECTS with its own primitives (build the interpolation node + wire addListener/removeListener,
13
- // hold the debounce setTimeout, trigger its own re-render, record the cross-talk y). The math leaf
14
- // (computeStickyInterpolation) and the debounce-window pick (stickyDebounceMs) still live in
15
- // ./view/render-scroll-sticky; this module composes them into the ordered transition every adapter
16
- // shares. Ported from ScrollViewStickyHeader.js's effect.
1
+ // Sticky-header effect machine: one pure `reduceSticky(state, action, inputs)` shared by every
2
+ // adapter, so the zero-swallow gate, debounce pick, and rebuild decision live once, not once per
3
+ // framework's reactive dialect. Math leaf + debounce window live in ./view/render-scroll-sticky.
17
4
  import { dlog } from '@symbiote-native/engine';
18
5
  import { computeStickyInterpolation, stickyDebounceMs, } from '../view/render-scroll-sticky.js';
19
6
  // The un-measured identity interpolation (RN: a fresh AnimatedInterpolation before the header has
@@ -43,8 +30,8 @@ function arraysEqual(a, b) {
43
30
  return false;
44
31
  return a.every((value, index) => value === b[index]);
45
32
  }
46
- // Recompute the derived interpolation ranges off the current state + inputs (wrapping the load-bearing
47
- // computeStickyInterpolation math), store them, and return them for the rebuild effect.
33
+ // Recompute the derived ranges off the current state + inputs, store them, and return them for
34
+ // the rebuild effect.
48
35
  function deriveRanges(state, inputs) {
49
36
  const { inputRange, outputRange } = computeStickyInterpolation({
50
37
  measured: state.measured,
@@ -58,14 +45,99 @@ function deriveRanges(state, inputs) {
58
45
  state.outputRange = outputRange;
59
46
  return { inputRange, outputRange };
60
47
  }
61
- // The single transition every sticky-header adapter shares. The adapter maps a native event / animated
62
- // tick / timer fire to an action, calls this, stores the returned state, and executes the effects.
63
- // DIAGNOSTIC-only, gated: identifies which header instance a log line belongs to across a
64
- // device dump without needing full state dumps at every call — layoutY doubles as a stable
65
- // per-header id once measured (0 before the first layout).
48
+ // Diagnostic-only, gated: identifies which header instance a log line belongs to — layoutY
49
+ // doubles as a stable per-header id once measured (0 before the first layout).
66
50
  function headerTag(state) {
67
51
  return `y=${state.layoutY}`;
68
52
  }
53
+ // Redundant-geometry guard: Yoga re-fires onLayout with the same y/height on an unrelated
54
+ // relayout — without this a fresh prop identity per redundant layout trips an unbounded
55
+ // rebuild loop.
56
+ function handleLayout(state, action, inputs) {
57
+ const alreadyAtThisGeometry = state.measured &&
58
+ state.layoutY === action.y &&
59
+ state.layoutHeight === action.height;
60
+ state.layoutY = action.y;
61
+ state.layoutHeight = action.height;
62
+ state.measured = true;
63
+ const effects = [];
64
+ if (inputs.index !== undefined) {
65
+ effects.push({ kind: 'record-header-y', index: inputs.index, y: action.y });
66
+ }
67
+ if (alreadyAtThisGeometry && state.rangesEmitted) {
68
+ dlog(`STICKY[reducer ${headerTag(state)}] layout: redundant geometry, skipped rebuild`);
69
+ return { state, effects, changed: effects.length > 0 };
70
+ }
71
+ const { inputRange, outputRange } = deriveRanges(state, inputs);
72
+ state.rangesEmitted = true;
73
+ dlog(`STICKY[reducer ${headerTag(state)}] layout: measured=true inputRange=${JSON.stringify(inputRange)} ` +
74
+ `outputRange=${JSON.stringify(outputRange)}`);
75
+ effects.push({ kind: 'rebuild-interpolation', inputRange, outputRange });
76
+ return { state, effects, changed: true };
77
+ }
78
+ // Redundant-ranges guard (same cause as layout's): compare the derived result, not raw
79
+ // inputs. `hadEmitted` is load-bearing: an unmeasured header derives the same identity
80
+ // ranges the initial state holds, so without it the first dispatch reads as redundant.
81
+ function handleInputsChanged(state, inputs) {
82
+ const previousInputRange = state.inputRange;
83
+ const previousOutputRange = state.outputRange;
84
+ const hadEmitted = state.rangesEmitted;
85
+ const { inputRange, outputRange } = deriveRanges(state, inputs);
86
+ state.rangesEmitted = true;
87
+ if (hadEmitted &&
88
+ arraysEqual(previousInputRange, inputRange) &&
89
+ arraysEqual(previousOutputRange, outputRange)) {
90
+ dlog(`STICKY[reducer ${headerTag(state)}] inputs-changed: ranges unchanged, skipped rebuild`);
91
+ return { state, effects: [], changed: false };
92
+ }
93
+ dlog(`STICKY[reducer ${headerTag(state)}] inputs-changed: inputRange ${JSON.stringify(previousInputRange)}->` +
94
+ `${JSON.stringify(inputRange)} outputRange ${JSON.stringify(previousOutputRange)}->` +
95
+ `${JSON.stringify(outputRange)}`);
96
+ return {
97
+ state,
98
+ effects: [{ kind: 'rebuild-interpolation', inputRange, outputRange }],
99
+ changed: true,
100
+ };
101
+ }
102
+ // A freshly-rebuilt interpolation re-emits 0 to its listeners; swallow that first zero once a
103
+ // real value has committed. Otherwise schedule the host-tuned debounce.
104
+ function handleAnimatedTick(state, action, inputs) {
105
+ if (action.value === 0 && !state.haveReceivedInitialZeroTranslateY) {
106
+ state.haveReceivedInitialZeroTranslateY = true;
107
+ dlog(`STICKY[reducer ${headerTag(state)}] animated-tick: swallowed re-emitted zero translateY`);
108
+ return { state, effects: [], changed: false };
109
+ }
110
+ dlog(`STICKY[reducer ${headerTag(state)}] animated-tick: scheduling debounce delay=${stickyDebounceMs(inputs.os)} ` +
111
+ `value=${action.value}`);
112
+ return {
113
+ state,
114
+ effects: [
115
+ {
116
+ kind: 'schedule-debounce',
117
+ delay: stickyDebounceMs(inputs.os),
118
+ value: action.value,
119
+ },
120
+ ],
121
+ changed: false,
122
+ };
123
+ }
124
+ // Already-sitting-at-this-value bails to break a re-arriving settled value's cascade
125
+ // (apply-passthrough -> fresh identity -> reconnect -> another tick).
126
+ function handleDebounceFired(state, action) {
127
+ if (state.translateY === action.value) {
128
+ dlog(`STICKY[reducer ${headerTag(state)}] debounce-fired: already at translateY=${action.value}, no-op`);
129
+ return { state, effects: [], changed: false };
130
+ }
131
+ dlog(`STICKY[reducer ${headerTag(state)}] debounce-fired: committing translateY=${action.value}`);
132
+ state.translateY = action.value;
133
+ if (action.value !== 0)
134
+ state.haveReceivedInitialZeroTranslateY = false;
135
+ return {
136
+ state,
137
+ effects: [{ kind: 'apply-passthrough', translateY: action.value }],
138
+ changed: true,
139
+ };
140
+ }
69
141
  export function reduceSticky(state, action, inputs) {
70
142
  dlog(`STICKY[reducer ${headerTag(state)}] action=${action.kind}` +
71
143
  (action.kind === 'layout'
@@ -76,131 +148,13 @@ export function reduceSticky(state, action, inputs) {
76
148
  : '') +
77
149
  ` inputs={inverted=${inputs.inverted} scrollViewHeight=${inputs.scrollViewHeight} nextHeaderLayoutY=${inputs.nextHeaderLayoutY}}`);
78
150
  switch (action.kind) {
79
- case 'layout': {
80
- // Record own y/height, mark measured, rebuild the interpolation, and (when the reducer owns the
81
- // cross-talk index) hand the parent this header's y so the PREVIOUS header learns its collision
82
- // point. Matches RN ScrollViewStickyHeader.js._onLayout.
83
- //
84
- // Redundant-geometry guard: Yoga legitimately re-fires onLayout with the SAME y/height (relayout
85
- // passes triggered by an unrelated sibling, a native-driven prop commit, ...) — every consumer
86
- // must tolerate that. React's port gets this guard for FREE: `setLayoutY(sameValue)` is a no-op
87
- // (React bails out of re-rendering on an unchanged primitive), so a redundant onLayout never
88
- // reaches the rebuild `useEffect`. This reducer has no such implicit bail-out, so it must skip
89
- // the rebuild explicitly — device-confirmed (2026-08-13) that omitting this guard lets a
90
- // native-driven rebuild (fresh AnimatedProps/AnimatedStyle graph, fresh native connect) commit a
91
- // fresh prop identity on every redundant layout, which can itself provoke another relayout pass —
92
- // an unbounded same-tick rebuild ping-pong that trips Svelte's effect_update_depth_exceeded guard.
93
- const alreadyAtThisGeometry = state.measured &&
94
- state.layoutY === action.y &&
95
- state.layoutHeight === action.height;
96
- state.layoutY = action.y;
97
- state.layoutHeight = action.height;
98
- state.measured = true;
99
- const effects = [];
100
- if (inputs.index !== undefined) {
101
- effects.push({
102
- kind: 'record-header-y',
103
- index: inputs.index,
104
- y: action.y,
105
- });
106
- }
107
- if (alreadyAtThisGeometry && state.rangesEmitted) {
108
- dlog(`STICKY[reducer ${headerTag(state)}] layout: redundant geometry, skipped rebuild`);
109
- return { state, effects, changed: effects.length > 0 };
110
- }
111
- const { inputRange, outputRange } = deriveRanges(state, inputs);
112
- state.rangesEmitted = true;
113
- dlog(`STICKY[reducer ${headerTag(state)}] layout: measured=true inputRange=${JSON.stringify(inputRange)} ` +
114
- `outputRange=${JSON.stringify(outputRange)}`);
115
- effects.push({ kind: 'rebuild-interpolation', inputRange, outputRange });
116
- return { state, effects, changed: true };
117
- }
118
- case 'inputs-changed': {
119
- // A collision/viewport input changed (RN effect deps: inverted, scrollViewHeight,
120
- // nextHeaderLayoutY): recompute the ranges and rebuild.
121
- //
122
- // Redundant-ranges guard (sibling of 'layout's `alreadyAtThisGeometry` above, same root
123
- // cause): the adapter's own mount `$effect` re-dispatches 'inputs-changed' whenever its
124
- // `inverted`/`scrollViewHeight`/`nextHeaderLayoutY` derived values re-evaluate — which can
125
- // happen on an unrelated parent re-render (e.g. VirtualizedList re-deriving
126
- // `nextHeaderLayoutYFor(cell.index)` off its cross-talk Map on every reactive pass) even when
127
- // the COMPUTED value is identical. React gets no implicit protection here either (this is a
128
- // real `useEffect` with real deps), but RN's own deps array only fires on an ACTUAL primitive
129
- // change; a framework-agnostic caller re-dispatching on every derive needs the reducer itself
130
- // to compare the RESULT (the ranges), not the raw inputs (which may recompute to the same
131
- // ranges via different intermediate values) — device-confirmed (2026-08-13) this is a second,
132
- // independent source of the same unbounded same-tick rebuild loop the 'layout' guard fixed.
133
- const previousInputRange = state.inputRange;
134
- const previousOutputRange = state.outputRange;
135
- const hadEmitted = state.rangesEmitted;
136
- const { inputRange, outputRange } = deriveRanges(state, inputs);
137
- state.rangesEmitted = true;
138
- // `hadEmitted` is load-bearing, not defensive: an unmeasured header derives exactly the
139
- // identity ranges the initial state already holds, so without it the FIRST dispatch (Angular
140
- // sends one from ngOnInit, before any layout) reads as redundant and the header never emits
141
- // a rebuild at all — its wrapper then never commits. Regression-covered in
142
- // sticky-header-reducer.test.ts.
143
- if (hadEmitted &&
144
- arraysEqual(previousInputRange, inputRange) &&
145
- arraysEqual(previousOutputRange, outputRange)) {
146
- dlog(`STICKY[reducer ${headerTag(state)}] inputs-changed: ranges unchanged, skipped rebuild`);
147
- return { state, effects: [], changed: false };
148
- }
149
- dlog(`STICKY[reducer ${headerTag(state)}] inputs-changed: inputRange ${JSON.stringify(previousInputRange)}->` +
150
- `${JSON.stringify(inputRange)} outputRange ${JSON.stringify(previousOutputRange)}->` +
151
- `${JSON.stringify(outputRange)}`);
152
- return {
153
- state,
154
- effects: [{ kind: 'rebuild-interpolation', inputRange, outputRange }],
155
- changed: true,
156
- };
157
- }
158
- case 'animated-tick': {
159
- // A freshly-rebuilt interpolation re-emits 0 to its listeners; swallow that first zero once a
160
- // real value has committed (RN). Otherwise schedule the host-tuned debounce that pushes the
161
- // settled value into the committed transform for hit-testing.
162
- if (action.value === 0 && !state.haveReceivedInitialZeroTranslateY) {
163
- state.haveReceivedInitialZeroTranslateY = true;
164
- dlog(`STICKY[reducer ${headerTag(state)}] animated-tick: swallowed re-emitted zero translateY`);
165
- return { state, effects: [], changed: false };
166
- }
167
- dlog(`STICKY[reducer ${headerTag(state)}] animated-tick: scheduling debounce delay=${stickyDebounceMs(inputs.os)} ` +
168
- `value=${action.value}`);
169
- return {
170
- state,
171
- effects: [
172
- {
173
- kind: 'schedule-debounce',
174
- delay: stickyDebounceMs(inputs.os),
175
- value: action.value,
176
- },
177
- ],
178
- changed: false,
179
- };
180
- }
181
- case 'debounce-fired': {
182
- // Already sitting at this value: emit nothing. Same bail-out the 'layout' case above spells
183
- // out, for the same reason - React's own header gets it free from setTranslateY (an unchanged
184
- // primitive bails out of the re-render), a reducer has to say it. Device-confirmed 2026-08-18:
185
- // without it a re-arriving settled value emits apply-passthrough, the adapter force-renders,
186
- // the passthrough prop gets a fresh identity, the animated graph reconnects and re-emits into
187
- // another tick -> another debounce -> another passthrough. One header survives it; a screen of
188
- // 200 trips React's "Maximum update depth exceeded" and takes the app down.
189
- if (state.translateY === action.value) {
190
- dlog(`STICKY[reducer ${headerTag(state)}] debounce-fired: already at translateY=${action.value}, no-op`);
191
- return { state, effects: [], changed: false };
192
- }
193
- // The debounce completed: commit the settled translateY. Once a NON-zero value commits, re-arm
194
- // the swallow gate so the next interpolation rebuild's spurious 0 is dropped (RN).
195
- dlog(`STICKY[reducer ${headerTag(state)}] debounce-fired: committing translateY=${action.value}`);
196
- state.translateY = action.value;
197
- if (action.value !== 0)
198
- state.haveReceivedInitialZeroTranslateY = false;
199
- return {
200
- state,
201
- effects: [{ kind: 'apply-passthrough', translateY: action.value }],
202
- changed: true,
203
- };
204
- }
151
+ case 'layout':
152
+ return handleLayout(state, action, inputs);
153
+ case 'inputs-changed':
154
+ return handleInputsChanged(state, inputs);
155
+ case 'animated-tick':
156
+ return handleAnimatedTick(state, action, inputs);
157
+ case 'debounce-fired':
158
+ return handleDebounceFired(state, action);
205
159
  }
206
160
  }
@@ -100,11 +100,9 @@ export function createTouchableFeedbackHandlers(config, runtime, callbacks) {
100
100
  },
101
101
  };
102
102
  }
103
- // ---- RN-audited additions (2026-08-19) --------------------------------------------------------
104
- //
105
- // Measured against .vendors/react-native. Everything above predates that audit and is still what
106
- // the React, Vue, Svelte and Angular adapters call; the names below are the RN-accurate forms and
107
- // are additive on purpose, so the tree stays green while each adapter migrates.
103
+ // ---- RN-audited additions ----
104
+ // Measured against .vendors/react-native. The names below are the RN-accurate forms, kept
105
+ // additive so the tree stays green while each adapter migrates off the names above.
108
106
  // RN's Touchable* family OVERRIDES Pressability's own floor with 0 — TouchableOpacity.js:195,
109
107
  // TouchableHighlight.js:203 and TouchableWithoutFeedback.js all pass `minPressDuration: 0`. So
110
108
  // 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,
@@ -1,22 +1,12 @@
1
1
  // VirtualizedList orchestration reducer: the framework-agnostic STATE MACHINE that folds every
2
- // per-adapter effect skeleton into one place. Before this, each adapter (React useEffect, Vue
3
- // watch, Angular ngAfterViewChecked) re-wrote the same sequence — recompute the window, gate
4
- // onEndReached on `last === count - 1`, dedup by content length, run viewability, apply MVCP — in
5
- // its own reactive dialect, and the predicates in that glue (`last === count - 1`, `first === 0`,
6
- // the batch-fill catch-up test, the viewability guards) lived THREE times and quietly drifted.
7
- //
8
- // Here the whole decision half is one pure `reduceList(state, action, inputs) -> {state, effects}`.
9
- // The adapter keeps only what is genuinely framework-bound: translate a native event into an
10
- // ACTION, hold ONE state cell, and EXECUTE the returned EFFECTS with its own primitives (a native
11
- // scrollTo, a callback/emit, a setTimeout, a re-render). The geometry leaves (buildOffsets /
12
- // computeWindow / computeMvcpAdjustment / …) still live in ./virtualized-list; this module composes
13
- // them into the ordered transition every adapter shares.
14
- //
15
- // Effect EXECUTION stays per-adapter by design: which framework hook fires the commit, how a
16
- // native scrollTo is dispatched, how a debounce timer is held — an effect-list DESCRIBES the work,
17
- // it does not run it. State TRANSITIONS (including the derived window metrics) are owned entirely
18
- // here, so a windowing / edge / viewability / MVCP bug — and the drift between three copies of it —
19
- // is fixed once for all adapters.
2
+ // per-adapter effect skeleton into one place, so the same predicates no longer live in three
3
+ // reactive dialects and quietly drift.
4
+ // The whole decision half is one pure `reduceList(state, action, inputs) -> {state, effects}`. The
5
+ // adapter keeps only what's genuinely framework-bound: translate a native event into an ACTION,
6
+ // hold ONE state cell, and EXECUTE the returned EFFECTS with its own primitives.
7
+ // Effect EXECUTION stays per-adapter by design — an effect list DESCRIBES the work, it doesn't run
8
+ // it. State TRANSITIONS (including derived window metrics) are owned entirely here, so a
9
+ // windowing/edge/viewability/MVCP bug is fixed once for all adapters.
20
10
  import { dlog } from '@symbiote-native/engine';
21
11
  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, initialRenderRegion, wrapFixedLayout, } from './virtualized-list.js';
22
12
  import { recordCellMove, recordListFrame, } from './virtualized-list-diagnostics.js';
@@ -132,20 +122,12 @@ function deriveMetrics(state, inputs) {
132
122
  function keyForOf(inputs) {
133
123
  return (index) => resolveItemKey(inputs.getItem(inputs.data, index), index, inputs.keyExtractor);
134
124
  }
135
- // The after-render pass: every deferred effect, in the order 2 of 3 adapters already ran them
136
- // (batch-fill -> end -> start -> viewability -> initial-scroll -> MVCP). Each is guarded by its own
137
- // dedup state (sent*ForContentLength, lastViewable, appliedInitialScroll, firstVisibleKey), so
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
- //
125
+ // Reclassify the rendered cells and, if the viewable set changed, hand the adapter an info
126
+ // payload to fire. lastViewable folds back only when the fire actually lands, so a debounce
127
+ // superseded mid-flight still diffs against the last COMMITTED set.
144
128
  // 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.
129
+ // recordInteraction() ungates waitForInteraction immediately, and leaving this inline meant a
130
+ // never-scrolled list reported nothing after the interaction.
149
131
  function viewabilityEffects(state, inputs) {
150
132
  const m = state.metrics;
151
133
  if (inputs.viewabilityPairs.length === EMPTY_OFFSET ||
@@ -266,15 +248,10 @@ function commitList(state, inputs) {
266
248
  // target is past the last measured cell (RN VirtualizedList.js), else scroll to the resolved offset.
267
249
  function resolveScrollToIndex(state, inputs, action) {
268
250
  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.
251
+ // Range check FIRST: it must precede the onScrollToIndexFailed branch below, or an out-of-range
252
+ // index would be reported as a MEASUREMENT problem instead of a caller bug.
253
+ // The one place the range is enforced: offsetForIndex() still CLAMPS, since scrollToEnd and
254
+ // initialScrollIndex resolve through it with indices legitimately at or past the edge.
278
255
  const itemCount = inputs.getItemCount(inputs.data);
279
256
  if (action.index < FIRST_INDEX) {
280
257
  throw new Error(`scrollToIndex out of range: requested index ${action.index} but minimum is 0`);
@@ -313,10 +290,8 @@ function resolveScrollToIndex(state, inputs, action) {
313
290
  // an action, calls this, stores the returned state, and executes the returned effects.
314
291
  export function reduceList(state, action, inputs) {
315
292
  switch (action.kind) {
316
- // Scalar transitions never recompute the window — they set the input and ask for a render; the
317
- // metrics derive runs exactly ONCE per render, in the 'refresh-metrics' the adapter fires from
318
- // its render body. That single-derive-per-render invariant is what advances the throttled
319
- // committedWindow one step per frame (deriving here too would advance it twice).
293
+ // Scalar transitions never recompute the window — metrics derive runs exactly ONCE per
294
+ // render, in 'refresh-metrics', or committedWindow would advance twice per frame.
320
295
  case 'scroll':
321
296
  // First scroll is the interaction that ungates waitForInteraction viewability configs.
322
297
  state.hasInteracted = true;
@@ -338,10 +313,8 @@ export function reduceList(state, action, inputs) {
338
313
  const lengthSettled = isSettledLayout(knownLength, action.length);
339
314
  const offsetSettled = action.offset === undefined ||
340
315
  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).
316
+ // Settled means the same measurement, re-reported. Bail WITHOUT storing: keeping the value
317
+ // byte-identical is what stops the spacer moving and starves the relayout loop.
345
318
  if (lengthSettled && offsetSettled)
346
319
  return { state, effects: [], changed: false };
347
320
  // Each half is stored only if IT moved: a cell that slid without resizing must not have its