@symbiote-native/components 0.4.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -8
- package/build/accessibility-props.d.ts +11 -0
- package/build/accessibility-props.js +30 -117
- package/build/behaviors/image.d.ts +3 -0
- package/build/behaviors/image.js +123 -0
- package/build/behaviors/input-accessory-view.d.ts +3 -0
- package/build/behaviors/input-accessory-view.js +70 -0
- package/build/behaviors/pressable.d.ts +2 -0
- package/build/behaviors/pressable.js +310 -0
- package/build/behaviors/switch.d.ts +2 -0
- package/build/behaviors/switch.js +182 -0
- package/build/behaviors/text-input.d.ts +14 -0
- package/build/behaviors/text-input.js +291 -0
- package/build/bootstrap/index.js +1 -1
- package/build/component-names/index.android.js +23 -1
- package/build/component-names/index.ios.js +9 -1
- package/build/component-names/shared.d.ts +1 -1
- package/build/component-names/shared.js +40 -1
- package/build/descriptor.d.ts +8 -0
- package/build/descriptor.js +27 -0
- package/build/fold-host-bag.d.ts +15 -0
- package/build/fold-host-bag.js +99 -0
- package/build/index.d.ts +25 -12
- package/build/index.js +18 -6
- package/build/resolve-intrinsic.d.ts +7 -0
- package/build/resolve-intrinsic.js +49 -0
- package/build/state/pressable.d.ts +9 -0
- package/build/state/pressable.js +133 -37
- package/build/state/sticky-header-reducer.js +28 -5
- package/build/state/switch.js +1 -1
- package/build/state/text-input.d.ts +7 -1
- package/build/state/text-input.js +46 -8
- package/build/state/touchable.d.ts +30 -1
- package/build/state/touchable.js +94 -5
- package/build/state/virtualized-list-diagnostics.d.ts +31 -0
- package/build/state/virtualized-list-diagnostics.js +33 -0
- package/build/state/virtualized-list-reducer.d.ts +14 -0
- package/build/state/virtualized-list-reducer.js +179 -45
- package/build/state/virtualized-list.d.ts +5 -2
- package/build/state/virtualized-list.js +143 -34
- package/build/state-style.d.ts +15 -0
- package/build/state-style.js +47 -0
- package/build/text-props.d.ts +9 -0
- package/build/text-props.js +25 -0
- package/build/view/render-activity-indicator.js +37 -3
- package/build/view/render-image/index.d.ts +2 -0
- package/build/view/render-image/index.js +42 -5
- package/build/view/render-input-accessory-view.d.ts +2 -0
- package/build/view/render-input-accessory-view.js +41 -6
- package/build/view/render-keyboard-avoiding-view.d.ts +14 -2
- package/build/view/render-keyboard-avoiding-view.js +52 -6
- package/build/view/render-modal.js +4 -2
- package/build/view/render-pressable/index.js +3 -1
- package/build/view/render-scroll-sticky.js +1 -1
- package/build/view/render-scroll-view.js +9 -3
- package/build/view/render-switch.js +11 -2
- package/build/view/render-text-input.js +6 -2
- package/build/view/render-touchable-highlight.d.ts +11 -1
- package/build/view/render-touchable-highlight.js +11 -10
- package/build/view/render-touchable-native-feedback.js +5 -1
- package/host-primitives.cjs +380 -0
- package/host-primitives.d.cts +35 -0
- package/lowering-fixtures.cjs +259 -0
- package/lowering-fixtures.d.cts +17 -0
- package/package.json +42 -5
- package/specialize-state-style.cjs +219 -0
- package/specialize-state-style.d.cts +15 -0
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// latest count, while `lastNativeText` is bookkeeping the controlled-write effect mutates
|
|
12
12
|
// without a render. So the logic layer is the pure folds/maps + the controlled-write predicate;
|
|
13
13
|
// each adapter holds the two pieces in ITS own primitives (React useState/useRef, Vue ref/let).
|
|
14
|
+
import { Platform } from '@symbiote-native/engine';
|
|
14
15
|
// The acknowledged count starts at 0, native has reported nothing yet, so the first
|
|
15
16
|
// controlled write echoes 0 and lands on eventLag 0.
|
|
16
17
|
export const INITIAL_EVENT_COUNT = 0;
|
|
@@ -29,11 +30,25 @@ const inputModeToKeyboardType = {
|
|
|
29
30
|
email: 'email-address',
|
|
30
31
|
none: 'default',
|
|
31
32
|
numeric: 'number-pad',
|
|
32
|
-
search: 'default',
|
|
33
33
|
tel: 'phone-pad',
|
|
34
34
|
text: 'default',
|
|
35
35
|
url: 'url',
|
|
36
36
|
};
|
|
37
|
+
// `search` is the ONE inputMode token RN resolves per platform (TextInput.js:815-825):
|
|
38
|
+
// search: Platform.OS === 'ios' ? 'web-search' : 'default'
|
|
39
|
+
// iOS has a dedicated search keyboard whose return key is a magnifier; every other host falls back
|
|
40
|
+
// to the default one. It is deliberately absent from the map above, so there is one source for it
|
|
41
|
+
// rather than a map entry and an override that can drift apart.
|
|
42
|
+
//
|
|
43
|
+
// The host is an ARGUMENT rather than a `Platform.OS` read inside the body, because the headless
|
|
44
|
+
// Platform module always resolves to iOS (platform/index.ts re-exports index.ios) — a direct read
|
|
45
|
+
// would leave the android branch permanently unprovable. resolveTextInputProps supplies the real
|
|
46
|
+
// value. This costs the callers nothing: the exported fold signature is unchanged.
|
|
47
|
+
export function keyboardTypeForInputMode(inputMode, os) {
|
|
48
|
+
if (inputMode === 'search')
|
|
49
|
+
return os === 'ios' ? 'web-search' : 'default';
|
|
50
|
+
return mapAutoComplete(inputModeToKeyboardType, inputMode);
|
|
51
|
+
}
|
|
37
52
|
// RN's enterKeyHint -> returnKeyType map (TextInput.js:805). Note `enter` -> 'default'.
|
|
38
53
|
const enterKeyHintToReturnKeyType = {
|
|
39
54
|
done: 'done',
|
|
@@ -122,7 +137,9 @@ const autoCompleteWebToTextContentType = {
|
|
|
122
137
|
// if the token has no native equivalent. The caller decides the per-platform fallback. Named
|
|
123
138
|
// for its first user (autoComplete), but it is the one generic safe lookup every map shares.
|
|
124
139
|
export function mapAutoComplete(map, token) {
|
|
125
|
-
return Object.prototype.hasOwnProperty.call(map, token)
|
|
140
|
+
return Object.prototype.hasOwnProperty.call(map, token)
|
|
141
|
+
? map[token]
|
|
142
|
+
: undefined;
|
|
126
143
|
}
|
|
127
144
|
// RN folds W3C `autoComplete` per platform (TextInput.js:938): Android takes the mapped
|
|
128
145
|
// `autoComplete` token (falling back to the raw token), iOS takes the mapped `textContentType`
|
|
@@ -187,7 +204,7 @@ export function resolveTextInputProps(input) {
|
|
|
187
204
|
const folded = foldAutoComplete(input.autoComplete);
|
|
188
205
|
return {
|
|
189
206
|
keyboardType: input.inputMode !== undefined
|
|
190
|
-
?
|
|
207
|
+
? keyboardTypeForInputMode(input.inputMode, Platform.OS)
|
|
191
208
|
: input.keyboardType,
|
|
192
209
|
returnKeyType: input.enterKeyHint !== undefined
|
|
193
210
|
? mapAutoComplete(enterKeyHintToReturnKeyType, input.enterKeyHint)
|
|
@@ -195,11 +212,32 @@ export function resolveTextInputProps(input) {
|
|
|
195
212
|
editable: input.readOnly !== undefined ? !input.readOnly : input.editable,
|
|
196
213
|
submitBehavior: foldSubmitBehavior(input.submitBehavior, input.blurOnSubmit, input.multiline),
|
|
197
214
|
selectionColor: input.selectionColor,
|
|
198
|
-
cursorColor: input.cursorColor !== undefined
|
|
199
|
-
|
|
200
|
-
|
|
215
|
+
cursorColor: input.cursorColor !== undefined
|
|
216
|
+
? input.cursorColor
|
|
217
|
+
: input.selectionColor,
|
|
218
|
+
selectionHandleColor: input.selectionHandleColor !== undefined
|
|
219
|
+
? input.selectionHandleColor
|
|
220
|
+
: input.selectionColor,
|
|
221
|
+
underlineColorAndroid: input.underlineColorAndroid !== undefined
|
|
222
|
+
? input.underlineColorAndroid
|
|
223
|
+
: 'transparent',
|
|
201
224
|
autoComplete: folded.autoComplete,
|
|
202
|
-
textContentType: input.textContentType !== undefined
|
|
203
|
-
|
|
225
|
+
textContentType: input.textContentType !== undefined
|
|
226
|
+
? input.textContentType
|
|
227
|
+
: folded.textContentType,
|
|
228
|
+
showSoftInputOnFocus: input.inputMode !== undefined
|
|
229
|
+
? input.inputMode !== 'none'
|
|
230
|
+
: input.showSoftInputOnFocus,
|
|
204
231
|
};
|
|
205
232
|
}
|
|
233
|
+
const TEXT_INPUT_CALLBACKS = {
|
|
234
|
+
onValueChange: true,
|
|
235
|
+
onFocus: true,
|
|
236
|
+
onBlur: true,
|
|
237
|
+
onEndEditing: true,
|
|
238
|
+
onSubmitEditing: true,
|
|
239
|
+
onKeyPress: true,
|
|
240
|
+
onSelectionChange: true,
|
|
241
|
+
onContentSizeChange: true,
|
|
242
|
+
};
|
|
243
|
+
export const TEXT_INPUT_CALLBACK_NAMES = Object.keys(TEXT_INPUT_CALLBACKS);
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { type ISymbioteEvent } from '@symbiote-native/engine';
|
|
2
2
|
export declare const DEFAULT_ACTIVE_OPACITY = 0.2;
|
|
3
|
+
export declare const OPACITY_ACTIVE_GRANT_DURATION_MS = 0;
|
|
3
4
|
export declare const OPACITY_ACTIVE_DURATION_MS = 150;
|
|
4
5
|
export declare const OPACITY_INACTIVE_DURATION_MS = 250;
|
|
5
6
|
export declare const RESTING_OPACITY = 1;
|
|
6
7
|
export declare const DEFAULT_HIGHLIGHT_CHILD_OPACITY = 0.85;
|
|
7
8
|
export declare const DEFAULT_UNDERLAY_COLOR = "black";
|
|
8
|
-
export declare const DEFAULT_MIN_PRESS_DURATION_MS = 130;
|
|
9
9
|
export type ITouchableHandler = (event: ISymbioteEvent) => void;
|
|
10
10
|
export interface IPressTimingProps {
|
|
11
11
|
delayPressIn?: number;
|
|
@@ -34,3 +34,32 @@ export interface ITouchableFeedbackHandlers {
|
|
|
34
34
|
handlePressOut: ITouchableHandler;
|
|
35
35
|
}
|
|
36
36
|
export declare function createTouchableFeedbackHandlers(config: ITouchableFeedbackConfig, runtime: ITouchableFeedbackRuntime, callbacks: ITouchableFeedbackCallbacks): ITouchableFeedbackHandlers;
|
|
37
|
+
export declare const TOUCHABLE_MIN_PRESS_DURATION_MS = 0;
|
|
38
|
+
export declare function restingOpacityFromStyle(style: unknown): number;
|
|
39
|
+
export interface ITouchablePressHandlerProps {
|
|
40
|
+
onPress?: unknown;
|
|
41
|
+
onPressIn?: unknown;
|
|
42
|
+
onPressOut?: unknown;
|
|
43
|
+
onLongPress?: unknown;
|
|
44
|
+
}
|
|
45
|
+
export declare function hasTouchablePressHandler(props: ITouchablePressHandlerProps): boolean;
|
|
46
|
+
export interface IHighlightUnderlayRuntime {
|
|
47
|
+
hideTimerCancel: (() => void) | undefined;
|
|
48
|
+
}
|
|
49
|
+
export declare function createHighlightUnderlayRuntime(): IHighlightUnderlayRuntime;
|
|
50
|
+
export interface IHighlightUnderlayConfig {
|
|
51
|
+
delayPressOut: number;
|
|
52
|
+
hasPressHandler: boolean;
|
|
53
|
+
schedule: (callback: () => void, ms: number) => () => void;
|
|
54
|
+
}
|
|
55
|
+
export interface IHighlightUnderlayCallbacks {
|
|
56
|
+
setShown: (shown: boolean) => void;
|
|
57
|
+
onShowUnderlay?: () => void;
|
|
58
|
+
onHideUnderlay?: () => void;
|
|
59
|
+
}
|
|
60
|
+
export interface IHighlightUnderlayHandlers {
|
|
61
|
+
handlePressIn: ITouchableHandler;
|
|
62
|
+
handlePress: ITouchableHandler;
|
|
63
|
+
handlePressOut: ITouchableHandler;
|
|
64
|
+
}
|
|
65
|
+
export declare function createHighlightUnderlayHandlers(config: IHighlightUnderlayConfig, runtime: IHighlightUnderlayRuntime, callbacks: IHighlightUnderlayCallbacks): IHighlightUnderlayHandlers;
|
package/build/state/touchable.js
CHANGED
|
@@ -3,18 +3,35 @@
|
|
|
3
3
|
// (delayPressIn/delayPressOut/minPressDuration) and the deactivation floor math. The Animated
|
|
4
4
|
// feedback itself is framework (each adapter's Animated namespace), so it stays in the adapter;
|
|
5
5
|
// only the timing constants + the pure wait computation live here.
|
|
6
|
-
import { dlog } from '@symbiote-native/engine';
|
|
7
|
-
// TouchableOpacity.js: _opacityActive(150)/_opacityInactive(250), activeOpacity 0.2.
|
|
6
|
+
import { dlog, flattenStyle, } from '@symbiote-native/engine';
|
|
7
|
+
// TouchableOpacity.js: _opacityActive(0 | 150)/_opacityInactive(250), activeOpacity 0.2.
|
|
8
8
|
export const DEFAULT_ACTIVE_OPACITY = 0.2;
|
|
9
|
+
// RN picks the press-in fade duration from WHERE the press-in came from
|
|
10
|
+
// (`TouchableOpacity.js:215-220`):
|
|
11
|
+
//
|
|
12
|
+
// event.dispatchConfig.registrationName === 'onResponderGrant' ? 0 : 150
|
|
13
|
+
//
|
|
14
|
+
// The grant branch is an ordinary tap and it is INSTANT. `Pressability.js:453-468` shows why:
|
|
15
|
+
// `onResponderGrant` calls `_receiveSignal('RESPONDER_GRANT')` and then, when delayPressIn is 0,
|
|
16
|
+
// `_receiveSignal('DELAY', event)` SYNCHRONOUSLY with that same grant event — so the event that
|
|
17
|
+
// reaches onPressIn still carries `onResponderGrant`. The 150 branch is a RE-activation: the
|
|
18
|
+
// finger drifted outside and came back (RESPONDER_INACTIVE_PRESS_OUT -> RESPONDER_ACTIVE_PRESS_IN,
|
|
19
|
+
// driven by onResponderMove).
|
|
20
|
+
//
|
|
21
|
+
// Our engine dispatches pressIn from exactly ONE place — `core/engine/src/events/index.ts:391`,
|
|
22
|
+
// on topTouchStart, ahead of negotiateResponder — and has no drift-back-in re-activation at all.
|
|
23
|
+
// So every pressIn we produce is the grant-equivalent, and 0 is the duration that applies. Using
|
|
24
|
+
// 150 (which all five adapters did until 2026-08-19) makes every tap fade in visibly slower than
|
|
25
|
+
// RN.
|
|
26
|
+
export const OPACITY_ACTIVE_GRANT_DURATION_MS = 0;
|
|
27
|
+
// The re-activation branch. Unreachable today — kept so the constant exists when the press
|
|
28
|
+
// machine grows drift-back-in, and because it is the value RN uses there.
|
|
9
29
|
export const OPACITY_ACTIVE_DURATION_MS = 150;
|
|
10
30
|
export const OPACITY_INACTIVE_DURATION_MS = 250;
|
|
11
31
|
export const RESTING_OPACITY = 1;
|
|
12
32
|
// TouchableHighlight.js: child opacity 0.85, underlay 'black' when unset.
|
|
13
33
|
export const DEFAULT_HIGHLIGHT_CHILD_OPACITY = 0.85;
|
|
14
34
|
export const DEFAULT_UNDERLAY_COLOR = 'black';
|
|
15
|
-
// RN's Pressability DEFAULT_MIN_PRESS_DURATION, the floor a press visual is held, so a very fast
|
|
16
|
-
// tap still flashes the active feedback (Pressability.js).
|
|
17
|
-
export const DEFAULT_MIN_PRESS_DURATION_MS = 130;
|
|
18
35
|
// RN's _deactivate floor: the press-out waits at least minPressDuration past activation (so a fast
|
|
19
36
|
// tap holds the active visual) and at least delayPressOut, whichever is longer. `heldFor` is how
|
|
20
37
|
// long the visual has already been active (0 when it never activated).
|
|
@@ -82,3 +99,75 @@ export function createTouchableFeedbackHandlers(config, runtime, callbacks) {
|
|
|
82
99
|
},
|
|
83
100
|
};
|
|
84
101
|
}
|
|
102
|
+
// ---- RN-audited additions (2026-08-19) --------------------------------------------------------
|
|
103
|
+
//
|
|
104
|
+
// Measured against .vendors/react-native. Everything above predates that audit and is still what
|
|
105
|
+
// the React, Vue, Svelte and Angular adapters call; the names below are the RN-accurate forms and
|
|
106
|
+
// are additive on purpose, so the tree stays green while each adapter migrates.
|
|
107
|
+
// RN's Touchable* family OVERRIDES Pressability's own floor with 0 — TouchableOpacity.js:195,
|
|
108
|
+
// TouchableHighlight.js:203 and TouchableWithoutFeedback.js all pass `minPressDuration: 0`. So
|
|
109
|
+
// Pressability's own default of 130 (Pressability.js:264) reaches a Touchable in RN NEVER: what holds the active visual there is the Animated fade's own duration,
|
|
110
|
+
// not a press-duration floor. Adapters defaulting minPressDuration to 130 delay every press-out by
|
|
111
|
+
// an eighth of a second RN does not.
|
|
112
|
+
export const TOUCHABLE_MIN_PRESS_DURATION_MS = 0;
|
|
113
|
+
// RN's _getChildStyleOpacityWithDefault (TouchableOpacity.js): the fade returns to the opacity the
|
|
114
|
+
// CALLER's style asks for, not to a hard 1 — a Touchable styled `opacity: 0.6` must settle back at
|
|
115
|
+
// 0.6, and its Animated.Value must START there or the first paint jumps to fully opaque. `unknown`
|
|
116
|
+
// in, because a style prop is an arbitrarily nested array the engine's flattenStyle resolves.
|
|
117
|
+
export function restingOpacityFromStyle(style) {
|
|
118
|
+
const opacity = flattenStyle(style).opacity;
|
|
119
|
+
return typeof opacity === 'number' ? opacity : RESTING_OPACITY;
|
|
120
|
+
}
|
|
121
|
+
export function hasTouchablePressHandler(props) {
|
|
122
|
+
return (props.onPress != null ||
|
|
123
|
+
props.onPressIn != null ||
|
|
124
|
+
props.onPressOut != null ||
|
|
125
|
+
props.onLongPress != null);
|
|
126
|
+
}
|
|
127
|
+
export function createHighlightUnderlayRuntime() {
|
|
128
|
+
return { hideTimerCancel: undefined };
|
|
129
|
+
}
|
|
130
|
+
export function createHighlightUnderlayHandlers(config, runtime, callbacks) {
|
|
131
|
+
const { delayPressOut, hasPressHandler, schedule } = config;
|
|
132
|
+
const { setShown, onShowUnderlay, onHideUnderlay } = callbacks;
|
|
133
|
+
function clearHideTimer() {
|
|
134
|
+
if (runtime.hideTimerCancel !== undefined) {
|
|
135
|
+
runtime.hideTimerCancel();
|
|
136
|
+
runtime.hideTimerCancel = undefined;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
function show() {
|
|
140
|
+
if (!hasPressHandler)
|
|
141
|
+
return;
|
|
142
|
+
setShown(true);
|
|
143
|
+
onShowUnderlay?.();
|
|
144
|
+
}
|
|
145
|
+
function hide() {
|
|
146
|
+
clearHideTimer();
|
|
147
|
+
if (!hasPressHandler)
|
|
148
|
+
return;
|
|
149
|
+
setShown(false);
|
|
150
|
+
onHideUnderlay?.();
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
handlePressIn() {
|
|
154
|
+
clearHideTimer();
|
|
155
|
+
show();
|
|
156
|
+
},
|
|
157
|
+
// RN holds the underlay for delayPressOut past the tap, so a press too fast to see still
|
|
158
|
+
// flashes. The timer is what onPressOut below reads to know it must not hide yet.
|
|
159
|
+
handlePress() {
|
|
160
|
+
clearHideTimer();
|
|
161
|
+
show();
|
|
162
|
+
dlog(`TouchableHighlight underlay held ${delayPressOut}ms after press`);
|
|
163
|
+
runtime.hideTimerCancel = schedule(() => {
|
|
164
|
+
runtime.hideTimerCancel = undefined;
|
|
165
|
+
hide();
|
|
166
|
+
}, delayPressOut);
|
|
167
|
+
},
|
|
168
|
+
handlePressOut() {
|
|
169
|
+
if (runtime.hideTimerCancel === undefined)
|
|
170
|
+
hide();
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export interface IListDiagnosticFrame {
|
|
2
|
+
scrollOffset: number;
|
|
3
|
+
viewportLength: number;
|
|
4
|
+
first: number;
|
|
5
|
+
last: number;
|
|
6
|
+
targetFirst: number;
|
|
7
|
+
targetLast: number;
|
|
8
|
+
count: number;
|
|
9
|
+
measuredCount: number;
|
|
10
|
+
averageLength: number;
|
|
11
|
+
averageStride: number;
|
|
12
|
+
firstOffset: number;
|
|
13
|
+
firstRaw: number | undefined;
|
|
14
|
+
total: number;
|
|
15
|
+
leadingExtent: number;
|
|
16
|
+
trailingExtent: number;
|
|
17
|
+
}
|
|
18
|
+
export interface IListDiagnosticMove {
|
|
19
|
+
kind: 'moved' | 'sized';
|
|
20
|
+
index: number;
|
|
21
|
+
from: number;
|
|
22
|
+
to: number;
|
|
23
|
+
}
|
|
24
|
+
interface IListDiagnosticHandlers {
|
|
25
|
+
onFrame?: (frame: IListDiagnosticFrame) => void;
|
|
26
|
+
onMove?: (move: IListDiagnosticMove) => void;
|
|
27
|
+
}
|
|
28
|
+
export declare function subscribeListDiagnostics(handlers: IListDiagnosticHandlers): () => void;
|
|
29
|
+
export declare function recordListFrame(build: () => IListDiagnosticFrame): void;
|
|
30
|
+
export declare function recordCellMove(kind: 'moved' | 'sized', index: number, from: number, to: number): void;
|
|
31
|
+
export {};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// A diagnostic tap on the list's windowing state, for reading on a DEVICE. The whole class of bugs
|
|
2
|
+
// this exists for — a spacer that disagrees with where the host actually laid the cells out — is
|
|
3
|
+
// invisible to a headless test, because the fake reports back exactly what the model asked for. The
|
|
4
|
+
// residual only exists where a real Yoga does the layout.
|
|
5
|
+
//
|
|
6
|
+
// Off unless something subscribes: `sink` is undefined and every record is one property read, the
|
|
7
|
+
// same cost model as dlog. Nothing here is gated behind DEBUG, because the canary's HUD subscribes
|
|
8
|
+
// explicitly rather than relying on an env flag surviving a Metro cache.
|
|
9
|
+
// A SET, not one slot. Two subscribers overlap for real: swapping between two screens that each
|
|
10
|
+
// mount a readout disposes the outgoing one AFTER the incoming one mounts, so a single slot would
|
|
11
|
+
// leave the survivor unsubscribed and the readout permanently blank.
|
|
12
|
+
const subscribers = new Set();
|
|
13
|
+
export function subscribeListDiagnostics(handlers) {
|
|
14
|
+
subscribers.add(handlers);
|
|
15
|
+
return () => {
|
|
16
|
+
subscribers.delete(handlers);
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
// The frame is built through a callback so nothing is assembled while nobody is listening — the
|
|
20
|
+
// snapshot reads a dozen fields off the state and would otherwise cost that on every recompute.
|
|
21
|
+
export function recordListFrame(build) {
|
|
22
|
+
if (subscribers.size === 0)
|
|
23
|
+
return;
|
|
24
|
+
const frame = build();
|
|
25
|
+
for (const handlers of subscribers)
|
|
26
|
+
handlers.onFrame?.(frame);
|
|
27
|
+
}
|
|
28
|
+
export function recordCellMove(kind, index, from, to) {
|
|
29
|
+
if (subscribers.size === 0)
|
|
30
|
+
return;
|
|
31
|
+
for (const handlers of subscribers)
|
|
32
|
+
handlers.onMove?.({ kind, index, from, to });
|
|
33
|
+
}
|
|
@@ -17,6 +17,19 @@ export interface IListState<ItemT> {
|
|
|
17
17
|
scrollOffset: number;
|
|
18
18
|
viewportLength: number;
|
|
19
19
|
measured: Map<number, number>;
|
|
20
|
+
measuredOffsets: Map<number, number>;
|
|
21
|
+
measureVersion: number;
|
|
22
|
+
offsetsCache: {
|
|
23
|
+
count: number;
|
|
24
|
+
data: unknown;
|
|
25
|
+
getItemLayout: unknown;
|
|
26
|
+
averageLength: number;
|
|
27
|
+
averageStride: number;
|
|
28
|
+
measureVersion: number;
|
|
29
|
+
offsets: number[];
|
|
30
|
+
lengths: number[];
|
|
31
|
+
total: number;
|
|
32
|
+
} | null;
|
|
20
33
|
committedWindow: {
|
|
21
34
|
first: number;
|
|
22
35
|
last: number;
|
|
@@ -65,6 +78,7 @@ export type IListAction<ItemT> = {
|
|
|
65
78
|
kind: 'measure';
|
|
66
79
|
index: number;
|
|
67
80
|
length: number;
|
|
81
|
+
offset?: number;
|
|
68
82
|
} | {
|
|
69
83
|
kind: 'refresh-metrics';
|
|
70
84
|
} | {
|
|
@@ -18,12 +18,16 @@
|
|
|
18
18
|
// here, so a windowing / edge / viewability / MVCP bug — and the drift between three copies of it —
|
|
19
19
|
// is fixed once for all adapters.
|
|
20
20
|
import { dlog } from '@symbiote-native/engine';
|
|
21
|
-
import { EMPTY_OFFSET, FIRST_INDEX, NO_CONTENT_LENGTH_SENT, NO_INDEX, buildOffsets, computeEndReached, computeMvcpAdjustment, computeStartReached, computeViewableSet, computeWindow, decideEdgeReached, diffViewable, highestMeasuredIndex, indexOfItem, maxMinimumViewTime, offsetForEnd, offsetForIndex, resolveAverageLength, resolveItemKey, throttleWindow, wrapFixedLayout, } from './virtualized-list.js';
|
|
21
|
+
import { EMPTY_OFFSET, FIRST_INDEX, NO_CONTENT_LENGTH_SENT, NO_INDEX, averageMeasuredStride, buildOffsets, computeEndReached, computeMvcpAdjustment, computeStartReached, computeViewableSet, computeWindow, decideEdgeReached, diffViewable, highestMeasuredIndex, indexOfItem, isSettledLayout, maxMinimumViewTime, offsetForEnd, offsetForIndex, resolveAverageLength, resolveItemKey, throttleWindow, wrapFixedLayout, } from './virtualized-list.js';
|
|
22
|
+
import { recordCellMove, recordListFrame, } from './virtualized-list-diagnostics.js';
|
|
22
23
|
export function createInitialListState() {
|
|
23
24
|
return {
|
|
24
25
|
scrollOffset: EMPTY_OFFSET,
|
|
25
26
|
viewportLength: EMPTY_OFFSET,
|
|
26
27
|
measured: new Map(),
|
|
28
|
+
measuredOffsets: new Map(),
|
|
29
|
+
measureVersion: 0,
|
|
30
|
+
offsetsCache: null,
|
|
27
31
|
committedWindow: { first: FIRST_INDEX, last: NO_INDEX },
|
|
28
32
|
sentEndForContentLength: NO_CONTENT_LENGTH_SENT,
|
|
29
33
|
sentStartForContentLength: NO_CONTENT_LENGTH_SENT,
|
|
@@ -58,7 +62,34 @@ function deriveMetrics(state, inputs) {
|
|
|
58
62
|
const count = inputs.getItemCount(inputs.data);
|
|
59
63
|
const fixedLayout = wrapFixedLayout(inputs.data, inputs.getItemLayout);
|
|
60
64
|
const averageLength = resolveAverageLength(fixedLayout, count, state.measured);
|
|
61
|
-
const
|
|
65
|
+
const averageStride = averageMeasuredStride(state.measuredOffsets, averageLength);
|
|
66
|
+
// Every input buildOffsets reads, compared before paying for it again. `data` and
|
|
67
|
+
// `getItemLayout` stand in for `fixedLayout`, which wrapFixedLayout mints fresh on every call
|
|
68
|
+
// and whose identity therefore means nothing.
|
|
69
|
+
const cached = state.offsetsCache;
|
|
70
|
+
const reusable = cached !== null &&
|
|
71
|
+
cached.count === count &&
|
|
72
|
+
cached.data === inputs.data &&
|
|
73
|
+
cached.getItemLayout === inputs.getItemLayout &&
|
|
74
|
+
cached.averageLength === averageLength &&
|
|
75
|
+
cached.averageStride === averageStride &&
|
|
76
|
+
cached.measureVersion === state.measureVersion;
|
|
77
|
+
const { offsets, lengths, total } = reusable
|
|
78
|
+
? cached
|
|
79
|
+
: buildOffsets(count, state.measured, state.measuredOffsets, fixedLayout, averageLength, averageStride);
|
|
80
|
+
if (!reusable) {
|
|
81
|
+
state.offsetsCache = {
|
|
82
|
+
count,
|
|
83
|
+
data: inputs.data,
|
|
84
|
+
getItemLayout: inputs.getItemLayout,
|
|
85
|
+
averageLength,
|
|
86
|
+
averageStride,
|
|
87
|
+
measureVersion: state.measureVersion,
|
|
88
|
+
offsets,
|
|
89
|
+
lengths,
|
|
90
|
+
total,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
62
93
|
const target = computeWindow(count, offsets, lengths, state.scrollOffset, state.viewportLength, inputs.windowSize, inputs.initialNumToRender);
|
|
63
94
|
const throttled = throttleWindow(target, state.committedWindow, inputs.maxToRenderPerBatch);
|
|
64
95
|
state.committedWindow = throttled;
|
|
@@ -73,6 +104,29 @@ function deriveMetrics(state, inputs) {
|
|
|
73
104
|
averageLength,
|
|
74
105
|
fixedLayout,
|
|
75
106
|
};
|
|
107
|
+
recordListFrame(() => ({
|
|
108
|
+
scrollOffset: state.scrollOffset,
|
|
109
|
+
viewportLength: state.viewportLength,
|
|
110
|
+
first: throttled.first,
|
|
111
|
+
last: throttled.last,
|
|
112
|
+
targetFirst: target.first,
|
|
113
|
+
targetLast: target.last,
|
|
114
|
+
count,
|
|
115
|
+
measuredCount: state.measured.size,
|
|
116
|
+
averageLength,
|
|
117
|
+
averageStride: averageMeasuredStride(state.measuredOffsets, averageLength),
|
|
118
|
+
firstOffset: offsets[throttled.first] ?? EMPTY_OFFSET,
|
|
119
|
+
firstRaw: state.measuredOffsets.get(throttled.first),
|
|
120
|
+
total,
|
|
121
|
+
leadingExtent: throttled.first > FIRST_INDEX
|
|
122
|
+
? offsets[throttled.first - 1] +
|
|
123
|
+
lengths[throttled.first - 1] -
|
|
124
|
+
offsets[FIRST_INDEX]
|
|
125
|
+
: EMPTY_OFFSET,
|
|
126
|
+
trailingExtent: throttled.last < count - 1
|
|
127
|
+
? total - offsets[throttled.last + 1]
|
|
128
|
+
: EMPTY_OFFSET,
|
|
129
|
+
}));
|
|
76
130
|
return state;
|
|
77
131
|
}
|
|
78
132
|
function keyForOf(inputs) {
|
|
@@ -82,13 +136,61 @@ function keyForOf(inputs) {
|
|
|
82
136
|
// (batch-fill -> end -> start -> viewability -> initial-scroll -> MVCP). Each is guarded by its own
|
|
83
137
|
// dedup state (sent*ForContentLength, lastViewable, appliedInitialScroll, firstVisibleKey), so
|
|
84
138
|
// running commit on every render is safe — the guards prevent a redundant fire.
|
|
139
|
+
// Reclassify the rendered cells and, if the viewable set changed, hand the adapter an info payload
|
|
140
|
+
// to fire (after minimumViewTime, if any). lastViewable is folded back only when the fire actually
|
|
141
|
+
// lands (the 'viewable-fired' action), so a debounce superseded mid-flight still diffs against the
|
|
142
|
+
// last COMMITTED set.
|
|
143
|
+
//
|
|
144
|
+
// Extracted from commitList because 'record-interaction' needs the same pass: RN's
|
|
145
|
+
// recordInteraction() ungates waitForInteraction and calls _updateViewableItems immediately
|
|
146
|
+
// (VirtualizedList.js ~288-296). Leaving the pass inline meant a list that fits its viewport and is
|
|
147
|
+
// never scrolled reported nothing at all after the interaction — the next commit that would have
|
|
148
|
+
// carried the report never came.
|
|
149
|
+
function viewabilityEffects(state, inputs) {
|
|
150
|
+
const m = state.metrics;
|
|
151
|
+
if (inputs.viewabilityPairs.length === EMPTY_OFFSET ||
|
|
152
|
+
state.viewportLength === EMPTY_OFFSET ||
|
|
153
|
+
m.count === FIRST_INDEX) {
|
|
154
|
+
return [];
|
|
155
|
+
}
|
|
156
|
+
const { tokens, map } = computeViewableSet({
|
|
157
|
+
first: m.first,
|
|
158
|
+
last: m.last,
|
|
159
|
+
count: m.count,
|
|
160
|
+
offsets: m.offsets,
|
|
161
|
+
lengths: m.lengths,
|
|
162
|
+
scrollOffset: state.scrollOffset,
|
|
163
|
+
viewportLength: state.viewportLength,
|
|
164
|
+
data: inputs.data,
|
|
165
|
+
getItem: inputs.getItem,
|
|
166
|
+
keyExtractor: inputs.keyExtractor,
|
|
167
|
+
pairs: inputs.viewabilityPairs,
|
|
168
|
+
hasInteracted: state.hasInteracted,
|
|
169
|
+
});
|
|
170
|
+
const diff = diffViewable(state.lastViewable, map, tokens);
|
|
171
|
+
if (!diff.hasChanged)
|
|
172
|
+
return [];
|
|
173
|
+
dlog(`VirtualizedList viewable=${tokens.length} changed=${diff.changed.length} ` +
|
|
174
|
+
`(window [${m.first}, ${m.last}])`);
|
|
175
|
+
return [
|
|
176
|
+
{
|
|
177
|
+
kind: 'fire-viewable',
|
|
178
|
+
info: { viewableItems: tokens, changed: diff.changed },
|
|
179
|
+
delay: maxMinimumViewTime(inputs.viewabilityPairs),
|
|
180
|
+
map,
|
|
181
|
+
},
|
|
182
|
+
];
|
|
183
|
+
}
|
|
85
184
|
function commitList(state, inputs) {
|
|
86
185
|
const effects = [];
|
|
87
186
|
const m = state.metrics;
|
|
88
187
|
// Batch fill: when the throttled window has not reached the target, ask for another render tick so
|
|
89
188
|
// it keeps filling toward target (RN's incremental fill).
|
|
90
189
|
if (!(m.first <= m.target.first && m.last >= m.target.last)) {
|
|
91
|
-
effects.push({
|
|
190
|
+
effects.push({
|
|
191
|
+
kind: 'schedule-refill',
|
|
192
|
+
delay: inputs.updateCellsBatchingPeriod,
|
|
193
|
+
});
|
|
92
194
|
}
|
|
93
195
|
// onEndReached: fire only when the actual last cell is rendered AND within threshold; dedup by
|
|
94
196
|
// content length; re-arm on scroll away from the end (RN _maybeCallOnEdgeReached).
|
|
@@ -123,39 +225,7 @@ function commitList(state, inputs) {
|
|
|
123
225
|
effects.push({ kind: 'fire-start-reached', distanceFromStart });
|
|
124
226
|
}
|
|
125
227
|
}
|
|
126
|
-
|
|
127
|
-
// info payload to fire (after minimumViewTime, if any). lastViewable is folded back only when the
|
|
128
|
-
// fire actually lands (the 'viewable-fired' action), so a debounce that is superseded mid-flight
|
|
129
|
-
// still diffs against the last COMMITTED set.
|
|
130
|
-
if (inputs.viewabilityPairs.length > EMPTY_OFFSET &&
|
|
131
|
-
state.viewportLength > EMPTY_OFFSET &&
|
|
132
|
-
m.count !== FIRST_INDEX) {
|
|
133
|
-
const { tokens, map } = computeViewableSet({
|
|
134
|
-
first: m.first,
|
|
135
|
-
last: m.last,
|
|
136
|
-
count: m.count,
|
|
137
|
-
offsets: m.offsets,
|
|
138
|
-
lengths: m.lengths,
|
|
139
|
-
scrollOffset: state.scrollOffset,
|
|
140
|
-
viewportLength: state.viewportLength,
|
|
141
|
-
data: inputs.data,
|
|
142
|
-
getItem: inputs.getItem,
|
|
143
|
-
keyExtractor: inputs.keyExtractor,
|
|
144
|
-
pairs: inputs.viewabilityPairs,
|
|
145
|
-
hasInteracted: state.hasInteracted,
|
|
146
|
-
});
|
|
147
|
-
const diff = diffViewable(state.lastViewable, map, tokens);
|
|
148
|
-
if (diff.hasChanged) {
|
|
149
|
-
dlog(`VirtualizedList viewable=${tokens.length} changed=${diff.changed.length} ` +
|
|
150
|
-
`(window [${m.first}, ${m.last}])`);
|
|
151
|
-
effects.push({
|
|
152
|
-
kind: 'fire-viewable',
|
|
153
|
-
info: { viewableItems: tokens, changed: diff.changed },
|
|
154
|
-
delay: maxMinimumViewTime(inputs.viewabilityPairs),
|
|
155
|
-
map,
|
|
156
|
-
});
|
|
157
|
-
}
|
|
158
|
-
}
|
|
228
|
+
effects.push(...viewabilityEffects(state, inputs));
|
|
159
229
|
// initialScrollIndex: once the first viewport is known, jump to that index a single time.
|
|
160
230
|
if (inputs.initialScrollIndex !== undefined &&
|
|
161
231
|
!state.appliedInitialScroll &&
|
|
@@ -183,7 +253,11 @@ function commitList(state, inputs) {
|
|
|
183
253
|
effects.push({ kind: 'scroll-to', offset: EMPTY_OFFSET, animated: true });
|
|
184
254
|
}
|
|
185
255
|
else if (mvcp.action.kind === 'shift') {
|
|
186
|
-
effects.push({
|
|
256
|
+
effects.push({
|
|
257
|
+
kind: 'scroll-to',
|
|
258
|
+
offset: mvcp.action.offset,
|
|
259
|
+
animated: false,
|
|
260
|
+
});
|
|
187
261
|
}
|
|
188
262
|
state.firstVisibleKey = mvcp.firstVisibleKey;
|
|
189
263
|
return { state, effects, changed: false };
|
|
@@ -192,6 +266,25 @@ function commitList(state, inputs) {
|
|
|
192
266
|
// target is past the last measured cell (RN VirtualizedList.js), else scroll to the resolved offset.
|
|
193
267
|
function resolveScrollToIndex(state, inputs, action) {
|
|
194
268
|
const m = state.metrics;
|
|
269
|
+
// Range check FIRST, mirroring RN VirtualizedList.js (~165-178) invariant-for-invariant. Three
|
|
270
|
+
// separate messages rather than one, because an empty list and an index past the end are
|
|
271
|
+
// different diagnoses. It has to precede the onScrollToIndexFailed branch below, or an
|
|
272
|
+
// out-of-range index on a list without getItemLayout would be reported as a MEASUREMENT problem
|
|
273
|
+
// and send the reader to inspect cell layout for what is a caller bug.
|
|
274
|
+
//
|
|
275
|
+
// Note this is the one place the range is enforced: offsetForIndex() still CLAMPS, and must, since
|
|
276
|
+
// scrollToEnd and initialScrollIndex resolve through it with indices that are legitimately at or
|
|
277
|
+
// past the edge.
|
|
278
|
+
const itemCount = inputs.getItemCount(inputs.data);
|
|
279
|
+
if (action.index < FIRST_INDEX) {
|
|
280
|
+
throw new Error(`scrollToIndex out of range: requested index ${action.index} but minimum is 0`);
|
|
281
|
+
}
|
|
282
|
+
if (itemCount < 1) {
|
|
283
|
+
throw new Error(`scrollToIndex out of range: item length ${itemCount} but minimum is 1`);
|
|
284
|
+
}
|
|
285
|
+
if (action.index >= itemCount) {
|
|
286
|
+
throw new Error(`scrollToIndex out of range: requested index ${action.index} is out of 0 to ${itemCount - 1}`);
|
|
287
|
+
}
|
|
195
288
|
const measuredCeiling = highestMeasuredIndex(state.measured);
|
|
196
289
|
if (inputs.getItemLayout === undefined && action.index > measuredCeiling) {
|
|
197
290
|
dlog(`VirtualizedList onScrollToIndexFailed index=${action.index} ` +
|
|
@@ -230,26 +323,61 @@ export function reduceList(state, action, inputs) {
|
|
|
230
323
|
state.scrollOffset = action.offset;
|
|
231
324
|
return { state, effects: [], changed: true };
|
|
232
325
|
case 'layout':
|
|
326
|
+
// Same settling rule as a cell measurement: the scroll host re-reports its own size after
|
|
327
|
+
// every relayout, and a re-derive off noise in its last bits drives the same loop.
|
|
328
|
+
if (isSettledLayout(state.viewportLength, action.length))
|
|
329
|
+
return { state, effects: [], changed: false };
|
|
233
330
|
state.viewportLength = action.length;
|
|
234
331
|
return { state, effects: [], changed: true };
|
|
235
|
-
case 'measure':
|
|
236
|
-
// A fixed getItemLayout owns cell sizes, so a measured length is ignored
|
|
237
|
-
// length changes nothing. Both guards keep an idle onLayout from forcing a render.
|
|
332
|
+
case 'measure': {
|
|
333
|
+
// A fixed getItemLayout owns cell sizes, so a measured length is ignored.
|
|
238
334
|
if (inputs.getItemLayout !== undefined)
|
|
239
335
|
return { state, effects: [], changed: false };
|
|
240
|
-
|
|
336
|
+
const knownLength = state.measured.get(action.index);
|
|
337
|
+
const knownOffset = state.measuredOffsets.get(action.index);
|
|
338
|
+
const lengthSettled = isSettledLayout(knownLength, action.length);
|
|
339
|
+
const offsetSettled = action.offset === undefined ||
|
|
340
|
+
isSettledLayout(knownOffset, action.offset);
|
|
341
|
+
// Settled means "the same measurement, re-reported" — an idle onLayout, or the float noise a
|
|
342
|
+
// relayout leaves behind. Bail WITHOUT storing: keeping the settled value byte-identical is
|
|
343
|
+
// the half that matters, because the spacer derived from it then stops moving too and the
|
|
344
|
+
// relayout loop has nothing left to feed on (see LAYOUT_EPSILON).
|
|
345
|
+
if (lengthSettled && offsetSettled)
|
|
241
346
|
return { state, effects: [], changed: false };
|
|
347
|
+
// Each half is stored only if IT moved: a cell that slid without resizing must not have its
|
|
348
|
+
// length rewritten with a noisier reading of the same number.
|
|
349
|
+
if (!lengthSettled) {
|
|
350
|
+
if (knownLength !== undefined)
|
|
351
|
+
recordCellMove('sized', action.index, knownLength, action.length);
|
|
352
|
+
state.measured.set(action.index, action.length);
|
|
353
|
+
state.measureVersion += 1;
|
|
354
|
+
}
|
|
355
|
+
if (action.offset !== undefined && !offsetSettled) {
|
|
356
|
+
if (knownOffset !== undefined)
|
|
357
|
+
recordCellMove('moved', action.index, knownOffset, action.offset);
|
|
358
|
+
state.measuredOffsets.set(action.index, action.offset);
|
|
359
|
+
state.measureVersion += 1;
|
|
242
360
|
}
|
|
243
|
-
state.measured.set(action.index, action.length);
|
|
244
361
|
return { state, effects: [], changed: true };
|
|
362
|
+
}
|
|
245
363
|
case 'batch-tick':
|
|
246
364
|
// The refill timer fired: ask for a render, whose refresh-metrics grows the window one step.
|
|
247
365
|
return { state, effects: [], changed: true };
|
|
248
366
|
case 'refresh-metrics':
|
|
249
|
-
return {
|
|
367
|
+
return {
|
|
368
|
+
state: deriveMetrics(state, inputs),
|
|
369
|
+
effects: [],
|
|
370
|
+
changed: true,
|
|
371
|
+
};
|
|
250
372
|
case 'record-interaction':
|
|
373
|
+
// The flag flip must land BEFORE the pass — computeViewableSet reads hasInteracted to decide
|
|
374
|
+
// whether a waitForInteraction pair is still gated.
|
|
251
375
|
state.hasInteracted = true;
|
|
252
|
-
return {
|
|
376
|
+
return {
|
|
377
|
+
state,
|
|
378
|
+
effects: viewabilityEffects(state, inputs),
|
|
379
|
+
changed: false,
|
|
380
|
+
};
|
|
253
381
|
case 'viewable-fired':
|
|
254
382
|
state.lastViewable = action.map;
|
|
255
383
|
return { state, effects: [], changed: false };
|
|
@@ -258,7 +386,13 @@ export function reduceList(state, action, inputs) {
|
|
|
258
386
|
case 'scroll-to-offset':
|
|
259
387
|
return {
|
|
260
388
|
state,
|
|
261
|
-
effects: [
|
|
389
|
+
effects: [
|
|
390
|
+
{
|
|
391
|
+
kind: 'scroll-to',
|
|
392
|
+
offset: action.offset,
|
|
393
|
+
animated: action.animated,
|
|
394
|
+
},
|
|
395
|
+
],
|
|
262
396
|
changed: false,
|
|
263
397
|
};
|
|
264
398
|
case 'scroll-to-index':
|