@symbiote-native/angular 2.0.1 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +5 -4
  2. package/babel-register-composed.cjs +0 -8
  3. package/build/angular/callback-host.d.ts +25 -0
  4. package/build/angular/callback-host.js +211 -0
  5. package/build/angular/callback-host.js.map +1 -0
  6. package/build/angular/change-detection-flush.d.ts +14 -2
  7. package/build/angular/change-detection-flush.js +54 -6
  8. package/build/angular/change-detection-flush.js.map +1 -1
  9. package/build/angular/components/flat-list/index.js +39 -86
  10. package/build/angular/components/flat-list/index.js.map +1 -1
  11. package/build/angular/components/image-shared.js +49 -110
  12. package/build/angular/components/image-shared.js.map +1 -1
  13. package/build/angular/components/keyboard-avoiding-view/index.js +33 -72
  14. package/build/angular/components/keyboard-avoiding-view/index.js.map +1 -1
  15. package/build/angular/components/modal/index.js +58 -103
  16. package/build/angular/components/modal/index.js.map +1 -1
  17. package/build/angular/components/pressable-props.d.ts +2 -0
  18. package/build/angular/components/section-list/index.js +20 -103
  19. package/build/angular/components/section-list/index.js.map +1 -1
  20. package/build/angular/components/touchable-props.d.ts +1 -0
  21. package/build/angular/components/virtualized-list/directives.js +32 -22
  22. package/build/angular/components/virtualized-list/directives.js.map +1 -1
  23. package/build/angular/components/virtualized-list/index.d.ts +7 -1
  24. package/build/angular/components/virtualized-list/index.js +350 -359
  25. package/build/angular/components/virtualized-list/index.js.map +1 -1
  26. package/build/angular/components/virtualized-section-list/directives.js +20 -12
  27. package/build/angular/components/virtualized-section-list/directives.js.map +1 -1
  28. package/build/angular/components/virtualized-section-list/index.js +36 -142
  29. package/build/angular/components/virtualized-section-list/index.js.map +1 -1
  30. package/build/angular/components.d.ts +1 -1
  31. package/build/angular/components.js +1 -1
  32. package/build/angular/components.js.map +1 -1
  33. package/build/angular/create-portal/index.js +9 -8
  34. package/build/angular/create-portal/index.js.map +1 -1
  35. package/build/angular/create-tunnel/index.js +13 -12
  36. package/build/angular/create-tunnel/index.js.map +1 -1
  37. package/build/angular/descriptor-to-angular/index.js +6 -6
  38. package/build/angular/descriptor-to-angular/index.js.map +1 -1
  39. package/build/angular/diagnostics.d.ts +13 -0
  40. package/build/angular/diagnostics.js +18 -0
  41. package/build/angular/diagnostics.js.map +1 -1
  42. package/build/angular/element-props.d.ts +1 -3
  43. package/build/angular/elements.d.ts +46 -32
  44. package/build/angular/elements.js +217 -434
  45. package/build/angular/elements.js.map +1 -1
  46. package/build/angular/index.d.ts +3 -1
  47. package/build/angular/index.js +44 -13
  48. package/build/angular/index.js.map +1 -1
  49. package/build/angular/modules/animated/animated-leaf-binder.js +0 -2
  50. package/build/angular/modules/animated/animated-leaf-binder.js.map +1 -1
  51. package/build/angular/modules/animated/create-animated-component.js +69 -74
  52. package/build/angular/modules/animated/create-animated-component.js.map +1 -1
  53. package/build/angular/modules/animated/index.d.ts +30 -19
  54. package/build/angular/modules/status-bar/index.js +2 -8
  55. package/build/angular/modules/status-bar/index.js.map +1 -1
  56. package/build/angular/primitives/index.d.ts +14 -28
  57. package/build/angular/primitives/index.js +44 -107
  58. package/build/angular/primitives/index.js.map +1 -1
  59. package/build/angular/primitives/shared.d.ts +1 -0
  60. package/build/angular/primitives/shared.js +47 -32
  61. package/build/angular/primitives/shared.js.map +1 -1
  62. package/build/angular/render/index.js +40 -3
  63. package/build/angular/render/index.js.map +1 -1
  64. package/build/angular/renderer/index.d.ts +80 -0
  65. package/build/angular/renderer/index.js +324 -114
  66. package/build/angular/renderer/index.js.map +1 -1
  67. package/build/angular/runtime-matching.d.ts +9 -0
  68. package/build/angular/runtime-matching.js +82 -0
  69. package/build/angular/runtime-matching.js.map +1 -0
  70. package/build/angular/services/color-scheme.service.js +4 -4
  71. package/build/angular/services/color-scheme.service.js.map +1 -1
  72. package/build/angular/services/window-dimensions.service.js +4 -4
  73. package/build/angular/services/window-dimensions.service.js.map +1 -1
  74. package/build/angular/style-host.d.ts +11 -0
  75. package/build/angular/style-host.js +86 -0
  76. package/build/angular/style-host.js.map +1 -0
  77. package/package.json +6 -6
  78. package/src/callback-host.ts +229 -0
  79. package/src/change-detection-flush.ts +62 -9
  80. package/src/components/flat-list/index.ts +2 -1
  81. package/src/components/keyboard-avoiding-view/index.ts +8 -0
  82. package/src/components/pressable-props.ts +8 -0
  83. package/src/components/touchable-props.ts +4 -0
  84. package/src/components/virtualized-list/index.ts +98 -45
  85. package/src/components.ts +0 -2
  86. package/src/diagnostics.ts +18 -0
  87. package/src/element-props.ts +1 -5
  88. package/src/elements.ts +159 -38
  89. package/src/index.ts +47 -14
  90. package/src/primitives/index.ts +14 -70
  91. package/src/primitives/shared.ts +21 -6
  92. package/src/render/index.ts +56 -2
  93. package/src/renderer/index.ts +378 -104
  94. package/src/runtime-matching.ts +80 -0
  95. package/src/style-host.ts +94 -0
  96. package/tsconfig.angular.base.json +11 -0
@@ -10,19 +10,26 @@ import {
10
10
  createAnchor,
11
11
  createElement,
12
12
  createRawText,
13
+ componentOf,
13
14
  dlog,
14
15
  getExplicitStyle,
15
16
  insertBefore,
16
17
  isDebug,
18
+ isRawTextNode,
17
19
  isSymbioteEvent,
18
20
  isSymbioteNode,
21
+ isTextContainer,
22
+ isSameShallowStyle,
23
+ nextSiblingOf,
24
+ parentOf,
25
+ propOf,
26
+ registerBeforeFlush,
19
27
  removeChild,
20
28
  routeProp,
21
29
  setEventListener,
22
- setProp,
23
30
  setText,
31
+ textOf,
24
32
  toPublicInstance,
25
- RAW_TEXT_COMPONENT,
26
33
  SymbioteSurface,
27
34
  type ISymbioteNode,
28
35
  } from '@symbiote-native/engine';
@@ -30,12 +37,12 @@ import {
30
37
  COMPONENT_DESCRIPTORS,
31
38
  descriptorFor,
32
39
  } from '@symbiote-native/components';
33
- import { foldHostBag } from '@symbiote-native/components/fold-host-bag';
34
40
  import type { Renderer2, RendererFactory2, RendererType2 } from '@angular/core';
35
41
  import { isAnchorHostComponent } from '../anchor-host-registry';
36
42
  import {
37
43
  countAngular,
38
44
  noteAngularCreate,
45
+ noteAngularStyleWrite,
39
46
  noteAngularWrite,
40
47
  } from '../diagnostics';
41
48
  // Angular host nodes are all SymbioteNode (elements, raw text, anchors). The mount
@@ -48,71 +55,34 @@ function isSurface(parent: IHostElement): parent is SymbioteSurface {
48
55
  }
49
56
 
50
57
  function isRawText(node: ISymbioteNode): boolean {
51
- return node.component === RAW_TEXT_COMPONENT;
58
+ return isRawTextNode(node);
52
59
  }
53
60
 
54
61
  function isRecord(value: unknown): value is Record<string, unknown> {
55
62
  return typeof value === 'object' && value !== null;
56
63
  }
57
64
 
58
- // RN's Text.js applies two defaults on the way to native (core/components/host-primitives.cjs's
59
- // `Text.defaults`; the authority on what they MEAN is core/components/src/text-props.ts's
60
- // resolveTextProps, which the composed `Text` @Component already calls). That component's own
61
- // host paints directly (Text is not anchor-hosted see the top-level "View/Text's own component
62
- // doesn't have this split" reasoning elsewhere in this file), so createElement runs for its INNER
63
- // text node too; seeding here therefore covers both the composed Text and any bare
64
- // `text` a future lowering emits, uniformly. Found missing 2026-08-31 (a cross-adapter
65
- // key-count diff against Vue's real BenchmarkRow.vue) — without this a lowered Text's
66
- // `numberOfLines` clips with no ellipsis, silently, on device only. Vue's renderer already does
67
- // this (`adapters/vue/src/renderer/index.ts`'s `seedTextDefaults`); Angular's simply never did.
65
+ // RN's TWO TEXT DEFAULTS LEFT THIS RENDERER ENTIRELY ON 2026-09-18, in two steps a fortnight apart.
66
+ // The SEED went first, to the payload builder writing them as props cost a crossing every time an
67
+ // app authored the same value (6 000 per 1 000-row create, `writesOfUnchanged`). What stayed was a
68
+ // resolver for the clear-back path: a write of `undefined` looked up the default instead of clearing.
68
69
  //
69
- // Sourced from `foldHostBag` (`@symbiote-native/components/fold-host-bag`, driven by
70
- // `HOST_PRIMITIVES.Text.defaults`) rather than a second hardcoded copy React and Svelte call the
71
- // same function directly; this used to be a THIRD, independent restatement of the same two
72
- // defaults, with nothing to catch it drifting from the spec if a default's value ever changed.
73
- // `foldHostBag('text', {})` on an EMPTY bag folds every default with no authored value to
74
- // override it (the alias loop has nothing to fold `id` is only rewritten when present), which is
75
- // exactly the seed this function needs.
76
- function seedTextDefaults(node: ISymbioteNode): void {
77
- const seeded = foldHostBag('text', {});
78
- for (const [key, value] of Object.entries(seeded)) setProp(node, key, value);
79
- }
80
-
81
- // An explicit `undefined` must NOT clear one of those defaults — RN treats a missing prop and an
82
- // explicit undefined alike, and only a literal `false` opts allowFontScaling out. Reached only
83
- // when a later write clears a key back to undefined, so it costs nothing on the common path.
84
- // `foldHostBag` folds every Text default when called this way (not just `key`), because its
85
- // contract is "fold a whole bag" — the extra key computed alongside `key` is simply unread here.
86
- function textDefaultFor(el: IHostElement, key: string): unknown {
87
- if (isSurface(el) || !el.isText) return undefined;
88
- return foldHostBag('text', { [key]: undefined })[key];
89
- }
90
-
91
- // RN's `id` is the modern W3C-named alias for `nativeID` (core/components/host-primitives.cjs's
92
- // `ID_ALIAS`) — View.js/Text.js copy it over unconditionally, so the two name ONE native prop.
93
- // React/Solid/Svelte fold it in a wrapper or transform; Angular had it nowhere, so `<view
94
- // id="x">`/`[id]="x"` reached Fabric with an unknown `id` key and no `nativeID` — silently, on
95
- // device only. Lives in the renderer (mirroring Vue's `PROP_ALIASES`) so it covers every path
96
- // that can set a prop — `setAttribute`, `setProperty`, and (should a future lowering emit one) a
97
- // hand-built call — not just the composed component's own `id` @Input.
98
- // `symbioteStyle` is what the lowering transform emits in place of `[style]`: Angular routes a
99
- // `style` binding to its own CSS styling engine, which cannot represent an RN StyleProp (an array
100
- // throws inside change detection). Under any other name it is an ordinary property binding and
101
- // arrives here.
70
+ // That is gone too, and for the reason the seed was: `applyTextDefaults` (and its C++ twin) runs on
71
+ // EVERY commit of every `RCTText`, so a cleared key is absent for exactly as long as it takes the
72
+ // payload builder to supply the default again. The resolver was answering a question nothing asks.
73
+ //
74
+ // `PROP_ALIASES` (`id` -> `nativeID`) left this renderer on 2026-09-18 `routeProp` resolves it
75
+ // for every adapter now, so every path that can set a prop still reaches it.
102
76
  // Angular's two-way sugar `[(value)]` compiles to a `(valueChange)` binding; the engine knows the
103
77
  // same fold as the function prop `onValueChange`. See `listen()`. The two names live in a leaf
104
78
  // module so `elements.ts`'s ControlValueAccessor can name them without importing this cyclic file.
105
79
  import { VALUE_CHANGE_EVENT, VALUE_CHANGE_PROP } from './value-change';
106
- import { flushViewFor } from '../change-detection-flush';
107
-
108
- const PROP_ALIASES: ReadonlyMap<string, string> = new Map([
109
- ['id', 'nativeID'],
110
- ['symbioteStyle', 'style'],
111
- ]);
112
-
113
- function aliasedPropName(name: string): string {
114
- return PROP_ALIASES.get(name) ?? name;
115
- }
80
+ import {
81
+ createCallbackWrapper,
82
+ flushViewFor,
83
+ isWrappableCallback,
84
+ type ICallbackWrapper,
85
+ } from '../change-detection-flush';
116
86
 
117
87
  // The app callbacks an engine behavior READS BACK inside the same microtask turn, as an Angular
118
88
  // `(event)` binding — `valueChange` is handled in `listen` on its own, since it also needs the field
@@ -125,8 +95,53 @@ function aliasedPropName(name: string): string {
125
95
  // and adds the `markForCheck` a prop callback needs and an event binding gets from Angular.
126
96
  const READ_BACK_EVENTS: ReadonlySet<string> = new Set(['refresh']);
127
97
 
98
+ // How many distinct style objects the renderer keeps to hand back by identity. A screen's styles are
99
+ // a handful; the bench row has four. See `publishedStyles`.
100
+ const STYLE_CACHE = 16;
101
+
128
102
  type IReadBackListener = (event: unknown) => unknown;
129
103
 
104
+ // `listen(VALUE_CHANGE_EVENT)` can be called TWICE for the SAME logical `(valueChange)`/`[(value)]`
105
+ // binding on a MATCHED element — Angular's own compiled output-binding codegen wires one listener,
106
+ // `ValueChangeElement.ngOnInit`'s manual bridge wires a second (`lowered-two-way-value.test.ts`,
107
+ // "delivers a bound handler exactly once per change"). Those two must DEDUPE (last one replaces,
108
+ // not composes — firing both double-delivers the identical value). A genuinely different explicit
109
+ // `[onValueChange]` app-level handler (routed here via `ngOnChanges`'s generic per-input loop) must
110
+ // instead be PRESERVED and called alongside the bridge, or it silently stops firing the moment
111
+ // `[(value)]`/`(valueChange)` is also bound. This set is what tells the two cases apart.
112
+ const bridgedValueChangeHandlers = new WeakSet<IReadBackListener>();
113
+
114
+ // The genuinely explicit `[onValueChange]` app handler for a node, if one is bound — read LIVE by
115
+ // the composed forward function on every event rather than captured once at compose time. That is
116
+ // what makes handler installation order-independent: `ngOnChanges` (the explicit handler) and the
117
+ // two `listen(VALUE_CHANGE_EVENT)` calls (the bridge, see below) can happen in any interleaving —
118
+ // at initial mount OR on a later `[onValueChange]` rebind (`ngOnChanges` again, same code path,
119
+ // routed through `setProperty`) — without one silently overwriting the other's closure.
120
+ const explicitValueChangeHandlers = new WeakMap<
121
+ ISymbioteNode,
122
+ IReadBackListener
123
+ >();
124
+
125
+ function composeValueChangeHandler(
126
+ target: ISymbioteNode,
127
+ bridgeCallback: (event: unknown) => boolean | void,
128
+ ): IReadBackListener {
129
+ const forwardValue = withChangeDetection(target, (event: unknown) => {
130
+ explicitValueChangeHandlers.get(target)?.(event);
131
+ if (isSymbioteEvent(event)) {
132
+ if ('text' in event) return bridgeCallback(event.text);
133
+ if ('value' in event) return bridgeCallback(event.value);
134
+ }
135
+ return bridgeCallback(event);
136
+ });
137
+ bridgedValueChangeHandlers.add(forwardValue);
138
+ return forwardValue;
139
+ }
140
+
141
+ function isReadBackListener(value: unknown): value is IReadBackListener {
142
+ return typeof value === 'function';
143
+ }
144
+
130
145
  function withChangeDetection(
131
146
  node: IHostElement,
132
147
  listener: IReadBackListener,
@@ -158,8 +173,8 @@ function describeHost(node: IHostElement | null | undefined): string {
158
173
  if (isSurface(node)) return 'surface';
159
174
  const anchorId = anchorDebugIds.get(node);
160
175
  return anchorId !== undefined
161
- ? `${node.component}#${anchorId}`
162
- : node.component;
176
+ ? `${componentOf(node)}#${anchorId}`
177
+ : componentOf(node);
163
178
  }
164
179
 
165
180
  // A hand-written tag ngtsc accepts must contain a HYPHEN, so the six dashless intrinsics
@@ -204,9 +219,9 @@ const PRIMITIVE_SELECTOR_ALIAS: Record<string, string> = {
204
219
  // stray RCTRawText would paint). Angular's ɵɵtext only ever lands text inside a <text>,
205
220
  // but guard anyway for parity with the Vue adapter and to fail loudly on a bad template.
206
221
  function assertTextPlacement(child: ISymbioteNode, parent: IHostElement): void {
207
- if (isRawText(child) && (isSurface(parent) || !parent.isText)) {
222
+ if (isRawText(child) && (isSurface(parent) || !isTextContainer(parent))) {
208
223
  throw new Error(
209
- `Text string "${String(child.props.text)}" must be rendered inside a <text>`,
224
+ `Text string "${textOf(child) ?? ''}" must be rendered inside a <text>`,
210
225
  );
211
226
  }
212
227
  }
@@ -220,9 +235,163 @@ export class SymbioteRenderer implements Renderer2 {
220
235
  // render.ts (unmount), so per-node cleanup is a no-op.
221
236
  destroyNode: ((node: ISymbioteNode) => void) | null = null;
222
237
 
223
- constructor(private readonly surface: SymbioteSurface) {}
238
+ // THE STYLING RUN, and why it is held rather than published key by key.
239
+ //
240
+ // Angular has no whole-value styling call: `ɵɵstyleMap` / `ɵɵclassMap` walk the key-value array
241
+ // and call `setStyle` / `addClass` once PER KEY (`updateStyling` -> `applyStyling`, upstream
242
+ // `@angular/core`). RN wants one `style` prop, so publishing on each call rebuilt the whole object
243
+ // every time — N writes for an N-key style, the k-th carrying k keys, and N DISTINCT values where
244
+ // the app authored one. Measured on the headless bench arm: `values` 10 001 against Vue's 3 004
245
+ // for the identical tree, and `convert` 30.0 ms against 0.6.
246
+ //
247
+ // The run is safe to hold because it is CONTIGUOUS: `updateStyling` takes its node from
248
+ // `getSelectedIndex()` and the loop never changes element mid-way, so the calls for one node
249
+ // arrive with nothing between them. Anything else — a different node, any other renderer call, a
250
+ // host read, the commit — closes it first.
251
+ // `ISymbioteNode`, not `IHostElement`: a surface is turned away by `isSurface` at every entry
252
+ // point, so a run can only ever be open on a real node, and saying so keeps the publish below
253
+ // free of a second guard.
254
+ private pendingStyleNode: ISymbioteNode | undefined;
255
+ private pendingStyle: Record<string, unknown> = {};
256
+ private pendingClassNode: ISymbioteNode | undefined;
257
+ private readonly releaseBeforeFlush: () => void;
258
+
259
+ constructor(private readonly surface: SymbioteSurface) {
260
+ // A READ AND A COMMIT BOTH ARRIVE FROM ELSEWHERE, so the renderer cannot close the run on its
261
+ // own: a turn whose last act is a style change has no next call to close it, and the commit
262
+ // would paint the node without it. `flushOps` is the one door in front of every drain.
263
+ this.releaseBeforeFlush = registerBeforeFlush(() => this.flushStyling());
264
+ }
224
265
 
225
- destroy(): void {}
266
+ /**
267
+ * Angular is done with this renderer FOR ONE COMPONENT — which is not the same as this renderer
268
+ * being done, and the difference was a shipped bug.
269
+ *
270
+ * The factory hands ONE instance to every component on the surface, so Angular calls this
271
+ * whenever ANY component's views are torn down; a keyed `@for` replace destroys a thousand. So it
272
+ * publishes what it is holding and nothing more. Releasing the `beforeFlush` registration here
273
+ * closed the only door a style run has — `flushOps` is what asks a renderer for what it holds —
274
+ * while every still-living view went on writing into the accumulator. The style then reached
275
+ * Fabric only when some OTHER node's `openStyleRun` happened to close the run, which on the
276
+ * benchmark screen was two steps after the selection that asked for it.
277
+ *
278
+ * The registration follows the SURFACE now, and `SymbioteRendererFactory.dispose` releases it.
279
+ */
280
+ destroy(): void {
281
+ this.flushStyling();
282
+ }
283
+
284
+ /** The surface is going away — publish what is held and stop listening. See `destroy` above. */
285
+ dispose(): void {
286
+ this.flushStyling();
287
+ this.releaseBeforeFlush();
288
+ }
289
+
290
+ /**
291
+ * Publish whatever the styling run is holding. Idempotent, and free when it holds nothing.
292
+ *
293
+ * IT DOES NOT REQUEST A COMMIT, and that is not an omission — the request is made when the style
294
+ * is ACCUMULATED, exactly where it was made before the run existed. This runs from inside
295
+ * `flushOps`, which the commit itself calls first, so asking there schedules a SECOND commit whose
296
+ * tree is already current: a full `completeRoot` plus a Yoga pass for nothing.
297
+ *
298
+ * Measured, because it did not look like a cost. The bench arm's `select` fell 17.1 -> 3.8 ms and
299
+ * its `remove` rose 7.1 -> 18.2 in the same runs — the extra commit lands in whichever step's
300
+ * microtask happens to run it, so the work had MOVED between steps rather than gone. Two rows
301
+ * moving by the same amount in opposite directions is what that always looks like.
302
+ */
303
+ private flushStyling(): void {
304
+ const styled = this.pendingStyleNode;
305
+ if (styled !== undefined) {
306
+ const style = this.pendingStyle;
307
+ this.pendingStyleNode = undefined;
308
+ this.pendingStyle = {};
309
+ routeProp(styled, 'style', this.canonicalStyle(style));
310
+ }
311
+ const classed = this.pendingClassNode;
312
+ if (classed !== undefined) {
313
+ this.pendingClassNode = undefined;
314
+ routeProp(classed, 'class', this.classStringFor(classed));
315
+ }
316
+ }
317
+
318
+ /**
319
+ * The union of a node's two class sources, or `undefined` when it has none.
320
+ *
321
+ * A node can be told its classes BOTH ways in one pass: Ivy compiles `[class.foo]`, `[ngClass]`
322
+ * and a static `class=` down to per-token `addClass`/`removeClass`, while a whole-string `class`
323
+ * arrives at `setProperty` — which is the shape Angular uses when a directive declares `class` as
324
+ * an input and the styling binding is shadowed into it (`setShadowStylingInputFlags`,
325
+ * `view/directives.ts`). Publishing either one alone erases the other, and the DOM renderer this
326
+ * mirrors has no such problem because `classList` accumulates for it.
327
+ */
328
+ private classStringFor(el: ISymbioteNode): string | undefined {
329
+ const tokens = this.classTokens.get(el);
330
+ const whole = this.classStrings.get(el);
331
+ if (whole === undefined) {
332
+ return tokens !== undefined && tokens.size > 0
333
+ ? [...tokens].join(' ')
334
+ : undefined;
335
+ }
336
+ if (tokens === undefined || tokens.size === 0) {
337
+ return whole.length > 0 ? whole : undefined;
338
+ }
339
+ const union = new Set(
340
+ whole.split(/\s+/u).filter(token => token.length > 0),
341
+ );
342
+ for (const token of tokens) union.add(token);
343
+ return union.size > 0 ? [...union].join(' ') : undefined;
344
+ }
345
+
346
+ /**
347
+ * Style objects this renderer has already published, so a list of identical rows sends ONE.
348
+ *
349
+ * The intern table keys by IDENTITY, so a fresh object per node is a fresh entry per node and the
350
+ * host converts every entry across JSI. Measured on the bench arm: `values` 7 001 against Vue's
351
+ * 3 004 for the identical tree, with `convert` 19.5 ms against 0.6 — and the gap is the KIND
352
+ * rather than the count, ~4 000 of Angular's being objects that convert recursively where Vue's
353
+ * are scalars plus four hoisted styles every row shares.
354
+ *
355
+ * Angular cannot share them on its own: `ɵɵstyleMap` hands over KEYS, so the object is this
356
+ * renderer's own construction. Recognising one it has already built is what puts an Angular app
357
+ * back on the footing of a framework whose author hoisted the constant.
358
+ *
359
+ * SMALL AND MRU. A screen has a handful of distinct styles and re-publishes them thousands of
360
+ * times, so a hit is almost always at the front; a miss costs at most `STYLE_CACHE` shallow
361
+ * compares, which is nothing beside the conversion it saves. An app with more distinct styles than
362
+ * this simply stops sharing — it never stops being correct.
363
+ */
364
+ private readonly publishedStyles: Record<string, unknown>[] = [];
365
+
366
+ /** A published object equal to this one, or this one — which then becomes the published copy. */
367
+ private canonicalStyle(
368
+ style: Record<string, unknown>,
369
+ ): Record<string, unknown> {
370
+ for (let at = 0; at < this.publishedStyles.length; at += 1) {
371
+ const known = this.publishedStyles[at];
372
+ if (!isSameShallowStyle(style, known)) continue;
373
+ if (at > 0) {
374
+ this.publishedStyles.splice(at, 1);
375
+ this.publishedStyles.unshift(known);
376
+ }
377
+ return known;
378
+ }
379
+ this.publishedStyles.unshift(style);
380
+ if (this.publishedStyles.length > STYLE_CACHE) this.publishedStyles.pop();
381
+ return style;
382
+ }
383
+
384
+ /** The accumulator for this node's style run, opening one (and closing any other) if needed. */
385
+ private openStyleRun(el: ISymbioteNode): Record<string, unknown> {
386
+ if (this.pendingStyleNode === el) return this.pendingStyle;
387
+ this.flushStyling();
388
+ // Seeded from what is STANDING, because Angular sends only the keys that changed — an update
389
+ // that moves one key must not drop the rest.
390
+ const current = getExplicitStyle(el);
391
+ this.pendingStyle = isRecord(current) ? { ...current } : {};
392
+ this.pendingStyleNode = el;
393
+ return this.pendingStyle;
394
+ }
226
395
 
227
396
  createElement(name: string): IHostNode {
228
397
  // `name` is the component's host tag — a symbiote intrinsic (`view`,
@@ -270,7 +439,6 @@ export class SymbioteRenderer implements Renderer2 {
270
439
  descriptor.isText,
271
440
  engineName,
272
441
  );
273
- if (descriptor.isText) seedTextDefaults(node);
274
442
  if (isDebug()) {
275
443
  dlog(`angular createElement ${name} -> ${descriptor.component}`);
276
444
  }
@@ -354,16 +522,23 @@ export class SymbioteRenderer implements Renderer2 {
354
522
  // Detach from the child's own retained parent (a top-level node lives in
355
523
  // surface.children with no parent). Angular's `parent` arg is ignored in favor of the
356
524
  // authoritative link, mirroring the Vue adapter's remove.
525
+ // Angular tears down a root view by removing its HOST, which here is the surface itself. There
526
+ // is nothing above it to detach from — the old retained tree absorbed the call (`indexOf` of a
527
+ // node that is not in the list is -1), and the mutation buffer cannot: it would record a remove
528
+ // naming the surface as a child of its own node.
529
+ if (isSurface(oldChild)) return;
357
530
  countAngular('nodesRemoved');
358
531
  if (isDebug()) {
359
532
  const angularParent = _parent !== null ? describeHost(_parent) : 'null';
360
533
  const retainedParent =
361
- oldChild.parent !== undefined ? describeHost(oldChild.parent) : 'none';
534
+ parentOf(oldChild) !== undefined
535
+ ? describeHost(parentOf(oldChild))
536
+ : 'none';
362
537
  dlog(
363
538
  `Angular renderer removeChild angularParent=${angularParent} retainedParent=${retainedParent} child=${describeHost(oldChild)}`,
364
539
  );
365
540
  }
366
- const parent = oldChild.parent;
541
+ const parent = parentOf(oldChild);
367
542
  if (parent !== undefined) removeChild(parent, oldChild);
368
543
  else this.surface.removeChild(oldChild);
369
544
  this.surface.requestCommit();
@@ -383,14 +558,13 @@ export class SymbioteRenderer implements Renderer2 {
383
558
  // does `inject(ViewContainerRef)`, e.g. VListOutletDirective) forwards this null straight into
384
559
  // insertBefore without checking it; without that guard it crashed on-device.
385
560
  parentNode(node: IHostNode): IHostElement | null {
386
- return node.parent ?? null;
561
+ return parentOf(node) ?? null;
387
562
  }
388
563
 
389
564
  nextSibling(node: IHostNode): IHostNode | null {
390
- const siblings =
391
- node.parent !== undefined ? node.parent.children : this.surface.children;
392
- const index = siblings.indexOf(node);
393
- return index >= 0 ? (siblings[index + 1] ?? null) : null;
565
+ // `?? null` because Renderer2 types the miss as null; the engine answers undefined
566
+ // uniformly and owns the top-level fallback through the surface it is handed.
567
+ return nextSiblingOf(node, this.surface) ?? null;
394
568
  }
395
569
 
396
570
  // locateHostElement always routes createComponent's `hostElement` THROUGH here as
@@ -401,20 +575,24 @@ export class SymbioteRenderer implements Renderer2 {
401
575
  return typeof selectorOrNode === 'string' ? this.surface : selectorOrNode;
402
576
  }
403
577
 
578
+ // The three prop writers below close the styling run FIRST. A read or a commit would do it through
579
+ // `registerBeforeFlush`, but neither happens here: this is one prop write landing on the same node
580
+ // whose `style` or `class` is still held, and a run published afterwards would overwrite it.
404
581
  setAttribute(el: IHostElement, name: string, value: string): void {
405
582
  if (isSurface(el)) return;
583
+ this.flushStyling();
406
584
  countAngular('rendererWrites');
407
585
  noteAngularWrite(name);
408
- routeProp(el, aliasedPropName(name), value);
586
+ routeProp(el, name, value);
409
587
  this.surface.requestCommit();
410
588
  }
411
589
 
412
590
  removeAttribute(el: IHostElement, name: string): void {
413
591
  if (isSurface(el)) return;
592
+ this.flushStyling();
414
593
  countAngular('rendererWrites');
415
594
  noteAngularWrite(name);
416
- const aliased = aliasedPropName(name);
417
- routeProp(el, aliased, textDefaultFor(el, aliased));
595
+ routeProp(el, name, undefined);
418
596
  this.surface.requestCommit();
419
597
  }
420
598
 
@@ -427,6 +605,15 @@ export class SymbioteRenderer implements Renderer2 {
427
605
  // resolves identically regardless of adapter.
428
606
  private readonly classTokens = new WeakMap<IHostNode, Set<string>>();
429
607
 
608
+ // The OTHER class source: a whole string written as a prop. Kept apart from the token set rather
609
+ // than merged into it, because the two are replaced independently — a new `[class]` value
610
+ // replaces this string entirely while leaving every `[class.foo]` token standing.
611
+ private readonly classStrings = new WeakMap<IHostNode, string>();
612
+ private readonly callbackWrappers = new WeakMap<
613
+ IHostNode,
614
+ ICallbackWrapper
615
+ >();
616
+
430
617
  addClass(el: IHostElement, name: string): void {
431
618
  if (isSurface(el)) return;
432
619
  countAngular('rendererWrites');
@@ -434,7 +621,7 @@ export class SymbioteRenderer implements Renderer2 {
434
621
  const tokens = this.classTokens.get(el) ?? new Set<string>();
435
622
  tokens.add(name);
436
623
  this.classTokens.set(el, tokens);
437
- routeProp(el, 'class', [...tokens].join(' '));
624
+ this.openClassRun(el);
438
625
  this.surface.requestCommit();
439
626
  }
440
627
 
@@ -445,10 +632,18 @@ export class SymbioteRenderer implements Renderer2 {
445
632
  countAngular('rendererWrites');
446
633
  noteAngularWrite('class');
447
634
  tokens.delete(name);
448
- routeProp(el, 'class', tokens.size > 0 ? [...tokens].join(' ') : undefined);
635
+ this.openClassRun(el);
449
636
  this.surface.requestCommit();
450
637
  }
451
638
 
639
+ // The token SET is the accumulator here — `addClass` has already put the token in it — so this
640
+ // only has to remember whose run is open. Closing it re-joins the set once.
641
+ private openClassRun(el: ISymbioteNode): void {
642
+ if (this.pendingClassNode === el) return;
643
+ this.flushStyling();
644
+ this.pendingClassNode = el;
645
+ }
646
+
452
647
  // Angular decomposes a [style] binding into per-key setStyle calls (ɵɵstyleMap). RN wants
453
648
  // the whole style object as one `style` prop, so merge each key into it — onto the explicit
454
649
  // style half tracked by routeProp's centralized class+style merge (core/engine/src/node.ts),
@@ -457,21 +652,23 @@ export class SymbioteRenderer implements Renderer2 {
457
652
  setStyle(el: IHostElement, style: string, value: unknown): void {
458
653
  if (isSurface(el)) return;
459
654
  countAngular('rendererWrites');
460
- noteAngularWrite(`style.${style}`);
461
- const current = getExplicitStyle(el);
462
- const base = isRecord(current) ? current : {};
463
- routeProp(el, 'style', { ...base, [style]: value });
655
+ noteAngularStyleWrite(style);
656
+ this.openStyleRun(el)[style] = value;
464
657
  this.surface.requestCommit();
465
658
  }
466
659
 
467
660
  removeStyle(el: IHostElement, style: string): void {
468
661
  if (isSurface(el)) return;
469
- const current = getExplicitStyle(el);
470
- if (!isRecord(current)) return;
662
+ // A remove on a node with no style at all is Angular clearing a binding it never set. Opening a
663
+ // run for it would publish an empty style onto a node that had none, which the old early return
664
+ // was there to avoid.
665
+ if (this.pendingStyleNode !== el && !isRecord(getExplicitStyle(el))) return;
471
666
  countAngular('rendererWrites');
472
- noteAngularWrite(`style.${style}`);
473
- const { [style]: _removed, ...rest } = current;
474
- routeProp(el, 'style', rest);
667
+ noteAngularStyleWrite(style);
668
+ const run = this.openStyleRun(el);
669
+ // The accumulator is this renderer's own object, never the node's — see `openStyleRun`.
670
+
671
+ delete run[style];
475
672
  this.surface.requestCommit();
476
673
  }
477
674
 
@@ -481,15 +678,64 @@ export class SymbioteRenderer implements Renderer2 {
481
678
  if (isSurface(el)) return;
482
679
  countAngular('rendererWrites');
483
680
  noteAngularWrite(name);
484
- const aliased = aliasedPropName(name);
485
- routeProp(
486
- el,
487
- aliased,
488
- value === undefined ? textDefaultFor(el, aliased) : value,
489
- );
681
+ // A WHOLE CLASS STRING IS NOT AN ORDINARY PROP: it is one of two sources the node's class list
682
+ // is built from, and writing it straight through would erase every `[class.foo]` token the
683
+ // other one put there. It joins the class run instead, exactly as `addClass` does.
684
+ if (name === 'class') {
685
+ this.openClassRun(el);
686
+ this.classStrings.set(el, typeof value === 'string' ? value : '');
687
+ this.surface.requestCommit();
688
+ return;
689
+ }
690
+ // `ngOnChanges` routes every `[onValueChange]` write here (both the initial bind and any
691
+ // later rebind to a new function reference) — never through `listen()`. When the write is a
692
+ // genuinely explicit app handler (not a prior bridge's own forward function), update the live
693
+ // slot `composeValueChangeHandler`'s forward function reads on every event; when a bridge is
694
+ // already installed on this node, that's ALL that's needed — the installed forward function
695
+ // picks the new handler up on its own, no re-route. Without this, a rebind after mount would
696
+ // silently kill the `[(value)]` two-way sync the moment it overwrote the composed prop raw.
697
+ if (name === VALUE_CHANGE_PROP) {
698
+ if (isReadBackListener(value) && !bridgedValueChangeHandlers.has(value)) {
699
+ explicitValueChangeHandlers.set(el, value);
700
+ } else {
701
+ explicitValueChangeHandlers.delete(el);
702
+ }
703
+ const current = propOf(el, VALUE_CHANGE_PROP);
704
+ const bridgeActive =
705
+ isReadBackListener(current) && bridgedValueChangeHandlers.has(current);
706
+ if (bridgeActive) {
707
+ this.surface.requestCommit();
708
+ return;
709
+ }
710
+ }
711
+ this.flushStyling();
712
+ routeProp(el, name, this.wrapCallback(el, name, value));
490
713
  this.surface.requestCommit();
491
714
  }
492
715
 
716
+ // AN `on*` PROP IS CALLED BY THE ENGINE, so Angular is told nothing and a plain field mutation
717
+ // inside the app's handler dirties no view — the "pan readout stuck at dx 0" bug
718
+ // `change-detection-flush.ts` records. It used to be wrapped by `SymbioteElement.ngOnChanges`,
719
+ // which is the wrong place now that most element directives are withheld from runtime matching
720
+ // (`../runtime-matching`): a withheld tag's binding reaches the renderer DIRECTLY through
721
+ // `ɵɵproperty` and never passes through a directive at all.
722
+ //
723
+ // Here it is reached by both paths and by every adapter surface — an element binding, a composed
724
+ // component's flat bag, an imperative write — which is a better home than the one it left.
725
+ //
726
+ // The cache is per NODE rather than per caller, because a node is what both paths agree on, and the
727
+ // wrapper is built on the first callback a node receives: the overwhelming majority of tags carry
728
+ // no `on*` prop, and an eager one would be a closure and a `WeakMap` per element for nothing.
729
+ private wrapCallback(node: IHostNode, name: string, value: unknown): unknown {
730
+ if (!isWrappableCallback(name, value)) return value;
731
+ let wrapper = this.callbackWrappers.get(node);
732
+ if (wrapper === undefined) {
733
+ wrapper = createCallbackWrapper(node);
734
+ this.callbackWrappers.set(node, wrapper);
735
+ }
736
+ return wrapper(name, value);
737
+ }
738
+
493
739
  setValue(node: IHostNode, value: string): void {
494
740
  countAngular('rendererWrites');
495
741
  noteAngularWrite('#text');
@@ -513,11 +759,11 @@ export class SymbioteRenderer implements Renderer2 {
513
759
  ): () => void {
514
760
  if (!isSymbioteNode(target)) return () => {};
515
761
  // `[(value)]` desugars to `(valueChange)`, which is the spelling every Angular template writes
516
- // for a Switch or a TextInput. On the COMPONENT path it is an `@Output()` the wrapper derives
517
- // from the raw `change` payload; on a LOWERED element there is no component, and registering
518
- // `valueChange` as an engine event would wait forever for a Fabric event of that name.
762
+ // for a Switch or a TextInput. It used to be an `@Output()` a wrapper derived from the raw
763
+ // `change` payload; a tag has no component, and registering `valueChange` as an engine event
764
+ // would wait forever for a Fabric event of that name.
519
765
  //
520
- // The lowered path already carries the same fold under RN's own spelling: both behaviors call
766
+ // The same fold already exists under RN's own spelling: both behaviors call
521
767
  // `node.props.onValueChange(event)` — a plain function PROP, not an event
522
768
  // (`behaviors/switch.ts`, `behaviors/text-input.ts`), with `text`/`value` carried as a FIELD on
523
769
  // the event object rather than a second argument (Svelte forces every individual `on*` prop
@@ -526,15 +772,31 @@ export class SymbioteRenderer implements Renderer2 {
526
772
  // unwrap that field back to a bare value before handing it to Angular's callback, or `text =
527
773
  // $event` would assign the whole event object instead of the typed string/boolean.
528
774
  if (eventName === VALUE_CHANGE_EVENT) {
529
- const forwardValue = withChangeDetection(target, (event: unknown) => {
530
- if (isSymbioteEvent(event)) {
531
- if ('text' in event) return callback(event.text);
532
- if ('value' in event) return callback(event.value);
533
- }
534
- return callback(event);
535
- });
536
- routeProp(target, VALUE_CHANGE_PROP, forwardValue);
537
- return () => routeProp(target, VALUE_CHANGE_PROP, undefined);
775
+ // `ngOnChanges` may already have written an explicit `[onValueChange]` binding onto this
776
+ // same prop key via `routeProp` (`setProperty`'s generic reflect loop) — seed the live
777
+ // explicit-handler slot from it, unless what's there is a PRIOR bridge's own forward
778
+ // function (the Angular-vs-manual-bridge double-registration this branch already dedupes,
779
+ // `listen(VALUE_CHANGE_EVENT)` fires twice per element — see `bridgedValueChangeHandlers`).
780
+ // Composing with the explicit handler (never replacing it) is the RN-parity behavior: RN's
781
+ // `onChange` always fires regardless of whether `value` is controlled.
782
+ const currentOnValueChange = propOf(target, VALUE_CHANGE_PROP);
783
+ if (
784
+ isReadBackListener(currentOnValueChange) &&
785
+ !bridgedValueChangeHandlers.has(currentOnValueChange)
786
+ ) {
787
+ explicitValueChangeHandlers.set(target, currentOnValueChange);
788
+ }
789
+ routeProp(
790
+ target,
791
+ VALUE_CHANGE_PROP,
792
+ composeValueChangeHandler(target, callback),
793
+ );
794
+ return () =>
795
+ routeProp(
796
+ target,
797
+ VALUE_CHANGE_PROP,
798
+ explicitValueChangeHandlers.get(target),
799
+ );
538
800
  }
539
801
  const listener = READ_BACK_EVENTS.has(eventName)
540
802
  ? withChangeDetection(target, callback)
@@ -559,6 +821,18 @@ export class SymbioteRendererFactory implements RendererFactory2 {
559
821
  return (this.renderer ??= new SymbioteRenderer(this.surface));
560
822
  }
561
823
 
824
+ /**
825
+ * The surface is being torn down, so the one renderer it shares can stop listening.
826
+ *
827
+ * IT IS THE FACTORY'S JOB and not Angular's, because the instance outlives any single component:
828
+ * Angular's own `Renderer2.destroy()` arrives once per destroyed component and must not close a
829
+ * door every surviving view still writes through. See `SymbioteRenderer.destroy`.
830
+ */
831
+ dispose(): void {
832
+ this.renderer?.dispose();
833
+ this.renderer = undefined;
834
+ }
835
+
562
836
  // Not commit coalescing (requestCommit owns that) — a per-CD-pass counter only, now that the
563
837
  // ScrollView projection bridge this used to also flush is gone (`../register.ts`'s
564
838
  // `registerScrollViewBehavior` owns the content node and the sticky seam for every adapter).