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