@symbiote-native/angular 1.0.0 → 2.0.1

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 (248) hide show
  1. package/README.md +13 -10
  2. package/babel-register-composed.cjs +41 -19
  3. package/bin/symbiote-angular-dev.cjs +16 -4
  4. package/build/angular/anchor-host-registry.js +1 -17
  5. package/build/angular/anchor-host-registry.js.map +1 -1
  6. package/build/angular/change-detection-flush.d.ts +31 -0
  7. package/build/angular/change-detection-flush.js +110 -0
  8. package/build/angular/change-detection-flush.js.map +1 -0
  9. package/build/angular/components/activity-indicator-props.d.ts +2 -0
  10. package/build/angular/components/activity-indicator-props.js +2 -0
  11. package/build/angular/components/activity-indicator-props.js.map +1 -0
  12. package/build/angular/components/button-props.d.ts +2 -0
  13. package/build/angular/components/button-props.js +2 -0
  14. package/build/angular/components/button-props.js.map +1 -0
  15. package/build/angular/components/flat-list/index.d.ts +2 -1
  16. package/build/angular/components/flat-list/index.js +22 -10
  17. package/build/angular/components/flat-list/index.js.map +1 -1
  18. package/build/angular/components/image-background-props.d.ts +6 -0
  19. package/build/angular/components/image-background-props.js +2 -0
  20. package/build/angular/components/image-background-props.js.map +1 -0
  21. package/build/angular/components/{image/shared.d.ts → image-shared.d.ts} +1 -1
  22. package/build/angular/components/{image/shared.js → image-shared.js} +7 -2
  23. package/build/angular/components/image-shared.js.map +1 -0
  24. package/build/angular/components/input-accessory-view-props.d.ts +7 -0
  25. package/build/angular/components/input-accessory-view-props.js +9 -0
  26. package/build/angular/components/input-accessory-view-props.js.map +1 -0
  27. package/build/angular/components/keyboard-avoiding-view/index.js +11 -17
  28. package/build/angular/components/keyboard-avoiding-view/index.js.map +1 -1
  29. package/build/angular/components/modal/index.d.ts +1 -0
  30. package/build/angular/components/modal/index.js +19 -12
  31. package/build/angular/components/modal/index.js.map +1 -1
  32. package/build/angular/components/pressable-props.d.ts +32 -0
  33. package/build/angular/components/pressable-props.js +11 -0
  34. package/build/angular/components/pressable-props.js.map +1 -0
  35. package/build/angular/components/refresh-control-props.d.ts +16 -0
  36. package/build/angular/components/refresh-control-props.js +14 -0
  37. package/build/angular/components/refresh-control-props.js.map +1 -0
  38. package/build/angular/components/safe-area-view-props.d.ts +7 -0
  39. package/build/angular/components/safe-area-view-props.js +11 -0
  40. package/build/angular/components/safe-area-view-props.js.map +1 -0
  41. package/build/angular/components/scroll-view-props.d.ts +76 -0
  42. package/build/angular/components/scroll-view-props.js +18 -0
  43. package/build/angular/components/scroll-view-props.js.map +1 -0
  44. package/build/angular/components/text-input-props.d.ts +45 -0
  45. package/build/angular/components/text-input-props.js +18 -0
  46. package/build/angular/components/text-input-props.js.map +1 -0
  47. package/build/angular/components/touchable-native-feedback/touchable-native-feedback-props.d.ts +6 -0
  48. package/build/angular/components/touchable-native-feedback/touchable-native-feedback-props.js +2 -0
  49. package/build/angular/components/touchable-native-feedback/touchable-native-feedback-props.js.map +1 -0
  50. package/build/angular/components/touchable-props.d.ts +14 -0
  51. package/build/angular/components/touchable-props.js +13 -0
  52. package/build/angular/components/touchable-props.js.map +1 -0
  53. package/build/angular/components/touchable-without-feedback/touchable-without-feedback-props.d.ts +3 -0
  54. package/build/angular/components/touchable-without-feedback/touchable-without-feedback-props.js +2 -0
  55. package/build/angular/components/touchable-without-feedback/touchable-without-feedback-props.js.map +1 -0
  56. package/build/angular/components/virtualized-list/index.d.ts +5 -1
  57. package/build/angular/components/virtualized-list/index.js +366 -280
  58. package/build/angular/components/virtualized-list/index.js.map +1 -1
  59. package/build/angular/components.d.ts +20 -26
  60. package/build/angular/components.js +12 -14
  61. package/build/angular/components.js.map +1 -1
  62. package/build/angular/create-portal/index.d.ts +2 -2
  63. package/build/angular/create-portal/index.js +5 -5
  64. package/build/angular/create-tunnel/index.js +1 -1
  65. package/build/angular/element-props.d.ts +53 -0
  66. package/build/angular/element-props.js +2 -0
  67. package/build/angular/element-props.js.map +1 -0
  68. package/build/angular/elements.d.ts +528 -0
  69. package/build/angular/elements.js +1444 -0
  70. package/build/angular/elements.js.map +1 -0
  71. package/build/angular/host-instance/index.js +1 -1
  72. package/build/angular/index.d.ts +4 -2
  73. package/build/angular/index.js +15 -1
  74. package/build/angular/index.js.map +1 -1
  75. package/build/angular/modules/animated/create-animated-component.d.ts +5 -6
  76. package/build/angular/modules/animated/create-animated-component.js +60 -73
  77. package/build/angular/modules/animated/create-animated-component.js.map +1 -1
  78. package/build/angular/modules/animated/index.js +1 -1
  79. package/build/angular/modules/animated/index.js.map +1 -1
  80. package/build/angular/modules/image/index.d.ts +3 -0
  81. package/build/angular/modules/image/index.js +16 -0
  82. package/build/angular/modules/image/index.js.map +1 -0
  83. package/build/angular/primitives/index.d.ts +18 -18
  84. package/build/angular/primitives/index.js +40 -36
  85. package/build/angular/primitives/index.js.map +1 -1
  86. package/build/angular/primitives/shared.d.ts +2 -3
  87. package/build/angular/primitives/shared.js +8 -53
  88. package/build/angular/primitives/shared.js.map +1 -1
  89. package/build/angular/register.d.ts +1 -1
  90. package/build/angular/register.js +12 -31
  91. package/build/angular/register.js.map +1 -1
  92. package/build/angular/render/index.js +3 -3
  93. package/build/angular/render/index.js.map +1 -1
  94. package/build/angular/renderer/index.js +113 -67
  95. package/build/angular/renderer/index.js.map +1 -1
  96. package/build/angular/renderer/value-change.d.ts +2 -0
  97. package/build/angular/renderer/value-change.js +9 -0
  98. package/build/angular/renderer/value-change.js.map +1 -0
  99. package/metro-config.cjs +54 -20
  100. package/metro-css-parser.cjs +2 -1
  101. package/package.json +5 -14
  102. package/src/anchor-host-registry.ts +1 -17
  103. package/src/change-detection-flush.ts +120 -0
  104. package/src/components/activity-indicator-props.ts +21 -0
  105. package/src/components/button-props.ts +26 -0
  106. package/src/components/flat-list/index.ts +12 -4
  107. package/src/components/image-background-props.ts +24 -0
  108. package/src/components/{image/shared.ts → image-shared.ts} +6 -1
  109. package/src/components/input-accessory-view-props.ts +21 -0
  110. package/src/components/keyboard-avoiding-view/index.ts +6 -9
  111. package/src/components/modal/index.ts +15 -7
  112. package/src/components/pressable-props.ts +73 -0
  113. package/src/components/refresh-control-props.ts +43 -0
  114. package/src/components/safe-area-view-props.ts +28 -0
  115. package/src/components/scroll-view-props.ts +109 -0
  116. package/src/components/text-input-props.ts +72 -0
  117. package/src/components/touchable-native-feedback/touchable-native-feedback-props.ts +23 -0
  118. package/src/components/touchable-props.ts +30 -0
  119. package/src/components/touchable-without-feedback/touchable-without-feedback-props.ts +21 -0
  120. package/src/components/virtualized-list/index.ts +239 -156
  121. package/src/components.ts +84 -35
  122. package/src/create-portal/index.ts +5 -5
  123. package/src/create-tunnel/index.ts +1 -1
  124. package/src/diagnostics.ts +1 -1
  125. package/src/element-props.ts +90 -0
  126. package/src/elements.ts +919 -0
  127. package/src/host-instance/index.ts +1 -1
  128. package/src/index.ts +52 -15
  129. package/src/modules/animated/create-animated-component.ts +50 -55
  130. package/src/modules/animated/index.ts +1 -1
  131. package/src/modules/image/index.ts +18 -0
  132. package/src/primitives/index.ts +22 -18
  133. package/src/primitives/shared.ts +11 -44
  134. package/src/register.ts +12 -40
  135. package/src/render/index.ts +3 -3
  136. package/src/renderer/index.ts +122 -73
  137. package/src/renderer/value-change.ts +8 -0
  138. package/babel-lower-host-primitives.cjs +0 -636
  139. package/build/angular/components/activity-indicator/index.android.d.ts +0 -13
  140. package/build/angular/components/activity-indicator/index.android.js +0 -29
  141. package/build/angular/components/activity-indicator/index.android.js.map +0 -1
  142. package/build/angular/components/activity-indicator/index.d.ts +0 -1
  143. package/build/angular/components/activity-indicator/index.ios.d.ts +0 -9
  144. package/build/angular/components/activity-indicator/index.ios.js +0 -26
  145. package/build/angular/components/activity-indicator/index.ios.js.map +0 -1
  146. package/build/angular/components/activity-indicator/index.js +0 -4
  147. package/build/angular/components/activity-indicator/index.js.map +0 -1
  148. package/build/angular/components/activity-indicator/shared.d.ts +0 -67
  149. package/build/angular/components/activity-indicator/shared.js +0 -260
  150. package/build/angular/components/activity-indicator/shared.js.map +0 -1
  151. package/build/angular/components/button.d.ts +0 -68
  152. package/build/angular/components/button.js +0 -360
  153. package/build/angular/components/button.js.map +0 -1
  154. package/build/angular/components/image/index.android.d.ts +0 -15
  155. package/build/angular/components/image/index.android.js +0 -83
  156. package/build/angular/components/image/index.android.js.map +0 -1
  157. package/build/angular/components/image/index.d.ts +0 -1
  158. package/build/angular/components/image/index.ios.d.ts +0 -15
  159. package/build/angular/components/image/index.ios.js +0 -83
  160. package/build/angular/components/image/index.ios.js.map +0 -1
  161. package/build/angular/components/image/index.js +0 -3
  162. package/build/angular/components/image/index.js.map +0 -1
  163. package/build/angular/components/image/shared.js.map +0 -1
  164. package/build/angular/components/image-background.d.ts +0 -85
  165. package/build/angular/components/image-background.js +0 -443
  166. package/build/angular/components/image-background.js.map +0 -1
  167. package/build/angular/components/input-accessory-view/index.d.ts +0 -66
  168. package/build/angular/components/input-accessory-view/index.js +0 -287
  169. package/build/angular/components/input-accessory-view/index.js.map +0 -1
  170. package/build/angular/components/pressable/index.d.ts +0 -134
  171. package/build/angular/components/pressable/index.js +0 -514
  172. package/build/angular/components/pressable/index.js.map +0 -1
  173. package/build/angular/components/refresh-control/index.d.ts +0 -91
  174. package/build/angular/components/refresh-control/index.js +0 -382
  175. package/build/angular/components/refresh-control/index.js.map +0 -1
  176. package/build/angular/components/safe-area-view/index.d.ts +0 -64
  177. package/build/angular/components/safe-area-view/index.js +0 -287
  178. package/build/angular/components/safe-area-view/index.js.map +0 -1
  179. package/build/angular/components/scroll-view/index.android.d.ts +0 -20
  180. package/build/angular/components/scroll-view/index.android.js +0 -262
  181. package/build/angular/components/scroll-view/index.android.js.map +0 -1
  182. package/build/angular/components/scroll-view/index.d.ts +0 -3
  183. package/build/angular/components/scroll-view/index.ios.d.ts +0 -8
  184. package/build/angular/components/scroll-view/index.ios.js +0 -92
  185. package/build/angular/components/scroll-view/index.ios.js.map +0 -1
  186. package/build/angular/components/scroll-view/index.js +0 -6
  187. package/build/angular/components/scroll-view/index.js.map +0 -1
  188. package/build/angular/components/scroll-view/projection.d.ts +0 -67
  189. package/build/angular/components/scroll-view/projection.js +0 -560
  190. package/build/angular/components/scroll-view/projection.js.map +0 -1
  191. package/build/angular/components/scroll-view/shared.d.ts +0 -241
  192. package/build/angular/components/scroll-view/shared.js +0 -848
  193. package/build/angular/components/scroll-view/shared.js.map +0 -1
  194. package/build/angular/components/scroll-view/sticky-header.d.ts +0 -40
  195. package/build/angular/components/scroll-view/sticky-header.js +0 -224
  196. package/build/angular/components/scroll-view/sticky-header.js.map +0 -1
  197. package/build/angular/components/switch/index.android.d.ts +0 -24
  198. package/build/angular/components/switch/index.android.js +0 -112
  199. package/build/angular/components/switch/index.android.js.map +0 -1
  200. package/build/angular/components/switch/index.d.ts +0 -1
  201. package/build/angular/components/switch/index.ios.d.ts +0 -23
  202. package/build/angular/components/switch/index.ios.js +0 -111
  203. package/build/angular/components/switch/index.ios.js.map +0 -1
  204. package/build/angular/components/switch/index.js +0 -4
  205. package/build/angular/components/switch/index.js.map +0 -1
  206. package/build/angular/components/switch/shared.d.ts +0 -49
  207. package/build/angular/components/switch/shared.js +0 -191
  208. package/build/angular/components/switch/shared.js.map +0 -1
  209. package/build/angular/components/text-input.d.ts +0 -167
  210. package/build/angular/components/text-input.js +0 -684
  211. package/build/angular/components/text-input.js.map +0 -1
  212. package/build/angular/components/touchable/index.d.ts +0 -283
  213. package/build/angular/components/touchable/index.js +0 -1453
  214. package/build/angular/components/touchable/index.js.map +0 -1
  215. package/build/angular/components/touchable-native-feedback/index.d.ts +0 -87
  216. package/build/angular/components/touchable-native-feedback/index.js +0 -409
  217. package/build/angular/components/touchable-native-feedback/index.js.map +0 -1
  218. package/build/angular/state-style.d.ts +0 -9
  219. package/build/angular/state-style.js +0 -39
  220. package/build/angular/state-style.js.map +0 -1
  221. package/metro-transformer.cjs +0 -69
  222. package/src/components/activity-indicator/index.android.ts +0 -23
  223. package/src/components/activity-indicator/index.ios.ts +0 -21
  224. package/src/components/activity-indicator/index.ts +0 -4
  225. package/src/components/activity-indicator/shared.ts +0 -225
  226. package/src/components/button.ts +0 -237
  227. package/src/components/image/index.android.ts +0 -95
  228. package/src/components/image/index.ios.ts +0 -95
  229. package/src/components/image/index.ts +0 -3
  230. package/src/components/image-background.ts +0 -308
  231. package/src/components/input-accessory-view/index.ts +0 -246
  232. package/src/components/pressable/index.ts +0 -522
  233. package/src/components/refresh-control/index.ts +0 -349
  234. package/src/components/safe-area-view/index.ts +0 -247
  235. package/src/components/scroll-view/index.android.ts +0 -205
  236. package/src/components/scroll-view/index.ios.ts +0 -81
  237. package/src/components/scroll-view/index.ts +0 -7
  238. package/src/components/scroll-view/projection.ts +0 -713
  239. package/src/components/scroll-view/shared.ts +0 -1093
  240. package/src/components/scroll-view/sticky-header.ts +0 -267
  241. package/src/components/switch/index.android.ts +0 -113
  242. package/src/components/switch/index.ios.ts +0 -112
  243. package/src/components/switch/index.ts +0 -4
  244. package/src/components/switch/shared.ts +0 -284
  245. package/src/components/text-input.ts +0 -669
  246. package/src/components/touchable/index.ts +0 -956
  247. package/src/components/touchable-native-feedback/index.ts +0 -327
  248. package/src/state-style.ts +0 -39
@@ -1,713 +0,0 @@
1
- import {
2
- AnimatedProps,
3
- type AnimatedValue,
4
- dlog,
5
- isDebug,
6
- isAnchor,
7
- reduceProps,
8
- routeProp,
9
- whenCommitted,
10
- createElement,
11
- appendChild,
12
- insertBefore,
13
- removeChild,
14
- type AnimatedInterpolation,
15
- type ISymbioteEvent,
16
- type ISymbioteNode,
17
- } from '@symbiote-native/engine';
18
- import { countAngular } from '../../diagnostics';
19
- import {
20
- createInitialStickyState,
21
- readLayoutNumber,
22
- reduceSticky,
23
- STICKY_HEADER_Z_INDEX,
24
- type IStickyAction,
25
- type IStickyEffect,
26
- type IStickyReducerInputs,
27
- } from '@symbiote-native/components';
28
- import { descriptorFor } from '@symbiote-native/components';
29
- import { Platform } from '@symbiote-native/engine';
30
-
31
- type IInsert = (
32
- parent: ISymbioteNode,
33
- child: ISymbioteNode,
34
- beforeChild?: ISymbioteNode,
35
- ) => void;
36
- type IRemove = (parent: ISymbioteNode, child: ISymbioteNode) => void;
37
-
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
- }
70
-
71
- interface IProjectedRecord {
72
- child: ISymbioteNode;
73
- wrapper: ISymbioteNode | undefined;
74
- sticky: StickyProjectionWrapper | undefined;
75
- stickyIndex: number | undefined;
76
- }
77
-
78
- export interface IScrollViewProjectionConfig {
79
- stickyHeaderIndices: readonly number[] | undefined;
80
- invertStickyHeaders: boolean | undefined;
81
- scrollViewHeight: number | undefined;
82
- scrollAnimatedValue: AnimatedValue;
83
- customStickyHeaderComponent: unknown;
84
- excludeRefreshControl: boolean;
85
- }
86
-
87
- function createViewNode(): ISymbioteNode {
88
- const descriptor = descriptorFor('symbiote-view');
89
- return createElement(descriptor.component, descriptor.isText);
90
- }
91
-
92
- function directNode(record: IProjectedRecord): ISymbioteNode {
93
- return record.wrapper ?? record.child;
94
- }
95
-
96
- function isProjectedRefreshControl(node: ISymbioteNode): boolean {
97
- if (
98
- node.component === 'PullToRefreshView' ||
99
- node.component === 'AndroidSwipeRefreshLayout'
100
- ) {
101
- return true;
102
- }
103
- // Angular public <RefreshControl> is an anchor host whose real native refresh node lives inside
104
- // its component view. Projection filtering must recognize that anchor, otherwise iOS re-renders
105
- // a RefreshControl sibling and also leaves the projected one inside the scroll content.
106
- return isAnchor(node) && node.children.some(isProjectedRefreshControl);
107
- }
108
-
109
- // The SECOND Angular sticky effect-runner (auto-projected children can't be arbitrary Angular
110
- // component classes, so the controller drives engine nodes directly). It shares the ONE
111
- // reduceSticky state machine with ScrollViewStickyHeader — the DECISIONS (zero-swallow gate, debounce
112
- // delay, rebuild ranges, cross-talk record) live there; this runner only EXECUTES the effects on the
113
- // engine node (build interpolation + wire listener, hold the debounce timer, re-apply the node props).
114
- // Because it owns the child index, it consumes the reducer's record-header-y effect for cross-talk.
115
- // Diagnostic-only: a process-wide counter so each controller's log lines are attributable.
116
- let projectionControllerSeq = 0;
117
-
118
- class StickyProjectionWrapper {
119
- private readonly state = createInitialStickyState();
120
- private animatedTranslateY: AnimatedInterpolation;
121
- private interpolation: AnimatedInterpolation | undefined;
122
- private animatedProps: AnimatedProps | undefined;
123
- private cancelBind: (() => void) | undefined;
124
- private listenerId: string | undefined;
125
- private debounceTimer: ReturnType<typeof setTimeout> | undefined;
126
-
127
- constructor(
128
- private readonly controller: ScrollViewProjectionController,
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,
132
- private readonly node: ISymbioteNode,
133
- ) {
134
- this.animatedTranslateY =
135
- this.controller.config.scrollAnimatedValue.interpolate({
136
- inputRange: [-1, 0],
137
- outputRange: [0, 0],
138
- });
139
- dlog(
140
- `STICKY[wrap] created index=${childIndex} scrollValueIsNative=${this.controller.config.scrollAnimatedValue.__isNative()}`,
141
- );
142
- this.dispatch({ kind: 'inputs-changed' });
143
- }
144
-
145
- destroy(): void {
146
- this.cancelBind?.();
147
- this.cancelBind = undefined;
148
- if (this.interpolation !== undefined && this.listenerId !== undefined) {
149
- this.interpolation.removeListener(this.listenerId);
150
- this.listenerId = undefined;
151
- }
152
- if (this.debounceTimer !== undefined) {
153
- clearTimeout(this.debounceTimer);
154
- this.debounceTimer = undefined;
155
- }
156
- if (this.animatedProps !== undefined) {
157
- this.animatedProps.__detach();
158
- this.animatedProps = undefined;
159
- }
160
- }
161
-
162
- // The controller calls this when a cross-talk input changed (a sibling recorded its y): rebuild the
163
- // ranges off the new nextStickyHeaderY.
164
- rebuild(): void {
165
- this.dispatch({ kind: 'inputs-changed' });
166
- }
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
-
186
- private inputs(): IStickyReducerInputs {
187
- return {
188
- os: Platform.OS,
189
- inverted: this.controller.config.invertStickyHeaders,
190
- scrollViewHeight: this.controller.config.scrollViewHeight,
191
- nextHeaderLayoutY: this.controller.nextStickyHeaderY(this.childIndex),
192
- index: this.childIndex,
193
- };
194
- }
195
-
196
- private dispatch(action: IStickyAction): void {
197
- this.runEffects(reduceSticky(this.state, action, this.inputs()).effects);
198
- }
199
-
200
- private runEffects(effects: IStickyEffect[]): void {
201
- for (const effect of effects) {
202
- switch (effect.kind) {
203
- case 'rebuild-interpolation': {
204
- if (
205
- this.interpolation !== undefined &&
206
- this.listenerId !== undefined
207
- ) {
208
- this.interpolation.removeListener(this.listenerId);
209
- this.listenerId = undefined;
210
- }
211
- const next = this.controller.config.scrollAnimatedValue.interpolate({
212
- inputRange: effect.inputRange,
213
- outputRange: effect.outputRange,
214
- });
215
- this.listenerId = next.addListener(this.animatedValueListener);
216
- this.interpolation = next;
217
- this.animatedTranslateY = next;
218
- this.applyProps();
219
- dlog(
220
- `STICKY[wrap] rebuilt index=${this.childIndex} measured=${this.state.measured} y=${this.state.layoutY}`,
221
- );
222
- break;
223
- }
224
- case 'schedule-debounce':
225
- if (this.debounceTimer !== undefined)
226
- clearTimeout(this.debounceTimer);
227
- this.debounceTimer = setTimeout(() => {
228
- this.debounceTimer = undefined;
229
- this.dispatch({ kind: 'debounce-fired', value: effect.value });
230
- }, effect.delay);
231
- break;
232
- case 'apply-passthrough':
233
- this.applyProps();
234
- break;
235
- case 'record-header-y':
236
- this.controller.recordHeaderLayoutY(effect.index, effect.y);
237
- break;
238
- }
239
- }
240
- }
241
-
242
- private readonly onLayout = (event: ISymbioteEvent): void => {
243
- const y = readLayoutNumber(event, 'y');
244
- const height = readLayoutNumber(event, 'height');
245
- dlog(
246
- `STICKY[wrap] index=${this.childIndex} onLayout y=${y} height=${height}`,
247
- );
248
- // Keep the previous value when a field is absent (RN sets state only on a defined read).
249
- this.dispatch({
250
- kind: 'layout',
251
- y: y ?? this.state.layoutY,
252
- height: height ?? this.state.layoutHeight,
253
- });
254
- };
255
-
256
- // The ONLY thing that drives the committed pin on this path: applyProps has no __makeNative(),
257
- // so the wrapper is JS-driven end to end. If the scroll value has been made native up front the
258
- // cascade to child listeners stops (AnimatedWithChildren.__callListeners skips children once
259
- // isNative) and this never fires - the header then renders in place and never moves.
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 });
271
- };
272
-
273
- private props(): Record<string, unknown> {
274
- const style = {
275
- transform: [{ translateY: this.animatedTranslateY }],
276
- zIndex: STICKY_HEADER_Z_INDEX,
277
- };
278
- const passthrough =
279
- this.state.translateY === null
280
- ? undefined
281
- : {
282
- transform: [{ translateY: this.state.translateY }],
283
- zIndex: STICKY_HEADER_Z_INDEX,
284
- };
285
- return {
286
- style: passthrough === undefined ? style : [style, passthrough],
287
- onLayout: this.onLayout,
288
- collapsable: false,
289
- };
290
- }
291
-
292
- private applyProps(): void {
293
- const props = this.props();
294
- const reduced = reduceProps(props);
295
- // Thunk: applyProps is on the scroll-driven path (every animated-tick while the pin is
296
- // JS-driven), and JSON.stringify of a reduced transform is not free.
297
- dlog(
298
- () =>
299
- `STICKY[wrap] index=${this.childIndex} applyProps translateY=${String(this.state.translateY)} ` +
300
- `measured=${this.state.measured} layoutY=${this.state.layoutY} ` +
301
- `scrollValueIsNative=${this.controller.config.scrollAnimatedValue.__isNative()} ` +
302
- `reducedTransform=${JSON.stringify(reduced.transform ?? reduced.style)}`,
303
- );
304
- for (const [key, value] of Object.entries(reduced))
305
- routeProp(this.node, key, value);
306
-
307
- const next = new AnimatedProps(props);
308
- next.__attach();
309
- // No explicit __makeNative() here on purpose: __attach joins the leaf to the interpolation,
310
- // which sits under the shared scroll value, and AnimatedWithChildren.__addChild promotes a
311
- // child joining an ALREADY-native parent (graph.ts). So the pin is native exactly when the
312
- // scroll value is - logged below, because "is this actually on the UI thread" is otherwise
313
- // invisible until you read three files.
314
- dlog(
315
- () =>
316
- `STICKY[wrap] index=${this.childIndex} leaf attached isNative=${next.__isNative()} ` +
317
- `interpolationIsNative=${this.animatedTranslateY.__isNative()} ` +
318
- `scrollValueIsNative=${this.controller.config.scrollAnimatedValue.__isNative()}`,
319
- );
320
- if (this.animatedProps !== undefined) this.animatedProps.__detach();
321
- this.animatedProps = next;
322
- this.cancelBind?.();
323
- this.cancelBind = whenCommitted(this.node, () => {
324
- next.setNativeView(this.node);
325
- dlog(
326
- `STICKY[wrap] index=${this.childIndex} setNativeView isNative=${next.__isNative()}`,
327
- );
328
- });
329
- }
330
- }
331
-
332
- export class ScrollViewProjectionController {
333
- config: IScrollViewProjectionConfig;
334
- private contentNode: ISymbioteNode | undefined;
335
- private readonly records: IProjectedRecord[] = [];
336
- private readonly headerLayoutYs = new Map<number, number>();
337
-
338
- // Diagnostic-only: one ScrollView can host several projection controllers over its lifetime,
339
- // and an app has several ScrollViews at once. Without an id every log line below is
340
- // indistinguishable between instances, which is what made a "logs never stop" report
341
- // impossible to read as either a real loop or two lists warming up in turn.
342
- private readonly instanceId = ++projectionControllerSeq;
343
- private reconcileSeq = 0;
344
- private isReconcilePending = false;
345
- private shouldNotifyAll = false;
346
-
347
- constructor(config: IScrollViewProjectionConfig) {
348
- this.config = config;
349
- }
350
-
351
- update(config: IScrollViewProjectionConfig): void {
352
- this.config = config;
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);
393
- }
394
-
395
- bindContentNode(node: ISymbioteNode): void {
396
- dlog(
397
- `Angular ScrollView projection bindContentNode node=${node.component} preExistingChildren=${node.children.length} recordsBefore=${this.records.length}`,
398
- );
399
- this.contentNode = node;
400
- contentProjection.set(node, this);
401
- if (this.records.length === 0 && node.children.length > 0) {
402
- for (const child of [...node.children]) {
403
- this.records.push({
404
- child,
405
- wrapper: undefined,
406
- sticky: undefined,
407
- stickyIndex: undefined,
408
- });
409
- projectedOwners.set(child, this);
410
- }
411
- }
412
- this.scheduleReconcile();
413
- }
414
-
415
- appendProjectedChild(
416
- parent: ISymbioteNode,
417
- child: ISymbioteNode,
418
- insert: IInsert,
419
- ): void {
420
- dlog(
421
- `Angular ScrollView projection appendProjectedChild parent=${parent.component} child=${child.component} recordsBefore=${this.records.length}`,
422
- );
423
- const existing = this.records.find(record => record.child === child);
424
- if (existing !== undefined)
425
- this.records.splice(this.records.indexOf(existing), 1);
426
- const record: IProjectedRecord = {
427
- child,
428
- wrapper: undefined,
429
- sticky: undefined,
430
- stickyIndex: undefined,
431
- };
432
- this.records.push(record);
433
- projectedOwners.set(child, this);
434
- this.insertRecord(parent, record, undefined, insert);
435
- this.scheduleReconcile();
436
- }
437
-
438
- insertProjectedChild(
439
- parent: ISymbioteNode,
440
- child: ISymbioteNode,
441
- beforeChild: ISymbioteNode | null,
442
- insert: IInsert,
443
- ): void {
444
- dlog(
445
- `Angular ScrollView projection insertProjectedChild parent=${parent.component} child=${child.component} before=${beforeChild ? `${beforeChild.component}` : 'null'} recordsBefore=${this.records.length}`,
446
- );
447
- const existing = this.records.find(record => record.child === child);
448
- if (existing !== undefined)
449
- this.records.splice(this.records.indexOf(existing), 1);
450
- const beforeRecord =
451
- beforeChild === null
452
- ? undefined
453
- : this.records.find(
454
- record =>
455
- record.child === beforeChild || record.wrapper === beforeChild,
456
- );
457
- const record: IProjectedRecord = {
458
- child,
459
- wrapper: undefined,
460
- sticky: undefined,
461
- stickyIndex: undefined,
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
- }
474
- const index =
475
- beforeRecord === undefined
476
- ? this.records.length
477
- : this.records.indexOf(beforeRecord);
478
- this.records.splice(index, 0, record);
479
- projectedOwners.set(child, this);
480
- this.insertRecord(parent, record, beforeRecord, insert);
481
- this.scheduleReconcile();
482
- }
483
-
484
- removeProjectedChild(child: ISymbioteNode, remove: IRemove): boolean {
485
- const record = this.records.find(
486
- entry => entry.child === child || entry.wrapper === child,
487
- );
488
- if (record === undefined || this.contentNode === undefined) return false;
489
- record.sticky?.destroy();
490
- projectedOwners.delete(record.child);
491
- const direct = directNode(record);
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);
495
- this.records.splice(this.records.indexOf(record), 1);
496
- this.scheduleReconcile();
497
- return true;
498
- }
499
-
500
- nextStickyHeaderY(index: number): number | undefined {
501
- const stickyIndices = this.config.stickyHeaderIndices ?? [];
502
- const next = stickyIndices.find(entry => entry > index);
503
- const y = next === undefined ? undefined : this.headerLayoutYs.get(next);
504
- dlog(`STICKY[proj] nextStickyHeaderY(index=${index}) next=${next} y=${y}`);
505
- return y;
506
- }
507
-
508
- recordHeaderLayoutY(index: number, y: number): void {
509
- dlog(`STICKY[proj] recordHeaderLayoutY index=${index} y=${y}`);
510
- if (this.headerLayoutYs.get(index) === y) return;
511
- this.headerLayoutYs.set(index, y);
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();
528
- }
529
-
530
- private insertRecord(
531
- parent: ISymbioteNode,
532
- record: IProjectedRecord,
533
- beforeRecord: IProjectedRecord | undefined,
534
- insert: IInsert,
535
- ): void {
536
- const before =
537
- beforeRecord === undefined ? undefined : directNode(beforeRecord);
538
- dlog(
539
- `STICKY[proj#${this.instanceId}] insertRecord child=${record.child.component} ` +
540
- `before=${before ? before.component : 'append'} parentIsContentNode=${parent === this.contentNode}`,
541
- );
542
- if (before === undefined) insert(parent, record.child);
543
- else insert(parent, record.child, before);
544
- }
545
-
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 {
554
- if (this.contentNode === undefined) return;
555
- const seq = ++this.reconcileSeq;
556
- dlog(
557
- () =>
558
- `STICKY[proj#${this.instanceId}] reconcile#${seq} records=${this.records.length} ` +
559
- `children=${this.contentNode?.children.length} sticky=${JSON.stringify(this.config.stickyHeaderIndices ?? [])}`,
560
- );
561
- const stickyIndices = new Set(this.config.stickyHeaderIndices ?? []);
562
- if (
563
- this.config.customStickyHeaderComponent !== undefined &&
564
- stickyIndices.size > 0
565
- ) {
566
- dlog(
567
- 'STICKY[proj] uses built-in sticky wrapper; custom StickyHeaderComponent is explicit-composition only',
568
- );
569
- }
570
-
571
- let paintIndex = 0;
572
- for (const record of [...this.records]) {
573
- if (
574
- this.config.excludeRefreshControl &&
575
- isProjectedRefreshControl(record.child)
576
- ) {
577
- // Removed INLINE, not through removeProjectedChild: that method ends with its own
578
- // reconcileStickyRecords(), so calling it from inside this walk re-enters the walk while
579
- // this.records is being spliced under it - one refresh control could then drive an
580
- // unbounded recursion. Same removal, without the re-entry.
581
- this.dropRecord(record);
582
- continue;
583
- }
584
- const countsAsChild =
585
- !isAnchor(record.child) && !isProjectedRefreshControl(record.child);
586
- const childIndex = paintIndex;
587
- if (countsAsChild) paintIndex += 1;
588
- const shouldWrap = countsAsChild && stickyIndices.has(childIndex);
589
- // Deliberately NOT logged per child per walk: reconcile runs on every projected insert, so
590
- // that shape is O(children x inserts) and buries the few lines that carry information. Only
591
- // a state TRANSITION is logged, below.
592
- if (shouldWrap && record.wrapper === undefined)
593
- this.wrapRecord(record, childIndex);
594
- else if (!shouldWrap && record.wrapper !== undefined)
595
- this.unwrapRecord(record);
596
- else if (shouldWrap && record.stickyIndex !== childIndex) {
597
- countAngular('stickyWrapperReuses');
598
- record.sticky?.setChildIndex(childIndex);
599
- record.stickyIndex = childIndex;
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
- );
618
- }
619
- }
620
-
621
- // The removal half of removeProjectedChild, without its trailing reconcile - safe to call from
622
- // inside the reconcile walk itself.
623
- private dropRecord(record: IProjectedRecord): void {
624
- if (this.contentNode === undefined) return;
625
- record.sticky?.destroy();
626
- projectedOwners.delete(record.child);
627
- const direct = directNode(record);
628
- if (direct.parent === this.contentNode)
629
- removeChild(this.contentNode, direct);
630
- const index = this.records.indexOf(record);
631
- if (index !== -1) this.records.splice(index, 1);
632
- }
633
-
634
- // Auto projection runs after Angular has already created projected child host nodes. At this
635
- // renderer boundary we can AOT-safely rearrange engine nodes, but we cannot instantiate an
636
- // arbitrary Angular component class as a wrapper without owning a ViewContainerRef/injector and
637
- // Angular projectable nodes for its <ng-content>. Therefore the automatic sticky path always uses
638
- // the built-in engine-node wrapper; custom StickyHeaderComponent wrappers remain supported by
639
- // explicit Angular composition with <ScrollViewStickyHeader>/<symbiote-sticky-header> or a user
640
- // component that composes it in the template.
641
- private wrapRecord(record: IProjectedRecord, childIndex: number): void {
642
- if (this.contentNode === undefined) return;
643
- const wrapper = createViewNode();
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);
659
- appendChild(wrapper, record.child);
660
- record.wrapper = wrapper;
661
- countAngular('stickyWrapperCreates');
662
- record.sticky = new StickyProjectionWrapper(this, childIndex, wrapper);
663
- record.stickyIndex = childIndex;
664
- dlog(
665
- `STICKY[proj#${this.instanceId}] WRAP child=${record.child.component} paintIndex=${childIndex}`,
666
- );
667
- }
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
-
685
- private unwrapRecord(record: IProjectedRecord): void {
686
- if (this.contentNode === undefined || record.wrapper === undefined) return;
687
- dlog(
688
- `STICKY[proj#${this.instanceId}] UNWRAP child=${record.child.component}`,
689
- );
690
- record.sticky?.destroy();
691
- record.sticky = undefined;
692
- record.stickyIndex = undefined;
693
- const wrapper = record.wrapper;
694
- insertBefore(this.contentNode, record.child, wrapper);
695
- removeChild(this.contentNode, wrapper);
696
- record.wrapper = undefined;
697
- }
698
- }
699
-
700
- export function getScrollViewProjection(
701
- node: ISymbioteNode,
702
- ): ScrollViewProjectionController | undefined {
703
- return contentProjection.get(node);
704
- }
705
-
706
- export function removeScrollViewProjectedChild(
707
- child: ISymbioteNode,
708
- remove: IRemove,
709
- ): boolean {
710
- return (
711
- projectedOwners.get(child)?.removeProjectedChild(child, remove) ?? false
712
- );
713
- }