@symbiote-native/components 3.1.0 → 3.1.2

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 (38) hide show
  1. package/README.md +17 -18
  2. package/build/behaviors/activity-indicator/shared.js +29 -71
  3. package/build/behaviors/button.d.ts +0 -9
  4. package/build/behaviors/button.js +48 -234
  5. package/build/behaviors/image-background.js +21 -81
  6. package/build/behaviors/image.js +3 -10
  7. package/build/behaviors/input-accessory-view.js +10 -51
  8. package/build/behaviors/pressable.d.ts +0 -48
  9. package/build/behaviors/pressable.js +59 -149
  10. package/build/behaviors/scroll-view/index.android.js +10 -30
  11. package/build/behaviors/scroll-view/shared.js +55 -186
  12. package/build/behaviors/scroll-view/sticky.d.ts +0 -8
  13. package/build/behaviors/scroll-view/sticky.js +54 -142
  14. package/build/behaviors/text-input.d.ts +0 -8
  15. package/build/behaviors/text-input.js +57 -156
  16. package/build/behaviors/touchable-highlight.js +14 -54
  17. package/build/behaviors/touchable-native-feedback.js +9 -32
  18. package/build/behaviors/touchable-opacity.js +3 -18
  19. package/build/behaviors/touchable-without-feedback.js +6 -24
  20. package/build/bootstrap/index.d.ts +1 -0
  21. package/build/bootstrap/index.js +2 -1
  22. package/build/component-names/index.android.js +6 -8
  23. package/build/component-names/shared.js +12 -42
  24. package/build/index.js +13 -19
  25. package/build/scroll-view-commands.js +9 -17
  26. package/build/state/pressable.js +18 -43
  27. package/build/state/sticky-header-reducer.js +103 -149
  28. package/build/state/touchable.js +3 -5
  29. package/build/state/virtualized-list-reducer.js +21 -48
  30. package/build/state/virtualized-list.js +63 -148
  31. package/build/text-props.js +3 -13
  32. package/build/view/render-button.js +13 -53
  33. package/build/view/render-input-accessory-view.js +8 -24
  34. package/build/view/render-pressable/index.js +3 -4
  35. package/build/view/render-scroll-view.js +13 -23
  36. package/build/view/render-touchable-native-feedback.js +5 -14
  37. package/host-primitives.cjs +33 -207
  38. package/package.json +3 -3
@@ -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
@@ -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,63 +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
- import { Platform } from '@symbiote-native/engine';
15
- // Button.js:394-437, one constant per literal so a value cannot drift silently. The LABEL's seven
16
- // went with `resolveButtonTextStyle` on 2026-09-18 — they live in `SymbioteFabricProps.cpp` beside
17
- // the rule that reads them and are deliberately not duplicated here.
18
- const ANDROID_BUTTON_BLUE = '#2196F3';
19
- const ANDROID_DISABLED_BACKGROUND = '#dfdfdf';
20
- const ANDROID_ELEVATION = 4;
21
- const ANDROID_DISABLED_ELEVATION = 0;
22
- const ANDROID_BORDER_RADIUS = 2;
23
- // `BUTTON_ACCESSIBILITY_ROLE` and `resolveButtonImportantForAccessibility` WERE HERE and are gone
24
- // (2026-09-18). Both are `foldButtonProps` in `SymbioteFabricProps.cpp` now, and neither had a
25
- // caller left afterwards — only its own unit test, which is the shape this project calls a mirror:
26
- // a JS copy of a rule that runs elsewhere, kept alive by the test that asserts it. It would have
27
- // stayed green forever while meaning nothing.
28
- // `buttonTextStyle` AND `resolveButtonTextStyle` ARE GONE (2026-09-18) — the label's style is
29
- // `foldButtonLabelStyle` in `SymbioteFabricProps.cpp`, reached off the label text's own tag. Its
30
- // constants live THERE now and are not mirrored here; this file keeps only what Android's own fold
31
- // still needs.
32
- //
33
- // It was the last rule in this primitive to move and it needed a seam none of the others did. Its
34
- // inputs are the BUTTON's `color` and `disabled`, and the node it hangs on is the button's
35
- // GRANDCHILD on iOS (`button -> view -> text`) and its child on Android — so `ownerProps`, which
36
- // answers "my parent", could not reach it. `IAncestorLookup` asks for the nearest ancestor carrying
37
- // a tag instead, which is a CSS ancestor selector and makes one rule right on both trees.
38
- // `buttonViewStyle` AND `resolveButtonViewStyle` ARE GONE (2026-09-18), and with them the last of
39
- // Button's folds. The Material look is inside `foldButtonProps` in `SymbioteFabricProps.cpp`, behind
40
- // `#ifdef ANDROID` — where it belongs, since `{}` on iOS was the whole of its other branch.
41
- //
42
- // Its five constants went too rather than staying as a copy nothing reads.
43
- //
44
- // WHAT MADE THIS ONE DIFFERENT from the four ports before it: the Android branch is no longer
45
- // untestable. The test host grew an arm that compiles `#ifdef ANDROID`
46
- // (`core/engine/cpp/tests/CMakeLists.txt`, `SYMBIOTE_PLATFORM_ANDROID`), so the style, the `color`
47
- // override and the disabled greying are asserted against the COMMITTED PAYLOAD in
48
- // `core/engine/cpp/tests/js/android-rules.itest.ts` — strictly better than the mocked-`Platform.OS`
49
- // unit test that went with them, which asserted a JS function rather than what Fabric receives.
50
- // `resolveButtonTitle` IS GONE (2026-09-18) — the uppercase-on-Android rule is `foldButtonLabel` in
51
- // `SymbioteFabricProps.cpp`, reached off the label's own tag. It had no caller left but its own two
52
- // unit tests, which is the orphan shape this migration keeps turning up: a JS copy of a rule that
53
- // runs elsewhere, kept alive by the test asserting it, green forever and proving nothing.
54
- //
55
- // A COVERAGE GAP WENT WITH IT, recorded rather than hidden. The C++ rule is `#ifdef ANDROID` — a raw
56
- // text commits as `RCTRawText` on both platforms, so unlike `Switch`/`AndroidSwitch` there is no view
57
- // NAME for a rule to branch on — and this host is not Android. The deleted Android test reached the
58
- // branch by mocking `Platform.OS`; what it mocked was a JS function that no longer exists. Same
59
- // class as `android_ripple` and `decelerationRate`'s constants, and closing it means an Android arm
60
- // of the test host, not a mock.
61
- //
62
- // One behaviour difference shipped with the move and is deliberate: RN uppercases through
63
- // 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.
64
24
  /**
65
25
  * Whether the button is disabled, which `aria-disabled` may decide on its own.
66
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.