@symbiote-native/svelte 0.2.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/build/bootstrap.js +1 -1
  2. package/build/class-value.js +1 -1
  3. package/build/components/RefreshControl.svelte +4 -2
  4. package/build/components/Text.svelte +11 -2
  5. package/build/components/View.svelte +3 -1
  6. package/build/components/activity-indicator/index.svelte +8 -2
  7. package/build/components/button.svelte +8 -3
  8. package/build/components/flat-list/index.svelte +46 -11
  9. package/build/components/image/image-logic.js +13 -3
  10. package/build/components/image-background/index.svelte +8 -2
  11. package/build/components/index.d.ts +2 -2
  12. package/build/components/input-accessory-view/index.svelte +4 -1
  13. package/build/components/keyboard-avoiding-view/index.svelte +34 -11
  14. package/build/components/modal/index.svelte +4 -1
  15. package/build/components/pressable/index.svelte +48 -23
  16. package/build/components/pressable/index.svelte.d.ts +5 -1
  17. package/build/components/pressable/pressable-props.d.ts +1 -1
  18. package/build/components/safe-area-view-props.d.ts +1 -0
  19. package/build/components/scroll-view/index.svelte +54 -21
  20. package/build/components/scroll-view/sticky-header.svelte +49 -16
  21. package/build/components/section-list/index.svelte +6 -1
  22. package/build/components/switch/index.svelte +16 -5
  23. package/build/components/text-input/index.svelte +80 -8
  24. package/build/components/text-input/index.svelte.d.ts +5 -0
  25. package/build/components/touchable-highlight/index.svelte +115 -15
  26. package/build/components/touchable-highlight/touchable-highlight-props.d.ts +2 -0
  27. package/build/components/touchable-native-feedback/touchable-native-feedback.svelte +8 -3
  28. package/build/components/touchable-opacity/index.svelte +113 -56
  29. package/build/components/touchable-without-feedback/index.svelte +60 -4
  30. package/build/components/virtualized-list/index.svelte +233 -70
  31. package/build/components/virtualized-list/virtualized-list-props.js +8 -4
  32. package/build/components/virtualized-section-list/index.svelte +56 -9
  33. package/build/components/virtualized-section-list/index.svelte.d.ts +1 -1
  34. package/build/components/virtualized-section-list/virtualized-section-list-props.d.ts +5 -0
  35. package/build/create-portal/index.d.ts +22 -0
  36. package/build/create-portal/index.js +160 -0
  37. package/build/descriptor-to-svelte.js +15 -21
  38. package/build/dom-shim/element.d.ts +9 -3
  39. package/build/dom-shim/element.js +77 -25
  40. package/build/dom-shim/fold-host-bag.d.ts +1 -0
  41. package/build/dom-shim/fold-host-bag.js +7 -0
  42. package/build/dom-shim/index.d.ts +1 -0
  43. package/build/dom-shim/index.js +3 -0
  44. package/build/dom-shim/patch-globals.js +4 -2
  45. package/build/dom-shim/shim-node.js +11 -0
  46. package/build/dom-shim/text.js +38 -1
  47. package/build/host-instance.js +9 -5
  48. package/build/index.d.ts +6 -3
  49. package/build/index.js +20 -11
  50. package/build/modules/animated/create-animated-component.d.ts +5 -0
  51. package/build/modules/animated/create-animated-component.js +131 -0
  52. package/build/modules/animated/index.d.ts +24 -14
  53. package/build/modules/animated/index.js +30 -30
  54. package/build/modules/status-bar/index.js +1 -1
  55. package/build/modules/status-bar/index.svelte +4 -1
  56. package/build/native-view-bridge.d.ts +1 -1
  57. package/build/native-view-bridge.js +1 -1
  58. package/build/preprocessor/collapse-text-whitespace.js +20 -7
  59. package/build/preprocessor/forbid-web-only-constructs.js +8 -2
  60. package/build/preprocessor/lower-host-primitives.d.ts +10 -0
  61. package/build/preprocessor/lower-host-primitives.js +455 -0
  62. package/build/preprocessor/scoped-styles.js +141 -104
  63. package/build/register.d.ts +1 -0
  64. package/build/register.js +33 -0
  65. package/build/render.js +48 -3
  66. package/build/scope-token.d.ts +3 -2
  67. package/build/scope-token.js +6 -17
  68. package/build/state-style.d.ts +1 -0
  69. package/build/state-style.js +8 -0
  70. package/build/style-scope.d.ts +2 -1
  71. package/build/style-scope.js +10 -8
  72. package/metro-svelte-transformer.cjs +48 -14
  73. package/package.json +28 -10
  74. package/build/modules/animated/AnimatedImage.svelte +0 -115
  75. package/build/modules/animated/AnimatedImage.svelte.d.ts +0 -5
  76. package/build/modules/animated/AnimatedScrollView.svelte +0 -92
  77. package/build/modules/animated/AnimatedScrollView.svelte.d.ts +0 -17
  78. package/build/modules/animated/AnimatedText.svelte +0 -58
  79. package/build/modules/animated/AnimatedText.svelte.d.ts +0 -5
  80. package/build/modules/animated/AnimatedView.svelte +0 -95
  81. package/build/modules/animated/AnimatedView.svelte.d.ts +0 -5
@@ -24,7 +24,10 @@
24
24
  // `<svelte:element>` is forbidden (svelte-adapter-dom-shim skill §4/§7 - its shim surface isn't
25
25
  // implemented), so the horizontal/vertical choice is two static branches sharing one
26
26
  // `{#snippet listBody()}`, not a dynamic tag.
27
- import type { IVirtualizedListProps, IVirtualizedListHandle } from './virtualized-list-props';
27
+ import type {
28
+ IVirtualizedListProps,
29
+ IVirtualizedListHandle,
30
+ } from './virtualized-list-props';
28
31
 
29
32
  export type { IVirtualizedListProps, IVirtualizedListHandle };
30
33
  </script>
@@ -51,6 +54,7 @@
51
54
  listEffectSignature,
52
55
  nextStickyHeaderY,
53
56
  readLayoutLength,
57
+ readLayoutOffset,
54
58
  readLayoutNumber,
55
59
  readScrollOffset,
56
60
  reduceList,
@@ -85,7 +89,10 @@
85
89
  SCROLL_VIEW_STICKY_CONTEXT_KEY,
86
90
  type IScrollViewStickyContext,
87
91
  } from '../scroll-view/scroll-view-sticky-context';
88
- import { pickAccessibilityProps, type IVirtualizedListProps as IProps } from './virtualized-list-props';
92
+ import {
93
+ pickAccessibilityProps,
94
+ type IVirtualizedListProps as IProps,
95
+ } from './virtualized-list-props';
89
96
  import { createAttachmentsSync } from '../../runes/attachments';
90
97
 
91
98
  let props: IProps<ItemT> = $props();
@@ -104,13 +111,17 @@
104
111
 
105
112
  // Offset we're imperatively driving native to before hostShim's engine node is live. Fresh
106
113
  // object identity each push so the commit path re-applies it even when the value repeats.
107
- let commandedOffset = $state.raw<{ x: number; y: number } | undefined>(undefined);
114
+ let commandedOffset = $state.raw<{ x: number; y: number } | undefined>(
115
+ undefined,
116
+ );
108
117
  // Bumped on a render-relevant change so `metrics` re-runs - listState is a PLAIN object
109
118
  // (not $state), mutating it triggers nothing on its own.
110
119
  let version = $state(0);
111
120
  let separatorVersion = $state(0);
112
121
 
113
- const scrollHandle: IScrollViewHandle = buildScrollViewHandle(() => hostShim?.engineNode ?? null);
122
+ const scrollHandle: IScrollViewHandle = buildScrollViewHandle(
123
+ () => hostShim?.engineNode ?? null,
124
+ );
114
125
 
115
126
  // The one folded state cell — the Svelte twin of Vue's plain listState / React's stateRef.
116
127
  const listState: IListState<ItemT> = createInitialListState<ItemT>();
@@ -147,9 +158,11 @@
147
158
  horizontal: props.horizontal === true,
148
159
  inverted: props.inverted === true,
149
160
  onEndReached: props.onEndReached,
150
- onEndReachedThreshold: props.onEndReachedThreshold ?? DEFAULT_END_REACHED_THRESHOLD,
161
+ onEndReachedThreshold:
162
+ props.onEndReachedThreshold ?? DEFAULT_END_REACHED_THRESHOLD,
151
163
  onStartReached: props.onStartReached,
152
- onStartReachedThreshold: props.onStartReachedThreshold ?? DEFAULT_START_REACHED_THRESHOLD,
164
+ onStartReachedThreshold:
165
+ props.onStartReachedThreshold ?? DEFAULT_START_REACHED_THRESHOLD,
153
166
  onRefresh: props.onRefresh,
154
167
  refreshing: props.refreshing,
155
168
  progressViewOffset: props.progressViewOffset,
@@ -157,9 +170,11 @@
157
170
  viewabilityConfig: props.viewabilityConfig,
158
171
  viewabilityConfigCallbackPairs: props.viewabilityConfigCallbackPairs,
159
172
  onScrollToIndexFailed: props.onScrollToIndexFailed,
160
- initialNumToRender: props.initialNumToRender ?? DEFAULT_INITIAL_NUM_TO_RENDER,
173
+ initialNumToRender:
174
+ props.initialNumToRender ?? DEFAULT_INITIAL_NUM_TO_RENDER,
161
175
  initialScrollIndex: props.initialScrollIndex,
162
- maxToRenderPerBatch: props.maxToRenderPerBatch ?? DEFAULT_MAX_TO_RENDER_PER_BATCH,
176
+ maxToRenderPerBatch:
177
+ props.maxToRenderPerBatch ?? DEFAULT_MAX_TO_RENDER_PER_BATCH,
163
178
  updateCellsBatchingPeriod:
164
179
  props.updateCellsBatchingPeriod ?? DEFAULT_UPDATE_CELLS_BATCHING_PERIOD,
165
180
  windowSize: props.windowSize ?? DEFAULT_WINDOW_SIZE,
@@ -207,7 +222,11 @@
207
222
  }
208
223
 
209
224
  function keyFor(index: number): string {
210
- return resolveItemKey(narrowed.getItem(narrowed.data, index), index, narrowed.keyExtractor);
225
+ return resolveItemKey(
226
+ narrowed.getItem(narrowed.data, index),
227
+ index,
228
+ narrowed.keyExtractor,
229
+ );
211
230
  }
212
231
 
213
232
  function scrollToPixel(offset: number, animated: boolean): void {
@@ -221,11 +240,16 @@
221
240
  return;
222
241
  }
223
242
  commandedOffset = undefined;
224
- dlog(`VirtualizedList scrollTo offset=${clamped} animated=${animated} (horizontal=${narrowed.horizontal})`);
243
+ dlog(
244
+ `VirtualizedList scrollTo offset=${clamped} animated=${animated} (horizontal=${narrowed.horizontal})`,
245
+ );
225
246
  scrollHandle.scrollTo({ x: target.x, y: target.y, animated });
226
247
  }
227
248
 
228
- function runEffects(effects: IListEffect<ItemT>[], inputs: IListReducerInputs<ItemT>): void {
249
+ function runEffects(
250
+ effects: IListEffect<ItemT>[],
251
+ inputs: IListReducerInputs<ItemT>,
252
+ ): void {
229
253
  const p = narrowed;
230
254
  for (const effect of effects) {
231
255
  switch (effect.kind) {
@@ -300,14 +324,17 @@
300
324
  });
301
325
 
302
326
  const hasStickyHeaders = $derived(
303
- narrowed.stickyHeaderIndices !== undefined && narrowed.stickyHeaderIndices.length > 0,
327
+ narrowed.stickyHeaderIndices !== undefined &&
328
+ narrowed.stickyHeaderIndices.length > 0,
304
329
  );
305
330
  // Resolved dynamically, exactly like React (adapters/react/.../scroll-view/shared.ts:267). Do
306
331
  // not hardcode this false to keep the JS listener alive - RN gates native the same way
307
332
  // (AnimatedWithChildren.js:74 `if (!this.__isNative)`) and the pin is the native transform, not
308
333
  // the listener, so forcing JS mode only trades away the native driver: header lag on iOS,
309
334
  // outright failure on Android (commit debounce 15ms vs iOS's 64ms - render-scroll-sticky.ts).
310
- const nativeStickyAvailable = $derived(hasStickyHeaders && isNativeAnimatedAvailable());
335
+ const nativeStickyAvailable = $derived(
336
+ hasStickyHeaders && isNativeAnimatedAvailable(),
337
+ );
311
338
  // RN's FlatList/SectionList expose no invertStickyHeaders of their own (only ScrollView does) -
312
339
  // sticky headers always pin to the top here, matching RN.
313
340
  const forwarding = $derived(
@@ -332,12 +359,16 @@
332
359
  // keeps headers pinned via onScroll.
333
360
  $effect(() => {
334
361
  if (!nativeStickyAvailable) {
335
- dlog('VirtualizedList sticky attachStickyScroll skipped: nativeStickyAvailable=false');
362
+ dlog(
363
+ 'VirtualizedList sticky attachStickyScroll skipped: nativeStickyAvailable=false',
364
+ );
336
365
  return;
337
366
  }
338
367
  const node = hostShim?.engineNode;
339
368
  if (node === undefined) {
340
- dlog('VirtualizedList sticky attachStickyScroll skipped: engineNode not ready yet');
369
+ dlog(
370
+ 'VirtualizedList sticky attachStickyScroll skipped: engineNode not ready yet',
371
+ );
341
372
  return;
342
373
  }
343
374
  dlog('VirtualizedList sticky attachStickyScroll attached');
@@ -359,7 +390,10 @@
359
390
  if (forwarding.mode !== 'sticky-js') return handleScroll;
360
391
  return animatedEvent(
361
392
  [{ nativeEvent: { contentOffset: { y: scrollAnimatedValue } } }],
362
- { listener: (...args: unknown[]) => forwardScrollEvent(handleScroll, args) },
393
+ {
394
+ listener: (...args: unknown[]) =>
395
+ forwardScrollEvent(handleScroll, args),
396
+ },
363
397
  );
364
398
  });
365
399
 
@@ -416,14 +450,23 @@
416
450
  return (event: ISymbioteEvent): void => {
417
451
  const length = readLayoutLength(event, narrowed.horizontal);
418
452
  if (length === undefined) return;
419
- dlog(`VirtualizedList cell ${index} measured length=${length}`);
420
- dispatch({ kind: 'measure', index, length });
453
+ const offset = readLayoutOffset(event, narrowed.horizontal);
454
+ dlog(
455
+ `VirtualizedList cell ${index} measured length=${length} offset=${offset ?? 'none'}`,
456
+ );
457
+ dispatch({ kind: 'measure', index, length, offset });
421
458
  };
422
459
  }
423
460
 
424
- function mergeSeparator(gapIndex: number, patch: Partial<ISeparatorProps<ItemT>>): void {
461
+ function mergeSeparator(
462
+ gapIndex: number,
463
+ patch: Partial<ISeparatorProps<ItemT>>,
464
+ ): void {
425
465
  if (!isSeparatorGapInRange(gapIndex, listState.metrics.count)) return;
426
- separatorOverrides.set(gapIndex, { ...separatorOverrides.get(gapIndex), ...patch });
466
+ separatorOverrides.set(gapIndex, {
467
+ ...separatorOverrides.get(gapIndex),
468
+ ...patch,
469
+ });
427
470
  separatorVersion += 1;
428
471
  }
429
472
 
@@ -439,7 +482,10 @@
439
482
  mergeSeparator(index - 1, { highlighted: false });
440
483
  mergeSeparator(index, { highlighted: false });
441
484
  },
442
- updateProps: (select: 'leading' | 'trailing', newProps: Record<string, unknown>): void => {
485
+ updateProps: (
486
+ select: 'leading' | 'trailing',
487
+ newProps: Record<string, unknown>,
488
+ ): void => {
443
489
  mergeSeparator(select === 'leading' ? index - 1 : index, newProps);
444
490
  },
445
491
  };
@@ -448,8 +494,15 @@
448
494
  // ---- imperative handle: component instance exports, the Svelte twin of Vue's expose() /
449
495
  // React's useImperativeHandle. A parent does `<VirtualizedList bind:this={ref} .../>` and calls
450
496
  // `ref.scrollToIndex(...)`. ----
451
- export function scrollToOffset(params: { offset: number; animated?: boolean }): void {
452
- dispatch({ kind: 'scroll-to-offset', offset: params.offset, animated: params.animated ?? true });
497
+ export function scrollToOffset(params: {
498
+ offset: number;
499
+ animated?: boolean;
500
+ }): void {
501
+ dispatch({
502
+ kind: 'scroll-to-offset',
503
+ offset: params.offset,
504
+ animated: params.animated ?? true,
505
+ });
453
506
  }
454
507
  export function scrollToIndex(params: {
455
508
  index: number;
@@ -520,11 +573,16 @@
520
573
  };
521
574
  });
522
575
 
523
- const scrollViewIntrinsics = $derived(selectScrollIntrinsics(narrowed.horizontal, narrowed.contentContainerStyle));
576
+ const scrollViewIntrinsics = $derived(
577
+ selectScrollIntrinsics(narrowed.horizontal, narrowed.contentContainerStyle),
578
+ );
524
579
 
525
580
  const resolvedStyle = $derived(
526
581
  narrowed.inverted
527
- ? [narrowed.style, narrowed.horizontal ? INVERTED_X_STYLE : INVERTED_Y_STYLE]
582
+ ? [
583
+ narrowed.style,
584
+ narrowed.horizontal ? INVERTED_X_STYLE : INVERTED_Y_STYLE,
585
+ ]
528
586
  : narrowed.style,
529
587
  );
530
588
  const resolvedContentContainerStyle = $derived(
@@ -545,7 +603,10 @@
545
603
 
546
604
  const outerBag = $derived.by(() => {
547
605
  const bag: Record<string, unknown> = {
548
- style: [scrollViewIntrinsics.scrollViewBaseStyle, layoutSplit !== undefined ? layoutSplit.inner : resolvedStyle],
606
+ style: [
607
+ scrollViewIntrinsics.scrollViewBaseStyle,
608
+ layoutSplit !== undefined ? layoutSplit.inner : resolvedStyle,
609
+ ],
549
610
  horizontal: narrowed.horizontal,
550
611
  // RN defaults nested scrolling ON (ScrollView.js `nestedScrollEnabled ?? true`). This file
551
612
  // hand-rolls the raw scroll intrinsic instead of rendering <ScrollView>, so it never
@@ -560,18 +621,26 @@
560
621
  // forwarding.scrollEventThrottle (not the raw prop): folds in the sticky-mode default
561
622
  // (1 native / 16 JS-fallback) when unset - without it a sticky header rebuilt off too-sparse
562
623
  // scroll events pins/collides late.
563
- if (forwarding.scrollEventThrottle !== undefined) bag.scrollEventThrottle = forwarding.scrollEventThrottle;
564
- if (narrowed.onScrollBeginDrag !== undefined) bag.onScrollBeginDrag = narrowed.onScrollBeginDrag;
565
- if (narrowed.onScrollEndDrag !== undefined) bag.onScrollEndDrag = narrowed.onScrollEndDrag;
566
- if (narrowed.onMomentumScrollBegin !== undefined) bag.onMomentumScrollBegin = narrowed.onMomentumScrollBegin;
567
- if (narrowed.onMomentumScrollEnd !== undefined) bag.onMomentumScrollEnd = narrowed.onMomentumScrollEnd;
624
+ if (forwarding.scrollEventThrottle !== undefined)
625
+ bag.scrollEventThrottle = forwarding.scrollEventThrottle;
626
+ if (narrowed.onScrollBeginDrag !== undefined)
627
+ bag.onScrollBeginDrag = narrowed.onScrollBeginDrag;
628
+ if (narrowed.onScrollEndDrag !== undefined)
629
+ bag.onScrollEndDrag = narrowed.onScrollEndDrag;
630
+ if (narrowed.onMomentumScrollBegin !== undefined)
631
+ bag.onMomentumScrollBegin = narrowed.onMomentumScrollBegin;
632
+ if (narrowed.onMomentumScrollEnd !== undefined)
633
+ bag.onMomentumScrollEnd = narrowed.onMomentumScrollEnd;
568
634
  if (narrowed.keyboardShouldPersistTaps !== undefined) {
569
635
  bag.keyboardShouldPersistTaps = narrowed.keyboardShouldPersistTaps;
570
636
  }
571
- if (narrowed.keyboardDismissMode !== undefined) bag.keyboardDismissMode = narrowed.keyboardDismissMode;
572
- if (narrowed.stickyHeaderIndices !== undefined) bag.stickyHeaderIndices = narrowed.stickyHeaderIndices;
637
+ if (narrowed.keyboardDismissMode !== undefined)
638
+ bag.keyboardDismissMode = narrowed.keyboardDismissMode;
639
+ if (narrowed.stickyHeaderIndices !== undefined)
640
+ bag.stickyHeaderIndices = narrowed.stickyHeaderIndices;
573
641
  if (narrowed.maintainVisibleContentPosition !== undefined) {
574
- bag.maintainVisibleContentPosition = narrowed.maintainVisibleContentPosition;
642
+ bag.maintainVisibleContentPosition =
643
+ narrowed.maintainVisibleContentPosition;
575
644
  }
576
645
  // Object.assign merges a bag already built field-by-field (pickAccessibilityProps), not a raw
577
646
  // spread of `props`, so this stays inside the object-bag convention (svelte-adapter-dom-shim
@@ -597,10 +666,15 @@
597
666
  PLATFORM.refreshControlMode === 'wrap' && refreshControlProps !== undefined,
598
667
  );
599
668
 
600
- const contentBag = $derived({ style: resolvedContentContainerStyle, collapsable: false });
669
+ const contentBag = $derived({
670
+ style: resolvedContentContainerStyle,
671
+ collapsable: false,
672
+ });
601
673
 
602
674
  const stickySet = $derived(
603
- narrowed.stickyHeaderIndices !== undefined ? new Set(narrowed.stickyHeaderIndices) : undefined,
675
+ narrowed.stickyHeaderIndices !== undefined
676
+ ? new Set(narrowed.stickyHeaderIndices)
677
+ : undefined,
604
678
  );
605
679
  $effect(() => {
606
680
  dlog(
@@ -624,7 +698,6 @@
624
698
  keyFor,
625
699
  stickyIndices: stickySet,
626
700
  hasHeader,
627
- hasSeparators: props.separator !== undefined,
628
701
  });
629
702
  });
630
703
  // forcedStickyCell prepended to the window cells so BOTH walk through the SAME keyed {#each}.
@@ -638,7 +711,11 @@
638
711
  : (plan?.cells ?? []),
639
712
  );
640
713
  const cellInvertedStyle = $derived(
641
- narrowed.inverted ? (narrowed.horizontal ? INVERTED_X_STYLE : INVERTED_Y_STYLE) : undefined,
714
+ narrowed.inverted
715
+ ? narrowed.horizontal
716
+ ? INVERTED_X_STYLE
717
+ : INVERTED_Y_STYLE
718
+ : undefined,
642
719
  );
643
720
 
644
721
  // Reads separatorVersion so a .highlight()/.unhighlight()/.updateProps() call (ISeparators, above)
@@ -656,47 +733,133 @@
656
733
  }
657
734
  </script>
658
735
 
659
- {#snippet listBody()}<!--
660
- No whitespace between sibling {#if} blocks below, or between each-block siblings, or around the
661
- spacers. Svelte only trims LEADING/TRAILING whitespace of a fragment; whitespace BETWEEN two
662
- sibling non-text nodes collapses to a text node and is kept (Svelte compiler utils.js
663
- clean_nodes, verified against 5.56.8), which would land as a stray RCTRawText child of this
664
- scroll content - invalid on real Fabric for a non-Text view (dom-shim/text.ts). Caught by
665
- virtualized-list.smoke.test.ts asserting an exact windowed child count; keep it exact this way
666
- if you touch this block.
667
- -->{#if hasHeader}<symbiote-view p={{}}>{@render props.header?.()}</symbiote-view>{/if}{#if metrics.count === FIRST_INDEX}{#if props.empty}<symbiote-view p={{}}>{@render props.empty()}</symbiote-view>{/if}{:else if plan}{#if plan.leadingExtent > EMPTY_OFFSET}<symbiote-view
668
- p={{ style: narrowed.horizontal ? { width: plan.leadingExtent } : { height: plan.leadingExtent } }}
669
- ></symbiote-view>{/if}{#each allCells as cell (cell.key)}{#if stickySet?.has(cell.index)}<ScrollViewStickyHeader onLayout={stickyLayoutFor(cell.index)} nextHeaderLayoutY={nextStickyHeaderYFor(cell.index)}>{@render props.item({
670
- item: narrowed.getItem(narrowed.data, cell.index),
671
- index: cell.index,
672
- separators: makeSeparators(cell.index),
673
- })}</ScrollViewStickyHeader>{:else}<symbiote-view p={{ onLayout: makeCellMeasure(cell.index), style: cellInvertedStyle }}>{@render props.item({
674
- item: narrowed.getItem(narrowed.data, cell.index),
675
- index: cell.index,
676
- separators: makeSeparators(cell.index),
677
- })}</symbiote-view>{/if}{#if plan.forcedStickyCell && cell.index === plan.forcedStickyCell.index && plan.gapExtent > EMPTY_OFFSET}<symbiote-view
678
- p={{ style: narrowed.horizontal ? { width: plan.gapExtent } : { height: plan.gapExtent } }}
679
- ></symbiote-view>{/if}{#if props.separator && cell.index < metrics.last && cell.index !== plan.forcedStickyCell?.index}<symbiote-view p={{}}>{@render props.separator(separatorPropsFor(cell.index))}</symbiote-view>{/if}{/each}{#if plan.trailingExtent > EMPTY_OFFSET}<symbiote-view
680
- p={{ style: narrowed.horizontal ? { width: plan.trailingExtent } : { height: plan.trailingExtent } }}
681
- ></symbiote-view>{/if}{/if}{#if props.footer}<symbiote-view p={{}}>{@render props.footer()}</symbiote-view>{/if}
736
+ {#snippet listBody()}
737
+ <!--
738
+ Svelte keeps the whitespace between the sibling blocks below as ' ' text nodes; the shim drops
739
+ each one because this scroll content takes no raw text (dom-shim/text.ts, §16b). That is what
740
+ lets virtualized-list.smoke.test.ts still assert an exact windowed child count.
741
+ -->
742
+ {#if hasHeader}
743
+ <symbiote-view p={{}}>
744
+ {@render props.header?.()}
745
+ </symbiote-view>
746
+ {/if}
747
+ {#if metrics.count === FIRST_INDEX}
748
+ {#if props.empty}
749
+ <symbiote-view p={{}}>
750
+ {@render props.empty()}
751
+ </symbiote-view>
752
+ {/if}
753
+ {:else if plan}
754
+ {#if plan.leadingExtent > EMPTY_OFFSET}
755
+ <symbiote-view
756
+ p={{
757
+ style: narrowed.horizontal
758
+ ? { width: plan.leadingExtent }
759
+ : { height: plan.leadingExtent },
760
+ }}
761
+ />
762
+ {/if}
763
+ {#each allCells as cell (cell.key)}
764
+ {#if stickySet?.has(cell.index)}
765
+ <ScrollViewStickyHeader
766
+ onLayout={stickyLayoutFor(cell.index)}
767
+ nextHeaderLayoutY={nextStickyHeaderYFor(cell.index)}
768
+ >
769
+ {@render props.item({
770
+ item: narrowed.getItem(narrowed.data, cell.index),
771
+ index: cell.index,
772
+ separators: makeSeparators(cell.index),
773
+ })}
774
+ {#if props.separator && cell.index < metrics.count - 1}
775
+ <symbiote-view p={{}}>
776
+ {@render props.separator(separatorPropsFor(cell.index))}
777
+ </symbiote-view>
778
+ {/if}
779
+ </ScrollViewStickyHeader>
780
+ {:else}
781
+ <symbiote-view
782
+ p={{
783
+ onLayout: makeCellMeasure(cell.index),
784
+ style: cellInvertedStyle,
785
+ }}
786
+ >
787
+ {@render props.item({
788
+ item: narrowed.getItem(narrowed.data, cell.index),
789
+ index: cell.index,
790
+ separators: makeSeparators(cell.index),
791
+ })}
792
+ {#if props.separator && cell.index < metrics.count - 1}
793
+ <symbiote-view p={{}}>
794
+ {@render props.separator(separatorPropsFor(cell.index))}
795
+ </symbiote-view>
796
+ {/if}
797
+ </symbiote-view>
798
+ {/if}
799
+ {#if plan.forcedStickyCell && cell.index === plan.forcedStickyCell.index && plan.gapExtent > EMPTY_OFFSET}
800
+ <symbiote-view
801
+ p={{
802
+ style: narrowed.horizontal
803
+ ? { width: plan.gapExtent }
804
+ : { height: plan.gapExtent },
805
+ }}
806
+ />
807
+ {/if}
808
+ {/each}
809
+ {#if plan.trailingExtent > EMPTY_OFFSET}
810
+ <symbiote-view
811
+ p={{
812
+ style: narrowed.horizontal
813
+ ? { width: plan.trailingExtent }
814
+ : { height: plan.trailingExtent },
815
+ }}
816
+ />
817
+ {/if}
818
+ {/if}
819
+ {#if props.footer}
820
+ <symbiote-view p={{}}>
821
+ {@render props.footer()}
822
+ </symbiote-view>
823
+ {/if}
682
824
  {/snippet}
683
825
 
684
- {#snippet scrollBody()}<!--
685
- Same no-whitespace-between-siblings rule as listBody() above: the optional sibling
686
- RefreshControl and the content container are two siblings of one parent when RefreshControl is
687
- NOT wrapping (iOS), so they must sit edge-to-edge with zero characters between them.
688
- -->{#if !shouldWrapRefreshControl && refreshControlProps !== undefined}<RefreshControl {...refreshControlProps} />{/if}{#if narrowed.horizontal}<symbiote-horizontal-scroll-content p={contentBag}>{@render listBody()}</symbiote-horizontal-scroll-content>{:else}<symbiote-scroll-content p={contentBag}>{@render listBody()}</symbiote-scroll-content>{/if}{/snippet}
826
+ {#snippet scrollBody()}
827
+ <!--
828
+ Same as listBody() above: on iOS the non-wrapping RefreshControl and the content container are
829
+ siblings of one parent, and the gap between them is dropped before it reaches Fabric.
830
+ -->
831
+ {#if !shouldWrapRefreshControl && refreshControlProps !== undefined}
832
+ <RefreshControl {...refreshControlProps} />
833
+ {/if}
834
+ {#if narrowed.horizontal}
835
+ <symbiote-horizontal-scroll-content p={contentBag}>
836
+ {@render listBody()}
837
+ </symbiote-horizontal-scroll-content>
838
+ {:else}
839
+ <symbiote-scroll-content p={contentBag}>
840
+ {@render listBody()}
841
+ </symbiote-scroll-content>
842
+ {/if}
843
+ {/snippet}
689
844
 
690
845
  {#if shouldWrapRefreshControl && refreshControlProps !== undefined}
691
846
  <RefreshControl {...refreshControlProps} style={layoutSplit?.outer}>
692
847
  {#if narrowed.horizontal}
693
- <symbiote-horizontal-scroll-view p={outerBag} bind:this={hostShim}>{@render scrollBody()}</symbiote-horizontal-scroll-view>
848
+ <symbiote-horizontal-scroll-view p={outerBag} bind:this={hostShim}>
849
+ {@render scrollBody()}
850
+ </symbiote-horizontal-scroll-view>
694
851
  {:else}
695
- <symbiote-scroll-view p={outerBag} bind:this={hostShim}>{@render scrollBody()}</symbiote-scroll-view>
852
+ <symbiote-scroll-view p={outerBag} bind:this={hostShim}>
853
+ {@render scrollBody()}
854
+ </symbiote-scroll-view>
696
855
  {/if}
697
856
  </RefreshControl>
698
857
  {:else if narrowed.horizontal}
699
- <symbiote-horizontal-scroll-view p={outerBag} bind:this={hostShim}>{@render scrollBody()}</symbiote-horizontal-scroll-view>
858
+ <symbiote-horizontal-scroll-view p={outerBag} bind:this={hostShim}>
859
+ {@render scrollBody()}
860
+ </symbiote-horizontal-scroll-view>
700
861
  {:else}
701
- <symbiote-scroll-view p={outerBag} bind:this={hostShim}>{@render scrollBody()}</symbiote-scroll-view>
862
+ <symbiote-scroll-view p={outerBag} bind:this={hostShim}>
863
+ {@render scrollBody()}
864
+ </symbiote-scroll-view>
702
865
  {/if}
@@ -47,19 +47,23 @@ export function pickAccessibilityProps(props) {
47
47
  picked.accessibilityElementsHidden = resolved.accessibilityElementsHidden;
48
48
  }
49
49
  if (resolved.accessibilityIgnoresInvertColors !== undefined) {
50
- picked.accessibilityIgnoresInvertColors = resolved.accessibilityIgnoresInvertColors;
50
+ picked.accessibilityIgnoresInvertColors =
51
+ resolved.accessibilityIgnoresInvertColors;
51
52
  }
52
53
  if (resolved.accessibilityLanguage !== undefined) {
53
54
  picked.accessibilityLanguage = resolved.accessibilityLanguage;
54
55
  }
55
56
  if (resolved.accessibilityRespondsToUserInteraction !== undefined) {
56
- picked.accessibilityRespondsToUserInteraction = resolved.accessibilityRespondsToUserInteraction;
57
+ picked.accessibilityRespondsToUserInteraction =
58
+ resolved.accessibilityRespondsToUserInteraction;
57
59
  }
58
60
  if (resolved.accessibilityShowsLargeContentViewer !== undefined) {
59
- picked.accessibilityShowsLargeContentViewer = resolved.accessibilityShowsLargeContentViewer;
61
+ picked.accessibilityShowsLargeContentViewer =
62
+ resolved.accessibilityShowsLargeContentViewer;
60
63
  }
61
64
  if (resolved.accessibilityLargeContentTitle !== undefined) {
62
- picked.accessibilityLargeContentTitle = resolved.accessibilityLargeContentTitle;
65
+ picked.accessibilityLargeContentTitle =
66
+ resolved.accessibilityLargeContentTitle;
63
67
  }
64
68
  if (resolved.onAccessibilityAction !== undefined)
65
69
  picked.onAccessibilityAction = resolved.onAccessibilityAction;
@@ -13,7 +13,11 @@
13
13
  ISection,
14
14
  } from './virtualized-section-list-props';
15
15
 
16
- export type { IVirtualizedSectionListProps, IVirtualizedSectionListHandle, ISection };
16
+ export type {
17
+ IVirtualizedSectionListProps,
18
+ IVirtualizedSectionListHandle,
19
+ ISection,
20
+ };
17
21
  </script>
18
22
 
19
23
  <script lang="ts" generics="ItemT">
@@ -30,7 +34,10 @@
30
34
  import { Platform, dlog, type ISymbioteNode } from '@symbiote-native/engine';
31
35
  import VirtualizedList from '../virtualized-list/index.svelte';
32
36
  import { pickAccessibilityProps } from '../virtualized-list/virtualized-list-props';
33
- import type { IVirtualizedListHandle, IScrollViewHandle } from '../virtualized-list/virtualized-list-props';
37
+ import type {
38
+ IVirtualizedListHandle,
39
+ IScrollViewHandle,
40
+ } from '../virtualized-list/virtualized-list-props';
34
41
  import type { IVirtualizedSectionListProps as IProps } from './virtualized-section-list-props';
35
42
  import { pickAttachmentProps } from '../../runes/attachments';
36
43
 
@@ -50,7 +57,11 @@
50
57
 
51
58
  // RN sticks section headers by default only on iOS; Android does not unless asked.
52
59
  const stickyHeaderIndices = $derived(
53
- resolveStickySectionHeaders(props.stickySectionHeadersEnabled, headerIndices, Platform.OS),
60
+ resolveStickySectionHeaders(
61
+ props.stickySectionHeadersEnabled,
62
+ headerIndices,
63
+ Platform.OS,
64
+ ),
54
65
  );
55
66
  // DIAGNOSTIC (2026-08-13, tracking why stickyHeaderIndices comes back undefined on Android
56
67
  // despite the demo passing the bare `stickySectionHeadersEnabled` shorthand): the previous
@@ -78,11 +89,35 @@
78
89
  function getEntryCount(): number {
79
90
  return entries.length;
80
91
  }
81
- function entryKeyExtractor(entry: ISectionEntry<ItemT>, index: number): string {
92
+ function entryKeyExtractor(
93
+ entry: ISectionEntry<ItemT>,
94
+ index: number,
95
+ ): string {
82
96
  return sectionEntryKey(entry, index, props.keyExtractor);
83
97
  }
84
98
 
85
- function entrySeparatorProps(entryProps: ISeparatorProps<ISectionEntry<ItemT>>): ISeparatorProps<ItemT> {
99
+ // Hand the callback `sections`, not the entries: RN's inner VirtualizedList gets
100
+ // `data={this.props.sections}` (VirtualizedSectionList.js:216) while ours streams the FLATTENED
101
+ // entries, so the same user code would otherwise see a different argument here than on RN.
102
+ //
103
+ // UPSTREAM-DIVERGENCE(react-native): the flat INDEX matches RN's (two rows per section, header
104
+ // and footer) only while the `sectionSeparator` snippet is unset. With it, flattenSections emits
105
+ // an extra 'section-separator' row per boundary that RN renders inside the neighbouring cell, so
106
+ // indices shift by one per boundary from the second section on. Deliberate - that row is how this
107
+ // adapter paints the separator; a caller combining the two must account for it.
108
+ const entryItemLayout = $derived.by(() => {
109
+ const getItemLayout = props.getItemLayout;
110
+ if (getItemLayout === undefined) return undefined;
111
+ return (
112
+ _entries: unknown,
113
+ index: number,
114
+ ): { length: number; offset: number; index: number } =>
115
+ getItemLayout(props.sections, index);
116
+ });
117
+
118
+ function entrySeparatorProps(
119
+ entryProps: ISeparatorProps<ISectionEntry<ItemT>>,
120
+ ): ISeparatorProps<ItemT> {
86
121
  return {
87
122
  ...entryProps,
88
123
  leadingItem: unwrapEntryItem(entryProps.leadingItem),
@@ -100,9 +135,15 @@
100
135
  viewPosition?: number;
101
136
  animated?: boolean;
102
137
  }): void {
103
- const flatIndex = scrollLocationToFlatIndex(headerIndices, params.sectionIndex, params.itemIndex);
138
+ const flatIndex = scrollLocationToFlatIndex(
139
+ headerIndices,
140
+ params.sectionIndex,
141
+ params.itemIndex,
142
+ );
104
143
  if (flatIndex === undefined) {
105
- dlog(`VirtualizedSectionList scrollToLocation: section ${params.sectionIndex} out of range`);
144
+ dlog(
145
+ `VirtualizedSectionList scrollToLocation: section ${params.sectionIndex} out of range`,
146
+ );
106
147
  return;
107
148
  }
108
149
  dlog(
@@ -155,7 +196,12 @@
155
196
  {:else if entry.kind === 'section-separator'}
156
197
  {@render props.sectionSeparator?.()}
157
198
  {:else}
158
- {@render props.item({ item: entry.item, index: entry.itemIndex, section: entry.section, separators })}
199
+ {@render props.item({
200
+ item: entry.item,
201
+ index: entry.itemIndex,
202
+ section: entry.section,
203
+ separators,
204
+ })}
159
205
  {/if}
160
206
  {/snippet}
161
207
 
@@ -176,7 +222,8 @@
176
222
  footer={props.footer}
177
223
  empty={props.empty}
178
224
  keyExtractor={entryKeyExtractor}
179
- stickyHeaderIndices={stickyHeaderIndices}
225
+ getItemLayout={entryItemLayout}
226
+ {stickyHeaderIndices}
180
227
  inverted={props.inverted}
181
228
  extraData={props.extraData}
182
229
  onEndReached={props.onEndReached}
@@ -1,5 +1,5 @@
1
1
  import type { IVirtualizedSectionListProps, IVirtualizedSectionListHandle, ISection } from './virtualized-section-list-props';
2
- export type { IVirtualizedSectionListProps, IVirtualizedSectionListHandle, ISection };
2
+ export type { IVirtualizedSectionListProps, IVirtualizedSectionListHandle, ISection, };
3
3
  import { type ISymbioteNode } from '@symbiote-native/engine';
4
4
  import type { IScrollViewHandle } from '../virtualized-list/virtualized-list-props';
5
5
  import type { IVirtualizedSectionListProps as IProps } from './virtualized-section-list-props';
@@ -25,6 +25,11 @@ export interface IVirtualizedSectionListProps<ItemT> extends IAccessibilityProps
25
25
  footer?: Snippet;
26
26
  empty?: Snippet;
27
27
  keyExtractor?: (item: ItemT, index: number) => string;
28
+ getItemLayout?: (data: ReadonlyArray<ISection<ItemT>> | null, index: number) => {
29
+ length: number;
30
+ offset: number;
31
+ index: number;
32
+ };
28
33
  stickySectionHeadersEnabled?: boolean;
29
34
  extraData?: unknown;
30
35
  onEndReached?: (info: {