@symbiote-native/angular 0.6.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (191) hide show
  1. package/README.md +3 -3
  2. package/bin/symbiote-angular-dev.cjs +20 -19
  3. package/build/angular/anchor-host-registry.js +2 -2
  4. package/build/angular/anchor-host-registry.js.map +1 -1
  5. package/build/angular/bootstrap.js +1 -1
  6. package/build/angular/bootstrap.js.map +1 -1
  7. package/build/angular/components/activity-indicator/index.android.d.ts +2 -1
  8. package/build/angular/components/activity-indicator/index.android.js +12 -4
  9. package/build/angular/components/activity-indicator/index.android.js.map +1 -1
  10. package/build/angular/components/activity-indicator/index.ios.d.ts +2 -1
  11. package/build/angular/components/activity-indicator/index.ios.js +8 -3
  12. package/build/angular/components/activity-indicator/index.ios.js.map +1 -1
  13. package/build/angular/components/activity-indicator/shared.d.ts +1 -1
  14. package/build/angular/components/activity-indicator/shared.js +4 -4
  15. package/build/angular/components/activity-indicator/shared.js.map +1 -1
  16. package/build/angular/components/button.d.ts +4 -2
  17. package/build/angular/components/button.js +41 -22
  18. package/build/angular/components/button.js.map +1 -1
  19. package/build/angular/components/flat-list/index.d.ts +6 -4
  20. package/build/angular/components/flat-list/index.js +80 -50
  21. package/build/angular/components/flat-list/index.js.map +1 -1
  22. package/build/angular/components/image/index.android.d.ts +8 -3
  23. package/build/angular/components/image/index.android.js +42 -10
  24. package/build/angular/components/image/index.android.js.map +1 -1
  25. package/build/angular/components/image/index.ios.d.ts +8 -3
  26. package/build/angular/components/image/index.ios.js +42 -10
  27. package/build/angular/components/image/index.ios.js.map +1 -1
  28. package/build/angular/components/image/shared.d.ts +4 -1
  29. package/build/angular/components/image/shared.js +51 -8
  30. package/build/angular/components/image/shared.js.map +1 -1
  31. package/build/angular/components/image-background.d.ts +2 -1
  32. package/build/angular/components/image-background.js +26 -10
  33. package/build/angular/components/image-background.js.map +1 -1
  34. package/build/angular/components/input-accessory-view/index.d.ts +9 -4
  35. package/build/angular/components/input-accessory-view/index.js +49 -10
  36. package/build/angular/components/input-accessory-view/index.js.map +1 -1
  37. package/build/angular/components/keyboard-avoiding-view/index.d.ts +3 -1
  38. package/build/angular/components/keyboard-avoiding-view/index.js +44 -12
  39. package/build/angular/components/keyboard-avoiding-view/index.js.map +1 -1
  40. package/build/angular/components/modal/index.d.ts +11 -8
  41. package/build/angular/components/modal/index.js +51 -29
  42. package/build/angular/components/modal/index.js.map +1 -1
  43. package/build/angular/components/pressable/index.d.ts +12 -6
  44. package/build/angular/components/pressable/index.js +66 -13
  45. package/build/angular/components/pressable/index.js.map +1 -1
  46. package/build/angular/components/refresh-control/index.d.ts +8 -4
  47. package/build/angular/components/refresh-control/index.js +54 -16
  48. package/build/angular/components/refresh-control/index.js.map +1 -1
  49. package/build/angular/components/safe-area-view/index.d.ts +9 -4
  50. package/build/angular/components/safe-area-view/index.js +48 -9
  51. package/build/angular/components/safe-area-view/index.js.map +1 -1
  52. package/build/angular/components/scroll-view/index.android.d.ts +2 -3
  53. package/build/angular/components/scroll-view/index.android.js +88 -61
  54. package/build/angular/components/scroll-view/index.android.js.map +1 -1
  55. package/build/angular/components/scroll-view/index.ios.d.ts +2 -3
  56. package/build/angular/components/scroll-view/index.ios.js +24 -18
  57. package/build/angular/components/scroll-view/index.ios.js.map +1 -1
  58. package/build/angular/components/scroll-view/projection.d.ts +35 -0
  59. package/build/angular/components/scroll-view/projection.js +279 -53
  60. package/build/angular/components/scroll-view/projection.js.map +1 -1
  61. package/build/angular/components/scroll-view/shared.d.ts +14 -5
  62. package/build/angular/components/scroll-view/shared.js +225 -75
  63. package/build/angular/components/scroll-view/shared.js.map +1 -1
  64. package/build/angular/components/scroll-view/sticky-header.js +21 -22
  65. package/build/angular/components/scroll-view/sticky-header.js.map +1 -1
  66. package/build/angular/components/section-list/index.d.ts +7 -1
  67. package/build/angular/components/section-list/index.js +91 -54
  68. package/build/angular/components/section-list/index.js.map +1 -1
  69. package/build/angular/components/switch/index.android.d.ts +7 -3
  70. package/build/angular/components/switch/index.android.js +52 -10
  71. package/build/angular/components/switch/index.android.js.map +1 -1
  72. package/build/angular/components/switch/index.ios.d.ts +7 -3
  73. package/build/angular/components/switch/index.ios.js +52 -10
  74. package/build/angular/components/switch/index.ios.js.map +1 -1
  75. package/build/angular/components/switch/shared.d.ts +13 -4
  76. package/build/angular/components/switch/shared.js +71 -7
  77. package/build/angular/components/switch/shared.js.map +1 -1
  78. package/build/angular/components/text-input.d.ts +11 -3
  79. package/build/angular/components/text-input.js +68 -8
  80. package/build/angular/components/text-input.js.map +1 -1
  81. package/build/angular/components/touchable/index.d.ts +40 -9
  82. package/build/angular/components/touchable/index.js +303 -67
  83. package/build/angular/components/touchable/index.js.map +1 -1
  84. package/build/angular/components/touchable-native-feedback/index.d.ts +6 -3
  85. package/build/angular/components/touchable-native-feedback/index.js +63 -27
  86. package/build/angular/components/touchable-native-feedback/index.js.map +1 -1
  87. package/build/angular/components/virtualized-list/directives.js +22 -18
  88. package/build/angular/components/virtualized-list/directives.js.map +1 -1
  89. package/build/angular/components/virtualized-list/index.d.ts +15 -4
  90. package/build/angular/components/virtualized-list/index.js +326 -94
  91. package/build/angular/components/virtualized-list/index.js.map +1 -1
  92. package/build/angular/components/virtualized-section-list/directives.js +12 -12
  93. package/build/angular/components/virtualized-section-list/directives.js.map +1 -1
  94. package/build/angular/components/virtualized-section-list/index.d.ts +19 -1
  95. package/build/angular/components/virtualized-section-list/index.js +93 -22
  96. package/build/angular/components/virtualized-section-list/index.js.map +1 -1
  97. package/build/angular/components.d.ts +2 -2
  98. package/build/angular/components.js +5 -0
  99. package/build/angular/components.js.map +1 -1
  100. package/build/angular/create-portal/index.js +34 -36
  101. package/build/angular/create-portal/index.js.map +1 -1
  102. package/build/angular/create-tunnel/index.js +19 -22
  103. package/build/angular/create-tunnel/index.js.map +1 -1
  104. package/build/angular/descriptor-to-angular/index.js +6 -4
  105. package/build/angular/descriptor-to-angular/index.js.map +1 -1
  106. package/build/angular/diagnostics.d.ts +33 -0
  107. package/build/angular/diagnostics.js +82 -0
  108. package/build/angular/diagnostics.js.map +1 -0
  109. package/build/angular/host-instance/index.js +1 -1
  110. package/build/angular/host-instance/index.js.map +1 -1
  111. package/build/angular/index.d.ts +9 -4
  112. package/build/angular/index.js +17 -3
  113. package/build/angular/index.js.map +1 -1
  114. package/build/angular/modules/animated/animated-leaf-binder.d.ts +1 -7
  115. package/build/angular/modules/animated/animated-leaf-binder.js +22 -72
  116. package/build/angular/modules/animated/animated-leaf-binder.js.map +1 -1
  117. package/build/angular/modules/animated/create-animated-component.d.ts +11 -6
  118. package/build/angular/modules/animated/create-animated-component.js +106 -55
  119. package/build/angular/modules/animated/create-animated-component.js.map +1 -1
  120. package/build/angular/modules/status-bar/index.js +4 -4
  121. package/build/angular/modules/status-bar/index.js.map +1 -1
  122. package/build/angular/primitives/index.d.ts +20 -3
  123. package/build/angular/primitives/index.js +84 -48
  124. package/build/angular/primitives/index.js.map +1 -1
  125. package/build/angular/primitives/shared.d.ts +48 -2
  126. package/build/angular/primitives/shared.js +158 -11
  127. package/build/angular/primitives/shared.js.map +1 -1
  128. package/build/angular/render/index.js +69 -38
  129. package/build/angular/render/index.js.map +1 -1
  130. package/build/angular/renderer/index.d.ts +1 -0
  131. package/build/angular/renderer/index.js +37 -2
  132. package/build/angular/renderer/index.js.map +1 -1
  133. package/build/angular/services/color-scheme.service.js +3 -3
  134. package/build/angular/services/color-scheme.service.js.map +1 -1
  135. package/build/angular/services/window-dimensions.service.js +4 -4
  136. package/build/angular/services/window-dimensions.service.js.map +1 -1
  137. package/package.json +10 -8
  138. package/src/anchor-host-registry.ts +2 -2
  139. package/src/bootstrap.ts +4 -1
  140. package/src/components/activity-indicator/index.android.ts +8 -1
  141. package/src/components/activity-indicator/index.ios.ts +4 -0
  142. package/src/components/activity-indicator/shared.ts +32 -13
  143. package/src/components/button.ts +46 -19
  144. package/src/components/flat-list/index.ts +122 -62
  145. package/src/components/image/index.android.ts +58 -7
  146. package/src/components/image/index.ios.ts +58 -7
  147. package/src/components/image/shared.ts +92 -21
  148. package/src/components/image-background.ts +29 -9
  149. package/src/components/input-accessory-view/index.ts +67 -12
  150. package/src/components/keyboard-avoiding-view/index.ts +61 -15
  151. package/src/components/modal/index.ts +72 -33
  152. package/src/components/pressable/index.ts +117 -28
  153. package/src/components/refresh-control/index.ts +74 -18
  154. package/src/components/safe-area-view/index.ts +64 -10
  155. package/src/components/scroll-view/index.android.ts +62 -47
  156. package/src/components/scroll-view/index.ios.ts +19 -17
  157. package/src/components/scroll-view/projection.ts +363 -64
  158. package/src/components/scroll-view/shared.ts +312 -90
  159. package/src/components/scroll-view/sticky-header.ts +36 -24
  160. package/src/components/section-list/index.ts +86 -53
  161. package/src/components/switch/index.android.ts +60 -8
  162. package/src/components/switch/index.ios.ts +60 -8
  163. package/src/components/switch/shared.ts +93 -6
  164. package/src/components/text-input.ts +95 -13
  165. package/src/components/touchable/index.ts +348 -64
  166. package/src/components/touchable-native-feedback/index.ts +94 -31
  167. package/src/components/virtualized-list/directives.ts +19 -5
  168. package/src/components/virtualized-list/index.ts +380 -85
  169. package/src/components/virtualized-section-list/directives.ts +6 -3
  170. package/src/components/virtualized-section-list/index.ts +146 -33
  171. package/src/components.ts +9 -1
  172. package/src/create-portal/index.ts +31 -31
  173. package/src/create-tunnel/index.ts +16 -17
  174. package/src/descriptor-to-angular/index.ts +29 -8
  175. package/src/diagnostics.ts +138 -0
  176. package/src/host-instance/index.ts +9 -3
  177. package/src/index.ts +61 -3
  178. package/src/modules/animated/animated-leaf-binder.ts +28 -77
  179. package/src/modules/animated/create-animated-component.ts +115 -47
  180. package/src/modules/app-registry/index.ts +9 -9
  181. package/src/modules/status-bar/index.ts +13 -4
  182. package/src/primitives/index.ts +47 -2
  183. package/src/primitives/shared.ts +170 -10
  184. package/src/render/index.ts +77 -39
  185. package/src/renderer/index.ts +76 -14
  186. package/src/services/color-scheme.service.ts +3 -1
  187. package/src/services/window-dimensions.service.ts +11 -4
  188. package/build/angular/modules/animated/animated-leaf-binder/index.d.ts +0 -14
  189. package/build/angular/modules/animated/animated-leaf-binder/index.js +0 -91
  190. package/build/angular/modules/animated/animated-leaf-binder/index.js.map +0 -1
  191. package/src/modules/animated/animated-leaf-binder/index.ts +0 -101
@@ -1,33 +1,35 @@
1
- // ScrollView, the Angular lifecycle half. The Fabric tree is nested: a scroll view
2
- // wraps a content view that holds the children (RN's ScrollView.js shape). The platform-invariant
3
- // math (decelerationRate, the per-axis intrinsics/base style, the content-size dedupe, the
4
- // imperative handle, the native sticky scroll-attach, the aria/role fold) lives in
5
- // @symbiote-native/components, shared verbatim with React/Vue. Here Angular supplies only the lifecycle:
6
- // the host node held by IDENTITY through a directive's ElementRef, a forwarded-prop bag set onto
7
- // that node through Renderer2 (-> routeProp), the imperative handle (scrollTo/scrollToEnd/
8
- // flashScrollIndicators), the content-size dedupe, and the native sticky scroll-attach wired
9
- // through whenCommitted. This is the Angular twin of the React useRef + Vue shallowRef host.
1
+ // ScrollView, the Angular lifecycle half. The Fabric tree is nested: a scroll view wraps a
2
+ // content view holding the children (RN's ScrollView.js shape). The platform-invariant math
3
+ // (decelerationRate, per-axis intrinsics, content-size dedupe, imperative handle, native sticky
4
+ // scroll-attach, aria/role fold) lives in @symbiote-native/components, shared with React/Vue.
5
+ // Angular supplies only the lifecycle: the host node held by IDENTITY through a directive's
6
+ // ElementRef, a forwarded-prop bag set via Renderer2 (-> routeProp), and the native sticky attach
7
+ // wired through whenCommitted.
10
8
  //
11
- // What diverges per platform, how a RefreshControl integrates, stays in the
12
- // .ios/.android files (Metro filename-selected): iOS renders the RefreshControl as a SIBLING
13
- // before the content container; on Android an AndroidSwipeRefreshLayout WRAPS the scroll view.
9
+ // RefreshControl integration diverges per platform (.ios/.android, Metro filename-selected): iOS
10
+ // renders it as a SIBLING before the content container; Android WRAPS the scroll view in an
11
+ // AndroidSwipeRefreshLayout.
14
12
  //
15
- // Angular cannot transform <ng-content> children in the template the way React children.map /
16
- // Vue slots() can, so ScrollView owns a tiny projection bridge: the content host registers a
17
- // ScrollViewProjectionController with the Angular renderer, and the renderer wraps direct projected
18
- // children whose indices match stickyHeaderIndices. Android RefreshControl projection is handled by
19
- // re-rendering the projected component's native prop surface as the wrapper around the scroll view
20
- // (RN's Android shape), while excluding the original projected node from the content slot.
13
+ // Angular cannot transform <ng-content> children the way React children.map / Vue slots() can, so
14
+ // ScrollView owns a projection bridge: the content host registers a ScrollViewProjectionController
15
+ // with the Angular renderer, which wraps projected children whose indices match
16
+ // stickyHeaderIndices. Android RefreshControl projection re-renders the projected component as the
17
+ // wrapper around the scroll view (RN's Android shape), excluding it from the content slot.
21
18
 
22
19
  import {
23
20
  Directive,
21
+ computed,
22
+ signal,
24
23
  ElementRef,
25
24
  EventEmitter,
26
25
  Output,
27
26
  ViewChild,
28
27
  ContentChild,
29
28
  inject,
29
+ type AfterContentChecked,
30
30
  type AfterViewInit,
31
+ type DoCheck,
32
+ type OnChanges,
31
33
  type OnDestroy,
32
34
  } from '@angular/core';
33
35
  import {
@@ -60,7 +62,7 @@ import {
60
62
  type ISymbioteNode,
61
63
  type IViewStyle,
62
64
  } from '@symbiote-native/engine';
63
- import { SymbioteHostPropsDirective } from '../../primitives';
65
+ import { anchorHostStyle, SymbioteHostPropsDirective } from '../../primitives';
64
66
  import { RefreshControl } from '../refresh-control';
65
67
  import { ScrollViewProjectionController } from './projection';
66
68
 
@@ -158,13 +160,12 @@ export const SCROLL_VIEW_INPUTS = [
158
160
  'aria-valuetext',
159
161
  ];
160
162
 
161
- // The Angular-facing prop surface. React's IScrollViewProps is React-coupled (ReactNode children,
162
- // ReactElement refreshControl); Angular takes children via <ng-content> and composes the
163
- // RefreshControl through projection / an outer wrap, so this mirrors the same pass-through surface
164
- // minus those, declared per-adapter over the shared accessibility base since a framework element/ref
165
- // field can't live in a shared agnostic type. Every prop is accepted and typed against the full
166
- // React surface so app code type-checks against parity now.
167
- export interface IAngularScrollViewProps extends IAccessibilityProps, IAriaProps {
163
+ // The Angular-facing prop surface: React's IScrollViewProps minus the React-coupled fields
164
+ // (ReactNode children, ReactElement refreshControl) Angular takes children via <ng-content> and
165
+ // composes RefreshControl through projection instead. Declared per-adapter over the shared
166
+ // accessibility base since a framework element/ref field can't live in a shared agnostic type.
167
+ export interface IAngularScrollViewProps
168
+ extends IAccessibilityProps, IAriaProps {
168
169
  style?: IStyleProp<IViewStyle>;
169
170
  // A bare string resolves through the shared style registry, like `class` on the host node.
170
171
  contentContainerStyle?: IStyleProp<IViewStyle> | string;
@@ -176,7 +177,12 @@ export interface IAngularScrollViewProps extends IAccessibilityProps, IAriaProps
176
177
  bounces?: boolean;
177
178
  decelerationRate?: 'normal' | 'fast' | number;
178
179
  scrollEventThrottle?: number;
179
- contentInset?: { top?: number; left?: number; bottom?: number; right?: number };
180
+ contentInset?: {
181
+ top?: number;
182
+ left?: number;
183
+ bottom?: number;
184
+ right?: number;
185
+ };
180
186
  contentOffset?: { x: number; y: number };
181
187
  removeClippedSubviews?: boolean;
182
188
  snapToInterval?: number;
@@ -204,11 +210,17 @@ export interface IAngularScrollViewProps extends IAccessibilityProps, IAriaProps
204
210
  alwaysBounceHorizontal?: boolean;
205
211
  alwaysBounceVertical?: boolean;
206
212
  centerContent?: boolean;
207
- scrollIndicatorInsets?: { top?: number; left?: number; bottom?: number; right?: number };
213
+ scrollIndicatorInsets?: {
214
+ top?: number;
215
+ left?: number;
216
+ bottom?: number;
217
+ right?: number;
218
+ };
208
219
  indicatorStyle?: 'default' | 'black' | 'white';
209
220
  directionalLockEnabled?: boolean;
210
221
  automaticallyAdjustKeyboardInsets?: boolean;
211
- contentInsetAdjustmentBehavior?: 'automatic' | 'scrollableAxes' | 'never' | 'always';
222
+ contentInsetAdjustmentBehavior?:
223
+ 'automatic' | 'scrollableAxes' | 'never' | 'always';
212
224
  minimumZoomScale?: number;
213
225
  maximumZoomScale?: number;
214
226
  zoomScale?: number;
@@ -287,7 +299,15 @@ export class ScrollViewProjectionDirective {
287
299
  // ngtsc rejects the inherited hooks/queries (NG2007). The concrete platform @Components add ONLY a
288
300
  // decorator + their platform template; all behavior lives here.
289
301
  @Directive()
290
- export abstract class ScrollViewBase implements IAngularScrollViewInputs, AfterViewInit, OnDestroy {
302
+ export abstract class ScrollViewBase
303
+ implements
304
+ IAngularScrollViewInputs,
305
+ AfterContentChecked,
306
+ AfterViewInit,
307
+ DoCheck,
308
+ OnChanges,
309
+ OnDestroy
310
+ {
291
311
  style: IStyleProp<IViewStyle> | undefined;
292
312
  contentContainerStyle: IStyleProp<IViewStyle> | string | undefined;
293
313
  horizontal: boolean | undefined;
@@ -391,7 +411,22 @@ export abstract class ScrollViewBase implements IAngularScrollViewInputs, AfterV
391
411
  @ViewChild('host', { read: SymbioteHostPropsDirective })
392
412
  private hostDirective?: SymbioteHostPropsDirective;
393
413
 
394
- @ContentChild(RefreshControl) protected projectedRefreshControl?: RefreshControl;
414
+ @ContentChild(RefreshControl)
415
+ protected projectedRefreshControl?: RefreshControl;
416
+
417
+ // This component's OWN anchor host — where a `class="..."` at the use site resolves (see
418
+ // anchorHostStyle's doc comment) — NOT `#host` in the platform templates, which targets the real
419
+ // inner scroll-view primitive one level down. Injected in the BASE because both platform
420
+ // subclasses merged the identical anchor style into their `scrollProps` override.
421
+ private readonly elementRef = inject<ElementRef<unknown>>(ElementRef);
422
+
423
+ // The anchor's class-derived style is written by the RENDERER (addClass/removeClass ->
424
+ // commitClassStyle), never through an @Input, so `inputsRevision` cannot cover it and a bag
425
+ // memoized on plain reads of it would keep a stale class forever. It gets its own signal, polled
426
+ // in ngDoCheck below: that hook runs on exactly the checks that could have changed it (Angular
427
+ // applies the parent's class bindings before flushing this directive's pre-order hooks), which is
428
+ // the same cadence the old `scrollProps` getter was re-read at.
429
+ protected readonly anchorStyle = signal<unknown>(undefined);
395
430
 
396
431
  // A single AnimatedValue tracks the scroll offset and drives every sticky header's translateY
397
432
  // (RN's _scrollAnimatedValue). A stable field (allocated once, held by identity — Angular does
@@ -404,6 +439,9 @@ export abstract class ScrollViewBase implements IAngularScrollViewInputs, AfterV
404
439
  private lastContentSize: IContentSize | null = null;
405
440
  // Inverted sticky headers stick to the BOTTOM, so they need the viewport height (RN _handleLayout).
406
441
  private viewportHeight: number | undefined = undefined;
442
+ // What the projection controller was last told about the projected RefreshControl, so
443
+ // ngAfterContentChecked can tell a real content change from an ordinary check.
444
+ private lastHasProjectedRefreshControl = false;
407
445
 
408
446
  protected readonly projectionController = new ScrollViewProjectionController({
409
447
  stickyHeaderIndices: undefined,
@@ -421,6 +459,9 @@ export abstract class ScrollViewBase implements IAngularScrollViewInputs, AfterV
421
459
  // bind runs now if committed, else after the commit that assigns the tag. Detached on destroy.
422
460
  private detachStickyScroll: (() => void) | undefined;
423
461
  private cancelStickyBind: (() => void) | undefined;
462
+ // What the current attach was made for, so a re-run that changes nothing stays a no-op.
463
+ private stickyAttachedEnabled = false;
464
+ private stickyAttachedNode: ISymbioteNode | null = null;
424
465
 
425
466
  private lastLoggedIsHorizontal: boolean | undefined = undefined;
426
467
 
@@ -431,9 +472,18 @@ export abstract class ScrollViewBase implements IAngularScrollViewInputs, AfterV
431
472
  // (commit.ts) falls back to Object.is on a function leaf, so even a structurally-identical bag
432
473
  // reads as "props changed" and forces a real Fabric re-clone that cascades up every ancestor
433
474
  // (see AnimatedComponentBase.reconcile()'s identical warning).
434
- private readonly handleInvertedStickyLayout = (event: ISymbioteEvent): void => {
475
+ private readonly handleInvertedStickyLayout = (
476
+ event: ISymbioteEvent,
477
+ ): void => {
435
478
  const height = readLayoutDimension(event, 'height');
436
- if (height !== undefined) this.viewportHeight = height;
479
+ // The projection controller is pushed from here rather than picked up on the next prop-bag
480
+ // read: viewportHeight is internal mutable state, so once `scrollProps` became a memoized
481
+ // computed nothing would re-read it. Guarded on a real change - an onLayout that reports the
482
+ // same height must not cost a sticky reconcile walk.
483
+ if (height !== undefined && height !== this.viewportHeight) {
484
+ this.viewportHeight = height;
485
+ this.updateProjectionController();
486
+ }
437
487
  this.layout.emit(event);
438
488
  };
439
489
 
@@ -449,14 +499,11 @@ export abstract class ScrollViewBase implements IAngularScrollViewInputs, AfterV
449
499
  this.contentSizeChange.emit({ width, height });
450
500
  };
451
501
 
452
- // Memoizes the wrapper each `emitterCallback` builds, keyed by the emitter it closes over
453
- // (identity holds across ScrollViewBase's own @Output()s and a projected RefreshControl's,
454
- // since a WeakMap key is just object identity). Without this, `scrollProps`/`refreshControlProps`
455
- // handed a fresh `(event) => emitter.emit(event)` closure to the forwarded prop bag on every
456
- // evaluation the same jsonEqual/Object.is function-leaf problem as the arrow fields above, one
457
- // level further removed. `.observed` is checked BEFORE the cache so an unbound event still
458
- // resolves to `undefined` (the "nobody cares" contract `emitterCallback`'s own comment documents),
459
- // not a stale cached wrapper from before the last subscriber unsubscribed.
502
+ // Memoizes the wrapper each `emitterCallback` builds, keyed by the emitter it closes over.
503
+ // Without this, `scrollProps`/`refreshControlProps` would hand a fresh closure to the forwarded
504
+ // prop bag on every evaluation the same jsonEqual/Object.is function-leaf problem as the arrow
505
+ // fields above. `.observed` is checked BEFORE the cache so an unbound event still resolves to
506
+ // `undefined` rather than a stale wrapper from before the last unsubscribe.
460
507
  private readonly emitterCallbackCache = new WeakMap<
461
508
  EventEmitter<ISymbioteEvent>,
462
509
  IScrollHandler
@@ -470,7 +517,8 @@ export abstract class ScrollViewBase implements IAngularScrollViewInputs, AfterV
470
517
  // new listener wrapper (and a new AnimatedEvent walking `collectMappedValues`) on every unrelated
471
518
  // change-detection pass while sticky headers are active.
472
519
  private cachedStickyOnScrollSource: IScrollHandler | undefined;
473
- private cachedStickyOnScrollHandler: ((...args: readonly unknown[]) => void) | undefined;
520
+ private cachedStickyOnScrollHandler:
521
+ ((...args: readonly unknown[]) => void) | undefined;
474
522
 
475
523
  get isHorizontal(): boolean {
476
524
  const value = this.horizontal === true;
@@ -484,28 +532,57 @@ export abstract class ScrollViewBase implements IAngularScrollViewInputs, AfterV
484
532
  }
485
533
 
486
534
  private get hasStickyHeaders(): boolean {
487
- return this.stickyHeaderIndices !== undefined && this.stickyHeaderIndices.length > 0;
535
+ return (
536
+ this.stickyHeaderIndices !== undefined &&
537
+ this.stickyHeaderIndices.length > 0
538
+ );
488
539
  }
489
540
 
490
541
  // A class-name string resolves through the shared registry before it reaches
491
542
  // selectScrollIntrinsics, which only understands style objects/arrays.
492
- private get resolvedContentContainerStyle(): IStyleProp<IViewStyle> | undefined {
543
+ private get resolvedContentContainerStyle():
544
+ IStyleProp<IViewStyle> | undefined {
493
545
  return typeof this.contentContainerStyle === 'string'
494
546
  ? resolveClassName(this.contentContainerStyle)
495
547
  : this.contentContainerStyle;
496
548
  }
497
549
 
498
550
  private get scrollViewBaseStyle(): IViewStyle {
499
- return selectScrollIntrinsics(this.isHorizontal, this.resolvedContentContainerStyle)
500
- .scrollViewBaseStyle;
551
+ return selectScrollIntrinsics(
552
+ this.isHorizontal,
553
+ this.resolvedContentContainerStyle,
554
+ ).scrollViewBaseStyle;
501
555
  }
502
556
 
557
+ // Bridges non-reactive @Input fields into the reactive graph, so a computed() can memoize a
558
+ // bag derived from them. Signal inputs would make this unnecessary, but `input()` is visible
559
+ // only to the AOT compiler and this package's unit suite runs on JIT - see the
560
+ // `angular-adapter-change-detection` skill, §6. `signal()`/`computed()` themselves are plain
561
+ // runtime APIs and need no compiler support, which is what makes this bridge possible at all.
562
+ //
563
+ // ONLY safe for a bag whose every dependency is an @Input. A bag that also reads internal
564
+ // mutable state (viewportHeight, lastContentSize, the anchor's class-derived style) would go
565
+ // stale, because nothing bumps this for those - make that state its own signal (see
566
+ // `anchorStyle`) instead of widening this one.
567
+ private readonly inputsRevision = signal(0);
568
+
503
569
  // The forwarded host-prop bag for the scroll-view node: aria/role folded, the native pass-through
504
570
  // families, the scroll-event callbacks, then the lifecycle-managed overrides (nestedScrollEnabled
505
571
  // default ON, horizontal when defined, decelerationRate resolved per-platform, the sticky onScroll
506
572
  // path). Base style UNDER user style so an explicit user value still wins. Mirrors Vue's outerProps
507
573
  // + scrollProps assembly.
508
- get scrollProps(): Record<string, unknown> {
574
+ //
575
+ // MEASURED 2026-08-16: as a getter this rebuilt the whole bag on every refresh of this view, so
576
+ // the `symbioteHostProps` reference check failed every time and every key was re-pushed through
577
+ // the renderer - once per scroll frame per host binding (2 before `contentProps` was memoized,
578
+ // 1 after, 0 now; the pin lives in prop-bag-stability.test.ts).
579
+ //
580
+ // It is a `computed` on the BASE and no longer overridable: a subclass getter cannot override a
581
+ // field. The platform subclasses used to override it with byte-identical bodies (merge the
582
+ // anchor's class-derived style ahead of the resolved style), so that merge moved down here
583
+ // rather than into a `decorateScrollProps` hook both of them would implement the same way.
584
+ readonly scrollProps = computed<Record<string, unknown>>(() => {
585
+ this.inputsRevision();
509
586
  const bag: Record<string, unknown> = compact({
510
587
  ...resolveAccessibilityProps(this.accessibilityInputs()),
511
588
  scrollEnabled: this.scrollEnabled,
@@ -563,15 +640,13 @@ export abstract class ScrollViewBase implements IAngularScrollViewInputs, AfterV
563
640
  }
564
641
 
565
642
  // onScroll: when sticky headers are active, the offset must reach the AnimatedValue (RN
566
- // _scrollAnimatedValueAttachment). With the native module, the value is driven on the UI thread
567
- // by the post-commit attach below, so onScroll forwards to the user with throttle 1
568
- // (ScrollView.js:1798). Without it, Animated.event drives the value each frame (the JS jitter)
569
- // and forwards the user handler as the listener passthrough.
570
- // The scroll-forwarding DECISIONS (which onScroll path, the 1/16 throttle defaults, whether to
571
- // capture the viewport height) are folded out to the shared resolveScrollForwarding; here Angular
572
- // only EXECUTES them, keeping its stable-reference handlers (stickyOnScrollHandler /
573
- // handleInvertedStickyLayout / emitterCallback) so a fresh closure never forces a re-clone cascade.
574
- const nativeStickyAvailable = this.hasStickyHeaders && isNativeAnimatedAvailable();
643
+ // _scrollAnimatedValueAttachment). With the native module, the post-commit attach below drives
644
+ // it on the UI thread (throttle 1, ScrollView.js:1798); without it, Animated.event drives the
645
+ // value each frame (JS jitter). The DECISIONS (which path, throttle defaults, whether to
646
+ // capture viewport height) are folded out to the shared resolveScrollForwarding; Angular only
647
+ // EXECUTES them, keeping stable-reference handlers so a fresh closure never forces a re-clone.
648
+ const nativeStickyAvailable =
649
+ this.hasStickyHeaders && isNativeAnimatedAvailable();
575
650
  const forwarding = resolveScrollForwarding({
576
651
  hasStickyHeaders: this.hasStickyHeaders,
577
652
  nativeStickyAvailable,
@@ -599,41 +674,62 @@ export abstract class ScrollViewBase implements IAngularScrollViewInputs, AfterV
599
674
  if (layoutCallback !== undefined) bag.onLayout = layoutCallback;
600
675
  }
601
676
 
677
+ // Thunks, not strings: an eagerly built message would be paid with logging off (see dlog's own
678
+ // comment). Less often than it used to fire now that this bag is memoized, but the recompute
679
+ // still rides every real input change.
680
+ dlog(
681
+ () =>
682
+ `Angular ScrollView -> ${this.isHorizontal ? 'horizontal' : 'vertical'} (sticky=${this.hasStickyHeaders})`,
683
+ );
602
684
  dlog(
603
- `Angular ScrollView -> ${this.isHorizontal ? 'horizontal' : 'vertical'} (sticky=${this.hasStickyHeaders})`,
685
+ () =>
686
+ `STICKY[sv] forwarding mode=${forwarding.mode} throttle=${forwarding.scrollEventThrottle} ` +
687
+ `nativeStickyAvailable=${nativeStickyAvailable} onScrollBound=${bag.onScroll !== undefined}`,
604
688
  );
605
689
 
606
- bag.style = [this.scrollViewBaseStyle, this.style];
607
- this.updateProjectionController();
690
+ // The anchor's class-derived style goes FIRST: flattenStyle's later-wins collapse keeps an
691
+ // explicit [style] winning over the ambient class. Flat rather than the nested
692
+ // `[anchor, [base, style]]` the platform overrides used to build - flattenStyle folds nested
693
+ // arrays in the same order, so the resolved value is unchanged.
694
+ bag.style = [this.anchorStyle(), this.scrollViewBaseStyle, this.style];
608
695
  return bag;
609
- }
696
+ });
610
697
 
611
698
  // Overridable hook, NOT `this.style` directly: `splitLayoutProps` below decides which layout
612
699
  // properties (flex/height/gap/…) go on the Android outer refresh-control wrapper vs. the inner
613
- // scroll view. `this.style` alone only ever carries the explicit `[style]` @Input — a composed
614
- // component's OWN anchor host (see anchorHostStyle's doc comment in primitives/shared.ts) can
615
- // hold ADDITIONAL class-derived layout style that's otherwise invisible to this split, so its
616
- // wrapper never gets its needed layout (collapses to zero size the Android outer wrapper
617
- // shows literally nothing on screen). This mirrors the Vue adapter's identical
618
- // `layoutSplitStyle` field and the real Android device bug it fixes; the Angular Android
619
- // ScrollView (index.android.ts) overrides this to merge in `anchorHostStyle`. Default here is
620
- // `this.style` alone, preserving current behavior for anything that doesn't override it.
700
+ // scroll view. `this.style` alone only carries the explicit `[style]` @Input — a composed
701
+ // component's own anchor host can hold ADDITIONAL class-derived layout style invisible to this
702
+ // split, so the wrapper collapses to zero size on real Android devices. Angular's Android
703
+ // ScrollView (index.android.ts) overrides this to merge in `anchorHostStyle` (mirrors Vue's
704
+ // identical `layoutSplitStyle` field and the device bug it fixes).
621
705
  protected get layoutSplitStyle(): IStyleProp<IViewStyle> {
622
706
  return this.style;
623
707
  }
624
708
 
625
- get androidWrappedScrollProps(): Record<string, unknown> {
626
- const props = { ...this.scrollProps };
709
+ // Memoizable for the same reasons `scrollProps` is: `layoutSplitStyle` resolves to @Input `style`
710
+ // plus (on Android) the `anchorStyle` signal, both covered. The anchor style is deliberately NOT
711
+ // re-merged into `props.style` here - `splitLayoutProps` already routed its layout half to the
712
+ // outer refresh-control wrapper and left the rest in `inner`.
713
+ readonly androidWrappedScrollProps = computed<Record<string, unknown>>(() => {
714
+ this.inputsRevision();
715
+ const props = { ...this.scrollProps() };
627
716
  const { inner, outer } = splitLayoutProps(this.layoutSplitStyle);
628
717
  dlog(
629
- `Angular ScrollView splitProbe layoutSplitStyle=${JSON.stringify(this.layoutSplitStyle)} ` +
718
+ () =>
719
+ `Angular ScrollView splitProbe layoutSplitStyle=${JSON.stringify(this.layoutSplitStyle)} ` +
630
720
  `outer=${JSON.stringify(outer)} inner=${JSON.stringify(inner)}`,
631
721
  );
632
722
  props.style = [this.scrollViewBaseStyle, inner];
633
723
  props.nestedScrollEnabled = true;
634
724
  return props;
635
- }
725
+ });
636
726
 
727
+ // Deliberately still GETTERS, unlike every other bag on this class. Their whole content comes
728
+ // from the PROJECTED RefreshControl's own @Input()s (refreshing, tintColor, colors, …), which
729
+ // Angular routes through THAT component's ngOnChanges - `inputsRevision` here never moves for
730
+ // them. Memoizing on it would freeze `refreshing` at its first value and strand the native
731
+ // spinner forever, which is exactly the silent-staleness class this file avoids elsewhere.
732
+ // Making them memoizable means giving RefreshControl its own revision signal to read here.
637
733
  get iosRefreshControlProps(): Record<string, unknown> {
638
734
  return this.refreshControlProps();
639
735
  }
@@ -641,7 +737,8 @@ export abstract class ScrollViewBase implements IAngularScrollViewInputs, AfterV
641
737
  get androidRefreshControlProps(): Record<string, unknown> {
642
738
  const { outer } = splitLayoutProps(this.layoutSplitStyle);
643
739
  dlog(
644
- `Angular ScrollView refreshControlProbe outer=${JSON.stringify(outer)} ` +
740
+ () =>
741
+ `Angular ScrollView refreshControlProbe outer=${JSON.stringify(outer)} ` +
645
742
  `hasRefresh=${this.projectedRefreshControl !== undefined}`,
646
743
  );
647
744
  return this.refreshControlProps(outer);
@@ -651,7 +748,9 @@ export abstract class ScrollViewBase implements IAngularScrollViewInputs, AfterV
651
748
  this.projectedRefreshControl?.handleRefresh(nativeNode);
652
749
  }
653
750
 
654
- private refreshControlProps(style?: IStyleProp<IViewStyle>): Record<string, unknown> {
751
+ private refreshControlProps(
752
+ style?: IStyleProp<IViewStyle>,
753
+ ): Record<string, unknown> {
655
754
  const refresh = this.projectedRefreshControl;
656
755
  if (refresh === undefined) return {};
657
756
  return compact({
@@ -685,25 +784,39 @@ export abstract class ScrollViewBase implements IAngularScrollViewInputs, AfterV
685
784
  // children of a scroll view that hosts exactly one — an addViewAt crash). collapsableChildren
686
785
  // false preserves the cell views maintainVisibleContentPosition/snapToAlignment anchor against.
687
786
  // onLayout synthesizes onContentSizeChange (deduped). iOS never flattens; both are no-ops there.
688
- get contentProps(): Record<string, unknown> {
787
+ readonly contentProps = computed<Record<string, unknown>>(() => {
788
+ this.inputsRevision();
689
789
  const { contentStyle } = selectScrollIntrinsics(
690
790
  this.isHorizontal,
691
791
  this.resolvedContentContainerStyle,
692
792
  );
693
- const bag: Record<string, unknown> = { style: contentStyle, collapsable: false };
694
- if (this.maintainVisibleContentPosition !== undefined || this.snapToAlignment !== undefined) {
793
+ const bag: Record<string, unknown> = {
794
+ style: contentStyle,
795
+ collapsable: false,
796
+ };
797
+ if (
798
+ this.maintainVisibleContentPosition !== undefined ||
799
+ this.snapToAlignment !== undefined
800
+ ) {
695
801
  bag.collapsableChildren = false;
696
802
  }
697
803
  bag.onLayout = this.handleContentLayout;
698
804
  return bag;
699
- }
805
+ });
700
806
 
701
807
  // Wraps an @Output() as a plain callback only while it has a subscriber, so an unbound event
702
808
  // still leaves the forwarded prop bag key absent — the same "undefined means nobody cares"
703
809
  // contract the old @Input() callbacks had. Mirrors Pressable's emitterHandler. The wrapper itself
704
810
  // is memoized per emitter (emitterCallbackCache) rather than rebuilt on every call: see that
705
811
  // field's comment for why an unstable closure here breaks the forwarded prop bag.
706
- private emitterCallback(emitter: EventEmitter<ISymbioteEvent>): IScrollHandler | undefined {
812
+ //
813
+ // `.observed` is NOT reactive, and `scrollProps` memoizes over it. That is sound because Angular
814
+ // subscribes a template's (event) listeners in its CREATION pass, before any input is written and
815
+ // before this component's own view is first refreshed - so `observed` is already settled at the
816
+ // first evaluation and cannot flip afterwards without destroying the whole component.
817
+ private emitterCallback(
818
+ emitter: EventEmitter<ISymbioteEvent>,
819
+ ): IScrollHandler | undefined {
707
820
  if (!emitter.observed) return undefined;
708
821
  let cached = this.emitterCallbackCache.get(emitter);
709
822
  if (cached === undefined) {
@@ -727,7 +840,10 @@ export abstract class ScrollViewBase implements IAngularScrollViewInputs, AfterV
727
840
  [{ nativeEvent: { contentOffset: { y: this.scrollAnimatedValue } } }],
728
841
  userOnScroll === undefined
729
842
  ? undefined
730
- : { listener: (...args: readonly unknown[]) => forwardScrollEvent(userOnScroll, args) },
843
+ : {
844
+ listener: (...args: readonly unknown[]) =>
845
+ forwardScrollEvent(userOnScroll, args),
846
+ },
731
847
  );
732
848
  }
733
849
  return this.cachedStickyOnScrollHandler;
@@ -743,7 +859,9 @@ export abstract class ScrollViewBase implements IAngularScrollViewInputs, AfterV
743
859
  // The imperative API a parent reaches via @ViewChild(ScrollView). buildScrollViewHandle is the
744
860
  // shared, proven handle (React/Vue use it verbatim); it reads the node through the LAZY getter on
745
861
  // every call, so a command before commit no-ops rather than freezing a null node.
746
- private readonly handle: IScrollViewHandle = buildScrollViewHandle(() => this.hostNode);
862
+ private readonly handle: IScrollViewHandle = buildScrollViewHandle(
863
+ () => this.hostNode,
864
+ );
747
865
 
748
866
  scrollTo(options?: { x?: number; y?: number; animated?: boolean }): void {
749
867
  this.handle.scrollTo(options);
@@ -762,24 +880,124 @@ export abstract class ScrollViewBase implements IAngularScrollViewInputs, AfterV
762
880
  }
763
881
 
764
882
  ngAfterViewInit(): void {
883
+ dlog('STICKY[sv] ngAfterViewInit');
884
+ // Runs once the template has bound the content node, so this is the reconcile that actually
885
+ // wraps the sticky children (the ngOnChanges one above fires before the content node exists).
765
886
  this.updateProjectionController();
766
887
  this.attachSticky();
767
888
  }
768
889
 
769
- // Wire the native sticky-scroll attach after the view exists. Re-attaches if the node identity
770
- // changes; no-op when sticky is off or the native module is absent.
890
+ // Re-read the anchor's class-derived style. It is written by the renderer's addClass/removeClass,
891
+ // never through an @Input, so this poll is the only thing that can invalidate a bag holding it -
892
+ // ngOnChanges alone would strand a class toggled after mount (covered by
893
+ // scroll-view-class-style.test.ts's toggle case, which goes red the moment this hook stops
894
+ // polling). ngDoCheck is the right hook, not a near-enough one: render3's `callHooks` flushes a
895
+ // node's pre-order hooks at the next `ɵɵadvance` PAST it, which is after the parent wrote this
896
+ // element's `ɵɵclassProp` and before this component's own view refreshes. It also runs under
897
+ // `setActiveConsumer(null)`, so the write registers no dependency on the caller's view and cannot
898
+ // throw NG0600. And `commitClassStyle` allocates a fresh style array only when a class token
899
+ // actually moved, so `signal.set`'s own Object.is check makes an unchanged poll dirty nothing.
900
+ ngDoCheck(): void {
901
+ this.anchorStyle.set(anchorHostStyle(this.elementRef));
902
+ // The attach can want a node that does not exist yet, and nothing else would ask again.
903
+ // ngOnChanges covers "indices arrived late"; it does NOT cover "indices arrived late AND the host
904
+ // node had not committed yet", which is exactly what a VirtualizedList does - it derives
905
+ // stickyHeaderIndices from the measured window, so they land after this component's
906
+ // ngAfterViewInit and before its host node resolves. Device-diagnosed 2026-08-18 on
907
+ // examples/angular BenchmarkScreen STICKY PATH B, whose log ends on `attachSticky NOT attaching
908
+ // (wantsAttach=true hasNode=false)` - sticky never turned on, while PATH A (literal indices in
909
+ // the template) worked because ngAfterViewInit already saw a node. Retried ONLY from that stuck
910
+ // state, so a settled ScrollView pays nothing per check. Device-verified the same day.
911
+ // Not covered headless: sticky-native-attach.test.ts's late-indices case has its node resolved by
912
+ // the time the indices land, and the deferred-creation ordering under `@if` never rendered the
913
+ // ScrollView at all.
914
+ if (this.stickyAttachedEnabled && this.stickyAttachedNode === null)
915
+ this.attachSticky();
916
+ }
917
+
918
+ // The projected RefreshControl is a @ContentChild, not an @Input, so ngOnChanges never fires when
919
+ // one is added or removed at the use site - and `excludeRefreshControl` decides whether the
920
+ // projection walk pulls it out of the scroll content. Angular resolves the query before this
921
+ // hook. Guarded on the flag itself so a check that changed nothing skips the reconcile walk,
922
+ // which the old getter-driven update could not do.
923
+ ngAfterContentChecked(): void {
924
+ if (this.hasProjectedRefreshControl === this.lastHasProjectedRefreshControl)
925
+ return;
926
+ this.lastHasProjectedRefreshControl = this.hasProjectedRefreshControl;
927
+ this.updateProjectionController();
928
+ }
929
+
930
+ // `stickyHeaderIndices` is an ordinary @Input, so it can arrive AFTER the first change-detection
931
+ // pass - any app deriving it from data rather than writing a literal in the template does exactly
932
+ // that. ngAfterViewInit runs once, so without this the native attach below would be skipped on
933
+ // that first pass and never retried: the wrapper still projects (that half self-heals), the
934
+ // header sits at the right place with the right z-index, and simply never moves. Device-reported
935
+ // 2026-08-16, regression-covered in sticky-native-attach.test.ts. The other three adapters never
936
+ // had this hole because they re-run the attach from a reactive effect keyed on the same
937
+ // condition (Svelte's `$effect` in index.svelte, React's useEffect deps, Vue's watcher).
938
+ ngOnChanges(): void {
939
+ dlog('STICKY[sv] ngOnChanges');
940
+ // Every computed() prop bag below reads plain @Input fields, which are NOT reactive on their
941
+ // own - this bump is what tells them an input changed. It must stay in ngOnChanges: that is
942
+ // the single moment Angular has finished writing every changed input for this pass.
943
+ this.inputsRevision.update(revision => revision + 1);
944
+ // stickyHeaderIndices / invertStickyHeaders / StickyHeaderComponent are @Inputs, so this is
945
+ // where the projection controller learns about them. It used to be a side effect of reading
946
+ // the `scrollProps` getter - which a memoized computed no longer performs per pass.
947
+ this.updateProjectionController();
948
+ this.attachSticky();
949
+ }
950
+
951
+ // Wire the native sticky-scroll attach once the view exists AND sticky is actually on. Re-runs
952
+ // on every input change, so it must be idempotent: see the guard below.
771
953
  private attachSticky(): void {
954
+ const wantsAttach = this.hasStickyHeaders && isNativeAnimatedAvailable();
955
+ const node = wantsAttach ? this.hostNode : null;
956
+ dlog(
957
+ () =>
958
+ `STICKY[sv] attachSticky indices=${JSON.stringify(this.stickyHeaderIndices)} ` +
959
+ `hasSticky=${this.hasStickyHeaders} nativeAvailable=${isNativeAnimatedAvailable()} ` +
960
+ `wantsAttach=${wantsAttach} hasNode=${node !== null} ` +
961
+ `wasEnabled=${this.stickyAttachedEnabled} nodeChanged=${node !== this.stickyAttachedNode}`,
962
+ );
963
+ // Nothing that decides the attach has changed - return rather than detach and rebind the
964
+ // native scroll event, which every unrelated @Input change would otherwise churn now that
965
+ // ngOnChanges drives this too.
966
+ if (
967
+ wantsAttach === this.stickyAttachedEnabled &&
968
+ node === this.stickyAttachedNode
969
+ ) {
970
+ dlog(
971
+ 'STICKY[sv] attachSticky skipped (nothing about the attach changed)',
972
+ );
973
+ return;
974
+ }
975
+
772
976
  this.cancelStickyBind?.();
773
977
  this.cancelStickyBind = undefined;
774
978
  if (this.detachStickyScroll !== undefined) {
775
979
  this.detachStickyScroll();
776
980
  this.detachStickyScroll = undefined;
777
981
  }
778
- if (!this.hasStickyHeaders || !isNativeAnimatedAvailable()) return;
779
- const node = this.hostNode;
780
- if (node === null) return;
982
+ // Recorded even when the node is not resolved yet, so the NEXT call (which will see a real
983
+ // node) reads as a change and attaches.
984
+ this.stickyAttachedEnabled = wantsAttach;
985
+ this.stickyAttachedNode = node;
986
+ if (!wantsAttach || node === null) {
987
+ dlog(
988
+ `STICKY[sv] attachSticky NOT attaching (wantsAttach=${wantsAttach} hasNode=${node !== null})`,
989
+ );
990
+ return;
991
+ }
992
+ dlog(
993
+ 'STICKY[sv] attachSticky waiting for commit to bind the native scroll event',
994
+ );
781
995
  this.cancelStickyBind = whenCommitted(node, () => {
782
- this.detachStickyScroll = attachStickyScroll(node, this.scrollAnimatedValue);
996
+ dlog('STICKY[sv] attachSticky node committed -> attachStickyScroll');
997
+ this.detachStickyScroll = attachStickyScroll(
998
+ node,
999
+ this.scrollAnimatedValue,
1000
+ );
783
1001
  });
784
1002
  }
785
1003
 
@@ -802,7 +1020,9 @@ export abstract class ScrollViewBase implements IAngularScrollViewInputs, AfterV
802
1020
 
803
1021
  // Typed as the a11y intersection WITH the string index so resolveAccessibilityProps's result
804
1022
  // stays assignable into the forwarded bag (a genuine narrowing, built at that type — no cast).
805
- private accessibilityInputs(): IAccessibilityProps & IAriaProps & Record<string, unknown> {
1023
+ private accessibilityInputs(): IAccessibilityProps &
1024
+ IAriaProps &
1025
+ Record<string, unknown> {
806
1026
  return {
807
1027
  testID: this.testID,
808
1028
  nativeID: this.nativeID,
@@ -821,8 +1041,10 @@ export abstract class ScrollViewBase implements IAngularScrollViewInputs, AfterV
821
1041
  accessibilityElementsHidden: this.accessibilityElementsHidden,
822
1042
  accessibilityIgnoresInvertColors: this.accessibilityIgnoresInvertColors,
823
1043
  accessibilityLanguage: this.accessibilityLanguage,
824
- accessibilityRespondsToUserInteraction: this.accessibilityRespondsToUserInteraction,
825
- accessibilityShowsLargeContentViewer: this.accessibilityShowsLargeContentViewer,
1044
+ accessibilityRespondsToUserInteraction:
1045
+ this.accessibilityRespondsToUserInteraction,
1046
+ accessibilityShowsLargeContentViewer:
1047
+ this.accessibilityShowsLargeContentViewer,
826
1048
  accessibilityLargeContentTitle: this.accessibilityLargeContentTitle,
827
1049
  role: this.role,
828
1050
  'aria-label': this['aria-label'],