@symbiote-native/solid 0.0.1 → 0.1.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/build/bootstrap.d.ts +7 -0
- package/build/bootstrap.js +18 -0
- package/build/components/activity-indicator/index.android.d.ts +2 -0
- package/build/components/activity-indicator/index.android.js +11 -0
- package/build/components/activity-indicator/index.d.ts +1 -0
- package/build/components/activity-indicator/index.ios.d.ts +2 -0
- package/build/components/activity-indicator/index.ios.js +10 -0
- package/build/components/activity-indicator/index.js +4 -0
- package/build/components/activity-indicator/shared.d.ts +8 -0
- package/build/components/activity-indicator/shared.js +51 -0
- package/build/components/button.d.ts +7 -0
- package/build/components/button.jsx +45 -0
- package/build/components/flat-list/index.d.ts +10 -0
- package/build/components/flat-list/index.jsx +118 -0
- package/build/components/image-background.d.ts +10 -0
- package/build/components/image-background.jsx +74 -0
- package/build/components/image.d.ts +9 -0
- package/build/components/image.js +75 -0
- package/build/components/input-accessory-view.d.ts +11 -0
- package/build/components/input-accessory-view.jsx +39 -0
- package/build/components/keyboard-avoiding-view.d.ts +15 -0
- package/build/components/keyboard-avoiding-view.jsx +152 -0
- package/build/components/modal.d.ts +24 -0
- package/build/components/modal.jsx +125 -0
- package/build/components/pressable.d.ts +30 -0
- package/build/components/pressable.jsx +185 -0
- package/build/components/refresh-control.d.ts +18 -0
- package/build/components/refresh-control.jsx +36 -0
- package/build/components/safe-area-view.d.ts +10 -0
- package/build/components/safe-area-view.jsx +24 -0
- package/build/components/scroll-view/index.android.d.ts +2 -0
- package/build/components/scroll-view/index.android.js +11 -0
- package/build/components/scroll-view/index.d.ts +2 -0
- package/build/components/scroll-view/index.ios.d.ts +2 -0
- package/build/components/scroll-view/index.ios.js +8 -0
- package/build/components/scroll-view/index.js +9 -0
- package/build/components/scroll-view/shared.d.ts +85 -0
- package/build/components/scroll-view/shared.jsx +334 -0
- package/build/components/scroll-view/sticky-header.d.ts +18 -0
- package/build/components/scroll-view/sticky-header.jsx +182 -0
- package/build/components/section-list/index.d.ts +6 -0
- package/build/components/section-list/index.jsx +8 -0
- package/build/components/switch/index.android.d.ts +2 -0
- package/build/components/switch/index.android.js +12 -0
- package/build/components/switch/index.d.ts +1 -0
- package/build/components/switch/index.ios.d.ts +2 -0
- package/build/components/switch/index.ios.js +11 -0
- package/build/components/switch/index.js +4 -0
- package/build/components/switch/shared.d.ts +11 -0
- package/build/components/switch/shared.js +87 -0
- package/build/components/text-input.d.ts +10 -0
- package/build/components/text-input.js +268 -0
- package/build/components/text.d.ts +26 -0
- package/build/components/text.jsx +27 -0
- package/build/components/touchable/index.d.ts +22 -0
- package/build/components/touchable/index.jsx +247 -0
- package/build/components/touchable-native-feedback/index.d.ts +16 -0
- package/build/components/touchable-native-feedback/index.jsx +68 -0
- package/build/components/view.d.ts +32 -0
- package/build/components/view.jsx +49 -0
- package/build/components/virtualized-list/index.android.d.ts +2 -0
- package/build/components/virtualized-list/index.android.js +9 -0
- package/build/components/virtualized-list/index.d.ts +1 -0
- package/build/components/virtualized-list/index.ios.d.ts +2 -0
- package/build/components/virtualized-list/index.ios.js +9 -0
- package/build/components/virtualized-list/index.js +4 -0
- package/build/components/virtualized-list/shared.d.ts +75 -0
- package/build/components/virtualized-list/shared.jsx +782 -0
- package/build/components/virtualized-section-list/index.d.ts +62 -0
- package/build/components/virtualized-section-list/index.jsx +145 -0
- package/build/components.d.ts +43 -0
- package/build/components.js +54 -0
- package/build/create-portal/index.d.ts +11 -0
- package/build/create-portal/index.jsx +88 -0
- package/build/create-tunnel/index.d.ts +13 -0
- package/build/create-tunnel/index.jsx +53 -0
- package/build/descriptor-to-solid.d.ts +3 -0
- package/build/descriptor-to-solid.js +91 -0
- package/build/host-instance.d.ts +2 -0
- package/build/host-instance.js +36 -0
- package/build/index.d.ts +30 -0
- package/build/index.js +96 -0
- package/build/jsx-runtime.d.ts +26 -0
- package/build/jsx-runtime.js +46 -0
- package/build/modules/animated/create-animated-component.d.ts +10 -0
- package/build/modules/animated/create-animated-component.jsx +105 -0
- package/build/modules/animated/index.d.ts +54 -0
- package/build/modules/animated/index.js +78 -0
- package/build/modules/app-registry/index.d.ts +7 -0
- package/build/modules/app-registry/index.js +37 -0
- package/build/modules/status-bar.d.ts +7 -0
- package/build/modules/status-bar.js +45 -0
- package/build/primitives/create-color-scheme.d.ts +3 -0
- package/build/primitives/create-color-scheme.js +33 -0
- package/build/primitives/create-window-dimensions.d.ts +3 -0
- package/build/primitives/create-window-dimensions.js +34 -0
- package/build/render.d.ts +7 -0
- package/build/render.js +91 -0
- package/build/renderer.d.ts +7 -0
- package/build/renderer.js +195 -0
- package/build/utils/host-ref.d.ts +3 -0
- package/build/utils/host-ref.js +22 -0
- package/build/utils/stable-keys.d.ts +1 -0
- package/build/utils/stable-keys.js +31 -0
- package/package.json +5 -5
|
@@ -0,0 +1,782 @@
|
|
|
1
|
+
// VirtualizedList — real windowing over a hand-authored scroll host. Only the cells whose computed
|
|
2
|
+
// offset falls inside the visible window (plus a leading/trailing buffer) exist as native views;
|
|
3
|
+
// everything above and below collapses into spacers whose sizes sum to the off-screen extent, so the
|
|
4
|
+
// scroll thumb and total content size stay right without mounting all N rows.
|
|
5
|
+
//
|
|
6
|
+
// The orchestration — window recompute, edge-reached, viewability, batch fill, MVCP, the imperative
|
|
7
|
+
// scrolls — is the framework-agnostic `reduceList` state machine in @symbiote-native/components,
|
|
8
|
+
// shared verbatim with React, Vue, Svelte and Angular. So is every geometry leaf (`buildListPlan`,
|
|
9
|
+
// `computeWindow`, `resolveItemKey`, …) and every scroll-host helper ScrollView also uses
|
|
10
|
+
// (`selectScrollIntrinsics`, `resolveScrollForwarding`, `splitLayoutProps`, `buildScrollViewHandle`,
|
|
11
|
+
// `attachStickyScroll`, `forwardScrollEvent`). Solid supplies ONLY its lifecycle: it turns native
|
|
12
|
+
// events into ACTIONS, holds ONE plain state cell, runs the returned EFFECTS with Solid primitives,
|
|
13
|
+
// and assembles the host elements. Lists have no Descriptor render fn — the cell content is the
|
|
14
|
+
// user's own subtree (`symbiote-add-component` §0, category 2).
|
|
15
|
+
//
|
|
16
|
+
// WHY THIS FILE HAND-AUTHORS THE SCROLL INTRINSICS INSTEAD OF RENDERING <ScrollView>, which is what
|
|
17
|
+
// adapters/react and adapters/vue do. ScrollView takes an OPAQUE children slot, so its sticky path
|
|
18
|
+
// has to resolve those children through solid's `children()` helper and re-wrap the whole list every
|
|
19
|
+
// time any of them changes — and here "any of them changes" is every scroll event, which would tear
|
|
20
|
+
// down and rebuild every sticky header mid-scroll (losing its measured layout each time). Walking
|
|
21
|
+
// `plan.cells` instead lets this file wrap the flagged CELL itself, once, inside the keyed <For> row
|
|
22
|
+
// that owns it. Same conclusion, same reason, as adapters/svelte's index.svelte.
|
|
23
|
+
//
|
|
24
|
+
// THREE SOLID FACTS DRIVE THE REST, and none of them is cosmetic (.claude/rules/
|
|
25
|
+
// solid-descriptor-bridge.md):
|
|
26
|
+
//
|
|
27
|
+
// 1. A component body runs ONCE and `insert` REPLACES rather than diffing. So the cells ride a keyed
|
|
28
|
+
// <For> over the cell KEY strings — <For> matches by value identity, which is what makes a row
|
|
29
|
+
// MOVE as the window slides instead of being destroyed and rebuilt. <Index> would have been
|
|
30
|
+
// exactly wrong here: it keys by position, so row 0 would be reused for a different item on every
|
|
31
|
+
// scroll and its whole `renderItem` subtree would be rebuilt each step.
|
|
32
|
+
// 2. `renderItem` is a render prop, so its info arrives as an ACCESSOR and the call is untracked — a
|
|
33
|
+
// tracked call would rebuild the cell subtree on every change (trap 4). Same shape
|
|
34
|
+
// `<For>{(item, index) => …}` and Pressable's child already use.
|
|
35
|
+
// 3. The scroll AXIS picks a different host TAG and Solid cannot swap a tag under a live node — so
|
|
36
|
+
// the flip is an explicit rebuild boundary (trap 5), with the build untracked so every other prop
|
|
37
|
+
// re-props the SAME nodes through `spread`.
|
|
38
|
+
//
|
|
39
|
+
// The ONE thing that differs per platform is where the RefreshControl sits: a childless SIBLING
|
|
40
|
+
// before the content on iOS, WRAPPING the whole scroll host on Android (an Android ScrollView takes
|
|
41
|
+
// exactly one child). That is `IScrollViewHostPlatform.refreshControlMode`, the same fact this
|
|
42
|
+
// adapter's ScrollView encodes, so the two platform barrels feed it in.
|
|
43
|
+
import { For, Show, createEffect, createMemo, createRenderEffect, createSignal, on, onCleanup, splitProps, untrack, } from 'solid-js';
|
|
44
|
+
import { DEFAULT_END_REACHED_THRESHOLD, DEFAULT_INITIAL_NUM_TO_RENDER, DEFAULT_MAX_TO_RENDER_PER_BATCH, DEFAULT_START_REACHED_THRESHOLD, DEFAULT_UPDATE_CELLS_BATCHING_PERIOD, DEFAULT_WINDOW_SIZE, EMPTY_OFFSET, FIRST_INDEX, NO_INDEX, INVERTED_X_STYLE, INVERTED_Y_STYLE, buildListPlan, buildScrollViewHandle, buildViewabilityPairs, nextStickyHeaderY, isSeparatorGapInRange, listEffectSignature, attachStickyScroll, forwardScrollEvent, resolveAccessibilityProps, resolveScrollForwarding, splitLayoutProps, selectScrollIntrinsics, createInitialListState, readLayoutLength, readLayoutOffset, readLayoutNumber, readScrollOffset, reduceList, resolveItemKey, } from '@symbiote-native/components';
|
|
45
|
+
import { AnimatedValue, dlog, event as animatedEvent, getNativeTag, isNativeAnimatedAvailable, isSymbioteNode, whenCommitted, resolveClassName, } from '@symbiote-native/engine';
|
|
46
|
+
import { createElement, insert, insertNode, setProp, spread, } from '../../renderer.js';
|
|
47
|
+
import { withStableKeys } from '../../utils/stable-keys.js';
|
|
48
|
+
import { RefreshControl } from '../refresh-control.jsx';
|
|
49
|
+
import { ScrollViewStickyHeader } from '../scroll-view/sticky-header.jsx';
|
|
50
|
+
// Consumed by the lifecycle itself; everything LEFT OVER is the accessibility surface, which rides
|
|
51
|
+
// down onto the scroll host exactly as React spreads `...accessibilityRest` onto its ScrollView.
|
|
52
|
+
const HANDLED_PROPS = [
|
|
53
|
+
'data',
|
|
54
|
+
'getItem',
|
|
55
|
+
'getItemCount',
|
|
56
|
+
'renderItem',
|
|
57
|
+
'getItemLayout',
|
|
58
|
+
'initialNumToRender',
|
|
59
|
+
'windowSize',
|
|
60
|
+
'ListHeaderComponent',
|
|
61
|
+
'ListFooterComponent',
|
|
62
|
+
'ListEmptyComponent',
|
|
63
|
+
'ItemSeparatorComponent',
|
|
64
|
+
'ref',
|
|
65
|
+
'onEndReached',
|
|
66
|
+
'onEndReachedThreshold',
|
|
67
|
+
'onStartReached',
|
|
68
|
+
'onStartReachedThreshold',
|
|
69
|
+
'keyExtractor',
|
|
70
|
+
'onViewableItemsChanged',
|
|
71
|
+
'viewabilityConfig',
|
|
72
|
+
'viewabilityConfigCallbackPairs',
|
|
73
|
+
'onScroll',
|
|
74
|
+
'onScrollBeginDrag',
|
|
75
|
+
'onScrollEndDrag',
|
|
76
|
+
'onMomentumScrollBegin',
|
|
77
|
+
'onMomentumScrollEnd',
|
|
78
|
+
'scrollEventThrottle',
|
|
79
|
+
'keyboardShouldPersistTaps',
|
|
80
|
+
'keyboardDismissMode',
|
|
81
|
+
'horizontal',
|
|
82
|
+
'inverted',
|
|
83
|
+
'style',
|
|
84
|
+
'contentContainerStyle',
|
|
85
|
+
'class',
|
|
86
|
+
'onScrollToIndexFailed',
|
|
87
|
+
'onRefresh',
|
|
88
|
+
'refreshing',
|
|
89
|
+
'progressViewOffset',
|
|
90
|
+
'stickyHeaderIndices',
|
|
91
|
+
'maintainVisibleContentPosition',
|
|
92
|
+
'initialScrollIndex',
|
|
93
|
+
'maxToRenderPerBatch',
|
|
94
|
+
'updateCellsBatchingPeriod',
|
|
95
|
+
'extraData',
|
|
96
|
+
];
|
|
97
|
+
// A ListHeaderComponent occupies child 0 of the content container, which shifts every data index
|
|
98
|
+
// native MVCP anchors against by one (RN VirtualizedList.js does the same bump).
|
|
99
|
+
const HEADER_CHILD_COUNT = 1;
|
|
100
|
+
export function createVirtualizedList(platform) {
|
|
101
|
+
return function VirtualizedList(props) {
|
|
102
|
+
// The ONE folded state cell (RN's scattered refs collapsed into IListState). A plain object, never
|
|
103
|
+
// a signal: nothing reads it reactively.
|
|
104
|
+
const headerElement = untrack(() => props.ListHeaderComponent);
|
|
105
|
+
const footerElement = untrack(() => props.ListFooterComponent);
|
|
106
|
+
const emptyElement = untrack(() => props.ListEmptyComponent);
|
|
107
|
+
// The scroll host, held by IDENTITY in a plain variable: a store or any proxy wrapper would be a
|
|
108
|
+
// different key than the one the engine's commit mirror holds, and every imperative command would
|
|
109
|
+
// silently no-op (symbiote-engine-core §3).
|
|
110
|
+
let hostNode = null;
|
|
111
|
+
// A LAZY node getter, not the node captured once: it is null until the tree builds, and every
|
|
112
|
+
// command re-reads it.
|
|
113
|
+
const scrollHandle = buildScrollViewHandle(() => hostNode);
|
|
114
|
+
const [, accessibilityRest] = splitProps(props, HANDLED_PROPS);
|
|
115
|
+
const isHorizontal = () => props.horizontal === true;
|
|
116
|
+
// Drives every sticky header's translateY (RN's _scrollAnimatedValue). Allocated unconditionally,
|
|
117
|
+
// exactly like React's unconditional hook, and held by identity.
|
|
118
|
+
const scrollAnimatedValue = new AnimatedValue(0);
|
|
119
|
+
// Sticky cross-talk (RN's _headerLayoutYs): a data-index -> measured-y map so each header learns
|
|
120
|
+
// where the NEXT one starts, its push-off collision point. Mutated imperatively from each
|
|
121
|
+
// header's onLayout; the version bump is what lets the PREVIOUS header re-read it.
|
|
122
|
+
const headerLayoutYs = new Map();
|
|
123
|
+
const [stickyVersion, setStickyVersion] = createSignal(0);
|
|
124
|
+
const stickySet = createMemo(() => props.stickyHeaderIndices !== undefined
|
|
125
|
+
? new Set(props.stickyHeaderIndices)
|
|
126
|
+
: undefined);
|
|
127
|
+
const hasStickyHeaders = () => {
|
|
128
|
+
const indices = props.stickyHeaderIndices;
|
|
129
|
+
return indices !== undefined && indices.length > FIRST_INDEX;
|
|
130
|
+
};
|
|
131
|
+
const nativeStickyAvailable = () => hasStickyHeaders() && isNativeAnimatedAvailable();
|
|
132
|
+
// RN's FlatList/SectionList expose no invertStickyHeaders of their own (only ScrollView does), so
|
|
133
|
+
// list headers always pin to the top and the inverted viewport capture is never needed.
|
|
134
|
+
const forwarding = createMemo(() => resolveScrollForwarding({
|
|
135
|
+
hasStickyHeaders: hasStickyHeaders(),
|
|
136
|
+
nativeStickyAvailable: nativeStickyAvailable(),
|
|
137
|
+
invertStickyHeaders: undefined,
|
|
138
|
+
scrollEventThrottle: props.scrollEventThrottle,
|
|
139
|
+
maintainVisibleContentPosition: props.maintainVisibleContentPosition,
|
|
140
|
+
snapToAlignment: undefined,
|
|
141
|
+
}));
|
|
142
|
+
// Inversion flips the scroll container along the scroll axis; each cell re-flips so its own content
|
|
143
|
+
// stays upright (RN does the same with a scale(-1) transform). The content CONTAINER is left alone
|
|
144
|
+
// — flipping it too would cancel the outer flip.
|
|
145
|
+
const invertedStyle = () => {
|
|
146
|
+
if (props.inverted !== true)
|
|
147
|
+
return undefined;
|
|
148
|
+
return isHorizontal() ? INVERTED_X_STYLE : INVERTED_Y_STYLE;
|
|
149
|
+
};
|
|
150
|
+
const listState = createInitialListState();
|
|
151
|
+
// Bumped on every render-relevant transition; that bump is what re-derives `metrics`. listState is
|
|
152
|
+
// a plain object, so mutating it triggers nothing on its own.
|
|
153
|
+
const [version, setVersion] = createSignal(0);
|
|
154
|
+
// Per-gap separator overrides, keyed by the LEADING cell index of the gap. Render state read
|
|
155
|
+
// directly in the cell walk, not part of the windowing orchestration, so it stays adapter-side.
|
|
156
|
+
const separatorOverrides = new Map();
|
|
157
|
+
const [separatorVersion, setSeparatorVersion] = createSignal(0);
|
|
158
|
+
// The adapter owns the timers; the reducer only asks for a delay.
|
|
159
|
+
let viewableTimer = null;
|
|
160
|
+
let batchTimer = null;
|
|
161
|
+
// The offset we are imperatively driving native to before the host node has a Fabric tag. Rides
|
|
162
|
+
// down as the scroll host's contentOffset prop; fresh object identity each push.
|
|
163
|
+
const [commandedOffset, setCommandedOffset] = createSignal(undefined);
|
|
164
|
+
function inputs() {
|
|
165
|
+
return {
|
|
166
|
+
data: props.data,
|
|
167
|
+
getItem: props.getItem,
|
|
168
|
+
getItemCount: props.getItemCount,
|
|
169
|
+
keyExtractor: props.keyExtractor,
|
|
170
|
+
getItemLayout: props.getItemLayout,
|
|
171
|
+
horizontal: isHorizontal(),
|
|
172
|
+
windowSize: props.windowSize ?? DEFAULT_WINDOW_SIZE,
|
|
173
|
+
initialNumToRender: props.initialNumToRender ?? DEFAULT_INITIAL_NUM_TO_RENDER,
|
|
174
|
+
maxToRenderPerBatch: props.maxToRenderPerBatch ?? DEFAULT_MAX_TO_RENDER_PER_BATCH,
|
|
175
|
+
updateCellsBatchingPeriod: props.updateCellsBatchingPeriod ??
|
|
176
|
+
DEFAULT_UPDATE_CELLS_BATCHING_PERIOD,
|
|
177
|
+
onEndReachedThreshold: props.onEndReachedThreshold ?? DEFAULT_END_REACHED_THRESHOLD,
|
|
178
|
+
onStartReachedThreshold: props.onStartReachedThreshold ?? DEFAULT_START_REACHED_THRESHOLD,
|
|
179
|
+
maintainVisibleContentPosition: props.maintainVisibleContentPosition,
|
|
180
|
+
initialScrollIndex: props.initialScrollIndex,
|
|
181
|
+
onEndReachedActive: props.onEndReached !== undefined,
|
|
182
|
+
onStartReachedActive: props.onStartReached !== undefined,
|
|
183
|
+
viewabilityPairs: buildViewabilityPairs(props.onViewableItemsChanged, props.viewabilityConfig, props.viewabilityConfigCallbackPairs),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
function keyFor(index) {
|
|
187
|
+
return resolveItemKey(props.getItem(props.data, index), index, props.keyExtractor);
|
|
188
|
+
}
|
|
189
|
+
// The work the reducer asked for, run with Solid's own primitives. The reducer DESCRIBES an
|
|
190
|
+
// effect; which timer holds a debounce and how a native scroll is dispatched stay here.
|
|
191
|
+
function runEffects(effects) {
|
|
192
|
+
for (const effect of effects) {
|
|
193
|
+
switch (effect.kind) {
|
|
194
|
+
case 'scroll-to': {
|
|
195
|
+
const clamped = Math.max(EMPTY_OFFSET, effect.offset);
|
|
196
|
+
const target = {
|
|
197
|
+
x: isHorizontal() ? clamped : EMPTY_OFFSET,
|
|
198
|
+
y: isHorizontal() ? EMPTY_OFFSET : clamped,
|
|
199
|
+
};
|
|
200
|
+
// A native scrollTo needs the node's COMMITTED Fabric tag; this adapter commits on a
|
|
201
|
+
// microtask, so a scroll requested in the same tick as mount has none yet.
|
|
202
|
+
// contentOffset is RN's own pre-commit fallback.
|
|
203
|
+
const node = hostNode;
|
|
204
|
+
if (node !== null && getNativeTag(node) !== undefined) {
|
|
205
|
+
dlog(`VirtualizedList scrollTo offset=${clamped} animated=${effect.animated}`);
|
|
206
|
+
setCommandedOffset(undefined);
|
|
207
|
+
scrollHandle.scrollTo({ ...target, animated: effect.animated });
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
dlog(`VirtualizedList scrollTo offset=${clamped} pending-commit`);
|
|
211
|
+
setCommandedOffset(target);
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
case 'fire-end-reached':
|
|
215
|
+
props.onEndReached?.({ distanceFromEnd: effect.distanceFromEnd });
|
|
216
|
+
break;
|
|
217
|
+
case 'fire-start-reached':
|
|
218
|
+
props.onStartReached?.({
|
|
219
|
+
distanceFromStart: effect.distanceFromStart,
|
|
220
|
+
});
|
|
221
|
+
break;
|
|
222
|
+
case 'fire-viewable': {
|
|
223
|
+
const pairs = inputs().viewabilityPairs;
|
|
224
|
+
const info = effect.info;
|
|
225
|
+
const map = effect.map;
|
|
226
|
+
// lastViewable is folded back only when the fire actually LANDS, so a debounce superseded
|
|
227
|
+
// mid-flight still diffs against the last committed set.
|
|
228
|
+
const fire = () => {
|
|
229
|
+
for (const pair of pairs)
|
|
230
|
+
pair.onViewableItemsChanged(info);
|
|
231
|
+
dispatch({ kind: 'viewable-fired', map });
|
|
232
|
+
};
|
|
233
|
+
if (viewableTimer !== null) {
|
|
234
|
+
clearTimeout(viewableTimer);
|
|
235
|
+
viewableTimer = null;
|
|
236
|
+
}
|
|
237
|
+
if (effect.delay > EMPTY_OFFSET) {
|
|
238
|
+
viewableTimer = setTimeout(() => {
|
|
239
|
+
viewableTimer = null;
|
|
240
|
+
fire();
|
|
241
|
+
}, effect.delay);
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
fire();
|
|
245
|
+
}
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
case 'schedule-refill':
|
|
249
|
+
// Ask for another render tick so the throttled window keeps growing toward its target.
|
|
250
|
+
if (batchTimer !== null)
|
|
251
|
+
clearTimeout(batchTimer);
|
|
252
|
+
batchTimer = setTimeout(() => {
|
|
253
|
+
batchTimer = null;
|
|
254
|
+
dispatch({ kind: 'batch-tick' });
|
|
255
|
+
}, effect.delay);
|
|
256
|
+
break;
|
|
257
|
+
case 'fire-scroll-to-index-failed':
|
|
258
|
+
props.onScrollToIndexFailed?.({
|
|
259
|
+
index: effect.index,
|
|
260
|
+
highestMeasuredFrameIndex: effect.highestMeasuredFrameIndex,
|
|
261
|
+
averageItemLength: effect.averageItemLength,
|
|
262
|
+
});
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
// Untracked by construction: dispatch is reached from native events, timers and the imperative
|
|
268
|
+
// handle, never from a render position, and reading every prop through inputs() inside a tracked
|
|
269
|
+
// scope would subscribe that scope to the whole prop surface.
|
|
270
|
+
function dispatch(action) {
|
|
271
|
+
untrack(() => {
|
|
272
|
+
const result = reduceList(listState, action, inputs());
|
|
273
|
+
runEffects(result.effects);
|
|
274
|
+
if (result.changed)
|
|
275
|
+
setVersion(tick => tick + 1);
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
// RN animates every imperative scroll unless the caller passes animated: false; each resolves to an
|
|
279
|
+
// offset inside the reducer and then rides the native scrollTo command.
|
|
280
|
+
const handle = {
|
|
281
|
+
scrollToOffset: (params) => {
|
|
282
|
+
dispatch({
|
|
283
|
+
kind: 'scroll-to-offset',
|
|
284
|
+
offset: params.offset,
|
|
285
|
+
animated: params.animated ?? true,
|
|
286
|
+
});
|
|
287
|
+
},
|
|
288
|
+
scrollToIndex: (params) => {
|
|
289
|
+
dispatch({
|
|
290
|
+
kind: 'scroll-to-index',
|
|
291
|
+
index: params.index,
|
|
292
|
+
animated: params.animated ?? true,
|
|
293
|
+
viewPosition: params.viewPosition ?? FIRST_INDEX,
|
|
294
|
+
viewOffset: params.viewOffset ?? EMPTY_OFFSET,
|
|
295
|
+
});
|
|
296
|
+
},
|
|
297
|
+
scrollToItem: (params) => {
|
|
298
|
+
dispatch({
|
|
299
|
+
kind: 'scroll-to-item',
|
|
300
|
+
item: params.item,
|
|
301
|
+
animated: params.animated ?? true,
|
|
302
|
+
viewPosition: params.viewPosition ?? FIRST_INDEX,
|
|
303
|
+
});
|
|
304
|
+
},
|
|
305
|
+
scrollToEnd: (params) => {
|
|
306
|
+
dispatch({ kind: 'scroll-to-end', animated: params?.animated ?? true });
|
|
307
|
+
},
|
|
308
|
+
flashScrollIndicators: () => {
|
|
309
|
+
scrollHandle.flashScrollIndicators();
|
|
310
|
+
},
|
|
311
|
+
// Three names for RN-API parity; SymbioteNative has no findNodeHandle / legacy-instance
|
|
312
|
+
// distinction, so all three route to the same underlying handle (see IScrollRoutingHandle).
|
|
313
|
+
getNativeScrollRef: () => scrollHandle,
|
|
314
|
+
getScrollableNode: () => scrollHandle,
|
|
315
|
+
getScrollResponder: () => scrollHandle,
|
|
316
|
+
getScrollNode: () => scrollHandle.getScrollNode(),
|
|
317
|
+
// RN's recordInteraction: flip the interaction flag so waitForInteraction viewability configs
|
|
318
|
+
// start reporting.
|
|
319
|
+
recordInteraction: () => {
|
|
320
|
+
dispatch({ kind: 'record-interaction' });
|
|
321
|
+
},
|
|
322
|
+
};
|
|
323
|
+
// Solid's `ref` is a COMPILE-TIME construct: a `ref={list}` call site has already been rewritten
|
|
324
|
+
// into a callback by the time this body reads it (utils/host-ref.ts has the full rationale).
|
|
325
|
+
if (typeof props.ref === 'function')
|
|
326
|
+
props.ref(handle);
|
|
327
|
+
function onViewportLayout(event) {
|
|
328
|
+
const length = readLayoutLength(event, isHorizontal());
|
|
329
|
+
if (length === undefined)
|
|
330
|
+
return;
|
|
331
|
+
dlog(`VirtualizedList onLayout viewport=${length}`);
|
|
332
|
+
dispatch({ kind: 'layout', length });
|
|
333
|
+
}
|
|
334
|
+
// Without getItemLayout the list learns its cell sizes from each cell's own onLayout, which is
|
|
335
|
+
// what fills the offset table the window and the spacers are computed from.
|
|
336
|
+
function makeCellMeasure(index) {
|
|
337
|
+
return (event) => {
|
|
338
|
+
const length = readLayoutLength(event, isHorizontal());
|
|
339
|
+
if (length === undefined)
|
|
340
|
+
return;
|
|
341
|
+
const offset = readLayoutOffset(event, isHorizontal());
|
|
342
|
+
dlog(`VirtualizedList cell ${index()} measured length=${length} offset=${offset ?? 'none'}`);
|
|
343
|
+
dispatch({ kind: 'measure', index: index(), length, offset });
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
function handleScroll(event) {
|
|
347
|
+
const offset = readScrollOffset(event, isHorizontal());
|
|
348
|
+
if (offset === undefined)
|
|
349
|
+
return;
|
|
350
|
+
dlog(`VirtualizedList onScroll offset=${offset}`);
|
|
351
|
+
// A real user/native scroll supersedes any pending commanded offset.
|
|
352
|
+
setCommandedOffset(undefined);
|
|
353
|
+
dispatch({ kind: 'scroll', offset });
|
|
354
|
+
// Compose, don't clobber: internal windowing ran first, now the user's onScroll.
|
|
355
|
+
props.onScroll?.(event);
|
|
356
|
+
}
|
|
357
|
+
// Without the native animated module the sticky value has to be driven off the JS thread, so the
|
|
358
|
+
// handler is wrapped in Animated.event; the native and plain paths forward it untouched.
|
|
359
|
+
const scrollHandler = createMemo(() => {
|
|
360
|
+
if (forwarding().mode !== 'sticky-js')
|
|
361
|
+
return handleScroll;
|
|
362
|
+
return animatedEvent([{ nativeEvent: { contentOffset: { y: scrollAnimatedValue } } }], {
|
|
363
|
+
listener: (...args) => forwardScrollEvent(handleScroll, args),
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
// The single derive-per-render: the reducer recomputes the window exactly once per render pass
|
|
367
|
+
// (deriving twice would advance its throttled window two batch steps per frame).
|
|
368
|
+
const metrics = createMemo(() => {
|
|
369
|
+
version();
|
|
370
|
+
reduceList(listState, { kind: 'refresh-metrics' }, inputs());
|
|
371
|
+
return listState.metrics;
|
|
372
|
+
});
|
|
373
|
+
// The shared plan: the spacer extents plus the in-window cell keys. Every geometry decision in it
|
|
374
|
+
// is core's, so a windowing bug is fixed once for all adapters.
|
|
375
|
+
const plan = createMemo(() => {
|
|
376
|
+
const m = metrics();
|
|
377
|
+
if (m.count === FIRST_INDEX)
|
|
378
|
+
return null;
|
|
379
|
+
return buildListPlan({
|
|
380
|
+
count: m.count,
|
|
381
|
+
first: m.first,
|
|
382
|
+
last: m.last,
|
|
383
|
+
offsets: m.offsets,
|
|
384
|
+
lengths: m.lengths,
|
|
385
|
+
total: m.total,
|
|
386
|
+
keyFor,
|
|
387
|
+
stickyIndices: stickySet(),
|
|
388
|
+
hasHeader: headerElement !== undefined,
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
// forcedStickyCell is prepended to the window cells so BOTH walk through the SAME keyed <For>.
|
|
392
|
+
// Two separate template positions would give a header toggling between "forced ahead of the
|
|
393
|
+
// window" and "inside the window" no shared identity, so it would be destroyed and rebuilt
|
|
394
|
+
// (losing its measured layout, back to its default translateY) on every crossing.
|
|
395
|
+
const cells = createMemo(() => {
|
|
396
|
+
const current = plan();
|
|
397
|
+
if (current === null)
|
|
398
|
+
return [];
|
|
399
|
+
return current.forcedStickyCell !== undefined
|
|
400
|
+
? [current.forcedStickyCell, ...current.cells]
|
|
401
|
+
: current.cells;
|
|
402
|
+
});
|
|
403
|
+
// <For> matches by VALUE identity, so the KEYS — not the plan's freshly-built cell objects — are
|
|
404
|
+
// what it walks. A key that survives a window slide keeps its row's nodes and simply moves; over
|
|
405
|
+
// the objects every row would be rebuilt on every recompute.
|
|
406
|
+
const cellKeys = createMemo(() => cells().map(cell => cell.key));
|
|
407
|
+
const cellIndexByKey = createMemo(() => new Map(cells().map((cell) => [cell.key, cell.index])));
|
|
408
|
+
// The after-commit pass, in the order every adapter runs it: batch fill, edge-reached, viewability,
|
|
409
|
+
// initial scroll, MVCP. Each is guarded by its own dedup state inside the reducer, so running it on
|
|
410
|
+
// every windowing change is safe. `on()` runs the body untracked, so this depends on the windowing
|
|
411
|
+
// SIGNATURE alone — the same dedup key every adapter shares — and not on every prop the reducer
|
|
412
|
+
// inputs touch.
|
|
413
|
+
const commitSignature = createMemo(() => {
|
|
414
|
+
metrics();
|
|
415
|
+
return listEffectSignature(listState);
|
|
416
|
+
});
|
|
417
|
+
createEffect(on(commitSignature, () => {
|
|
418
|
+
const result = reduceList(listState, { kind: 'commit' }, inputs());
|
|
419
|
+
runEffects(result.effects);
|
|
420
|
+
}));
|
|
421
|
+
// RN's ViewabilityHelper.dispose: a pending dwell timer must not outlive the list.
|
|
422
|
+
onCleanup(() => {
|
|
423
|
+
if (viewableTimer !== null)
|
|
424
|
+
clearTimeout(viewableTimer);
|
|
425
|
+
if (batchTimer !== null)
|
|
426
|
+
clearTimeout(batchTimer);
|
|
427
|
+
});
|
|
428
|
+
const isEmpty = () => metrics().count === FIRST_INDEX;
|
|
429
|
+
const leadingExtent = () => plan()?.leadingExtent ?? EMPTY_OFFSET;
|
|
430
|
+
const trailingExtent = () => plan()?.trailingExtent ?? EMPTY_OFFSET;
|
|
431
|
+
const spacerStyle = (extent) => isHorizontal() ? { width: extent } : { height: extent };
|
|
432
|
+
// Merge an override onto the separator at a given gap. A gap index outside [0, count-2] addresses
|
|
433
|
+
// no separator, so the write is a no-op (RN bails on the same bounds).
|
|
434
|
+
function mergeSeparator(gapIndex, patch) {
|
|
435
|
+
if (!isSeparatorGapInRange(gapIndex, listState.metrics.count))
|
|
436
|
+
return;
|
|
437
|
+
separatorOverrides.set(gapIndex, {
|
|
438
|
+
...separatorOverrides.get(gapIndex),
|
|
439
|
+
...patch,
|
|
440
|
+
});
|
|
441
|
+
setSeparatorVersion(tick => tick + 1);
|
|
442
|
+
}
|
|
443
|
+
// The ISeparators handle for the cell at `index` (RN CellRenderer._separators): highlight touches
|
|
444
|
+
// the gap before AND after this cell, so a pressed row draws a full-bleed divider on both sides.
|
|
445
|
+
function makeSeparators(index) {
|
|
446
|
+
return {
|
|
447
|
+
highlight: () => {
|
|
448
|
+
mergeSeparator(index - 1, { highlighted: true });
|
|
449
|
+
mergeSeparator(index, { highlighted: true });
|
|
450
|
+
},
|
|
451
|
+
unhighlight: () => {
|
|
452
|
+
mergeSeparator(index - 1, { highlighted: false });
|
|
453
|
+
mergeSeparator(index, { highlighted: false });
|
|
454
|
+
},
|
|
455
|
+
updateProps: (select, newProps) => {
|
|
456
|
+
mergeSeparator(select === 'leading' ? index - 1 : index, newProps);
|
|
457
|
+
},
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
// Reads separatorVersion so a highlight()/unhighlight()/updateProps() call reactively refreshes
|
|
461
|
+
// this gap's props — separatorOverrides is a plain Map and tracks nothing on its own.
|
|
462
|
+
function separatorPropsFor(index) {
|
|
463
|
+
separatorVersion();
|
|
464
|
+
const overrides = separatorOverrides.get(index);
|
|
465
|
+
return {
|
|
466
|
+
highlighted: overrides?.highlighted === true,
|
|
467
|
+
leadingItem: props.getItem(props.data, index),
|
|
468
|
+
trailingItem: props.getItem(props.data, index + 1),
|
|
469
|
+
...overrides,
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
function separatorElement(index) {
|
|
473
|
+
const Separator = props.ItemSeparatorComponent;
|
|
474
|
+
if (Separator === undefined)
|
|
475
|
+
return undefined;
|
|
476
|
+
// A spread of a CALL compiles to mergeProps(() => …), so the separator's props stay live.
|
|
477
|
+
return <Separator {...separatorPropsFor(index)}/>;
|
|
478
|
+
}
|
|
479
|
+
// RN renders a separator in the gap AFTER a cell, and only while there is a following rendered
|
|
480
|
+
// cell — never after the window's last one.
|
|
481
|
+
// RN renders a separator in the gap AFTER a cell, and only while there is a following rendered
|
|
482
|
+
// cell — never after the window's last one, and never after the force-mounted sticky cell, which
|
|
483
|
+
// is not adjacent to the window.
|
|
484
|
+
// RN gates the separator on the last index of the DATA, not of the WINDOW
|
|
485
|
+
// (VirtualizedList.js:793 `const end = getItemCount(data) - 1`), and now that the separator
|
|
486
|
+
// lives INSIDE the measuring wrapper that distinction is load-bearing: gating on the window
|
|
487
|
+
// would make a cell's own measured height change as the window slides past it. Device-measured
|
|
488
|
+
// 2026-08-19 as a run of cells all shifting by exactly the divider's 1px.
|
|
489
|
+
// Nothing about the WINDOW may enter this predicate. The separator lives inside the measuring
|
|
490
|
+
// wrapper, so whatever decides to render it decides the cell's own height — and a height that
|
|
491
|
+
// depends on where the window happens to sit moves the content under the user every time the
|
|
492
|
+
// window slides. Two window-dependent terms were removed after being measured on device
|
|
493
|
+
// 2026-08-19, each as a run of cells shifting by exactly the divider's 1px: gating on the
|
|
494
|
+
// window's `last` (RN gates on the data's, VirtualizedList.js:793), and excluding the
|
|
495
|
+
// force-mounted sticky cell. RN excludes neither — its sticky header keeps its separator like
|
|
496
|
+
// any other cell.
|
|
497
|
+
const hasSeparatorAfter = (index) => props.ItemSeparatorComponent !== undefined && index < metrics().count - 1;
|
|
498
|
+
// Records a sticky header's measured y (RN's _headerLayoutYs) so the header AHEAD of it can
|
|
499
|
+
// compute its collision point; without it every header would stick indefinitely.
|
|
500
|
+
function makeStickyCellLayout(index) {
|
|
501
|
+
const measure = makeCellMeasure(index);
|
|
502
|
+
return (event) => {
|
|
503
|
+
measure(event);
|
|
504
|
+
const y = readLayoutNumber(event, 'y');
|
|
505
|
+
if (y === undefined || headerLayoutYs.get(index()) === y)
|
|
506
|
+
return;
|
|
507
|
+
dlog(`VirtualizedList sticky header index=${index()} y=${y}`);
|
|
508
|
+
headerLayoutYs.set(index(), y);
|
|
509
|
+
setStickyVersion(tick => tick + 1);
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
// Only a header MOUNTED RIGHT NOW can collide with this one — the same restriction React gets by
|
|
513
|
+
// handing its ScrollView the RENDERED sticky positions rather than the full index list. Feeding
|
|
514
|
+
// the full list instead lets a header collide against a stale y from one that already scrolled
|
|
515
|
+
// out, freezing it in place. The map itself is deliberately NOT pruned (React does not either): a
|
|
516
|
+
// measured y stays valid for when that header scrolls back in.
|
|
517
|
+
function nextStickyHeaderYFor(index) {
|
|
518
|
+
// Read the bump FIRST so this stays a dependency of whichever header calls it — that is what
|
|
519
|
+
// turns "a later header measured" into "the previous header rebuilds its collision range".
|
|
520
|
+
stickyVersion();
|
|
521
|
+
const indices = props.stickyHeaderIndices;
|
|
522
|
+
if (indices === undefined)
|
|
523
|
+
return undefined;
|
|
524
|
+
const mounted = new Set(cells().map(cell => cell.index));
|
|
525
|
+
const rendered = indices.filter(sticky => mounted.has(sticky));
|
|
526
|
+
return nextStickyHeaderY(rendered, rendered.indexOf(index), headerLayoutYs);
|
|
527
|
+
}
|
|
528
|
+
function buildCell(index, sticky) {
|
|
529
|
+
// Called ONCE and untracked, the shape Solid core uses for its own render props: a tracked call
|
|
530
|
+
// would put the item signals in the cell's `insert` effect and rebuild this whole subtree on
|
|
531
|
+
// every window step (.claude/rules/solid-descriptor-bridge.md §4).
|
|
532
|
+
const content = untrack(() => props.renderItem(() => ({
|
|
533
|
+
item: props.getItem(props.data, index()),
|
|
534
|
+
index: index(),
|
|
535
|
+
separators: makeSeparators(index()),
|
|
536
|
+
})));
|
|
537
|
+
// The separator rides INSIDE the measuring wrapper, where RN's own cell renderer puts it
|
|
538
|
+
// (VirtualizedListCellRenderer.js:218-221). As a SIBLING it is an extra flex child, so the
|
|
539
|
+
// chrome between two cells is gap + separator + gap while a spacer collapsing that region
|
|
540
|
+
// replaces it with one gap — every cell below the leading spacer then lands short by
|
|
541
|
+
// (separator + gap), and the content jumps by that much each time the window's first index
|
|
542
|
+
// moves. Measured at exactly 17px on device 2026-08-19 (a 1px divider under a 16px container
|
|
543
|
+
// gap); see .claude/rules/list-geometry-feedback-loop.md.
|
|
544
|
+
const separator = (<Show when={hasSeparatorAfter(index())}>
|
|
545
|
+
<symbiote-view>{separatorElement(index())}</symbiote-view>
|
|
546
|
+
</Show>);
|
|
547
|
+
if (!sticky) {
|
|
548
|
+
return (<symbiote-view onLayout={makeCellMeasure(index)} style={invertedStyle()}>
|
|
549
|
+
{content}
|
|
550
|
+
{separator}
|
|
551
|
+
</symbiote-view>);
|
|
552
|
+
}
|
|
553
|
+
// The sticky wrapper measures the cell itself, so it REPLACES the plain measuring view rather
|
|
554
|
+
// than nesting inside it. Every reactive input is written as a CALL, which makes the compiler
|
|
555
|
+
// emit a getter the header reads inside its OWN effect — a resolved value here would join the
|
|
556
|
+
// enclosing insert effect and rebuild every header on every layout.
|
|
557
|
+
return (<ScrollViewStickyHeader onLayout={makeStickyCellLayout(index)} nextHeaderLayoutY={nextStickyHeaderYFor(index())} scrollAnimatedValue={scrollAnimatedValue} inverted={undefined} scrollViewHeight={undefined}>
|
|
558
|
+
{content}
|
|
559
|
+
{separator}
|
|
560
|
+
</ScrollViewStickyHeader>);
|
|
561
|
+
}
|
|
562
|
+
function buildRow(key) {
|
|
563
|
+
// The row is keyed by its cell key, so its data INDEX moves under it as the window slides. The
|
|
564
|
+
// previous value is the fallback for the tick between a key leaving the plan and <For>
|
|
565
|
+
// disposing its row.
|
|
566
|
+
const index = createMemo((previous) => cellIndexByKey().get(key) ?? previous, NO_INDEX);
|
|
567
|
+
const isSticky = createMemo(() => stickySet()?.has(index()) === true);
|
|
568
|
+
// A sticky cell is a different host subtree, not a different prop, and Solid has no reconciler
|
|
569
|
+
// to swap one for the other — so the flip is an explicit rebuild boundary keyed on the
|
|
570
|
+
// DISCRIMINATOR alone, with the build untracked by `on()`.
|
|
571
|
+
const cell = createMemo(on(isSticky, sticky => buildCell(index, sticky)));
|
|
572
|
+
const forcedStickyIndex = () => plan()?.forcedStickyCell?.index;
|
|
573
|
+
const gapExtent = () => plan()?.gapExtent ?? EMPTY_OFFSET;
|
|
574
|
+
return [
|
|
575
|
+
cell,
|
|
576
|
+
// The gap between the force-mounted sticky cell and the window's first cell.
|
|
577
|
+
<Show when={forcedStickyIndex() === index() && gapExtent() > EMPTY_OFFSET}>
|
|
578
|
+
<symbiote-view style={spacerStyle(gapExtent())}/>
|
|
579
|
+
</Show>,
|
|
580
|
+
];
|
|
581
|
+
}
|
|
582
|
+
// The scroll host's own intrinsics. A horizontal list resolves DIFFERENT host tags (on Android
|
|
583
|
+
// horizontal scrolling is a separate ViewManager entirely) and lays its content out in a row.
|
|
584
|
+
// A class-name string resolves through the shared registry before it reaches the intrinsic
|
|
585
|
+
// selector, which only understands style objects/arrays.
|
|
586
|
+
const contentContainerStyleInput = () => {
|
|
587
|
+
const style = props.contentContainerStyle;
|
|
588
|
+
return typeof style === 'string' ? resolveClassName(style) : style;
|
|
589
|
+
};
|
|
590
|
+
const intrinsics = createMemo(() => selectScrollIntrinsics(isHorizontal(), contentContainerStyleInput()));
|
|
591
|
+
const wrapsRefreshControl = () => platform.refreshControlMode === 'wrap' && props.onRefresh !== undefined;
|
|
592
|
+
// Under the Android wrap the class has to be resolved BEFORE the layout/visual split, or a
|
|
593
|
+
// class-only layout prop (flex, height, gap, …) never reaches the wrapper and it collapses to
|
|
594
|
+
// nothing. Same reasoning as ScrollView's.
|
|
595
|
+
const splitStyles = createMemo(() => splitLayoutProps([
|
|
596
|
+
resolveClassName(props.class),
|
|
597
|
+
props.style,
|
|
598
|
+
invertedStyle(),
|
|
599
|
+
]));
|
|
600
|
+
// withStableKeys because several keys below are conditional and Solid's `spread` walks only the
|
|
601
|
+
// CURRENT key set with no removal pass — a key that vanished would keep its last value on the
|
|
602
|
+
// native view forever (.claude/rules/solid-descriptor-bridge.md §1).
|
|
603
|
+
const outerBag = withStableKeys(() => {
|
|
604
|
+
const bag = {
|
|
605
|
+
...resolveAccessibilityProps(accessibilityRest),
|
|
606
|
+
// Base style UNDER the user style, so an explicit height / flexDirection still wins. Under
|
|
607
|
+
// the Android wrap only the VISUAL half stays here; the LAYOUT half moved to the wrapper.
|
|
608
|
+
style: wrapsRefreshControl()
|
|
609
|
+
? [intrinsics().scrollViewBaseStyle, splitStyles().inner]
|
|
610
|
+
: [intrinsics().scrollViewBaseStyle, props.style, invertedStyle()],
|
|
611
|
+
// Load-bearing on iOS (it flips RCTScrollView's own axis); ignored by Android's dedicated
|
|
612
|
+
// horizontal manager.
|
|
613
|
+
horizontal: isHorizontal(),
|
|
614
|
+
// RN's ScrollView default (`nestedScrollEnabled ?? true`): Android needs it for a list nested
|
|
615
|
+
// inside another scrollable to scroll at all, and the Android RefreshControl wrap needs the
|
|
616
|
+
// inner scroll to take the gesture before the refresh parent.
|
|
617
|
+
nestedScrollEnabled: true,
|
|
618
|
+
onLayout: onViewportLayout,
|
|
619
|
+
onScroll: scrollHandler(),
|
|
620
|
+
};
|
|
621
|
+
// forwarding's throttle, not the raw prop: it folds in the sticky-mode default (1 native / 16
|
|
622
|
+
// JS-fallback), without which a header rebuilt off too-sparse scroll events pins late.
|
|
623
|
+
if (forwarding().scrollEventThrottle !== undefined)
|
|
624
|
+
bag.scrollEventThrottle = forwarding().scrollEventThrottle;
|
|
625
|
+
// Stripped under the wrap: splitStyles already folded the resolved class into outer/inner, so
|
|
626
|
+
// forwarding it raw too would re-apply its LAYOUT half a second time.
|
|
627
|
+
if (!wrapsRefreshControl())
|
|
628
|
+
bag.class = props.class;
|
|
629
|
+
if (props.onScrollBeginDrag !== undefined)
|
|
630
|
+
bag.onScrollBeginDrag = props.onScrollBeginDrag;
|
|
631
|
+
if (props.onScrollEndDrag !== undefined)
|
|
632
|
+
bag.onScrollEndDrag = props.onScrollEndDrag;
|
|
633
|
+
if (props.onMomentumScrollBegin !== undefined)
|
|
634
|
+
bag.onMomentumScrollBegin = props.onMomentumScrollBegin;
|
|
635
|
+
if (props.onMomentumScrollEnd !== undefined)
|
|
636
|
+
bag.onMomentumScrollEnd = props.onMomentumScrollEnd;
|
|
637
|
+
if (props.keyboardShouldPersistTaps !== undefined)
|
|
638
|
+
bag.keyboardShouldPersistTaps = props.keyboardShouldPersistTaps;
|
|
639
|
+
if (props.keyboardDismissMode !== undefined)
|
|
640
|
+
bag.keyboardDismissMode = props.keyboardDismissMode;
|
|
641
|
+
// Forwarded so native anchors the cells it CAN see; the JS half (the prepended items collapsed
|
|
642
|
+
// into the leading spacer) is the reducer's. minIndexForVisible counts CHILDREN, so it is
|
|
643
|
+
// bumped by one when a ListHeaderComponent occupies child 0.
|
|
644
|
+
const commanded = commandedOffset();
|
|
645
|
+
if (commanded !== undefined)
|
|
646
|
+
bag.contentOffset = commanded;
|
|
647
|
+
const mvcp = props.maintainVisibleContentPosition;
|
|
648
|
+
if (mvcp !== undefined) {
|
|
649
|
+
bag.maintainVisibleContentPosition = {
|
|
650
|
+
...mvcp,
|
|
651
|
+
minIndexForVisible: mvcp.minIndexForVisible +
|
|
652
|
+
(headerElement !== undefined ? HEADER_CHILD_COUNT : FIRST_INDEX),
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
return bag;
|
|
656
|
+
});
|
|
657
|
+
// `collapsable: false` keeps the content container a real Yoga node: Android Fabric would otherwise
|
|
658
|
+
// flatten it away and hoist the cells up as DIRECT children of the scroll view, which hosts exactly
|
|
659
|
+
// one. A horizontal list also pins the container to the full content WIDTH, or the row is stretched
|
|
660
|
+
// to the viewport and there is nothing to scroll.
|
|
661
|
+
const contentBag = withStableKeys(() => {
|
|
662
|
+
const bag = {
|
|
663
|
+
style: isHorizontal()
|
|
664
|
+
? [intrinsics().contentStyle, { width: metrics().total }]
|
|
665
|
+
: intrinsics().contentStyle,
|
|
666
|
+
collapsable: false,
|
|
667
|
+
};
|
|
668
|
+
// maintainVisibleContentPosition anchors against the metrics of MOUNTED cell views, and Android
|
|
669
|
+
// Fabric flattens layout-only cells away — so the native helper would have nothing to anchor to
|
|
670
|
+
// and the list jumps on prepend. RN keeps the cells real the same way. iOS never flattens.
|
|
671
|
+
if (props.maintainVisibleContentPosition !== undefined)
|
|
672
|
+
bag.collapsableChildren = false;
|
|
673
|
+
return bag;
|
|
674
|
+
});
|
|
675
|
+
// Every reactive read below sits inside a <Show>/<For> prop getter, so this accessor itself has no
|
|
676
|
+
// dependencies and the content view's `insert` effect runs exactly ONCE. Moving one of those
|
|
677
|
+
// conditions into a plain helper called from here would put it in that effect's dependency set and
|
|
678
|
+
// rebuild the whole list on the next scroll (.claude/rules/solid-descriptor-bridge.md §4).
|
|
679
|
+
function listBody() {
|
|
680
|
+
return [
|
|
681
|
+
<Show when={headerElement !== undefined}>
|
|
682
|
+
<symbiote-view>{headerElement}</symbiote-view>
|
|
683
|
+
</Show>,
|
|
684
|
+
<Show when={isEmpty()} fallback={<>
|
|
685
|
+
<Show when={leadingExtent() > EMPTY_OFFSET}>
|
|
686
|
+
<symbiote-view style={spacerStyle(leadingExtent())}/>
|
|
687
|
+
</Show>
|
|
688
|
+
<For each={cellKeys()}>{buildRow}</For>
|
|
689
|
+
<Show when={trailingExtent() > EMPTY_OFFSET}>
|
|
690
|
+
<symbiote-view style={spacerStyle(trailingExtent())}/>
|
|
691
|
+
</Show>
|
|
692
|
+
</>}>
|
|
693
|
+
<Show when={emptyElement !== undefined}>
|
|
694
|
+
<symbiote-view>{emptyElement}</symbiote-view>
|
|
695
|
+
</Show>
|
|
696
|
+
</Show>,
|
|
697
|
+
<Show when={footerElement !== undefined}>
|
|
698
|
+
<symbiote-view>{footerElement}</symbiote-view>
|
|
699
|
+
</Show>,
|
|
700
|
+
];
|
|
701
|
+
}
|
|
702
|
+
function hostElement(tag) {
|
|
703
|
+
const node = createElement(tag);
|
|
704
|
+
// Narrowing, not defensive: the renderer types createElement over its IHostNode union (which
|
|
705
|
+
// includes the surface), while everything below needs a real host node.
|
|
706
|
+
if (!isSymbioteNode(node))
|
|
707
|
+
throw new Error(`VirtualizedList: ${tag} did not create a host node`);
|
|
708
|
+
return node;
|
|
709
|
+
}
|
|
710
|
+
// Built ONCE per tree: `refreshing` is written as a CALL, so the compiler emits a getter and the
|
|
711
|
+
// controlled state keeps reaching this same node.
|
|
712
|
+
function buildRefreshControl() {
|
|
713
|
+
const onRefresh = props.onRefresh;
|
|
714
|
+
if (onRefresh === undefined)
|
|
715
|
+
return undefined;
|
|
716
|
+
dlog('VirtualizedList wiring RefreshControl (onRefresh provided)');
|
|
717
|
+
const element = (<RefreshControl refreshing={props.refreshing ?? false} onRefresh={onRefresh} progressViewOffset={props.progressViewOffset}/>);
|
|
718
|
+
return isSymbioteNode(element) ? element : undefined;
|
|
719
|
+
}
|
|
720
|
+
// The nested scroll-view / content-view pair RN's ScrollView commits.
|
|
721
|
+
function buildTree() {
|
|
722
|
+
const { scrollViewIntrinsic, contentIntrinsic } = intrinsics();
|
|
723
|
+
dlog(`VirtualizedList -> ${scrollViewIntrinsic}`);
|
|
724
|
+
const content = hostElement(contentIntrinsic);
|
|
725
|
+
spread(content, contentBag, true);
|
|
726
|
+
insert(content, listBody);
|
|
727
|
+
const scroll = hostElement(scrollViewIntrinsic);
|
|
728
|
+
hostNode = scroll;
|
|
729
|
+
spread(scroll, outerBag, true);
|
|
730
|
+
// iOS: the RefreshControl is a childless SIBLING placed BEFORE the content container (RN
|
|
731
|
+
// ScrollView.js: {refreshControl}{contentContainer}).
|
|
732
|
+
const refresh = buildRefreshControl();
|
|
733
|
+
if (refresh !== undefined && platform.refreshControlMode === 'sibling') {
|
|
734
|
+
insertNode(scroll, refresh);
|
|
735
|
+
}
|
|
736
|
+
insertNode(scroll, content);
|
|
737
|
+
if (refresh === undefined || !wrapsRefreshControl())
|
|
738
|
+
return scroll;
|
|
739
|
+
// Android: the RefreshControl WRAPS the scroll host. React does this with cloneElement and Vue
|
|
740
|
+
// by re-invoking the VNode's type; Solid has neither, because the element arrived already
|
|
741
|
+
// built — so the scroll host is nested into the existing node and the outer (layout) half of
|
|
742
|
+
// the style is written onto it. A render effect, not a one-shot set, so a later style change
|
|
743
|
+
// still moves.
|
|
744
|
+
createRenderEffect(() => {
|
|
745
|
+
setProp(refresh, 'style', splitStyles().outer);
|
|
746
|
+
});
|
|
747
|
+
insertNode(refresh, scroll);
|
|
748
|
+
return refresh;
|
|
749
|
+
}
|
|
750
|
+
// Drive the sticky scroll value on the native UI thread (RN's attachNativeEvent) so the header
|
|
751
|
+
// interpolations ride scroll natively with no JS per frame. `tree()` is read for its dependency:
|
|
752
|
+
// a rebuild means a NEW scroll node to attach to. The engine commits on a microtask, so the node
|
|
753
|
+
// has no Fabric tag on the first run and the attach would no-op with no retry — whenCommitted is
|
|
754
|
+
// that retry.
|
|
755
|
+
function attachSticky() {
|
|
756
|
+
createEffect(() => {
|
|
757
|
+
tree();
|
|
758
|
+
if (!nativeStickyAvailable())
|
|
759
|
+
return;
|
|
760
|
+
const node = hostNode;
|
|
761
|
+
if (node === null)
|
|
762
|
+
return;
|
|
763
|
+
let detach;
|
|
764
|
+
const cancel = whenCommitted(node, () => {
|
|
765
|
+
detach = attachStickyScroll(node, scrollAnimatedValue);
|
|
766
|
+
});
|
|
767
|
+
onCleanup(() => {
|
|
768
|
+
cancel();
|
|
769
|
+
detach?.();
|
|
770
|
+
});
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
// The scroll AXIS picks a different host TAG and Solid cannot swap a tag under a live node, so the
|
|
774
|
+
// flip REBUILDS — which is exactly what React does when an element type changes. `on()` runs
|
|
775
|
+
// buildTree untracked, so every other read inside it re-props the SAME nodes through `spread`
|
|
776
|
+
// instead of rebuilding them.
|
|
777
|
+
const treeShape = createMemo(() => String(isHorizontal()));
|
|
778
|
+
const tree = createMemo(on(treeShape, () => buildTree()));
|
|
779
|
+
attachSticky();
|
|
780
|
+
return tree;
|
|
781
|
+
};
|
|
782
|
+
}
|