@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,39 +1,20 @@
1
- // VirtualizedList logic: the framework-agnostic windowing engine. Every adapter
2
- // (React hooks, Vue reactivity) drives the SAME math from here, so a windowing /
3
- // viewability / edge-reached bug is fixed once for all adapters. The adapter
4
- // supplies only its lifecycle (refs/state/effects), the imperative handle
5
- // wiring, and the per-cell element creation (createElement / h) - never the
6
- // geometry.
7
- //
8
- // What lives here:
9
- // - the RN-matching defaults + sentinels,
10
- // - the nativeEvent payload readers (scroll offset / layout length),
11
- // - offset table + window computation + batch throttling,
12
- // - viewability classification, the viewable-set diff, and the minimumViewTime fold,
13
- // - the edge-reached (onEndReached / onStartReached) distance + threshold compute,
14
- // - the assembled child PLAN (spacer extents, in-window cell keys, sticky child
15
- // positions) the adapter maps onto its host elements,
16
- // - the shared data + imperative-handle types.
17
- //
18
- // What stays in the adapter (genuinely framework-bound): the cell CONTENT is the
19
- // framework's own children (renderItem -> ReactNode / VNode), so there is no
20
- // Descriptor render fn for a list - the shared layer for lists is this STATE/logic
21
- // module, not a view/render-*.ts.
1
+ // VirtualizedList logic: the framework-agnostic windowing engine every adapter drives the same
2
+ // math from, so a windowing/viewability/edge-reached bug is fixed once for all. The adapter
3
+ // supplies only its lifecycle, the imperative handle wiring, and per-cell element creation.
4
+ // What stays in the adapter: the cell CONTENT is the framework's own children (renderItem ->
5
+ // ReactNode / VNode), so there is no Descriptor render fn for a list — this state/logic module is
6
+ // the shared layer, not a view/render-*.ts.
22
7
  import { dlog, Platform } from '@symbiote-native/engine';
23
- // Defaults match RN. windowSize is measured in viewport-lengths (21 => ten screens
24
- // of buffer on each side of the visible region). initialNumToRender bounds the first
25
- // paint before any layout is measured. maxToRenderPerBatch / batching period mirror
26
- // RN's incremental fill defaults.
8
+ // Defaults match RN. windowSize is in viewport-lengths (21 => ten screens of buffer each side).
9
+ // initialNumToRender bounds the first paint before any layout is measured.
27
10
  export const DEFAULT_WINDOW_SIZE = 21;
28
11
  export const DEFAULT_INITIAL_NUM_TO_RENDER = 10;
29
12
  export const DEFAULT_MAX_TO_RENDER_PER_BATCH = 10;
30
13
  export const DEFAULT_UPDATE_CELLS_BATCHING_PERIOD = 50;
31
14
  export const DEFAULT_VIEW_AREA_COVERAGE_PERCENT_THRESHOLD = 0;
32
- // `_maybeCallOnEdgeReached`'s OWN fallback (`VirtualizedList.js:1567`) for whether to actually
33
- // FIRE onEndReached/onStartReached when the app gives no threshold — a flat 2 PIXELS. This is a
34
- // different RN default from `onEndReachedThresholdOrDefault`'s `?? 2`, which is a MULTIPLE of the
35
- // visible length used only for internal render-ahead windowing (a concern this engine does not
36
- // separate out); conflating the two here used to fire the callback two whole screens early.
15
+ // RN's own fallback for whether to actually FIRE onEndReached/onStartReached when the app gives no
16
+ // threshold — a flat 2 PIXELS, distinct from the windowing-only `?? 2` MULTIPLE elsewhere in RN;
17
+ // conflating the two fires the callback two whole screens early.
37
18
  export const DEFAULT_EDGE_REACHED_THRESHOLD_PX = 2;
38
19
  export const FIRST_INDEX = 0;
39
20
  export const EMPTY_OFFSET = 0;
@@ -45,10 +26,8 @@ export const ON_EDGE_REACHED_EPSILON = 0.001;
45
26
  // Sentinel for "onEndReached / onStartReached has not fired for any content length
46
27
  // yet". Real content lengths are >= 0, so -1 can never collide with one.
47
28
  export const NO_CONTENT_LENGTH_SENT = -1;
48
- // Inversion flips the content container along the scroll axis; each cell re-flips so
49
- // its own content stays upright. VirtualizedList.js `styles.verticallyInverted`: Android flips
50
- // with `scale: -1` because `scaleY: -1` can ANR on API 33+ (react-native#35350); the native side
51
- // then moves the scrollbar back via `isInvertedVirtualizedList`.
29
+ // Inversion flips the content container along the scroll axis; each cell re-flips so its own
30
+ // content stays upright. Android flips with `scale: -1` since `scaleY: -1` can ANR on API 33+.
52
31
  export function invertedYStyleFor(os) {
53
32
  return os === 'android'
54
33
  ? { transform: [{ scale: -1 }] }
@@ -74,10 +53,8 @@ export function readScrollOffset(event, horizontal) {
74
53
  return undefined;
75
54
  return readNumber(offset, horizontal ? 'x' : 'y');
76
55
  }
77
- // The cell's own position inside the scroll content, as the host reported it. Paired with
78
- // readLayoutLength at the same onLayout: this is the value buildOffsets stores VERBATIM, and it is
79
- // what lets the table describe where the content actually is rather than where a sum of heights
80
- // says it should be.
56
+ // The cell's own position in the scroll content, as the host reported it — the value buildOffsets
57
+ // stores VERBATIM, so the table describes where content actually is, not a sum of heights.
81
58
  export function readLayoutOffset(event, horizontal) {
82
59
  const native = asRecord(event.nativeEvent);
83
60
  if (native === undefined)
@@ -96,43 +73,19 @@ export function readLayoutLength(event, horizontal) {
96
73
  return undefined;
97
74
  return readNumber(layout, horizontal ? 'width' : 'height');
98
75
  }
99
- // Resolve every cell offset/length from the cache (or getItemLayout), filling gaps with
100
- // the running average so an unmeasured tail still has a plausible total. Returns the
101
- // per-index offset table plus the grand total extent.
102
- //
103
- // THE COORDINATE SPACE IS THE HOST'S, NOT A MODEL'S. `measuredOffsets` holds each cell's raw y (x
104
- // when horizontal) exactly as onLayout reported it — content-container relative, so it carries the
105
- // container's padding, the list header, and whatever spacer stood above the cell at the time. A
106
- // measured cell is placed at that value VERBATIM. It is never re-derived from a neighbour, never
107
- // rebased onto a running sum. This is react-native's ListMetricsAggregator: `getCellMetricsApprox`
108
- // returns a laid-out cell's real frame untouched and approximates only what has never been seen.
109
- //
110
- // That "verbatim" is the whole safety property, and it is not a stylistic preference — it is the
111
- // fix for the canary going blank mid-scroll (diagnosed on device 2026-08-19). The table is not
112
- // merely an output: buildListPlan sizes the leading spacer from it, the host lays the window's
113
- // cells out below that spacer, and their measured y — spacer included — comes straight back in
114
- // here. It is a closed loop. Combining two measurements arithmetically inside that loop feeds the
115
- // model's own error back to itself: with a Yoga `gap` on the content container (the canary's .grid
116
- // has one) a spacer is an extra flex child, so its presence shifts the layout by one more gap than
117
- // any pure-arithmetic model predicts, and differencing two cells measured either side of that
118
- // change banks the difference. Measured, at one gap per recompute, unbounded — onScroll fires per
119
- // frame, so the spacer walks thousands of pixels away from reality within a second of dragging and
120
- // the window lands nowhere near the viewport. Regression test:
121
- // core/components/src/state/virtualized-list-feedback.test.ts.
122
- //
123
- // Unmeasured cells are the only place an estimate lives, and they advance by the average STRIDE
124
- // (measured cell-origin to cell-origin) rather than by the average LENGTH. A height is not the
125
- // distance to the next cell: separators, section gaps and container `gap` all live in between, and
126
- // sizing an unmeasured region by heights alone leaves it short by exactly that chrome.
127
- //
128
- // A fixed getItemLayout skips all of it — authoritative by contract, and its offsets are exact.
76
+ // Resolve every cell offset/length from the cache (or getItemLayout), filling gaps with the
77
+ // running average so an unmeasured tail still has a plausible total.
78
+ // The coordinate space is the HOST's, not a model's: a measured cell is placed at onLayout's raw
79
+ // value VERBATIM, never rebased onto a running sum — differencing two measurements would compound
80
+ // a Yoga `gap` shift unboundedly once buildListPlan feeds a sized spacer back through this table.
81
+ // Unmeasured cells advance by the average STRIDE (origin to origin), not the average LENGTH —
82
+ // separators/gaps live between cells, so heights alone fall short. A fixed getItemLayout skips
83
+ // all of it, authoritative by contract.
129
84
  export function buildOffsets(count, measured, measuredOffsets, fixedLayout, averageLength, averageStride) {
130
85
  const offsets = new Array(count);
131
86
  const lengths = new Array(count);
132
- // What the average stride has left over once the average cell is accounted for: the chrome drawn
133
- // BETWEEN two cells. Estimating with this rather than the stride itself keeps a cell whose own
134
- // length IS known from being overwritten by an average — the stride is only ever used for the
135
- // part nobody measured.
87
+ // What the average stride leaves over once the average cell is accounted for: the chrome drawn
88
+ // BETWEEN two cells, used only for the part nobody measured.
136
89
  const interCellChrome = Math.max(EMPTY_OFFSET, averageStride - averageLength);
137
90
  // Where the next cell goes when its own position has never been reported.
138
91
  let cursor = EMPTY_OFFSET;
@@ -180,14 +133,10 @@ export function initialRenderRegion(count, initialScrollIndex, initialNumToRende
180
133
  return { first, last: Math.min(count, first + initialNumToRender) - 1 };
181
134
  }
182
135
  // Clamp a freshly computed window against the previously-committed one so at most
183
- // maxToRenderPerBatch new cells are added on each side per tick (RN's incremental fill).
184
- // The window grows toward the target over successive batch ticks rather than snapping in
185
- // one render: cheaper first paint on a big jump.
186
- //
187
- // With NO previous window - the list just received data - RN paints its initial region and grows
188
- // from there (`_createRenderMask` adds `_initialRenderRegion` to a window constrained from empty),
189
- // even when the viewport is already known. Snapping to the target instead mounted ~125 rows where
190
- // RN mounts 10 on a 420pt viewport (`stock-virtualized-suite.itest.tsx`).
136
+ // maxToRenderPerBatch new cells are added on each side per tick — the window grows toward the
137
+ // target over successive ticks rather than snapping in one render.
138
+ // With NO previous window, RN paints its initial region and grows from there even when the
139
+ // viewport is already known; snapping to the target instead mounts far more rows than RN would.
191
140
  export function throttleWindow(target, previous, maxToRenderPerBatch, initialRegion) {
192
141
  if (previous.last < previous.first)
193
142
  return initialRegion.last < initialRegion.first ? target : initialRegion;
@@ -198,12 +147,9 @@ export function throttleWindow(target, previous, maxToRenderPerBatch, initialReg
198
147
  return target;
199
148
  return { first, last };
200
149
  }
201
- // A cell is viewable when its visible fraction clears the configured threshold
202
- // (`ViewabilityHelper.js`'s `_isViewable` + `computeViewableItems`). Two percents exist and they
203
- // are NOT interchangeable: `viewAreaCoveragePercentThreshold` is a fraction of the VIEWPORT,
204
- // `itemVisiblePercentThreshold` a fraction of the CELL's own length — a short cell mostly visible
205
- // in a tall viewport clears the second easily while failing the first. Area wins whenever it is
206
- // set (vendor checks `viewAreaCoveragePercentThreshold != null` first); item only when it is not.
150
+ // A cell is viewable when its visible fraction clears the configured threshold. Two percents exist
151
+ // and are NOT interchangeable: viewAreaCoveragePercentThreshold is a fraction of the VIEWPORT,
152
+ // itemVisiblePercentThreshold a fraction of the CELL's own length. Area wins whenever set.
207
153
  export function isCellViewable(cellOffset, cellLength, scrollOffset, viewportLength, config) {
208
154
  const top = cellOffset - scrollOffset;
209
155
  const bottom = top + cellLength;
@@ -212,10 +158,8 @@ export function isCellViewable(cellOffset, cellLength, scrollOffset, viewportLen
212
158
  // construction, not a zero percent happening to clear a zero threshold.
213
159
  if (bottom <= EMPTY_OFFSET || top >= viewportLength)
214
160
  return false;
215
- // RN's own `_isEntirelyVisible` shortcut: viewable in EITHER mode regardless of the cell's
216
- // share of the viewport, since an area threshold sized to the viewport could otherwise reject
217
- // every fully-visible cell smaller than that share. `bottom > top` excludes a zero-length cell
218
- // (no measurement yet), which vendor falls through to the percent math instead.
161
+ // Fully inside the viewport is viewable in EITHER mode, or an area threshold sized to the
162
+ // viewport could reject every fully-visible cell smaller than that share.
219
163
  if (top >= EMPTY_OFFSET && bottom <= viewportLength && bottom > top) {
220
164
  return true;
221
165
  }
@@ -242,18 +186,9 @@ export function offsetForIndex(index, viewPosition, viewOffset, count, offsets,
242
186
  const positioned = cellOffset - viewPosition * (viewportLength - cellLength);
243
187
  return Math.max(EMPTY_OFFSET, positioned - viewOffset);
244
188
  }
245
- // Two layout readings are the SAME measurement unless they differ by more than this.
246
- //
247
- // A relayout does not reproduce a float bit-for-bit: the cell positions are derived from a spacer
248
- // height that is itself a float, so an onLayout that changed nothing observable still comes back a
249
- // few ulps off. Compared with ===, every one of those counts as a change — the reducer stores it,
250
- // the spacer derived from it moves in its last bits, Fabric commits the new value, Yoga relays out,
251
- // and the fresh onLayout starts the next turn. A loop at frame rate, from a difference no screen
252
- // can show. Device-measured 2026-08-19: 1203 recomputes over one short drag, its log full of
253
- // `27.33 -> 27.33 (-0.00)`.
254
- //
255
- // The smallest change a host can actually express is one device pixel — a third of a point at @3x —
256
- // so this sits ~30x below any real move and ~1e11 above the noise.
189
+ // Two layout readings are the SAME measurement unless they differ by more than this — a relayout
190
+ // doesn't reproduce a float bit-for-bit, so comparing with === loops at frame rate on noise no
191
+ // screen can show. One device pixel (a third of a point at @3x) sits far above that noise.
257
192
  export const LAYOUT_EPSILON = 0.01;
258
193
  // `known` is optional because a first measurement has nothing to settle against, and must count as
259
194
  // a change.
@@ -270,15 +205,12 @@ export function averageMeasuredLength(measured) {
270
205
  sum += length;
271
206
  return sum / measured.size;
272
207
  }
273
- // Average origin-to-origin distance between two ADJACENT measured cells — the length plus whatever
274
- // chrome the list draws in the gap (a separator, a section gap, the content container's Yoga
275
- // `gap`). Only adjacent pairs qualify: across a hole the distance covers cells nobody measured.
276
- //
277
- // This is what an unmeasured cell advances by, and it is deliberately not averageMeasuredLength.
278
- // Sizing an unmeasured region by heights alone makes the model shorter than the content, so the
279
- // spacer standing in for that region under-reserves and everything below it slides up — the
280
- // jump-and-return the canary showed before the offsets became host-absolute. Falls back to the
281
- // length average while no adjacent pair has been measured yet.
208
+ // Average origin-to-origin distance between two ADJACENT measured cells — length plus whatever
209
+ // chrome the list draws in the gap. Only adjacent pairs qualify: across a hole the distance
210
+ // covers cells nobody measured.
211
+ // Deliberately not averageMeasuredLength: sizing an unmeasured region by heights alone makes the
212
+ // model shorter than the content, so its spacer under-reserves and everything below slides up.
213
+ // Falls back to the length average while no adjacent pair has been measured yet.
282
214
  export function averageMeasuredStride(measuredOffsets, fallback) {
283
215
  let sum = EMPTY_OFFSET;
284
216
  let pairs = EMPTY_OFFSET;
@@ -301,13 +233,10 @@ export function highestMeasuredIndex(measured) {
301
233
  }
302
234
  return highest;
303
235
  }
304
- // onEndReached distance + threshold test (RN _maybeCallOnEdgeReached). The adapter still
305
- // gates on "the last cell is actually rendered" and dedups by content length via its own
306
- // ref; this returns only the pure geometry.
307
- //
308
- // `thresholdMultiplier` is `undefined` for "the app gave no onEndReachedThreshold" — RN's own
309
- // unset-case answer is a flat `DEFAULT_EDGE_REACHED_THRESHOLD_PX`, never a viewport-length
310
- // multiple, so `undefined` must NOT be defaulted to a multiplier at the call site.
236
+ // onEndReached distance + threshold test. The adapter still gates on "the last cell is actually
237
+ // rendered" and dedups by content length via its own ref; this returns only the pure geometry.
238
+ // `thresholdMultiplier` undefined means the app gave no onEndReachedThreshold — RN's own answer
239
+ // is a flat DEFAULT_EDGE_REACHED_THRESHOLD_PX, never a viewport-length multiple.
311
240
  export function computeEndReached(total, scrollOffset, viewportLength, thresholdMultiplier) {
312
241
  let distanceFromEnd = total - (scrollOffset + viewportLength);
313
242
  if (distanceFromEnd < ON_EDGE_REACHED_EPSILON)
@@ -368,10 +297,8 @@ export function computeViewableSet(params) {
368
297
  }
369
298
  return { tokens, map };
370
299
  }
371
- // The `changed` delta between two viewable sets: newly viewable (true) and newly hidden
372
- // (false). hasChanged is false when the viewable KEY set is identical, so the adapter can
373
- // skip firing (RN dedups the same way). Hidden tokens come straight from the previous map,
374
- // so no rescan of all N items.
300
+ // The `changed` delta between two viewable sets: newly viewable (true) and newly hidden (false).
301
+ // hasChanged is false when the viewable KEY set is identical, so the adapter can skip firing.
375
302
  export function diffViewable(previous, current, currentTokens) {
376
303
  let hasChanged = previous.size !== current.size;
377
304
  if (!hasChanged) {
@@ -416,11 +343,9 @@ function findClosestStickyIndexBelow(first, stickyIndices) {
416
343
  }
417
344
  return NO_INDEX;
418
345
  }
419
- // Compute the windowed child PLAN: the spacer extents, the in-window cells (index + key),
420
- // the force-mounted sticky cell (if any) ahead of the window, and the sticky child
421
- // positions. The adapter walks this plan and creates the host elements (createElement / h)
422
- // plus the framework cell content. This is the shared half of the render; only the element
423
- // creation and the user's renderItem stay per-adapter.
346
+ // Compute the windowed child PLAN: spacer extents, in-window cells, the force-mounted sticky cell
347
+ // (if any), and sticky child positions. The adapter walks this and creates the host elements;
348
+ // only element creation and the user's renderItem stay per-adapter.
424
349
  export function buildListPlan(params) {
425
350
  const cells = [];
426
351
  const closestStickyIndex = params.stickyIndices !== undefined
@@ -429,14 +354,9 @@ export function buildListPlan(params) {
429
354
  const forcedStickyCell = closestStickyIndex === NO_INDEX
430
355
  ? undefined
431
356
  : { index: closestStickyIndex, key: params.keyFor(closestStickyIndex) };
432
- // A spacer stands in for a contiguous run of cells, so its extent is the distance from the first
433
- // of them to the far edge of the last — a difference between two positions the host itself
434
- // reported, never a sum of heights. That is what carries the chrome BETWEEN those cells
435
- // (separators, section gaps, the container's Yoga `gap`) without the model having to know it
436
- // exists, and it is why the spacer lands the following cell exactly where it already was: the
437
- // spacer occupies one child slot, precisely as the region it replaces began and ended on a cell
438
- // boundary. Summing heights instead under-reserves by the chrome; rebasing onto a running model
439
- // re-introduces the feedback loop buildOffsets exists to avoid.
357
+ // A spacer's extent is the distance from the first cell it replaces to the far edge of the
358
+ // last — a difference between two host-reported positions, never a sum of heights, so the next
359
+ // cell lands exactly where it was. Rebasing would reintroduce buildOffsets' feedback loop.
440
360
  const regionExtent = (from, to) => to < from
441
361
  ? EMPTY_OFFSET
442
362
  : params.offsets[to] + params.lengths[to] - params.offsets[from];
@@ -451,11 +371,9 @@ export function buildListPlan(params) {
451
371
  ? params.total - params.offsets[params.last + 1]
452
372
  : EMPTY_OFFSET;
453
373
  const stickyChildPositions = [];
454
- // The header (when present) is child 0; the leading spacer (when non-empty) is the next
455
- // child; the forced sticky cell (when present) plus its own gap spacer follow. Each cell is
456
- // EXACTLY one child — an ItemSeparatorComponent rides INSIDE the cell's own measuring wrapper
457
- // (RN VirtualizedListCellRenderer.js:218-221), so it neither shifts these positions nor shows
458
- // up in the geometry as chrome the spacers would have to account for separately.
374
+ // The header (when present) is child 0; the leading spacer is the next child; the forced sticky
375
+ // cell plus its own gap spacer follow. Each cell is EXACTLY one child — an ItemSeparatorComponent
376
+ // rides INSIDE the cell's own measuring wrapper, so it never shifts these positions.
459
377
  let childPosition = (params.hasHeader ? 1 : 0) + (leadingExtent > EMPTY_OFFSET ? 1 : 0);
460
378
  if (forcedStickyCell !== undefined) {
461
379
  stickyChildPositions.push(childPosition);
@@ -526,10 +444,8 @@ export function computeMvcpAdjustment(params) {
526
444
  action: { kind: 'shift', offset: params.scrollOffset + insertedExtent },
527
445
  };
528
446
  }
529
- // RN's real default (`VirtualizeUtils.js`'s `keyExtractor`): an object item's own `key`, else its
530
- // `id`, else the index. Most apps never pass `keyExtractor` at all and rely on this to keep list
531
- // identity stable across inserts/removes — falling straight to the index (what this used to do)
532
- // silently breaks that the moment two items swap position.
447
+ // RN's real default: an object item's own `key`, else its `id`, else the index. Most apps rely on
448
+ // this to keep list identity stable across inserts/removes — the index alone breaks on a swap.
533
449
  export function defaultKeyExtractor(item, index) {
534
450
  if (typeof item === 'object' && item !== null) {
535
451
  const record = item;
@@ -564,10 +480,9 @@ export function offsetForEnd(total, viewportLength) {
564
480
  export function isSeparatorGapInRange(gapIndex, count) {
565
481
  return gapIndex >= FIRST_INDEX && gapIndex <= count - 2;
566
482
  }
567
- // onEndReached / onStartReached fire decision + content-length dedup (RN _maybeCallOnEdgeReached).
568
- // The geometry (withinThreshold) comes from computeEndReached/computeStartReached; this folds in the
569
- // "edge cell actually rendered" gate, the dedup against the last-fired content length, and the
570
- // re-arm once scrolled away from the edge. Returns whether to fire plus the next dedup sentinel.
483
+ // onEndReached / onStartReached fire decision + content-length dedup. The geometry comes from
484
+ // computeEndReached/computeStartReached; this folds in the "edge cell rendered" gate and the
485
+ // dedup against the last-fired content length.
571
486
  export function decideEdgeReached(params) {
572
487
  const { withinThreshold, edgeCellRendered, total, sentForContentLength } = params;
573
488
  if (withinThreshold && edgeCellRendered && sentForContentLength !== total) {
@@ -1,14 +1,4 @@
1
- // The ellipsize modes a Text accepts. THE DEFAULTS THAT USED TO BE APPLIED HERE ARE GONE (2026-09-18)
2
- // — they are the platform's, so they live in the engine's payload builder and reach every `RCTText`
3
- // whoever authored it: `foldTextDefaults`, `SymbioteFabricProps.cpp`.
4
- //
5
- // `resolveTextProps` was the third implementation of a two-line rule. Its one runtime caller was
6
- // Button's label, which wrote both keys onto a node the builder was about to default anyway — the
7
- // same seed shape `seedTextDefaults` had in three adapters, at a smaller scale. The rule reaches
8
- // that node by being keyed on the COMPONENT, so nothing has to hand it down.
9
- //
10
- // Why the original fold existed at all, kept because the bug is easy to reintroduce: we declared both
11
- // props in all four adapters and applied NEITHER default, so native fell back to its own `clip`.
12
- // Device-observed 2026-08-19 on examples/svelte — a Text with `numberOfLines={1}` cut mid-word with
13
- // no ellipsis. Nothing failed; the text was simply wrong.
1
+ // The ellipsize modes a Text accepts. The defaults are the platform's — `foldTextDefaults` in
2
+ // `SymbioteFabricProps.cpp` — reaching every `RCTText` whoever authored it. Declaring both props
3
+ // with neither defaulted silently falls back to native's own `clip`: no error, just wrong text.
14
4
  export {};
@@ -4,54 +4,23 @@
4
4
  //
5
5
  // The inner view is not optional chrome. RN's Button is the one control in the library that ships
6
6
  // a finished appearance, and on Android that appearance — a filled, elevated, rounded Material
7
- // button with an uppercased label — lives entirely on that node. A port that renders only the
8
- // touchable and the text is an iOS port, which is what this file was until 2026-09-09: blue text on
9
- // nothing, wherever Android was.
7
+ // button with an uppercased label — lives entirely on that node.
10
8
  //
11
9
  // What is NOT here, because a layer below already does it: the `aria-*` -> `accessibilityState`
12
10
  // fold RN performs in `Button.js:326-331`. The engine folds those for every node
13
11
  // (`core/engine/src/accessibility-props.ts`), so repeating it here would fold twice.
14
- // `BUTTON_ACCESSIBILITY_ROLE` and `resolveButtonImportantForAccessibility` WERE HERE and are gone
15
- // (2026-09-18). Both are `foldButtonProps` in `SymbioteFabricProps.cpp` now, and neither had a
16
- // caller left afterwards — only its own unit test, which is the shape this project calls a mirror:
17
- // a JS copy of a rule that runs elsewhere, kept alive by the test that asserts it. It would have
18
- // stayed green forever while meaning nothing.
19
- // `buttonTextStyle` AND `resolveButtonTextStyle` ARE GONE (2026-09-18) — the label's style is
20
- // `foldButtonLabelStyle` in `SymbioteFabricProps.cpp`, reached off the label text's own tag. Its
21
- // constants live THERE now and are not mirrored here; this file keeps only what Android's own fold
22
- // still needs.
23
- //
24
- // It was the last rule in this primitive to move and it needed a seam none of the others did. Its
25
- // inputs are the BUTTON's `color` and `disabled`, and the node it hangs on is the button's
26
- // GRANDCHILD on iOS (`button -> view -> text`) and its child on Android — so `ownerProps`, which
27
- // answers "my parent", could not reach it. `IAncestorLookup` asks for the nearest ancestor carrying
28
- // a tag instead, which is a CSS ancestor selector and makes one rule right on both trees.
29
- // `buttonViewStyle` AND `resolveButtonViewStyle` ARE GONE (2026-09-18), and with them the last of
30
- // Button's folds. The Material look is inside `foldButtonProps` in `SymbioteFabricProps.cpp`, behind
31
- // `#ifdef ANDROID` — where it belongs, since `{}` on iOS was the whole of its other branch.
32
- //
33
- // Its five constants went too rather than staying as a copy nothing reads.
34
- //
35
- // WHAT MADE THIS ONE DIFFERENT from the four ports before it: the Android branch is no longer
36
- // untestable. The test host grew an arm that compiles `#ifdef ANDROID`
37
- // (`core/engine/cpp/tests/CMakeLists.txt`, `SYMBIOTE_PLATFORM_ANDROID`), so the style, the `color`
38
- // override and the disabled greying are asserted against the COMMITTED PAYLOAD in
39
- // `core/engine/cpp/tests/js/android-rules.itest.ts` — strictly better than the mocked-`Platform.OS`
40
- // unit test that went with them, which asserted a JS function rather than what Fabric receives.
41
- // `resolveButtonTitle` IS GONE (2026-09-18) — the uppercase-on-Android rule is `foldButtonLabel` in
42
- // `SymbioteFabricProps.cpp`, reached off the label's own tag. It had no caller left but its own two
43
- // unit tests, which is the orphan shape this migration keeps turning up: a JS copy of a rule that
44
- // runs elsewhere, kept alive by the test asserting it, green forever and proving nothing.
45
- //
46
- // A COVERAGE GAP WENT WITH IT, recorded rather than hidden. The C++ rule is `#ifdef ANDROID` — a raw
47
- // text commits as `RCTRawText` on both platforms, so unlike `Switch`/`AndroidSwitch` there is no view
48
- // NAME for a rule to branch on — and this host is not Android. The deleted Android test reached the
49
- // branch by mocking `Platform.OS`; what it mocked was a JS function that no longer exists. Same
50
- // class as `android_ripple` and `decelerationRate`'s constants, and closing it means an Android arm
51
- // of the test host, not a mock.
52
- //
53
- // One behaviour difference shipped with the move and is deliberate: RN uppercases through
54
- // JavaScript's full-Unicode `toUpperCase`, and the C++ rule is ASCII-only. See `foldButtonLabel`.
12
+ // `BUTTON_ACCESSIBILITY_ROLE`, `resolveButtonImportantForAccessibility`, and the Android style
13
+ // constants are `foldButtonProps`/`foldButtonLabelStyle` in `SymbioteFabricProps.cpp` now — no JS
14
+ // mirror is kept here to avoid a rule with no caller staying green for the wrong reason.
15
+ // `foldButtonLabelStyle` reaches the label's style off its own tag via `IAncestorLookup` —
16
+ // needed because the label is the button's GRANDCHILD on iOS but its CHILD on Android, so
17
+ // `ownerProps` (nearest parent) can't reach it either way.
18
+ // The Material look (`foldButtonProps`, `SymbioteFabricProps.cpp`, `#ifdef ANDROID`) — style,
19
+ // `color` override, disabled greying — is asserted against the COMMITTED PAYLOAD in
20
+ // `android-rules.itest.ts`, not a JS unit test mocking `Platform.OS`.
21
+ // The uppercase-on-Android rule is `foldButtonLabel` in `SymbioteFabricProps.cpp`. Untested here
22
+ // (this host isn't Android, and raw text has no view NAME for a rule to branch on). Deliberate
23
+ // divergence: RN uppercases via full-Unicode `toUpperCase`, the C++ rule is ASCII-only.
55
24
  /**
56
25
  * Whether the button is disabled, which `aria-disabled` may decide on its own.
57
26
  *
@@ -4,28 +4,12 @@
4
4
  // the two by id. There is no JS-side translation — style / nativeID / backgroundColor map straight
5
5
  // onto the intrinsic.
6
6
  //
7
- // THE MAPPING FUNCTION IS GONE (2026-09-18) and only the type is left, which is the honest residue:
8
- // `mapInputAccessoryViewProps` took the bag apart and put it back together unchanged, so the
9
- // behavior stopped calling it and nothing else ever did. Angular still names the type for its
10
- // `@Input()` declarations (`adapters/angular/src/elements.ts`), so the shape stays; the fold does
11
- // not. Why it was never a rule, and the numeric `backgroundColor` it used to drop:
12
- // `core/components/src/behaviors/input-accessory-view.ts`.
7
+ // THE MAPPING FUNCTION IS GONE — only the type is left: `mapInputAccessoryViewProps` took the bag
8
+ // apart and put it back unchanged, so nothing calls it any more. Angular still names the type for
9
+ // its `@Input()` declarations (`adapters/angular/src/elements.ts`); the fold does not exist.
13
10
  export {};
14
- // WHAT THE DELETED FOLD KNEW THAT THIS FILE NO LONGER HAS TO, kept because the trap is a property
15
- // of the engine and outlives the function that hit it. The `undefined` guards on `nativeID` /
16
- // `backgroundColor` were LOAD-BEARING, and not for the reason they look it:
17
- //
18
- // authored <input-accessory-view id="p" testID="p">
19
- // guarded RCTInputAccessoryView{testID, nativeID:"p"}
20
- // unguarded RCTInputAccessoryView{testID} <- the alias result, deleted
21
- //
22
- // `setProp` collapses an undefined value to an absent key, so a conditional write is normally
23
- // cosmetic (`.claude/rules/fabric-boolean-event-gates.md`) — it is destructive precisely when the
24
- // key has an ALIAS SOURCE. `id` arrives, something renames it to `nativeID`, and a
25
- // `nativeID: undefined` written afterwards deletes what the rename just produced. Removing the
26
- // guards on that reasoning cost a day on 2026-09-01.
27
- //
28
- // It cannot recur here, and that is the point: the rename is `foldIdAlias` in
29
- // `SymbioteFabricProps.cpp` now, one rule at the end of the payload build with nothing downstream
30
- // of it to overwrite the result. Anything that reintroduces a JS-side alias for this tag
31
- // reintroduces the trap with it.
11
+ // WHAT THE DELETED FOLD KNEW: the `undefined` guards on `nativeID`/`backgroundColor` were
12
+ // load-bearing because `nativeID` has an ALIAS SOURCE (`id`) — `setProp` collapsing undefined to
13
+ // an absent key is normally cosmetic, but here it would delete what the alias fold just produced.
14
+ // It cannot recur here: the rename is `foldIdAlias` in `SymbioteFabricProps.cpp`, the LAST rule
15
+ // in the payload build with nothing downstream to overwrite it.
@@ -12,10 +12,9 @@ export function resolveDisabledAccessibilityState(accessibilityState, disabled)
12
12
  ? { ...accessibilityState, disabled }
13
13
  : accessibilityState;
14
14
  }
15
- // RN computes `focusable` in all five touchables and we computed it nowhere until 2026-09-09, so a
16
- // DISABLED control stayed focusable — a keyboard, a TV remote or switch control could land on
17
- // something that cannot be pressed. There are TWO formulas, split by primitive and not by platform;
18
- // do not collapse them.
15
+ // RN computes `focusable` in all five touchables; without it a DISABLED control stays focusable —
16
+ // a keyboard, TV remote or switch control could land on something that cannot be pressed. There
17
+ // are TWO formulas, split by primitive and not by platform; do not collapse them.
19
18
  //
20
19
  // Pressable defaults it ON (Pressable.js:258). `!== false`, not `?? true`: only a literal `false`
21
20
  // opts out, the same shape `accessible` uses one file over.
@@ -1,10 +1,6 @@
1
- // ScrollView: the render half (framework-agnostic). The Fabric tree is nested: the scroll
2
- // view wraps a content view that holds the children (RN's own ScrollView.js shape). Resolving
3
- // decelerationRate, picking the per-axis intrinsics/base style, reading layout dimensions, and
4
- // the content-size dedupe are all platform- and framework-invariant, so they live here. The
5
- // adapter owns the lifecycle (refs/state/effects) and the element assembly; it calls these pure
6
- // helpers from prepareScrollView. What diverges per platform, and how a RefreshControl
7
- // integrates, stays in the adapter's .ios/.android files.
1
+ // ScrollView's render half (framework-agnostic): picking intrinsics, reading layout dimensions,
2
+ // and the content-size dedupe are platform-invariant, so they live here; the adapter owns
3
+ // lifecycle + element assembly, calling these pure helpers from prepareScrollView.
8
4
  import { readLayoutField } from './layout-event.js';
9
5
  // Thin re-export kept for the existing public surface (adapters import this name from
10
6
  // `@symbiote-native/components`); the actual field read is shared with render-scroll-sticky's
@@ -31,20 +27,16 @@ export function selectScrollIntrinsics(isHorizontal, contentContainerStyle) {
31
27
  contentStyle,
32
28
  };
33
29
  }
34
- // When sticky headers are active the scroll offset must reach the AnimatedValue; RN raises the scroll
35
- // event rate for it (ScrollView.js:1798): throttle 1 on the native driver (it can afford every frame),
36
- // 16 on the JS fallback (Animated.event drives the value in JS). Without sticky headers the user's
37
- // throttle passes through untouched. These two magic numbers were copied into all three adapters.
30
+ // When sticky headers are active, the scroll offset must reach the AnimatedValue; RN raises the
31
+ // scroll event rate for it (ScrollView.js:1798): throttle 1 on the native driver, 16 on the JS
32
+ // fallback. Without sticky headers the user's throttle passes through untouched.
38
33
  const STICKY_NATIVE_SCROLL_THROTTLE = 1;
39
34
  const STICKY_JS_SCROLL_THROTTLE = 16;
40
- // maintainVisibleContentPosition (and Android snapToAlignment) anchor against MOUNTED cell views;
41
- // Android Fabric view-flattens layout-only cells away, so RN keeps them as real views with
42
- // collapsableChildren={false} on the content container (ScrollView.js:1731 `preserveChildren`).
43
- // No-op on iOS.
44
- //
45
- // Named and exported rather than inlined because the host behavior's slot fold needs the same
46
- // answer from a different place — it has the owner's raw props and none of the sticky inputs
47
- // `resolveScrollForwarding` also takes. One exported function is what keeps the two from drifting.
35
+ // maintainVisibleContentPosition/snapToAlignment anchor against mounted cells; Android Fabric
36
+ // view-flattens layout-only cells away, so RN keeps them real with collapsableChildren={false}
37
+ // (ScrollView.js:1731). No-op on iOS.
38
+ // Exported rather than inlined: the host behavior's slot fold needs the same answer from a
39
+ // place with only the owner's raw props, none of `resolveScrollForwarding`'s sticky inputs.
48
40
  export function preservesContentChildren(maintainVisibleContentPosition, snapToAlignment) {
49
41
  return (maintainVisibleContentPosition !== undefined ||
50
42
  snapToAlignment !== undefined);
@@ -75,10 +67,8 @@ export function resolveScrollForwarding(inputs) {
75
67
  collapsableChildren,
76
68
  };
77
69
  }
78
- // Did the content view's measured size actually change since the last fire? RN synthesizes
79
- // onContentSizeChange from the inner content view's onLayout, but that fires on every layout
80
- // pass; RN dedupes so the user handler only sees real size changes (ScrollView.js). First
81
- // measurement (last === null) always counts as a change.
70
+ // onContentSizeChange synthesizes from the content view's onLayout, which fires on every layout
71
+ // pass — RN dedupes so the user handler only sees real size changes.
82
72
  export function didContentSizeChange(last, next) {
83
73
  if (last === null)
84
74
  return true;
@@ -27,20 +27,11 @@ export function selectableBackgroundBorderless(rippleRadius) {
27
27
  export function rippleBackground(color, borderless, rippleRadius) {
28
28
  return { type: 'RippleAndroid', color, borderless, rippleRadius };
29
29
  }
30
- // `backgroundProps` WAS HERE AND IS GONE (2026-09-18). It mapped the resolved background plus
31
- // `useForeground` onto the native slot Android reads, and that is the `#ifdef ANDROID` tail of
32
- // `foldCloneOntoChild` in `SymbioteFabricProps.cpp`.
33
- //
34
- // A JS COPY OF A RULE THAT RUNS IN C++, with no runtime caller and no test, reachable only through
35
- // the package barrel. Found by asking what each `Platform.OS` branch still left in
36
- // `core/components` is FOR. Its C++ twin is covered on both arms —
37
- // `android-rules.android.itest.ts` asserts the foreground slot and `clone-onto-child-payload.itest.ts`
38
- // asserts its absence off Android — and break-testing the gate fires exactly one of them.
39
- //
40
- // `canUseNativeForeground` below stays, and the distinction is the reusable half: it is a QUESTION
41
- // an app asks the platform (`TouchableNativeFeedback.canUseNativeForeground()` is RN's own public
42
- // static), not a rule that decides a payload. Same class as the slider reading a folded
43
- // `accessibilityState` — asking is not reimplementing.
30
+ // `backgroundProps` mapped the resolved background + `useForeground` onto the native slot — now
31
+ // the `#ifdef ANDROID` tail of `foldCloneOntoChild` in `SymbioteFabricProps.cpp`, covered by
32
+ // `android-rules.android.itest.ts` and `clone-onto-child-payload.itest.ts`.
33
+ // `canUseNativeForeground` below stays: it's a QUESTION an app asks the platform (RN's own public
34
+ // static), not a rule deciding a payload — same class as reading a folded `accessibilityState`.
44
35
  /**
45
36
  * RN's four statics, under RN's own spelling — `TouchableNativeFeedback.Ripple(color, borderless)`.
46
37
  *