@symbiote-native/angular 2.0.0 → 3.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 (97) 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 +34 -1
  63. package/build/angular/render/index.js.map +1 -1
  64. package/build/angular/renderer/index.d.ts +56 -0
  65. package/build/angular/renderer/index.js +295 -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/metro-config.cjs +32 -16
  78. package/package.json +5 -5
  79. package/src/callback-host.ts +229 -0
  80. package/src/change-detection-flush.ts +62 -9
  81. package/src/components/flat-list/index.ts +2 -1
  82. package/src/components/keyboard-avoiding-view/index.ts +8 -0
  83. package/src/components/pressable-props.ts +8 -0
  84. package/src/components/touchable-props.ts +4 -0
  85. package/src/components/virtualized-list/index.ts +98 -45
  86. package/src/components.ts +0 -2
  87. package/src/diagnostics.ts +18 -0
  88. package/src/element-props.ts +1 -5
  89. package/src/elements.ts +159 -38
  90. package/src/index.ts +47 -14
  91. package/src/primitives/index.ts +14 -70
  92. package/src/primitives/shared.ts +21 -6
  93. package/src/render/index.ts +44 -0
  94. package/src/renderer/index.ts +347 -104
  95. package/src/runtime-matching.ts +80 -0
  96. package/src/style-host.ts +94 -0
  97. package/tsconfig.angular.base.json +11 -0
@@ -4,76 +4,35 @@
4
4
  // engine's tiny mutation API; the engine owns all Fabric clone-on-write, shared with
5
5
  // every other adapter. This is the Angular twin of adapters/vue/src/renderer.ts — proof
6
6
  // that the same engine mutation API drives both frameworks.
7
- import { appendChild, createAnchor, createElement, createRawText, dlog, getExplicitStyle, insertBefore, isDebug, isSymbioteEvent, isSymbioteNode, removeChild, routeProp, setEventListener, setProp, setText, toPublicInstance, RAW_TEXT_COMPONENT, SymbioteSurface, } from '@symbiote-native/engine';
7
+ import { appendChild, createAnchor, createElement, createRawText, componentOf, dlog, getExplicitStyle, insertBefore, isDebug, isRawTextNode, isSymbioteEvent, isSymbioteNode, isTextContainer, isSameShallowStyle, nextSiblingOf, parentOf, propOf, registerBeforeFlush, removeChild, routeProp, setEventListener, setText, textOf, toPublicInstance, SymbioteSurface, } from '@symbiote-native/engine';
8
8
  import { COMPONENT_DESCRIPTORS, descriptorFor, } from '@symbiote-native/components';
9
- import { foldHostBag } from '@symbiote-native/components/fold-host-bag';
10
9
  import { isAnchorHostComponent } from '../anchor-host-registry';
11
- import { countAngular, noteAngularCreate, noteAngularWrite, } from '../diagnostics';
10
+ import { countAngular, noteAngularCreate, noteAngularStyleWrite, noteAngularWrite, } from '../diagnostics';
12
11
  function isSurface(parent) {
13
12
  return parent instanceof SymbioteSurface;
14
13
  }
15
14
  function isRawText(node) {
16
- return node.component === RAW_TEXT_COMPONENT;
15
+ return isRawTextNode(node);
17
16
  }
18
17
  function isRecord(value) {
19
18
  return typeof value === 'object' && value !== null;
20
19
  }
21
- // RN's Text.js applies two defaults on the way to native (core/components/host-primitives.cjs's
22
- // `Text.defaults`; the authority on what they MEAN is core/components/src/text-props.ts's
23
- // resolveTextProps, which the composed `Text` @Component already calls). That component's own
24
- // host paints directly (Text is not anchor-hosted see the top-level "View/Text's own component
25
- // doesn't have this split" reasoning elsewhere in this file), so createElement runs for its INNER
26
- // text node too; seeding here therefore covers both the composed Text and any bare
27
- // `text` a future lowering emits, uniformly. Found missing 2026-08-31 (a cross-adapter
28
- // key-count diff against Vue's real BenchmarkRow.vue) — without this a lowered Text's
29
- // `numberOfLines` clips with no ellipsis, silently, on device only. Vue's renderer already does
30
- // this (`adapters/vue/src/renderer/index.ts`'s `seedTextDefaults`); Angular's simply never did.
20
+ // RN's TWO TEXT DEFAULTS LEFT THIS RENDERER ENTIRELY ON 2026-09-18, in two steps a fortnight apart.
21
+ // The SEED went first, to the payload builder writing them as props cost a crossing every time an
22
+ // app authored the same value (6 000 per 1 000-row create, `writesOfUnchanged`). What stayed was a
23
+ // resolver for the clear-back path: a write of `undefined` looked up the default instead of clearing.
31
24
  //
32
- // Sourced from `foldHostBag` (`@symbiote-native/components/fold-host-bag`, driven by
33
- // `HOST_PRIMITIVES.Text.defaults`) rather than a second hardcoded copy React and Svelte call the
34
- // same function directly; this used to be a THIRD, independent restatement of the same two
35
- // defaults, with nothing to catch it drifting from the spec if a default's value ever changed.
36
- // `foldHostBag('text', {})` on an EMPTY bag folds every default with no authored value to
37
- // override it (the alias loop has nothing to fold `id` is only rewritten when present), which is
38
- // exactly the seed this function needs.
39
- function seedTextDefaults(node) {
40
- const seeded = foldHostBag('text', {});
41
- for (const [key, value] of Object.entries(seeded))
42
- setProp(node, key, value);
43
- }
44
- // An explicit `undefined` must NOT clear one of those defaults — RN treats a missing prop and an
45
- // explicit undefined alike, and only a literal `false` opts allowFontScaling out. Reached only
46
- // when a later write clears a key back to undefined, so it costs nothing on the common path.
47
- // `foldHostBag` folds every Text default when called this way (not just `key`), because its
48
- // contract is "fold a whole bag" — the extra key computed alongside `key` is simply unread here.
49
- function textDefaultFor(el, key) {
50
- if (isSurface(el) || !el.isText)
51
- return undefined;
52
- return foldHostBag('text', { [key]: undefined })[key];
53
- }
54
- // RN's `id` is the modern W3C-named alias for `nativeID` (core/components/host-primitives.cjs's
55
- // `ID_ALIAS`) — View.js/Text.js copy it over unconditionally, so the two name ONE native prop.
56
- // React/Solid/Svelte fold it in a wrapper or transform; Angular had it nowhere, so `<view
57
- // id="x">`/`[id]="x"` reached Fabric with an unknown `id` key and no `nativeID` — silently, on
58
- // device only. Lives in the renderer (mirroring Vue's `PROP_ALIASES`) so it covers every path
59
- // that can set a prop — `setAttribute`, `setProperty`, and (should a future lowering emit one) a
60
- // hand-built call — not just the composed component's own `id` @Input.
61
- // `symbioteStyle` is what the lowering transform emits in place of `[style]`: Angular routes a
62
- // `style` binding to its own CSS styling engine, which cannot represent an RN StyleProp (an array
63
- // throws inside change detection). Under any other name it is an ordinary property binding and
64
- // arrives here.
25
+ // That is gone too, and for the reason the seed was: `applyTextDefaults` (and its C++ twin) runs on
26
+ // EVERY commit of every `RCTText`, so a cleared key is absent for exactly as long as it takes the
27
+ // payload builder to supply the default again. The resolver was answering a question nothing asks.
28
+ //
29
+ // `PROP_ALIASES` (`id` -> `nativeID`) left this renderer on 2026-09-18 `routeProp` resolves it
30
+ // for every adapter now, so every path that can set a prop still reaches it.
65
31
  // Angular's two-way sugar `[(value)]` compiles to a `(valueChange)` binding; the engine knows the
66
32
  // same fold as the function prop `onValueChange`. See `listen()`. The two names live in a leaf
67
33
  // module so `elements.ts`'s ControlValueAccessor can name them without importing this cyclic file.
68
34
  import { VALUE_CHANGE_EVENT, VALUE_CHANGE_PROP } from './value-change';
69
- import { flushViewFor } from '../change-detection-flush';
70
- const PROP_ALIASES = new Map([
71
- ['id', 'nativeID'],
72
- ['symbioteStyle', 'style'],
73
- ]);
74
- function aliasedPropName(name) {
75
- return PROP_ALIASES.get(name) ?? name;
76
- }
35
+ import { createCallbackWrapper, flushViewFor, isWrappableCallback, } from '../change-detection-flush';
77
36
  // The app callbacks an engine behavior READS BACK inside the same microtask turn, as an Angular
78
37
  // `(event)` binding — `valueChange` is handled in `listen` on its own, since it also needs the field
79
38
  // unwrapped. Zoneless change detection is a macrotask, so without a flush the behavior reads the
@@ -84,6 +43,42 @@ function aliasedPropName(name) {
84
43
  // wrapped by `SymbioteElement` itself, which reaches every one of them rather than a named three,
85
44
  // and adds the `markForCheck` a prop callback needs and an event binding gets from Angular.
86
45
  const READ_BACK_EVENTS = new Set(['refresh']);
46
+ // How many distinct style objects the renderer keeps to hand back by identity. A screen's styles are
47
+ // a handful; the bench row has four. See `publishedStyles`.
48
+ const STYLE_CACHE = 16;
49
+ // `listen(VALUE_CHANGE_EVENT)` can be called TWICE for the SAME logical `(valueChange)`/`[(value)]`
50
+ // binding on a MATCHED element — Angular's own compiled output-binding codegen wires one listener,
51
+ // `ValueChangeElement.ngOnInit`'s manual bridge wires a second (`lowered-two-way-value.test.ts`,
52
+ // "delivers a bound handler exactly once per change"). Those two must DEDUPE (last one replaces,
53
+ // not composes — firing both double-delivers the identical value). A genuinely different explicit
54
+ // `[onValueChange]` app-level handler (routed here via `ngOnChanges`'s generic per-input loop) must
55
+ // instead be PRESERVED and called alongside the bridge, or it silently stops firing the moment
56
+ // `[(value)]`/`(valueChange)` is also bound. This set is what tells the two cases apart.
57
+ const bridgedValueChangeHandlers = new WeakSet();
58
+ // The genuinely explicit `[onValueChange]` app handler for a node, if one is bound — read LIVE by
59
+ // the composed forward function on every event rather than captured once at compose time. That is
60
+ // what makes handler installation order-independent: `ngOnChanges` (the explicit handler) and the
61
+ // two `listen(VALUE_CHANGE_EVENT)` calls (the bridge, see below) can happen in any interleaving —
62
+ // at initial mount OR on a later `[onValueChange]` rebind (`ngOnChanges` again, same code path,
63
+ // routed through `setProperty`) — without one silently overwriting the other's closure.
64
+ const explicitValueChangeHandlers = new WeakMap();
65
+ function composeValueChangeHandler(target, bridgeCallback) {
66
+ const forwardValue = withChangeDetection(target, (event) => {
67
+ explicitValueChangeHandlers.get(target)?.(event);
68
+ if (isSymbioteEvent(event)) {
69
+ if ('text' in event)
70
+ return bridgeCallback(event.text);
71
+ if ('value' in event)
72
+ return bridgeCallback(event.value);
73
+ }
74
+ return bridgeCallback(event);
75
+ });
76
+ bridgedValueChangeHandlers.add(forwardValue);
77
+ return forwardValue;
78
+ }
79
+ function isReadBackListener(value) {
80
+ return typeof value === 'function';
81
+ }
87
82
  function withChangeDetection(node, listener) {
88
83
  return (event) => {
89
84
  const result = listener(event);
@@ -112,8 +107,8 @@ function describeHost(node) {
112
107
  return 'surface';
113
108
  const anchorId = anchorDebugIds.get(node);
114
109
  return anchorId !== undefined
115
- ? `${node.component}#${anchorId}`
116
- : node.component;
110
+ ? `${componentOf(node)}#${anchorId}`
111
+ : componentOf(node);
117
112
  }
118
113
  // A hand-written tag ngtsc accepts must contain a HYPHEN, so the six dashless intrinsics
119
114
  // (`view`/`text`/`pressable`/`image`/`switch`/`modal`) need a second spelling that has one.
@@ -154,23 +149,144 @@ const PRIMITIVE_SELECTOR_ALIAS = {
154
149
  // stray RCTRawText would paint). Angular's ɵɵtext only ever lands text inside a <text>,
155
150
  // but guard anyway for parity with the Vue adapter and to fail loudly on a bad template.
156
151
  function assertTextPlacement(child, parent) {
157
- if (isRawText(child) && (isSurface(parent) || !parent.isText)) {
158
- throw new Error(`Text string "${String(child.props.text)}" must be rendered inside a <text>`);
152
+ if (isRawText(child) && (isSurface(parent) || !isTextContainer(parent))) {
153
+ throw new Error(`Text string "${textOf(child) ?? ''}" must be rendered inside a <text>`);
159
154
  }
160
155
  }
161
156
  // One renderer per mounted surface. Every mutation asks the surface to (microtask-
162
157
  // coalesced) recommit — the same seam Vue uses; a burst of Angular change-detection
163
158
  // mutations collapses into one completeRoot.
164
159
  export class SymbioteRenderer {
165
- surface;
166
- data = {};
167
- // Angular calls destroyNode per-node only when this is non-null; teardown happens in
168
- // render.ts (unmount), so per-node cleanup is a no-op.
169
- destroyNode = null;
170
160
  constructor(surface) {
171
161
  this.surface = surface;
162
+ this.data = {};
163
+ // Angular calls destroyNode per-node only when this is non-null; teardown happens in
164
+ // render.ts (unmount), so per-node cleanup is a no-op.
165
+ this.destroyNode = null;
166
+ this.pendingStyle = {};
167
+ /**
168
+ * Style objects this renderer has already published, so a list of identical rows sends ONE.
169
+ *
170
+ * The intern table keys by IDENTITY, so a fresh object per node is a fresh entry per node and the
171
+ * host converts every entry across JSI. Measured on the bench arm: `values` 7 001 against Vue's
172
+ * 3 004 for the identical tree, with `convert` 19.5 ms against 0.6 — and the gap is the KIND
173
+ * rather than the count, ~4 000 of Angular's being objects that convert recursively where Vue's
174
+ * are scalars plus four hoisted styles every row shares.
175
+ *
176
+ * Angular cannot share them on its own: `ɵɵstyleMap` hands over KEYS, so the object is this
177
+ * renderer's own construction. Recognising one it has already built is what puts an Angular app
178
+ * back on the footing of a framework whose author hoisted the constant.
179
+ *
180
+ * SMALL AND MRU. A screen has a handful of distinct styles and re-publishes them thousands of
181
+ * times, so a hit is almost always at the front; a miss costs at most `STYLE_CACHE` shallow
182
+ * compares, which is nothing beside the conversion it saves. An app with more distinct styles than
183
+ * this simply stops sharing — it never stops being correct.
184
+ */
185
+ this.publishedStyles = [];
186
+ // Ivy compiles every class= / [class.foo] / [ngClass] form down to per-token addClass/
187
+ // removeClass calls (never a single setAttribute('class', ...) call), so a per-node token set
188
+ // is accumulated here and re-joined into one string on every change, then handed to
189
+ // routeProp('class', ...) exactly like Vue's template `class="..."` and React's JSX
190
+ // `className="..."` — all three resolve through the SAME centralized class+style merge in
191
+ // core/engine/src/node.ts, so a class registered via the SFC/CSS-Modules style compiler
192
+ // resolves identically regardless of adapter.
193
+ this.classTokens = new WeakMap();
194
+ // The OTHER class source: a whole string written as a prop. Kept apart from the token set rather
195
+ // than merged into it, because the two are replaced independently — a new `[class]` value
196
+ // replaces this string entirely while leaving every `[class.foo]` token standing.
197
+ this.classStrings = new WeakMap();
198
+ this.callbackWrappers = new WeakMap();
199
+ // A READ AND A COMMIT BOTH ARRIVE FROM ELSEWHERE, so the renderer cannot close the run on its
200
+ // own: a turn whose last act is a style change has no next call to close it, and the commit
201
+ // would paint the node without it. `flushOps` is the one door in front of every drain.
202
+ this.releaseBeforeFlush = registerBeforeFlush(() => this.flushStyling());
203
+ }
204
+ destroy() {
205
+ this.flushStyling();
206
+ this.releaseBeforeFlush();
207
+ }
208
+ /**
209
+ * Publish whatever the styling run is holding. Idempotent, and free when it holds nothing.
210
+ *
211
+ * IT DOES NOT REQUEST A COMMIT, and that is not an omission — the request is made when the style
212
+ * is ACCUMULATED, exactly where it was made before the run existed. This runs from inside
213
+ * `flushOps`, which the commit itself calls first, so asking there schedules a SECOND commit whose
214
+ * tree is already current: a full `completeRoot` plus a Yoga pass for nothing.
215
+ *
216
+ * Measured, because it did not look like a cost. The bench arm's `select` fell 17.1 -> 3.8 ms and
217
+ * its `remove` rose 7.1 -> 18.2 in the same runs — the extra commit lands in whichever step's
218
+ * microtask happens to run it, so the work had MOVED between steps rather than gone. Two rows
219
+ * moving by the same amount in opposite directions is what that always looks like.
220
+ */
221
+ flushStyling() {
222
+ const styled = this.pendingStyleNode;
223
+ if (styled !== undefined) {
224
+ const style = this.pendingStyle;
225
+ this.pendingStyleNode = undefined;
226
+ this.pendingStyle = {};
227
+ routeProp(styled, 'style', this.canonicalStyle(style));
228
+ }
229
+ const classed = this.pendingClassNode;
230
+ if (classed !== undefined) {
231
+ this.pendingClassNode = undefined;
232
+ routeProp(classed, 'class', this.classStringFor(classed));
233
+ }
234
+ }
235
+ /**
236
+ * The union of a node's two class sources, or `undefined` when it has none.
237
+ *
238
+ * A node can be told its classes BOTH ways in one pass: Ivy compiles `[class.foo]`, `[ngClass]`
239
+ * and a static `class=` down to per-token `addClass`/`removeClass`, while a whole-string `class`
240
+ * arrives at `setProperty` — which is the shape Angular uses when a directive declares `class` as
241
+ * an input and the styling binding is shadowed into it (`setShadowStylingInputFlags`,
242
+ * `view/directives.ts`). Publishing either one alone erases the other, and the DOM renderer this
243
+ * mirrors has no such problem because `classList` accumulates for it.
244
+ */
245
+ classStringFor(el) {
246
+ const tokens = this.classTokens.get(el);
247
+ const whole = this.classStrings.get(el);
248
+ if (whole === undefined) {
249
+ return tokens !== undefined && tokens.size > 0
250
+ ? [...tokens].join(' ')
251
+ : undefined;
252
+ }
253
+ if (tokens === undefined || tokens.size === 0) {
254
+ return whole.length > 0 ? whole : undefined;
255
+ }
256
+ const union = new Set(whole.split(/\s+/u).filter(token => token.length > 0));
257
+ for (const token of tokens)
258
+ union.add(token);
259
+ return union.size > 0 ? [...union].join(' ') : undefined;
260
+ }
261
+ /** A published object equal to this one, or this one — which then becomes the published copy. */
262
+ canonicalStyle(style) {
263
+ for (let at = 0; at < this.publishedStyles.length; at += 1) {
264
+ const known = this.publishedStyles[at];
265
+ if (!isSameShallowStyle(style, known))
266
+ continue;
267
+ if (at > 0) {
268
+ this.publishedStyles.splice(at, 1);
269
+ this.publishedStyles.unshift(known);
270
+ }
271
+ return known;
272
+ }
273
+ this.publishedStyles.unshift(style);
274
+ if (this.publishedStyles.length > STYLE_CACHE)
275
+ this.publishedStyles.pop();
276
+ return style;
277
+ }
278
+ /** The accumulator for this node's style run, opening one (and closing any other) if needed. */
279
+ openStyleRun(el) {
280
+ if (this.pendingStyleNode === el)
281
+ return this.pendingStyle;
282
+ this.flushStyling();
283
+ // Seeded from what is STANDING, because Angular sends only the keys that changed — an update
284
+ // that moves one key must not drop the rest.
285
+ const current = getExplicitStyle(el);
286
+ this.pendingStyle = isRecord(current) ? { ...current } : {};
287
+ this.pendingStyleNode = el;
288
+ return this.pendingStyle;
172
289
  }
173
- destroy() { }
174
290
  createElement(name) {
175
291
  // `name` is the component's host tag — a symbiote intrinsic (`view`,
176
292
  // `text`, …), a public ergonomic alias (`View`, `Text`), or a raw Fabric view
@@ -207,8 +323,6 @@ export class SymbioteRenderer {
207
323
  // (e.g. 'RCTView') and any registered behavior silently never attaches — Vue's renderer
208
324
  // already passes this correctly (`createElement(descriptor.component, descriptor.isText, type)`).
209
325
  const node = createElement(descriptor.component, descriptor.isText, engineName);
210
- if (descriptor.isText)
211
- seedTextDefaults(node);
212
326
  if (isDebug()) {
213
327
  dlog(`angular createElement ${name} -> ${descriptor.component}`);
214
328
  }
@@ -283,13 +397,21 @@ export class SymbioteRenderer {
283
397
  // Detach from the child's own retained parent (a top-level node lives in
284
398
  // surface.children with no parent). Angular's `parent` arg is ignored in favor of the
285
399
  // authoritative link, mirroring the Vue adapter's remove.
400
+ // Angular tears down a root view by removing its HOST, which here is the surface itself. There
401
+ // is nothing above it to detach from — the old retained tree absorbed the call (`indexOf` of a
402
+ // node that is not in the list is -1), and the mutation buffer cannot: it would record a remove
403
+ // naming the surface as a child of its own node.
404
+ if (isSurface(oldChild))
405
+ return;
286
406
  countAngular('nodesRemoved');
287
407
  if (isDebug()) {
288
408
  const angularParent = _parent !== null ? describeHost(_parent) : 'null';
289
- const retainedParent = oldChild.parent !== undefined ? describeHost(oldChild.parent) : 'none';
409
+ const retainedParent = parentOf(oldChild) !== undefined
410
+ ? describeHost(parentOf(oldChild))
411
+ : 'none';
290
412
  dlog(`Angular renderer removeChild angularParent=${angularParent} retainedParent=${retainedParent} child=${describeHost(oldChild)}`);
291
413
  }
292
- const parent = oldChild.parent;
414
+ const parent = parentOf(oldChild);
293
415
  if (parent !== undefined)
294
416
  removeChild(parent, oldChild);
295
417
  else
@@ -310,12 +432,12 @@ export class SymbioteRenderer {
310
432
  // does `inject(ViewContainerRef)`, e.g. VListOutletDirective) forwards this null straight into
311
433
  // insertBefore without checking it; without that guard it crashed on-device.
312
434
  parentNode(node) {
313
- return node.parent ?? null;
435
+ return parentOf(node) ?? null;
314
436
  }
315
437
  nextSibling(node) {
316
- const siblings = node.parent !== undefined ? node.parent.children : this.surface.children;
317
- const index = siblings.indexOf(node);
318
- return index >= 0 ? (siblings[index + 1] ?? null) : null;
438
+ // `?? null` because Renderer2 types the miss as null; the engine answers undefined
439
+ // uniformly and owns the top-level fallback through the surface it is handed.
440
+ return nextSiblingOf(node, this.surface) ?? null;
319
441
  }
320
442
  // locateHostElement always routes createComponent's `hostElement` THROUGH here as
321
443
  // `selectorOrNode` (Angular's own core.mjs) — it is never bypassed just because a real
@@ -324,31 +446,27 @@ export class SymbioteRenderer {
324
446
  selectRootElement(selectorOrNode) {
325
447
  return typeof selectorOrNode === 'string' ? this.surface : selectorOrNode;
326
448
  }
449
+ // The three prop writers below close the styling run FIRST. A read or a commit would do it through
450
+ // `registerBeforeFlush`, but neither happens here: this is one prop write landing on the same node
451
+ // whose `style` or `class` is still held, and a run published afterwards would overwrite it.
327
452
  setAttribute(el, name, value) {
328
453
  if (isSurface(el))
329
454
  return;
455
+ this.flushStyling();
330
456
  countAngular('rendererWrites');
331
457
  noteAngularWrite(name);
332
- routeProp(el, aliasedPropName(name), value);
458
+ routeProp(el, name, value);
333
459
  this.surface.requestCommit();
334
460
  }
335
461
  removeAttribute(el, name) {
336
462
  if (isSurface(el))
337
463
  return;
464
+ this.flushStyling();
338
465
  countAngular('rendererWrites');
339
466
  noteAngularWrite(name);
340
- const aliased = aliasedPropName(name);
341
- routeProp(el, aliased, textDefaultFor(el, aliased));
467
+ routeProp(el, name, undefined);
342
468
  this.surface.requestCommit();
343
469
  }
344
- // Ivy compiles every class= / [class.foo] / [ngClass] form down to per-token addClass/
345
- // removeClass calls (never a single setAttribute('class', ...) call), so a per-node token set
346
- // is accumulated here and re-joined into one string on every change, then handed to
347
- // routeProp('class', ...) exactly like Vue's template `class="..."` and React's JSX
348
- // `className="..."` — all three resolve through the SAME centralized class+style merge in
349
- // core/engine/src/node.ts, so a class registered via the SFC/CSS-Modules style compiler
350
- // resolves identically regardless of adapter.
351
- classTokens = new WeakMap();
352
470
  addClass(el, name) {
353
471
  if (isSurface(el))
354
472
  return;
@@ -357,7 +475,7 @@ export class SymbioteRenderer {
357
475
  const tokens = this.classTokens.get(el) ?? new Set();
358
476
  tokens.add(name);
359
477
  this.classTokens.set(el, tokens);
360
- routeProp(el, 'class', [...tokens].join(' '));
478
+ this.openClassRun(el);
361
479
  this.surface.requestCommit();
362
480
  }
363
481
  removeClass(el, name) {
@@ -369,9 +487,17 @@ export class SymbioteRenderer {
369
487
  countAngular('rendererWrites');
370
488
  noteAngularWrite('class');
371
489
  tokens.delete(name);
372
- routeProp(el, 'class', tokens.size > 0 ? [...tokens].join(' ') : undefined);
490
+ this.openClassRun(el);
373
491
  this.surface.requestCommit();
374
492
  }
493
+ // The token SET is the accumulator here — `addClass` has already put the token in it — so this
494
+ // only has to remember whose run is open. Closing it re-joins the set once.
495
+ openClassRun(el) {
496
+ if (this.pendingClassNode === el)
497
+ return;
498
+ this.flushStyling();
499
+ this.pendingClassNode = el;
500
+ }
375
501
  // Angular decomposes a [style] binding into per-key setStyle calls (ɵɵstyleMap). RN wants
376
502
  // the whole style object as one `style` prop, so merge each key into it — onto the explicit
377
503
  // style half tracked by routeProp's centralized class+style merge (core/engine/src/node.ts),
@@ -381,22 +507,23 @@ export class SymbioteRenderer {
381
507
  if (isSurface(el))
382
508
  return;
383
509
  countAngular('rendererWrites');
384
- noteAngularWrite(`style.${style}`);
385
- const current = getExplicitStyle(el);
386
- const base = isRecord(current) ? current : {};
387
- routeProp(el, 'style', { ...base, [style]: value });
510
+ noteAngularStyleWrite(style);
511
+ this.openStyleRun(el)[style] = value;
388
512
  this.surface.requestCommit();
389
513
  }
390
514
  removeStyle(el, style) {
391
515
  if (isSurface(el))
392
516
  return;
393
- const current = getExplicitStyle(el);
394
- if (!isRecord(current))
517
+ // A remove on a node with no style at all is Angular clearing a binding it never set. Opening a
518
+ // run for it would publish an empty style onto a node that had none, which the old early return
519
+ // was there to avoid.
520
+ if (this.pendingStyleNode !== el && !isRecord(getExplicitStyle(el)))
395
521
  return;
396
522
  countAngular('rendererWrites');
397
- noteAngularWrite(`style.${style}`);
398
- const { [style]: _removed, ...rest } = current;
399
- routeProp(el, 'style', rest);
523
+ noteAngularStyleWrite(style);
524
+ const run = this.openStyleRun(el);
525
+ // The accumulator is this renderer's own object, never the node's — see `openStyleRun`.
526
+ delete run[style];
400
527
  this.surface.requestCommit();
401
528
  }
402
529
  // [prop]="x" bindings. routeProp makes the prop-vs-event decision from the node's
@@ -406,10 +533,63 @@ export class SymbioteRenderer {
406
533
  return;
407
534
  countAngular('rendererWrites');
408
535
  noteAngularWrite(name);
409
- const aliased = aliasedPropName(name);
410
- routeProp(el, aliased, value === undefined ? textDefaultFor(el, aliased) : value);
536
+ // A WHOLE CLASS STRING IS NOT AN ORDINARY PROP: it is one of two sources the node's class list
537
+ // is built from, and writing it straight through would erase every `[class.foo]` token the
538
+ // other one put there. It joins the class run instead, exactly as `addClass` does.
539
+ if (name === 'class') {
540
+ this.openClassRun(el);
541
+ this.classStrings.set(el, typeof value === 'string' ? value : '');
542
+ this.surface.requestCommit();
543
+ return;
544
+ }
545
+ // `ngOnChanges` routes every `[onValueChange]` write here (both the initial bind and any
546
+ // later rebind to a new function reference) — never through `listen()`. When the write is a
547
+ // genuinely explicit app handler (not a prior bridge's own forward function), update the live
548
+ // slot `composeValueChangeHandler`'s forward function reads on every event; when a bridge is
549
+ // already installed on this node, that's ALL that's needed — the installed forward function
550
+ // picks the new handler up on its own, no re-route. Without this, a rebind after mount would
551
+ // silently kill the `[(value)]` two-way sync the moment it overwrote the composed prop raw.
552
+ if (name === VALUE_CHANGE_PROP) {
553
+ if (isReadBackListener(value) && !bridgedValueChangeHandlers.has(value)) {
554
+ explicitValueChangeHandlers.set(el, value);
555
+ }
556
+ else {
557
+ explicitValueChangeHandlers.delete(el);
558
+ }
559
+ const current = propOf(el, VALUE_CHANGE_PROP);
560
+ const bridgeActive = isReadBackListener(current) && bridgedValueChangeHandlers.has(current);
561
+ if (bridgeActive) {
562
+ this.surface.requestCommit();
563
+ return;
564
+ }
565
+ }
566
+ this.flushStyling();
567
+ routeProp(el, name, this.wrapCallback(el, name, value));
411
568
  this.surface.requestCommit();
412
569
  }
570
+ // AN `on*` PROP IS CALLED BY THE ENGINE, so Angular is told nothing and a plain field mutation
571
+ // inside the app's handler dirties no view — the "pan readout stuck at dx 0" bug
572
+ // `change-detection-flush.ts` records. It used to be wrapped by `SymbioteElement.ngOnChanges`,
573
+ // which is the wrong place now that most element directives are withheld from runtime matching
574
+ // (`../runtime-matching`): a withheld tag's binding reaches the renderer DIRECTLY through
575
+ // `ɵɵproperty` and never passes through a directive at all.
576
+ //
577
+ // Here it is reached by both paths and by every adapter surface — an element binding, a composed
578
+ // component's flat bag, an imperative write — which is a better home than the one it left.
579
+ //
580
+ // The cache is per NODE rather than per caller, because a node is what both paths agree on, and the
581
+ // wrapper is built on the first callback a node receives: the overwhelming majority of tags carry
582
+ // no `on*` prop, and an eager one would be a closure and a `WeakMap` per element for nothing.
583
+ wrapCallback(node, name, value) {
584
+ if (!isWrappableCallback(name, value))
585
+ return value;
586
+ let wrapper = this.callbackWrappers.get(node);
587
+ if (wrapper === undefined) {
588
+ wrapper = createCallbackWrapper(node);
589
+ this.callbackWrappers.set(node, wrapper);
590
+ }
591
+ return wrapper(name, value);
592
+ }
413
593
  setValue(node, value) {
414
594
  countAngular('rendererWrites');
415
595
  noteAngularWrite('#text');
@@ -429,11 +609,11 @@ export class SymbioteRenderer {
429
609
  if (!isSymbioteNode(target))
430
610
  return () => { };
431
611
  // `[(value)]` desugars to `(valueChange)`, which is the spelling every Angular template writes
432
- // for a Switch or a TextInput. On the COMPONENT path it is an `@Output()` the wrapper derives
433
- // from the raw `change` payload; on a LOWERED element there is no component, and registering
434
- // `valueChange` as an engine event would wait forever for a Fabric event of that name.
612
+ // for a Switch or a TextInput. It used to be an `@Output()` a wrapper derived from the raw
613
+ // `change` payload; a tag has no component, and registering `valueChange` as an engine event
614
+ // would wait forever for a Fabric event of that name.
435
615
  //
436
- // The lowered path already carries the same fold under RN's own spelling: both behaviors call
616
+ // The same fold already exists under RN's own spelling: both behaviors call
437
617
  // `node.props.onValueChange(event)` — a plain function PROP, not an event
438
618
  // (`behaviors/switch.ts`, `behaviors/text-input.ts`), with `text`/`value` carried as a FIELD on
439
619
  // the event object rather than a second argument (Svelte forces every individual `on*` prop
@@ -442,17 +622,20 @@ export class SymbioteRenderer {
442
622
  // unwrap that field back to a bare value before handing it to Angular's callback, or `text =
443
623
  // $event` would assign the whole event object instead of the typed string/boolean.
444
624
  if (eventName === VALUE_CHANGE_EVENT) {
445
- const forwardValue = withChangeDetection(target, (event) => {
446
- if (isSymbioteEvent(event)) {
447
- if ('text' in event)
448
- return callback(event.text);
449
- if ('value' in event)
450
- return callback(event.value);
451
- }
452
- return callback(event);
453
- });
454
- routeProp(target, VALUE_CHANGE_PROP, forwardValue);
455
- return () => routeProp(target, VALUE_CHANGE_PROP, undefined);
625
+ // `ngOnChanges` may already have written an explicit `[onValueChange]` binding onto this
626
+ // same prop key via `routeProp` (`setProperty`'s generic reflect loop) — seed the live
627
+ // explicit-handler slot from it, unless what's there is a PRIOR bridge's own forward
628
+ // function (the Angular-vs-manual-bridge double-registration this branch already dedupes,
629
+ // `listen(VALUE_CHANGE_EVENT)` fires twice per element — see `bridgedValueChangeHandlers`).
630
+ // Composing with the explicit handler (never replacing it) is the RN-parity behavior: RN's
631
+ // `onChange` always fires regardless of whether `value` is controlled.
632
+ const currentOnValueChange = propOf(target, VALUE_CHANGE_PROP);
633
+ if (isReadBackListener(currentOnValueChange) &&
634
+ !bridgedValueChangeHandlers.has(currentOnValueChange)) {
635
+ explicitValueChangeHandlers.set(target, currentOnValueChange);
636
+ }
637
+ routeProp(target, VALUE_CHANGE_PROP, composeValueChangeHandler(target, callback));
638
+ return () => routeProp(target, VALUE_CHANGE_PROP, explicitValueChangeHandlers.get(target));
456
639
  }
457
640
  const listener = READ_BACK_EVENTS.has(eventName)
458
641
  ? withChangeDetection(target, callback)
@@ -465,8 +648,6 @@ export class SymbioteRenderer {
465
648
  // surface-bound renderer for every component (begin/end commit-coalescing is unnecessary —
466
649
  // requestCommit already microtask-coalesces).
467
650
  export class SymbioteRendererFactory {
468
- surface;
469
- renderer;
470
651
  constructor(surface) {
471
652
  this.surface = surface;
472
653
  }