@symbiote-native/angular 0.7.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/README.md +3 -3
  2. package/babel-lower-host-primitives.cjs +636 -0
  3. package/babel-register-composed.cjs +21 -3
  4. package/build/angular/anchor-host-registry.js +3 -1
  5. package/build/angular/anchor-host-registry.js.map +1 -1
  6. package/build/angular/bootstrap.js +1 -1
  7. package/build/angular/bootstrap.js.map +1 -1
  8. package/build/angular/components/activity-indicator/index.android.js +7 -2
  9. package/build/angular/components/activity-indicator/index.android.js.map +1 -1
  10. package/build/angular/components/activity-indicator/index.ios.js +3 -1
  11. package/build/angular/components/activity-indicator/index.ios.js.map +1 -1
  12. package/build/angular/components/activity-indicator/shared.d.ts +1 -1
  13. package/build/angular/components/activity-indicator/shared.js +1 -1
  14. package/build/angular/components/activity-indicator/shared.js.map +1 -1
  15. package/build/angular/components/button.d.ts +3 -0
  16. package/build/angular/components/button.js +22 -5
  17. package/build/angular/components/button.js.map +1 -1
  18. package/build/angular/components/flat-list/index.d.ts +1 -1
  19. package/build/angular/components/flat-list/index.js +47 -14
  20. package/build/angular/components/flat-list/index.js.map +1 -1
  21. package/build/angular/components/image/index.android.js +4 -10
  22. package/build/angular/components/image/index.android.js.map +1 -1
  23. package/build/angular/components/image/index.ios.js +4 -10
  24. package/build/angular/components/image/index.ios.js.map +1 -1
  25. package/build/angular/components/image/shared.d.ts +4 -4
  26. package/build/angular/components/image/shared.js +51 -33
  27. package/build/angular/components/image/shared.js.map +1 -1
  28. package/build/angular/components/image-background.d.ts +3 -0
  29. package/build/angular/components/image-background.js +32 -9
  30. package/build/angular/components/image-background.js.map +1 -1
  31. package/build/angular/components/input-accessory-view/index.d.ts +2 -1
  32. package/build/angular/components/input-accessory-view/index.js +18 -16
  33. package/build/angular/components/input-accessory-view/index.js.map +1 -1
  34. package/build/angular/components/keyboard-avoiding-view/index.d.ts +3 -1
  35. package/build/angular/components/keyboard-avoiding-view/index.js +56 -19
  36. package/build/angular/components/keyboard-avoiding-view/index.js.map +1 -1
  37. package/build/angular/components/modal/index.d.ts +1 -0
  38. package/build/angular/components/modal/index.js +21 -10
  39. package/build/angular/components/modal/index.js.map +1 -1
  40. package/build/angular/components/pressable/index.d.ts +10 -5
  41. package/build/angular/components/pressable/index.js +61 -16
  42. package/build/angular/components/pressable/index.js.map +1 -1
  43. package/build/angular/components/refresh-control/index.d.ts +2 -1
  44. package/build/angular/components/refresh-control/index.js +13 -9
  45. package/build/angular/components/refresh-control/index.js.map +1 -1
  46. package/build/angular/components/safe-area-view/index.d.ts +3 -1
  47. package/build/angular/components/safe-area-view/index.js +22 -20
  48. package/build/angular/components/safe-area-view/index.js.map +1 -1
  49. package/build/angular/components/scroll-view/index.android.js +44 -14
  50. package/build/angular/components/scroll-view/index.android.js.map +1 -1
  51. package/build/angular/components/scroll-view/index.ios.js +13 -5
  52. package/build/angular/components/scroll-view/index.ios.js.map +1 -1
  53. package/build/angular/components/scroll-view/projection.d.ts +32 -0
  54. package/build/angular/components/scroll-view/projection.js +212 -47
  55. package/build/angular/components/scroll-view/projection.js.map +1 -1
  56. package/build/angular/components/scroll-view/shared.d.ts +2 -0
  57. package/build/angular/components/scroll-view/shared.js +48 -11
  58. package/build/angular/components/scroll-view/shared.js.map +1 -1
  59. package/build/angular/components/scroll-view/sticky-header.js +7 -3
  60. package/build/angular/components/scroll-view/sticky-header.js.map +1 -1
  61. package/build/angular/components/section-list/index.d.ts +9 -1
  62. package/build/angular/components/section-list/index.js +72 -18
  63. package/build/angular/components/section-list/index.js.map +1 -1
  64. package/build/angular/components/switch/index.android.js +23 -9
  65. package/build/angular/components/switch/index.android.js.map +1 -1
  66. package/build/angular/components/switch/index.ios.js +23 -9
  67. package/build/angular/components/switch/index.ios.js.map +1 -1
  68. package/build/angular/components/switch/shared.d.ts +1 -1
  69. package/build/angular/components/switch/shared.js +5 -2
  70. package/build/angular/components/switch/shared.js.map +1 -1
  71. package/build/angular/components/text-input.d.ts +8 -2
  72. package/build/angular/components/text-input.js +79 -34
  73. package/build/angular/components/text-input.js.map +1 -1
  74. package/build/angular/components/touchable/index.d.ts +36 -7
  75. package/build/angular/components/touchable/index.js +280 -72
  76. package/build/angular/components/touchable/index.js.map +1 -1
  77. package/build/angular/components/touchable-native-feedback/index.d.ts +2 -1
  78. package/build/angular/components/touchable-native-feedback/index.js +19 -17
  79. package/build/angular/components/touchable-native-feedback/index.js.map +1 -1
  80. package/build/angular/components/virtualized-list/directives.js +4 -0
  81. package/build/angular/components/virtualized-list/directives.js.map +1 -1
  82. package/build/angular/components/virtualized-list/index.d.ts +8 -2
  83. package/build/angular/components/virtualized-list/index.js +207 -61
  84. package/build/angular/components/virtualized-list/index.js.map +1 -1
  85. package/build/angular/components/virtualized-section-list/directives.js.map +1 -1
  86. package/build/angular/components/virtualized-section-list/index.d.ts +21 -1
  87. package/build/angular/components/virtualized-section-list/index.js +100 -22
  88. package/build/angular/components/virtualized-section-list/index.js.map +1 -1
  89. package/build/angular/components.d.ts +2 -2
  90. package/build/angular/components.js +1 -1
  91. package/build/angular/components.js.map +1 -1
  92. package/build/angular/create-portal/index.d.ts +11 -2
  93. package/build/angular/create-portal/index.js +42 -12
  94. package/build/angular/create-portal/index.js.map +1 -1
  95. package/build/angular/create-tunnel/index.js.map +1 -1
  96. package/build/angular/descriptor-to-angular/index.js +3 -1
  97. package/build/angular/descriptor-to-angular/index.js.map +1 -1
  98. package/build/angular/diagnostics.d.ts +33 -0
  99. package/build/angular/diagnostics.js +82 -0
  100. package/build/angular/diagnostics.js.map +1 -0
  101. package/build/angular/gate-demand.d.ts +37 -0
  102. package/build/angular/gate-demand.js +84 -0
  103. package/build/angular/gate-demand.js.map +1 -0
  104. package/build/angular/host-instance/index.js +1 -1
  105. package/build/angular/host-instance/index.js.map +1 -1
  106. package/build/angular/index.d.ts +6 -2
  107. package/build/angular/index.js +16 -1
  108. package/build/angular/index.js.map +1 -1
  109. package/build/angular/modules/animated/create-animated-component.d.ts +1 -0
  110. package/build/angular/modules/animated/create-animated-component.js +41 -18
  111. package/build/angular/modules/animated/create-animated-component.js.map +1 -1
  112. package/build/angular/modules/app-registry/index.js.map +1 -1
  113. package/build/angular/modules/status-bar/index.js +1 -1
  114. package/build/angular/modules/status-bar/index.js.map +1 -1
  115. package/build/angular/primitives/index.d.ts +31 -2
  116. package/build/angular/primitives/index.js +81 -3
  117. package/build/angular/primitives/index.js.map +1 -1
  118. package/build/angular/primitives/shared.d.ts +9 -1
  119. package/build/angular/primitives/shared.js +49 -3
  120. package/build/angular/primitives/shared.js.map +1 -1
  121. package/build/angular/register.d.ts +1 -0
  122. package/build/angular/register.js +35 -0
  123. package/build/angular/register.js.map +1 -0
  124. package/build/angular/render/index.js +51 -6
  125. package/build/angular/render/index.js.map +1 -1
  126. package/build/angular/renderer/index.d.ts +1 -0
  127. package/build/angular/renderer/index.js +157 -21
  128. package/build/angular/renderer/index.js.map +1 -1
  129. package/build/angular/services/color-scheme.service.js.map +1 -1
  130. package/build/angular/services/window-dimensions.service.js +1 -1
  131. package/build/angular/services/window-dimensions.service.js.map +1 -1
  132. package/build/angular/state-style.d.ts +9 -0
  133. package/build/angular/state-style.js +39 -0
  134. package/build/angular/state-style.js.map +1 -0
  135. package/metro-transformer.cjs +69 -0
  136. package/package.json +27 -8
  137. package/src/anchor-host-registry.ts +3 -1
  138. package/src/bootstrap.ts +4 -1
  139. package/src/components/activity-indicator/index.android.ts +7 -2
  140. package/src/components/activity-indicator/index.ios.ts +3 -1
  141. package/src/components/activity-indicator/shared.ts +32 -13
  142. package/src/components/button.ts +36 -6
  143. package/src/components/flat-list/index.ts +91 -29
  144. package/src/components/image/index.android.ts +9 -6
  145. package/src/components/image/index.ios.ts +9 -6
  146. package/src/components/image/shared.ts +110 -45
  147. package/src/components/image-background.ts +47 -10
  148. package/src/components/input-accessory-view/index.ts +41 -18
  149. package/src/components/keyboard-avoiding-view/index.ts +76 -22
  150. package/src/components/modal/index.ts +39 -12
  151. package/src/components/pressable/index.ts +121 -31
  152. package/src/components/refresh-control/index.ts +36 -13
  153. package/src/components/safe-area-view/index.ts +44 -19
  154. package/src/components/scroll-view/index.android.ts +31 -9
  155. package/src/components/scroll-view/index.ios.ts +17 -4
  156. package/src/components/scroll-view/projection.ts +290 -63
  157. package/src/components/scroll-view/shared.ts +124 -30
  158. package/src/components/scroll-view/sticky-header.ts +25 -8
  159. package/src/components/section-list/index.ts +79 -19
  160. package/src/components/switch/index.android.ts +18 -7
  161. package/src/components/switch/index.ios.ts +18 -7
  162. package/src/components/switch/shared.ts +15 -4
  163. package/src/components/text-input.ts +119 -31
  164. package/src/components/touchable/index.ts +339 -72
  165. package/src/components/touchable-native-feedback/index.ts +50 -18
  166. package/src/components/virtualized-list/directives.ts +19 -5
  167. package/src/components/virtualized-list/index.ts +292 -72
  168. package/src/components/virtualized-section-list/directives.ts +6 -3
  169. package/src/components/virtualized-section-list/index.ts +164 -34
  170. package/src/components.ts +6 -1
  171. package/src/create-portal/index.ts +47 -11
  172. package/src/create-tunnel/index.ts +3 -1
  173. package/src/descriptor-to-angular/index.ts +29 -8
  174. package/src/diagnostics.ts +138 -0
  175. package/src/gate-demand.ts +109 -0
  176. package/src/host-instance/index.ts +9 -3
  177. package/src/index.ts +33 -2
  178. package/src/modules/animated/create-animated-component.ts +67 -19
  179. package/src/modules/app-registry/index.ts +3 -1
  180. package/src/modules/status-bar/index.ts +13 -4
  181. package/src/primitives/index.ts +73 -2
  182. package/src/primitives/shared.ts +70 -9
  183. package/src/register.ts +43 -0
  184. package/src/render/index.ts +59 -7
  185. package/src/renderer/index.ts +209 -37
  186. package/src/services/color-scheme.service.ts +3 -1
  187. package/src/services/window-dimensions.service.ts +11 -4
  188. package/src/state-style.ts +39 -0
@@ -18,6 +18,7 @@ import {
18
18
  removeChild,
19
19
  routeProp,
20
20
  setEventListener,
21
+ setProp,
21
22
  setText,
22
23
  toPublicInstance,
23
24
  RAW_TEXT_COMPONENT,
@@ -25,9 +26,16 @@ import {
25
26
  type ISymbioteNode,
26
27
  } from '@symbiote-native/engine';
27
28
  import { descriptorFor } from '@symbiote-native/components';
29
+ import { foldHostBag } from '@symbiote-native/components/fold-host-bag';
28
30
  import type { Renderer2, RendererFactory2, RendererType2 } from '@angular/core';
29
31
  import { isAnchorHostComponent } from '../anchor-host-registry';
30
32
  import {
33
+ countAngular,
34
+ noteAngularCreate,
35
+ noteAngularWrite,
36
+ } from '../diagnostics';
37
+ import {
38
+ flushScrollViewProjections,
31
39
  getScrollViewProjection,
32
40
  removeScrollViewProjectedChild,
33
41
  } from '../components/scroll-view/projection';
@@ -49,6 +57,64 @@ function isRecord(value: unknown): value is Record<string, unknown> {
49
57
  return typeof value === 'object' && value !== null;
50
58
  }
51
59
 
60
+ // RN's Text.js applies two defaults on the way to native (core/components/host-primitives.cjs's
61
+ // `Text.defaults`; the authority on what they MEAN is core/components/src/text-props.ts's
62
+ // resolveTextProps, which the composed `Text` @Component already calls). That component's own
63
+ // host paints directly (Text is not anchor-hosted — see the top-level "View/Text's own component
64
+ // doesn't have this split" reasoning elsewhere in this file), so createElement runs for its INNER
65
+ // symbiote-text node too; seeding here therefore covers both the composed Text and any bare
66
+ // `symbiote-text` a future lowering emits, uniformly. Found missing 2026-08-31 (a cross-adapter
67
+ // key-count diff against Vue's real BenchmarkRow.vue) — without this a lowered Text's
68
+ // `numberOfLines` clips with no ellipsis, silently, on device only. Vue's renderer already does
69
+ // this (`adapters/vue/src/renderer/index.ts`'s `seedTextDefaults`); Angular's simply never did.
70
+ //
71
+ // Sourced from `foldHostBag` (`@symbiote-native/components/fold-host-bag`, driven by
72
+ // `HOST_PRIMITIVES.Text.defaults`) rather than a second hardcoded copy — React and Svelte call the
73
+ // same function directly; this used to be a THIRD, independent restatement of the same two
74
+ // defaults, with nothing to catch it drifting from the spec if a default's value ever changed.
75
+ // `foldHostBag('symbiote-text', {})` on an EMPTY bag folds every default with no authored value to
76
+ // override it (the alias loop has nothing to fold — `id` is only rewritten when present), which is
77
+ // exactly the seed this function needs.
78
+ function seedTextDefaults(node: ISymbioteNode): void {
79
+ const seeded = foldHostBag('symbiote-text', {});
80
+ for (const [key, value] of Object.entries(seeded)) setProp(node, key, value);
81
+ }
82
+
83
+ // An explicit `undefined` must NOT clear one of those defaults — RN treats a missing prop and an
84
+ // explicit undefined alike, and only a literal `false` opts allowFontScaling out. Reached only
85
+ // when a later write clears a key back to undefined, so it costs nothing on the common path.
86
+ // `foldHostBag` folds every Text default when called this way (not just `key`), because its
87
+ // contract is "fold a whole bag" — the extra key computed alongside `key` is simply unread here.
88
+ function textDefaultFor(el: IHostElement, key: string): unknown {
89
+ if (isSurface(el) || !el.isText) return undefined;
90
+ return foldHostBag('symbiote-text', { [key]: undefined })[key];
91
+ }
92
+
93
+ // RN's `id` is the modern W3C-named alias for `nativeID` (core/components/host-primitives.cjs's
94
+ // `ID_ALIAS`) — View.js/Text.js copy it over unconditionally, so the two name ONE native prop.
95
+ // React/Solid/Svelte fold it in a wrapper or transform; Angular had it nowhere, so `<View
96
+ // id="x">`/`[id]="x"` reached Fabric with an unknown `id` key and no `nativeID` — silently, on
97
+ // device only. Lives in the renderer (mirroring Vue's `PROP_ALIASES`) so it covers every path
98
+ // that can set a prop — `setAttribute`, `setProperty`, and (should a future lowering emit one) a
99
+ // hand-built call — not just the composed component's own `id` @Input.
100
+ // `symbioteStyle` is what the lowering transform emits in place of `[style]`: Angular routes a
101
+ // `style` binding to its own CSS styling engine, which cannot represent an RN StyleProp (an array
102
+ // throws inside change detection). Under any other name it is an ordinary property binding and
103
+ // arrives here.
104
+ // Angular's two-way sugar `[(value)]` compiles to a `(valueChange)` binding; the engine knows the
105
+ // same fold as the function prop `onValueChange`. See `listen()`.
106
+ const VALUE_CHANGE_EVENT = 'valueChange';
107
+ const VALUE_CHANGE_PROP = 'onValueChange';
108
+
109
+ const PROP_ALIASES: ReadonlyMap<string, string> = new Map([
110
+ ['id', 'nativeID'],
111
+ ['symbioteStyle', 'style'],
112
+ ]);
113
+
114
+ function aliasedPropName(name: string): string {
115
+ return PROP_ALIASES.get(name) ?? name;
116
+ }
117
+
52
118
  // Diagnostic-only: tags each anchor with a sequential id so log lines can tell distinct
53
119
  // anchors apart (they all otherwise print the same generic '#anchor' component name).
54
120
  // Gated behind isDebug() so a production build pays zero cost (no WeakMap writes).
@@ -68,7 +134,9 @@ function describeHost(node: IHostElement | null | undefined): string {
68
134
  if (node === null || node === undefined) return 'null';
69
135
  if (isSurface(node)) return 'surface';
70
136
  const anchorId = anchorDebugIds.get(node);
71
- return anchorId !== undefined ? `${node.component}#${anchorId}` : node.component;
137
+ return anchorId !== undefined
138
+ ? `${node.component}#${anchorId}`
139
+ : node.component;
72
140
  }
73
141
 
74
142
  const PRIMITIVE_SELECTOR_ALIAS: Record<string, string> = {
@@ -84,9 +152,15 @@ const PRIMITIVE_SELECTOR_ALIAS: Record<string, string> = {
84
152
  // Inserting a bare raw-text node anywhere but inside a <Text> is invalid in Fabric (a
85
153
  // stray RCTRawText would paint). Angular's ɵɵtext only ever lands text inside a <Text>,
86
154
  // but guard anyway for parity with the Vue adapter and to fail loudly on a bad template.
155
+ // `removeScrollViewProjectedChild` takes a remove callback and uses it only when the child turns
156
+ // out to be projected, which almost none are — so the engine's own `removeChild` is passed by
157
+ // reference rather than wrapped in an arrow. The wrapper was allocated on EVERY removed node:
158
+ // 10 000 closures on a Clear whose engine window is 0.1 ms.
87
159
  function assertTextPlacement(child: ISymbioteNode, parent: IHostElement): void {
88
160
  if (isRawText(child) && (isSurface(parent) || !parent.isText)) {
89
- throw new Error(`Text string "${String(child.props.text)}" must be rendered inside a <Text>`);
161
+ throw new Error(
162
+ `Text string "${String(child.props.text)}" must be rendered inside a <Text>`,
163
+ );
90
164
  }
91
165
  }
92
166
 
@@ -109,18 +183,37 @@ export class SymbioteRenderer implements Renderer2 {
109
183
  // name for a native leaf. Public aliases are normalized to their engine primitive name
110
184
  // before descriptor lookup. descriptorFor resolves it; an unknown `symbiote-*` is a typo,
111
185
  // any other string flows through as a raw Fabric name (events/processors derived from its
112
- // ViewConfig). toPublicInstance grafts the imperative API (measure / setNativeProps /
113
- // focus) onto the raw node in place, returning the SAME identity the commit mirror keys on.
186
+ // ViewConfig). The imperative API (measure / setNativeProps / focus) is on the node's
187
+ // prototype, so toPublicInstance hands back the SAME identity the commit mirror keys on.
188
+ countAngular('nodesCreated');
189
+ noteAngularCreate(name);
114
190
  const engineName = PRIMITIVE_SELECTOR_ALIAS[name] ?? name;
191
+
115
192
  if (isAnchorHostComponent(engineName)) {
116
193
  const anchor = tagAnchorForDebug(createAnchor());
117
- dlog(`angular createElement ${name} -> anchor host ${describeHost(anchor)}`);
194
+ if (isDebug()) {
195
+ dlog(
196
+ `angular createElement ${name} -> anchor host ${describeHost(anchor)}`,
197
+ );
198
+ }
118
199
  return anchor;
119
200
  }
120
201
 
121
202
  const descriptor = descriptorFor(engineName);
122
- const node = createElement(descriptor.component, descriptor.isText);
123
- dlog(`angular createElement ${name} -> ${descriptor.component}`);
203
+ // The tag, not just the resolved Fabric name: the host-behavior registry is keyed by the
204
+ // INTRINSIC tag (host-behavior.ts's own comment), and a node only ever carries the resolved
205
+ // name afterward. Omitting this argument means attachHostBehavior looks up the WRONG key
206
+ // (e.g. 'RCTView') and any registered behavior silently never attaches — Vue's renderer
207
+ // already passes this correctly (`createElement(descriptor.component, descriptor.isText, type)`).
208
+ const node = createElement(
209
+ descriptor.component,
210
+ descriptor.isText,
211
+ engineName,
212
+ );
213
+ if (descriptor.isText) seedTextDefaults(node);
214
+ if (isDebug()) {
215
+ dlog(`angular createElement ${name} -> ${descriptor.component}`);
216
+ }
124
217
  return toPublicInstance(node);
125
218
  }
126
219
 
@@ -128,12 +221,16 @@ export class SymbioteRenderer implements Renderer2 {
128
221
  // Angular structural directives (*ngIf / @if / @for) need anchor nodes to track
129
222
  // position. A real retained node the commit walk SKIPS — no native view. Twin of the
130
223
  // Vue createComment path.
224
+ countAngular('nodesCreated');
131
225
  const anchor = tagAnchorForDebug(createAnchor());
132
- dlog(`Angular renderer createComment -> ${describeHost(anchor)}`);
226
+ if (isDebug()) {
227
+ dlog(`Angular renderer createComment -> ${describeHost(anchor)}`);
228
+ }
133
229
  return anchor;
134
230
  }
135
231
 
136
232
  createText(value: string): IHostNode {
233
+ countAngular('nodesCreated');
137
234
  return createRawText(value);
138
235
  }
139
236
 
@@ -142,15 +239,22 @@ export class SymbioteRenderer implements Renderer2 {
142
239
  // (see parentNode below) — mirrors Angular's own `if (parentRNode !== null)` guard in
143
240
  // addLViewToLContainer: skip silently now, the later projection pass places it correctly.
144
241
  if (parent === null) return;
242
+ countAngular('nodesInserted');
145
243
  assertTextPlacement(newChild, parent);
146
244
  if (isSurface(parent)) {
147
- dlog(`Angular renderer appendChild parent=surface child=${describeHost(newChild)}`);
245
+ if (isDebug()) {
246
+ dlog(
247
+ `Angular renderer appendChild parent=surface child=${describeHost(newChild)}`,
248
+ );
249
+ }
148
250
  parent.appendChild(newChild);
149
251
  } else {
150
252
  const projection = getScrollViewProjection(parent);
151
- dlog(
152
- `Angular renderer appendChild parent=${describeHost(parent)} child=${describeHost(newChild)} projection=${projection !== undefined}`,
153
- );
253
+ if (isDebug()) {
254
+ dlog(
255
+ `Angular renderer appendChild parent=${describeHost(parent)} child=${describeHost(newChild)} projection=${projection !== undefined}`,
256
+ );
257
+ }
154
258
  if (projection !== undefined) {
155
259
  projection.appendProjectedChild(parent, newChild, (target, child) =>
156
260
  appendChild(target, child),
@@ -162,25 +266,39 @@ export class SymbioteRenderer implements Renderer2 {
162
266
  this.surface.requestCommit();
163
267
  }
164
268
 
165
- insertBefore(parent: IHostElement | null, newChild: IHostNode, refChild: IHostNode | null): void {
269
+ insertBefore(
270
+ parent: IHostElement | null,
271
+ newChild: IHostNode,
272
+ refChild: IHostNode | null,
273
+ ): void {
166
274
  if (parent === null) return; // see the appendChild guard above
275
+ countAngular('nodesInserted');
167
276
  assertTextPlacement(newChild, parent);
168
277
  if (isSurface(parent)) {
169
- dlog(
170
- `Angular renderer insertBefore parent=surface child=${describeHost(newChild)} ref=${refChild ? describeHost(refChild) : 'null'}`,
171
- );
278
+ if (isDebug()) {
279
+ dlog(
280
+ `Angular renderer insertBefore parent=surface child=${describeHost(newChild)} ref=${refChild ? describeHost(refChild) : 'null'}`,
281
+ );
282
+ }
172
283
  if (refChild) parent.insertBefore(newChild, refChild);
173
284
  else parent.appendChild(newChild);
174
285
  } else {
175
286
  const projection = getScrollViewProjection(parent);
176
- dlog(
177
- `Angular renderer insertBefore parent=${describeHost(parent)} child=${describeHost(newChild)} ref=${refChild ? describeHost(refChild) : 'null'} projection=${projection !== undefined}`,
178
- );
287
+ if (isDebug()) {
288
+ dlog(
289
+ `Angular renderer insertBefore parent=${describeHost(parent)} child=${describeHost(newChild)} ref=${refChild ? describeHost(refChild) : 'null'} projection=${projection !== undefined}`,
290
+ );
291
+ }
179
292
  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
- });
293
+ projection.insertProjectedChild(
294
+ parent,
295
+ newChild,
296
+ refChild,
297
+ (target, child, before) => {
298
+ if (before === undefined) appendChild(target, child);
299
+ else insertBefore(target, child, before);
300
+ },
301
+ );
184
302
  } else if (refChild) {
185
303
  insertBefore(parent, newChild, refChild);
186
304
  } else {
@@ -194,14 +312,16 @@ export class SymbioteRenderer implements Renderer2 {
194
312
  // Detach from the child's own retained parent (a top-level node lives in
195
313
  // surface.children with no parent). Angular's `parent` arg is ignored in favor of the
196
314
  // authoritative link, mirroring the Vue adapter's remove.
197
- 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),
201
- );
202
- dlog(
203
- `Angular renderer removeChild angularParent=${angularParent} retainedParent=${retainedParent} child=${describeHost(oldChild)} viaProjection=${wasProjected}`,
204
- );
315
+ countAngular('nodesRemoved');
316
+ const wasProjected = removeScrollViewProjectedChild(oldChild, removeChild);
317
+ if (isDebug()) {
318
+ const angularParent = _parent !== null ? describeHost(_parent) : 'null';
319
+ const retainedParent =
320
+ oldChild.parent !== undefined ? describeHost(oldChild.parent) : 'none';
321
+ dlog(
322
+ `Angular renderer removeChild angularParent=${angularParent} retainedParent=${retainedParent} child=${describeHost(oldChild)} viaProjection=${wasProjected}`,
323
+ );
324
+ }
205
325
  if (!wasProjected) {
206
326
  const parent = oldChild.parent;
207
327
  if (parent !== undefined) removeChild(parent, oldChild);
@@ -228,7 +348,8 @@ export class SymbioteRenderer implements Renderer2 {
228
348
  }
229
349
 
230
350
  nextSibling(node: IHostNode): IHostNode | null {
231
- const siblings = node.parent !== undefined ? node.parent.children : this.surface.children;
351
+ const siblings =
352
+ node.parent !== undefined ? node.parent.children : this.surface.children;
232
353
  const index = siblings.indexOf(node);
233
354
  return index >= 0 ? (siblings[index + 1] ?? null) : null;
234
355
  }
@@ -243,13 +364,18 @@ export class SymbioteRenderer implements Renderer2 {
243
364
 
244
365
  setAttribute(el: IHostElement, name: string, value: string): void {
245
366
  if (isSurface(el)) return;
246
- routeProp(el, name, value);
367
+ countAngular('rendererWrites');
368
+ noteAngularWrite(name);
369
+ routeProp(el, aliasedPropName(name), value);
247
370
  this.surface.requestCommit();
248
371
  }
249
372
 
250
373
  removeAttribute(el: IHostElement, name: string): void {
251
374
  if (isSurface(el)) return;
252
- routeProp(el, name, undefined);
375
+ countAngular('rendererWrites');
376
+ noteAngularWrite(name);
377
+ const aliased = aliasedPropName(name);
378
+ routeProp(el, aliased, textDefaultFor(el, aliased));
253
379
  this.surface.requestCommit();
254
380
  }
255
381
 
@@ -264,6 +390,8 @@ export class SymbioteRenderer implements Renderer2 {
264
390
 
265
391
  addClass(el: IHostElement, name: string): void {
266
392
  if (isSurface(el)) return;
393
+ countAngular('rendererWrites');
394
+ noteAngularWrite('class');
267
395
  const tokens = this.classTokens.get(el) ?? new Set<string>();
268
396
  tokens.add(name);
269
397
  this.classTokens.set(el, tokens);
@@ -275,6 +403,8 @@ export class SymbioteRenderer implements Renderer2 {
275
403
  if (isSurface(el)) return;
276
404
  const tokens = this.classTokens.get(el);
277
405
  if (tokens === undefined) return;
406
+ countAngular('rendererWrites');
407
+ noteAngularWrite('class');
278
408
  tokens.delete(name);
279
409
  routeProp(el, 'class', tokens.size > 0 ? [...tokens].join(' ') : undefined);
280
410
  this.surface.requestCommit();
@@ -287,6 +417,8 @@ export class SymbioteRenderer implements Renderer2 {
287
417
  // merge writes, and spreading an array as a record would silently produce numeric-index keys.
288
418
  setStyle(el: IHostElement, style: string, value: unknown): void {
289
419
  if (isSurface(el)) return;
420
+ countAngular('rendererWrites');
421
+ noteAngularWrite(`style.${style}`);
290
422
  const current = getExplicitStyle(el);
291
423
  const base = isRecord(current) ? current : {};
292
424
  routeProp(el, 'style', { ...base, [style]: value });
@@ -297,6 +429,8 @@ export class SymbioteRenderer implements Renderer2 {
297
429
  if (isSurface(el)) return;
298
430
  const current = getExplicitStyle(el);
299
431
  if (!isRecord(current)) return;
432
+ countAngular('rendererWrites');
433
+ noteAngularWrite(`style.${style}`);
300
434
  const { [style]: _removed, ...rest } = current;
301
435
  routeProp(el, 'style', rest);
302
436
  this.surface.requestCommit();
@@ -306,14 +440,25 @@ export class SymbioteRenderer implements Renderer2 {
306
440
  // ViewConfig (identical to React/Vue), so the whole flat-bag prop layer is shared.
307
441
  setProperty(el: IHostElement, name: string, value: unknown): void {
308
442
  if (isSurface(el)) return;
309
- routeProp(el, name, value);
443
+ countAngular('rendererWrites');
444
+ noteAngularWrite(name);
445
+ const aliased = aliasedPropName(name);
446
+ routeProp(
447
+ el,
448
+ aliased,
449
+ value === undefined ? textDefaultFor(el, aliased) : value,
450
+ );
310
451
  this.surface.requestCommit();
311
452
  }
312
453
 
313
454
  setValue(node: IHostNode, value: string): void {
455
+ countAngular('rendererWrites');
456
+ noteAngularWrite('#text');
314
457
  // A useful permanent seam: text mutations are low-frequency and the one place a stale
315
458
  // `{{binding}}` (a change-detection gap) shows up as "the setValue never fired".
316
- dlog(`Angular renderer setValue "${value}" on ${describeHost(node)}`);
459
+ if (isDebug()) {
460
+ dlog(`Angular renderer setValue "${value}" on ${describeHost(node)}`);
461
+ }
317
462
  setText(node, value);
318
463
  this.surface.requestCommit();
319
464
  }
@@ -328,6 +473,20 @@ export class SymbioteRenderer implements Renderer2 {
328
473
  callback: (event: unknown) => boolean | void,
329
474
  ): () => void {
330
475
  if (!isSymbioteNode(target)) return () => {};
476
+ // `[(value)]` desugars to `(valueChange)`, which is the spelling every Angular template writes
477
+ // for a Switch or a TextInput. On the COMPONENT path it is an `@Output()` the wrapper derives
478
+ // from the raw `change` payload; on a LOWERED element there is no component, and registering
479
+ // `valueChange` as an engine event would wait forever for a Fabric event of that name.
480
+ //
481
+ // The lowered path already carries the same fold under RN's own spelling: both behaviors call
482
+ // `node.props.onValueChange(value, event)` — a plain function PROP, not an event
483
+ // (`behaviors/switch.ts`, `behaviors/text-input.ts`). So this is a rename, not a mechanism:
484
+ // route the binding to that prop and `[(value)]` behaves identically on both paths. Without it
485
+ // the transform had to refuse to lower the two primitives whose idiomatic spelling this is.
486
+ if (eventName === VALUE_CHANGE_EVENT) {
487
+ routeProp(target, VALUE_CHANGE_PROP, callback);
488
+ return () => routeProp(target, VALUE_CHANGE_PROP, undefined);
489
+ }
331
490
  setEventListener(target, eventName, callback);
332
491
  return () => setEventListener(target, eventName, undefined);
333
492
  }
@@ -341,7 +500,20 @@ export class SymbioteRendererFactory implements RendererFactory2 {
341
500
 
342
501
  constructor(private readonly surface: SymbioteSurface) {}
343
502
 
344
- createRenderer(_hostElement: unknown, _type: RendererType2 | null): Renderer2 {
503
+ createRenderer(
504
+ _hostElement: unknown,
505
+ _type: RendererType2 | null,
506
+ ): Renderer2 {
345
507
  return (this.renderer ??= new SymbioteRenderer(this.surface));
346
508
  }
509
+
510
+ // Not commit coalescing (requestCommit owns that) — this is the one moment where a ScrollView's
511
+ // two input channels are known to be consistent: Angular has finished writing every `@Input` AND
512
+ // the renderer has finished every projected insert/remove for this pass, while the surface's
513
+ // commit is still only queued. Sticky projection reconciles are batched to here for both reasons;
514
+ // see flushScrollViewProjections.
515
+ end(): void {
516
+ countAngular('cdPasses');
517
+ flushScrollViewProjections();
518
+ }
347
519
  }
@@ -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);
@@ -1,5 +1,9 @@
1
1
  import { DestroyRef, inject, Injectable, signal } from '@angular/core';
2
- import { Dimensions, type IDimensionsSet, type IDisplayMetrics } from '@symbiote-native/engine';
2
+ import {
3
+ Dimensions,
4
+ type IDimensionsSet,
5
+ type IDisplayMetrics,
6
+ } from '@symbiote-native/engine';
3
7
 
4
8
  /**
5
9
  * Angular twin of React's `useWindowDimensions` and Vue's `useWindowDimensions` composable. The
@@ -28,9 +32,12 @@ export class WindowDimensionsService {
28
32
  }
29
33
  };
30
34
 
31
- const subscription = Dimensions.addEventListener('change', (set: IDimensionsSet) => {
32
- handleChange(set.window);
33
- });
35
+ const subscription = Dimensions.addEventListener(
36
+ 'change',
37
+ (set: IDimensionsSet) => {
38
+ handleChange(set.window);
39
+ },
40
+ );
34
41
  // Re-check once after subscribing to close the gap between construction and the listener.
35
42
  handleChange(Dimensions.get('window'));
36
43
 
@@ -0,0 +1,39 @@
1
+ // Re-export only. The implementation is shared (`@symbiote-native/components`) because two adapters
2
+ // had written byte-identical copies; this file exists so the code a transform EMITS keeps importing
3
+ // from the adapter package the app already depends on, rather than reaching past it.
4
+ //
5
+ // Imported from the SUBPATH, not the barrel: every adapter barrel re-exports
6
+ // `@symbiote-native/components` wholesale, so a name on that barrel silently becomes public API on
7
+ // all five — which is how a transform internal nearly shipped as a supported export.
8
+ //
9
+ // Present on every adapter, including those with no lowering transform yet: a per-framework subpath
10
+ // that four of five declare is exactly the silent gap `tests/package-subpath-parity.test.ts` exists
11
+ // to catch, and the file costs a re-export. Deliberately NOT on the public barrel — the emitted
12
+ // code names it, app code does not.
13
+ export {
14
+ resolveStateStyle,
15
+ type IPressStateArgument,
16
+ type IResolvedStateStyle,
17
+ } from '@symbiote-native/components/state-style';
18
+
19
+ // Angular-only addition to this otherwise byte-identical file: a template expression can call a
20
+ // PIPE, never an arbitrary imported function — `resolveStateStyle(x)` in a template resolves as
21
+ // `ctx.resolveStateStyle(x)`, a component-instance method lookup, which the lowering transform has
22
+ // no component to add one to (verified against the installed compiler-cli, 2026-09-01: the linker
23
+ // compiles it straight to a `ctx.` property read with no error, so this fails at RUNTIME —
24
+ // `ctx.resolveStateStyle is not a function` — not at compile time). A pipe is the one thing that
25
+ // resolves through `dependencies` the way a component/directive does.
26
+ //
27
+ // `emitStyleExpressionOnce` still applies: the transform's `@let pair = (expr | resolveStateStyle);`
28
+ // evaluates `expr` exactly once (verified via the compiled `ɵɵpipeBind1` call) and `pair.style` /
29
+ // `pair.activeStyle` are two cheap reads of the local, never a second evaluation of `expr`.
30
+ import { Pipe, type PipeTransform } from '@angular/core';
31
+ import { resolveStateStyle as resolveStateStyleImpl } from '@symbiote-native/components/state-style';
32
+ import type { IResolvedStateStyle } from '@symbiote-native/components/state-style';
33
+
34
+ @Pipe({ name: 'resolveStateStyle', standalone: true })
35
+ export class SymbioteStateStylePipe implements PipeTransform {
36
+ transform(value: unknown): IResolvedStateStyle {
37
+ return resolveStateStyleImpl(value);
38
+ }
39
+ }