@symbiote-native/angular 0.6.2 → 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 (191) hide show
  1. package/README.md +3 -3
  2. package/bin/symbiote-angular-dev.cjs +20 -19
  3. package/build/angular/anchor-host-registry.js +2 -2
  4. package/build/angular/anchor-host-registry.js.map +1 -1
  5. package/build/angular/bootstrap.js +1 -1
  6. package/build/angular/bootstrap.js.map +1 -1
  7. package/build/angular/components/activity-indicator/index.android.d.ts +2 -1
  8. package/build/angular/components/activity-indicator/index.android.js +12 -4
  9. package/build/angular/components/activity-indicator/index.android.js.map +1 -1
  10. package/build/angular/components/activity-indicator/index.ios.d.ts +2 -1
  11. package/build/angular/components/activity-indicator/index.ios.js +8 -3
  12. package/build/angular/components/activity-indicator/index.ios.js.map +1 -1
  13. package/build/angular/components/activity-indicator/shared.d.ts +1 -1
  14. package/build/angular/components/activity-indicator/shared.js +4 -4
  15. package/build/angular/components/activity-indicator/shared.js.map +1 -1
  16. package/build/angular/components/button.d.ts +4 -2
  17. package/build/angular/components/button.js +41 -22
  18. package/build/angular/components/button.js.map +1 -1
  19. package/build/angular/components/flat-list/index.d.ts +6 -4
  20. package/build/angular/components/flat-list/index.js +80 -50
  21. package/build/angular/components/flat-list/index.js.map +1 -1
  22. package/build/angular/components/image/index.android.d.ts +8 -3
  23. package/build/angular/components/image/index.android.js +42 -10
  24. package/build/angular/components/image/index.android.js.map +1 -1
  25. package/build/angular/components/image/index.ios.d.ts +8 -3
  26. package/build/angular/components/image/index.ios.js +42 -10
  27. package/build/angular/components/image/index.ios.js.map +1 -1
  28. package/build/angular/components/image/shared.d.ts +4 -1
  29. package/build/angular/components/image/shared.js +51 -8
  30. package/build/angular/components/image/shared.js.map +1 -1
  31. package/build/angular/components/image-background.d.ts +2 -1
  32. package/build/angular/components/image-background.js +26 -10
  33. package/build/angular/components/image-background.js.map +1 -1
  34. package/build/angular/components/input-accessory-view/index.d.ts +9 -4
  35. package/build/angular/components/input-accessory-view/index.js +49 -10
  36. package/build/angular/components/input-accessory-view/index.js.map +1 -1
  37. package/build/angular/components/keyboard-avoiding-view/index.d.ts +3 -1
  38. package/build/angular/components/keyboard-avoiding-view/index.js +44 -12
  39. package/build/angular/components/keyboard-avoiding-view/index.js.map +1 -1
  40. package/build/angular/components/modal/index.d.ts +11 -8
  41. package/build/angular/components/modal/index.js +51 -29
  42. package/build/angular/components/modal/index.js.map +1 -1
  43. package/build/angular/components/pressable/index.d.ts +12 -6
  44. package/build/angular/components/pressable/index.js +66 -13
  45. package/build/angular/components/pressable/index.js.map +1 -1
  46. package/build/angular/components/refresh-control/index.d.ts +8 -4
  47. package/build/angular/components/refresh-control/index.js +54 -16
  48. package/build/angular/components/refresh-control/index.js.map +1 -1
  49. package/build/angular/components/safe-area-view/index.d.ts +9 -4
  50. package/build/angular/components/safe-area-view/index.js +48 -9
  51. package/build/angular/components/safe-area-view/index.js.map +1 -1
  52. package/build/angular/components/scroll-view/index.android.d.ts +2 -3
  53. package/build/angular/components/scroll-view/index.android.js +88 -61
  54. package/build/angular/components/scroll-view/index.android.js.map +1 -1
  55. package/build/angular/components/scroll-view/index.ios.d.ts +2 -3
  56. package/build/angular/components/scroll-view/index.ios.js +24 -18
  57. package/build/angular/components/scroll-view/index.ios.js.map +1 -1
  58. package/build/angular/components/scroll-view/projection.d.ts +35 -0
  59. package/build/angular/components/scroll-view/projection.js +279 -53
  60. package/build/angular/components/scroll-view/projection.js.map +1 -1
  61. package/build/angular/components/scroll-view/shared.d.ts +14 -5
  62. package/build/angular/components/scroll-view/shared.js +225 -75
  63. package/build/angular/components/scroll-view/shared.js.map +1 -1
  64. package/build/angular/components/scroll-view/sticky-header.js +21 -22
  65. package/build/angular/components/scroll-view/sticky-header.js.map +1 -1
  66. package/build/angular/components/section-list/index.d.ts +7 -1
  67. package/build/angular/components/section-list/index.js +91 -54
  68. package/build/angular/components/section-list/index.js.map +1 -1
  69. package/build/angular/components/switch/index.android.d.ts +7 -3
  70. package/build/angular/components/switch/index.android.js +52 -10
  71. package/build/angular/components/switch/index.android.js.map +1 -1
  72. package/build/angular/components/switch/index.ios.d.ts +7 -3
  73. package/build/angular/components/switch/index.ios.js +52 -10
  74. package/build/angular/components/switch/index.ios.js.map +1 -1
  75. package/build/angular/components/switch/shared.d.ts +13 -4
  76. package/build/angular/components/switch/shared.js +71 -7
  77. package/build/angular/components/switch/shared.js.map +1 -1
  78. package/build/angular/components/text-input.d.ts +11 -3
  79. package/build/angular/components/text-input.js +68 -8
  80. package/build/angular/components/text-input.js.map +1 -1
  81. package/build/angular/components/touchable/index.d.ts +40 -9
  82. package/build/angular/components/touchable/index.js +303 -67
  83. package/build/angular/components/touchable/index.js.map +1 -1
  84. package/build/angular/components/touchable-native-feedback/index.d.ts +6 -3
  85. package/build/angular/components/touchable-native-feedback/index.js +63 -27
  86. package/build/angular/components/touchable-native-feedback/index.js.map +1 -1
  87. package/build/angular/components/virtualized-list/directives.js +22 -18
  88. package/build/angular/components/virtualized-list/directives.js.map +1 -1
  89. package/build/angular/components/virtualized-list/index.d.ts +15 -4
  90. package/build/angular/components/virtualized-list/index.js +326 -94
  91. package/build/angular/components/virtualized-list/index.js.map +1 -1
  92. package/build/angular/components/virtualized-section-list/directives.js +12 -12
  93. package/build/angular/components/virtualized-section-list/directives.js.map +1 -1
  94. package/build/angular/components/virtualized-section-list/index.d.ts +19 -1
  95. package/build/angular/components/virtualized-section-list/index.js +93 -22
  96. package/build/angular/components/virtualized-section-list/index.js.map +1 -1
  97. package/build/angular/components.d.ts +2 -2
  98. package/build/angular/components.js +5 -0
  99. package/build/angular/components.js.map +1 -1
  100. package/build/angular/create-portal/index.js +34 -36
  101. package/build/angular/create-portal/index.js.map +1 -1
  102. package/build/angular/create-tunnel/index.js +19 -22
  103. package/build/angular/create-tunnel/index.js.map +1 -1
  104. package/build/angular/descriptor-to-angular/index.js +6 -4
  105. package/build/angular/descriptor-to-angular/index.js.map +1 -1
  106. package/build/angular/diagnostics.d.ts +33 -0
  107. package/build/angular/diagnostics.js +82 -0
  108. package/build/angular/diagnostics.js.map +1 -0
  109. package/build/angular/host-instance/index.js +1 -1
  110. package/build/angular/host-instance/index.js.map +1 -1
  111. package/build/angular/index.d.ts +9 -4
  112. package/build/angular/index.js +17 -3
  113. package/build/angular/index.js.map +1 -1
  114. package/build/angular/modules/animated/animated-leaf-binder.d.ts +1 -7
  115. package/build/angular/modules/animated/animated-leaf-binder.js +22 -72
  116. package/build/angular/modules/animated/animated-leaf-binder.js.map +1 -1
  117. package/build/angular/modules/animated/create-animated-component.d.ts +11 -6
  118. package/build/angular/modules/animated/create-animated-component.js +106 -55
  119. package/build/angular/modules/animated/create-animated-component.js.map +1 -1
  120. package/build/angular/modules/status-bar/index.js +4 -4
  121. package/build/angular/modules/status-bar/index.js.map +1 -1
  122. package/build/angular/primitives/index.d.ts +20 -3
  123. package/build/angular/primitives/index.js +84 -48
  124. package/build/angular/primitives/index.js.map +1 -1
  125. package/build/angular/primitives/shared.d.ts +48 -2
  126. package/build/angular/primitives/shared.js +158 -11
  127. package/build/angular/primitives/shared.js.map +1 -1
  128. package/build/angular/render/index.js +69 -38
  129. package/build/angular/render/index.js.map +1 -1
  130. package/build/angular/renderer/index.d.ts +1 -0
  131. package/build/angular/renderer/index.js +37 -2
  132. package/build/angular/renderer/index.js.map +1 -1
  133. package/build/angular/services/color-scheme.service.js +3 -3
  134. package/build/angular/services/color-scheme.service.js.map +1 -1
  135. package/build/angular/services/window-dimensions.service.js +4 -4
  136. package/build/angular/services/window-dimensions.service.js.map +1 -1
  137. package/package.json +10 -8
  138. package/src/anchor-host-registry.ts +2 -2
  139. package/src/bootstrap.ts +4 -1
  140. package/src/components/activity-indicator/index.android.ts +8 -1
  141. package/src/components/activity-indicator/index.ios.ts +4 -0
  142. package/src/components/activity-indicator/shared.ts +32 -13
  143. package/src/components/button.ts +46 -19
  144. package/src/components/flat-list/index.ts +122 -62
  145. package/src/components/image/index.android.ts +58 -7
  146. package/src/components/image/index.ios.ts +58 -7
  147. package/src/components/image/shared.ts +92 -21
  148. package/src/components/image-background.ts +29 -9
  149. package/src/components/input-accessory-view/index.ts +67 -12
  150. package/src/components/keyboard-avoiding-view/index.ts +61 -15
  151. package/src/components/modal/index.ts +72 -33
  152. package/src/components/pressable/index.ts +117 -28
  153. package/src/components/refresh-control/index.ts +74 -18
  154. package/src/components/safe-area-view/index.ts +64 -10
  155. package/src/components/scroll-view/index.android.ts +62 -47
  156. package/src/components/scroll-view/index.ios.ts +19 -17
  157. package/src/components/scroll-view/projection.ts +363 -64
  158. package/src/components/scroll-view/shared.ts +312 -90
  159. package/src/components/scroll-view/sticky-header.ts +36 -24
  160. package/src/components/section-list/index.ts +86 -53
  161. package/src/components/switch/index.android.ts +60 -8
  162. package/src/components/switch/index.ios.ts +60 -8
  163. package/src/components/switch/shared.ts +93 -6
  164. package/src/components/text-input.ts +95 -13
  165. package/src/components/touchable/index.ts +348 -64
  166. package/src/components/touchable-native-feedback/index.ts +94 -31
  167. package/src/components/virtualized-list/directives.ts +19 -5
  168. package/src/components/virtualized-list/index.ts +380 -85
  169. package/src/components/virtualized-section-list/directives.ts +6 -3
  170. package/src/components/virtualized-section-list/index.ts +146 -33
  171. package/src/components.ts +9 -1
  172. package/src/create-portal/index.ts +31 -31
  173. package/src/create-tunnel/index.ts +16 -17
  174. package/src/descriptor-to-angular/index.ts +29 -8
  175. package/src/diagnostics.ts +138 -0
  176. package/src/host-instance/index.ts +9 -3
  177. package/src/index.ts +61 -3
  178. package/src/modules/animated/animated-leaf-binder.ts +28 -77
  179. package/src/modules/animated/create-animated-component.ts +115 -47
  180. package/src/modules/app-registry/index.ts +9 -9
  181. package/src/modules/status-bar/index.ts +13 -4
  182. package/src/primitives/index.ts +47 -2
  183. package/src/primitives/shared.ts +170 -10
  184. package/src/render/index.ts +77 -39
  185. package/src/renderer/index.ts +76 -14
  186. package/src/services/color-scheme.service.ts +3 -1
  187. package/src/services/window-dimensions.service.ts +11 -4
  188. package/build/angular/modules/animated/animated-leaf-binder/index.d.ts +0 -14
  189. package/build/angular/modules/animated/animated-leaf-binder/index.js +0 -91
  190. package/build/angular/modules/animated/animated-leaf-binder/index.js.map +0 -1
  191. package/src/modules/animated/animated-leaf-binder/index.ts +0 -101
@@ -35,9 +35,12 @@ import {
35
35
  Input,
36
36
  Output,
37
37
  ViewChild,
38
+ computed,
38
39
  inject,
40
+ signal,
39
41
  type AfterViewChecked,
40
42
  type DoCheck,
43
+ type OnChanges,
41
44
  type OnDestroy,
42
45
  } from '@angular/core';
43
46
  import {
@@ -53,10 +56,12 @@ import {
53
56
  INVERTED_Y_STYLE,
54
57
  buildListPlan,
55
58
  buildViewabilityPairs,
59
+ computeWindow,
56
60
  createInitialListState,
57
61
  isSeparatorGapInRange,
58
62
  listEffectSignature,
59
63
  readLayoutLength,
64
+ readLayoutOffset,
60
65
  readScrollOffset,
61
66
  reduceList,
62
67
  resolveAccessibilityProps,
@@ -84,9 +89,14 @@ import {
84
89
  type ISymbioteNode,
85
90
  type IViewStyle,
86
91
  } from '@symbiote-native/engine';
92
+ import { countAngular } from '../../diagnostics';
87
93
  import { ScrollView } from '../scroll-view';
88
94
  import { RefreshControl } from '../refresh-control';
89
- import { anchorHostStyle, ViewHost } from '../../primitives';
95
+ import {
96
+ anchorHostStyle,
97
+ SymbioteStyleInputDirective,
98
+ ViewHost,
99
+ } from '../../primitives';
90
100
  import {
91
101
  VListEmptyDirective,
92
102
  VListFooterDirective,
@@ -123,7 +133,8 @@ export type { IVListItemContext, IVListSeparatorContext } from './directives';
123
133
  // element-returning props (renderItem, ListHeader/Footer/Empty Component, ItemSeparatorComponent):
124
134
  // those are the per-adapter children/render fields and become `<ng-template>` directives in Angular.
125
135
  // Everything agnostic is the SAME surface.
126
- export interface IVirtualizedListProps<ItemT> extends IAccessibilityProps, IAriaProps {
136
+ export interface IVirtualizedListProps<ItemT>
137
+ extends IAccessibilityProps, IAriaProps {
127
138
  data: unknown;
128
139
  getItem: (data: unknown, index: number) => ItemT;
129
140
  getItemCount: (data: unknown) => number;
@@ -202,6 +213,9 @@ interface IWindowCell<ItemT> {
202
213
  @Component({
203
214
  selector: 'VirtualizedList',
204
215
  standalone: true,
216
+ hostDirectives: [
217
+ { directive: SymbioteStyleInputDirective, inputs: ['style'] },
218
+ ],
205
219
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
206
220
  imports: [ScrollView, RefreshControl, VListOutletDirective, ViewHost],
207
221
  changeDetection: ChangeDetectionStrategy.OnPush,
@@ -210,30 +224,40 @@ interface IWindowCell<ItemT> {
210
224
  [horizontal]="isHorizontal"
211
225
  [style]="resolvedStyle"
212
226
  [contentContainerStyle]="resolvedContentContainerStyle"
213
- [testID]="foldedAccessibility.testID"
214
- [nativeID]="foldedAccessibility.nativeID"
215
- [accessible]="foldedAccessibility.accessible"
216
- [accessibilityLabel]="foldedAccessibility.accessibilityLabel"
217
- [accessibilityHint]="foldedAccessibility.accessibilityHint"
218
- [accessibilityRole]="foldedAccessibility.accessibilityRole"
219
- [accessibilityState]="foldedAccessibility.accessibilityState"
220
- [accessibilityValue]="foldedAccessibility.accessibilityValue"
221
- [accessibilityActions]="foldedAccessibility.accessibilityActions"
222
- [accessibilityLabelledBy]="foldedAccessibility.accessibilityLabelledBy"
223
- [importantForAccessibility]="foldedAccessibility.importantForAccessibility"
224
- [accessibilityLiveRegion]="foldedAccessibility.accessibilityLiveRegion"
225
- [screenReaderFocusable]="foldedAccessibility.screenReaderFocusable"
226
- [accessibilityViewIsModal]="foldedAccessibility.accessibilityViewIsModal"
227
- [accessibilityElementsHidden]="foldedAccessibility.accessibilityElementsHidden"
228
- [accessibilityIgnoresInvertColors]="foldedAccessibility.accessibilityIgnoresInvertColors"
229
- [accessibilityLanguage]="foldedAccessibility.accessibilityLanguage"
227
+ [testID]="foldedAccessibility().testID"
228
+ [nativeID]="foldedAccessibility().nativeID"
229
+ [accessible]="foldedAccessibility().accessible"
230
+ [accessibilityLabel]="foldedAccessibility().accessibilityLabel"
231
+ [accessibilityHint]="foldedAccessibility().accessibilityHint"
232
+ [accessibilityRole]="foldedAccessibility().accessibilityRole"
233
+ [accessibilityState]="foldedAccessibility().accessibilityState"
234
+ [accessibilityValue]="foldedAccessibility().accessibilityValue"
235
+ [accessibilityActions]="foldedAccessibility().accessibilityActions"
236
+ [accessibilityLabelledBy]="foldedAccessibility().accessibilityLabelledBy"
237
+ [importantForAccessibility]="
238
+ foldedAccessibility().importantForAccessibility
239
+ "
240
+ [accessibilityLiveRegion]="foldedAccessibility().accessibilityLiveRegion"
241
+ [screenReaderFocusable]="foldedAccessibility().screenReaderFocusable"
242
+ [accessibilityViewIsModal]="
243
+ foldedAccessibility().accessibilityViewIsModal
244
+ "
245
+ [accessibilityElementsHidden]="
246
+ foldedAccessibility().accessibilityElementsHidden
247
+ "
248
+ [accessibilityIgnoresInvertColors]="
249
+ foldedAccessibility().accessibilityIgnoresInvertColors
250
+ "
251
+ [accessibilityLanguage]="foldedAccessibility().accessibilityLanguage"
230
252
  [accessibilityRespondsToUserInteraction]="
231
- foldedAccessibility.accessibilityRespondsToUserInteraction
253
+ foldedAccessibility().accessibilityRespondsToUserInteraction
232
254
  "
233
255
  [accessibilityShowsLargeContentViewer]="
234
- foldedAccessibility.accessibilityShowsLargeContentViewer
256
+ foldedAccessibility().accessibilityShowsLargeContentViewer
257
+ "
258
+ [accessibilityLargeContentTitle]="
259
+ foldedAccessibility().accessibilityLargeContentTitle
235
260
  "
236
- [accessibilityLargeContentTitle]="foldedAccessibility.accessibilityLargeContentTitle"
237
261
  (accessibilityAction)="accessibilityActionTick($event)"
238
262
  (accessibilityTap)="accessibilityTapTick($event)"
239
263
  (magicTap)="magicTapTick($event)"
@@ -275,21 +299,43 @@ interface IWindowCell<ItemT> {
275
299
  @if (leadingSpacerStyle !== null) {
276
300
  <symbiote-view [style]="leadingSpacerStyle"></symbiote-view>
277
301
  }
302
+ @if (forcedStickyCell !== null) {
303
+ <symbiote-view
304
+ (layout)="handleCellLayout(forcedStickyCell.measure, $event)"
305
+ [style]="cellStyle"
306
+ >
307
+ <ng-container
308
+ [vListOutlet]="itemDir?.templateRef"
309
+ [vListOutletContext]="forcedStickyCell.context"
310
+ ></ng-container>
311
+ </symbiote-view>
312
+ }
313
+ @if (gapSpacerStyle !== null) {
314
+ <symbiote-view [style]="gapSpacerStyle"></symbiote-view>
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. -->
278
323
  @for (cell of windowCells; track cell.key) {
279
- <symbiote-view (layout)="handleCellLayout(cell.measure, $event)" [style]="cellStyle">
324
+ <symbiote-view
325
+ (layout)="handleCellLayout(cell.measure, $event)"
326
+ [style]="cellStyle"
327
+ >
280
328
  <ng-container
281
329
  [vListOutlet]="itemDir?.templateRef"
282
330
  [vListOutletContext]="cell.context"
283
331
  ></ng-container>
284
- </symbiote-view>
285
- @if (cell.separatorContext !== undefined) {
286
- <symbiote-view>
332
+ @if (cell.separatorContext !== undefined) {
287
333
  <ng-container
288
334
  [vListOutlet]="separatorDir?.templateRef"
289
335
  [vListOutletContext]="cell.separatorContext"
290
336
  ></ng-container>
291
- </symbiote-view>
292
- }
337
+ }
338
+ </symbiote-view>
293
339
  }
294
340
  @if (trailingSpacerStyle !== null) {
295
341
  <symbiote-view [style]="trailingSpacerStyle"></symbiote-view>
@@ -310,15 +356,22 @@ export class VirtualizedList<ItemT = unknown>
310
356
  IVirtualizedListHandle,
311
357
  DoCheck,
312
358
  AfterViewChecked,
359
+ OnChanges,
313
360
  OnDestroy
314
361
  {
315
362
  // The list's edge/viewability/failure events as real Angular events: `(endReached)="…"`, not
316
363
  // `[onEndReached]="…"`. See handleRefresh/accessibility*Tick below for how the still-callback-
317
364
  // shaped ScrollView/RefreshControl @Input()s are fed from these.
318
- @Output() readonly endReached = new EventEmitter<{ distanceFromEnd: number }>();
319
- @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
+ }>();
320
371
  @Output() readonly refresh = new EventEmitter<void>();
321
- @Output() readonly viewableItemsChanged = new EventEmitter<IViewableItemsChangedInfo<ItemT>>();
372
+ @Output() readonly viewableItemsChanged = new EventEmitter<
373
+ IViewableItemsChangedInfo<ItemT>
374
+ >();
322
375
  @Output() readonly scrollToIndexFailed = new EventEmitter<{
323
376
  index: number;
324
377
  highestMeasuredFrameIndex: number;
@@ -352,7 +405,8 @@ export class VirtualizedList<ItemT = unknown>
352
405
  // (no wrapper) falls back to this component's own `.observed`, unchanged from before.
353
406
  @Input() refreshRequested?: boolean;
354
407
  @Input() viewabilityConfig?: IViewabilityConfig;
355
- @Input() viewabilityConfigCallbackPairs?: IViewabilityConfigCallbackPair<ItemT>[];
408
+ @Input()
409
+ viewabilityConfigCallbackPairs?: IViewabilityConfigCallbackPair<ItemT>[];
356
410
  @Input() initialNumToRender?: number;
357
411
  @Input() initialScrollIndex?: number;
358
412
  @Input() maxToRenderPerBatch?: number;
@@ -383,8 +437,10 @@ export class VirtualizedList<ItemT = unknown>
383
437
  @Input() accessibilityValue?: IAccessibilityProps['accessibilityValue'];
384
438
  @Input() accessibilityActions?: IAccessibilityProps['accessibilityActions'];
385
439
  @Input() accessibilityLabelledBy?: string | string[];
386
- @Input() importantForAccessibility?: IAccessibilityProps['importantForAccessibility'];
387
- @Input() accessibilityLiveRegion?: IAccessibilityProps['accessibilityLiveRegion'];
440
+ @Input()
441
+ importantForAccessibility?: IAccessibilityProps['importantForAccessibility'];
442
+ @Input()
443
+ accessibilityLiveRegion?: IAccessibilityProps['accessibilityLiveRegion'];
388
444
  @Input() screenReaderFocusable?: boolean;
389
445
  @Input() accessibilityViewIsModal?: boolean;
390
446
  @Input() accessibilityElementsHidden?: boolean;
@@ -416,7 +472,8 @@ export class VirtualizedList<ItemT = unknown>
416
472
  @ContentChild(VListHeaderDirective) headerDir?: VListHeaderDirective;
417
473
  @ContentChild(VListFooterDirective) footerDir?: VListFooterDirective;
418
474
  @ContentChild(VListEmptyDirective) emptyDir?: VListEmptyDirective;
419
- @ContentChild(VListSeparatorDirective) separatorDir?: VListSeparatorDirective<ItemT>;
475
+ @ContentChild(VListSeparatorDirective)
476
+ separatorDir?: VListSeparatorDirective<ItemT>;
420
477
 
421
478
  // The composed inner scroll view. Its instance IS an IScrollViewHandle (scrollTo / scrollToEnd /
422
479
  // flashScrollIndicators / getScrollNode), so the imperative handle delegates straight to it.
@@ -426,7 +483,13 @@ export class VirtualizedList<ItemT = unknown>
426
483
  // --- template-bound view state, assembled in ngDoCheck (recomputeView) ---
427
484
  itemCount = EMPTY_OFFSET;
428
485
  windowCells: IWindowCell<ItemT>[] = [];
486
+ // The nearest sticky-header cell force-mounted outside [first,last] (plan.forcedStickyCell), the
487
+ // Angular twin of RN's _ensureClosestStickyHeader. Rendered ahead of windowCells, right after the
488
+ // leading spacer, with gapSpacerStyle filling the gap to the window's own first cell — see
489
+ // buildListPlan's doc comment for the exact child order this assumes.
490
+ forcedStickyCell: IWindowCell<ItemT> | null = null;
429
491
  leadingSpacerStyle: IViewStyle | null = null;
492
+ gapSpacerStyle: IViewStyle | null = null;
430
493
  trailingSpacerStyle: IViewStyle | null = null;
431
494
  cellStyle: IViewStyle | undefined = undefined;
432
495
  renderedStickyIndices: number[] | undefined = undefined;
@@ -437,7 +500,8 @@ export class VirtualizedList<ItemT = unknown>
437
500
  resolvedStyle: IViewStyle | undefined = undefined;
438
501
  resolvedContentContainerStyle: IStyleProp<IViewStyle> | undefined = undefined;
439
502
  resolvedMaintainVisibleContentPosition:
440
- { minIndexForVisible: number; autoscrollToTopThreshold?: number } | undefined = undefined;
503
+ | { minIndexForVisible: number; autoscrollToTopThreshold?: number }
504
+ | undefined = undefined;
441
505
  // The offset we are imperatively driving native to before the scroll handle attaches (rides down
442
506
  // as contentOffset). A fresh object identity each push so the commit re-applies a repeated value.
443
507
  commandedOffset: { x: number; y: number } | undefined = undefined;
@@ -446,11 +510,35 @@ export class VirtualizedList<ItemT = unknown>
446
510
  // measured lengths, committed window, edge/viewability dedup, MVCP anchor — all in IListState,
447
511
  // driven by the shared reduceList. `renderVersion` bumps whenever a transition changes render
448
512
  // state, so ngDoCheck's recompute-dedup can detect it (listState is a plain object, not tracked).
449
- private readonly listState: IListState<ItemT> = createInitialListState<ItemT>();
513
+ private readonly listState: IListState<ItemT> =
514
+ createInitialListState<ItemT>();
450
515
  private renderVersion = EMPTY_OFFSET;
451
516
 
452
- private readonly cellMeasures = new Map<number, (event: ISymbioteEvent) => void>();
453
- 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
+ >();
454
542
  // The adapter-owned debounce (minimumViewTime) and incremental-fill timers; the reducer only
455
543
  // hands back a delay.
456
544
  private viewableTimer: ReturnType<typeof setTimeout> | null = null;
@@ -476,7 +564,27 @@ export class VirtualizedList<ItemT = unknown>
476
564
  // inner `<ScrollView>` one level down (itself its own separate anchor host).
477
565
  private readonly elementRef = inject(ElementRef);
478
566
 
479
- get foldedAccessibility(): IAccessibilityProps & IAriaProps & Record<string, unknown> {
567
+ // Bridges the non-reactive @Input fields into the reactive graph so the computed below can
568
+ // memoize a bag derived from them (the same bridge ScrollView's shared.ts uses - read its comment
569
+ // for why signal inputs are not an option while this package's unit suite runs on JIT).
570
+ //
571
+ // ONLY safe for a bag whose every dependency is an @Input. This list's window machinery
572
+ // (listState, renderVersion, windowCells, the measured metrics) is driven from scroll/layout
573
+ // callbacks that never touch ngOnChanges, so nothing derived from it may ride this signal - it
574
+ // would memoize a window that stops re-computing mid-scroll. Make that state its own signal
575
+ // instead of widening this one.
576
+ private readonly inputsRevision = signal(0);
577
+
578
+ // MEASURED: the template feeds 20 separate <ScrollView> inputs off this one bag, and Angular does
579
+ // not cache a getter across binding expressions - as a getter it rebuilt the object 20 times per
580
+ // refresh, and every consumer saw a fresh reference, so all 20 reported "changed" and wrote
581
+ // through to the ScrollView. A computed evaluates once and hands back the SAME object until an
582
+ // input actually changes. Every dependency below is an @Input, which is what makes
583
+ // inputsRevision a complete dependency set here.
584
+ readonly foldedAccessibility = computed<
585
+ IAccessibilityProps & IAriaProps & Record<string, unknown>
586
+ >(() => {
587
+ this.inputsRevision();
480
588
  return resolveAccessibilityProps({
481
589
  testID: this.testID,
482
590
  nativeID: this.nativeID,
@@ -495,8 +603,10 @@ export class VirtualizedList<ItemT = unknown>
495
603
  accessibilityElementsHidden: this.accessibilityElementsHidden,
496
604
  accessibilityIgnoresInvertColors: this.accessibilityIgnoresInvertColors,
497
605
  accessibilityLanguage: this.accessibilityLanguage,
498
- accessibilityRespondsToUserInteraction: this.accessibilityRespondsToUserInteraction,
499
- accessibilityShowsLargeContentViewer: this.accessibilityShowsLargeContentViewer,
606
+ accessibilityRespondsToUserInteraction:
607
+ this.accessibilityRespondsToUserInteraction,
608
+ accessibilityShowsLargeContentViewer:
609
+ this.accessibilityShowsLargeContentViewer,
500
610
  accessibilityLargeContentTitle: this.accessibilityLargeContentTitle,
501
611
  role: this.role,
502
612
  'aria-label': this.ariaLabel,
@@ -514,7 +624,7 @@ export class VirtualizedList<ItemT = unknown>
514
624
  'aria-valuenow': this.ariaValueNow,
515
625
  'aria-valuetext': this.ariaValueText,
516
626
  });
517
- }
627
+ });
518
628
 
519
629
  get shouldRenderRefreshControl(): boolean {
520
630
  return this.refreshRequested ?? this.refresh.observed;
@@ -536,7 +646,9 @@ export class VirtualizedList<ItemT = unknown>
536
646
  return this.maxToRenderPerBatch ?? DEFAULT_MAX_TO_RENDER_PER_BATCH;
537
647
  }
538
648
  private get updateCellsBatchingPeriodValue(): number {
539
- return this.updateCellsBatchingPeriod ?? DEFAULT_UPDATE_CELLS_BATCHING_PERIOD;
649
+ return (
650
+ this.updateCellsBatchingPeriod ?? DEFAULT_UPDATE_CELLS_BATCHING_PERIOD
651
+ );
540
652
  }
541
653
  private get onEndReachedThresholdValue(): number {
542
654
  return this.onEndReachedThreshold ?? DEFAULT_END_REACHED_THRESHOLD;
@@ -549,6 +661,7 @@ export class VirtualizedList<ItemT = unknown>
549
661
  // flow through it, which an @Output() can't carry), so this is a stable arrow field passed
550
662
  // straight to [onScroll]. onLayout is a real @Output() now, bound via (layout)="onLayoutTick($event)".
551
663
  onScrollTick = (event: ISymbioteEvent): void => {
664
+ countAngular('scrollTicks');
552
665
  const offset = readScrollOffset(event, this.isHorizontal);
553
666
  if (offset === undefined) return;
554
667
  dlog(`Angular VirtualizedList onScroll offset=${offset}`);
@@ -615,7 +728,8 @@ export class VirtualizedList<ItemT = unknown>
615
728
  onStartReachedActive: this.startReached.observed,
616
729
  viewabilityPairs: buildViewabilityPairs(
617
730
  this.viewableItemsChanged.observed
618
- ? (info: IViewableItemsChangedInfo<ItemT>): void => this.viewableItemsChanged.emit(info)
731
+ ? (info: IViewableItemsChangedInfo<ItemT>): void =>
732
+ this.viewableItemsChanged.emit(info)
619
733
  : undefined,
620
734
  this.viewabilityConfig,
621
735
  this.viewabilityConfigCallbackPairs,
@@ -631,13 +745,60 @@ export class VirtualizedList<ItemT = unknown>
631
745
  const inputs = this.buildInputs();
632
746
  const result = reduceList(this.listState, action, inputs);
633
747
  this.runEffects(result.effects, inputs);
634
- if (result.changed) {
635
- this.renderVersion += 1;
636
- this.cdr.markForCheck();
637
- }
748
+ if (!result.changed) return;
749
+ this.renderVersion += 1;
750
+ if (this.isWindowSettled(action)) return;
751
+ countAngular('listMarks');
752
+ this.cdr.markForCheck();
638
753
  }
639
754
 
640
- private runEffects(effects: IListEffect<ItemT>[], inputs: IListReducerInputs<ItemT>): void {
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;
775
+ const m = this.listState.metrics;
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;
796
+ }
797
+
798
+ private runEffects(
799
+ effects: IListEffect<ItemT>[],
800
+ inputs: IListReducerInputs<ItemT>,
801
+ ): void {
641
802
  for (const effect of effects) {
642
803
  switch (effect.kind) {
643
804
  case 'scroll-to':
@@ -647,7 +808,9 @@ export class VirtualizedList<ItemT = unknown>
647
808
  this.endReached.emit({ distanceFromEnd: effect.distanceFromEnd });
648
809
  break;
649
810
  case 'fire-start-reached':
650
- this.startReached.emit({ distanceFromStart: effect.distanceFromStart });
811
+ this.startReached.emit({
812
+ distanceFromStart: effect.distanceFromStart,
813
+ });
651
814
  break;
652
815
  case 'fire-scroll-to-index-failed':
653
816
  this.scrollToIndexFailed.emit({
@@ -690,6 +853,14 @@ export class VirtualizedList<ItemT = unknown>
690
853
  }
691
854
  }
692
855
 
856
+ // foldedAccessibility reads plain @Input fields, which are NOT reactive on their own - this bump
857
+ // is what tells that computed an input changed. It must stay in ngOnChanges: that is the single
858
+ // moment Angular has finished writing every changed input for this pass, and it runs before
859
+ // ngDoCheck below, so the recompute already sees the current values.
860
+ ngOnChanges(): void {
861
+ this.inputsRevision.update(revision => revision + 1);
862
+ }
863
+
693
864
  // Once-per-CD recompute, BEFORE the template bindings are read (so the freshly computed
694
865
  // template-bound fields render this pass — computing them in ngAfterContentChecked instead would
695
866
  // trip ExpressionChangedAfterItHasBeenChecked). The dedup guard runs refresh-metrics (which owns
@@ -697,6 +868,7 @@ export class VirtualizedList<ItemT = unknown>
697
868
  // grows exactly one step per meaningful CD. The projected templates (ContentChild) are resolved
698
869
  // from the second CD on; the first paint (count/viewport still 0) corrects on the layout-driven CD.
699
870
  ngDoCheck(): void {
871
+ countAngular('listChecks');
700
872
  const recomputeInputs: unknown[] = [
701
873
  this.data,
702
874
  this.extraData,
@@ -710,6 +882,10 @@ export class VirtualizedList<ItemT = unknown>
710
882
  this.stickyHeaderIndices,
711
883
  this.maintainVisibleContentPosition,
712
884
  this.style,
885
+ // `recomputeView` folds this into the committed style, but it arrives through
886
+ // addClass/removeClass, never as an @Input - so without it here no entry moves on a class
887
+ // toggle and the gate skips the recompute forever. Reference-stable, so the dedup holds.
888
+ anchorHostStyle(this.elementRef),
713
889
  this.contentContainerStyle,
714
890
  // Folds scroll / layout / measure / batch-tick: dispatch bumps renderVersion on any change.
715
891
  this.renderVersion,
@@ -728,6 +904,7 @@ export class VirtualizedList<ItemT = unknown>
728
904
  return;
729
905
  }
730
906
  // The single derive-per-CD: recompute the window off the current state before the view reads it.
907
+ countAngular('listRecomputes');
731
908
  reduceList(this.listState, { kind: 'refresh-metrics' }, this.buildInputs());
732
909
  this.recomputeView();
733
910
  }
@@ -752,7 +929,9 @@ export class VirtualizedList<ItemT = unknown>
752
929
  const hasHeader = this.headerDir !== undefined;
753
930
  const hasSeparators = this.separatorDir !== undefined;
754
931
  const stickySet =
755
- this.stickyHeaderIndices !== undefined ? new Set(this.stickyHeaderIndices) : undefined;
932
+ this.stickyHeaderIndices !== undefined
933
+ ? new Set(this.stickyHeaderIndices)
934
+ : undefined;
756
935
 
757
936
  this.resolvedContentContainerStyle = this.isHorizontal
758
937
  ? [this.contentContainerStyle, { width: m.total }]
@@ -774,15 +953,40 @@ export class VirtualizedList<ItemT = unknown>
774
953
  : {
775
954
  ...this.maintainVisibleContentPosition,
776
955
  minIndexForVisible:
777
- this.maintainVisibleContentPosition.minIndexForVisible + (hasHeader ? 1 : 0),
956
+ this.maintainVisibleContentPosition.minIndexForVisible +
957
+ (hasHeader ? 1 : 0),
778
958
  };
779
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
+
780
978
  if (m.count === FIRST_INDEX) {
979
+ this.cellContexts.clear();
980
+ this.nextCellContexts.clear();
781
981
  this.windowCells = [];
982
+ this.forcedStickyCell = null;
782
983
  this.leadingSpacerStyle = null;
984
+ this.gapSpacerStyle = null;
783
985
  this.trailingSpacerStyle = null;
784
986
  this.renderedStickyIndices = undefined;
785
- dlog(`Angular VirtualizedList empty (viewport=${this.listState.viewportLength})`);
987
+ dlog(
988
+ `Angular VirtualizedList empty (viewport=${this.listState.viewportLength})`,
989
+ );
786
990
  return;
787
991
  }
788
992
 
@@ -796,38 +1000,54 @@ export class VirtualizedList<ItemT = unknown>
796
1000
  keyFor: this.keyFor,
797
1001
  stickyIndices: stickySet,
798
1002
  hasHeader,
799
- hasSeparators,
800
1003
  });
801
1004
  this.leadingSpacerStyle =
802
- plan.leadingExtent > EMPTY_OFFSET ? this.spacerStyle(plan.leadingExtent) : 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;
803
1010
  this.trailingSpacerStyle =
804
- plan.trailingExtent > EMPTY_OFFSET ? this.spacerStyle(plan.trailingExtent) : null;
1011
+ plan.trailingExtent > EMPTY_OFFSET
1012
+ ? this.spacerStyle(plan.trailingExtent)
1013
+ : null;
805
1014
  this.renderedStickyIndices =
806
1015
  stickySet !== undefined && plan.stickyChildPositions.length > 0
807
1016
  ? plan.stickyChildPositions
808
1017
  : undefined;
1018
+ dlog(
1019
+ `STICKY[list] stickySet=${stickySet === undefined ? 'undefined' : JSON.stringify([...stickySet])} ` +
1020
+ `childPositions=${JSON.stringify(plan.stickyChildPositions)} ` +
1021
+ `forcedStickyCell=${plan.forcedStickyCell === undefined ? 'none' : plan.forcedStickyCell.index} ` +
1022
+ `window=[${m.first},${m.last}] hasHeader=${this.headerDir !== undefined}`,
1023
+ );
1024
+
1025
+ this.forcedStickyCell =
1026
+ plan.forcedStickyCell !== undefined
1027
+ ? this.buildWindowCell(
1028
+ plan.forcedStickyCell.index,
1029
+ plan.forcedStickyCell.key,
1030
+ false,
1031
+ )
1032
+ : null;
809
1033
 
810
1034
  const cells: IWindowCell<ItemT>[] = [];
811
1035
  for (const planned of plan.cells) {
812
- const item = this.getItem(this.data, planned.index);
813
- const context: IVListItemContext<ItemT> = {
814
- $implicit: item,
815
- index: planned.index,
816
- separators: this.makeSeparators(planned.index),
817
- };
818
- let separatorContext: IVListSeparatorContext<ItemT> | undefined;
819
- if (hasSeparators && planned.index < m.last) {
820
- separatorContext = this.buildSeparatorContext(planned.index, item);
821
- }
822
- cells.push({
823
- key: planned.key,
824
- index: planned.index,
825
- context,
826
- measure: this.cellMeasure(planned.index),
827
- separatorContext,
828
- });
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
+ );
829
1045
  }
830
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>>();
831
1051
 
832
1052
  dlog(
833
1053
  `Angular VirtualizedList window [${m.first}, ${m.last}] of ${m.count} ` +
@@ -835,7 +1055,53 @@ export class VirtualizedList<ItemT = unknown>
835
1055
  );
836
1056
  }
837
1057
 
838
- private buildSeparatorContext(index: number, item: ItemT): IVListSeparatorContext<ItemT> {
1058
+ // Assembles one cell (window or forced-sticky) — the context, measure closure, and optional
1059
+ // separator context — so both plan.cells and plan.forcedStickyCell build identically.
1060
+ private buildWindowCell(
1061
+ index: number,
1062
+ key: string,
1063
+ includeSeparator: boolean,
1064
+ ): IWindowCell<ItemT> {
1065
+ const item = this.getItem(this.data, index);
1066
+ return {
1067
+ key,
1068
+ index,
1069
+ context: this.contextFor(key, index, item),
1070
+ measure: this.cellMeasure(index),
1071
+ separatorContext: includeSeparator
1072
+ ? this.buildSeparatorContext(index, item)
1073
+ : undefined,
1074
+ };
1075
+ }
1076
+
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> {
839
1105
  const overrides = this.separatorOverrides.get(index);
840
1106
  const highlighted = overrides?.highlighted ?? false;
841
1107
  const context: IVListSeparatorContext<ItemT> = {
@@ -848,7 +1114,12 @@ export class VirtualizedList<ItemT = unknown>
848
1114
  // arbitrary separator props); the typed fields above stay authoritative.
849
1115
  if (overrides !== undefined) {
850
1116
  for (const key of Object.keys(overrides)) {
851
- if (key === 'highlighted' || key === 'leadingItem' || key === 'trailingItem') continue;
1117
+ if (
1118
+ key === 'highlighted' ||
1119
+ key === 'leadingItem' ||
1120
+ key === 'trailingItem'
1121
+ )
1122
+ continue;
852
1123
  context[key] = overrides[key];
853
1124
  }
854
1125
  }
@@ -865,10 +1136,13 @@ export class VirtualizedList<ItemT = unknown>
865
1136
  const measure = (event: ISymbioteEvent): void => {
866
1137
  const length = readLayoutLength(event, this.isHorizontal);
867
1138
  if (length === undefined) return;
868
- 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
+ );
869
1143
  // The reducer guards a fixed getItemLayout and a repeated length; a no-op leaves state and the
870
1144
  // view untouched (dispatch marks for check only when something changed).
871
- this.dispatch({ kind: 'measure', index, length });
1145
+ this.dispatch({ kind: 'measure', index, length, offset });
872
1146
  };
873
1147
  this.cellMeasures.set(index, measure);
874
1148
  return measure;
@@ -876,18 +1150,29 @@ export class VirtualizedList<ItemT = unknown>
876
1150
 
877
1151
  // Per-cell onLayout rides the engine's structural (layout) event channel — Angular forbids an
878
1152
  // [onLayout] property binding (NG8002). $event arrives untyped, narrowed before reaching measure.
879
- handleCellLayout(measure: (event: ISymbioteEvent) => void, event: unknown): void {
1153
+ handleCellLayout(
1154
+ measure: (event: ISymbioteEvent) => void,
1155
+ event: unknown,
1156
+ ): void {
880
1157
  if (this.isSymbioteEvent(event)) measure(event);
881
1158
  }
882
1159
 
883
1160
  private isSymbioteEvent(value: unknown): value is ISymbioteEvent {
884
- return typeof value === 'object' && value !== null && 'nativeEvent' in value;
1161
+ return (
1162
+ typeof value === 'object' && value !== null && 'nativeEvent' in value
1163
+ );
885
1164
  }
886
1165
 
887
- private mergeSeparator(gapIndex: number, patch: Partial<ISeparatorProps<unknown>>): void {
1166
+ private mergeSeparator(
1167
+ gapIndex: number,
1168
+ patch: Partial<ISeparatorProps<unknown>>,
1169
+ ): void {
888
1170
  const count = this.listState.metrics.count;
889
1171
  if (!isSeparatorGapInRange(gapIndex, count)) return;
890
- this.separatorOverrides.set(gapIndex, { ...this.separatorOverrides.get(gapIndex), ...patch });
1172
+ this.separatorOverrides.set(gapIndex, {
1173
+ ...this.separatorOverrides.get(gapIndex),
1174
+ ...patch,
1175
+ });
891
1176
  this.cdr.markForCheck();
892
1177
  }
893
1178
 
@@ -903,7 +1188,10 @@ export class VirtualizedList<ItemT = unknown>
903
1188
  this.mergeSeparator(index - 1, { highlighted: false });
904
1189
  this.mergeSeparator(index, { highlighted: false });
905
1190
  },
906
- updateProps: (select: 'leading' | 'trailing', newProps: Record<string, unknown>): void => {
1191
+ updateProps: (
1192
+ select: 'leading' | 'trailing',
1193
+ newProps: Record<string, unknown>,
1194
+ ): void => {
907
1195
  this.mergeSeparator(select === 'leading' ? index - 1 : index, newProps);
908
1196
  },
909
1197
  };
@@ -936,7 +1224,11 @@ export class VirtualizedList<ItemT = unknown>
936
1224
  });
937
1225
  }
938
1226
 
939
- scrollToItem(params: { item: unknown; animated?: boolean; viewPosition?: number }): void {
1227
+ scrollToItem(params: {
1228
+ item: unknown;
1229
+ animated?: boolean;
1230
+ viewPosition?: number;
1231
+ }): void {
940
1232
  this.dispatch({
941
1233
  kind: 'scroll-to-item',
942
1234
  item: params.item,
@@ -946,7 +1238,10 @@ export class VirtualizedList<ItemT = unknown>
946
1238
  }
947
1239
 
948
1240
  scrollToEnd(params?: { animated?: boolean }): void {
949
- this.dispatch({ kind: 'scroll-to-end', animated: params?.animated ?? true });
1241
+ this.dispatch({
1242
+ kind: 'scroll-to-end',
1243
+ animated: params?.animated ?? true,
1244
+ });
950
1245
  }
951
1246
 
952
1247
  flashScrollIndicators(): void {