@symbiote-native/components 0.4.0 → 1.0.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 (67) hide show
  1. package/README.md +30 -8
  2. package/build/accessibility-props.d.ts +11 -0
  3. package/build/accessibility-props.js +30 -117
  4. package/build/behaviors/image.d.ts +3 -0
  5. package/build/behaviors/image.js +123 -0
  6. package/build/behaviors/input-accessory-view.d.ts +3 -0
  7. package/build/behaviors/input-accessory-view.js +70 -0
  8. package/build/behaviors/pressable.d.ts +2 -0
  9. package/build/behaviors/pressable.js +310 -0
  10. package/build/behaviors/switch.d.ts +2 -0
  11. package/build/behaviors/switch.js +182 -0
  12. package/build/behaviors/text-input.d.ts +14 -0
  13. package/build/behaviors/text-input.js +291 -0
  14. package/build/bootstrap/index.js +1 -1
  15. package/build/component-names/index.android.js +23 -1
  16. package/build/component-names/index.ios.js +9 -1
  17. package/build/component-names/shared.d.ts +1 -1
  18. package/build/component-names/shared.js +40 -1
  19. package/build/descriptor.d.ts +8 -0
  20. package/build/descriptor.js +27 -0
  21. package/build/fold-host-bag.d.ts +15 -0
  22. package/build/fold-host-bag.js +99 -0
  23. package/build/index.d.ts +25 -12
  24. package/build/index.js +18 -6
  25. package/build/resolve-intrinsic.d.ts +7 -0
  26. package/build/resolve-intrinsic.js +49 -0
  27. package/build/state/pressable.d.ts +9 -0
  28. package/build/state/pressable.js +133 -37
  29. package/build/state/sticky-header-reducer.js +28 -5
  30. package/build/state/switch.js +1 -1
  31. package/build/state/text-input.d.ts +7 -1
  32. package/build/state/text-input.js +46 -8
  33. package/build/state/touchable.d.ts +30 -1
  34. package/build/state/touchable.js +94 -5
  35. package/build/state/virtualized-list-diagnostics.d.ts +31 -0
  36. package/build/state/virtualized-list-diagnostics.js +33 -0
  37. package/build/state/virtualized-list-reducer.d.ts +14 -0
  38. package/build/state/virtualized-list-reducer.js +179 -45
  39. package/build/state/virtualized-list.d.ts +5 -2
  40. package/build/state/virtualized-list.js +143 -34
  41. package/build/state-style.d.ts +15 -0
  42. package/build/state-style.js +47 -0
  43. package/build/text-props.d.ts +9 -0
  44. package/build/text-props.js +25 -0
  45. package/build/view/render-activity-indicator.js +37 -3
  46. package/build/view/render-image/index.d.ts +2 -0
  47. package/build/view/render-image/index.js +42 -5
  48. package/build/view/render-input-accessory-view.d.ts +2 -0
  49. package/build/view/render-input-accessory-view.js +41 -6
  50. package/build/view/render-keyboard-avoiding-view.d.ts +14 -2
  51. package/build/view/render-keyboard-avoiding-view.js +52 -6
  52. package/build/view/render-modal.js +4 -2
  53. package/build/view/render-pressable/index.js +3 -1
  54. package/build/view/render-scroll-sticky.js +1 -1
  55. package/build/view/render-scroll-view.js +9 -3
  56. package/build/view/render-switch.js +11 -2
  57. package/build/view/render-text-input.js +6 -2
  58. package/build/view/render-touchable-highlight.d.ts +11 -1
  59. package/build/view/render-touchable-highlight.js +11 -10
  60. package/build/view/render-touchable-native-feedback.js +5 -1
  61. package/host-primitives.cjs +380 -0
  62. package/host-primitives.d.cts +35 -0
  63. package/lowering-fixtures.cjs +259 -0
  64. package/lowering-fixtures.d.cts +17 -0
  65. package/package.json +42 -5
  66. package/specialize-state-style.cjs +219 -0
  67. package/specialize-state-style.d.cts +15 -0
@@ -11,6 +11,7 @@
11
11
  // latest count, while `lastNativeText` is bookkeeping the controlled-write effect mutates
12
12
  // without a render. So the logic layer is the pure folds/maps + the controlled-write predicate;
13
13
  // each adapter holds the two pieces in ITS own primitives (React useState/useRef, Vue ref/let).
14
+ import { Platform } from '@symbiote-native/engine';
14
15
  // The acknowledged count starts at 0, native has reported nothing yet, so the first
15
16
  // controlled write echoes 0 and lands on eventLag 0.
16
17
  export const INITIAL_EVENT_COUNT = 0;
@@ -29,11 +30,25 @@ const inputModeToKeyboardType = {
29
30
  email: 'email-address',
30
31
  none: 'default',
31
32
  numeric: 'number-pad',
32
- search: 'default',
33
33
  tel: 'phone-pad',
34
34
  text: 'default',
35
35
  url: 'url',
36
36
  };
37
+ // `search` is the ONE inputMode token RN resolves per platform (TextInput.js:815-825):
38
+ // search: Platform.OS === 'ios' ? 'web-search' : 'default'
39
+ // iOS has a dedicated search keyboard whose return key is a magnifier; every other host falls back
40
+ // to the default one. It is deliberately absent from the map above, so there is one source for it
41
+ // rather than a map entry and an override that can drift apart.
42
+ //
43
+ // The host is an ARGUMENT rather than a `Platform.OS` read inside the body, because the headless
44
+ // Platform module always resolves to iOS (platform/index.ts re-exports index.ios) — a direct read
45
+ // would leave the android branch permanently unprovable. resolveTextInputProps supplies the real
46
+ // value. This costs the callers nothing: the exported fold signature is unchanged.
47
+ export function keyboardTypeForInputMode(inputMode, os) {
48
+ if (inputMode === 'search')
49
+ return os === 'ios' ? 'web-search' : 'default';
50
+ return mapAutoComplete(inputModeToKeyboardType, inputMode);
51
+ }
37
52
  // RN's enterKeyHint -> returnKeyType map (TextInput.js:805). Note `enter` -> 'default'.
38
53
  const enterKeyHintToReturnKeyType = {
39
54
  done: 'done',
@@ -122,7 +137,9 @@ const autoCompleteWebToTextContentType = {
122
137
  // if the token has no native equivalent. The caller decides the per-platform fallback. Named
123
138
  // for its first user (autoComplete), but it is the one generic safe lookup every map shares.
124
139
  export function mapAutoComplete(map, token) {
125
- return Object.prototype.hasOwnProperty.call(map, token) ? map[token] : undefined;
140
+ return Object.prototype.hasOwnProperty.call(map, token)
141
+ ? map[token]
142
+ : undefined;
126
143
  }
127
144
  // RN folds W3C `autoComplete` per platform (TextInput.js:938): Android takes the mapped
128
145
  // `autoComplete` token (falling back to the raw token), iOS takes the mapped `textContentType`
@@ -187,7 +204,7 @@ export function resolveTextInputProps(input) {
187
204
  const folded = foldAutoComplete(input.autoComplete);
188
205
  return {
189
206
  keyboardType: input.inputMode !== undefined
190
- ? mapAutoComplete(inputModeToKeyboardType, input.inputMode)
207
+ ? keyboardTypeForInputMode(input.inputMode, Platform.OS)
191
208
  : input.keyboardType,
192
209
  returnKeyType: input.enterKeyHint !== undefined
193
210
  ? mapAutoComplete(enterKeyHintToReturnKeyType, input.enterKeyHint)
@@ -195,11 +212,32 @@ export function resolveTextInputProps(input) {
195
212
  editable: input.readOnly !== undefined ? !input.readOnly : input.editable,
196
213
  submitBehavior: foldSubmitBehavior(input.submitBehavior, input.blurOnSubmit, input.multiline),
197
214
  selectionColor: input.selectionColor,
198
- cursorColor: input.cursorColor !== undefined ? input.cursorColor : input.selectionColor,
199
- selectionHandleColor: input.selectionHandleColor !== undefined ? input.selectionHandleColor : input.selectionColor,
200
- underlineColorAndroid: input.underlineColorAndroid !== undefined ? input.underlineColorAndroid : 'transparent',
215
+ cursorColor: input.cursorColor !== undefined
216
+ ? input.cursorColor
217
+ : input.selectionColor,
218
+ selectionHandleColor: input.selectionHandleColor !== undefined
219
+ ? input.selectionHandleColor
220
+ : input.selectionColor,
221
+ underlineColorAndroid: input.underlineColorAndroid !== undefined
222
+ ? input.underlineColorAndroid
223
+ : 'transparent',
201
224
  autoComplete: folded.autoComplete,
202
- textContentType: input.textContentType !== undefined ? input.textContentType : folded.textContentType,
203
- showSoftInputOnFocus: input.inputMode !== undefined ? input.inputMode !== 'none' : input.showSoftInputOnFocus,
225
+ textContentType: input.textContentType !== undefined
226
+ ? input.textContentType
227
+ : folded.textContentType,
228
+ showSoftInputOnFocus: input.inputMode !== undefined
229
+ ? input.inputMode !== 'none'
230
+ : input.showSoftInputOnFocus,
204
231
  };
205
232
  }
233
+ const TEXT_INPUT_CALLBACKS = {
234
+ onValueChange: true,
235
+ onFocus: true,
236
+ onBlur: true,
237
+ onEndEditing: true,
238
+ onSubmitEditing: true,
239
+ onKeyPress: true,
240
+ onSelectionChange: true,
241
+ onContentSizeChange: true,
242
+ };
243
+ export const TEXT_INPUT_CALLBACK_NAMES = Object.keys(TEXT_INPUT_CALLBACKS);
@@ -1,11 +1,11 @@
1
1
  import { type ISymbioteEvent } from '@symbiote-native/engine';
2
2
  export declare const DEFAULT_ACTIVE_OPACITY = 0.2;
3
+ export declare const OPACITY_ACTIVE_GRANT_DURATION_MS = 0;
3
4
  export declare const OPACITY_ACTIVE_DURATION_MS = 150;
4
5
  export declare const OPACITY_INACTIVE_DURATION_MS = 250;
5
6
  export declare const RESTING_OPACITY = 1;
6
7
  export declare const DEFAULT_HIGHLIGHT_CHILD_OPACITY = 0.85;
7
8
  export declare const DEFAULT_UNDERLAY_COLOR = "black";
8
- export declare const DEFAULT_MIN_PRESS_DURATION_MS = 130;
9
9
  export type ITouchableHandler = (event: ISymbioteEvent) => void;
10
10
  export interface IPressTimingProps {
11
11
  delayPressIn?: number;
@@ -34,3 +34,32 @@ export interface ITouchableFeedbackHandlers {
34
34
  handlePressOut: ITouchableHandler;
35
35
  }
36
36
  export declare function createTouchableFeedbackHandlers(config: ITouchableFeedbackConfig, runtime: ITouchableFeedbackRuntime, callbacks: ITouchableFeedbackCallbacks): ITouchableFeedbackHandlers;
37
+ export declare const TOUCHABLE_MIN_PRESS_DURATION_MS = 0;
38
+ export declare function restingOpacityFromStyle(style: unknown): number;
39
+ export interface ITouchablePressHandlerProps {
40
+ onPress?: unknown;
41
+ onPressIn?: unknown;
42
+ onPressOut?: unknown;
43
+ onLongPress?: unknown;
44
+ }
45
+ export declare function hasTouchablePressHandler(props: ITouchablePressHandlerProps): boolean;
46
+ export interface IHighlightUnderlayRuntime {
47
+ hideTimerCancel: (() => void) | undefined;
48
+ }
49
+ export declare function createHighlightUnderlayRuntime(): IHighlightUnderlayRuntime;
50
+ export interface IHighlightUnderlayConfig {
51
+ delayPressOut: number;
52
+ hasPressHandler: boolean;
53
+ schedule: (callback: () => void, ms: number) => () => void;
54
+ }
55
+ export interface IHighlightUnderlayCallbacks {
56
+ setShown: (shown: boolean) => void;
57
+ onShowUnderlay?: () => void;
58
+ onHideUnderlay?: () => void;
59
+ }
60
+ export interface IHighlightUnderlayHandlers {
61
+ handlePressIn: ITouchableHandler;
62
+ handlePress: ITouchableHandler;
63
+ handlePressOut: ITouchableHandler;
64
+ }
65
+ export declare function createHighlightUnderlayHandlers(config: IHighlightUnderlayConfig, runtime: IHighlightUnderlayRuntime, callbacks: IHighlightUnderlayCallbacks): IHighlightUnderlayHandlers;
@@ -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':