@symbiote-native/angular 3.1.3 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/babel-register-composed.cjs +1 -0
  2. package/build/angular/accessibility-events.d.ts +12 -0
  3. package/build/angular/accessibility-events.js +28 -0
  4. package/build/angular/accessibility-events.js.map +1 -0
  5. package/build/angular/accessibility-inputs.d.ts +40 -0
  6. package/build/angular/accessibility-inputs.js +130 -0
  7. package/build/angular/accessibility-inputs.js.map +1 -0
  8. package/build/angular/bootstrap.d.ts +3 -1
  9. package/build/angular/bootstrap.js +4 -1
  10. package/build/angular/bootstrap.js.map +1 -1
  11. package/build/angular/components/checkbox-props.d.ts +2 -0
  12. package/build/angular/components/checkbox-props.js +2 -0
  13. package/build/angular/components/checkbox-props.js.map +1 -0
  14. package/build/angular/components/flat-list/flat-list-template.d.ts +1 -0
  15. package/build/angular/components/flat-list/flat-list-template.js +158 -0
  16. package/build/angular/components/flat-list/flat-list-template.js.map +1 -0
  17. package/build/angular/components/flat-list/index.d.ts +6 -61
  18. package/build/angular/components/flat-list/index.js +26 -463
  19. package/build/angular/components/flat-list/index.js.map +1 -1
  20. package/build/angular/components/keyboard-avoiding-view/index.d.ts +4 -40
  21. package/build/angular/components/keyboard-avoiding-view/index.js +7 -131
  22. package/build/angular/components/keyboard-avoiding-view/index.js.map +1 -1
  23. package/build/angular/components/modal/index.d.ts +5 -41
  24. package/build/angular/components/modal/index.js +8 -133
  25. package/build/angular/components/modal/index.js.map +1 -1
  26. package/build/angular/components/section-list/index.d.ts +4 -51
  27. package/build/angular/components/section-list/index.js +18 -335
  28. package/build/angular/components/section-list/index.js.map +1 -1
  29. package/build/angular/components/virtualized-list/cell-registry.d.ts +35 -0
  30. package/build/angular/components/virtualized-list/cell-registry.js +133 -0
  31. package/build/angular/components/virtualized-list/cell-registry.js.map +1 -0
  32. package/build/angular/components/virtualized-list/index.d.ts +18 -253
  33. package/build/angular/components/virtualized-list/index.js +112 -1178
  34. package/build/angular/components/virtualized-list/index.js.map +1 -1
  35. package/build/angular/components/virtualized-list/list-a11y-forward.d.ts +1 -0
  36. package/build/angular/components/virtualized-list/list-a11y-forward.js +42 -0
  37. package/build/angular/components/virtualized-list/list-a11y-forward.js.map +1 -0
  38. package/build/angular/components/virtualized-list/list-bags.d.ts +32 -0
  39. package/build/angular/components/virtualized-list/list-bags.js +120 -0
  40. package/build/angular/components/virtualized-list/list-bags.js.map +1 -0
  41. package/build/angular/components/virtualized-list/list-effects.d.ts +31 -0
  42. package/build/angular/components/virtualized-list/list-effects.js +83 -0
  43. package/build/angular/components/virtualized-list/list-effects.js.map +1 -0
  44. package/build/angular/components/virtualized-list/list-events.d.ts +17 -0
  45. package/build/angular/components/virtualized-list/list-events.js +34 -0
  46. package/build/angular/components/virtualized-list/list-events.js.map +1 -0
  47. package/build/angular/components/virtualized-list/list-handle.d.ts +35 -0
  48. package/build/angular/components/virtualized-list/list-handle.js +61 -0
  49. package/build/angular/components/virtualized-list/list-handle.js.map +1 -0
  50. package/build/angular/components/virtualized-list/list-inputs.d.ts +78 -0
  51. package/build/angular/components/virtualized-list/list-inputs.js +150 -0
  52. package/build/angular/components/virtualized-list/list-inputs.js.map +1 -0
  53. package/build/angular/components/virtualized-list/list-props.d.ts +58 -0
  54. package/build/angular/components/virtualized-list/list-props.js +3 -0
  55. package/build/angular/components/virtualized-list/list-props.js.map +1 -0
  56. package/build/angular/components/virtualized-list/list-styles.d.ts +23 -0
  57. package/build/angular/components/virtualized-list/list-styles.js +26 -0
  58. package/build/angular/components/virtualized-list/list-styles.js.map +1 -0
  59. package/build/angular/components/virtualized-list/list-template.d.ts +1 -0
  60. package/build/angular/components/virtualized-list/list-template.js +176 -0
  61. package/build/angular/components/virtualized-list/list-template.js.map +1 -0
  62. package/build/angular/components/virtualized-section-list/index.d.ts +13 -70
  63. package/build/angular/components/virtualized-section-list/index.js +22 -455
  64. package/build/angular/components/virtualized-section-list/index.js.map +1 -1
  65. package/build/angular/components/virtualized-section-list/section-list-template.d.ts +1 -0
  66. package/build/angular/components/virtualized-section-list/section-list-template.js +107 -0
  67. package/build/angular/components/virtualized-section-list/section-list-template.js.map +1 -0
  68. package/build/angular/components.d.ts +1 -0
  69. package/build/angular/components.js.map +1 -1
  70. package/build/angular/descriptor-to-angular/descriptor-host.d.ts +8 -0
  71. package/build/angular/descriptor-to-angular/descriptor-host.js +38 -0
  72. package/build/angular/descriptor-to-angular/descriptor-host.js.map +1 -0
  73. package/build/angular/descriptor-to-angular/index.d.ts +1 -0
  74. package/build/angular/descriptor-to-angular/index.js +4 -0
  75. package/build/angular/descriptor-to-angular/index.js.map +1 -1
  76. package/build/angular/element-base.d.ts +153 -0
  77. package/build/angular/element-base.js +272 -0
  78. package/build/angular/element-base.js.map +1 -0
  79. package/build/angular/elements-controls.d.ts +165 -0
  80. package/build/angular/elements-controls.js +382 -0
  81. package/build/angular/elements-controls.js.map +1 -0
  82. package/build/angular/elements-layout.d.ts +122 -0
  83. package/build/angular/elements-layout.js +242 -0
  84. package/build/angular/elements-layout.js.map +1 -0
  85. package/build/angular/elements-press.d.ts +96 -0
  86. package/build/angular/elements-press.js +227 -0
  87. package/build/angular/elements-press.js.map +1 -0
  88. package/build/angular/elements.d.ts +6 -516
  89. package/build/angular/elements.js +8 -1091
  90. package/build/angular/elements.js.map +1 -1
  91. package/build/angular/finalization-registry.d.ts +4 -0
  92. package/build/angular/finalization-registry.js +17 -0
  93. package/build/angular/finalization-registry.js.map +1 -0
  94. package/build/angular/host-instance/index.d.ts +2 -0
  95. package/build/angular/host-instance/index.js +4 -4
  96. package/build/angular/host-instance/index.js.map +1 -1
  97. package/build/angular/index.d.ts +9 -4
  98. package/build/angular/index.js +8 -3
  99. package/build/angular/index.js.map +1 -1
  100. package/build/angular/native-view-base.d.ts +27 -0
  101. package/build/angular/native-view-base.js +69 -0
  102. package/build/angular/native-view-base.js.map +1 -0
  103. package/build/angular/render/index.d.ts +2 -1
  104. package/build/angular/render/index.js +46 -73
  105. package/build/angular/render/index.js.map +1 -1
  106. package/build/angular/services/connect-watched-signal.d.ts +2 -0
  107. package/build/angular/services/connect-watched-signal.js +9 -0
  108. package/build/angular/services/connect-watched-signal.js.map +1 -0
  109. package/build/angular/services/create-event-value-hook.d.ts +3 -0
  110. package/build/angular/services/create-event-value-hook.js +17 -0
  111. package/build/angular/services/create-event-value-hook.js.map +1 -0
  112. package/build/angular/services/create-resource-hook.d.ts +7 -0
  113. package/build/angular/services/create-resource-hook.js +20 -0
  114. package/build/angular/services/create-resource-hook.js.map +1 -0
  115. package/build/angular/services/index.d.ts +5 -0
  116. package/build/angular/services/index.js +5 -0
  117. package/build/angular/services/index.js.map +1 -1
  118. package/build/angular/services/inject-event.d.ts +6 -0
  119. package/build/angular/services/inject-event.js +24 -0
  120. package/build/angular/services/inject-event.js.map +1 -0
  121. package/build/angular/services/permissions-service-base.d.ts +14 -0
  122. package/build/angular/services/permissions-service-base.js +38 -0
  123. package/build/angular/services/permissions-service-base.js.map +1 -0
  124. package/package.json +6 -6
  125. package/src/accessibility-events.ts +13 -0
  126. package/src/accessibility-inputs.ts +89 -0
  127. package/src/bootstrap.ts +6 -2
  128. package/src/components/checkbox-props.ts +12 -0
  129. package/src/components/flat-list/flat-list-template.ts +159 -0
  130. package/src/components/flat-list/index.ts +30 -307
  131. package/src/components/keyboard-avoiding-view/index.ts +4 -78
  132. package/src/components/modal/index.ts +7 -80
  133. package/src/components/section-list/index.ts +21 -172
  134. package/src/components/virtualized-list/cell-registry.ts +199 -0
  135. package/src/components/virtualized-list/index.ts +145 -1181
  136. package/src/components/virtualized-list/list-a11y-forward.ts +42 -0
  137. package/src/components/virtualized-list/list-bags.ts +142 -0
  138. package/src/components/virtualized-list/list-effects.ts +116 -0
  139. package/src/components/virtualized-list/list-events.ts +31 -0
  140. package/src/components/virtualized-list/list-handle.ts +92 -0
  141. package/src/components/virtualized-list/list-inputs.ts +149 -0
  142. package/src/components/virtualized-list/list-props.ts +81 -0
  143. package/src/components/virtualized-list/list-styles.ts +62 -0
  144. package/src/components/virtualized-list/list-template.ts +175 -0
  145. package/src/components/virtualized-section-list/index.ts +40 -351
  146. package/src/components/virtualized-section-list/section-list-template.ts +108 -0
  147. package/src/components.ts +1 -0
  148. package/src/descriptor-to-angular/descriptor-host.ts +27 -0
  149. package/src/descriptor-to-angular/index.ts +5 -0
  150. package/src/element-base.ts +272 -0
  151. package/src/elements-controls.ts +282 -0
  152. package/src/elements-layout.ts +141 -0
  153. package/src/elements-press.ts +145 -0
  154. package/src/elements.ts +73 -788
  155. package/src/finalization-registry.ts +18 -0
  156. package/src/host-instance/index.ts +4 -4
  157. package/src/index.ts +18 -2
  158. package/src/native-view-base.ts +86 -0
  159. package/src/render/index.ts +76 -77
  160. package/src/services/connect-watched-signal.ts +15 -0
  161. package/src/services/create-event-value-hook.ts +24 -0
  162. package/src/services/create-resource-hook.ts +32 -0
  163. package/src/services/index.ts +5 -0
  164. package/src/services/inject-event.ts +52 -0
  165. package/src/services/permissions-service-base.ts +46 -0
@@ -1,91 +1,40 @@
1
- // VirtualizedList, the Angular lifecycle half. The windowing engine (offset table, window
2
- // compute, batch throttle, viewability, edge-reached, the child PLAN, the imperative-handle
3
- // geometry) lives in @symbiote-native/components/state, shared verbatim with the React and Vue adapters
4
- // so every adapter keeps the same feature surface. Here Angular supplies only the lifecycle: plain fields
5
- // for scroll offset / viewport / measurement bumps, a once-per-CD metrics + view recompute in
6
- // ngDoCheck (the Angular twin of React render / Vue `computed` — it owns the controlled
7
- // committedWindow throttle and assembles the windowed cells before the template bindings are read),
8
- // and the after-commit work (batch fill, onEndReached/onStartReached, viewability, initialScroll, MVCP)
9
- // in ngAfterViewChecked. OnPush + ChangeDetectorRef.markForCheck() drives re-render off the native
10
- // scroll/layout/measure callbacks (they fire outside Angular's own event bindings, so they must
11
- // mark the view dirty). This is the Angular twin of the React useReducer/useEffect and Vue
12
- // ref/computed/watch over the same shared functions. It writes the `scroll-view`/
13
- // `horizontal-scroll-view` TAG directly, exactly as the React/Vue lists drive their scroll tag.
14
- //
15
- // Per-item rendering is TEMPLATES, not a callback: React/Vue `renderItem: (info) => element` does
16
- // not translate to Angular. The app supplies the cell via `<ng-template vListItem>` (and the
17
- // header/footer/empty/separator slots via vListHeader/vListFooter/vListEmpty/vListSeparator); the
18
- // list captures them with @ContentChild and stamps the WINDOWED slice through VListOutletDirective
19
- // (a core-only NgTemplateOutlet twin — @angular/common is not a dependency). See ./directives.ts.
20
- // Every React/Vue behavior is present (windowing, onEndReached, viewability, headers/footers/empty/
21
- // separators, refresh, imperative scroll, horizontal, inverted, MVCP); only the cell AUTHORING
22
- // shape differs, since a callback prop returning an element has no Angular equivalent.
23
- //
24
- // Lists have no Descriptor render fn — the cell content is the framework's own children.
25
- // Cells/spacers are plain view host elements.
1
+ // Angular lifecycle half of VirtualizedList, the windowing engine is shared with React and Vue
2
+ // The bound surface is in ./list-inputs.ts, cells in ./cell-registry.ts, effects in
3
+ // ./list-effects.ts, styles in ./list-styles.ts and the authoring templates in ./directives.ts
26
4
 
27
5
  import {
28
6
  CUSTOM_ELEMENTS_SCHEMA,
29
7
  ChangeDetectionStrategy,
30
8
  ChangeDetectorRef,
31
9
  Component,
32
- ContentChild,
33
10
  ElementRef,
34
- EventEmitter,
35
- Input,
36
- Output,
37
- TemplateRef,
38
11
  ViewChild,
39
- computed,
40
12
  inject,
41
- signal,
42
13
  type AfterViewChecked,
43
14
  type DoCheck,
44
- type OnChanges,
45
15
  type OnDestroy,
46
16
  } from '@angular/core';
47
17
  import {
48
- DEFAULT_INITIAL_NUM_TO_RENDER,
49
- DEFAULT_MAX_TO_RENDER_PER_BATCH,
50
- DEFAULT_UPDATE_CELLS_BATCHING_PERIOD,
51
- DEFAULT_WINDOW_SIZE,
52
18
  EMPTY_OFFSET,
53
19
  FIRST_INDEX,
54
- INVERTED_X_STYLE,
55
- INVERTED_Y_STYLE,
20
+ LIST_ACTION_KIND,
56
21
  buildListPlan,
22
+ buildScrollViewHandle,
57
23
  buildViewabilityPairs,
58
24
  computeWindow,
59
25
  createInitialListState,
60
- isSeparatorGapInRange,
61
26
  listEffectSignature,
62
- readLayoutLength,
63
- readLayoutOffset,
64
- readScrollOffset,
65
27
  reduceList,
66
- resolveAccessibilityProps,
67
28
  resolveItemKey,
68
- type IAccessibilityProps,
69
- type IAccessibilityStateValue,
70
- type IAriaProps,
71
29
  type IListAction,
72
- type IListEffect,
73
30
  type IListReducerInputs,
74
31
  type IListState,
75
- type ISeparatorProps,
76
- type ISeparators,
77
32
  type IScrollViewHandle,
78
- type IViewabilityConfig,
79
- type IViewabilityConfigCallbackPair,
80
33
  type IViewableItemsChangedInfo,
81
- type IVirtualizedListHandle,
82
- buildScrollViewHandle,
83
34
  } from '@symbiote-native/components';
84
35
  import {
85
36
  dlog,
86
- flattenStyle,
87
37
  isSymbioteNode,
88
- type IStyleProp,
89
38
  type ISymbioteEvent,
90
39
  type ISymbioteNode,
91
40
  type IViewStyle,
@@ -97,19 +46,15 @@ import {
97
46
  SymbioteStyleInputDirective,
98
47
  ViewHost,
99
48
  } from '../../primitives';
100
- import {
101
- VListEmptyDirective,
102
- VListFooterDirective,
103
- VListHeaderDirective,
104
- VListItemDirective,
105
- VListOutletDirective,
106
- VListSeparatorDirective,
107
- type IVListItemContext,
108
- type IVListSeparatorContext,
109
- } from './directives';
110
-
111
- // Re-export the shared list types + the authoring directives so flat-list / section-list keep
112
- // importing them from '../virtualized-list', exactly as the React/Vue adapters re-export them.
49
+ import { CellRegistry, type IWindowCell } from './cell-registry';
50
+ import { VListOutletDirective } from './directives';
51
+ import { ListEffectRunner } from './list-effects';
52
+ import { VirtualizedListBagsBase } from './list-bags';
53
+ import type { IVirtualizedListInputs } from './list-props';
54
+ import { resolveListStyles } from './list-styles';
55
+ import { VIRTUALIZED_LIST_TEMPLATE } from './list-template';
56
+
57
+ // Re-exported so flat-list and section-list keep importing them from '../virtualized-list'
113
58
  export type {
114
59
  ICellLayout,
115
60
  ISeparators,
@@ -127,109 +72,15 @@ export {
127
72
  VListItemDirective,
128
73
  VListSeparatorDirective,
129
74
  } from './directives';
130
- import {
131
- gateWanted,
132
- injectGateDemandAbove,
133
- type IGatedAccessibilityEvent,
134
- } from '../../gate-demand';
135
75
  export type { IVListItemContext, IVListSeparatorContext } from './directives';
136
76
 
137
- // The Angular VirtualizedList prop surface. Mirrors React/Vue's IVirtualizedListProps MINUS the
138
- // element-returning props (renderItem, ListHeader/Footer/Empty Component, ItemSeparatorComponent):
139
- // those are the per-adapter children/render fields and become `<ng-template>` directives in Angular.
140
- // Everything agnostic is the SAME surface.
141
- export interface IVirtualizedListProps<ItemT>
142
- extends IAccessibilityProps, IAriaProps {
143
- data: unknown;
144
- getItem: (data: unknown, index: number) => ItemT;
145
- getItemCount: (data: unknown) => number;
146
- keyExtractor?: (item: ItemT, index: number) => string;
147
- getItemLayout?: (
148
- data: unknown,
149
- index: number,
150
- ) => { length: number; offset: number; index: number };
151
- horizontal?: boolean;
152
- inverted?: boolean;
153
- extraData?: unknown;
154
- onEndReached?: (info: { distanceFromEnd: number }) => void;
155
- onEndReachedThreshold?: number;
156
- onStartReached?: (info: { distanceFromStart: number }) => void;
157
- onStartReachedThreshold?: number;
158
- onRefresh?: () => void;
159
- refreshing?: boolean | null;
160
- progressViewOffset?: number;
161
- onViewableItemsChanged?: (info: IViewableItemsChangedInfo<ItemT>) => void;
162
- viewabilityConfig?: IViewabilityConfig;
163
- viewabilityConfigCallbackPairs?: IViewabilityConfigCallbackPair<ItemT>[];
164
- onScrollToIndexFailed?: (info: {
165
- index: number;
166
- highestMeasuredFrameIndex: number;
167
- averageItemLength: number;
168
- }) => void;
169
- initialNumToRender?: number;
170
- initialScrollIndex?: number;
171
- maxToRenderPerBatch?: number;
172
- updateCellsBatchingPeriod?: number;
173
- windowSize?: number;
174
- stickyHeaderIndices?: number[];
175
- maintainVisibleContentPosition?: {
176
- minIndexForVisible: number;
177
- autoscrollToTopThreshold?: number;
178
- };
179
- onScroll?: (event: ISymbioteEvent) => void;
180
- onScrollBeginDrag?: (event: ISymbioteEvent) => void;
181
- onScrollEndDrag?: (event: ISymbioteEvent) => void;
182
- onMomentumScrollBegin?: (event: ISymbioteEvent) => void;
183
- onMomentumScrollEnd?: (event: ISymbioteEvent) => void;
184
- scrollEventThrottle?: number;
185
- keyboardShouldPersistTaps?: boolean | 'always' | 'never' | 'handled';
186
- keyboardDismissMode?: 'none' | 'on-drag' | 'interactive';
187
- removeClippedSubviews?: boolean;
188
- nestedScrollEnabled?: boolean;
189
- style?: IStyleProp<IViewStyle>;
190
- contentContainerStyle?: IStyleProp<IViewStyle>;
191
- }
192
-
193
- // What the VirtualizedList component itself takes as plain @Input()s: the full surface minus the
194
- // events it exposes as real @Output() EventEmitters instead (see the class below), mirroring how
195
- // pressable/index.ts derives IAngularPressableInputs from IAngularPressableProps.
196
- export type IVirtualizedListInputs<ItemT> = Omit<
197
- IVirtualizedListProps<ItemT>,
198
- | 'onEndReached'
199
- | 'onStartReached'
200
- | 'onRefresh'
201
- | 'onViewableItemsChanged'
202
- | 'onScrollToIndexFailed'
203
- | 'onAccessibilityAction'
204
- | 'onAccessibilityTap'
205
- | 'onMagicTap'
206
- | 'onAccessibilityEscape'
207
- >;
77
+ type IListPlan = ReturnType<typeof buildListPlan>;
208
78
 
209
- // One in-window cell, assembled in ngDoCheck and stamped by the template's @for. The
210
- // context object is fresh each pass; VListOutletDirective folds it onto the live embedded view, so
211
- // the cell view is reused (the windowing recompute does not tear cells down).
212
- interface IWindowCell<ItemT> {
213
- key: string;
214
- index: number;
215
- context: IVListItemContext<ItemT>;
216
- measure: (event: ISymbioteEvent) => void;
217
- separatorContext?: IVListSeparatorContext<ItemT>;
218
- /**
219
- * Picks the cell's TAG in the template — `sticky-header` pins, `view` does not.
220
- *
221
- * It is stable per cell key (an index is a section header or it is not), so the `@if` never
222
- * swaps an element type under a live cell and nothing remounts as the window slides.
223
- */
224
- isSticky: boolean;
225
- }
79
+ export type {
80
+ IVirtualizedListInputs,
81
+ IVirtualizedListProps,
82
+ } from './list-props';
226
83
 
227
- // The scroll body is identical on both axes; only the outer tag differs (the axis comes from the
228
- // TAG, never a prop — `<horizontal-scroll-view>` vs `<scroll-view>`, matching every other adapter,
229
- // `.claude/rules/host-primitive-tier.md`). No shared-outlet primitive exists to de-duplicate this
230
- // (this package's own NgTemplateOutlet twin is keyed on a per-cell context, not a static block, and
231
- // ngc's partial evaluator needs the decorator's `template` to be one literal), so the body is
232
- // duplicated across the two branches — the same shape Svelte's own `{#if horizontal}` takes.
233
84
  @Component({
234
85
  selector: 'VirtualizedList',
235
86
  standalone: true,
@@ -239,641 +90,71 @@ interface IWindowCell<ItemT> {
239
90
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
240
91
  imports: [SymbioteHostPropsDirective, VListOutletDirective, ViewHost],
241
92
  changeDetection: ChangeDetectionStrategy.OnPush,
242
- template: `
243
- @if (isHorizontal) {
244
- <horizontal-scroll-view #scrollHost [symbioteHostProps]="scrollViewBag()">
245
- @if (shouldRenderRefreshControl) {
246
- <refresh-control
247
- [symbioteHostProps]="refreshControlBag()"
248
- ></refresh-control>
249
- }
250
-
251
- @if (headerDir !== undefined) {
252
- <view>
253
- <ng-container [vListOutlet]="headerDir.templateRef"></ng-container>
254
- </view>
255
- }
256
-
257
- @if (itemCount === 0) {
258
- @if (emptyDir !== undefined) {
259
- <view>
260
- <ng-container [vListOutlet]="emptyDir.templateRef"></ng-container>
261
- </view>
262
- }
263
- } @else {
264
- @if (leadingSpacerStyle !== null) {
265
- <view [style]="leadingSpacerStyle"></view>
266
- }
267
- @if (forcedStickyCell !== null) {
268
- <sticky-header
269
- (layout)="handleCellLayout(forcedStickyCell.measure, $event)"
270
- [style]="cellStyle"
271
- >
272
- <ng-container
273
- [vListOutlet]="cellTemplate"
274
- [vListOutletContext]="forcedStickyCell.context"
275
- ></ng-container>
276
- </sticky-header>
277
- }
278
- @if (gapSpacerStyle !== null) {
279
- <view [style]="gapSpacerStyle"></view>
280
- }
281
- @for (cell of windowCells; track cell.key) {
282
- @if (cell.isSticky) {
283
- <sticky-header
284
- (layout)="handleCellLayout(cell.measure, $event)"
285
- [style]="cellStyle"
286
- >
287
- <ng-container
288
- [vListOutlet]="cellTemplate"
289
- [vListOutletContext]="cell.context"
290
- ></ng-container>
291
- @if (cell.separatorContext !== undefined) {
292
- <ng-container
293
- [vListOutlet]="separatorTemplate"
294
- [vListOutletContext]="cell.separatorContext"
295
- ></ng-container>
296
- }
297
- </sticky-header>
298
- } @else {
299
- <view
300
- (layout)="handleCellLayout(cell.measure, $event)"
301
- [style]="cellStyle"
302
- >
303
- <ng-container
304
- [vListOutlet]="cellTemplate"
305
- [vListOutletContext]="cell.context"
306
- ></ng-container>
307
- @if (cell.separatorContext !== undefined) {
308
- <ng-container
309
- [vListOutlet]="separatorTemplate"
310
- [vListOutletContext]="cell.separatorContext"
311
- ></ng-container>
312
- }
313
- </view>
314
- }
315
- }
316
- @if (trailingSpacerStyle !== null) {
317
- <view [style]="trailingSpacerStyle"></view>
318
- }
319
- }
320
-
321
- @if (footerDir !== undefined) {
322
- <view>
323
- <ng-container [vListOutlet]="footerDir.templateRef"></ng-container>
324
- </view>
325
- }
326
- </horizontal-scroll-view>
327
- } @else {
328
- <scroll-view #scrollHost [symbioteHostProps]="scrollViewBag()">
329
- @if (shouldRenderRefreshControl) {
330
- <refresh-control
331
- [symbioteHostProps]="refreshControlBag()"
332
- ></refresh-control>
333
- }
334
-
335
- @if (headerDir !== undefined) {
336
- <view>
337
- <ng-container [vListOutlet]="headerDir.templateRef"></ng-container>
338
- </view>
339
- }
340
-
341
- @if (itemCount === 0) {
342
- @if (emptyDir !== undefined) {
343
- <view>
344
- <ng-container [vListOutlet]="emptyDir.templateRef"></ng-container>
345
- </view>
346
- }
347
- } @else {
348
- @if (leadingSpacerStyle !== null) {
349
- <view [style]="leadingSpacerStyle"></view>
350
- }
351
- @if (forcedStickyCell !== null) {
352
- <sticky-header
353
- (layout)="handleCellLayout(forcedStickyCell.measure, $event)"
354
- [style]="cellStyle"
355
- >
356
- <ng-container
357
- [vListOutlet]="cellTemplate"
358
- [vListOutletContext]="forcedStickyCell.context"
359
- ></ng-container>
360
- </sticky-header>
361
- }
362
- @if (gapSpacerStyle !== null) {
363
- <view [style]="gapSpacerStyle"></view>
364
- }
365
- <!-- Separator sits INSIDE the measuring view, matching RN's cell renderer
366
- (VirtualizedListCellRenderer.js:218-221) — as a sibling it'd add an extra flex gap.
367
- See list-geometry-feedback-loop.md. -->
368
- @for (cell of windowCells; track cell.key) {
369
- @if (cell.isSticky) {
370
- <sticky-header
371
- (layout)="handleCellLayout(cell.measure, $event)"
372
- [style]="cellStyle"
373
- >
374
- <ng-container
375
- [vListOutlet]="cellTemplate"
376
- [vListOutletContext]="cell.context"
377
- ></ng-container>
378
- @if (cell.separatorContext !== undefined) {
379
- <ng-container
380
- [vListOutlet]="separatorTemplate"
381
- [vListOutletContext]="cell.separatorContext"
382
- ></ng-container>
383
- }
384
- </sticky-header>
385
- } @else {
386
- <view
387
- (layout)="handleCellLayout(cell.measure, $event)"
388
- [style]="cellStyle"
389
- >
390
- <ng-container
391
- [vListOutlet]="cellTemplate"
392
- [vListOutletContext]="cell.context"
393
- ></ng-container>
394
- @if (cell.separatorContext !== undefined) {
395
- <ng-container
396
- [vListOutlet]="separatorTemplate"
397
- [vListOutletContext]="cell.separatorContext"
398
- ></ng-container>
399
- }
400
- </view>
401
- }
402
- }
403
- @if (trailingSpacerStyle !== null) {
404
- <view [style]="trailingSpacerStyle"></view>
405
- }
406
- }
407
-
408
- @if (footerDir !== undefined) {
409
- <view>
410
- <ng-container [vListOutlet]="footerDir.templateRef"></ng-container>
411
- </view>
412
- }
413
- </scroll-view>
414
- }
415
- `,
93
+ template: VIRTUALIZED_LIST_TEMPLATE,
416
94
  })
417
95
  export class VirtualizedList<ItemT = unknown>
418
- implements
419
- IVirtualizedListInputs<ItemT>,
420
- IVirtualizedListHandle,
421
- DoCheck,
422
- AfterViewChecked,
423
- OnChanges,
424
- OnDestroy
96
+ extends VirtualizedListBagsBase<ItemT>
97
+ implements IVirtualizedListInputs<ItemT>, DoCheck, AfterViewChecked, OnDestroy
425
98
  {
426
- // The list's edge/viewability/failure events as real Angular events: `(endReached)="…"`, not
427
- // `[onEndReached]="…"`. See handleRefresh/accessibility*Tick below for how the still-callback-
428
- // shaped scroll-view/refresh-control bag keys are fed from these.
429
- @Output() readonly endReached = new EventEmitter<{
430
- distanceFromEnd: number;
431
- }>();
432
- @Output() readonly startReached = new EventEmitter<{
433
- distanceFromStart: number;
434
- }>();
435
- @Output() readonly refresh = new EventEmitter<void>();
436
- @Output() readonly viewableItemsChanged = new EventEmitter<
437
- IViewableItemsChangedInfo<ItemT>
438
- >();
439
- @Output() readonly scrollToIndexFailed = new EventEmitter<{
440
- index: number;
441
- highestMeasuredFrameIndex: number;
442
- averageItemLength: number;
443
- }>();
444
- @Output() readonly accessibilityAction = new EventEmitter<ISymbioteEvent>();
445
- @Output() readonly accessibilityTap = new EventEmitter<ISymbioteEvent>();
446
- @Output() readonly magicTap = new EventEmitter<ISymbioteEvent>();
447
- @Output() readonly accessibilityEscape = new EventEmitter<ISymbioteEvent>();
448
-
449
- // This wrapper binds the four gated accessibility events on the component it renders, which
450
- // Angular forces to be unconditional and which would light that component's gates on every
451
- // instance. It answers for them instead — see `gate-demand.ts`.
452
- private readonly gateDemandAbove = injectGateDemandAbove();
453
-
454
- wantsGate(name: IGatedAccessibilityEvent): boolean {
455
- return gateWanted(this.gateDemandAbove, name, this[name]);
456
- }
457
-
458
- @Input({ required: true }) data!: unknown;
459
- @Input({ required: true }) getItem!: (data: unknown, index: number) => ItemT;
460
- @Input({ required: true }) getItemCount!: (data: unknown) => number;
461
- @Input() keyExtractor?: (item: ItemT, index: number) => string;
462
- @Input() getItemLayout?: (
463
- data: unknown,
464
- index: number,
465
- ) => { length: number; offset: number; index: number };
466
- @Input() horizontal?: boolean;
467
- @Input() inverted?: boolean;
468
- @Input() extraData?: unknown;
469
- @Input() onEndReachedThreshold?: number;
470
- @Input() onStartReachedThreshold?: number;
471
- @Input() refreshing?: boolean | null;
472
- @Input() progressViewOffset?: number;
473
- // Set explicitly by a wrapper (FlatList/VirtualizedSectionList) that ALWAYS binds
474
- // `(refresh)="refresh.emit()"` on this component to re-forward the event outward — that binding
475
- // itself makes `this.refresh.observed` permanently true (Angular subscribes unconditionally the
476
- // moment a template writes `(refresh)="…"`, regardless of what the handler does), so `.observed`
477
- // can no longer tell "the app actually wants pull-to-refresh" from "a wrapper is just forwarding
478
- // the event". A wrapper passes its OWN public `refresh.observed` here instead; direct usage
479
- // (no wrapper) falls back to this component's own `.observed`, unchanged from before.
480
- @Input() refreshRequested?: boolean;
481
- @Input() viewabilityConfig?: IViewabilityConfig;
482
- @Input()
483
- viewabilityConfigCallbackPairs?: IViewabilityConfigCallbackPair<ItemT>[];
484
- @Input() initialNumToRender?: number;
485
- @Input() initialScrollIndex?: number;
486
- @Input() maxToRenderPerBatch?: number;
487
- @Input() updateCellsBatchingPeriod?: number;
488
- @Input() windowSize?: number;
489
- @Input() stickyHeaderIndices?: number[];
490
- @Input() maintainVisibleContentPosition?: {
491
- minIndexForVisible: number;
492
- autoscrollToTopThreshold?: number;
493
- };
494
- @Input() onScroll?: (event: ISymbioteEvent) => void;
495
- @Input() onScrollBeginDrag?: (event: ISymbioteEvent) => void;
496
- @Input() onScrollEndDrag?: (event: ISymbioteEvent) => void;
497
- @Input() onMomentumScrollBegin?: (event: ISymbioteEvent) => void;
498
- @Input() onMomentumScrollEnd?: (event: ISymbioteEvent) => void;
499
- @Input() scrollEventThrottle?: number;
500
- @Input() keyboardShouldPersistTaps?: boolean | 'always' | 'never' | 'handled';
501
- @Input() keyboardDismissMode?: 'none' | 'on-drag' | 'interactive';
502
- @Input() removeClippedSubviews?: boolean;
503
- @Input() nestedScrollEnabled?: boolean;
504
- @Input() style?: IStyleProp<IViewStyle>;
505
- @Input() contentContainerStyle?: IStyleProp<IViewStyle>;
506
- @Input() testID?: string;
507
- @Input() nativeID?: string;
508
- @Input() accessible?: boolean;
509
- @Input() accessibilityLabel?: string;
510
- @Input() accessibilityHint?: string;
511
- @Input() accessibilityRole?: IAccessibilityProps['accessibilityRole'];
512
- @Input() accessibilityState?: IAccessibilityStateValue;
513
- @Input() accessibilityValue?: IAccessibilityProps['accessibilityValue'];
514
- @Input() accessibilityActions?: IAccessibilityProps['accessibilityActions'];
515
- @Input() accessibilityLabelledBy?: string | string[];
516
- @Input()
517
- importantForAccessibility?: IAccessibilityProps['importantForAccessibility'];
518
- @Input()
519
- accessibilityLiveRegion?: IAccessibilityProps['accessibilityLiveRegion'];
520
- @Input() screenReaderFocusable?: boolean;
521
- @Input() accessibilityViewIsModal?: boolean;
522
- @Input() accessibilityElementsHidden?: boolean;
523
- @Input() accessibilityIgnoresInvertColors?: boolean;
524
- @Input() accessibilityLanguage?: string;
525
- @Input() accessibilityRespondsToUserInteraction?: boolean;
526
- @Input() accessibilityShowsLargeContentViewer?: boolean;
527
- @Input() accessibilityLargeContentTitle?: string;
528
- @Input() role?: IAriaProps['role'];
529
- @Input() ariaLabel?: string;
530
- @Input() ariaLabelledBy?: string;
531
- @Input() ariaLive?: IAriaProps['aria-live'];
532
- @Input() ariaHidden?: boolean;
533
- @Input() ariaBusy?: boolean;
534
- @Input() ariaChecked?: boolean | 'mixed';
535
- @Input() ariaDisabled?: boolean;
536
- @Input() ariaExpanded?: boolean;
537
- @Input() ariaSelected?: boolean;
538
- @Input() ariaModal?: boolean;
539
- @Input() ariaValueMax?: number;
540
- @Input() ariaValueMin?: number;
541
- @Input() ariaValueNow?: number;
542
- @Input() ariaValueText?: string;
543
-
544
- // The cell + slot templates the app authors (the Angular twin of renderItem / ListHeaderComponent
545
- // / ListFooterComponent / ListEmptyComponent / ItemSeparatorComponent), captured from projected
546
- // <ng-template> content. Resolved by ngAfterContentInit, read each ngDoCheck (from the 2nd CD on).
547
- @ContentChild(VListItemDirective) itemDir?: VListItemDirective<ItemT>;
548
- @ContentChild(VListHeaderDirective) headerDir?: VListHeaderDirective;
549
- @ContentChild(VListFooterDirective) footerDir?: VListFooterDirective;
550
- @ContentChild(VListEmptyDirective) emptyDir?: VListEmptyDirective;
551
- @ContentChild(VListSeparatorDirective)
552
- separatorDir?: VListSeparatorDirective<ItemT>;
553
-
554
- // A wrapping list (FlatList) hands its app's cell/separator templates straight in, so a cell is
555
- // one outlet deep instead of a wrapper outlet around the app's own; each wins over projection.
556
- @Input() itemTemplate?: TemplateRef<IVListItemContext<ItemT>>;
557
- @Input() itemSeparatorTemplate?: TemplateRef<IVListSeparatorContext<ItemT>>;
558
-
559
- get cellTemplate(): TemplateRef<IVListItemContext<ItemT>> | undefined {
560
- return this.itemTemplate ?? this.itemDir?.templateRef;
561
- }
562
-
563
- get separatorTemplate():
564
- TemplateRef<IVListSeparatorContext<ItemT>> | undefined {
565
- return this.itemSeparatorTemplate ?? this.separatorDir?.templateRef;
566
- }
567
-
568
- // The inner scroll TAG's own engine node — a template ref on a bare intrinsic hands back the
569
- // host node directly (`isSymbioteNode(elementRef.nativeElement)`, matching `anchorHostStyle`'s
570
- // own reader below), so there is no wrapper component left to read a handle off. Present from
571
- // whichever branch (`scroll-view` / `horizontal-scroll-view`) is currently mounted, once the
572
- // template has run at least one CD pass.
99
+ // A template ref on a bare intrinsic hands back the host node directly
573
100
  @ViewChild('scrollHost', { read: ElementRef })
574
101
  private scrollHostRef?: ElementRef<unknown>;
575
102
 
576
- private get scrollNode(): ISymbioteNode | null {
103
+ protected get scrollNode(): ISymbioteNode | null {
577
104
  const node = this.scrollHostRef?.nativeElement;
578
105
  return isSymbioteNode(node) ? node : null;
579
106
  }
580
107
 
581
- // `buildScrollViewHandle` reads the node lazily through the getter above and no-ops before
582
- // commit — the same function every other adapter feeds from its own host-instance accessor.
583
- private readonly scrollHandle: IScrollViewHandle = buildScrollViewHandle(
108
+ protected readonly scrollHandle: IScrollViewHandle = buildScrollViewHandle(
584
109
  () => this.scrollNode,
585
110
  );
586
111
 
587
- // --- template-bound view state, assembled in ngDoCheck (recomputeView) ---
112
+ // Template-bound view state, assembled by `recomputeView` in `ngDoCheck`
588
113
  itemCount = EMPTY_OFFSET;
589
114
  windowCells: IWindowCell<ItemT>[] = [];
590
- // The nearest sticky-header cell force-mounted outside [first,last] (plan.forcedStickyCell), the
591
- // Angular twin of RN's _ensureClosestStickyHeader. Rendered ahead of windowCells, right after the
592
- // leading spacer, with gapSpacerStyle filling the gap to the window's own first cell — see
593
- // buildListPlan's doc comment for the exact child order this assumes.
115
+ // The nearest sticky cell mounted outside the window, `gapSpacerStyle` fills up to the window
594
116
  forcedStickyCell: IWindowCell<ItemT> | null = null;
595
117
  leadingSpacerStyle: IViewStyle | null = null;
596
118
  gapSpacerStyle: IViewStyle | null = null;
597
119
  trailingSpacerStyle: IViewStyle | null = null;
598
120
  cellStyle: IViewStyle | undefined = undefined;
599
- // Bound to the template's `[style]="resolvedStyle"`, which Angular compiles to the built-in
600
- // ɵɵstyleMap instruction — it only understands a flat object, never an array (RN's own
601
- // `style={[a, b]}` composition idiom crashes deep inside Angular's styling engine), so this
602
- // is always pre-flattened via the engine's own flattenStyle, never left as an array.
603
- resolvedStyle: IViewStyle | undefined = undefined;
604
- resolvedContentContainerStyle: IStyleProp<IViewStyle> | undefined = undefined;
605
- resolvedMaintainVisibleContentPosition:
606
- | { minIndexForVisible: number; autoscrollToTopThreshold?: number }
607
- | undefined = undefined;
608
- // The offset we are imperatively driving native to before the scroll handle attaches (rides down
609
- // as contentOffset). A fresh object identity each push so the commit re-applies a repeated value.
610
- commandedOffset: { x: number; y: number } | undefined = undefined;
611
121
 
612
- // The one folded state cell (the Angular twin of React's stateRef): scroll offset, viewport,
613
- // measured lengths, committed window, edge/viewability dedup, MVCP anchor — all in IListState,
614
- // driven by the shared reduceList. `renderVersion` bumps whenever a transition changes render
615
- // state, so ngDoCheck's recompute-dedup can detect it (listState is a plain object, not tracked).
122
+ // `renderVersion` bumps on every transition that changes render state, for the recompute dedup
616
123
  private readonly listState: IListState<ItemT> =
617
124
  createInitialListState<ItemT>();
618
125
  private renderVersion = EMPTY_OFFSET;
619
126
 
620
- private readonly cellMeasures = new Map<
621
- number,
622
- (event: ISymbioteEvent) => void
623
- >();
624
- // A cell's context object is the ONLY thing its stamped view reads, and VListOutletDirective
625
- // refreshes that view whenever the object's IDENTITY changes. recomputeView runs on every frame
626
- // the window moves, so minting a fresh context per cell made a four-row slide cost a refresh of
627
- // EVERY cell in the window. MEASURED on PATH B geometry (544 entries, 320px viewport, RN
628
- // defaults), per fling frame: 233-cell window, 4 cells genuinely entering, 228 outlet updates —
629
- // each one a copyContextFields plus a markForCheck that walks to the root. Solid, same geometry,
630
- // same shared reducer, same engine output: 4. Handing back the SAME object for a cell whose item
631
- // and index did not move keeps `[vListOutletContext]` reference-equal, so ngOnChanges never fires
632
- // for it. The fast adapters get this by construction — Solid's <For> is keyed on the cell KEY
633
- // strings precisely so "the plan's freshly-built cell objects" cannot rebuild every row
634
- // (adapters/solid/src/components/virtualized-list/shared.tsx, the cellKeys memo).
635
- private cellContexts = new Map<string, IVListItemContext<ItemT>>();
636
- private nextCellContexts = new Map<string, IVListItemContext<ItemT>>();
637
- // Everything a cell's CONTENT can depend on that is not its own item identity. RN's contract for
638
- // "re-render the cells, the data object itself did not change" is extraData; without it here an
639
- // extraData flip would reuse every context and repaint nothing.
640
- private cellContextEpoch: unknown[] | undefined = undefined;
641
- private readonly separatorOverrides = new Map<
642
- number,
643
- Partial<ISeparatorProps<unknown>>
644
- >();
645
- // The adapter-owned debounce (minimumViewTime) and incremental-fill timers; the reducer only
646
- // hands back a delay.
647
- private viewableTimer: ReturnType<typeof setTimeout> | null = null;
648
- private batchTimer: ReturnType<typeof setTimeout> | null = null;
649
- // Dedupes the after-commit effects: they run only when the windowing signature changed.
127
+ private readonly cells = new CellRegistry<ItemT>({
128
+ itemAt: index => this.getItem(this.data, index),
129
+ itemCount: () => this.listState.metrics.count,
130
+ isHorizontal: () => this.isHorizontal,
131
+ markForCheck: () => this.cdr.markForCheck(),
132
+ measured: (index, length, offset) =>
133
+ this.dispatch({ kind: LIST_ACTION_KIND.measure, index, length, offset }),
134
+ });
135
+
136
+ private readonly effects = new ListEffectRunner<ItemT>({
137
+ scrollToPixel: (offset, animated) => this.scrollToPixel(offset, animated),
138
+ dispatch: action => this.dispatch(action),
139
+ endReached: this.endReached,
140
+ startReached: this.startReached,
141
+ scrollToIndexFailed: this.scrollToIndexFailed,
142
+ });
143
+
650
144
  private lastEffectSignature = '';
651
- // Dedupes ngDoCheck's own recompute. WITHOUT this, recomputeView() unconditionally rebuilds
652
- // windowCells (and each cell's `separators` handle — fresh closures every call) on EVERY CD pass,
653
- // including ones triggered by something else entirely in the app. A fresh context object flows
654
- // into VListOutletDirective, which (correctly) sees its `context` @Input() change and calls
655
- // `viewRef.markForCheck()` — which reschedules ANOTHER change-detection tick via the zoneless
656
- // scheduler (adapters/angular/src/render.ts). Recomputing unconditionally therefore free-runs
657
- // forever the moment a list actually has cells to stamp (a real, previously-dormant bug only
658
- // exposed once flat-list.test.ts's projection fix let cells render for the first time — see that
659
- // test's comment). Guarding recompute behind "did anything relevant actually change" breaks the
660
- // cycle: unrelated CD passes reuse the same windowCells/context identities, so
661
- // VListOutletDirective's ngOnChanges sees no change and never reschedules.
145
+ // Without this dedup a rebuilt cell context reschedules another tick and the list free-runs
662
146
  private lastRecompute: unknown[] | undefined = undefined;
663
147
 
664
148
  private readonly cdr = inject(ChangeDetectorRef);
665
- // This component's OWN host — the non-painting anchor `class="..."` at the use site resolves
666
- // onto (see anchorHostStyle's doc comment) — NOT `scrollHostRef` above, which targets the real
667
- // inner `<scroll-view>`/`<horizontal-scroll-view>` tag one level down.
149
+ // This component's own host, the anchor that `class="..."` at the use site resolves onto
668
150
  private readonly elementRef = inject(ElementRef);
669
151
 
670
- // Bridges the non-reactive @Input fields into the reactive graph so `foldedAccessibility` below
671
- // can memoize a bag derived from them (read its own comment for why signal inputs are not an
672
- // option while this package's unit suite runs on JIT).
673
- //
674
- // ONLY safe for a bag whose every dependency is an @Input. This list's window machinery
675
- // (listState, renderVersion, windowCells, the measured metrics) is driven from scroll/layout
676
- // callbacks that never touch ngOnChanges, so nothing derived from it may ride this signal - it
677
- // would memoize a window that stops re-computing mid-scroll. Make that state its own signal
678
- // instead of widening this one.
679
- private readonly inputsRevision = signal(0);
680
-
681
- // MEASURED (while ScrollView was still a wrapper): the template fed 20 separate inputs off this
682
- // one bag, and Angular does not cache a getter across binding expressions - as a getter it
683
- // rebuilt the object 20 times per refresh, and every consumer saw a fresh reference, so all 20
684
- // reported "changed" and wrote through. A computed evaluates once and hands back the SAME object
685
- // until an input actually changes. Every dependency below is an @Input, which is what makes
686
- // inputsRevision a complete dependency set here — `scrollViewBag()` reuses this result rather
687
- // than re-running the fold.
688
- readonly foldedAccessibility = computed<
689
- IAccessibilityProps & IAriaProps & Record<string, unknown>
690
- >(() => {
691
- this.inputsRevision();
692
- return resolveAccessibilityProps({
693
- testID: this.testID,
694
- nativeID: this.nativeID,
695
- accessible: this.accessible,
696
- accessibilityLabel: this.accessibilityLabel,
697
- accessibilityHint: this.accessibilityHint,
698
- accessibilityRole: this.accessibilityRole,
699
- accessibilityState: this.accessibilityState,
700
- accessibilityValue: this.accessibilityValue,
701
- accessibilityActions: this.accessibilityActions,
702
- accessibilityLabelledBy: this.accessibilityLabelledBy,
703
- importantForAccessibility: this.importantForAccessibility,
704
- accessibilityLiveRegion: this.accessibilityLiveRegion,
705
- screenReaderFocusable: this.screenReaderFocusable,
706
- accessibilityViewIsModal: this.accessibilityViewIsModal,
707
- accessibilityElementsHidden: this.accessibilityElementsHidden,
708
- accessibilityIgnoresInvertColors: this.accessibilityIgnoresInvertColors,
709
- accessibilityLanguage: this.accessibilityLanguage,
710
- accessibilityRespondsToUserInteraction:
711
- this.accessibilityRespondsToUserInteraction,
712
- accessibilityShowsLargeContentViewer:
713
- this.accessibilityShowsLargeContentViewer,
714
- accessibilityLargeContentTitle: this.accessibilityLargeContentTitle,
715
- role: this.role,
716
- 'aria-label': this.ariaLabel,
717
- 'aria-labelledby': this.ariaLabelledBy,
718
- 'aria-live': this.ariaLive,
719
- 'aria-hidden': this.ariaHidden,
720
- 'aria-busy': this.ariaBusy,
721
- 'aria-checked': this.ariaChecked,
722
- 'aria-disabled': this.ariaDisabled,
723
- 'aria-expanded': this.ariaExpanded,
724
- 'aria-selected': this.ariaSelected,
725
- 'aria-modal': this.ariaModal,
726
- 'aria-valuemax': this.ariaValueMax,
727
- 'aria-valuemin': this.ariaValueMin,
728
- 'aria-valuenow': this.ariaValueNow,
729
- 'aria-valuetext': this.ariaValueText,
730
- });
731
- });
732
-
733
- get shouldRenderRefreshControl(): boolean {
734
- return this.refreshRequested ?? this.refresh.observed;
735
- }
736
-
737
- // The flat bag fed to the scroll tag via `[symbioteHostProps]`. A plain method, not `computed()`:
738
- // most of these fields (resolvedStyle, renderedStickyIndices, commandedOffset, …) are recomputed
739
- // in ngDoCheck/dispatch as ordinary fields, not signals, so a computed() memoized only against
740
- // `inputsRevision` would go stale the moment one of them changed outside an @Input() write. The
741
- // directive itself diffs per key before writing to the engine (`primitives/shared.ts`), so a
742
- // fresh object every CD pass costs nothing beyond the literal allocation.
743
- scrollViewBag(): Record<string, unknown> {
744
- return {
745
- ...this.foldedAccessibility(),
746
- style: this.resolvedStyle,
747
- contentContainerStyle: this.resolvedContentContainerStyle,
748
- onScroll: this.onScrollTick,
749
- onLayout: this.onLayoutTick,
750
- onScrollBeginDrag: this.onScrollBeginDrag,
751
- onScrollEndDrag: this.onScrollEndDrag,
752
- onMomentumScrollBegin: this.onMomentumScrollBegin,
753
- onMomentumScrollEnd: this.onMomentumScrollEnd,
754
- scrollEventThrottle: this.scrollEventThrottle,
755
- keyboardShouldPersistTaps: this.keyboardShouldPersistTaps,
756
- keyboardDismissMode: this.keyboardDismissMode,
757
- // RN's VirtualizedList spreads its props onto the ScrollView, this one included.
758
- removeClippedSubviews: this.removeClippedSubviews,
759
- nestedScrollEnabled: this.nestedScrollEnabled,
760
- // VirtualizedList.js:1111 — Android moves the scrollbar back after the `scale: -1` flip.
761
- isInvertedVirtualizedList: this.isInverted ? true : undefined,
762
- contentOffset: this.commandedOffset,
763
- // `stickyHeaderIndices` is deliberately NOT forwarded — it numbers the scroll view's PAINT
764
- // children, and a windowed list paints a header, a spacer and a slice, so the positions move
765
- // every time the window slides and the behavior re-wraps a different child each pass. The
766
- // cells carry the `sticky-header` TAG instead, which pins by DOCUMENT order.
767
- maintainVisibleContentPosition:
768
- this.resolvedMaintainVisibleContentPosition,
769
- // The four boolean-gated Fabric events (`.claude/rules/fabric-boolean-event-gates.md`): the
770
- // TAG has no `.observed` of its own, so `wantsGate` (own subscriber, or the demand from
771
- // whatever wraps this list) is what decides whether the flag reaches Fabric at all.
772
- onAccessibilityAction: this.wantsGate('accessibilityAction')
773
- ? this.accessibilityActionTick
774
- : undefined,
775
- onAccessibilityTap: this.wantsGate('accessibilityTap')
776
- ? this.accessibilityTapTick
777
- : undefined,
778
- onMagicTap: this.wantsGate('magicTap') ? this.magicTapTick : undefined,
779
- onAccessibilityEscape: this.wantsGate('accessibilityEscape')
780
- ? this.accessibilityEscapeTick
781
- : undefined,
782
- };
783
- }
784
-
785
- // The `refresh-control` tag's own bag — an ordinary first child, re-parented per platform by the
786
- // scroll-view behavior (`../refresh-control-props.ts`). `onRefresh` is a plain listener prop, not
787
- // a gated one: RefreshControl's `refresh` is unaffected by `fabric-boolean-event-gates.md`.
788
- refreshControlBag(): Record<string, unknown> {
789
- return {
790
- refreshing: this.refreshing ?? false,
791
- progressViewOffset: this.progressViewOffset,
792
- onRefresh: this.handleRefresh,
793
- };
794
- }
795
-
796
- get isHorizontal(): boolean {
797
- return this.horizontal === true;
798
- }
799
- private get isInverted(): boolean {
800
- return this.inverted === true;
801
- }
802
- private get windowSizeValue(): number {
803
- return this.windowSize ?? DEFAULT_WINDOW_SIZE;
804
- }
805
- private get initialNumToRenderValue(): number {
806
- return this.initialNumToRender ?? DEFAULT_INITIAL_NUM_TO_RENDER;
807
- }
808
- private get maxToRenderPerBatchValue(): number {
809
- return this.maxToRenderPerBatch ?? DEFAULT_MAX_TO_RENDER_PER_BATCH;
810
- }
811
- private get updateCellsBatchingPeriodValue(): number {
812
- return (
813
- this.updateCellsBatchingPeriod ?? DEFAULT_UPDATE_CELLS_BATCHING_PERIOD
814
- );
815
- }
816
- private get onEndReachedThresholdValue(): number | undefined {
817
- return this.onEndReachedThreshold;
818
- }
819
- private get onStartReachedThresholdValue(): number | undefined {
820
- return this.onStartReachedThreshold;
821
- }
822
-
823
- // onScroll stays a plain callback bag key (an Animated.event(...) target must be able to flow
824
- // through it, which an @Output() can't carry), so this is a stable arrow field read straight into
825
- // scrollViewBag()'s `onScroll`. onLayout is unconditional too — this list always needs its own
826
- // layout for windowing, regardless of whether an app subscribes to anything.
827
- onScrollTick = (event: ISymbioteEvent): void => {
828
- countAngular('scrollTicks');
829
- const offset = readScrollOffset(event, this.isHorizontal);
830
- if (offset === undefined) return;
831
- dlog(`Angular VirtualizedList onScroll offset=${offset}`);
832
- // A real native scroll supersedes any pending commanded offset.
833
- this.commandedOffset = undefined;
834
- this.dispatch({ kind: 'scroll', offset });
835
- // Compose, don't clobber: internal windowing ran first, now the user's onScroll.
836
- this.onScroll?.(event);
837
- };
838
-
839
- onLayoutTick = (event: ISymbioteEvent): void => {
840
- const length = readLayoutLength(event, this.isHorizontal);
841
- if (length === undefined) return;
842
- dlog(`Angular VirtualizedList onLayout viewport=${length}`);
843
- this.dispatch({ kind: 'layout', length });
844
- };
845
-
846
- // RefreshControl's refresh and the scroll tag's accessibility events reach here as plain
847
- // listener-prop values in scrollViewBag()/refreshControlBag() — these fields just adapt
848
- // VirtualizedList's own @Output() into a plain re-emit callback.
849
- handleRefresh = (): void => {
850
- this.refresh.emit();
851
- };
852
-
853
- accessibilityActionTick = (event: ISymbioteEvent): void => {
854
- this.accessibilityAction.emit(event);
855
- };
856
-
857
- accessibilityTapTick = (event: ISymbioteEvent): void => {
858
- this.accessibilityTap.emit(event);
859
- };
860
-
861
- magicTapTick = (event: ISymbioteEvent): void => {
862
- this.magicTap.emit(event);
863
- };
864
-
865
- accessibilityEscapeTick = (event: ISymbioteEvent): void => {
866
- this.accessibilityEscape.emit(event);
867
- };
868
-
869
152
  private keyFor = (index: number): string => {
870
153
  const item = this.getItem(this.data, index);
871
154
  return resolveItemKey(item, index, this.keyExtractor);
872
155
  };
873
156
 
874
- // The reducer inputs, folded off the @Input()s each call. The edge/viewability listeners map to
875
- // `.observed` (Angular's "is anyone bound to this @Output()"), so the reducer emits only when the
876
- // consumer listens — the same on-demand gating the prop-callback era had.
157
+ // Edge and viewability listeners map to `.observed`, so the reducer emits only if listened to
877
158
  private buildInputs(): IListReducerInputs<ItemT> {
878
159
  return {
879
160
  data: this.data,
@@ -886,8 +167,8 @@ export class VirtualizedList<ItemT = unknown>
886
167
  initialNumToRender: this.initialNumToRenderValue,
887
168
  maxToRenderPerBatch: this.maxToRenderPerBatchValue,
888
169
  updateCellsBatchingPeriod: this.updateCellsBatchingPeriodValue,
889
- onEndReachedThreshold: this.onEndReachedThresholdValue,
890
- onStartReachedThreshold: this.onStartReachedThresholdValue,
170
+ onEndReachedThreshold: this.onEndReachedThreshold,
171
+ onStartReachedThreshold: this.onStartReachedThreshold,
891
172
  onEndReachedActive: this.endReached.observed,
892
173
  onStartReachedActive: this.startReached.observed,
893
174
  viewabilityPairs: buildViewabilityPairs(
@@ -903,12 +184,11 @@ export class VirtualizedList<ItemT = unknown>
903
184
  };
904
185
  }
905
186
 
906
- // Map a native event / imperative call to an action, run the returned effects, and mark the view
907
- // dirty when render state changed (the native callbacks fire outside Angular's own bindings).
908
- private dispatch(action: IListAction<ItemT>): void {
187
+ // Native callbacks fire outside Angular's bindings, so a changed state must mark the view dirty
188
+ protected dispatch(action: IListAction<ItemT>): void {
909
189
  const inputs = this.buildInputs();
910
190
  const result = reduceList(this.listState, action, inputs);
911
- this.runEffects(result.effects, inputs);
191
+ this.effects.run(result.effects, inputs);
912
192
  if (!result.changed) return;
913
193
  this.renderVersion += 1;
914
194
  if (this.isWindowSettled(action)) return;
@@ -916,29 +196,13 @@ export class VirtualizedList<ItemT = unknown>
916
196
  this.cdr.markForCheck();
917
197
  }
918
198
 
919
- // The reducer reports `changed` for EVERY scroll offset - it has to, since the offset feeds
920
- // end-reached distance, viewability and the batch-fill timer, all handled by runEffects. The
921
- // TEMPLATE reads none of those; it reads the window. Marking on the offset repainted the whole
922
- // ancestor chain 60 times a second on a sticky screen for a window that had not moved.
923
- //
924
- // So the question here is "would a render leave the window exactly where it is", and it is asked
925
- // of the state as it is NOW. It must NOT be answered by comparing the last-rendered window with
926
- // the one before it: that window was derived from the PREVIOUS scroll offset, so once two
927
- // consecutive derives agree the comparison reads "settled" for every offset that ever follows -
928
- // no mark, no CD, no ngDoCheck, no refresh-metrics, so the window can never move again and the
929
- // list goes permanently deaf mid-scroll (device: examples/angular Benchmark sticky path B froze
930
- // at exactly the window the first viewport asked for, blank below it).
931
- //
932
- // computeWindow re-run over the CACHED offset table answers it directly. Two scans, no
933
- // allocation - buildOffsets, the O(count) allocating half, still runs once per render only. It is
934
- // the same function the render will use, so this cannot drift away from the window it predicts.
199
+ // Asks whether a render would leave the window where it is, of the state as it is now
200
+ // Comparing the last two rendered windows would read settled forever and the list goes deaf
935
201
  private isWindowSettled(action: IListAction<ItemT>): boolean {
936
- // A measurement rewrites the very offset table the prediction reads, so it has no cached
937
- // geometry to answer from.
938
- if (action.kind === 'measure') return false;
202
+ // A measurement rewrites the offset table the prediction reads
203
+ if (action.kind === LIST_ACTION_KIND.measure) return false;
939
204
  const m = this.listState.metrics;
940
- // Mid-fill: the committed window is still climbing toward target one batch step per render, and
941
- // only a render advances it. Skipping the mark here is what stalls the incremental fill.
205
+ // Mid-fill the window climbs one batch step per render, only a render advances it
942
206
  if (m.first !== m.target.first || m.last !== m.target.last) return false;
943
207
  const next = computeWindow(
944
208
  m.count,
@@ -959,83 +223,7 @@ export class VirtualizedList<ItemT = unknown>
959
223
  return isSettled;
960
224
  }
961
225
 
962
- private runEffects(
963
- effects: IListEffect<ItemT>[],
964
- inputs: IListReducerInputs<ItemT>,
965
- ): void {
966
- for (const effect of effects) {
967
- switch (effect.kind) {
968
- case 'scroll-to':
969
- this.scrollToPixel(effect.offset, effect.animated);
970
- break;
971
- case 'fire-end-reached':
972
- this.endReached.emit({ distanceFromEnd: effect.distanceFromEnd });
973
- break;
974
- case 'fire-start-reached':
975
- this.startReached.emit({
976
- distanceFromStart: effect.distanceFromStart,
977
- });
978
- break;
979
- case 'fire-scroll-to-index-failed':
980
- this.scrollToIndexFailed.emit({
981
- index: effect.index,
982
- highestMeasuredFrameIndex: effect.highestMeasuredFrameIndex,
983
- averageItemLength: effect.averageItemLength,
984
- });
985
- break;
986
- case 'schedule-refill': {
987
- if (this.batchTimer !== null) clearTimeout(this.batchTimer);
988
- this.batchTimer = setTimeout(() => {
989
- this.batchTimer = null;
990
- this.dispatch({ kind: 'batch-tick' });
991
- }, effect.delay);
992
- break;
993
- }
994
- case 'fire-viewable': {
995
- const pairs = inputs.viewabilityPairs;
996
- const info = effect.info;
997
- const map = effect.map;
998
- const fire = (): void => {
999
- for (const pair of pairs) {
1000
- pair.onViewableItemsChanged({
1001
- ...info,
1002
- viewabilityConfig: pair.viewabilityConfig,
1003
- });
1004
- }
1005
- this.dispatch({ kind: 'viewable-fired', map });
1006
- };
1007
- if (this.viewableTimer !== null) {
1008
- clearTimeout(this.viewableTimer);
1009
- this.viewableTimer = null;
1010
- }
1011
- if (effect.delay > EMPTY_OFFSET) {
1012
- this.viewableTimer = setTimeout(() => {
1013
- this.viewableTimer = null;
1014
- fire();
1015
- }, effect.delay);
1016
- } else {
1017
- fire();
1018
- }
1019
- break;
1020
- }
1021
- }
1022
- }
1023
- }
1024
-
1025
- // foldedAccessibility reads plain @Input fields, which are NOT reactive on their own - this bump
1026
- // is what tells that computed an input changed. It must stay in ngOnChanges: that is the single
1027
- // moment Angular has finished writing every changed input for this pass, and it runs before
1028
- // ngDoCheck below, so the recompute already sees the current values.
1029
- ngOnChanges(): void {
1030
- this.inputsRevision.update(revision => revision + 1);
1031
- }
1032
-
1033
- // Once-per-CD recompute, BEFORE the template bindings are read (so the freshly computed
1034
- // template-bound fields render this pass — computing them in ngAfterContentChecked instead would
1035
- // trip ExpressionChangedAfterItHasBeenChecked). The dedup guard runs refresh-metrics (which owns
1036
- // the controlled committedWindow throttle) only when something relevant changed, so the window
1037
- // grows exactly one step per meaningful CD. The projected templates (ContentChild) are resolved
1038
- // from the second CD on; the first paint (count/viewport still 0) corrects on the layout-driven CD.
226
+ // Runs before the bindings are read, a later hook would trip the changed-after-checked guard
1039
227
  ngDoCheck(): void {
1040
228
  countAngular('listChecks');
1041
229
  const recomputeInputs: unknown[] = [
@@ -1051,12 +239,9 @@ export class VirtualizedList<ItemT = unknown>
1051
239
  this.stickyHeaderIndices,
1052
240
  this.maintainVisibleContentPosition,
1053
241
  this.style,
1054
- // `recomputeView` folds this into the committed style, but it arrives through
1055
- // addClass/removeClass, never as an @Input - so without it here no entry moves on a class
1056
- // toggle and the gate skips the recompute forever. Reference-stable, so the dedup holds.
242
+ // Arrives through `addClass` and `removeClass`, never as an input
1057
243
  anchorHostStyle(this.elementRef),
1058
244
  this.contentContainerStyle,
1059
- // Folds scroll / layout / measure / batch-tick: dispatch bumps renderVersion on any change.
1060
245
  this.renderVersion,
1061
246
  this.headerDir !== undefined,
1062
247
  this.footerDir !== undefined,
@@ -1072,9 +257,12 @@ export class VirtualizedList<ItemT = unknown>
1072
257
  ) {
1073
258
  return;
1074
259
  }
1075
- // The single derive-per-CD: recompute the window off the current state before the view reads it.
1076
260
  countAngular('listRecomputes');
1077
- reduceList(this.listState, { kind: 'refresh-metrics' }, this.buildInputs());
261
+ reduceList(
262
+ this.listState,
263
+ { kind: LIST_ACTION_KIND.refreshMetrics },
264
+ this.buildInputs(),
265
+ );
1078
266
  this.recomputeView();
1079
267
  }
1080
268
 
@@ -1083,82 +271,36 @@ export class VirtualizedList<ItemT = unknown>
1083
271
  if (signature === this.lastEffectSignature) return;
1084
272
  this.lastEffectSignature = signature;
1085
273
  const inputs = this.buildInputs();
1086
- const result = reduceList(this.listState, { kind: 'commit' }, inputs);
1087
- this.runEffects(result.effects, inputs);
274
+ const result = reduceList(
275
+ this.listState,
276
+ { kind: LIST_ACTION_KIND.commit },
277
+ inputs,
278
+ );
279
+ this.effects.run(result.effects, inputs);
1088
280
  }
1089
281
 
1090
282
  ngOnDestroy(): void {
1091
- if (this.viewableTimer !== null) clearTimeout(this.viewableTimer);
1092
- if (this.batchTimer !== null) clearTimeout(this.batchTimer);
283
+ this.effects.dispose();
1093
284
  }
1094
285
 
1095
286
  private recomputeView(): void {
1096
287
  const m = this.listState.metrics;
1097
288
  this.itemCount = m.count;
1098
- const hasHeader = this.headerDir !== undefined;
1099
- const hasSeparators = this.separatorTemplate !== undefined;
1100
- const stickySet =
1101
- this.stickyHeaderIndices !== undefined
1102
- ? new Set(this.stickyHeaderIndices)
1103
- : undefined;
1104
-
1105
- this.resolvedContentContainerStyle = this.isHorizontal
1106
- ? [this.contentContainerStyle, { width: m.total }]
1107
- : this.contentContainerStyle;
1108
- this.resolvedStyle = flattenStyle([
1109
- anchorHostStyle(this.elementRef),
1110
- // VirtualizedList.js: `[inversionStyle, style]` — the app's style can override the flip.
1111
- this.isInverted
1112
- ? [this.isHorizontal ? INVERTED_X_STYLE : INVERTED_Y_STYLE, this.style]
1113
- : this.style,
1114
- ]);
1115
- this.cellStyle = this.isInverted
1116
- ? this.isHorizontal
1117
- ? INVERTED_X_STYLE
1118
- : INVERTED_Y_STYLE
1119
- : undefined;
1120
- this.resolvedMaintainVisibleContentPosition =
1121
- this.maintainVisibleContentPosition === undefined
1122
- ? undefined
1123
- : {
1124
- ...this.maintainVisibleContentPosition,
1125
- minIndexForVisible:
1126
- this.maintainVisibleContentPosition.minIndexForVisible +
1127
- (hasHeader ? 1 : 0),
1128
- };
1129
-
1130
- // A change in anything a cell's content reads other than its own item invalidates every cached
1131
- // context: identity reuse is only sound while the reused object still describes the cell.
1132
- const epoch: unknown[] = [
289
+ this.applyStyles(m.total);
290
+ this.cells.beginPass([
1133
291
  this.data,
1134
292
  this.extraData,
1135
293
  this.getItem,
1136
294
  this.keyExtractor,
1137
- ];
1138
- const previousEpoch = this.cellContextEpoch;
1139
- this.cellContextEpoch = epoch;
1140
- if (
1141
- previousEpoch === undefined ||
1142
- previousEpoch.length !== epoch.length ||
1143
- !previousEpoch.every((value, index) => value === epoch[index])
1144
- ) {
1145
- this.cellContexts.clear();
1146
- }
1147
-
295
+ ]);
1148
296
  if (m.count === FIRST_INDEX) {
1149
- this.cellContexts.clear();
1150
- this.nextCellContexts.clear();
1151
- this.windowCells = [];
1152
- this.forcedStickyCell = null;
1153
- this.leadingSpacerStyle = null;
1154
- this.gapSpacerStyle = null;
1155
- this.trailingSpacerStyle = null;
1156
- dlog(
1157
- `Angular VirtualizedList empty (viewport=${this.listState.viewportLength})`,
1158
- );
297
+ this.showEmpty();
1159
298
  return;
1160
299
  }
1161
-
300
+ const stickySet =
301
+ this.stickyHeaderIndices !== undefined
302
+ ? new Set(this.stickyHeaderIndices)
303
+ : undefined;
1162
304
  const plan = buildListPlan({
1163
305
  count: m.count,
1164
306
  first: m.first,
@@ -1168,161 +310,88 @@ export class VirtualizedList<ItemT = unknown>
1168
310
  total: m.total,
1169
311
  keyFor: this.keyFor,
1170
312
  stickyIndices: stickySet,
1171
- hasHeader,
313
+ hasHeader: this.headerDir !== undefined,
1172
314
  });
1173
- this.leadingSpacerStyle =
1174
- plan.leadingExtent > EMPTY_OFFSET
1175
- ? this.spacerStyle(plan.leadingExtent)
1176
- : null;
1177
- this.gapSpacerStyle =
1178
- plan.gapExtent > EMPTY_OFFSET ? this.spacerStyle(plan.gapExtent) : null;
1179
- this.trailingSpacerStyle =
1180
- plan.trailingExtent > EMPTY_OFFSET
1181
- ? this.spacerStyle(plan.trailingExtent)
1182
- : null;
1183
- // `plan.stickyChildPositions` is deliberately NOT read — it was the input to the index form.
1184
- dlog(
1185
- `STICKY[list] stickySet=${stickySet === undefined ? 'undefined' : JSON.stringify([...stickySet])} ` +
1186
- `childPositions=${JSON.stringify(plan.stickyChildPositions)} ` +
1187
- `forcedStickyCell=${plan.forcedStickyCell === undefined ? 'none' : plan.forcedStickyCell.index} ` +
1188
- `window=[${m.first},${m.last}] hasHeader=${this.headerDir !== undefined}`,
1189
- );
1190
-
1191
- this.forcedStickyCell =
1192
- plan.forcedStickyCell !== undefined
1193
- ? this.buildWindowCell(
1194
- plan.forcedStickyCell.index,
1195
- plan.forcedStickyCell.key,
1196
- false,
1197
- // Sticky by construction — `forcedStickyCell` exists only for an index in `stickySet`.
1198
- true,
1199
- )
1200
- : null;
1201
-
1202
- const cells: IWindowCell<ItemT>[] = [];
1203
- for (const planned of plan.cells) {
1204
- // RN gates the separator on the last index of the DATA, not the WINDOW
1205
- // (VirtualizedList.js:793 `getItemCount(data) - 1`). The separator lives INSIDE the
1206
- // measuring wrapper, so gating on the window would shift a cell's height as it slides past.
1207
- const includeSeparator = hasSeparators && planned.index < m.count - 1;
1208
- cells.push(
1209
- this.buildWindowCell(
1210
- planned.index,
1211
- planned.key,
1212
- includeSeparator,
1213
- stickySet?.has(planned.index) === true,
1214
- ),
1215
- );
1216
- }
1217
- this.windowCells = cells;
1218
- // Swap, don't prune: a cell that left the window is simply absent from the pass that just ran,
1219
- // so the old map is the garbage and the new one is exactly the live set.
1220
- this.cellContexts = this.nextCellContexts;
1221
- this.nextCellContexts = new Map<string, IVListItemContext<ItemT>>();
1222
-
315
+ this.applySpacers(plan);
316
+ this.buildCells(plan, stickySet);
1223
317
  dlog(
1224
318
  `Angular VirtualizedList window [${m.first}, ${m.last}] of ${m.count} ` +
1225
- `(offset=${this.listState.scrollOffset}, viewport=${this.listState.viewportLength}, rendered=${cells.length})`,
319
+ `(offset=${this.listState.scrollOffset}, viewport=${this.listState.viewportLength}, rendered=${this.windowCells.length})`,
1226
320
  );
1227
321
  }
1228
322
 
1229
- // Assembles one cell (window or forced-sticky) — the context, measure closure, and optional
1230
- // separator context — so both plan.cells and plan.forcedStickyCell build identically.
1231
- private buildWindowCell(
1232
- index: number,
1233
- key: string,
1234
- includeSeparator: boolean,
1235
- isSticky: boolean,
1236
- ): IWindowCell<ItemT> {
1237
- const item = this.getItem(this.data, index);
1238
- return {
1239
- key,
1240
- index,
1241
- context: this.contextFor(key, index, item),
1242
- measure: this.cellMeasure(index),
1243
- separatorContext: includeSeparator
1244
- ? this.buildSeparatorContext(index, item)
1245
- : undefined,
1246
- isSticky,
1247
- };
323
+ private applyStyles(total: number): void {
324
+ const styles = resolveListStyles({
325
+ isHorizontal: this.isHorizontal,
326
+ isInverted: this.isInverted,
327
+ total,
328
+ hasHeader: this.headerDir !== undefined,
329
+ style: this.style,
330
+ contentContainerStyle: this.contentContainerStyle,
331
+ anchorStyle: anchorHostStyle(this.elementRef),
332
+ maintainVisibleContentPosition: this.maintainVisibleContentPosition,
333
+ });
334
+ this.resolvedStyle = styles.style;
335
+ this.resolvedContentContainerStyle = styles.contentContainerStyle;
336
+ this.cellStyle = styles.cellStyle;
337
+ this.resolvedMaintainVisibleContentPosition =
338
+ styles.maintainVisibleContentPosition;
1248
339
  }
1249
340
 
1250
- // The context for one cell, reused by identity when the cell did not move. See cellContexts.
1251
- private contextFor(
1252
- key: string,
1253
- index: number,
1254
- item: ItemT,
1255
- ): IVListItemContext<ItemT> {
1256
- const cached = this.cellContexts.get(key);
1257
- if (
1258
- cached !== undefined &&
1259
- cached.$implicit === item &&
1260
- cached.index === index
1261
- ) {
1262
- this.nextCellContexts.set(key, cached);
1263
- return cached;
1264
- }
1265
- const context: IVListItemContext<ItemT> = {
1266
- $implicit: item,
1267
- index,
1268
- separators: this.makeSeparators(index),
1269
- };
1270
- this.nextCellContexts.set(key, context);
1271
- return context;
341
+ private showEmpty(): void {
342
+ this.cells.clear();
343
+ this.windowCells = [];
344
+ this.forcedStickyCell = null;
345
+ this.leadingSpacerStyle = null;
346
+ this.gapSpacerStyle = null;
347
+ this.trailingSpacerStyle = null;
348
+ dlog(
349
+ `Angular VirtualizedList empty (viewport=${this.listState.viewportLength})`,
350
+ );
1272
351
  }
1273
352
 
1274
- private buildSeparatorContext(
1275
- index: number,
1276
- item: ItemT,
1277
- ): IVListSeparatorContext<ItemT> {
1278
- const overrides = this.separatorOverrides.get(index);
1279
- const highlighted = overrides?.highlighted ?? false;
1280
- const context: IVListSeparatorContext<ItemT> = {
1281
- $implicit: highlighted,
1282
- highlighted,
1283
- leadingItem: item,
1284
- trailingItem: this.getItem(this.data, index + 1),
1285
- };
1286
- // Fold any custom updateProps keys through the context's index signature (RN lets a row drive
1287
- // arbitrary separator props); the typed fields above stay authoritative.
1288
- if (overrides !== undefined) {
1289
- for (const key of Object.keys(overrides)) {
1290
- if (
1291
- key === 'highlighted' ||
1292
- key === 'leadingItem' ||
1293
- key === 'trailingItem'
1294
- )
1295
- continue;
1296
- context[key] = overrides[key];
1297
- }
1298
- }
1299
- return context;
353
+ private applySpacers(plan: IListPlan): void {
354
+ this.leadingSpacerStyle = this.spacerStyle(plan.leadingExtent);
355
+ this.gapSpacerStyle = this.spacerStyle(plan.gapExtent);
356
+ this.trailingSpacerStyle = this.spacerStyle(plan.trailingExtent);
1300
357
  }
1301
358
 
1302
- private spacerStyle(extent: number): IViewStyle {
359
+ private spacerStyle(extent: number): IViewStyle | null {
360
+ if (extent <= EMPTY_OFFSET) return null;
1303
361
  return this.isHorizontal ? { width: extent } : { height: extent };
1304
362
  }
1305
363
 
1306
- private cellMeasure(index: number): (event: ISymbioteEvent) => void {
1307
- const existing = this.cellMeasures.get(index);
1308
- if (existing !== undefined) return existing;
1309
- const measure = (event: ISymbioteEvent): void => {
1310
- const length = readLayoutLength(event, this.isHorizontal);
1311
- if (length === undefined) return;
1312
- const offset = readLayoutOffset(event, this.isHorizontal);
1313
- dlog(
1314
- `Angular VirtualizedList cell ${index} measured length=${length} offset=${offset ?? 'none'}`,
1315
- );
1316
- // The reducer guards a fixed getItemLayout and a repeated length; a no-op leaves state and the
1317
- // view untouched (dispatch marks for check only when something changed).
1318
- this.dispatch({ kind: 'measure', index, length, offset });
1319
- };
1320
- this.cellMeasures.set(index, measure);
1321
- return measure;
364
+ private buildCells(
365
+ plan: IListPlan,
366
+ stickySet: Set<number> | undefined,
367
+ ): void {
368
+ const count = this.listState.metrics.count;
369
+ const forced = plan.forcedStickyCell;
370
+ dlog(
371
+ `STICKY[list] stickySet=${stickySet === undefined ? 'undefined' : JSON.stringify([...stickySet])} ` +
372
+ `childPositions=${JSON.stringify(plan.stickyChildPositions)} ` +
373
+ `forcedStickyCell=${forced === undefined ? 'none' : forced.index}`,
374
+ );
375
+ // Sticky by construction: a forced cell exists only for an index in `stickySet`
376
+ this.forcedStickyCell =
377
+ forced !== undefined
378
+ ? this.cells.windowCell(forced.index, forced.key, false, true)
379
+ : null;
380
+ const hasSeparators = this.separatorTemplate !== undefined;
381
+ // The separator gates on the last index of the data, not of the window, or a cell's height
382
+ // would shift as it slides past
383
+ this.windowCells = plan.cells.map(planned =>
384
+ this.cells.windowCell(
385
+ planned.index,
386
+ planned.key,
387
+ hasSeparators && planned.index < count - 1,
388
+ stickySet?.has(planned.index) === true,
389
+ ),
390
+ );
391
+ this.cells.endPass();
1322
392
  }
1323
393
 
1324
- // Per-cell onLayout rides the engine's structural (layout) event channel — Angular forbids an
1325
- // [onLayout] property binding (NG8002). $event arrives untyped, narrowed before reaching measure.
394
+ // Angular forbids an `[onLayout]` binding, so the event arrives untyped and is narrowed here
1326
395
  handleCellLayout(
1327
396
  measure: (event: ISymbioteEvent) => void,
1328
397
  event: unknown,
@@ -1336,111 +405,6 @@ export class VirtualizedList<ItemT = unknown>
1336
405
  );
1337
406
  }
1338
407
 
1339
- private mergeSeparator(
1340
- gapIndex: number,
1341
- patch: Partial<ISeparatorProps<unknown>>,
1342
- ): void {
1343
- const count = this.listState.metrics.count;
1344
- if (!isSeparatorGapInRange(gapIndex, count)) return;
1345
- this.separatorOverrides.set(gapIndex, {
1346
- ...this.separatorOverrides.get(gapIndex),
1347
- ...patch,
1348
- });
1349
- this.cdr.markForCheck();
1350
- }
1351
-
1352
- private makeSeparators(index: number): ISeparators {
1353
- return {
1354
- highlight: (): void => {
1355
- dlog(`Angular VirtualizedList separator highlight cell=${index}`);
1356
- this.mergeSeparator(index - 1, { highlighted: true });
1357
- this.mergeSeparator(index, { highlighted: true });
1358
- },
1359
- unhighlight: (): void => {
1360
- dlog(`Angular VirtualizedList separator unhighlight cell=${index}`);
1361
- this.mergeSeparator(index - 1, { highlighted: false });
1362
- this.mergeSeparator(index, { highlighted: false });
1363
- },
1364
- updateProps: (
1365
- select: 'leading' | 'trailing',
1366
- newProps: Record<string, unknown>,
1367
- ): void => {
1368
- this.mergeSeparator(select === 'leading' ? index - 1 : index, newProps);
1369
- },
1370
- };
1371
- }
1372
-
1373
- // ---- imperative handle (the shared IVirtualizedListHandle surface) ----
1374
- // Each scroll resolves to an offset (or a scroll-to-index failure) inside the reducer, then rides
1375
- // the scroll-to effect through scrollToPixel.
1376
-
1377
- scrollToOffset(params: { offset: number; animated?: boolean }): void {
1378
- this.dispatch({
1379
- kind: 'scroll-to-offset',
1380
- offset: params.offset,
1381
- animated: params.animated ?? true,
1382
- });
1383
- }
1384
-
1385
- scrollToIndex(params: {
1386
- index: number;
1387
- animated?: boolean;
1388
- viewOffset?: number;
1389
- viewPosition?: number;
1390
- }): void {
1391
- this.dispatch({
1392
- kind: 'scroll-to-index',
1393
- index: params.index,
1394
- animated: params.animated ?? true,
1395
- viewPosition: params.viewPosition ?? FIRST_INDEX,
1396
- viewOffset: params.viewOffset ?? EMPTY_OFFSET,
1397
- });
1398
- }
1399
-
1400
- scrollToItem(params: {
1401
- item: unknown;
1402
- animated?: boolean;
1403
- viewPosition?: number;
1404
- }): void {
1405
- this.dispatch({
1406
- kind: 'scroll-to-item',
1407
- item: params.item,
1408
- animated: params.animated ?? true,
1409
- viewPosition: params.viewPosition ?? FIRST_INDEX,
1410
- });
1411
- }
1412
-
1413
- scrollToEnd(params?: { animated?: boolean }): void {
1414
- this.dispatch({
1415
- kind: 'scroll-to-end',
1416
- animated: params?.animated ?? true,
1417
- });
1418
- }
1419
-
1420
- flashScrollIndicators(): void {
1421
- this.scrollHandle.flashScrollIndicators();
1422
- }
1423
-
1424
- getNativeScrollRef(): IScrollViewHandle | null {
1425
- return this.scrollNode !== null ? this.scrollHandle : null;
1426
- }
1427
-
1428
- getScrollableNode(): IScrollViewHandle | null {
1429
- return this.scrollNode !== null ? this.scrollHandle : null;
1430
- }
1431
-
1432
- getScrollResponder(): IScrollViewHandle | null {
1433
- return this.scrollNode !== null ? this.scrollHandle : null;
1434
- }
1435
-
1436
- getScrollNode(): ISymbioteNode | null {
1437
- return this.scrollHandle.getScrollNode();
1438
- }
1439
-
1440
- recordInteraction(): void {
1441
- this.dispatch({ kind: 'record-interaction' });
1442
- }
1443
-
1444
408
  private scrollToPixel(offset: number, animated: boolean): void {
1445
409
  const clamped = Math.max(EMPTY_OFFSET, offset);
1446
410
  const target = this.isHorizontal