@symbiote-native/angular 0.7.0 → 0.8.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 (158) hide show
  1. package/README.md +3 -3
  2. package/build/angular/bootstrap.js +1 -1
  3. package/build/angular/bootstrap.js.map +1 -1
  4. package/build/angular/components/activity-indicator/index.android.js +7 -2
  5. package/build/angular/components/activity-indicator/index.android.js.map +1 -1
  6. package/build/angular/components/activity-indicator/index.ios.js +3 -1
  7. package/build/angular/components/activity-indicator/index.ios.js.map +1 -1
  8. package/build/angular/components/activity-indicator/shared.d.ts +1 -1
  9. package/build/angular/components/activity-indicator/shared.js +1 -1
  10. package/build/angular/components/activity-indicator/shared.js.map +1 -1
  11. package/build/angular/components/button.js +13 -5
  12. package/build/angular/components/button.js.map +1 -1
  13. package/build/angular/components/flat-list/index.d.ts +1 -1
  14. package/build/angular/components/flat-list/index.js +47 -14
  15. package/build/angular/components/flat-list/index.js.map +1 -1
  16. package/build/angular/components/image/index.android.js +4 -2
  17. package/build/angular/components/image/index.android.js.map +1 -1
  18. package/build/angular/components/image/index.ios.js +4 -2
  19. package/build/angular/components/image/index.ios.js.map +1 -1
  20. package/build/angular/components/image/shared.js +18 -6
  21. package/build/angular/components/image/shared.js.map +1 -1
  22. package/build/angular/components/image-background.js +22 -8
  23. package/build/angular/components/image-background.js.map +1 -1
  24. package/build/angular/components/input-accessory-view/index.js +7 -2
  25. package/build/angular/components/input-accessory-view/index.js.map +1 -1
  26. package/build/angular/components/keyboard-avoiding-view/index.d.ts +1 -0
  27. package/build/angular/components/keyboard-avoiding-view/index.js +39 -9
  28. package/build/angular/components/keyboard-avoiding-view/index.js.map +1 -1
  29. package/build/angular/components/modal/index.js +7 -2
  30. package/build/angular/components/modal/index.js.map +1 -1
  31. package/build/angular/components/pressable/index.d.ts +1 -1
  32. package/build/angular/components/pressable/index.js +6 -2
  33. package/build/angular/components/pressable/index.js.map +1 -1
  34. package/build/angular/components/refresh-control/index.js +3 -1
  35. package/build/angular/components/refresh-control/index.js.map +1 -1
  36. package/build/angular/components/safe-area-view/index.js +3 -1
  37. package/build/angular/components/safe-area-view/index.js.map +1 -1
  38. package/build/angular/components/scroll-view/index.android.js +44 -14
  39. package/build/angular/components/scroll-view/index.android.js.map +1 -1
  40. package/build/angular/components/scroll-view/index.ios.js +13 -5
  41. package/build/angular/components/scroll-view/index.ios.js.map +1 -1
  42. package/build/angular/components/scroll-view/projection.d.ts +32 -0
  43. package/build/angular/components/scroll-view/projection.js +212 -47
  44. package/build/angular/components/scroll-view/projection.js.map +1 -1
  45. package/build/angular/components/scroll-view/shared.js +28 -7
  46. package/build/angular/components/scroll-view/shared.js.map +1 -1
  47. package/build/angular/components/scroll-view/sticky-header.js +7 -3
  48. package/build/angular/components/scroll-view/sticky-header.js.map +1 -1
  49. package/build/angular/components/section-list/index.d.ts +6 -1
  50. package/build/angular/components/section-list/index.js +63 -18
  51. package/build/angular/components/section-list/index.js.map +1 -1
  52. package/build/angular/components/switch/index.android.js +17 -3
  53. package/build/angular/components/switch/index.android.js.map +1 -1
  54. package/build/angular/components/switch/index.ios.js +17 -3
  55. package/build/angular/components/switch/index.ios.js.map +1 -1
  56. package/build/angular/components/switch/shared.d.ts +1 -1
  57. package/build/angular/components/switch/shared.js +5 -2
  58. package/build/angular/components/switch/shared.js.map +1 -1
  59. package/build/angular/components/text-input.d.ts +1 -1
  60. package/build/angular/components/text-input.js +25 -5
  61. package/build/angular/components/text-input.js.map +1 -1
  62. package/build/angular/components/touchable/index.d.ts +29 -7
  63. package/build/angular/components/touchable/index.js +246 -69
  64. package/build/angular/components/touchable/index.js.map +1 -1
  65. package/build/angular/components/touchable-native-feedback/index.js +2 -2
  66. package/build/angular/components/touchable-native-feedback/index.js.map +1 -1
  67. package/build/angular/components/virtualized-list/directives.js +4 -0
  68. package/build/angular/components/virtualized-list/directives.js.map +1 -1
  69. package/build/angular/components/virtualized-list/index.d.ts +5 -2
  70. package/build/angular/components/virtualized-list/index.js +197 -60
  71. package/build/angular/components/virtualized-list/index.js.map +1 -1
  72. package/build/angular/components/virtualized-section-list/directives.js.map +1 -1
  73. package/build/angular/components/virtualized-section-list/index.d.ts +18 -1
  74. package/build/angular/components/virtualized-section-list/index.js +90 -21
  75. package/build/angular/components/virtualized-section-list/index.js.map +1 -1
  76. package/build/angular/components.d.ts +1 -1
  77. package/build/angular/components.js.map +1 -1
  78. package/build/angular/create-portal/index.js +5 -1
  79. package/build/angular/create-portal/index.js.map +1 -1
  80. package/build/angular/create-tunnel/index.js.map +1 -1
  81. package/build/angular/descriptor-to-angular/index.js +3 -1
  82. package/build/angular/descriptor-to-angular/index.js.map +1 -1
  83. package/build/angular/diagnostics.d.ts +33 -0
  84. package/build/angular/diagnostics.js +82 -0
  85. package/build/angular/diagnostics.js.map +1 -0
  86. package/build/angular/host-instance/index.js +1 -1
  87. package/build/angular/host-instance/index.js.map +1 -1
  88. package/build/angular/index.d.ts +5 -2
  89. package/build/angular/index.js +15 -1
  90. package/build/angular/index.js.map +1 -1
  91. package/build/angular/modules/animated/create-animated-component.js +27 -9
  92. package/build/angular/modules/animated/create-animated-component.js.map +1 -1
  93. package/build/angular/modules/app-registry/index.js.map +1 -1
  94. package/build/angular/modules/status-bar/index.js +1 -1
  95. package/build/angular/modules/status-bar/index.js.map +1 -1
  96. package/build/angular/primitives/index.d.ts +19 -2
  97. package/build/angular/primitives/index.js +39 -3
  98. package/build/angular/primitives/index.js.map +1 -1
  99. package/build/angular/primitives/shared.d.ts +9 -1
  100. package/build/angular/primitives/shared.js +49 -3
  101. package/build/angular/primitives/shared.js.map +1 -1
  102. package/build/angular/render/index.js +48 -5
  103. package/build/angular/render/index.js.map +1 -1
  104. package/build/angular/renderer/index.d.ts +1 -0
  105. package/build/angular/renderer/index.js +37 -2
  106. package/build/angular/renderer/index.js.map +1 -1
  107. package/build/angular/services/color-scheme.service.js.map +1 -1
  108. package/build/angular/services/window-dimensions.service.js +1 -1
  109. package/build/angular/services/window-dimensions.service.js.map +1 -1
  110. package/package.json +5 -5
  111. package/src/bootstrap.ts +4 -1
  112. package/src/components/activity-indicator/index.android.ts +7 -2
  113. package/src/components/activity-indicator/index.ios.ts +3 -1
  114. package/src/components/activity-indicator/shared.ts +32 -13
  115. package/src/components/button.ts +19 -6
  116. package/src/components/flat-list/index.ts +91 -29
  117. package/src/components/image/index.android.ts +9 -2
  118. package/src/components/image/index.ios.ts +9 -2
  119. package/src/components/image/shared.ts +58 -19
  120. package/src/components/image-background.ts +29 -10
  121. package/src/components/input-accessory-view/index.ts +27 -10
  122. package/src/components/keyboard-avoiding-view/index.ts +55 -15
  123. package/src/components/modal/index.ts +22 -8
  124. package/src/components/pressable/index.ts +51 -21
  125. package/src/components/refresh-control/index.ts +22 -8
  126. package/src/components/safe-area-view/index.ts +16 -7
  127. package/src/components/scroll-view/index.android.ts +31 -9
  128. package/src/components/scroll-view/index.ios.ts +17 -4
  129. package/src/components/scroll-view/projection.ts +290 -63
  130. package/src/components/scroll-view/shared.ts +93 -26
  131. package/src/components/scroll-view/sticky-header.ts +25 -8
  132. package/src/components/section-list/index.ts +61 -19
  133. package/src/components/switch/index.android.ts +14 -3
  134. package/src/components/switch/index.ios.ts +14 -3
  135. package/src/components/switch/shared.ts +15 -4
  136. package/src/components/text-input.ts +45 -13
  137. package/src/components/touchable/index.ts +296 -72
  138. package/src/components/touchable-native-feedback/index.ts +32 -10
  139. package/src/components/virtualized-list/directives.ts +19 -5
  140. package/src/components/virtualized-list/index.ts +274 -72
  141. package/src/components/virtualized-section-list/directives.ts +6 -3
  142. package/src/components/virtualized-section-list/index.ts +146 -34
  143. package/src/components.ts +4 -1
  144. package/src/create-portal/index.ts +8 -2
  145. package/src/create-tunnel/index.ts +3 -1
  146. package/src/descriptor-to-angular/index.ts +29 -8
  147. package/src/diagnostics.ts +138 -0
  148. package/src/host-instance/index.ts +9 -3
  149. package/src/index.ts +31 -2
  150. package/src/modules/animated/create-animated-component.ts +44 -15
  151. package/src/modules/app-registry/index.ts +3 -1
  152. package/src/modules/status-bar/index.ts +13 -4
  153. package/src/primitives/index.ts +46 -2
  154. package/src/primitives/shared.ts +70 -9
  155. package/src/render/index.ts +56 -6
  156. package/src/renderer/index.ts +76 -14
  157. package/src/services/color-scheme.service.ts +3 -1
  158. package/src/services/window-dimensions.service.ts +11 -4
@@ -56,10 +56,12 @@ import {
56
56
  INVERTED_Y_STYLE,
57
57
  buildListPlan,
58
58
  buildViewabilityPairs,
59
+ computeWindow,
59
60
  createInitialListState,
60
61
  isSeparatorGapInRange,
61
62
  listEffectSignature,
62
63
  readLayoutLength,
64
+ readLayoutOffset,
63
65
  readScrollOffset,
64
66
  reduceList,
65
67
  resolveAccessibilityProps,
@@ -87,9 +89,14 @@ import {
87
89
  type ISymbioteNode,
88
90
  type IViewStyle,
89
91
  } from '@symbiote-native/engine';
92
+ import { countAngular } from '../../diagnostics';
90
93
  import { ScrollView } from '../scroll-view';
91
94
  import { RefreshControl } from '../refresh-control';
92
- import { anchorHostStyle, SymbioteStyleInputDirective, ViewHost } from '../../primitives';
95
+ import {
96
+ anchorHostStyle,
97
+ SymbioteStyleInputDirective,
98
+ ViewHost,
99
+ } from '../../primitives';
93
100
  import {
94
101
  VListEmptyDirective,
95
102
  VListFooterDirective,
@@ -126,7 +133,8 @@ export type { IVListItemContext, IVListSeparatorContext } from './directives';
126
133
  // element-returning props (renderItem, ListHeader/Footer/Empty Component, ItemSeparatorComponent):
127
134
  // those are the per-adapter children/render fields and become `<ng-template>` directives in Angular.
128
135
  // Everything agnostic is the SAME surface.
129
- export interface IVirtualizedListProps<ItemT> extends IAccessibilityProps, IAriaProps {
136
+ export interface IVirtualizedListProps<ItemT>
137
+ extends IAccessibilityProps, IAriaProps {
130
138
  data: unknown;
131
139
  getItem: (data: unknown, index: number) => ItemT;
132
140
  getItemCount: (data: unknown) => number;
@@ -205,7 +213,9 @@ interface IWindowCell<ItemT> {
205
213
  @Component({
206
214
  selector: 'VirtualizedList',
207
215
  standalone: true,
208
- hostDirectives: [{ directive: SymbioteStyleInputDirective, inputs: ['style'] }],
216
+ hostDirectives: [
217
+ { directive: SymbioteStyleInputDirective, inputs: ['style'] },
218
+ ],
209
219
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
210
220
  imports: [ScrollView, RefreshControl, VListOutletDirective, ViewHost],
211
221
  changeDetection: ChangeDetectionStrategy.OnPush,
@@ -224,12 +234,20 @@ interface IWindowCell<ItemT> {
224
234
  [accessibilityValue]="foldedAccessibility().accessibilityValue"
225
235
  [accessibilityActions]="foldedAccessibility().accessibilityActions"
226
236
  [accessibilityLabelledBy]="foldedAccessibility().accessibilityLabelledBy"
227
- [importantForAccessibility]="foldedAccessibility().importantForAccessibility"
237
+ [importantForAccessibility]="
238
+ foldedAccessibility().importantForAccessibility
239
+ "
228
240
  [accessibilityLiveRegion]="foldedAccessibility().accessibilityLiveRegion"
229
241
  [screenReaderFocusable]="foldedAccessibility().screenReaderFocusable"
230
- [accessibilityViewIsModal]="foldedAccessibility().accessibilityViewIsModal"
231
- [accessibilityElementsHidden]="foldedAccessibility().accessibilityElementsHidden"
232
- [accessibilityIgnoresInvertColors]="foldedAccessibility().accessibilityIgnoresInvertColors"
242
+ [accessibilityViewIsModal]="
243
+ foldedAccessibility().accessibilityViewIsModal
244
+ "
245
+ [accessibilityElementsHidden]="
246
+ foldedAccessibility().accessibilityElementsHidden
247
+ "
248
+ [accessibilityIgnoresInvertColors]="
249
+ foldedAccessibility().accessibilityIgnoresInvertColors
250
+ "
233
251
  [accessibilityLanguage]="foldedAccessibility().accessibilityLanguage"
234
252
  [accessibilityRespondsToUserInteraction]="
235
253
  foldedAccessibility().accessibilityRespondsToUserInteraction
@@ -237,7 +255,9 @@ interface IWindowCell<ItemT> {
237
255
  [accessibilityShowsLargeContentViewer]="
238
256
  foldedAccessibility().accessibilityShowsLargeContentViewer
239
257
  "
240
- [accessibilityLargeContentTitle]="foldedAccessibility().accessibilityLargeContentTitle"
258
+ [accessibilityLargeContentTitle]="
259
+ foldedAccessibility().accessibilityLargeContentTitle
260
+ "
241
261
  (accessibilityAction)="accessibilityActionTick($event)"
242
262
  (accessibilityTap)="accessibilityTapTick($event)"
243
263
  (magicTap)="magicTapTick($event)"
@@ -293,21 +313,29 @@ interface IWindowCell<ItemT> {
293
313
  @if (gapSpacerStyle !== null) {
294
314
  <symbiote-view [style]="gapSpacerStyle"></symbiote-view>
295
315
  }
316
+ <!-- The separator sits INSIDE the measuring view, as RN's own cell renderer places it
317
+ (VirtualizedListCellRenderer.js:218-221). As a sibling it is an extra flex child, so
318
+ the chrome between two cells is gap + separator + gap while a spacer collapsing that
319
+ region replaces it with a single gap — every cell below the leading spacer then lands
320
+ short by (separator + gap) and the content jumps by that much whenever the window's
321
+ first index moves. Measured at exactly 17px on device 2026-08-19; see
322
+ .claude/rules/list-geometry-feedback-loop.md. -->
296
323
  @for (cell of windowCells; track cell.key) {
297
- <symbiote-view (layout)="handleCellLayout(cell.measure, $event)" [style]="cellStyle">
324
+ <symbiote-view
325
+ (layout)="handleCellLayout(cell.measure, $event)"
326
+ [style]="cellStyle"
327
+ >
298
328
  <ng-container
299
329
  [vListOutlet]="itemDir?.templateRef"
300
330
  [vListOutletContext]="cell.context"
301
331
  ></ng-container>
302
- </symbiote-view>
303
- @if (cell.separatorContext !== undefined) {
304
- <symbiote-view>
332
+ @if (cell.separatorContext !== undefined) {
305
333
  <ng-container
306
334
  [vListOutlet]="separatorDir?.templateRef"
307
335
  [vListOutletContext]="cell.separatorContext"
308
336
  ></ng-container>
309
- </symbiote-view>
310
- }
337
+ }
338
+ </symbiote-view>
311
339
  }
312
340
  @if (trailingSpacerStyle !== null) {
313
341
  <symbiote-view [style]="trailingSpacerStyle"></symbiote-view>
@@ -334,10 +362,16 @@ export class VirtualizedList<ItemT = unknown>
334
362
  // The list's edge/viewability/failure events as real Angular events: `(endReached)="…"`, not
335
363
  // `[onEndReached]="…"`. See handleRefresh/accessibility*Tick below for how the still-callback-
336
364
  // shaped ScrollView/RefreshControl @Input()s are fed from these.
337
- @Output() readonly endReached = new EventEmitter<{ distanceFromEnd: number }>();
338
- @Output() readonly startReached = new EventEmitter<{ distanceFromStart: number }>();
365
+ @Output() readonly endReached = new EventEmitter<{
366
+ distanceFromEnd: number;
367
+ }>();
368
+ @Output() readonly startReached = new EventEmitter<{
369
+ distanceFromStart: number;
370
+ }>();
339
371
  @Output() readonly refresh = new EventEmitter<void>();
340
- @Output() readonly viewableItemsChanged = new EventEmitter<IViewableItemsChangedInfo<ItemT>>();
372
+ @Output() readonly viewableItemsChanged = new EventEmitter<
373
+ IViewableItemsChangedInfo<ItemT>
374
+ >();
341
375
  @Output() readonly scrollToIndexFailed = new EventEmitter<{
342
376
  index: number;
343
377
  highestMeasuredFrameIndex: number;
@@ -371,7 +405,8 @@ export class VirtualizedList<ItemT = unknown>
371
405
  // (no wrapper) falls back to this component's own `.observed`, unchanged from before.
372
406
  @Input() refreshRequested?: boolean;
373
407
  @Input() viewabilityConfig?: IViewabilityConfig;
374
- @Input() viewabilityConfigCallbackPairs?: IViewabilityConfigCallbackPair<ItemT>[];
408
+ @Input()
409
+ viewabilityConfigCallbackPairs?: IViewabilityConfigCallbackPair<ItemT>[];
375
410
  @Input() initialNumToRender?: number;
376
411
  @Input() initialScrollIndex?: number;
377
412
  @Input() maxToRenderPerBatch?: number;
@@ -402,8 +437,10 @@ export class VirtualizedList<ItemT = unknown>
402
437
  @Input() accessibilityValue?: IAccessibilityProps['accessibilityValue'];
403
438
  @Input() accessibilityActions?: IAccessibilityProps['accessibilityActions'];
404
439
  @Input() accessibilityLabelledBy?: string | string[];
405
- @Input() importantForAccessibility?: IAccessibilityProps['importantForAccessibility'];
406
- @Input() accessibilityLiveRegion?: IAccessibilityProps['accessibilityLiveRegion'];
440
+ @Input()
441
+ importantForAccessibility?: IAccessibilityProps['importantForAccessibility'];
442
+ @Input()
443
+ accessibilityLiveRegion?: IAccessibilityProps['accessibilityLiveRegion'];
407
444
  @Input() screenReaderFocusable?: boolean;
408
445
  @Input() accessibilityViewIsModal?: boolean;
409
446
  @Input() accessibilityElementsHidden?: boolean;
@@ -435,7 +472,8 @@ export class VirtualizedList<ItemT = unknown>
435
472
  @ContentChild(VListHeaderDirective) headerDir?: VListHeaderDirective;
436
473
  @ContentChild(VListFooterDirective) footerDir?: VListFooterDirective;
437
474
  @ContentChild(VListEmptyDirective) emptyDir?: VListEmptyDirective;
438
- @ContentChild(VListSeparatorDirective) separatorDir?: VListSeparatorDirective<ItemT>;
475
+ @ContentChild(VListSeparatorDirective)
476
+ separatorDir?: VListSeparatorDirective<ItemT>;
439
477
 
440
478
  // The composed inner scroll view. Its instance IS an IScrollViewHandle (scrollTo / scrollToEnd /
441
479
  // flashScrollIndicators / getScrollNode), so the imperative handle delegates straight to it.
@@ -462,7 +500,8 @@ export class VirtualizedList<ItemT = unknown>
462
500
  resolvedStyle: IViewStyle | undefined = undefined;
463
501
  resolvedContentContainerStyle: IStyleProp<IViewStyle> | undefined = undefined;
464
502
  resolvedMaintainVisibleContentPosition:
465
- { minIndexForVisible: number; autoscrollToTopThreshold?: number } | undefined = undefined;
503
+ | { minIndexForVisible: number; autoscrollToTopThreshold?: number }
504
+ | undefined = undefined;
466
505
  // The offset we are imperatively driving native to before the scroll handle attaches (rides down
467
506
  // as contentOffset). A fresh object identity each push so the commit re-applies a repeated value.
468
507
  commandedOffset: { x: number; y: number } | undefined = undefined;
@@ -471,20 +510,41 @@ export class VirtualizedList<ItemT = unknown>
471
510
  // measured lengths, committed window, edge/viewability dedup, MVCP anchor — all in IListState,
472
511
  // driven by the shared reduceList. `renderVersion` bumps whenever a transition changes render
473
512
  // state, so ngDoCheck's recompute-dedup can detect it (listState is a plain object, not tracked).
474
- private readonly listState: IListState<ItemT> = createInitialListState<ItemT>();
513
+ private readonly listState: IListState<ItemT> =
514
+ createInitialListState<ItemT>();
475
515
  private renderVersion = EMPTY_OFFSET;
476
516
 
477
- private readonly cellMeasures = new Map<number, (event: ISymbioteEvent) => void>();
478
- private readonly separatorOverrides = new Map<number, Partial<ISeparatorProps<unknown>>>();
517
+ private readonly cellMeasures = new Map<
518
+ number,
519
+ (event: ISymbioteEvent) => void
520
+ >();
521
+ // A cell's context object is the ONLY thing its stamped view reads, and VListOutletDirective
522
+ // refreshes that view whenever the object's IDENTITY changes. recomputeView runs on every frame
523
+ // the window moves, so minting a fresh context per cell made a four-row slide cost a refresh of
524
+ // EVERY cell in the window. MEASURED on PATH B geometry (544 entries, 320px viewport, RN
525
+ // defaults), per fling frame: 233-cell window, 4 cells genuinely entering, 228 outlet updates —
526
+ // each one a copyContextFields plus a markForCheck that walks to the root. Solid, same geometry,
527
+ // same shared reducer, same engine output: 4. Handing back the SAME object for a cell whose item
528
+ // and index did not move keeps `[vListOutletContext]` reference-equal, so ngOnChanges never fires
529
+ // for it. The fast adapters get this by construction — Solid's <For> is keyed on the cell KEY
530
+ // strings precisely so "the plan's freshly-built cell objects" cannot rebuild every row
531
+ // (adapters/solid/src/components/virtualized-list/shared.tsx, the cellKeys memo).
532
+ private cellContexts = new Map<string, IVListItemContext<ItemT>>();
533
+ private nextCellContexts = new Map<string, IVListItemContext<ItemT>>();
534
+ // Everything a cell's CONTENT can depend on that is not its own item identity. RN's contract for
535
+ // "re-render the cells, the data object itself did not change" is extraData; without it here an
536
+ // extraData flip would reuse every context and repaint nothing.
537
+ private cellContextEpoch: unknown[] | undefined = undefined;
538
+ private readonly separatorOverrides = new Map<
539
+ number,
540
+ Partial<ISeparatorProps<unknown>>
541
+ >();
479
542
  // The adapter-owned debounce (minimumViewTime) and incremental-fill timers; the reducer only
480
543
  // hands back a delay.
481
544
  private viewableTimer: ReturnType<typeof setTimeout> | null = null;
482
545
  private batchTimer: ReturnType<typeof setTimeout> | null = null;
483
546
  // Dedupes the after-commit effects: they run only when the windowing signature changed.
484
547
  private lastEffectSignature = '';
485
- // Dedupes dispatch's markForCheck against the RENDERED window (see dispatch below). Distinct from
486
- // lastEffectSignature, which gates the batch-fill timer and deliberately includes scrollOffset.
487
- private lastRenderSignature = '';
488
548
  // Dedupes ngDoCheck's own recompute. WITHOUT this, recomputeView() unconditionally rebuilds
489
549
  // windowCells (and each cell's `separators` handle — fresh closures every call) on EVERY CD pass,
490
550
  // including ones triggered by something else entirely in the app. A fresh context object flows
@@ -543,8 +603,10 @@ export class VirtualizedList<ItemT = unknown>
543
603
  accessibilityElementsHidden: this.accessibilityElementsHidden,
544
604
  accessibilityIgnoresInvertColors: this.accessibilityIgnoresInvertColors,
545
605
  accessibilityLanguage: this.accessibilityLanguage,
546
- accessibilityRespondsToUserInteraction: this.accessibilityRespondsToUserInteraction,
547
- accessibilityShowsLargeContentViewer: this.accessibilityShowsLargeContentViewer,
606
+ accessibilityRespondsToUserInteraction:
607
+ this.accessibilityRespondsToUserInteraction,
608
+ accessibilityShowsLargeContentViewer:
609
+ this.accessibilityShowsLargeContentViewer,
548
610
  accessibilityLargeContentTitle: this.accessibilityLargeContentTitle,
549
611
  role: this.role,
550
612
  'aria-label': this.ariaLabel,
@@ -584,7 +646,9 @@ export class VirtualizedList<ItemT = unknown>
584
646
  return this.maxToRenderPerBatch ?? DEFAULT_MAX_TO_RENDER_PER_BATCH;
585
647
  }
586
648
  private get updateCellsBatchingPeriodValue(): number {
587
- return this.updateCellsBatchingPeriod ?? DEFAULT_UPDATE_CELLS_BATCHING_PERIOD;
649
+ return (
650
+ this.updateCellsBatchingPeriod ?? DEFAULT_UPDATE_CELLS_BATCHING_PERIOD
651
+ );
588
652
  }
589
653
  private get onEndReachedThresholdValue(): number {
590
654
  return this.onEndReachedThreshold ?? DEFAULT_END_REACHED_THRESHOLD;
@@ -597,6 +661,7 @@ export class VirtualizedList<ItemT = unknown>
597
661
  // flow through it, which an @Output() can't carry), so this is a stable arrow field passed
598
662
  // straight to [onScroll]. onLayout is a real @Output() now, bound via (layout)="onLayoutTick($event)".
599
663
  onScrollTick = (event: ISymbioteEvent): void => {
664
+ countAngular('scrollTicks');
600
665
  const offset = readScrollOffset(event, this.isHorizontal);
601
666
  if (offset === undefined) return;
602
667
  dlog(`Angular VirtualizedList onScroll offset=${offset}`);
@@ -663,7 +728,8 @@ export class VirtualizedList<ItemT = unknown>
663
728
  onStartReachedActive: this.startReached.observed,
664
729
  viewabilityPairs: buildViewabilityPairs(
665
730
  this.viewableItemsChanged.observed
666
- ? (info: IViewableItemsChangedInfo<ItemT>): void => this.viewableItemsChanged.emit(info)
731
+ ? (info: IViewableItemsChangedInfo<ItemT>): void =>
732
+ this.viewableItemsChanged.emit(info)
667
733
  : undefined,
668
734
  this.viewabilityConfig,
669
735
  this.viewabilityConfigCallbackPairs,
@@ -681,23 +747,58 @@ export class VirtualizedList<ItemT = unknown>
681
747
  this.runEffects(result.effects, inputs);
682
748
  if (!result.changed) return;
683
749
  this.renderVersion += 1;
684
- // The reducer reports `changed` for EVERY scroll offset - it has to, since the offset feeds
685
- // end-reached distance, viewability and the batch-fill timer, all handled above in runEffects.
686
- // The TEMPLATE reads none of those; it reads the window. Marking on the offset repainted the
687
- // whole ancestor chain 60 times a second on a sticky screen for a window that had not moved.
688
- const signature = this.renderSignature();
689
- if (signature === this.lastRenderSignature) return;
690
- this.lastRenderSignature = signature;
750
+ if (this.isWindowSettled(action)) return;
751
+ countAngular('listMarks');
691
752
  this.cdr.markForCheck();
692
753
  }
693
754
 
694
- // The window, not the offset: exactly the metrics recomputeView turns into cells and spacers.
695
- private renderSignature(): string {
755
+ // The reducer reports `changed` for EVERY scroll offset - it has to, since the offset feeds
756
+ // end-reached distance, viewability and the batch-fill timer, all handled by runEffects. The
757
+ // TEMPLATE reads none of those; it reads the window. Marking on the offset repainted the whole
758
+ // ancestor chain 60 times a second on a sticky screen for a window that had not moved.
759
+ //
760
+ // So the question here is "would a render leave the window exactly where it is", and it is asked
761
+ // of the state as it is NOW. It must NOT be answered by comparing the last-rendered window with
762
+ // the one before it: that window was derived from the PREVIOUS scroll offset, so once two
763
+ // consecutive derives agree the comparison reads "settled" for every offset that ever follows -
764
+ // no mark, no CD, no ngDoCheck, no refresh-metrics, so the window can never move again and the
765
+ // list goes permanently deaf mid-scroll (device: examples/angular Benchmark sticky path B froze
766
+ // at exactly the window the first viewport asked for, blank below it).
767
+ //
768
+ // computeWindow re-run over the CACHED offset table answers it directly. Two scans, no
769
+ // allocation - buildOffsets, the O(count) allocating half, still runs once per render only. It is
770
+ // the same function the render will use, so this cannot drift away from the window it predicts.
771
+ private isWindowSettled(action: IListAction<ItemT>): boolean {
772
+ // A measurement rewrites the very offset table the prediction reads, so it has no cached
773
+ // geometry to answer from.
774
+ if (action.kind === 'measure') return false;
696
775
  const m = this.listState.metrics;
697
- return `${m.first}|${m.last}|${m.count}|${m.total}`;
776
+ // Mid-fill: the committed window is still climbing toward target one batch step per render, and
777
+ // only a render advances it. Skipping the mark here is what stalls the incremental fill.
778
+ if (m.first !== m.target.first || m.last !== m.target.last) return false;
779
+ const next = computeWindow(
780
+ m.count,
781
+ m.offsets,
782
+ m.lengths,
783
+ this.listState.scrollOffset,
784
+ this.listState.viewportLength,
785
+ this.windowSizeValue,
786
+ this.initialNumToRenderValue,
787
+ );
788
+ const isSettled =
789
+ next.first === m.target.first && next.last === m.target.last;
790
+ if (!isSettled)
791
+ dlog(
792
+ `Angular VirtualizedList window moves [${m.first}, ${m.last}] -> ` +
793
+ `[${next.first}, ${next.last}] at offset ${this.listState.scrollOffset}`,
794
+ );
795
+ return isSettled;
698
796
  }
699
797
 
700
- private runEffects(effects: IListEffect<ItemT>[], inputs: IListReducerInputs<ItemT>): void {
798
+ private runEffects(
799
+ effects: IListEffect<ItemT>[],
800
+ inputs: IListReducerInputs<ItemT>,
801
+ ): void {
701
802
  for (const effect of effects) {
702
803
  switch (effect.kind) {
703
804
  case 'scroll-to':
@@ -707,7 +808,9 @@ export class VirtualizedList<ItemT = unknown>
707
808
  this.endReached.emit({ distanceFromEnd: effect.distanceFromEnd });
708
809
  break;
709
810
  case 'fire-start-reached':
710
- this.startReached.emit({ distanceFromStart: effect.distanceFromStart });
811
+ this.startReached.emit({
812
+ distanceFromStart: effect.distanceFromStart,
813
+ });
711
814
  break;
712
815
  case 'fire-scroll-to-index-failed':
713
816
  this.scrollToIndexFailed.emit({
@@ -765,6 +868,7 @@ export class VirtualizedList<ItemT = unknown>
765
868
  // grows exactly one step per meaningful CD. The projected templates (ContentChild) are resolved
766
869
  // from the second CD on; the first paint (count/viewport still 0) corrects on the layout-driven CD.
767
870
  ngDoCheck(): void {
871
+ countAngular('listChecks');
768
872
  const recomputeInputs: unknown[] = [
769
873
  this.data,
770
874
  this.extraData,
@@ -800,6 +904,7 @@ export class VirtualizedList<ItemT = unknown>
800
904
  return;
801
905
  }
802
906
  // The single derive-per-CD: recompute the window off the current state before the view reads it.
907
+ countAngular('listRecomputes');
803
908
  reduceList(this.listState, { kind: 'refresh-metrics' }, this.buildInputs());
804
909
  this.recomputeView();
805
910
  }
@@ -824,7 +929,9 @@ export class VirtualizedList<ItemT = unknown>
824
929
  const hasHeader = this.headerDir !== undefined;
825
930
  const hasSeparators = this.separatorDir !== undefined;
826
931
  const stickySet =
827
- this.stickyHeaderIndices !== undefined ? new Set(this.stickyHeaderIndices) : undefined;
932
+ this.stickyHeaderIndices !== undefined
933
+ ? new Set(this.stickyHeaderIndices)
934
+ : undefined;
828
935
 
829
936
  this.resolvedContentContainerStyle = this.isHorizontal
830
937
  ? [this.contentContainerStyle, { width: m.total }]
@@ -846,17 +953,40 @@ export class VirtualizedList<ItemT = unknown>
846
953
  : {
847
954
  ...this.maintainVisibleContentPosition,
848
955
  minIndexForVisible:
849
- this.maintainVisibleContentPosition.minIndexForVisible + (hasHeader ? 1 : 0),
956
+ this.maintainVisibleContentPosition.minIndexForVisible +
957
+ (hasHeader ? 1 : 0),
850
958
  };
851
959
 
960
+ // A change in anything a cell's content reads other than its own item invalidates every cached
961
+ // context: identity reuse is only sound while the reused object still describes the cell.
962
+ const epoch: unknown[] = [
963
+ this.data,
964
+ this.extraData,
965
+ this.getItem,
966
+ this.keyExtractor,
967
+ ];
968
+ const previousEpoch = this.cellContextEpoch;
969
+ this.cellContextEpoch = epoch;
970
+ if (
971
+ previousEpoch === undefined ||
972
+ previousEpoch.length !== epoch.length ||
973
+ !previousEpoch.every((value, index) => value === epoch[index])
974
+ ) {
975
+ this.cellContexts.clear();
976
+ }
977
+
852
978
  if (m.count === FIRST_INDEX) {
979
+ this.cellContexts.clear();
980
+ this.nextCellContexts.clear();
853
981
  this.windowCells = [];
854
982
  this.forcedStickyCell = null;
855
983
  this.leadingSpacerStyle = null;
856
984
  this.gapSpacerStyle = null;
857
985
  this.trailingSpacerStyle = null;
858
986
  this.renderedStickyIndices = undefined;
859
- dlog(`Angular VirtualizedList empty (viewport=${this.listState.viewportLength})`);
987
+ dlog(
988
+ `Angular VirtualizedList empty (viewport=${this.listState.viewportLength})`,
989
+ );
860
990
  return;
861
991
  }
862
992
 
@@ -870,13 +1000,17 @@ export class VirtualizedList<ItemT = unknown>
870
1000
  keyFor: this.keyFor,
871
1001
  stickyIndices: stickySet,
872
1002
  hasHeader,
873
- hasSeparators,
874
1003
  });
875
1004
  this.leadingSpacerStyle =
876
- plan.leadingExtent > EMPTY_OFFSET ? this.spacerStyle(plan.leadingExtent) : null;
877
- this.gapSpacerStyle = plan.gapExtent > EMPTY_OFFSET ? this.spacerStyle(plan.gapExtent) : null;
1005
+ plan.leadingExtent > EMPTY_OFFSET
1006
+ ? this.spacerStyle(plan.leadingExtent)
1007
+ : null;
1008
+ this.gapSpacerStyle =
1009
+ plan.gapExtent > EMPTY_OFFSET ? this.spacerStyle(plan.gapExtent) : null;
878
1010
  this.trailingSpacerStyle =
879
- plan.trailingExtent > EMPTY_OFFSET ? this.spacerStyle(plan.trailingExtent) : null;
1011
+ plan.trailingExtent > EMPTY_OFFSET
1012
+ ? this.spacerStyle(plan.trailingExtent)
1013
+ : null;
880
1014
  this.renderedStickyIndices =
881
1015
  stickySet !== undefined && plan.stickyChildPositions.length > 0
882
1016
  ? plan.stickyChildPositions
@@ -890,15 +1024,30 @@ export class VirtualizedList<ItemT = unknown>
890
1024
 
891
1025
  this.forcedStickyCell =
892
1026
  plan.forcedStickyCell !== undefined
893
- ? this.buildWindowCell(plan.forcedStickyCell.index, plan.forcedStickyCell.key, false)
1027
+ ? this.buildWindowCell(
1028
+ plan.forcedStickyCell.index,
1029
+ plan.forcedStickyCell.key,
1030
+ false,
1031
+ )
894
1032
  : null;
895
1033
 
896
1034
  const cells: IWindowCell<ItemT>[] = [];
897
1035
  for (const planned of plan.cells) {
898
- const includeSeparator = hasSeparators && planned.index < m.last;
899
- cells.push(this.buildWindowCell(planned.index, planned.key, includeSeparator));
1036
+ // RN gates the separator on the last index of the DATA, not of the WINDOW
1037
+ // (VirtualizedList.js:793 `const end = getItemCount(data) - 1`), and now that the separator
1038
+ // lives INSIDE the measuring wrapper that distinction is load-bearing: gating on the window
1039
+ // would make a cell's own measured height change as the window slides past it. Device-measured
1040
+ // 2026-08-19 as a run of cells all shifting by exactly the divider's 1px.
1041
+ const includeSeparator = hasSeparators && planned.index < m.count - 1;
1042
+ cells.push(
1043
+ this.buildWindowCell(planned.index, planned.key, includeSeparator),
1044
+ );
900
1045
  }
901
1046
  this.windowCells = cells;
1047
+ // Swap, don't prune: a cell that left the window is simply absent from the pass that just ran,
1048
+ // so the old map is the garbage and the new one is exactly the live set.
1049
+ this.cellContexts = this.nextCellContexts;
1050
+ this.nextCellContexts = new Map<string, IVListItemContext<ItemT>>();
902
1051
 
903
1052
  dlog(
904
1053
  `Angular VirtualizedList window [${m.first}, ${m.last}] of ${m.count} ` +
@@ -914,21 +1063,45 @@ export class VirtualizedList<ItemT = unknown>
914
1063
  includeSeparator: boolean,
915
1064
  ): IWindowCell<ItemT> {
916
1065
  const item = this.getItem(this.data, index);
917
- const context: IVListItemContext<ItemT> = {
918
- $implicit: item,
919
- index,
920
- separators: this.makeSeparators(index),
921
- };
922
1066
  return {
923
1067
  key,
924
1068
  index,
925
- context,
1069
+ context: this.contextFor(key, index, item),
926
1070
  measure: this.cellMeasure(index),
927
- separatorContext: includeSeparator ? this.buildSeparatorContext(index, item) : undefined,
1071
+ separatorContext: includeSeparator
1072
+ ? this.buildSeparatorContext(index, item)
1073
+ : undefined,
928
1074
  };
929
1075
  }
930
1076
 
931
- private buildSeparatorContext(index: number, item: ItemT): IVListSeparatorContext<ItemT> {
1077
+ // The context for one cell, reused by identity when the cell did not move. See cellContexts.
1078
+ private contextFor(
1079
+ key: string,
1080
+ index: number,
1081
+ item: ItemT,
1082
+ ): IVListItemContext<ItemT> {
1083
+ const cached = this.cellContexts.get(key);
1084
+ if (
1085
+ cached !== undefined &&
1086
+ cached.$implicit === item &&
1087
+ cached.index === index
1088
+ ) {
1089
+ this.nextCellContexts.set(key, cached);
1090
+ return cached;
1091
+ }
1092
+ const context: IVListItemContext<ItemT> = {
1093
+ $implicit: item,
1094
+ index,
1095
+ separators: this.makeSeparators(index),
1096
+ };
1097
+ this.nextCellContexts.set(key, context);
1098
+ return context;
1099
+ }
1100
+
1101
+ private buildSeparatorContext(
1102
+ index: number,
1103
+ item: ItemT,
1104
+ ): IVListSeparatorContext<ItemT> {
932
1105
  const overrides = this.separatorOverrides.get(index);
933
1106
  const highlighted = overrides?.highlighted ?? false;
934
1107
  const context: IVListSeparatorContext<ItemT> = {
@@ -941,7 +1114,12 @@ export class VirtualizedList<ItemT = unknown>
941
1114
  // arbitrary separator props); the typed fields above stay authoritative.
942
1115
  if (overrides !== undefined) {
943
1116
  for (const key of Object.keys(overrides)) {
944
- if (key === 'highlighted' || key === 'leadingItem' || key === 'trailingItem') continue;
1117
+ if (
1118
+ key === 'highlighted' ||
1119
+ key === 'leadingItem' ||
1120
+ key === 'trailingItem'
1121
+ )
1122
+ continue;
945
1123
  context[key] = overrides[key];
946
1124
  }
947
1125
  }
@@ -958,10 +1136,13 @@ export class VirtualizedList<ItemT = unknown>
958
1136
  const measure = (event: ISymbioteEvent): void => {
959
1137
  const length = readLayoutLength(event, this.isHorizontal);
960
1138
  if (length === undefined) return;
961
- dlog(`Angular VirtualizedList cell ${index} measured length=${length}`);
1139
+ const offset = readLayoutOffset(event, this.isHorizontal);
1140
+ dlog(
1141
+ `Angular VirtualizedList cell ${index} measured length=${length} offset=${offset ?? 'none'}`,
1142
+ );
962
1143
  // The reducer guards a fixed getItemLayout and a repeated length; a no-op leaves state and the
963
1144
  // view untouched (dispatch marks for check only when something changed).
964
- this.dispatch({ kind: 'measure', index, length });
1145
+ this.dispatch({ kind: 'measure', index, length, offset });
965
1146
  };
966
1147
  this.cellMeasures.set(index, measure);
967
1148
  return measure;
@@ -969,18 +1150,29 @@ export class VirtualizedList<ItemT = unknown>
969
1150
 
970
1151
  // Per-cell onLayout rides the engine's structural (layout) event channel — Angular forbids an
971
1152
  // [onLayout] property binding (NG8002). $event arrives untyped, narrowed before reaching measure.
972
- handleCellLayout(measure: (event: ISymbioteEvent) => void, event: unknown): void {
1153
+ handleCellLayout(
1154
+ measure: (event: ISymbioteEvent) => void,
1155
+ event: unknown,
1156
+ ): void {
973
1157
  if (this.isSymbioteEvent(event)) measure(event);
974
1158
  }
975
1159
 
976
1160
  private isSymbioteEvent(value: unknown): value is ISymbioteEvent {
977
- return typeof value === 'object' && value !== null && 'nativeEvent' in value;
1161
+ return (
1162
+ typeof value === 'object' && value !== null && 'nativeEvent' in value
1163
+ );
978
1164
  }
979
1165
 
980
- private mergeSeparator(gapIndex: number, patch: Partial<ISeparatorProps<unknown>>): void {
1166
+ private mergeSeparator(
1167
+ gapIndex: number,
1168
+ patch: Partial<ISeparatorProps<unknown>>,
1169
+ ): void {
981
1170
  const count = this.listState.metrics.count;
982
1171
  if (!isSeparatorGapInRange(gapIndex, count)) return;
983
- this.separatorOverrides.set(gapIndex, { ...this.separatorOverrides.get(gapIndex), ...patch });
1172
+ this.separatorOverrides.set(gapIndex, {
1173
+ ...this.separatorOverrides.get(gapIndex),
1174
+ ...patch,
1175
+ });
984
1176
  this.cdr.markForCheck();
985
1177
  }
986
1178
 
@@ -996,7 +1188,10 @@ export class VirtualizedList<ItemT = unknown>
996
1188
  this.mergeSeparator(index - 1, { highlighted: false });
997
1189
  this.mergeSeparator(index, { highlighted: false });
998
1190
  },
999
- updateProps: (select: 'leading' | 'trailing', newProps: Record<string, unknown>): void => {
1191
+ updateProps: (
1192
+ select: 'leading' | 'trailing',
1193
+ newProps: Record<string, unknown>,
1194
+ ): void => {
1000
1195
  this.mergeSeparator(select === 'leading' ? index - 1 : index, newProps);
1001
1196
  },
1002
1197
  };
@@ -1029,7 +1224,11 @@ export class VirtualizedList<ItemT = unknown>
1029
1224
  });
1030
1225
  }
1031
1226
 
1032
- scrollToItem(params: { item: unknown; animated?: boolean; viewPosition?: number }): void {
1227
+ scrollToItem(params: {
1228
+ item: unknown;
1229
+ animated?: boolean;
1230
+ viewPosition?: number;
1231
+ }): void {
1033
1232
  this.dispatch({
1034
1233
  kind: 'scroll-to-item',
1035
1234
  item: params.item,
@@ -1039,7 +1238,10 @@ export class VirtualizedList<ItemT = unknown>
1039
1238
  }
1040
1239
 
1041
1240
  scrollToEnd(params?: { animated?: boolean }): void {
1042
- this.dispatch({ kind: 'scroll-to-end', animated: params?.animated ?? true });
1241
+ this.dispatch({
1242
+ kind: 'scroll-to-end',
1243
+ animated: params?.animated ?? true,
1244
+ });
1043
1245
  }
1044
1246
 
1045
1247
  flashScrollIndicators(): void {
@@ -46,7 +46,8 @@ export interface IVSectionContext<ItemT> {
46
46
  // `<ng-template vSectionItem>` — one section item row. The static guard types the `let-` bindings.
47
47
  @Directive({ selector: '[vSectionItem]', standalone: true })
48
48
  export class VSectionItemDirective<ItemT = unknown> {
49
- readonly templateRef = inject<TemplateRef<IVSectionItemContext<ItemT>>>(TemplateRef);
49
+ readonly templateRef =
50
+ inject<TemplateRef<IVSectionItemContext<ItemT>>>(TemplateRef);
50
51
 
51
52
  static ngTemplateContextGuard<T>(
52
53
  _dir: VSectionItemDirective<T>,
@@ -59,7 +60,8 @@ export class VSectionItemDirective<ItemT = unknown> {
59
60
  // `<ng-template vSectionHeader>` — rendered above each section's items (RN renderSectionHeader).
60
61
  @Directive({ selector: '[vSectionHeader]', standalone: true })
61
62
  export class VSectionHeaderDirective<ItemT = unknown> {
62
- readonly templateRef = inject<TemplateRef<IVSectionContext<ItemT>>>(TemplateRef);
63
+ readonly templateRef =
64
+ inject<TemplateRef<IVSectionContext<ItemT>>>(TemplateRef);
63
65
 
64
66
  static ngTemplateContextGuard<T>(
65
67
  _dir: VSectionHeaderDirective<T>,
@@ -72,7 +74,8 @@ export class VSectionHeaderDirective<ItemT = unknown> {
72
74
  // `<ng-template vSectionFooter>` — rendered below each section's items (RN renderSectionFooter).
73
75
  @Directive({ selector: '[vSectionFooter]', standalone: true })
74
76
  export class VSectionFooterDirective<ItemT = unknown> {
75
- readonly templateRef = inject<TemplateRef<IVSectionContext<ItemT>>>(TemplateRef);
77
+ readonly templateRef =
78
+ inject<TemplateRef<IVSectionContext<ItemT>>>(TemplateRef);
76
79
 
77
80
  static ngTemplateContextGuard<T>(
78
81
  _dir: VSectionFooterDirective<T>,