@symbiote-native/angular 0.7.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/README.md +3 -3
  2. package/babel-lower-host-primitives.cjs +636 -0
  3. package/babel-register-composed.cjs +21 -3
  4. package/build/angular/anchor-host-registry.js +3 -1
  5. package/build/angular/anchor-host-registry.js.map +1 -1
  6. package/build/angular/bootstrap.js +1 -1
  7. package/build/angular/bootstrap.js.map +1 -1
  8. package/build/angular/components/activity-indicator/index.android.js +7 -2
  9. package/build/angular/components/activity-indicator/index.android.js.map +1 -1
  10. package/build/angular/components/activity-indicator/index.ios.js +3 -1
  11. package/build/angular/components/activity-indicator/index.ios.js.map +1 -1
  12. package/build/angular/components/activity-indicator/shared.d.ts +1 -1
  13. package/build/angular/components/activity-indicator/shared.js +1 -1
  14. package/build/angular/components/activity-indicator/shared.js.map +1 -1
  15. package/build/angular/components/button.d.ts +3 -0
  16. package/build/angular/components/button.js +22 -5
  17. package/build/angular/components/button.js.map +1 -1
  18. package/build/angular/components/flat-list/index.d.ts +1 -1
  19. package/build/angular/components/flat-list/index.js +47 -14
  20. package/build/angular/components/flat-list/index.js.map +1 -1
  21. package/build/angular/components/image/index.android.js +4 -10
  22. package/build/angular/components/image/index.android.js.map +1 -1
  23. package/build/angular/components/image/index.ios.js +4 -10
  24. package/build/angular/components/image/index.ios.js.map +1 -1
  25. package/build/angular/components/image/shared.d.ts +4 -4
  26. package/build/angular/components/image/shared.js +51 -33
  27. package/build/angular/components/image/shared.js.map +1 -1
  28. package/build/angular/components/image-background.d.ts +3 -0
  29. package/build/angular/components/image-background.js +32 -9
  30. package/build/angular/components/image-background.js.map +1 -1
  31. package/build/angular/components/input-accessory-view/index.d.ts +2 -1
  32. package/build/angular/components/input-accessory-view/index.js +18 -16
  33. package/build/angular/components/input-accessory-view/index.js.map +1 -1
  34. package/build/angular/components/keyboard-avoiding-view/index.d.ts +3 -1
  35. package/build/angular/components/keyboard-avoiding-view/index.js +56 -19
  36. package/build/angular/components/keyboard-avoiding-view/index.js.map +1 -1
  37. package/build/angular/components/modal/index.d.ts +1 -0
  38. package/build/angular/components/modal/index.js +21 -10
  39. package/build/angular/components/modal/index.js.map +1 -1
  40. package/build/angular/components/pressable/index.d.ts +10 -5
  41. package/build/angular/components/pressable/index.js +61 -16
  42. package/build/angular/components/pressable/index.js.map +1 -1
  43. package/build/angular/components/refresh-control/index.d.ts +2 -1
  44. package/build/angular/components/refresh-control/index.js +13 -9
  45. package/build/angular/components/refresh-control/index.js.map +1 -1
  46. package/build/angular/components/safe-area-view/index.d.ts +3 -1
  47. package/build/angular/components/safe-area-view/index.js +22 -20
  48. package/build/angular/components/safe-area-view/index.js.map +1 -1
  49. package/build/angular/components/scroll-view/index.android.js +44 -14
  50. package/build/angular/components/scroll-view/index.android.js.map +1 -1
  51. package/build/angular/components/scroll-view/index.ios.js +13 -5
  52. package/build/angular/components/scroll-view/index.ios.js.map +1 -1
  53. package/build/angular/components/scroll-view/projection.d.ts +32 -0
  54. package/build/angular/components/scroll-view/projection.js +212 -47
  55. package/build/angular/components/scroll-view/projection.js.map +1 -1
  56. package/build/angular/components/scroll-view/shared.d.ts +2 -0
  57. package/build/angular/components/scroll-view/shared.js +48 -11
  58. package/build/angular/components/scroll-view/shared.js.map +1 -1
  59. package/build/angular/components/scroll-view/sticky-header.js +7 -3
  60. package/build/angular/components/scroll-view/sticky-header.js.map +1 -1
  61. package/build/angular/components/section-list/index.d.ts +9 -1
  62. package/build/angular/components/section-list/index.js +72 -18
  63. package/build/angular/components/section-list/index.js.map +1 -1
  64. package/build/angular/components/switch/index.android.js +23 -9
  65. package/build/angular/components/switch/index.android.js.map +1 -1
  66. package/build/angular/components/switch/index.ios.js +23 -9
  67. package/build/angular/components/switch/index.ios.js.map +1 -1
  68. package/build/angular/components/switch/shared.d.ts +1 -1
  69. package/build/angular/components/switch/shared.js +5 -2
  70. package/build/angular/components/switch/shared.js.map +1 -1
  71. package/build/angular/components/text-input.d.ts +8 -2
  72. package/build/angular/components/text-input.js +79 -34
  73. package/build/angular/components/text-input.js.map +1 -1
  74. package/build/angular/components/touchable/index.d.ts +36 -7
  75. package/build/angular/components/touchable/index.js +280 -72
  76. package/build/angular/components/touchable/index.js.map +1 -1
  77. package/build/angular/components/touchable-native-feedback/index.d.ts +2 -1
  78. package/build/angular/components/touchable-native-feedback/index.js +19 -17
  79. package/build/angular/components/touchable-native-feedback/index.js.map +1 -1
  80. package/build/angular/components/virtualized-list/directives.js +4 -0
  81. package/build/angular/components/virtualized-list/directives.js.map +1 -1
  82. package/build/angular/components/virtualized-list/index.d.ts +8 -2
  83. package/build/angular/components/virtualized-list/index.js +207 -61
  84. package/build/angular/components/virtualized-list/index.js.map +1 -1
  85. package/build/angular/components/virtualized-section-list/directives.js.map +1 -1
  86. package/build/angular/components/virtualized-section-list/index.d.ts +21 -1
  87. package/build/angular/components/virtualized-section-list/index.js +100 -22
  88. package/build/angular/components/virtualized-section-list/index.js.map +1 -1
  89. package/build/angular/components.d.ts +2 -2
  90. package/build/angular/components.js +1 -1
  91. package/build/angular/components.js.map +1 -1
  92. package/build/angular/create-portal/index.d.ts +11 -2
  93. package/build/angular/create-portal/index.js +42 -12
  94. package/build/angular/create-portal/index.js.map +1 -1
  95. package/build/angular/create-tunnel/index.js.map +1 -1
  96. package/build/angular/descriptor-to-angular/index.js +3 -1
  97. package/build/angular/descriptor-to-angular/index.js.map +1 -1
  98. package/build/angular/diagnostics.d.ts +33 -0
  99. package/build/angular/diagnostics.js +82 -0
  100. package/build/angular/diagnostics.js.map +1 -0
  101. package/build/angular/gate-demand.d.ts +37 -0
  102. package/build/angular/gate-demand.js +84 -0
  103. package/build/angular/gate-demand.js.map +1 -0
  104. package/build/angular/host-instance/index.js +1 -1
  105. package/build/angular/host-instance/index.js.map +1 -1
  106. package/build/angular/index.d.ts +6 -2
  107. package/build/angular/index.js +16 -1
  108. package/build/angular/index.js.map +1 -1
  109. package/build/angular/modules/animated/create-animated-component.d.ts +1 -0
  110. package/build/angular/modules/animated/create-animated-component.js +41 -18
  111. package/build/angular/modules/animated/create-animated-component.js.map +1 -1
  112. package/build/angular/modules/app-registry/index.js.map +1 -1
  113. package/build/angular/modules/status-bar/index.js +1 -1
  114. package/build/angular/modules/status-bar/index.js.map +1 -1
  115. package/build/angular/primitives/index.d.ts +31 -2
  116. package/build/angular/primitives/index.js +81 -3
  117. package/build/angular/primitives/index.js.map +1 -1
  118. package/build/angular/primitives/shared.d.ts +9 -1
  119. package/build/angular/primitives/shared.js +49 -3
  120. package/build/angular/primitives/shared.js.map +1 -1
  121. package/build/angular/register.d.ts +1 -0
  122. package/build/angular/register.js +35 -0
  123. package/build/angular/register.js.map +1 -0
  124. package/build/angular/render/index.js +51 -6
  125. package/build/angular/render/index.js.map +1 -1
  126. package/build/angular/renderer/index.d.ts +1 -0
  127. package/build/angular/renderer/index.js +157 -21
  128. package/build/angular/renderer/index.js.map +1 -1
  129. package/build/angular/services/color-scheme.service.js.map +1 -1
  130. package/build/angular/services/window-dimensions.service.js +1 -1
  131. package/build/angular/services/window-dimensions.service.js.map +1 -1
  132. package/build/angular/state-style.d.ts +9 -0
  133. package/build/angular/state-style.js +39 -0
  134. package/build/angular/state-style.js.map +1 -0
  135. package/metro-transformer.cjs +69 -0
  136. package/package.json +27 -8
  137. package/src/anchor-host-registry.ts +3 -1
  138. package/src/bootstrap.ts +4 -1
  139. package/src/components/activity-indicator/index.android.ts +7 -2
  140. package/src/components/activity-indicator/index.ios.ts +3 -1
  141. package/src/components/activity-indicator/shared.ts +32 -13
  142. package/src/components/button.ts +36 -6
  143. package/src/components/flat-list/index.ts +91 -29
  144. package/src/components/image/index.android.ts +9 -6
  145. package/src/components/image/index.ios.ts +9 -6
  146. package/src/components/image/shared.ts +110 -45
  147. package/src/components/image-background.ts +47 -10
  148. package/src/components/input-accessory-view/index.ts +41 -18
  149. package/src/components/keyboard-avoiding-view/index.ts +76 -22
  150. package/src/components/modal/index.ts +39 -12
  151. package/src/components/pressable/index.ts +121 -31
  152. package/src/components/refresh-control/index.ts +36 -13
  153. package/src/components/safe-area-view/index.ts +44 -19
  154. package/src/components/scroll-view/index.android.ts +31 -9
  155. package/src/components/scroll-view/index.ios.ts +17 -4
  156. package/src/components/scroll-view/projection.ts +290 -63
  157. package/src/components/scroll-view/shared.ts +124 -30
  158. package/src/components/scroll-view/sticky-header.ts +25 -8
  159. package/src/components/section-list/index.ts +79 -19
  160. package/src/components/switch/index.android.ts +18 -7
  161. package/src/components/switch/index.ios.ts +18 -7
  162. package/src/components/switch/shared.ts +15 -4
  163. package/src/components/text-input.ts +119 -31
  164. package/src/components/touchable/index.ts +339 -72
  165. package/src/components/touchable-native-feedback/index.ts +50 -18
  166. package/src/components/virtualized-list/directives.ts +19 -5
  167. package/src/components/virtualized-list/index.ts +292 -72
  168. package/src/components/virtualized-section-list/directives.ts +6 -3
  169. package/src/components/virtualized-section-list/index.ts +164 -34
  170. package/src/components.ts +6 -1
  171. package/src/create-portal/index.ts +47 -11
  172. package/src/create-tunnel/index.ts +3 -1
  173. package/src/descriptor-to-angular/index.ts +29 -8
  174. package/src/diagnostics.ts +138 -0
  175. package/src/gate-demand.ts +109 -0
  176. package/src/host-instance/index.ts +9 -3
  177. package/src/index.ts +33 -2
  178. package/src/modules/animated/create-animated-component.ts +67 -19
  179. package/src/modules/app-registry/index.ts +3 -1
  180. package/src/modules/status-bar/index.ts +13 -4
  181. package/src/primitives/index.ts +73 -2
  182. package/src/primitives/shared.ts +70 -9
  183. package/src/register.ts +43 -0
  184. package/src/render/index.ts +59 -7
  185. package/src/renderer/index.ts +209 -37
  186. package/src/services/color-scheme.service.ts +3 -1
  187. package/src/services/window-dimensions.service.ts +11 -4
  188. package/src/state-style.ts +39 -0
@@ -2,6 +2,7 @@ import {
2
2
  AnimatedProps,
3
3
  type AnimatedValue,
4
4
  dlog,
5
+ isDebug,
5
6
  isAnchor,
6
7
  reduceProps,
7
8
  routeProp,
@@ -14,6 +15,7 @@ import {
14
15
  type ISymbioteEvent,
15
16
  type ISymbioteNode,
16
17
  } from '@symbiote-native/engine';
18
+ import { countAngular } from '../../diagnostics';
17
19
  import {
18
20
  createInitialStickyState,
19
21
  readLayoutNumber,
@@ -26,11 +28,45 @@ import {
26
28
  import { descriptorFor } from '@symbiote-native/components';
27
29
  import { Platform } from '@symbiote-native/engine';
28
30
 
29
- type IInsert = (parent: ISymbioteNode, child: ISymbioteNode, beforeChild?: ISymbioteNode) => void;
31
+ type IInsert = (
32
+ parent: ISymbioteNode,
33
+ child: ISymbioteNode,
34
+ beforeChild?: ISymbioteNode,
35
+ ) => void;
30
36
  type IRemove = (parent: ISymbioteNode, child: ISymbioteNode) => void;
31
37
 
32
- const contentProjection = new WeakMap<ISymbioteNode, ScrollViewProjectionController>();
33
- const projectedWrappers = new WeakMap<ISymbioteNode, IProjectedRecord>();
38
+ const contentProjection = new WeakMap<
39
+ ISymbioteNode,
40
+ ScrollViewProjectionController
41
+ >();
42
+ // Which controller owns a projected child. Keyed by EVERY projected child, not only the wrapped
43
+ // ones: sticky indices are positional, so a child removed behind the controller's back leaves a dead
44
+ // record that inflates every later child's paint index (see removeScrollViewProjectedChild). A
45
+ // windowed list recycles cells continuously, so that drift grows with scroll distance.
46
+ const projectedOwners = new WeakMap<
47
+ ISymbioteNode,
48
+ ScrollViewProjectionController
49
+ >();
50
+
51
+ // Controllers with a coalesced sticky reconcile owed, drained once per change-detection pass by
52
+ // flushScrollViewProjections (see ScrollViewProjectionController.scheduleReconcile for why the
53
+ // flush must be synchronous rather than a microtask). A strong Set is correct here: an entry lives
54
+ // only from the mutation that queued it to the end of that same pass.
55
+ const pendingProjections = new Set<ScrollViewProjectionController>();
56
+
57
+ /**
58
+ * Run every sticky reconcile owed by this change-detection pass. Wired to the renderer factory's
59
+ * `end()`, which Angular calls at the end of `ApplicationRef.tick` — after both channels that feed
60
+ * a projection controller have written (children through the renderer, `stickyHeaderIndices` through
61
+ * an `@Input`), and before the surface's microtask-coalesced commit runs.
62
+ */
63
+ export function flushScrollViewProjections(): void {
64
+ if (pendingProjections.size === 0) return;
65
+ // Snapshot: a reconcile can wrap a record, and wrapping is itself a projected mutation.
66
+ const owed = [...pendingProjections];
67
+ pendingProjections.clear();
68
+ for (const controller of owed) controller.flushReconcile();
69
+ }
34
70
 
35
71
  interface IProjectedRecord {
36
72
  child: ISymbioteNode;
@@ -58,7 +94,10 @@ function directNode(record: IProjectedRecord): ISymbioteNode {
58
94
  }
59
95
 
60
96
  function isProjectedRefreshControl(node: ISymbioteNode): boolean {
61
- if (node.component === 'PullToRefreshView' || node.component === 'AndroidSwipeRefreshLayout') {
97
+ if (
98
+ node.component === 'PullToRefreshView' ||
99
+ node.component === 'AndroidSwipeRefreshLayout'
100
+ ) {
62
101
  return true;
63
102
  }
64
103
  // Angular public <RefreshControl> is an anchor host whose real native refresh node lives inside
@@ -87,13 +126,16 @@ class StickyProjectionWrapper {
87
126
 
88
127
  constructor(
89
128
  private readonly controller: ScrollViewProjectionController,
90
- private readonly childIndex: number,
129
+ // NOT readonly: a window step renumbers every paint index below it without changing which child
130
+ // this header is. See setChildIndex.
131
+ private childIndex: number,
91
132
  private readonly node: ISymbioteNode,
92
133
  ) {
93
- this.animatedTranslateY = this.controller.config.scrollAnimatedValue.interpolate({
94
- inputRange: [-1, 0],
95
- outputRange: [0, 0],
96
- });
134
+ this.animatedTranslateY =
135
+ this.controller.config.scrollAnimatedValue.interpolate({
136
+ inputRange: [-1, 0],
137
+ outputRange: [0, 0],
138
+ });
97
139
  dlog(
98
140
  `STICKY[wrap] created index=${childIndex} scrollValueIsNative=${this.controller.config.scrollAnimatedValue.__isNative()}`,
99
141
  );
@@ -123,6 +165,24 @@ class StickyProjectionWrapper {
123
165
  this.dispatch({ kind: 'inputs-changed' });
124
166
  }
125
167
 
168
+ // A window step renumbers every paint index below it while the header itself is unchanged: same
169
+ // child, same measured layout, new position among the projected children. Recreating the wrapper
170
+ // for that throws away `measured`/`layoutY` so the header re-measures from scratch, and costs a
171
+ // native round trip per step (restoreDefaultValues + dropAnimatedNode out, a fresh interpolation +
172
+ // AnimatedProps in). Feed the new index to the SAME state machine and let the reducer's range
173
+ // guard decide whether anything must be rebuilt.
174
+ //
175
+ // Identity is the CHILD - it owns the record, the record owns this wrapper - never the position.
176
+ // Angular CDK's virtual scroll makes the same call: on a window move `_updateContext` writes
177
+ // `view.context.index = renderedRange.start + i` into the EXISTING view rather than recreating it,
178
+ // and its trackBy is offset by `renderedRange.start` so identity stays absolute
179
+ // (`cdk/scrolling/virtual-for-of.ts`).
180
+ setChildIndex(next: number): void {
181
+ if (this.childIndex === next) return;
182
+ this.childIndex = next;
183
+ this.dispatch({ kind: 'inputs-changed' });
184
+ }
185
+
126
186
  private inputs(): IStickyReducerInputs {
127
187
  return {
128
188
  os: Platform.OS,
@@ -141,7 +201,10 @@ class StickyProjectionWrapper {
141
201
  for (const effect of effects) {
142
202
  switch (effect.kind) {
143
203
  case 'rebuild-interpolation': {
144
- if (this.interpolation !== undefined && this.listenerId !== undefined) {
204
+ if (
205
+ this.interpolation !== undefined &&
206
+ this.listenerId !== undefined
207
+ ) {
145
208
  this.interpolation.removeListener(this.listenerId);
146
209
  this.listenerId = undefined;
147
210
  }
@@ -159,7 +222,8 @@ class StickyProjectionWrapper {
159
222
  break;
160
223
  }
161
224
  case 'schedule-debounce':
162
- if (this.debounceTimer !== undefined) clearTimeout(this.debounceTimer);
225
+ if (this.debounceTimer !== undefined)
226
+ clearTimeout(this.debounceTimer);
163
227
  this.debounceTimer = setTimeout(() => {
164
228
  this.debounceTimer = undefined;
165
229
  this.dispatch({ kind: 'debounce-fired', value: effect.value });
@@ -178,7 +242,9 @@ class StickyProjectionWrapper {
178
242
  private readonly onLayout = (event: ISymbioteEvent): void => {
179
243
  const y = readLayoutNumber(event, 'y');
180
244
  const height = readLayoutNumber(event, 'height');
181
- dlog(`STICKY[wrap] index=${this.childIndex} onLayout y=${y} height=${height}`);
245
+ dlog(
246
+ `STICKY[wrap] index=${this.childIndex} onLayout y=${y} height=${height}`,
247
+ );
182
248
  // Keep the previous value when a field is absent (RN sets state only on a defined read).
183
249
  this.dispatch({
184
250
  kind: 'layout',
@@ -191,9 +257,17 @@ class StickyProjectionWrapper {
191
257
  // so the wrapper is JS-driven end to end. If the scroll value has been made native up front the
192
258
  // cascade to child listeners stops (AnimatedWithChildren.__callListeners skips children once
193
259
  // isNative) and this never fires - the header then renders in place and never moves.
194
- private readonly animatedValueListener = ({ value }: { value: number | string }): void => {
195
- dlog(() => `STICKY[wrap] index=${this.childIndex} animated-tick value=${String(value)}`);
196
- if (typeof value === 'number') this.dispatch({ kind: 'animated-tick', value });
260
+ private readonly animatedValueListener = ({
261
+ value,
262
+ }: {
263
+ value: number | string;
264
+ }): void => {
265
+ dlog(
266
+ () =>
267
+ `STICKY[wrap] index=${this.childIndex} animated-tick value=${String(value)}`,
268
+ );
269
+ if (typeof value === 'number')
270
+ this.dispatch({ kind: 'animated-tick', value });
197
271
  };
198
272
 
199
273
  private props(): Record<string, unknown> {
@@ -204,7 +278,10 @@ class StickyProjectionWrapper {
204
278
  const passthrough =
205
279
  this.state.translateY === null
206
280
  ? undefined
207
- : { transform: [{ translateY: this.state.translateY }], zIndex: STICKY_HEADER_Z_INDEX };
281
+ : {
282
+ transform: [{ translateY: this.state.translateY }],
283
+ zIndex: STICKY_HEADER_Z_INDEX,
284
+ };
208
285
  return {
209
286
  style: passthrough === undefined ? style : [style, passthrough],
210
287
  onLayout: this.onLayout,
@@ -224,7 +301,8 @@ class StickyProjectionWrapper {
224
301
  `scrollValueIsNative=${this.controller.config.scrollAnimatedValue.__isNative()} ` +
225
302
  `reducedTransform=${JSON.stringify(reduced.transform ?? reduced.style)}`,
226
303
  );
227
- for (const [key, value] of Object.entries(reduced)) routeProp(this.node, key, value);
304
+ for (const [key, value] of Object.entries(reduced))
305
+ routeProp(this.node, key, value);
228
306
 
229
307
  const next = new AnimatedProps(props);
230
308
  next.__attach();
@@ -244,7 +322,9 @@ class StickyProjectionWrapper {
244
322
  this.cancelBind?.();
245
323
  this.cancelBind = whenCommitted(this.node, () => {
246
324
  next.setNativeView(this.node);
247
- dlog(`STICKY[wrap] index=${this.childIndex} setNativeView isNative=${next.__isNative()}`);
325
+ dlog(
326
+ `STICKY[wrap] index=${this.childIndex} setNativeView isNative=${next.__isNative()}`,
327
+ );
248
328
  });
249
329
  }
250
330
  }
@@ -261,6 +341,8 @@ export class ScrollViewProjectionController {
261
341
  // impossible to read as either a real loop or two lists warming up in turn.
262
342
  private readonly instanceId = ++projectionControllerSeq;
263
343
  private reconcileSeq = 0;
344
+ private isReconcilePending = false;
345
+ private shouldNotifyAll = false;
264
346
 
265
347
  constructor(config: IScrollViewProjectionConfig) {
266
348
  this.config = config;
@@ -268,7 +350,46 @@ export class ScrollViewProjectionController {
268
350
 
269
351
  update(config: IScrollViewProjectionConfig): void {
270
352
  this.config = config;
271
- this.reconcileStickyRecords();
353
+ // The one caller that must notify every wrapper: scrollViewHeight / invertStickyHeaders live in
354
+ // this config and reach the wrappers through nothing else.
355
+ this.scheduleReconcile(true);
356
+ }
357
+
358
+ /**
359
+ * Coalesce every reconcile request of one change-detection pass into a SINGLE walk.
360
+ *
361
+ * Cost is half the reason: the walk is O(records), so running it per projected mutation makes
362
+ * mounting M children O(M^2) - 801 children on the benchmark screen's PATH A. Correctness is the
363
+ * worse half. A windowed list feeds the controller through TWO channels that land at different
364
+ * moments in the same pass: children arrive through the renderer (insert/remove), while
365
+ * `stickyHeaderIndices` arrive as an ordinary `@Input` via `update()` from ngOnChanges. Reconciling
366
+ * on each mutation therefore guarantees at least one walk matching the NEW children against the
367
+ * OLD indices - and those indices are positions in the rendered child array
368
+ * (`stickyChildPositions`), which shift by 1-3 the moment VirtualizedList grows a leading spacer or
369
+ * a forced sticky cell. Every mismatch re-labels a header and re-parents engine nodes for nothing.
370
+ *
371
+ * The flush point is `RendererFactory2.end()`: the end of the pass, still SYNCHRONOUS. It must not
372
+ * be a microtask - engine mutations only `markDirty`, the commit is microtask-coalesced by the
373
+ * surface, and a reconcile queued after that commit mutates a tree nobody commits again. Headless
374
+ * the sticky wrapper then never reaches Fabric at all (six tests caught exactly this); on device it
375
+ * lands a frame late or not at all. `end()` runs inside `ApplicationRef.tick`, so it precedes every
376
+ * microtask queued during the pass.
377
+ */
378
+ private scheduleReconcile(notifyAll = false): void {
379
+ countAngular('projectionSchedules');
380
+ this.shouldNotifyAll ||= notifyAll;
381
+ if (this.isReconcilePending) return;
382
+ this.isReconcilePending = true;
383
+ pendingProjections.add(this);
384
+ }
385
+
386
+ // Called by the renderer factory's end(). Public only for that seam.
387
+ flushReconcile(): void {
388
+ countAngular('projectionFlushes');
389
+ this.isReconcilePending = false;
390
+ const notifyAll = this.shouldNotifyAll;
391
+ this.shouldNotifyAll = false;
392
+ this.reconcileStickyRecords(notifyAll);
272
393
  }
273
394
 
274
395
  bindContentNode(node: ISymbioteNode): void {
@@ -279,18 +400,29 @@ export class ScrollViewProjectionController {
279
400
  contentProjection.set(node, this);
280
401
  if (this.records.length === 0 && node.children.length > 0) {
281
402
  for (const child of [...node.children]) {
282
- this.records.push({ child, wrapper: undefined, sticky: undefined, stickyIndex: undefined });
403
+ this.records.push({
404
+ child,
405
+ wrapper: undefined,
406
+ sticky: undefined,
407
+ stickyIndex: undefined,
408
+ });
409
+ projectedOwners.set(child, this);
283
410
  }
284
411
  }
285
- this.reconcileStickyRecords();
412
+ this.scheduleReconcile();
286
413
  }
287
414
 
288
- appendProjectedChild(parent: ISymbioteNode, child: ISymbioteNode, insert: IInsert): void {
415
+ appendProjectedChild(
416
+ parent: ISymbioteNode,
417
+ child: ISymbioteNode,
418
+ insert: IInsert,
419
+ ): void {
289
420
  dlog(
290
421
  `Angular ScrollView projection appendProjectedChild parent=${parent.component} child=${child.component} recordsBefore=${this.records.length}`,
291
422
  );
292
423
  const existing = this.records.find(record => record.child === child);
293
- if (existing !== undefined) this.records.splice(this.records.indexOf(existing), 1);
424
+ if (existing !== undefined)
425
+ this.records.splice(this.records.indexOf(existing), 1);
294
426
  const record: IProjectedRecord = {
295
427
  child,
296
428
  wrapper: undefined,
@@ -298,8 +430,9 @@ export class ScrollViewProjectionController {
298
430
  stickyIndex: undefined,
299
431
  };
300
432
  this.records.push(record);
433
+ projectedOwners.set(child, this);
301
434
  this.insertRecord(parent, record, undefined, insert);
302
- this.reconcileStickyRecords();
435
+ this.scheduleReconcile();
303
436
  }
304
437
 
305
438
  insertProjectedChild(
@@ -312,12 +445,14 @@ export class ScrollViewProjectionController {
312
445
  `Angular ScrollView projection insertProjectedChild parent=${parent.component} child=${child.component} before=${beforeChild ? `${beforeChild.component}` : 'null'} recordsBefore=${this.records.length}`,
313
446
  );
314
447
  const existing = this.records.find(record => record.child === child);
315
- if (existing !== undefined) this.records.splice(this.records.indexOf(existing), 1);
448
+ if (existing !== undefined)
449
+ this.records.splice(this.records.indexOf(existing), 1);
316
450
  const beforeRecord =
317
451
  beforeChild === null
318
452
  ? undefined
319
453
  : this.records.find(
320
- record => record.child === beforeChild || record.wrapper === beforeChild,
454
+ record =>
455
+ record.child === beforeChild || record.wrapper === beforeChild,
321
456
  );
322
457
  const record: IProjectedRecord = {
323
458
  child,
@@ -325,22 +460,40 @@ export class ScrollViewProjectionController {
325
460
  sticky: undefined,
326
461
  stickyIndex: undefined,
327
462
  };
463
+ // An insert-BEFORE whose anchor is not a record degrades into an append, here and again in
464
+ // insertRecord below - silently, which is how an ordering fault stays invisible until the screen
465
+ // is already wrong. A list that only ever appends (the benchmark screen's PATH A) never hits it;
466
+ // a virtualized list recycling cells into the middle does.
467
+ if (beforeChild !== null && beforeRecord === undefined) {
468
+ dlog(
469
+ () =>
470
+ `STICKY[proj#${this.instanceId}] ORDER insert-before anchor NOT a record ` +
471
+ `child=${child.component} before=${beforeChild.component} -> APPENDED to end`,
472
+ );
473
+ }
328
474
  const index =
329
- beforeRecord === undefined ? this.records.length : this.records.indexOf(beforeRecord);
475
+ beforeRecord === undefined
476
+ ? this.records.length
477
+ : this.records.indexOf(beforeRecord);
330
478
  this.records.splice(index, 0, record);
479
+ projectedOwners.set(child, this);
331
480
  this.insertRecord(parent, record, beforeRecord, insert);
332
- this.reconcileStickyRecords();
481
+ this.scheduleReconcile();
333
482
  }
334
483
 
335
484
  removeProjectedChild(child: ISymbioteNode, remove: IRemove): boolean {
336
- const record = this.records.find(entry => entry.child === child || entry.wrapper === child);
485
+ const record = this.records.find(
486
+ entry => entry.child === child || entry.wrapper === child,
487
+ );
337
488
  if (record === undefined || this.contentNode === undefined) return false;
338
489
  record.sticky?.destroy();
339
- projectedWrappers.delete(record.child);
490
+ projectedOwners.delete(record.child);
340
491
  const direct = directNode(record);
341
- if (direct.parent === this.contentNode) remove(this.contentNode, direct);
492
+ // The wrapper, when there is one, is the node actually parented to the content — detach
493
+ // whichever of the two carries the link, so a wrapped child never leaves its wrapper behind.
494
+ if (direct.parent !== undefined) remove(direct.parent, direct);
342
495
  this.records.splice(this.records.indexOf(record), 1);
343
- this.reconcileStickyRecords();
496
+ this.scheduleReconcile();
344
497
  return true;
345
498
  }
346
499
 
@@ -356,7 +509,22 @@ export class ScrollViewProjectionController {
356
509
  dlog(`STICKY[proj] recordHeaderLayoutY index=${index} y=${y}`);
357
510
  if (this.headerLayoutYs.get(index) === y) return;
358
511
  this.headerLayoutYs.set(index, y);
359
- for (const record of this.records) record.sticky?.rebuild();
512
+ // EXACTLY ONE wrapper reads this value. `nextStickyHeaderY(i)` returns the first sticky index
513
+ // greater than i, so `index` is the "next header" of the closest sticky index BELOW it and of
514
+ // no one else. Broadcasting to all of them instead cost O(N) per layout and O(N^2) to settle a
515
+ // screen - 200 headers answered "ranges unchanged, skipped rebuild" 199 times per layout event,
516
+ // 40 000 dispatches to lay out the benchmark screen's PATH A.
517
+ const stickyIndices = this.config.stickyHeaderIndices ?? [];
518
+ let previous: number | undefined;
519
+ // Same ascending-order assumption nextStickyHeaderY already makes.
520
+ for (const entry of stickyIndices) {
521
+ if (entry >= index) break;
522
+ previous = entry;
523
+ }
524
+ if (previous === undefined) return;
525
+ this.records
526
+ .find(record => record.stickyIndex === previous)
527
+ ?.sticky?.rebuild();
360
528
  }
361
529
 
362
530
  private insertRecord(
@@ -365,7 +533,8 @@ export class ScrollViewProjectionController {
365
533
  beforeRecord: IProjectedRecord | undefined,
366
534
  insert: IInsert,
367
535
  ): void {
368
- const before = beforeRecord === undefined ? undefined : directNode(beforeRecord);
536
+ const before =
537
+ beforeRecord === undefined ? undefined : directNode(beforeRecord);
369
538
  dlog(
370
539
  `STICKY[proj#${this.instanceId}] insertRecord child=${record.child.component} ` +
371
540
  `before=${before ? before.component : 'append'} parentIsContentNode=${parent === this.contentNode}`,
@@ -374,7 +543,14 @@ export class ScrollViewProjectionController {
374
543
  else insert(parent, record.child, before);
375
544
  }
376
545
 
377
- private reconcileStickyRecords(): void {
546
+ // notifyAll re-dispatches every sticky wrapper's inputs. Only a CONFIG change needs that
547
+ // (scrollViewHeight / invertStickyHeaders arrive through update() and reach the wrappers nowhere
548
+ // else). An INSERT must not: this walk runs once per projected child, so notifying N wrappers on
549
+ // each of M inserts is M x N - 801 x 200 = 160 200 dispatches to mount the benchmark screen's
550
+ // PATH A, versus 70 x 3 = 210 on the canary, which is why only the big screen dies. The two input
551
+ // changes an insert can actually cause are covered without a broadcast: a shifted childIndex by
552
+ // the stickyIndex branch below, and a moved neighbour by recordHeaderLayoutY.
553
+ private reconcileStickyRecords(notifyAll = false): void {
378
554
  if (this.contentNode === undefined) return;
379
555
  const seq = ++this.reconcileSeq;
380
556
  dlog(
@@ -383,7 +559,10 @@ export class ScrollViewProjectionController {
383
559
  `children=${this.contentNode?.children.length} sticky=${JSON.stringify(this.config.stickyHeaderIndices ?? [])}`,
384
560
  );
385
561
  const stickyIndices = new Set(this.config.stickyHeaderIndices ?? []);
386
- if (this.config.customStickyHeaderComponent !== undefined && stickyIndices.size > 0) {
562
+ if (
563
+ this.config.customStickyHeaderComponent !== undefined &&
564
+ stickyIndices.size > 0
565
+ ) {
387
566
  dlog(
388
567
  'STICKY[proj] uses built-in sticky wrapper; custom StickyHeaderComponent is explicit-composition only',
389
568
  );
@@ -391,7 +570,10 @@ export class ScrollViewProjectionController {
391
570
 
392
571
  let paintIndex = 0;
393
572
  for (const record of [...this.records]) {
394
- if (this.config.excludeRefreshControl && isProjectedRefreshControl(record.child)) {
573
+ if (
574
+ this.config.excludeRefreshControl &&
575
+ isProjectedRefreshControl(record.child)
576
+ ) {
395
577
  // Removed INLINE, not through removeProjectedChild: that method ends with its own
396
578
  // reconcileStickyRecords(), so calling it from inside this walk re-enters the walk while
397
579
  // this.records is being spliced under it - one refresh control could then drive an
@@ -399,20 +581,40 @@ export class ScrollViewProjectionController {
399
581
  this.dropRecord(record);
400
582
  continue;
401
583
  }
402
- const countsAsChild = !isAnchor(record.child) && !isProjectedRefreshControl(record.child);
584
+ const countsAsChild =
585
+ !isAnchor(record.child) && !isProjectedRefreshControl(record.child);
403
586
  const childIndex = paintIndex;
404
587
  if (countsAsChild) paintIndex += 1;
405
588
  const shouldWrap = countsAsChild && stickyIndices.has(childIndex);
406
589
  // Deliberately NOT logged per child per walk: reconcile runs on every projected insert, so
407
590
  // that shape is O(children x inserts) and buries the few lines that carry information. Only
408
591
  // a state TRANSITION is logged, below.
409
- if (shouldWrap && record.wrapper === undefined) this.wrapRecord(record, childIndex);
410
- else if (!shouldWrap && record.wrapper !== undefined) this.unwrapRecord(record);
592
+ if (shouldWrap && record.wrapper === undefined)
593
+ this.wrapRecord(record, childIndex);
594
+ else if (!shouldWrap && record.wrapper !== undefined)
595
+ this.unwrapRecord(record);
411
596
  else if (shouldWrap && record.stickyIndex !== childIndex) {
412
- record.sticky?.destroy();
413
- record.sticky = new StickyProjectionWrapper(this, childIndex, directNode(record));
597
+ countAngular('stickyWrapperReuses');
598
+ record.sticky?.setChildIndex(childIndex);
414
599
  record.stickyIndex = childIndex;
415
- } else if (shouldWrap) record.sticky?.rebuild();
600
+ } else if (shouldWrap && notifyAll) record.sticky?.rebuild();
601
+ }
602
+
603
+ // `paintIndex` above is counted off RECORDS, but the screen paints `contentNode.children`. The
604
+ // reconcile line at the top prints only the two COUNTS, which stay equal under any permutation
605
+ // - so it can never see this. Once the orders diverge, every sticky index addresses the wrong
606
+ // child and each wrap moves another node, so the fault compounds with scroll distance instead
607
+ // of correcting itself. Guarded by isDebug because it walks both lists.
608
+ if (!isDebug()) return;
609
+ const expected = this.records.map(record => directNode(record));
610
+ const painted = this.contentNode?.children ?? [];
611
+ const at = expected.findIndex((node, index) => node !== painted[index]);
612
+ if (at >= 0 || expected.length !== painted.length) {
613
+ dlog(
614
+ `STICKY[proj#${this.instanceId}] ORDER DIVERGED at ${at} ` +
615
+ `records=${expected.length} painted=${painted.length} ` +
616
+ `expected=${expected[at]?.component ?? 'none'} painted=${painted[at]?.component ?? 'none'}`,
617
+ );
416
618
  }
417
619
  }
418
620
 
@@ -421,9 +623,10 @@ export class ScrollViewProjectionController {
421
623
  private dropRecord(record: IProjectedRecord): void {
422
624
  if (this.contentNode === undefined) return;
423
625
  record.sticky?.destroy();
424
- projectedWrappers.delete(record.child);
626
+ projectedOwners.delete(record.child);
425
627
  const direct = directNode(record);
426
- if (direct.parent === this.contentNode) removeChild(this.contentNode, direct);
628
+ if (direct.parent === this.contentNode)
629
+ removeChild(this.contentNode, direct);
427
630
  const index = this.records.indexOf(record);
428
631
  if (index !== -1) this.records.splice(index, 1);
429
632
  }
@@ -438,27 +641,52 @@ export class ScrollViewProjectionController {
438
641
  private wrapRecord(record: IProjectedRecord, childIndex: number): void {
439
642
  if (this.contentNode === undefined) return;
440
643
  const wrapper = createViewNode();
441
- const currentParent = record.child.parent;
442
- const currentIndex = currentParent?.children.indexOf(record.child) ?? -1;
443
- if (currentParent === this.contentNode && currentIndex >= 0) {
444
- insertBefore(this.contentNode, wrapper, record.child);
445
- removeChild(this.contentNode, record.child);
446
- } else {
447
- appendChild(this.contentNode, wrapper);
448
- }
644
+ // The slot comes from `records`, the list that always knows where this record sits, never from
645
+ // the child's own current parent. Read off the child, a child not sitting DIRECTLY in the content
646
+ // has no slot to give up and the wrapper lands at the END - the wrapped cell jumps to the bottom
647
+ // of the scroll content, a silent reordering that compounds with scroll distance instead of
648
+ // correcting itself, and a windowed list reaches that state routinely. Angular CDK carries the
649
+ // same warning at its own insert site: insert AT the index, never insert-then-move, because an
650
+ // extra anchor node in between throws the move off (`cdk/scrolling/virtual-for-of.ts`,
651
+ // `_getEmbeddedViewArgs`) - and this file's tree is full of anchors.
652
+ //
653
+ // Order is load-bearing: place the wrapper at the slot FIRST, then move the child into it. The
654
+ // engine's appendChild detaches the child from its old parent, so the wrapper slides into the
655
+ // position the child vacates and no explicit removeChild is needed.
656
+ const after = this.nextDirectAfter(record);
657
+ if (after === undefined) appendChild(this.contentNode, wrapper);
658
+ else insertBefore(this.contentNode, wrapper, after);
449
659
  appendChild(wrapper, record.child);
450
660
  record.wrapper = wrapper;
661
+ countAngular('stickyWrapperCreates');
451
662
  record.sticky = new StickyProjectionWrapper(this, childIndex, wrapper);
452
663
  record.stickyIndex = childIndex;
453
- projectedWrappers.set(record.child, record);
454
664
  dlog(
455
665
  `STICKY[proj#${this.instanceId}] WRAP child=${record.child.component} paintIndex=${childIndex}`,
456
666
  );
457
667
  }
458
668
 
669
+ // The painted node of the first record AFTER `record` that is actually parented to the content —
670
+ // i.e. the anchor the wrapper must be inserted before. Records whose node has not been inserted
671
+ // yet are skipped rather than treated as a slot: a record exists from the moment the renderer
672
+ // reports the child, which can precede its placement.
673
+ private nextDirectAfter(record: IProjectedRecord): ISymbioteNode | undefined {
674
+ for (
675
+ let index = this.records.indexOf(record) + 1;
676
+ index < this.records.length;
677
+ index += 1
678
+ ) {
679
+ const node = directNode(this.records[index]);
680
+ if (node.parent === this.contentNode) return node;
681
+ }
682
+ return undefined;
683
+ }
684
+
459
685
  private unwrapRecord(record: IProjectedRecord): void {
460
686
  if (this.contentNode === undefined || record.wrapper === undefined) return;
461
- dlog(`STICKY[proj#${this.instanceId}] UNWRAP child=${record.child.component}`);
687
+ dlog(
688
+ `STICKY[proj#${this.instanceId}] UNWRAP child=${record.child.component}`,
689
+ );
462
690
  record.sticky?.destroy();
463
691
  record.sticky = undefined;
464
692
  record.stickyIndex = undefined;
@@ -466,7 +694,6 @@ export class ScrollViewProjectionController {
466
694
  insertBefore(this.contentNode, record.child, wrapper);
467
695
  removeChild(this.contentNode, wrapper);
468
696
  record.wrapper = undefined;
469
- projectedWrappers.delete(record.child);
470
697
  }
471
698
  }
472
699
 
@@ -476,11 +703,11 @@ export function getScrollViewProjection(
476
703
  return contentProjection.get(node);
477
704
  }
478
705
 
479
- export function removeScrollViewProjectedChild(child: ISymbioteNode, remove: IRemove): boolean {
480
- const record = projectedWrappers.get(child);
481
- if (record === undefined) return false;
482
- const controller = record.wrapper?.parent
483
- ? contentProjection.get(record.wrapper.parent)
484
- : undefined;
485
- return controller?.removeProjectedChild(child, remove) ?? false;
706
+ export function removeScrollViewProjectedChild(
707
+ child: ISymbioteNode,
708
+ remove: IRemove,
709
+ ): boolean {
710
+ return (
711
+ projectedOwners.get(child)?.removeProjectedChild(child, remove) ?? false
712
+ );
486
713
  }