@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.
- package/README.md +17 -18
- package/build/behaviors/activity-indicator/shared.js +29 -71
- package/build/behaviors/button.d.ts +0 -9
- package/build/behaviors/button.js +48 -234
- package/build/behaviors/image-background.js +21 -81
- package/build/behaviors/image.js +3 -10
- package/build/behaviors/input-accessory-view.js +10 -51
- package/build/behaviors/pressable.d.ts +0 -48
- package/build/behaviors/pressable.js +59 -149
- package/build/behaviors/scroll-view/index.android.js +10 -30
- package/build/behaviors/scroll-view/shared.js +55 -186
- package/build/behaviors/scroll-view/sticky.d.ts +0 -8
- package/build/behaviors/scroll-view/sticky.js +54 -142
- package/build/behaviors/text-input.d.ts +0 -8
- package/build/behaviors/text-input.js +57 -156
- package/build/behaviors/touchable-highlight.js +14 -54
- package/build/behaviors/touchable-native-feedback.js +9 -32
- package/build/behaviors/touchable-opacity.js +3 -18
- package/build/behaviors/touchable-without-feedback.js +6 -24
- package/build/bootstrap/index.d.ts +1 -0
- package/build/bootstrap/index.js +2 -1
- package/build/component-names/index.android.js +6 -8
- package/build/component-names/shared.js +12 -42
- package/build/index.js +13 -19
- package/build/scroll-view-commands.js +9 -17
- package/build/state/pressable.js +18 -43
- package/build/state/sticky-header-reducer.js +103 -149
- package/build/state/touchable.js +3 -5
- package/build/state/virtualized-list-reducer.js +21 -48
- package/build/state/virtualized-list.js +63 -148
- package/build/text-props.js +3 -13
- package/build/view/render-button.js +13 -53
- package/build/view/render-input-accessory-view.js +8 -24
- package/build/view/render-pressable/index.js +3 -4
- package/build/view/render-scroll-view.js +13 -23
- package/build/view/render-touchable-native-feedback.js +5 -14
- package/host-primitives.cjs +33 -207
- 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
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
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
|
|
146
|
-
//
|
|
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
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
//
|
|
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 —
|
|
317
|
-
//
|
|
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
|
|
342
|
-
//
|
|
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
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
|
24
|
-
//
|
|
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
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
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
|
-
//
|
|
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
|
|
78
|
-
//
|
|
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
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
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
|
|
133
|
-
// BETWEEN two cells
|
|
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
|
|
184
|
-
//
|
|
185
|
-
//
|
|
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
|
-
//
|
|
203
|
-
//
|
|
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
|
-
//
|
|
216
|
-
//
|
|
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
|
-
//
|
|
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 —
|
|
274
|
-
// chrome the list draws in the gap
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
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
|
|
305
|
-
//
|
|
306
|
-
//
|
|
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
|
-
//
|
|
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:
|
|
420
|
-
//
|
|
421
|
-
//
|
|
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
|
|
433
|
-
//
|
|
434
|
-
//
|
|
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
|
|
455
|
-
//
|
|
456
|
-
//
|
|
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
|
|
530
|
-
//
|
|
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
|
|
568
|
-
//
|
|
569
|
-
//
|
|
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) {
|
package/build/text-props.js
CHANGED
|
@@ -1,14 +1,4 @@
|
|
|
1
|
-
// The ellipsize modes a Text accepts.
|
|
2
|
-
// —
|
|
3
|
-
//
|
|
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.
|
|
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
|
-
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// the
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
//
|
|
24
|
-
// (
|
|
25
|
-
//
|
|
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
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
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
|
|
16
|
-
//
|
|
17
|
-
//
|
|
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.
|