@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.
- package/build/behaviors/activity-indicator/shared.js +29 -71
- package/build/behaviors/button.d.ts +0 -9
- package/build/behaviors/button.js +47 -233
- 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 +5 -51
- package/build/behaviors/pressable.js +101 -161
- package/build/behaviors/refresh-control.js +8 -2
- package/build/behaviors/scroll-view/index.android.js +10 -30
- package/build/behaviors/scroll-view/responder.d.ts +3 -2
- package/build/behaviors/scroll-view/responder.js +19 -21
- package/build/behaviors/scroll-view/shared.js +69 -185
- package/build/behaviors/scroll-view/sticky.d.ts +0 -8
- package/build/behaviors/scroll-view/sticky.js +54 -142
- package/build/behaviors/switch.js +18 -4
- package/build/behaviors/text-input.d.ts +0 -8
- package/build/behaviors/text-input.js +77 -162
- package/build/behaviors/touchable-highlight.js +14 -54
- package/build/behaviors/touchable-native-feedback.js +9 -32
- package/build/behaviors/touchable-opacity.d.ts +0 -7
- package/build/behaviors/touchable-opacity.js +65 -81
- package/build/behaviors/touchable-without-feedback.js +25 -41
- 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 +3 -11
- 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 -44
- 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,19 +1,6 @@
|
|
|
1
|
-
// Sticky-header effect machine:
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// projection wrapper) re-wrote the same per-header sequence — gate the freshly-rebuilt
|
|
5
|
-
// interpolation's spurious zero, debounce the settled translateY, rebuild the top/inverted ranges on
|
|
6
|
-
// a layout/collision-input change — in its own reactive dialect, so the zero-swallow gate, the
|
|
7
|
-
// debounce-delay pick, and the rebuild decision lived FOUR times and quietly drifted.
|
|
8
|
-
//
|
|
9
|
-
// Here the whole decision half is one pure `reduceSticky(state, action, inputs) -> {state, effects}`,
|
|
10
|
-
// PER sticky header. The adapter keeps only what is genuinely framework-bound: translate a native
|
|
11
|
-
// event / animated tick / timer fire into an ACTION, hold ONE state cell, and EXECUTE the returned
|
|
12
|
-
// EFFECTS with its own primitives (build the interpolation node + wire addListener/removeListener,
|
|
13
|
-
// hold the debounce setTimeout, trigger its own re-render, record the cross-talk y). The math leaf
|
|
14
|
-
// (computeStickyInterpolation) and the debounce-window pick (stickyDebounceMs) still live in
|
|
15
|
-
// ./view/render-scroll-sticky; this module composes them into the ordered transition every adapter
|
|
16
|
-
// shares. Ported from ScrollViewStickyHeader.js's effect.
|
|
1
|
+
// Sticky-header effect machine: one pure `reduceSticky(state, action, inputs)` shared by every
|
|
2
|
+
// adapter, so the zero-swallow gate, debounce pick, and rebuild decision live once, not once per
|
|
3
|
+
// framework's reactive dialect. Math leaf + debounce window live in ./view/render-scroll-sticky.
|
|
17
4
|
import { dlog } from '@symbiote-native/engine';
|
|
18
5
|
import { computeStickyInterpolation, stickyDebounceMs, } from '../view/render-scroll-sticky.js';
|
|
19
6
|
// The un-measured identity interpolation (RN: a fresh AnimatedInterpolation before the header has
|
|
@@ -43,8 +30,8 @@ function arraysEqual(a, b) {
|
|
|
43
30
|
return false;
|
|
44
31
|
return a.every((value, index) => value === b[index]);
|
|
45
32
|
}
|
|
46
|
-
// Recompute the derived
|
|
47
|
-
//
|
|
33
|
+
// Recompute the derived ranges off the current state + inputs, store them, and return them for
|
|
34
|
+
// the rebuild effect.
|
|
48
35
|
function deriveRanges(state, inputs) {
|
|
49
36
|
const { inputRange, outputRange } = computeStickyInterpolation({
|
|
50
37
|
measured: state.measured,
|
|
@@ -58,14 +45,99 @@ function deriveRanges(state, inputs) {
|
|
|
58
45
|
state.outputRange = outputRange;
|
|
59
46
|
return { inputRange, outputRange };
|
|
60
47
|
}
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
// DIAGNOSTIC-only, gated: identifies which header instance a log line belongs to across a
|
|
64
|
-
// device dump without needing full state dumps at every call — layoutY doubles as a stable
|
|
65
|
-
// per-header id once measured (0 before the first layout).
|
|
48
|
+
// Diagnostic-only, gated: identifies which header instance a log line belongs to — layoutY
|
|
49
|
+
// doubles as a stable per-header id once measured (0 before the first layout).
|
|
66
50
|
function headerTag(state) {
|
|
67
51
|
return `y=${state.layoutY}`;
|
|
68
52
|
}
|
|
53
|
+
// Redundant-geometry guard: Yoga re-fires onLayout with the same y/height on an unrelated
|
|
54
|
+
// relayout — without this a fresh prop identity per redundant layout trips an unbounded
|
|
55
|
+
// rebuild loop.
|
|
56
|
+
function handleLayout(state, action, inputs) {
|
|
57
|
+
const alreadyAtThisGeometry = state.measured &&
|
|
58
|
+
state.layoutY === action.y &&
|
|
59
|
+
state.layoutHeight === action.height;
|
|
60
|
+
state.layoutY = action.y;
|
|
61
|
+
state.layoutHeight = action.height;
|
|
62
|
+
state.measured = true;
|
|
63
|
+
const effects = [];
|
|
64
|
+
if (inputs.index !== undefined) {
|
|
65
|
+
effects.push({ kind: 'record-header-y', index: inputs.index, y: action.y });
|
|
66
|
+
}
|
|
67
|
+
if (alreadyAtThisGeometry && state.rangesEmitted) {
|
|
68
|
+
dlog(`STICKY[reducer ${headerTag(state)}] layout: redundant geometry, skipped rebuild`);
|
|
69
|
+
return { state, effects, changed: effects.length > 0 };
|
|
70
|
+
}
|
|
71
|
+
const { inputRange, outputRange } = deriveRanges(state, inputs);
|
|
72
|
+
state.rangesEmitted = true;
|
|
73
|
+
dlog(`STICKY[reducer ${headerTag(state)}] layout: measured=true inputRange=${JSON.stringify(inputRange)} ` +
|
|
74
|
+
`outputRange=${JSON.stringify(outputRange)}`);
|
|
75
|
+
effects.push({ kind: 'rebuild-interpolation', inputRange, outputRange });
|
|
76
|
+
return { state, effects, changed: true };
|
|
77
|
+
}
|
|
78
|
+
// Redundant-ranges guard (same cause as layout's): compare the derived result, not raw
|
|
79
|
+
// inputs. `hadEmitted` is load-bearing: an unmeasured header derives the same identity
|
|
80
|
+
// ranges the initial state holds, so without it the first dispatch reads as redundant.
|
|
81
|
+
function handleInputsChanged(state, inputs) {
|
|
82
|
+
const previousInputRange = state.inputRange;
|
|
83
|
+
const previousOutputRange = state.outputRange;
|
|
84
|
+
const hadEmitted = state.rangesEmitted;
|
|
85
|
+
const { inputRange, outputRange } = deriveRanges(state, inputs);
|
|
86
|
+
state.rangesEmitted = true;
|
|
87
|
+
if (hadEmitted &&
|
|
88
|
+
arraysEqual(previousInputRange, inputRange) &&
|
|
89
|
+
arraysEqual(previousOutputRange, outputRange)) {
|
|
90
|
+
dlog(`STICKY[reducer ${headerTag(state)}] inputs-changed: ranges unchanged, skipped rebuild`);
|
|
91
|
+
return { state, effects: [], changed: false };
|
|
92
|
+
}
|
|
93
|
+
dlog(`STICKY[reducer ${headerTag(state)}] inputs-changed: inputRange ${JSON.stringify(previousInputRange)}->` +
|
|
94
|
+
`${JSON.stringify(inputRange)} outputRange ${JSON.stringify(previousOutputRange)}->` +
|
|
95
|
+
`${JSON.stringify(outputRange)}`);
|
|
96
|
+
return {
|
|
97
|
+
state,
|
|
98
|
+
effects: [{ kind: 'rebuild-interpolation', inputRange, outputRange }],
|
|
99
|
+
changed: true,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
// A freshly-rebuilt interpolation re-emits 0 to its listeners; swallow that first zero once a
|
|
103
|
+
// real value has committed. Otherwise schedule the host-tuned debounce.
|
|
104
|
+
function handleAnimatedTick(state, action, inputs) {
|
|
105
|
+
if (action.value === 0 && !state.haveReceivedInitialZeroTranslateY) {
|
|
106
|
+
state.haveReceivedInitialZeroTranslateY = true;
|
|
107
|
+
dlog(`STICKY[reducer ${headerTag(state)}] animated-tick: swallowed re-emitted zero translateY`);
|
|
108
|
+
return { state, effects: [], changed: false };
|
|
109
|
+
}
|
|
110
|
+
dlog(`STICKY[reducer ${headerTag(state)}] animated-tick: scheduling debounce delay=${stickyDebounceMs(inputs.os)} ` +
|
|
111
|
+
`value=${action.value}`);
|
|
112
|
+
return {
|
|
113
|
+
state,
|
|
114
|
+
effects: [
|
|
115
|
+
{
|
|
116
|
+
kind: 'schedule-debounce',
|
|
117
|
+
delay: stickyDebounceMs(inputs.os),
|
|
118
|
+
value: action.value,
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
changed: false,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
// Already-sitting-at-this-value bails to break a re-arriving settled value's cascade
|
|
125
|
+
// (apply-passthrough -> fresh identity -> reconnect -> another tick).
|
|
126
|
+
function handleDebounceFired(state, action) {
|
|
127
|
+
if (state.translateY === action.value) {
|
|
128
|
+
dlog(`STICKY[reducer ${headerTag(state)}] debounce-fired: already at translateY=${action.value}, no-op`);
|
|
129
|
+
return { state, effects: [], changed: false };
|
|
130
|
+
}
|
|
131
|
+
dlog(`STICKY[reducer ${headerTag(state)}] debounce-fired: committing translateY=${action.value}`);
|
|
132
|
+
state.translateY = action.value;
|
|
133
|
+
if (action.value !== 0)
|
|
134
|
+
state.haveReceivedInitialZeroTranslateY = false;
|
|
135
|
+
return {
|
|
136
|
+
state,
|
|
137
|
+
effects: [{ kind: 'apply-passthrough', translateY: action.value }],
|
|
138
|
+
changed: true,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
69
141
|
export function reduceSticky(state, action, inputs) {
|
|
70
142
|
dlog(`STICKY[reducer ${headerTag(state)}] action=${action.kind}` +
|
|
71
143
|
(action.kind === 'layout'
|
|
@@ -76,131 +148,13 @@ export function reduceSticky(state, action, inputs) {
|
|
|
76
148
|
: '') +
|
|
77
149
|
` inputs={inverted=${inputs.inverted} scrollViewHeight=${inputs.scrollViewHeight} nextHeaderLayoutY=${inputs.nextHeaderLayoutY}}`);
|
|
78
150
|
switch (action.kind) {
|
|
79
|
-
case 'layout':
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
// (React bails out of re-rendering on an unchanged primitive), so a redundant onLayout never
|
|
88
|
-
// reaches the rebuild `useEffect`. This reducer has no such implicit bail-out, so it must skip
|
|
89
|
-
// the rebuild explicitly — device-confirmed (2026-08-13) that omitting this guard lets a
|
|
90
|
-
// native-driven rebuild (fresh AnimatedProps/AnimatedStyle graph, fresh native connect) commit a
|
|
91
|
-
// fresh prop identity on every redundant layout, which can itself provoke another relayout pass —
|
|
92
|
-
// an unbounded same-tick rebuild ping-pong that trips Svelte's effect_update_depth_exceeded guard.
|
|
93
|
-
const alreadyAtThisGeometry = state.measured &&
|
|
94
|
-
state.layoutY === action.y &&
|
|
95
|
-
state.layoutHeight === action.height;
|
|
96
|
-
state.layoutY = action.y;
|
|
97
|
-
state.layoutHeight = action.height;
|
|
98
|
-
state.measured = true;
|
|
99
|
-
const effects = [];
|
|
100
|
-
if (inputs.index !== undefined) {
|
|
101
|
-
effects.push({
|
|
102
|
-
kind: 'record-header-y',
|
|
103
|
-
index: inputs.index,
|
|
104
|
-
y: action.y,
|
|
105
|
-
});
|
|
106
|
-
}
|
|
107
|
-
if (alreadyAtThisGeometry && state.rangesEmitted) {
|
|
108
|
-
dlog(`STICKY[reducer ${headerTag(state)}] layout: redundant geometry, skipped rebuild`);
|
|
109
|
-
return { state, effects, changed: effects.length > 0 };
|
|
110
|
-
}
|
|
111
|
-
const { inputRange, outputRange } = deriveRanges(state, inputs);
|
|
112
|
-
state.rangesEmitted = true;
|
|
113
|
-
dlog(`STICKY[reducer ${headerTag(state)}] layout: measured=true inputRange=${JSON.stringify(inputRange)} ` +
|
|
114
|
-
`outputRange=${JSON.stringify(outputRange)}`);
|
|
115
|
-
effects.push({ kind: 'rebuild-interpolation', inputRange, outputRange });
|
|
116
|
-
return { state, effects, changed: true };
|
|
117
|
-
}
|
|
118
|
-
case 'inputs-changed': {
|
|
119
|
-
// A collision/viewport input changed (RN effect deps: inverted, scrollViewHeight,
|
|
120
|
-
// nextHeaderLayoutY): recompute the ranges and rebuild.
|
|
121
|
-
//
|
|
122
|
-
// Redundant-ranges guard (sibling of 'layout's `alreadyAtThisGeometry` above, same root
|
|
123
|
-
// cause): the adapter's own mount `$effect` re-dispatches 'inputs-changed' whenever its
|
|
124
|
-
// `inverted`/`scrollViewHeight`/`nextHeaderLayoutY` derived values re-evaluate — which can
|
|
125
|
-
// happen on an unrelated parent re-render (e.g. VirtualizedList re-deriving
|
|
126
|
-
// `nextHeaderLayoutYFor(cell.index)` off its cross-talk Map on every reactive pass) even when
|
|
127
|
-
// the COMPUTED value is identical. React gets no implicit protection here either (this is a
|
|
128
|
-
// real `useEffect` with real deps), but RN's own deps array only fires on an ACTUAL primitive
|
|
129
|
-
// change; a framework-agnostic caller re-dispatching on every derive needs the reducer itself
|
|
130
|
-
// to compare the RESULT (the ranges), not the raw inputs (which may recompute to the same
|
|
131
|
-
// ranges via different intermediate values) — device-confirmed (2026-08-13) this is a second,
|
|
132
|
-
// independent source of the same unbounded same-tick rebuild loop the 'layout' guard fixed.
|
|
133
|
-
const previousInputRange = state.inputRange;
|
|
134
|
-
const previousOutputRange = state.outputRange;
|
|
135
|
-
const hadEmitted = state.rangesEmitted;
|
|
136
|
-
const { inputRange, outputRange } = deriveRanges(state, inputs);
|
|
137
|
-
state.rangesEmitted = true;
|
|
138
|
-
// `hadEmitted` is load-bearing, not defensive: an unmeasured header derives exactly the
|
|
139
|
-
// identity ranges the initial state already holds, so without it the FIRST dispatch (Angular
|
|
140
|
-
// sends one from ngOnInit, before any layout) reads as redundant and the header never emits
|
|
141
|
-
// a rebuild at all — its wrapper then never commits. Regression-covered in
|
|
142
|
-
// sticky-header-reducer.test.ts.
|
|
143
|
-
if (hadEmitted &&
|
|
144
|
-
arraysEqual(previousInputRange, inputRange) &&
|
|
145
|
-
arraysEqual(previousOutputRange, outputRange)) {
|
|
146
|
-
dlog(`STICKY[reducer ${headerTag(state)}] inputs-changed: ranges unchanged, skipped rebuild`);
|
|
147
|
-
return { state, effects: [], changed: false };
|
|
148
|
-
}
|
|
149
|
-
dlog(`STICKY[reducer ${headerTag(state)}] inputs-changed: inputRange ${JSON.stringify(previousInputRange)}->` +
|
|
150
|
-
`${JSON.stringify(inputRange)} outputRange ${JSON.stringify(previousOutputRange)}->` +
|
|
151
|
-
`${JSON.stringify(outputRange)}`);
|
|
152
|
-
return {
|
|
153
|
-
state,
|
|
154
|
-
effects: [{ kind: 'rebuild-interpolation', inputRange, outputRange }],
|
|
155
|
-
changed: true,
|
|
156
|
-
};
|
|
157
|
-
}
|
|
158
|
-
case 'animated-tick': {
|
|
159
|
-
// A freshly-rebuilt interpolation re-emits 0 to its listeners; swallow that first zero once a
|
|
160
|
-
// real value has committed (RN). Otherwise schedule the host-tuned debounce that pushes the
|
|
161
|
-
// settled value into the committed transform for hit-testing.
|
|
162
|
-
if (action.value === 0 && !state.haveReceivedInitialZeroTranslateY) {
|
|
163
|
-
state.haveReceivedInitialZeroTranslateY = true;
|
|
164
|
-
dlog(`STICKY[reducer ${headerTag(state)}] animated-tick: swallowed re-emitted zero translateY`);
|
|
165
|
-
return { state, effects: [], changed: false };
|
|
166
|
-
}
|
|
167
|
-
dlog(`STICKY[reducer ${headerTag(state)}] animated-tick: scheduling debounce delay=${stickyDebounceMs(inputs.os)} ` +
|
|
168
|
-
`value=${action.value}`);
|
|
169
|
-
return {
|
|
170
|
-
state,
|
|
171
|
-
effects: [
|
|
172
|
-
{
|
|
173
|
-
kind: 'schedule-debounce',
|
|
174
|
-
delay: stickyDebounceMs(inputs.os),
|
|
175
|
-
value: action.value,
|
|
176
|
-
},
|
|
177
|
-
],
|
|
178
|
-
changed: false,
|
|
179
|
-
};
|
|
180
|
-
}
|
|
181
|
-
case 'debounce-fired': {
|
|
182
|
-
// Already sitting at this value: emit nothing. Same bail-out the 'layout' case above spells
|
|
183
|
-
// out, for the same reason - React's own header gets it free from setTranslateY (an unchanged
|
|
184
|
-
// primitive bails out of the re-render), a reducer has to say it. Device-confirmed 2026-08-18:
|
|
185
|
-
// without it a re-arriving settled value emits apply-passthrough, the adapter force-renders,
|
|
186
|
-
// the passthrough prop gets a fresh identity, the animated graph reconnects and re-emits into
|
|
187
|
-
// another tick -> another debounce -> another passthrough. One header survives it; a screen of
|
|
188
|
-
// 200 trips React's "Maximum update depth exceeded" and takes the app down.
|
|
189
|
-
if (state.translateY === action.value) {
|
|
190
|
-
dlog(`STICKY[reducer ${headerTag(state)}] debounce-fired: already at translateY=${action.value}, no-op`);
|
|
191
|
-
return { state, effects: [], changed: false };
|
|
192
|
-
}
|
|
193
|
-
// The debounce completed: commit the settled translateY. Once a NON-zero value commits, re-arm
|
|
194
|
-
// the swallow gate so the next interpolation rebuild's spurious 0 is dropped (RN).
|
|
195
|
-
dlog(`STICKY[reducer ${headerTag(state)}] debounce-fired: committing translateY=${action.value}`);
|
|
196
|
-
state.translateY = action.value;
|
|
197
|
-
if (action.value !== 0)
|
|
198
|
-
state.haveReceivedInitialZeroTranslateY = false;
|
|
199
|
-
return {
|
|
200
|
-
state,
|
|
201
|
-
effects: [{ kind: 'apply-passthrough', translateY: action.value }],
|
|
202
|
-
changed: true,
|
|
203
|
-
};
|
|
204
|
-
}
|
|
151
|
+
case 'layout':
|
|
152
|
+
return handleLayout(state, action, inputs);
|
|
153
|
+
case 'inputs-changed':
|
|
154
|
+
return handleInputsChanged(state, inputs);
|
|
155
|
+
case 'animated-tick':
|
|
156
|
+
return handleAnimatedTick(state, action, inputs);
|
|
157
|
+
case 'debounce-fired':
|
|
158
|
+
return handleDebounceFired(state, action);
|
|
205
159
|
}
|
|
206
160
|
}
|
package/build/state/touchable.js
CHANGED
|
@@ -100,11 +100,9 @@ export function createTouchableFeedbackHandlers(config, runtime, callbacks) {
|
|
|
100
100
|
},
|
|
101
101
|
};
|
|
102
102
|
}
|
|
103
|
-
// ---- RN-audited additions
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
// the React, Vue, Svelte and Angular adapters call; the names below are the RN-accurate forms and
|
|
107
|
-
// are additive on purpose, so the tree stays green while each adapter migrates.
|
|
103
|
+
// ---- RN-audited additions ----
|
|
104
|
+
// Measured against .vendors/react-native. The names below are the RN-accurate forms, kept
|
|
105
|
+
// additive so the tree stays green while each adapter migrates off the names above.
|
|
108
106
|
// RN's Touchable* family OVERRIDES Pressability's own floor with 0 — TouchableOpacity.js:195,
|
|
109
107
|
// TouchableHighlight.js:203 and TouchableWithoutFeedback.js all pass `minPressDuration: 0`. So
|
|
110
108
|
// Pressability's own default of 130 (Pressability.js:264) reaches a Touchable in RN NEVER: what holds the active visual there is the Animated fade's own duration,
|
|
@@ -1,22 +1,12 @@
|
|
|
1
1
|
// VirtualizedList orchestration reducer: the framework-agnostic STATE MACHINE that folds every
|
|
2
|
-
// per-adapter effect skeleton into one place
|
|
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
|