@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,6 +1,7 @@
1
1
  import {
2
2
  ChangeDetectorRef,
3
3
  Directive,
4
+ type DoCheck,
4
5
  ElementRef,
5
6
  inject,
6
7
  Input,
@@ -8,6 +9,7 @@ import {
8
9
  Renderer2,
9
10
  type SimpleChanges,
10
11
  } from '@angular/core';
12
+ import { countAngular } from '../diagnostics';
11
13
  import {
12
14
  flattenStyle,
13
15
  isSymbioteNode,
@@ -60,7 +62,9 @@ function isStyleValue<T>(value: unknown): value is IStyleProp<T> {
60
62
  );
61
63
  }
62
64
 
63
- export function anchorStyleProp<T>(elementRef: ElementRef<unknown>): IStyleProp<T> | undefined {
65
+ export function anchorStyleProp<T>(
66
+ elementRef: ElementRef<unknown>,
67
+ ): IStyleProp<T> | undefined {
64
68
  const value = anchorHostStyle(elementRef);
65
69
  return isStyleValue<T>(value) ? value : undefined;
66
70
  }
@@ -69,7 +73,10 @@ function isRecord(value: unknown): value is Record<string, unknown> {
69
73
  return typeof value === 'object' && value !== null;
70
74
  }
71
75
 
72
- function shallowStyleEqual(a: Record<string, unknown>, b: Record<string, unknown>): boolean {
76
+ function shallowStyleEqual(
77
+ a: Record<string, unknown>,
78
+ b: Record<string, unknown>,
79
+ ): boolean {
73
80
  const aKeys = Object.keys(a);
74
81
  if (aKeys.length !== Object.keys(b).length) return false;
75
82
  return aKeys.every(key => a[key] === b[key]);
@@ -96,11 +103,96 @@ export function stableAnchorStyle(
96
103
  previous: unknown,
97
104
  ): Record<string, unknown> {
98
105
  const next = flattenStyle([anchorHostStyle(elementRef), explicitStyle]);
99
- return isRecord(previous) && shallowStyleEqual(previous, next) ? previous : next;
106
+ return isRecord(previous) && shallowStyleEqual(previous, next)
107
+ ? previous
108
+ : next;
100
109
  }
101
110
 
102
111
  const ON_PREFIX = /^on[A-Z]/;
103
112
 
113
+ // The one `onX` that fires per FRAME rather than per gesture, and with sticky headers RN pins
114
+ // `scrollEventThrottle` to 1 (ScrollView.js), so 60 times a second. Since markForCheck walks
115
+ // RefreshView|Dirty to the ROOT, wrapping it cost one full ancestor-screen template execution per
116
+ // frame - the canary's ~37fps scroll (components/virtualized-list/scroll-cost.test.ts).
117
+ //
118
+ // Nothing lost its refresh: VirtualizedList marks itself when the window moves, sticky rides the
119
+ // native driver, and a caller's own handler is typically an Animated.event touching no Angular
120
+ // state. Drag/momentum begin/end stay wrapped - once per gesture is not a hot path.
121
+ const PER_FRAME_CALLBACK = 'onScroll';
122
+
123
+ /**
124
+ * Restores the OnPush dirty-marking Angular skips for a `[style]` binding that a directive
125
+ * declares as an input. Attach it to any component declaring a `style` input:
126
+ * `hostDirectives: [{ directive: SymbioteStyleInputDirective, inputs: ['style'] }]`.
127
+ *
128
+ * An ordinary `[foo]` binding compiles to `ɵɵproperty` -> `setPropertyAndInputs`, whose last act
129
+ * for a component host is `markDirtyIfOnPush` (`render3/instructions/shared.ts`) — the flag
130
+ * `detectChangesInView` needs before it will re-execute a non-CheckAlways view. `[style]` and
131
+ * `[class]` do NOT take that path: they compile to the styling instructions, which on finding a
132
+ * directive input of the same name hand off to `setDirectiveInputsWhichShadowsStyling`
133
+ * (`render3/instructions/property.ts`) — two lines that write the input and stop. No dirty flag.
134
+ * The input lands and `ngOnChanges` fires, but the receiving component's own template, and every
135
+ * getter it reads, is frozen at its creation value. Verified against @angular/core 22.0.8.
136
+ *
137
+ * Symbiote sits squarely on that gap: every composed component declares `style` as a real
138
+ * `@Input()` on purpose, because a raw `[style]` binding would otherwise be decomposed key by key
139
+ * by Angular's CSS style engine, which cannot represent an RN StyleProp array or an Animated value
140
+ * (see `SymbiotePrimitiveHost` above). So the one binding name Angular forgets to mark is the one
141
+ * carrying the whole styling surface.
142
+ *
143
+ * `markForCheck()` here is exactly the skipped `markDirtyIfOnPush`, not a bigger hammer: the write
144
+ * happens inside the parent's refresh, and `markViewDirty` uses the `Dirty` bit alone while views
145
+ * are refreshing (`render3/instructions/mark_view_dirty.ts`), which the ancestors then clear at the
146
+ * end of their own `refreshView`. A component that already bridges its inputs into a signal (the
147
+ * `inputsRevision` + `computed` pattern — Pressable, ScrollView) is immune on its own, since a
148
+ * signal write dirties the template consumer, which `detectChangesInView` honors in any mode.
149
+ */
150
+ @Directive({
151
+ selector: '[symbioteStyleInput]',
152
+ standalone: true,
153
+ inputs: ['style'],
154
+ })
155
+ export class SymbioteStyleInputDirective implements DoCheck, OnChanges {
156
+ private readonly cdr = inject(ChangeDetectorRef);
157
+ // A host directive's ElementRef is the HOST COMPONENT's element - i.e. exactly the anchor a
158
+ // `class=` at the use site resolves onto.
159
+ private readonly elementRef = inject(ElementRef);
160
+ private lastAnchorStyle: unknown;
161
+
162
+ style?: unknown;
163
+
164
+ ngOnChanges(): void {
165
+ this.cdr.markForCheck();
166
+ }
167
+
168
+ // The `class` half of the same gap, and it needs a POLL, not a hook: a class at the use site
169
+ // never becomes an input, so there is no SimpleChanges entry to fire on. Making the view refresh
170
+ // is enough - every composed component re-reads the anchor in a getter on its own.
171
+ //
172
+ // ngDoCheck runs during the PARENT's refresh even when this view would be skipped, so the mark
173
+ // lands in the same pass rather than one behind. The dedup gate is load-bearing: marking on every
174
+ // check re-dirties the view every tick and free-runs CD, the hazard `stableAnchorStyle` above
175
+ // prevents one level up.
176
+ ngDoCheck(): void {
177
+ countAngular('styleChecks');
178
+ const current = anchorHostStyle(this.elementRef);
179
+ if (isAnchorStyleUnchanged(this.lastAnchorStyle, current)) return;
180
+ this.lastAnchorStyle = current;
181
+ countAngular('styleMarks');
182
+ this.cdr.markForCheck();
183
+ }
184
+ }
185
+
186
+ // Reference equality is the normal path (the engine replaces props.style only on a real class
187
+ // change); the shallow compare guards against an equal-but-fresh object, which would turn the poll
188
+ // above into a CD loop.
189
+ function isAnchorStyleUnchanged(previous: unknown, next: unknown): boolean {
190
+ if (Object.is(previous, next)) return true;
191
+ return (
192
+ isRecord(previous) && isRecord(next) && shallowStyleEqual(previous, next)
193
+ );
194
+ }
195
+
104
196
  /**
105
197
  * Base for all Angular primitive host components (`symbiote-view`, `symbiote-text`, ...).
106
198
  * RN's `StyleProp` can be an object, an array, nested arrays, falsy entries, and Animated
@@ -114,10 +206,17 @@ const ON_PREFIX = /^on[A-Z]/;
114
206
  * custom renderer's `setProperty`, so the engine's `routeProp` handles them as usual.
115
207
  */
116
208
  @Directive()
117
- export abstract class SymbiotePrimitiveHost implements OnChanges {
209
+ export abstract class SymbiotePrimitiveHost implements DoCheck, OnChanges {
118
210
  private readonly renderer = inject(Renderer2);
119
211
  private readonly elementRef = inject(ElementRef);
120
212
 
213
+ // Counting only. Angular calls this once per view per check, so it is the only honest measure of
214
+ // how much tree a tick walks - and with CheckAlways primitives that is the whole application, not
215
+ // the screen on top. One integer increment next to a template refresh is not measurable.
216
+ ngDoCheck(): void {
217
+ countAngular('viewsChecked');
218
+ }
219
+
121
220
  @Input() style?: IStyleProp<unknown>;
122
221
 
123
222
  /**
@@ -130,8 +229,18 @@ export abstract class SymbiotePrimitiveHost implements OnChanges {
130
229
 
131
230
  ngOnChanges(changes: SimpleChanges): void {
132
231
  if (!('style' in changes)) return;
133
- const value = this.style === undefined ? undefined : flattenStyle(this.style);
134
- this.renderer.setProperty(this.elementRef.nativeElement, 'style', value);
232
+ const value =
233
+ this.style === undefined ? undefined : flattenStyle(this.style);
234
+ this.setHostProp('style', value);
235
+ }
236
+
237
+ /**
238
+ * Push one resolved prop onto the host element. `protected` rather than exposing the injected
239
+ * Renderer2/ElementRef, so a subclass applying its own defaults (TextHost) uses the same single
240
+ * write path this base already uses for `style`.
241
+ */
242
+ protected setHostProp(key: string, value: unknown): void {
243
+ this.renderer.setProperty(this.elementRef.nativeElement, key, value);
135
244
  }
136
245
  }
137
246
 
@@ -156,15 +265,58 @@ export class SymbioteHostPropsDirective {
156
265
  // The directive lives in its HOST component's template, so its injected ChangeDetectorRef IS
157
266
  // that component's own view detector — the one thing that can refresh it (see wrapCallback).
158
267
  private readonly cdr = inject(ChangeDetectorRef);
268
+ // One wrapper per ORIGINAL handler, for the lifetime of this directive. Without it every push
269
+ // of the props bag allocated a brand-new closure for every `onX` key, and the engine stored it
270
+ // as a new listener (`setEventListener` allocates its own closure on top) - per key, per push,
271
+ // on a path a scroll frame reaches 60 times a second. The wrapper body closes over nothing but
272
+ // `value` and this instance's `cdr`, so the handler alone is a complete cache key; `key` is
273
+ // read only by the ON_PREFIX guard above and never enters the wrapper.
274
+ //
275
+ // Reference stability is the point, not just the allocation: a fresh function on every push
276
+ // defeats every downstream identity check on the bag, so nothing upstream could ever conclude
277
+ // "this bag is unchanged" while a callback prop was in it.
278
+ private readonly wrappers = new WeakMap<
279
+ object,
280
+ (...args: unknown[]) => unknown
281
+ >();
282
+
283
+ // Values pushed by the previous bag, so a push writes only what actually moved. Copied rather
284
+ // than aliased: a component is free to hand back the same object with mutated fields.
285
+ private pushed: Record<string, unknown> = {};
159
286
 
160
287
  get node(): unknown {
161
288
  return this.elementRef.nativeElement;
162
289
  }
163
290
 
291
+ // MEASURED 2026-08-20, headless 1000-row create: writing every key of every bag cost 104 000
292
+ // engine setProp calls where Solid's identical screen cost 12 000 — and 90 000 of them carried
293
+ // `undefined`. A composed component's bag is a FIXED-SHAPE literal (Pressable's is ~48 keys,
294
+ // most of them unset on any given instance), so the first push of a fresh node spends most of
295
+ // its work deleting keys the node never had. Diffing per key removes exactly that: an unset key
296
+ // is skipped on mount and still written the moment it changes to or from a real value.
164
297
  set symbioteHostProps(props: Record<string, unknown>) {
298
+ const pushed = this.pushed;
299
+ // Only keys the node actually carries, so the vanished-key sweep below walks a handful rather
300
+ // than the bag's whole declared shape.
301
+ const next: Record<string, unknown> = {};
165
302
  for (const [key, value] of Object.entries(props)) {
166
- this.renderer.setProperty(this.elementRef.nativeElement, key, this.wrapCallback(key, value));
303
+ if (value !== undefined) next[key] = value;
304
+ if (Object.is(pushed[key], value)) continue;
305
+ this.renderer.setProperty(
306
+ this.elementRef.nativeElement,
307
+ key,
308
+ this.wrapCallback(key, value),
309
+ );
310
+ }
311
+ // A bag's key SET is not fixed across pushes — resolveAccessibilityProps folds aria-* aliases
312
+ // into a different shape once any of them holds a value — and a key that vanishes must clear
313
+ // the prop rather than leave the last value on the native view. A key still IN the bag was
314
+ // already handled above, `undefined` included.
315
+ for (const key of Object.keys(pushed)) {
316
+ if (key in props) continue;
317
+ this.renderer.setProperty(this.elementRef.nativeElement, key, undefined);
167
318
  }
319
+ this.pushed = next;
168
320
  }
169
321
 
170
322
  // A flat-bag `onX` callback (responder negotiation, onLongPress, …) is invoked by the engine's
@@ -180,11 +332,19 @@ export class SymbioteHostPropsDirective {
180
332
  // for free (setState / proxy reactivity).
181
333
  private wrapCallback(key: string, value: unknown): unknown {
182
334
  if (!ON_PREFIX.test(key) || typeof value !== 'function') return value;
183
- const handler = value as (...args: unknown[]) => unknown;
184
- return (...args: unknown[]): unknown => {
185
- const result = handler(...args);
335
+ if (key === PER_FRAME_CALLBACK) return value;
336
+ const cached = this.wrappers.get(value);
337
+ if (cached !== undefined) return cached;
338
+ // Reflect.apply rather than calling a cast-to-signature local: `typeof value === 'function'`
339
+ // narrows to `Function`, which has no call signature TypeScript will accept, and this repo
340
+ // does not use `as` to paper over that. `undefined` as the receiver preserves the previous
341
+ // unbound call.
342
+ const wrapper = (...args: unknown[]): unknown => {
343
+ const result: unknown = Reflect.apply(value, undefined, args);
186
344
  this.cdr.markForCheck();
187
345
  return result;
188
346
  };
347
+ this.wrappers.set(value, wrapper);
348
+ return wrapper;
189
349
  }
190
350
  }
@@ -10,6 +10,7 @@ import {
10
10
  createSurface,
11
11
  disposeRoot,
12
12
  dlog,
13
+ reportUncaughtError,
13
14
  toPublicInstance,
14
15
  type ISymbioteNode,
15
16
  type IRootTag,
@@ -62,7 +63,35 @@ function asAngularHost(hostNode: SymbioteSurface | ISymbioteNode): Element {
62
63
  // to the wrapper as projectable content.
63
64
  function createDetachedViewHost(): ISymbioteNode {
64
65
  const descriptor = descriptorFor('View');
65
- return toPublicInstance(createEngineElement(descriptor.component, descriptor.isText));
66
+ return toPublicInstance(
67
+ createEngineElement(descriptor.component, descriptor.isText),
68
+ );
69
+ }
70
+
71
+ // Angular's whole unhandled-error funnel: INTERNAL_APPLICATION_ERROR_HANDLER resolves this token
72
+ // and hands it everything the framework caught on the app's behalf — a scheduled (async) tick that
73
+ // threw, a template listener that threw, a rejected pending task. Routed to the engine so an
74
+ // Angular app reaches the same native redbox as a React one instead of a console line nobody sees
75
+ // off a dev machine.
76
+ //
77
+ // There is deliberately no caught-vs-uncaught split here, unlike the React adapter's
78
+ // onUncaughtError/onCaughtError pair. Angular has no error boundary, and every path where the app
79
+ // DID handle something keeps clear of this seam on its own: a `@defer` block with an `@error`
80
+ // branch renders that branch (defer/rendering.ts calls handleUncaughtError only when there is
81
+ // none), `resource()` parks the failure in its own error signal, and an app-level try/catch or
82
+ // `catchError` never reaches Angular at all. So anything arriving here is by construction
83
+ // unclaimed, and the redbox is the right answer for all of it.
84
+ //
85
+ // Two things this must not do. It must not also call `super.handleError` — reportUncaughtError
86
+ // picks exactly one channel (host reporter or console) precisely because RN routes console.error
87
+ // into LogBox too, so the pair would double-report every error. And it must not throw: the
88
+ // scheduler invokes this from inside its own `catch` (zoneless_scheduling_impl.ts's tick), where a
89
+ // second throw escapes to the host and kills the "report and keep running" behaviour the provider
90
+ // exists for.
91
+ class SymbioteErrorHandler extends ErrorHandler {
92
+ override handleError(error: unknown): void {
93
+ reportUncaughtError(error, { origin: 'angular render' });
94
+ }
66
95
  }
67
96
 
68
97
  interface IMountedApp {
@@ -81,7 +110,10 @@ export interface IMountOptions {
81
110
  wrapperComponent?: Type<unknown>;
82
111
  }
83
112
 
84
- function applyInputs(cmpRef: ComponentRef<unknown>, initialProps: object | undefined): void {
113
+ function applyInputs(
114
+ cmpRef: ComponentRef<unknown>,
115
+ initialProps: object | undefined,
116
+ ): void {
85
117
  if (initialProps === undefined) return;
86
118
  for (const [key, value] of Object.entries(initialProps)) {
87
119
  cmpRef.setInput(key, value);
@@ -118,45 +150,51 @@ export function mount(
118
150
  provide: RendererFactory2,
119
151
  useValue: new SymbioteRendererFactory(surface),
120
152
  },
121
- { provide: DOCUMENT, useValue: { head: surface, body: surface } },
122
- // createEnvironmentInjector with a null parent gives this injector scope
123
- // {'environment'} only (see EnvironmentNgModuleRefAdapter), so providedIn:'root'
124
- // tokens ApplicationRef included never resolve on their own (R3Injector.get
125
- // walks up looking for an injector whose `scopes` contains 'root', and a null
126
- // parent means that search always dead-ends in NullInjector). platform-browser's
127
- // BROWSER_MODULE_PROVIDERS solves this the same way for real DOM apps: it hands
128
- // { provide: INJECTOR_SCOPE, useValue: 'root' } to its OWN app-level providers, and
129
- // R3Injector's constructor reads that provider and self-tags this.scopes with
130
- // 'root'. We do the same here — no PlatformRef, no DOM, just this one provider.
153
+ // `getElementById` is not decoration: `resource()` resolves TransferState, whose root
154
+ // factory runs `retrieveTransferredState(doc, appId)` ->
155
+ // `doc.getElementById(appId + '-state')` (core/src/transfer_state.ts:156) to pick up
156
+ // server-rendered state. A stub without it makes that a TypeError, and because the throw
157
+ // happens while the component is being constructed, the ENTIRE screen renders nothing
158
+ // white body under a native header that navigation drew anyway, no error surfaced. The
159
+ // `optional: true` on the injector lookup does not help: the token resolves, its factory
160
+ // is what throws. Returning null is the honest client answer — there is no server here, so
161
+ // `script?.tagName` short-circuits and the caller gets `{}`.
162
+ {
163
+ provide: DOCUMENT,
164
+ useValue: {
165
+ head: surface,
166
+ body: surface,
167
+ getElementById: (): null => null,
168
+ },
169
+ },
170
+ // createEnvironmentInjector with a null parent scopes this injector to {'environment'}
171
+ // only (see EnvironmentNgModuleRefAdapter), so providedIn:'root' tokens — ApplicationRef
172
+ // included — never resolve (R3Injector.get walks up for a `scopes` containing 'root', and
173
+ // a null parent always dead-ends in NullInjector). platform-browser solves this the same
174
+ // way for real DOM apps via BROWSER_MODULE_PROVIDERS: hand { provide: INJECTOR_SCOPE,
175
+ // useValue: 'root' } to the app-level providers, which R3Injector's constructor reads to
176
+ // self-tag this.scopes with 'root'. Same trick here, no PlatformRef, no DOM.
131
177
  { provide: INJECTOR_SCOPE, useValue: 'root' },
132
- // Supplies the real ChangeDetectionSchedulerImpl (microtask-batched, self-scheduling
133
- // via ApplicationRef.afterTick) + NoopNgZone + ZONELESS_ENABLED: true — the exact
134
- // bundle internalCreateApplication() uses. With ApplicationRef reachable (see above),
135
- // there is no more reason for a hand-rolled scheduler: it replaces our old
136
- // unconditional `rootView.detectChanges(); cmpView.detectChanges()` (which force-ran
137
- // BOTH root views on every tick regardless of cause) with Angular's own tick(), which
138
- // only enters an attached view when something actually marked it (RefreshView / Dirty
139
- // consumer / HasChildViewsToRefresh). NOTE: this does NOT stop the root's own template
140
- // from re-running on a plain press or `ChangeDetectorRef.markForCheck()` anywhere in
141
- // the tree `markViewDirty` (which both native (event) bindings and `markForCheck()`
142
- // go through, see SymbioteHostPropsDirective) unconditionally sets RefreshView on
143
- // EVERY ancestor up to the root; that is fundamental, unavoidable Angular zoneless
144
- // behavior, true in every Angular app, not something this swap changes. What DOES
145
- // still protect a sibling branch from an unrelated press is a genuine child
146
- // `@Component` boundary — SignalView-compiled children are skip-eligible regardless of
147
- // this scheduler.
178
+ // Supplies the real ChangeDetectionSchedulerImpl (microtask-batched via
179
+ // ApplicationRef.afterTick) + NoopNgZone + ZONELESS_ENABLED: true — the exact bundle
180
+ // internalCreateApplication() uses. Replaces the old unconditional
181
+ // `rootView.detectChanges(); cmpView.detectChanges()` (force-ran both root views on every
182
+ // tick) with Angular's own tick(), which only enters a view something actually marked
183
+ // dirty. This does NOT stop the root's own template from re-running on a plain press or
184
+ // `markForCheck()` anywhere in the tree `markViewDirty` unconditionally sets RefreshView
185
+ // on every ancestor up to the root; that's fundamental Angular zoneless behavior, not
186
+ // something this swap changes. A genuine child `@Component` boundary still protects a
187
+ // sibling branch from an unrelated press.
148
188
  ...provideZonelessChangeDetectionInternal(),
149
- // Angular's own INTERNAL_APPLICATION_ERROR_HANDLER (core.mjs) reports a tick()
150
- // exception by calling `injector.get(ErrorHandler)` a normal `bootstrapApplication`
151
- // registers this token by default (platform-browser's BROWSER_MODULE_PROVIDERS), but
152
- // our from-scratch environment injector never did, so that lookup itself threw
153
- // NG0201 and REPLACED whatever the real error was with an unrelated "No provider
154
- // found for ErrorHandler" the real exception never got logged, and the NG0201 itself
155
- // propagated out of a bare Timeout callback uncaught (nothing above it in the stack to
156
- // catch it), i.e. any async tick() exception, anywhere in the app, crashed hard instead
157
- // of being reported. Providing the default `ErrorHandler` (same one bootstrapApplication
158
- // ships) restores the intended behavior: `console.error('ERROR', e)` and keep running.
159
- { provide: ErrorHandler, useClass: ErrorHandler },
189
+ // Angular's INTERNAL_APPLICATION_ERROR_HANDLER reports a tick() exception via
190
+ // `injector.get(ErrorHandler)`; a normal `bootstrapApplication` registers that token by
191
+ // default, but our from-scratch environment injector never did, so the lookup itself threw
192
+ // NG0201 and replaced the real error with "No provider found for ErrorHandler" — any async
193
+ // tick() exception crashed hard, uncaught, instead of being reported. The token still has to
194
+ // be provided for exactly that reason; what it resolves to is now SymbioteErrorHandler
195
+ // (see above), which reports through the engine instead of Angular's `console.error('ERROR',
196
+ // e)` and, like the default, returns rather than rethrows so the app keeps running.
197
+ { provide: ErrorHandler, useClass: SymbioteErrorHandler },
160
198
  ColorSchemeService,
161
199
  WindowDimensionsService,
162
200
  ],
@@ -28,6 +28,12 @@ import { descriptorFor } from '@symbiote-native/components';
28
28
  import type { Renderer2, RendererFactory2, RendererType2 } from '@angular/core';
29
29
  import { isAnchorHostComponent } from '../anchor-host-registry';
30
30
  import {
31
+ countAngular,
32
+ noteAngularCreate,
33
+ noteAngularWrite,
34
+ } from '../diagnostics';
35
+ import {
36
+ flushScrollViewProjections,
31
37
  getScrollViewProjection,
32
38
  removeScrollViewProjectedChild,
33
39
  } from '../components/scroll-view/projection';
@@ -68,7 +74,9 @@ function describeHost(node: IHostElement | null | undefined): string {
68
74
  if (node === null || node === undefined) return 'null';
69
75
  if (isSurface(node)) return 'surface';
70
76
  const anchorId = anchorDebugIds.get(node);
71
- return anchorId !== undefined ? `${node.component}#${anchorId}` : node.component;
77
+ return anchorId !== undefined
78
+ ? `${node.component}#${anchorId}`
79
+ : node.component;
72
80
  }
73
81
 
74
82
  const PRIMITIVE_SELECTOR_ALIAS: Record<string, string> = {
@@ -86,7 +94,9 @@ const PRIMITIVE_SELECTOR_ALIAS: Record<string, string> = {
86
94
  // but guard anyway for parity with the Vue adapter and to fail loudly on a bad template.
87
95
  function assertTextPlacement(child: ISymbioteNode, parent: IHostElement): void {
88
96
  if (isRawText(child) && (isSurface(parent) || !parent.isText)) {
89
- throw new Error(`Text string "${String(child.props.text)}" must be rendered inside a <Text>`);
97
+ throw new Error(
98
+ `Text string "${String(child.props.text)}" must be rendered inside a <Text>`,
99
+ );
90
100
  }
91
101
  }
92
102
 
@@ -111,10 +121,14 @@ export class SymbioteRenderer implements Renderer2 {
111
121
  // any other string flows through as a raw Fabric name (events/processors derived from its
112
122
  // ViewConfig). toPublicInstance grafts the imperative API (measure / setNativeProps /
113
123
  // focus) onto the raw node in place, returning the SAME identity the commit mirror keys on.
124
+ countAngular('nodesCreated');
125
+ noteAngularCreate(name);
114
126
  const engineName = PRIMITIVE_SELECTOR_ALIAS[name] ?? name;
115
127
  if (isAnchorHostComponent(engineName)) {
116
128
  const anchor = tagAnchorForDebug(createAnchor());
117
- dlog(`angular createElement ${name} -> anchor host ${describeHost(anchor)}`);
129
+ dlog(
130
+ `angular createElement ${name} -> anchor host ${describeHost(anchor)}`,
131
+ );
118
132
  return anchor;
119
133
  }
120
134
 
@@ -128,12 +142,14 @@ export class SymbioteRenderer implements Renderer2 {
128
142
  // Angular structural directives (*ngIf / @if / @for) need anchor nodes to track
129
143
  // position. A real retained node the commit walk SKIPS — no native view. Twin of the
130
144
  // Vue createComment path.
145
+ countAngular('nodesCreated');
131
146
  const anchor = tagAnchorForDebug(createAnchor());
132
147
  dlog(`Angular renderer createComment -> ${describeHost(anchor)}`);
133
148
  return anchor;
134
149
  }
135
150
 
136
151
  createText(value: string): IHostNode {
152
+ countAngular('nodesCreated');
137
153
  return createRawText(value);
138
154
  }
139
155
 
@@ -142,9 +158,12 @@ export class SymbioteRenderer implements Renderer2 {
142
158
  // (see parentNode below) — mirrors Angular's own `if (parentRNode !== null)` guard in
143
159
  // addLViewToLContainer: skip silently now, the later projection pass places it correctly.
144
160
  if (parent === null) return;
161
+ countAngular('nodesInserted');
145
162
  assertTextPlacement(newChild, parent);
146
163
  if (isSurface(parent)) {
147
- dlog(`Angular renderer appendChild parent=surface child=${describeHost(newChild)}`);
164
+ dlog(
165
+ `Angular renderer appendChild parent=surface child=${describeHost(newChild)}`,
166
+ );
148
167
  parent.appendChild(newChild);
149
168
  } else {
150
169
  const projection = getScrollViewProjection(parent);
@@ -162,8 +181,13 @@ export class SymbioteRenderer implements Renderer2 {
162
181
  this.surface.requestCommit();
163
182
  }
164
183
 
165
- insertBefore(parent: IHostElement | null, newChild: IHostNode, refChild: IHostNode | null): void {
184
+ insertBefore(
185
+ parent: IHostElement | null,
186
+ newChild: IHostNode,
187
+ refChild: IHostNode | null,
188
+ ): void {
166
189
  if (parent === null) return; // see the appendChild guard above
190
+ countAngular('nodesInserted');
167
191
  assertTextPlacement(newChild, parent);
168
192
  if (isSurface(parent)) {
169
193
  dlog(
@@ -177,10 +201,15 @@ export class SymbioteRenderer implements Renderer2 {
177
201
  `Angular renderer insertBefore parent=${describeHost(parent)} child=${describeHost(newChild)} ref=${refChild ? describeHost(refChild) : 'null'} projection=${projection !== undefined}`,
178
202
  );
179
203
  if (projection !== undefined) {
180
- projection.insertProjectedChild(parent, newChild, refChild, (target, child, before) => {
181
- if (before === undefined) appendChild(target, child);
182
- else insertBefore(target, child, before);
183
- });
204
+ projection.insertProjectedChild(
205
+ parent,
206
+ newChild,
207
+ refChild,
208
+ (target, child, before) => {
209
+ if (before === undefined) appendChild(target, child);
210
+ else insertBefore(target, child, before);
211
+ },
212
+ );
184
213
  } else if (refChild) {
185
214
  insertBefore(parent, newChild, refChild);
186
215
  } else {
@@ -194,10 +223,13 @@ export class SymbioteRenderer implements Renderer2 {
194
223
  // Detach from the child's own retained parent (a top-level node lives in
195
224
  // surface.children with no parent). Angular's `parent` arg is ignored in favor of the
196
225
  // authoritative link, mirroring the Vue adapter's remove.
226
+ countAngular('nodesRemoved');
197
227
  const angularParent = _parent !== null ? describeHost(_parent) : 'null';
198
- const retainedParent = oldChild.parent !== undefined ? describeHost(oldChild.parent) : 'none';
199
- const wasProjected = removeScrollViewProjectedChild(oldChild, (parent, child) =>
200
- removeChild(parent, child),
228
+ const retainedParent =
229
+ oldChild.parent !== undefined ? describeHost(oldChild.parent) : 'none';
230
+ const wasProjected = removeScrollViewProjectedChild(
231
+ oldChild,
232
+ (parent, child) => removeChild(parent, child),
201
233
  );
202
234
  dlog(
203
235
  `Angular renderer removeChild angularParent=${angularParent} retainedParent=${retainedParent} child=${describeHost(oldChild)} viaProjection=${wasProjected}`,
@@ -228,7 +260,8 @@ export class SymbioteRenderer implements Renderer2 {
228
260
  }
229
261
 
230
262
  nextSibling(node: IHostNode): IHostNode | null {
231
- const siblings = node.parent !== undefined ? node.parent.children : this.surface.children;
263
+ const siblings =
264
+ node.parent !== undefined ? node.parent.children : this.surface.children;
232
265
  const index = siblings.indexOf(node);
233
266
  return index >= 0 ? (siblings[index + 1] ?? null) : null;
234
267
  }
@@ -243,12 +276,16 @@ export class SymbioteRenderer implements Renderer2 {
243
276
 
244
277
  setAttribute(el: IHostElement, name: string, value: string): void {
245
278
  if (isSurface(el)) return;
279
+ countAngular('rendererWrites');
280
+ noteAngularWrite(name);
246
281
  routeProp(el, name, value);
247
282
  this.surface.requestCommit();
248
283
  }
249
284
 
250
285
  removeAttribute(el: IHostElement, name: string): void {
251
286
  if (isSurface(el)) return;
287
+ countAngular('rendererWrites');
288
+ noteAngularWrite(name);
252
289
  routeProp(el, name, undefined);
253
290
  this.surface.requestCommit();
254
291
  }
@@ -264,6 +301,8 @@ export class SymbioteRenderer implements Renderer2 {
264
301
 
265
302
  addClass(el: IHostElement, name: string): void {
266
303
  if (isSurface(el)) return;
304
+ countAngular('rendererWrites');
305
+ noteAngularWrite('class');
267
306
  const tokens = this.classTokens.get(el) ?? new Set<string>();
268
307
  tokens.add(name);
269
308
  this.classTokens.set(el, tokens);
@@ -275,6 +314,8 @@ export class SymbioteRenderer implements Renderer2 {
275
314
  if (isSurface(el)) return;
276
315
  const tokens = this.classTokens.get(el);
277
316
  if (tokens === undefined) return;
317
+ countAngular('rendererWrites');
318
+ noteAngularWrite('class');
278
319
  tokens.delete(name);
279
320
  routeProp(el, 'class', tokens.size > 0 ? [...tokens].join(' ') : undefined);
280
321
  this.surface.requestCommit();
@@ -287,6 +328,8 @@ export class SymbioteRenderer implements Renderer2 {
287
328
  // merge writes, and spreading an array as a record would silently produce numeric-index keys.
288
329
  setStyle(el: IHostElement, style: string, value: unknown): void {
289
330
  if (isSurface(el)) return;
331
+ countAngular('rendererWrites');
332
+ noteAngularWrite(`style.${style}`);
290
333
  const current = getExplicitStyle(el);
291
334
  const base = isRecord(current) ? current : {};
292
335
  routeProp(el, 'style', { ...base, [style]: value });
@@ -297,6 +340,8 @@ export class SymbioteRenderer implements Renderer2 {
297
340
  if (isSurface(el)) return;
298
341
  const current = getExplicitStyle(el);
299
342
  if (!isRecord(current)) return;
343
+ countAngular('rendererWrites');
344
+ noteAngularWrite(`style.${style}`);
300
345
  const { [style]: _removed, ...rest } = current;
301
346
  routeProp(el, 'style', rest);
302
347
  this.surface.requestCommit();
@@ -306,11 +351,15 @@ export class SymbioteRenderer implements Renderer2 {
306
351
  // ViewConfig (identical to React/Vue), so the whole flat-bag prop layer is shared.
307
352
  setProperty(el: IHostElement, name: string, value: unknown): void {
308
353
  if (isSurface(el)) return;
354
+ countAngular('rendererWrites');
355
+ noteAngularWrite(name);
309
356
  routeProp(el, name, value);
310
357
  this.surface.requestCommit();
311
358
  }
312
359
 
313
360
  setValue(node: IHostNode, value: string): void {
361
+ countAngular('rendererWrites');
362
+ noteAngularWrite('#text');
314
363
  // A useful permanent seam: text mutations are low-frequency and the one place a stale
315
364
  // `{{binding}}` (a change-detection gap) shows up as "the setValue never fired".
316
365
  dlog(`Angular renderer setValue "${value}" on ${describeHost(node)}`);
@@ -341,7 +390,20 @@ export class SymbioteRendererFactory implements RendererFactory2 {
341
390
 
342
391
  constructor(private readonly surface: SymbioteSurface) {}
343
392
 
344
- createRenderer(_hostElement: unknown, _type: RendererType2 | null): Renderer2 {
393
+ createRenderer(
394
+ _hostElement: unknown,
395
+ _type: RendererType2 | null,
396
+ ): Renderer2 {
345
397
  return (this.renderer ??= new SymbioteRenderer(this.surface));
346
398
  }
399
+
400
+ // Not commit coalescing (requestCommit owns that) — this is the one moment where a ScrollView's
401
+ // two input channels are known to be consistent: Angular has finished writing every `@Input` AND
402
+ // the renderer has finished every projected insert/remove for this pass, while the surface's
403
+ // commit is still only queued. Sticky projection reconciles are batched to here for both reasons;
404
+ // see flushScrollViewProjections.
405
+ end(): void {
406
+ countAngular('cdPasses');
407
+ flushScrollViewProjections();
408
+ }
347
409
  }
@@ -11,7 +11,9 @@ import { Appearance, type IColorSchemeName } from '@symbiote-native/engine';
11
11
  */
12
12
  @Injectable({ providedIn: 'root' })
13
13
  export class ColorSchemeService {
14
- readonly colorScheme = signal<IColorSchemeName | null>(Appearance.getColorScheme());
14
+ readonly colorScheme = signal<IColorSchemeName | null>(
15
+ Appearance.getColorScheme(),
16
+ );
15
17
 
16
18
  constructor() {
17
19
  const destroyRef = inject(DestroyRef);