@symbiote-native/angular 0.6.1 → 0.7.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 (206) hide show
  1. package/README.md +1 -1
  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/components/activity-indicator/index.android.d.ts +2 -1
  6. package/build/angular/components/activity-indicator/index.android.js +6 -3
  7. package/build/angular/components/activity-indicator/index.android.js.map +1 -1
  8. package/build/angular/components/activity-indicator/index.ios.d.ts +2 -1
  9. package/build/angular/components/activity-indicator/index.ios.js +6 -3
  10. package/build/angular/components/activity-indicator/index.ios.js.map +1 -1
  11. package/build/angular/components/activity-indicator/shared.js +3 -3
  12. package/build/angular/components/button.d.ts +4 -2
  13. package/build/angular/components/button.js +28 -17
  14. package/build/angular/components/button.js.map +1 -1
  15. package/build/angular/components/flat-list/index.d.ts +5 -3
  16. package/build/angular/components/flat-list/index.js +35 -38
  17. package/build/angular/components/flat-list/index.js.map +1 -1
  18. package/build/angular/components/image/index.android.d.ts +8 -3
  19. package/build/angular/components/image/index.android.js +39 -9
  20. package/build/angular/components/image/index.android.js.map +1 -1
  21. package/build/angular/components/image/index.ios.d.ts +8 -3
  22. package/build/angular/components/image/index.ios.js +39 -9
  23. package/build/angular/components/image/index.ios.js.map +1 -1
  24. package/build/angular/components/image/shared.d.ts +4 -1
  25. package/build/angular/components/image/shared.js +33 -2
  26. package/build/angular/components/image/shared.js.map +1 -1
  27. package/build/angular/components/image-background.d.ts +2 -1
  28. package/build/angular/components/image-background.js +6 -4
  29. package/build/angular/components/image-background.js.map +1 -1
  30. package/build/angular/components/input-accessory-view/index.d.ts +9 -4
  31. package/build/angular/components/input-accessory-view/index.js +43 -9
  32. package/build/angular/components/input-accessory-view/index.js.map +1 -1
  33. package/build/angular/components/keyboard-avoiding-view/index.d.ts +2 -1
  34. package/build/angular/components/keyboard-avoiding-view/index.js +6 -4
  35. package/build/angular/components/keyboard-avoiding-view/index.js.map +1 -1
  36. package/build/angular/components/modal/index.d.ts +11 -8
  37. package/build/angular/components/modal/index.js +45 -28
  38. package/build/angular/components/modal/index.js.map +1 -1
  39. package/build/angular/components/pressable/index.d.ts +11 -5
  40. package/build/angular/components/pressable/index.js +61 -12
  41. package/build/angular/components/pressable/index.js.map +1 -1
  42. package/build/angular/components/refresh-control/index.d.ts +8 -4
  43. package/build/angular/components/refresh-control/index.js +52 -16
  44. package/build/angular/components/refresh-control/index.js.map +1 -1
  45. package/build/angular/components/safe-area-view/index.d.ts +9 -4
  46. package/build/angular/components/safe-area-view/index.js +46 -9
  47. package/build/angular/components/safe-area-view/index.js.map +1 -1
  48. package/build/angular/components/scroll-view/index.android.d.ts +2 -3
  49. package/build/angular/components/scroll-view/index.android.js +49 -52
  50. package/build/angular/components/scroll-view/index.android.js.map +1 -1
  51. package/build/angular/components/scroll-view/index.ios.d.ts +2 -3
  52. package/build/angular/components/scroll-view/index.ios.js +15 -17
  53. package/build/angular/components/scroll-view/index.ios.js.map +1 -1
  54. package/build/angular/components/scroll-view/projection.d.ts +3 -0
  55. package/build/angular/components/scroll-view/projection.js +68 -7
  56. package/build/angular/components/scroll-view/projection.js.map +1 -1
  57. package/build/angular/components/scroll-view/shared.d.ts +14 -5
  58. package/build/angular/components/scroll-view/shared.js +198 -69
  59. package/build/angular/components/scroll-view/shared.js.map +1 -1
  60. package/build/angular/components/scroll-view/sticky-header.js +14 -19
  61. package/build/angular/components/scroll-view/sticky-header.js.map +1 -1
  62. package/build/angular/components/section-list/index.d.ts +2 -1
  63. package/build/angular/components/section-list/index.js +31 -39
  64. package/build/angular/components/section-list/index.js.map +1 -1
  65. package/build/angular/components/switch/index.android.d.ts +7 -3
  66. package/build/angular/components/switch/index.android.js +38 -10
  67. package/build/angular/components/switch/index.android.js.map +1 -1
  68. package/build/angular/components/switch/index.ios.d.ts +7 -3
  69. package/build/angular/components/switch/index.ios.js +38 -10
  70. package/build/angular/components/switch/index.ios.js.map +1 -1
  71. package/build/angular/components/switch/shared.d.ts +12 -3
  72. package/build/angular/components/switch/shared.js +66 -5
  73. package/build/angular/components/switch/shared.js.map +1 -1
  74. package/build/angular/components/text-input.d.ts +10 -2
  75. package/build/angular/components/text-input.js +46 -6
  76. package/build/angular/components/text-input.js.map +1 -1
  77. package/build/angular/components/touchable/index.d.ts +17 -8
  78. package/build/angular/components/touchable/index.js +78 -19
  79. package/build/angular/components/touchable/index.js.map +1 -1
  80. package/build/angular/components/touchable-native-feedback/index.d.ts +6 -3
  81. package/build/angular/components/touchable-native-feedback/index.js +61 -25
  82. package/build/angular/components/touchable-native-feedback/index.js.map +1 -1
  83. package/build/angular/components/virtualized-list/directives.js +18 -18
  84. package/build/angular/components/virtualized-list/index.d.ts +12 -4
  85. package/build/angular/components/virtualized-list/index.js +163 -68
  86. package/build/angular/components/virtualized-list/index.js.map +1 -1
  87. package/build/angular/components/virtualized-section-list/directives.js +12 -12
  88. package/build/angular/components/virtualized-section-list/index.d.ts +2 -1
  89. package/build/angular/components/virtualized-section-list/index.js +6 -4
  90. package/build/angular/components/virtualized-section-list/index.js.map +1 -1
  91. package/build/angular/components.d.ts +1 -1
  92. package/build/angular/components.js +5 -0
  93. package/build/angular/components.js.map +1 -1
  94. package/build/angular/create-portal/index.js +29 -35
  95. package/build/angular/create-portal/index.js.map +1 -1
  96. package/build/angular/create-tunnel/index.js +19 -22
  97. package/build/angular/create-tunnel/index.js.map +1 -1
  98. package/build/angular/descriptor-to-angular/index.js +3 -3
  99. package/build/angular/index.d.ts +4 -2
  100. package/build/angular/index.js +2 -2
  101. package/build/angular/index.js.map +1 -1
  102. package/build/angular/modules/animated/animated-leaf-binder.d.ts +1 -7
  103. package/build/angular/modules/animated/animated-leaf-binder.js +22 -72
  104. package/build/angular/modules/animated/animated-leaf-binder.js.map +1 -1
  105. package/build/angular/modules/animated/create-animated-component.d.ts +11 -6
  106. package/build/angular/modules/animated/create-animated-component.js +83 -50
  107. package/build/angular/modules/animated/create-animated-component.js.map +1 -1
  108. package/build/angular/modules/app-registry/index.js.map +1 -1
  109. package/build/angular/modules/status-bar/index.js +3 -3
  110. package/build/angular/primitives/index.d.ts +1 -1
  111. package/build/angular/primitives/index.js +46 -46
  112. package/build/angular/primitives/index.js.map +1 -1
  113. package/build/angular/primitives/shared.d.ts +39 -1
  114. package/build/angular/primitives/shared.js +110 -9
  115. package/build/angular/primitives/shared.js.map +1 -1
  116. package/build/angular/render/index.js +23 -35
  117. package/build/angular/render/index.js.map +1 -1
  118. package/build/angular/services/color-scheme.service.js +3 -3
  119. package/build/angular/services/window-dimensions.service.js +3 -3
  120. package/package.json +13 -8
  121. package/src/anchor-host-registry.ts +2 -2
  122. package/src/components/activity-indicator/index.android.ts +2 -0
  123. package/src/components/activity-indicator/index.ios.ts +2 -0
  124. package/src/components/button.ts +28 -14
  125. package/src/components/flat-list/index.ts +34 -36
  126. package/src/components/image/index.android.ts +50 -6
  127. package/src/components/image/index.ios.ts +50 -6
  128. package/src/components/image/shared.ts +34 -2
  129. package/src/components/image-background.ts +2 -1
  130. package/src/components/input-accessory-view/index.ts +42 -4
  131. package/src/components/keyboard-avoiding-view/index.ts +7 -1
  132. package/src/components/modal/index.ts +51 -26
  133. package/src/components/pressable/index.ts +67 -8
  134. package/src/components/refresh-control/index.ts +54 -12
  135. package/src/components/safe-area-view/index.ts +50 -5
  136. package/src/components/scroll-view/index.android.ts +34 -41
  137. package/src/components/scroll-view/index.ios.ts +10 -21
  138. package/src/components/scroll-view/projection.ts +79 -7
  139. package/src/components/scroll-view/shared.ts +223 -68
  140. package/src/components/scroll-view/sticky-header.ts +11 -16
  141. package/src/components/section-list/index.ts +27 -36
  142. package/src/components/switch/index.android.ts +48 -7
  143. package/src/components/switch/index.ios.ts +48 -7
  144. package/src/components/switch/shared.ts +79 -3
  145. package/src/components/text-input.ts +52 -2
  146. package/src/components/touchable/index.ts +76 -16
  147. package/src/components/touchable-native-feedback/index.ts +62 -21
  148. package/src/components/virtualized-list/index.ts +137 -44
  149. package/src/components/virtualized-section-list/index.ts +2 -1
  150. package/src/components.ts +5 -0
  151. package/src/create-portal/index.ts +23 -29
  152. package/src/create-tunnel/index.ts +13 -16
  153. package/src/index.ts +30 -1
  154. package/src/modules/animated/animated-leaf-binder.ts +28 -77
  155. package/src/modules/animated/create-animated-component.ts +75 -36
  156. package/src/modules/app-registry/index.ts +6 -8
  157. package/src/primitives/index.ts +1 -0
  158. package/src/primitives/shared.ts +102 -3
  159. package/src/render/index.ts +23 -35
  160. package/build/angular/modules/animated/animated-leaf-binder/index.d.ts +0 -14
  161. package/build/angular/modules/animated/animated-leaf-binder/index.js +0 -91
  162. package/build/angular/modules/animated/animated-leaf-binder/index.js.map +0 -1
  163. package/src/__tests__/responder-change-detection.test.ts +0 -102
  164. package/src/__tests__/responder-nested-cd.test.ts +0 -113
  165. package/src/angular-gaps.test.ts +0 -142
  166. package/src/angular-imports.test.ts +0 -100
  167. package/src/components/activity-indicator/activity-indicator-class-style.test.ts +0 -44
  168. package/src/components/activity-indicator/activity-indicator.test.ts +0 -131
  169. package/src/components/flat-list/column-wrapper-style-class.test.ts +0 -116
  170. package/src/components/flat-list/flat-list-array-style.test.ts +0 -131
  171. package/src/components/flat-list/flat-list-class-style.test.ts +0 -74
  172. package/src/components/flat-list/flat-list-scroll-containment.test.ts +0 -107
  173. package/src/components/flat-list/flat-list.test.ts +0 -99
  174. package/src/components/image/image-class-style.test.ts +0 -46
  175. package/src/components/image-background-class-style.test.ts +0 -48
  176. package/src/components/image-background-style-class.test.ts +0 -79
  177. package/src/components/input-accessory-view/input-accessory-view.test.ts +0 -43
  178. package/src/components/keyboard-avoiding-view/keyboard-avoiding-view.test.ts +0 -54
  179. package/src/components/modal/modal.test.ts +0 -43
  180. package/src/components/pressable/pressable.test.ts +0 -85
  181. package/src/components/refresh-control/refresh-control.test.ts +0 -41
  182. package/src/components/safe-area-view/safe-area-view.test.ts +0 -43
  183. package/src/components/scroll-view/android-scroll-view-axis-projection.test.ts +0 -123
  184. package/src/components/scroll-view/content-container-style-class.test.ts +0 -75
  185. package/src/components/scroll-view/scroll-view-class-style.test.ts +0 -134
  186. package/src/components/scroll-view/scroll-view-projection.test.ts +0 -194
  187. package/src/components/switch/android-switch-class.test.ts +0 -42
  188. package/src/components/switch/switch.test.ts +0 -92
  189. package/src/components/text-input-class-style.test.ts +0 -54
  190. package/src/components/touchable/touchable.test.ts +0 -102
  191. package/src/components/touchable-native-feedback/touchable-native-feedback.test.ts +0 -49
  192. package/src/components/virtualized-list/virtualized-list-class-style.test.ts +0 -76
  193. package/src/components/virtualized-section-list/virtualized-section-list-class-style.test.ts +0 -60
  194. package/src/create-portal/create-portal.test.ts +0 -90
  195. package/src/create-tunnel/create-tunnel.test.ts +0 -120
  196. package/src/descriptor-to-angular/descriptor-outlet.test.ts +0 -94
  197. package/src/host-instance/host-instance.test.ts +0 -49
  198. package/src/modules/animated/animated-leaf-binder/animated-leaf-binder.test.ts +0 -116
  199. package/src/modules/animated/animated-leaf-binder/index.ts +0 -101
  200. package/src/modules/animated/animated-leaf-binder.test.ts +0 -116
  201. package/src/modules/animated/animated-scroll-view.test.ts +0 -102
  202. package/src/modules/app-registry/app-registry.test.ts +0 -106
  203. package/src/modules/status-bar/status-bar.test.ts +0 -50
  204. package/src/render/render.test.ts +0 -147
  205. package/src/renderer/renderer.test.ts +0 -203
  206. package/src/services/services.test.ts +0 -163
@@ -13,11 +13,14 @@ import {
13
13
  ChangeDetectionStrategy,
14
14
  ChangeDetectorRef,
15
15
  Component,
16
+ computed,
16
17
  ElementRef,
17
18
  EventEmitter,
18
19
  inject,
19
20
  Input,
20
21
  Output,
22
+ signal,
23
+ type DoCheck,
21
24
  type OnChanges,
22
25
  type OnInit,
23
26
  type SimpleChanges,
@@ -33,7 +36,6 @@ import {
33
36
  type IAriaProps,
34
37
  type IModalAnimationType,
35
38
  type IModalOrientation,
36
- type IModalOrientationChangeEvent,
37
39
  type IModalPresentationStyle,
38
40
  type IModalState,
39
41
  } from '@symbiote-native/components';
@@ -44,7 +46,13 @@ import {
44
46
  type ISymbioteEvent,
45
47
  type IViewStyle,
46
48
  } from '@symbiote-native/engine';
47
- import { anchorHostStyle, ModalHost, SymbioteHostPropsDirective, ViewHost } from '../../primitives';
49
+ import {
50
+ anchorHostStyle,
51
+ ModalHost,
52
+ SymbioteHostPropsDirective,
53
+ SymbioteStyleInputDirective,
54
+ ViewHost,
55
+ } from '../../primitives';
48
56
 
49
57
  export type {
50
58
  IModalAnimationType,
@@ -53,17 +61,6 @@ export type {
53
61
  IModalOrientationChangeEvent,
54
62
  } from '@symbiote-native/components';
55
63
 
56
- const ORIENTATIONS: ReadonlyArray<IModalOrientation> = ['portrait', 'landscape'];
57
-
58
- function readOrientation(event: ISymbioteEvent): IModalOrientationChangeEvent | undefined {
59
- const native = event.nativeEvent;
60
- if (typeof native !== 'object' || native === null || !('orientation' in native)) return undefined;
61
- const orientation = native.orientation;
62
- return ORIENTATIONS.some(value => value === orientation)
63
- ? { orientation: orientation === 'landscape' ? 'landscape' : 'portrait' }
64
- : undefined;
65
- }
66
-
67
64
  // Mirrors React's IModalProps minus children (Angular takes children via <ng-content>).
68
65
  export interface IAngularModalProps extends IAccessibilityProps, IAriaProps {
69
66
  visible?: boolean;
@@ -79,7 +76,9 @@ export interface IAngularModalProps extends IAccessibilityProps, IAriaProps {
79
76
  onShow?: () => void;
80
77
  onDismiss?: () => void;
81
78
  onRequestClose?: () => void;
82
- onOrientationChange?: (event: IModalOrientationChangeEvent) => void;
79
+ // The engine hands every listener the ISymbioteEvent wrapper, so the orientation is read at
80
+ // event.nativeEvent.orientation (IModalOrientationChangeEvent describes that payload).
81
+ onOrientationChange?: (event: ISymbioteEvent) => void;
83
82
  style?: IStyleProp<IViewStyle>;
84
83
  }
85
84
 
@@ -102,17 +101,18 @@ export type IAngularModalInputs = Omit<
102
101
  @Component({
103
102
  selector: 'Modal',
104
103
  standalone: true,
104
+ hostDirectives: [{ directive: SymbioteStyleInputDirective, inputs: ['style'] }],
105
105
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
106
106
  imports: [ModalHost, ViewHost, SymbioteHostPropsDirective],
107
107
  changeDetection: ChangeDetectionStrategy.OnPush,
108
108
  template: `
109
109
  @if (shouldRender) {
110
110
  <symbiote-modal
111
- [symbioteHostProps]="hostProps"
111
+ [symbioteHostProps]="hostProps()"
112
112
  (show)="show.emit()"
113
113
  (dismiss)="dismiss.emit()"
114
114
  (requestClose)="requestClose.emit()"
115
- (orientationChange)="emitOrientation($event)"
115
+ (orientationChange)="emit(orientationChange, $event)"
116
116
  (accessibilityAction)="emit(accessibilityAction, $event)"
117
117
  (accessibilityTap)="emit(accessibilityTap, $event)"
118
118
  (magicTap)="emit(magicTap, $event)"
@@ -125,11 +125,11 @@ export type IAngularModalInputs = Omit<
125
125
  }
126
126
  `,
127
127
  })
128
- export class Modal implements IAngularModalInputs, OnInit, OnChanges {
128
+ export class Modal implements IAngularModalInputs, OnInit, OnChanges, DoCheck {
129
129
  @Output() readonly show = new EventEmitter<void>();
130
130
  @Output() readonly dismiss = new EventEmitter<void>();
131
131
  @Output() readonly requestClose = new EventEmitter<void>();
132
- @Output() readonly orientationChange = new EventEmitter<IModalOrientationChangeEvent>();
132
+ @Output() readonly orientationChange = new EventEmitter<ISymbioteEvent>();
133
133
  @Output() readonly accessibilityAction = new EventEmitter<ISymbioteEvent>();
134
134
  @Output() readonly accessibilityTap = new EventEmitter<ISymbioteEvent>();
135
135
  @Output() readonly magicTap = new EventEmitter<ISymbioteEvent>();
@@ -195,7 +195,20 @@ export class Modal implements IAngularModalInputs, OnInit, OnChanges {
195
195
  this.state = createInitialModalState(this.visible === true);
196
196
  }
197
197
 
198
+ // Bridges the non-reactive @Input fields `hostProps` reads into the reactive graph, so it can
199
+ // memoize. Plain fields read inside a computed() are UNTRACKED - something must signal "a
200
+ // dependency changed" or the bag goes stale. Signal inputs would do this natively, but `input()`
201
+ // is visible only to the AOT compiler and this package's unit suite runs on JIT (see the
202
+ // `angular-adapter-change-detection` skill, §6); `signal()`/`computed()` are plain runtime APIs.
203
+ // The keep-alive `state` is deliberately NOT a dependency: it gates `shouldRender`, never the bag.
204
+ private readonly hostPropsRevision = signal(0);
205
+ // What the anchor's class-derived style was when the bag was last built (identity, not value).
206
+ private lastAnchorStyle: unknown;
207
+
198
208
  ngOnChanges(changes: SimpleChanges): void {
209
+ // Before the keep-alive early-return below: this is the single moment Angular has finished
210
+ // writing every changed @Input, and `hostProps` depends on far more than `visible`.
211
+ this.hostPropsRevision.update(revision => revision + 1);
199
212
  const visibleChange = changes.visible;
200
213
  // First change is reflected by the ngOnInit seed; only later toggles drive the keep-alive.
201
214
  if (visibleChange === undefined || visibleChange.firstChange) return;
@@ -233,13 +246,31 @@ export class Modal implements IAngularModalInputs, OnInit, OnChanges {
233
246
  });
234
247
  }
235
248
 
249
+ // The anchor's class-derived style is NOT an @Input: `class="..."`/`[ngClass]` at the use site
250
+ // resolves through the renderer's addClass onto this component's anchor host (see
251
+ // anchorHostStyle's doc comment), so it never shows up in SimpleChanges and ngOnChanges alone
252
+ // would leave a later class toggle stranded. ngDoCheck runs at exactly the cadence the old
253
+ // getter was re-read, and bumps only on a real identity change.
254
+ ngDoCheck(): void {
255
+ const anchorStyle = anchorHostStyle(this.elementRef);
256
+ if (anchorStyle === this.lastAnchorStyle) return;
257
+ this.lastAnchorStyle = anchorStyle;
258
+ this.hostPropsRevision.update(revision => revision + 1);
259
+ }
260
+
236
261
  // renderModal's own MODAL_HOST_STYLE (position:'absolute') is the outer symbiote-modal node's
237
262
  // style; the anchor's class-derived style goes FIRST, that resolved style SECOND —
238
263
  // flattenStyle's later-wins collapse keeps the modal's own style winning over its ambient class.
239
- get hostProps(): Record<string, unknown> {
264
+ //
265
+ // A computed(), not a getter: Angular re-reads a template getter on every refresh of this view
266
+ // and `[symbioteHostProps]` compares by REFERENCE, so a freshly rebuilt (but identical) bag
267
+ // re-pushes every key through renderer.setProperty -> the engine's prop routing - and this one
268
+ // re-runs renderModal to build it.
269
+ readonly hostProps = computed<Record<string, unknown>>(() => {
270
+ this.hostPropsRevision();
240
271
  const descriptorProps = this.descriptor.props;
241
272
  return { ...descriptorProps, style: [anchorHostStyle(this.elementRef), descriptorProps.style] };
242
- }
273
+ });
243
274
 
244
275
  get containerStyle(): unknown {
245
276
  const container = this.descriptor.children[0];
@@ -250,12 +281,6 @@ export class Modal implements IAngularModalInputs, OnInit, OnChanges {
250
281
  if (isSymbioteEvent(event)) emitter.emit(event);
251
282
  }
252
283
 
253
- emitOrientation(event: unknown): void {
254
- if (!isSymbioteEvent(event)) return;
255
- const change = readOrientation(event);
256
- if (change !== undefined) this.orientationChange.emit(change);
257
- }
258
-
259
284
  // Typed as the a11y intersection WITH the string index (the bag renderModal spreads into the
260
285
  // host props), so resolveAccessibilityProps's result stays assignable to passthrough.
261
286
  private accessibilityInputs(): IAccessibilityProps & IAriaProps & Record<string, unknown> {
@@ -5,10 +5,14 @@ import {
5
5
  Component,
6
6
  ElementRef,
7
7
  EventEmitter,
8
+ computed,
8
9
  inject,
9
10
  Input,
10
11
  Output,
12
+ signal,
11
13
  ViewChild,
14
+ type DoCheck,
15
+ type OnChanges,
12
16
  } from '@angular/core';
13
17
  import {
14
18
  createPressHandlers,
@@ -40,7 +44,12 @@ import {
40
44
  type IStyleProp,
41
45
  type IViewStyle,
42
46
  } from '@symbiote-native/engine';
43
- import { anchorHostStyle, SymbioteHostPropsDirective, ViewHost } from '../../primitives';
47
+ import {
48
+ anchorHostStyle,
49
+ SymbioteHostPropsDirective,
50
+ SymbioteStyleInputDirective,
51
+ ViewHost,
52
+ } from '../../primitives';
44
53
 
45
54
  export type { IPressState, IPressableAndroidRippleConfig } from '@symbiote-native/components';
46
55
 
@@ -109,12 +118,13 @@ function asSymbioteEvent(event: unknown): ISymbioteEvent | undefined {
109
118
  @Component({
110
119
  selector: 'Pressable, symbiote-pressable',
111
120
  standalone: true,
121
+ hostDirectives: [{ directive: SymbioteStyleInputDirective, inputs: ['style'] }],
112
122
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
113
123
  imports: [ViewHost, SymbioteHostPropsDirective],
114
124
  template: `
115
125
  <symbiote-view
116
126
  #host
117
- [symbioteHostProps]="hostProps"
127
+ [symbioteHostProps]="hostProps()"
118
128
  (accessibilityAction)="emit(accessibilityAction, $event)"
119
129
  (accessibilityTap)="emit(accessibilityTap, $event)"
120
130
  (magicTap)="emit(magicTap, $event)"
@@ -131,7 +141,7 @@ function asSymbioteEvent(event: unknown): ISymbioteEvent | undefined {
131
141
  `,
132
142
  changeDetection: ChangeDetectionStrategy.OnPush,
133
143
  })
134
- export class Pressable implements IAngularPressableInputs {
144
+ export class Pressable implements IAngularPressableInputs, OnChanges, DoCheck {
135
145
  // The press/hover lifecycle as real Angular events: `(press)="onTap($event)"`, not
136
146
  // `[onPress]="onTap"`. createPressHandlers still wants plain IPressHandler callbacks, so
137
147
  // emitterHandler() below adapts each EventEmitter into one - only while something is actually
@@ -207,11 +217,20 @@ export class Pressable implements IAngularPressableInputs {
207
217
 
208
218
  @ViewChild('host', { read: ElementRef }) private hostElement?: ElementRef<unknown>;
209
219
 
210
- pressed = false;
220
+ // INTERNAL mutable state, driven by the press machine rather than by an @Input, so ngOnChanges
221
+ // can never cover it - it carries its OWN signal, which is what lets the memoized bag below stay
222
+ // correct across a press. Exposed as a plain boolean getter so a use site's `#ref.pressed` keeps
223
+ // reading a boolean and the public surface is unchanged.
224
+ private readonly pressedState = signal(false);
225
+
226
+ get pressed(): boolean {
227
+ return this.pressedState();
228
+ }
229
+
211
230
  private readonly runtime: IPressRuntime = createPressRuntime();
212
231
  private readonly host: IPressHost = {
213
232
  setPressed: pressed => {
214
- this.pressed = pressed;
233
+ this.pressedState.set(pressed);
215
234
  this.changeDetector.detectChanges();
216
235
  },
217
236
  getMeasureFn: () => {
@@ -231,6 +250,39 @@ export class Pressable implements IAngularPressableInputs {
231
250
  // inner `symbiote-view` one level down.
232
251
  private readonly elementRef = inject(ElementRef);
233
252
 
253
+ // Bridges the non-reactive @Input fields above into the reactive graph, so the computed() bag
254
+ // below can memoize a value derived from them. Signal inputs would make this unnecessary, but
255
+ // `input()` is visible only to the AOT compiler and this package's unit suite runs on JIT - see
256
+ // the `angular-adapter-change-detection` skill, §6. Mirrors ScrollView's identical bridge
257
+ // (components/scroll-view/shared.ts), including its constraint: ONLY safe for a bag whose every
258
+ // dependency is an @Input or carries its own signal, never for plain internal mutable state.
259
+ private readonly inputsRevision = signal(0);
260
+
261
+ // The anchor's class-derived style is the one hostProps dependency that is NOT an @Input:
262
+ // `class="..."`/`[class.x]`/`[ngClass]` at the use site resolves through the renderer's
263
+ // addClass/removeClass onto the anchor node (anchorHostStyle's doc comment), so it never appears
264
+ // in SimpleChanges and ngOnChanges cannot cover it. The engine's commitClassStyle allocates a
265
+ // fresh [classStyle, explicitStyle] array on exactly the passes where a class token actually
266
+ // moved, so signal.set's own Object.is check makes an unchanged poll a no-op that dirties nothing.
267
+ private readonly anchorStyle = signal<unknown>(undefined);
268
+
269
+ // ngDoCheck is the only hook that observes the anchor AFTER the parent wrote it. Angular flushes
270
+ // a node's pre-order hooks at the next `advance` PAST it (hooks.ts callHooks runs "until that
271
+ // node index exclusive") or in refreshView's post-template flush - both strictly after the
272
+ // parent's classProp on this element, and strictly before this component's own view refreshes.
273
+ // Angular also nulls the active consumer around lifecycle hooks (render3/hooks.ts), so this
274
+ // write registers no dependency on the caller's view and cannot throw NG0600.
275
+ ngDoCheck(): void {
276
+ this.anchorStyle.set(anchorHostStyle(this.elementRef));
277
+ }
278
+
279
+ ngOnChanges(): void {
280
+ // Tells the computed bag below that an @Input moved - plain class fields are not reactive on
281
+ // their own. Must stay in ngOnChanges: that is the single moment Angular has finished writing
282
+ // every changed input for this pass.
283
+ this.inputsRevision.update(revision => revision + 1);
284
+ }
285
+
234
286
  get resolvedStyle(): IStyleProp<IViewStyle> | undefined {
235
287
  const state: IPressState = { pressed: this.pressed };
236
288
  return isStyleFn(this.style) ? this.style(state) : this.style;
@@ -279,7 +331,14 @@ export class Pressable implements IAngularPressableInputs {
279
331
  }
280
332
 
281
333
  // Flat bag for `[symbioteHostProps]` (Renderer2.setProperty per key) - see primitives/shared.ts.
282
- get hostProps(): Record<string, unknown> {
334
+ //
335
+ // MEASURED 2026-08-16: as a getter this rebuilt the bag on every refresh of this view, and a
336
+ // press dirties this very view (SymbioteHostPropsDirective wraps each flat-bag onX with
337
+ // markForCheck, and setPressed calls detectChanges outright), so `[symbioteHostProps]`'s
338
+ // reference check failed every time and re-pushed every key through Renderer2.setProperty ->
339
+ // routeProp for an identical bag. Memoized, an unchanged bag costs one reference compare.
340
+ readonly hostProps = computed<Record<string, unknown>>(() => {
341
+ this.inputsRevision();
283
342
  return {
284
343
  testID: this.testID,
285
344
  nativeID: this.nativeID,
@@ -289,14 +348,14 @@ export class Pressable implements IAngularPressableInputs {
289
348
  nextFocusLeft: this.nextFocusLeft,
290
349
  nextFocusRight: this.nextFocusRight,
291
350
  nextFocusUp: this.nextFocusUp,
292
- style: [anchorHostStyle(this.elementRef), this.resolvedStyle],
351
+ style: [this.anchorStyle(), this.resolvedStyle],
293
352
  accessible: this.accessible,
294
353
  ...this.foldedAccessibility,
295
354
  android_disableSound: this.android_disableSound,
296
355
  nativeBackgroundAndroid: this.resolvedRippleProps?.nativeBackgroundAndroid,
297
356
  nativeForegroundAndroid: this.resolvedRippleProps?.nativeForegroundAndroid,
298
357
  };
299
- }
358
+ });
300
359
 
301
360
  handlePressIn(event: unknown): void {
302
361
  const symbioteEvent = asSymbioteEvent(event);
@@ -1,12 +1,12 @@
1
1
  // RefreshControl, the Angular lifecycle half. On iOS this is the PullToRefreshView Fabric node that
2
2
  // lives INSIDE a ScrollView (a childless sibling before the content container); on Android it is
3
- // AndroidSwipeRefreshLayout and WRAPS the scroll view, receiving it through <ng-content>.
4
- // The Angular twin of the React/Vue RefreshControl. There is no JS-side platform renaming and no
5
- // shared render fn every prop forwards straight to the native node, which reads the ones it
6
- // understands and ignores the rest, so the Android-only and iOS-only families ride down harmlessly
7
- // on both. So this folds aria/role through the shared resolveAccessibilityProps and maps the native
8
- // props + a11y + onRefresh straight onto the symbiote-refresh-control host, children via <ng-content>.
9
- // No platform branch (one composed component both platforms), so this stays a flat single file.
3
+ // AndroidSwipeRefreshLayout and WRAPS the scroll view, receiving it through <ng-content>. There is
4
+ // no JS-side platform renaming and no shared render fn every prop forwards straight to the
5
+ // native node, which reads what it understands and ignores the rest, so Android-only and iOS-only
6
+ // prop families ride down harmlessly on both. This folds aria/role through the shared
7
+ // resolveAccessibilityProps and maps the native props + a11y + onRefresh onto the
8
+ // symbiote-refresh-control host, children via <ng-content>. One composed component covers both
9
+ // platforms, so this stays a flat single file.
10
10
  //
11
11
  // `refreshing` is a controlled prop: the parent owns it and pushes it down each commit; native
12
12
  // reports the gesture via the direct `topRefresh` event, which the engine routes to the host's
@@ -16,13 +16,16 @@ import {
16
16
  CUSTOM_ELEMENTS_SCHEMA,
17
17
  ChangeDetectionStrategy,
18
18
  Component,
19
+ computed,
19
20
  ElementRef,
20
21
  EventEmitter,
21
22
  inject,
22
23
  Input,
23
24
  Output,
25
+ signal,
24
26
  ViewChild,
25
27
  type AfterViewInit,
28
+ type DoCheck,
26
29
  type OnChanges,
27
30
  type OnInit,
28
31
  type SimpleChanges,
@@ -42,7 +45,12 @@ import {
42
45
  type ISymbioteEvent,
43
46
  type IViewStyle,
44
47
  } from '@symbiote-native/engine';
45
- import { anchorHostStyle, RefreshControlHost, SymbioteHostPropsDirective } from '../../primitives';
48
+ import {
49
+ anchorHostStyle,
50
+ RefreshControlHost,
51
+ SymbioteHostPropsDirective,
52
+ SymbioteStyleInputDirective,
53
+ } from '../../primitives';
46
54
 
47
55
  // Record<string, unknown> already tolerates `style` holding the [anchorHostStyle, this.style]
48
56
  // array anchorHostStyle's merge produces (see hostProps below) — no widening needed.
@@ -89,13 +97,14 @@ export type IAngularRefreshControlInputs = Omit<
89
97
  @Component({
90
98
  selector: 'RefreshControl',
91
99
  standalone: true,
100
+ hostDirectives: [{ directive: SymbioteStyleInputDirective, inputs: ['style'] }],
92
101
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
93
102
  imports: [RefreshControlHost, SymbioteHostPropsDirective],
94
103
  changeDetection: ChangeDetectionStrategy.OnPush,
95
104
  template: `
96
105
  <symbiote-refresh-control
97
106
  #host
98
- [symbioteHostProps]="hostProps"
107
+ [symbioteHostProps]="hostProps()"
99
108
  (refresh)="handleRefresh()"
100
109
  (accessibilityAction)="emit(accessibilityAction, $event)"
101
110
  (accessibilityTap)="emit(accessibilityTap, $event)"
@@ -107,7 +116,7 @@ export type IAngularRefreshControlInputs = Omit<
107
116
  `,
108
117
  })
109
118
  export class RefreshControl
110
- implements IAngularRefreshControlInputs, OnInit, OnChanges, AfterViewInit
119
+ implements IAngularRefreshControlInputs, OnInit, OnChanges, DoCheck, AfterViewInit
111
120
  {
112
121
  // Controlled prop the parent owns; required to match the React reference surface.
113
122
  @Input({ required: true }) refreshing!: boolean;
@@ -175,6 +184,18 @@ export class RefreshControl
175
184
  private lastNativeRefreshing = false;
176
185
  private refreshNativeNode: unknown;
177
186
 
187
+ // Bridges the non-reactive @Input fields `hostProps` reads into the reactive graph, so it can
188
+ // memoize. Plain fields read inside a computed() are UNTRACKED - something must signal "a
189
+ // dependency changed" or the bag goes stale. Signal inputs would do this natively, but `input()`
190
+ // is visible only to the AOT compiler and this package's unit suite runs on JIT (see the
191
+ // `angular-adapter-change-detection` skill, §6); `signal()`/`computed()` are plain runtime APIs.
192
+ // Safe here because `refreshing` is a CONTROLLED @Input - the parent owns it, nothing in this
193
+ // class assigns it. The internal `lastNativeRefreshing` / `refreshNativeNode` fields drive the
194
+ // native handshake only; neither is read by the bag, so neither may bump this.
195
+ private readonly hostPropsRevision = signal(0);
196
+ // What the anchor's class-derived style was when the bag was last built (identity, not value).
197
+ private lastAnchorStyle: unknown;
198
+
178
199
  ngOnInit(): void {
179
200
  dlog('RefreshControl -> PullToRefreshView');
180
201
  dlog(`RefreshControl refreshing=${String(this.refreshing)}`);
@@ -188,6 +209,9 @@ export class RefreshControl
188
209
  }
189
210
 
190
211
  ngOnChanges(changes: SimpleChanges): void {
212
+ // Before the `refreshing`-only early-return below: this is the single moment Angular has
213
+ // finished writing every changed @Input, and `hostProps` depends on the whole input surface.
214
+ this.hostPropsRevision.update(revision => revision + 1);
191
215
  const refreshing = changes.refreshing;
192
216
  if (refreshing === undefined) return;
193
217
  if (refreshing.firstChange) {
@@ -220,11 +244,29 @@ export class RefreshControl
220
244
  if (isSymbioteEvent(event)) emitter.emit(event);
221
245
  }
222
246
 
247
+ // The anchor's class-derived style is NOT an @Input: `class="..."`/`[ngClass]` at the use site
248
+ // resolves through the renderer's addClass onto this component's anchor host (see
249
+ // anchorHostStyle's doc comment), so it never shows up in SimpleChanges and ngOnChanges alone
250
+ // would leave a later class toggle stranded. ngDoCheck runs at exactly the cadence the old
251
+ // getter was re-read, and bumps only on a real identity change.
252
+ ngDoCheck(): void {
253
+ const anchorStyle = anchorHostStyle(this.elementRef);
254
+ if (anchorStyle === this.lastAnchorStyle) return;
255
+ this.lastAnchorStyle = anchorStyle;
256
+ this.hostPropsRevision.update(revision => revision + 1);
257
+ }
258
+
223
259
  // The full native + a11y prop bag applied onto the host in one shot via
224
260
  // [symbioteHostProps], instead of enumerating each key as its own template binding. The
225
261
  // anchor's class-derived style goes FIRST, this component's own explicit `style` @Input SECOND
226
262
  // — flattenStyle's later-wins collapse keeps an explicit [style] winning over its ambient class.
227
- get hostProps(): IHostProps {
263
+ //
264
+ // A computed(), not a getter: Angular re-reads a template getter on every refresh of this view
265
+ // and `[symbioteHostProps]` compares by REFERENCE, so a freshly rebuilt (but identical) bag
266
+ // re-pushes every key through renderer.setProperty -> the engine's prop routing. A pull gesture
267
+ // dirties this view through its own `(refresh)` binding, which is exactly when that waste hit.
268
+ readonly hostProps = computed<IHostProps>(() => {
269
+ this.hostPropsRevision();
228
270
  return {
229
271
  refreshing: this.refreshing,
230
272
  tintColor: this.tintColor,
@@ -241,7 +283,7 @@ export class RefreshControl
241
283
  accessible: this.accessible,
242
284
  ...this.folded,
243
285
  };
244
- }
286
+ });
245
287
 
246
288
  // Fold the web aria-*/role aliases into the canonical accessibility* props once per render, so the
247
289
  // host node never sees an aria-* key (native ignores them) — the shared transform every adapter runs.
@@ -10,11 +10,15 @@ import {
10
10
  CUSTOM_ELEMENTS_SCHEMA,
11
11
  ChangeDetectionStrategy,
12
12
  Component,
13
+ computed,
13
14
  ElementRef,
14
15
  EventEmitter,
15
16
  inject,
16
17
  Input,
17
18
  Output,
19
+ signal,
20
+ type DoCheck,
21
+ type OnChanges,
18
22
  } from '@angular/core';
19
23
  import {
20
24
  resolveAccessibilityProps,
@@ -28,7 +32,12 @@ import {
28
32
  type ISymbioteEvent,
29
33
  type IViewStyle,
30
34
  } from '@symbiote-native/engine';
31
- import { anchorHostStyle, SafeAreaViewHost, SymbioteHostPropsDirective } from '../../primitives';
35
+ import {
36
+ anchorHostStyle,
37
+ SafeAreaViewHost,
38
+ SymbioteHostPropsDirective,
39
+ SymbioteStyleInputDirective,
40
+ } from '../../primitives';
32
41
 
33
42
  // Mirrors React's ISafeAreaViewProps minus children (Angular takes children via <ng-content>),
34
43
  // declared per-adapter over the shared accessibility base since the framework-specific children
@@ -53,12 +62,13 @@ export type IAngularSafeAreaViewInputs = Omit<
53
62
  @Component({
54
63
  selector: 'SafeAreaView',
55
64
  standalone: true,
65
+ hostDirectives: [{ directive: SymbioteStyleInputDirective, inputs: ['style'] }],
56
66
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
57
67
  imports: [SafeAreaViewHost, SymbioteHostPropsDirective],
58
68
  changeDetection: ChangeDetectionStrategy.OnPush,
59
69
  template: `
60
70
  <symbiote-safe-area-view
61
- [symbioteHostProps]="hostProps"
71
+ [symbioteHostProps]="hostProps()"
62
72
  (accessibilityAction)="emit(accessibilityAction, $event)"
63
73
  (accessibilityTap)="emit(accessibilityTap, $event)"
64
74
  (magicTap)="emit(magicTap, $event)"
@@ -69,7 +79,7 @@ export type IAngularSafeAreaViewInputs = Omit<
69
79
  </symbiote-safe-area-view>
70
80
  `,
71
81
  })
72
- export class SafeAreaView implements IAngularSafeAreaViewInputs {
82
+ export class SafeAreaView implements IAngularSafeAreaViewInputs, OnChanges, DoCheck {
73
83
  @Input() style?: IStyleProp<IViewStyle>;
74
84
  // Real @Output()s, not `[onX]="…"` callbacks. Safe to name `layout` the same as the native
75
85
  // `layout` event fired inside this component's own template — the engine's bubble() treats
@@ -127,6 +137,35 @@ export class SafeAreaView implements IAngularSafeAreaViewInputs {
127
137
  if (isSymbioteEvent(event)) emitter.emit(event);
128
138
  }
129
139
 
140
+ // Bridges the non-reactive fields `hostProps` reads into the reactive graph, so it can memoize.
141
+ // Plain fields read inside a computed() are UNTRACKED - something must signal "a dependency
142
+ // changed" or the bag goes stale. Signal inputs would do this natively, but `input()` is visible
143
+ // only to the AOT compiler and this package's unit suite runs on JIT (see the
144
+ // `angular-adapter-change-detection` skill, §6); `signal()`/`computed()` are plain runtime APIs.
145
+ // TWO bump sites, both required - see ngOnChanges and ngDoCheck below.
146
+ private readonly hostPropsRevision = signal(0);
147
+ // What the anchor's class-derived style was when the bag was last built (identity, not value).
148
+ private lastAnchorStyle: unknown;
149
+
150
+ ngOnChanges(): void {
151
+ // The single moment Angular has finished writing every changed @Input for this pass.
152
+ this.hostPropsRevision.update(revision => revision + 1);
153
+ }
154
+
155
+ // The anchor's class-derived style is NOT an @Input: `class="..."`/`[ngClass]` at the use site
156
+ // resolves through the renderer's addClass onto this component's anchor host (see
157
+ // anchorHostStyle's doc comment), so it never shows up in SimpleChanges - ngOnChanges alone
158
+ // would strand a class toggled after mount (regression-covered by safe-area-view.test.ts's
159
+ // class-toggle case, which fails the moment this hook stops bumping). ngDoCheck runs at exactly
160
+ // the cadence the old getter was re-read, and bumps only on a real identity change, so an
161
+ // unchanged anchor still costs nothing.
162
+ ngDoCheck(): void {
163
+ const anchorStyle = anchorHostStyle(this.elementRef);
164
+ if (anchorStyle === this.lastAnchorStyle) return;
165
+ this.lastAnchorStyle = anchorStyle;
166
+ this.hostPropsRevision.update(revision => revision + 1);
167
+ }
168
+
130
169
  // Folds style/testID/nativeID/accessible alongside the accessibility*/aria-*/role surface into
131
170
  // one bag for [symbioteHostProps] — resolveAccessibilityProps takes the whole props object (not
132
171
  // just the accessibility slice) and returns it with aria-*/role folded into accessibility*, so
@@ -134,7 +173,13 @@ export class SafeAreaView implements IAngularSafeAreaViewInputs {
134
173
  // SafeAreaView runs over its whole `rawProps`. The anchor's class-derived style goes FIRST, this
135
174
  // component's own explicit `style` @Input SECOND — flattenStyle's later-wins collapse keeps an
136
175
  // explicit [style] winning over its ambient class.
137
- get hostProps(): Record<string, unknown> {
176
+ //
177
+ // A computed(), not a getter: Angular re-reads a template getter on every refresh of this view
178
+ // and `[symbioteHostProps]` compares by REFERENCE, so a freshly rebuilt (but identical) bag
179
+ // re-pushes every key through renderer.setProperty -> the engine's prop routing. Memoized, an
180
+ // a11y/layout event that only dirties this view costs nothing.
181
+ readonly hostProps = computed<Record<string, unknown>>(() => {
182
+ this.hostPropsRevision();
138
183
  return resolveAccessibilityProps({
139
184
  style: [anchorHostStyle(this.elementRef), this.style],
140
185
  testID: this.testID,
@@ -173,5 +218,5 @@ export class SafeAreaView implements IAngularSafeAreaViewInputs {
173
218
  'aria-valuenow': this.ariaValueNow,
174
219
  'aria-valuetext': this.ariaValueText,
175
220
  });
176
- }
221
+ });
177
222
  }