@symbiote-native/angular 0.8.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 (121) hide show
  1. package/babel-lower-host-primitives.cjs +636 -0
  2. package/babel-register-composed.cjs +21 -3
  3. package/build/angular/anchor-host-registry.js +3 -1
  4. package/build/angular/anchor-host-registry.js.map +1 -1
  5. package/build/angular/components/button.d.ts +3 -0
  6. package/build/angular/components/button.js +10 -1
  7. package/build/angular/components/button.js.map +1 -1
  8. package/build/angular/components/image/index.android.js +0 -8
  9. package/build/angular/components/image/index.android.js.map +1 -1
  10. package/build/angular/components/image/index.ios.js +0 -8
  11. package/build/angular/components/image/index.ios.js.map +1 -1
  12. package/build/angular/components/image/shared.d.ts +4 -4
  13. package/build/angular/components/image/shared.js +33 -27
  14. package/build/angular/components/image/shared.js.map +1 -1
  15. package/build/angular/components/image-background.d.ts +3 -0
  16. package/build/angular/components/image-background.js +10 -1
  17. package/build/angular/components/image-background.js.map +1 -1
  18. package/build/angular/components/input-accessory-view/index.d.ts +2 -1
  19. package/build/angular/components/input-accessory-view/index.js +11 -14
  20. package/build/angular/components/input-accessory-view/index.js.map +1 -1
  21. package/build/angular/components/keyboard-avoiding-view/index.d.ts +2 -1
  22. package/build/angular/components/keyboard-avoiding-view/index.js +17 -10
  23. package/build/angular/components/keyboard-avoiding-view/index.js.map +1 -1
  24. package/build/angular/components/modal/index.d.ts +1 -0
  25. package/build/angular/components/modal/index.js +14 -8
  26. package/build/angular/components/modal/index.js.map +1 -1
  27. package/build/angular/components/pressable/index.d.ts +9 -4
  28. package/build/angular/components/pressable/index.js +55 -14
  29. package/build/angular/components/pressable/index.js.map +1 -1
  30. package/build/angular/components/refresh-control/index.d.ts +2 -1
  31. package/build/angular/components/refresh-control/index.js +10 -8
  32. package/build/angular/components/refresh-control/index.js.map +1 -1
  33. package/build/angular/components/safe-area-view/index.d.ts +3 -1
  34. package/build/angular/components/safe-area-view/index.js +19 -19
  35. package/build/angular/components/safe-area-view/index.js.map +1 -1
  36. package/build/angular/components/scroll-view/shared.d.ts +2 -0
  37. package/build/angular/components/scroll-view/shared.js +20 -4
  38. package/build/angular/components/scroll-view/shared.js.map +1 -1
  39. package/build/angular/components/section-list/index.d.ts +3 -0
  40. package/build/angular/components/section-list/index.js +10 -1
  41. package/build/angular/components/section-list/index.js.map +1 -1
  42. package/build/angular/components/switch/index.android.js +6 -6
  43. package/build/angular/components/switch/index.android.js.map +1 -1
  44. package/build/angular/components/switch/index.ios.js +6 -6
  45. package/build/angular/components/switch/index.ios.js.map +1 -1
  46. package/build/angular/components/text-input.d.ts +7 -1
  47. package/build/angular/components/text-input.js +54 -29
  48. package/build/angular/components/text-input.js.map +1 -1
  49. package/build/angular/components/touchable/index.d.ts +7 -0
  50. package/build/angular/components/touchable/index.js +34 -3
  51. package/build/angular/components/touchable/index.js.map +1 -1
  52. package/build/angular/components/touchable-native-feedback/index.d.ts +2 -1
  53. package/build/angular/components/touchable-native-feedback/index.js +17 -15
  54. package/build/angular/components/touchable-native-feedback/index.js.map +1 -1
  55. package/build/angular/components/virtualized-list/index.d.ts +3 -0
  56. package/build/angular/components/virtualized-list/index.js +10 -1
  57. package/build/angular/components/virtualized-list/index.js.map +1 -1
  58. package/build/angular/components/virtualized-section-list/index.d.ts +3 -0
  59. package/build/angular/components/virtualized-section-list/index.js +10 -1
  60. package/build/angular/components/virtualized-section-list/index.js.map +1 -1
  61. package/build/angular/components.d.ts +1 -1
  62. package/build/angular/components.js +1 -1
  63. package/build/angular/components.js.map +1 -1
  64. package/build/angular/create-portal/index.d.ts +11 -2
  65. package/build/angular/create-portal/index.js +37 -11
  66. package/build/angular/create-portal/index.js.map +1 -1
  67. package/build/angular/gate-demand.d.ts +37 -0
  68. package/build/angular/gate-demand.js +84 -0
  69. package/build/angular/gate-demand.js.map +1 -0
  70. package/build/angular/index.d.ts +1 -0
  71. package/build/angular/index.js +1 -0
  72. package/build/angular/index.js.map +1 -1
  73. package/build/angular/modules/animated/create-animated-component.d.ts +1 -0
  74. package/build/angular/modules/animated/create-animated-component.js +14 -9
  75. package/build/angular/modules/animated/create-animated-component.js.map +1 -1
  76. package/build/angular/primitives/index.d.ts +13 -1
  77. package/build/angular/primitives/index.js +43 -1
  78. package/build/angular/primitives/index.js.map +1 -1
  79. package/build/angular/register.d.ts +1 -0
  80. package/build/angular/register.js +35 -0
  81. package/build/angular/register.js.map +1 -0
  82. package/build/angular/render/index.js +3 -1
  83. package/build/angular/render/index.js.map +1 -1
  84. package/build/angular/renderer/index.js +120 -19
  85. package/build/angular/renderer/index.js.map +1 -1
  86. package/build/angular/state-style.d.ts +9 -0
  87. package/build/angular/state-style.js +39 -0
  88. package/build/angular/state-style.js.map +1 -0
  89. package/metro-transformer.cjs +69 -0
  90. package/package.json +27 -8
  91. package/src/anchor-host-registry.ts +3 -1
  92. package/src/components/button.ts +17 -0
  93. package/src/components/image/index.android.ts +0 -4
  94. package/src/components/image/index.ios.ts +0 -4
  95. package/src/components/image/shared.ts +52 -26
  96. package/src/components/image-background.ts +18 -0
  97. package/src/components/input-accessory-view/index.ts +14 -8
  98. package/src/components/keyboard-avoiding-view/index.ts +21 -7
  99. package/src/components/modal/index.ts +17 -4
  100. package/src/components/pressable/index.ts +70 -10
  101. package/src/components/refresh-control/index.ts +14 -5
  102. package/src/components/safe-area-view/index.ts +28 -12
  103. package/src/components/scroll-view/shared.ts +31 -4
  104. package/src/components/section-list/index.ts +18 -0
  105. package/src/components/switch/index.android.ts +4 -4
  106. package/src/components/switch/index.ios.ts +4 -4
  107. package/src/components/text-input.ts +73 -17
  108. package/src/components/touchable/index.ts +43 -0
  109. package/src/components/touchable-native-feedback/index.ts +18 -8
  110. package/src/components/virtualized-list/index.ts +18 -0
  111. package/src/components/virtualized-section-list/index.ts +18 -0
  112. package/src/components.ts +2 -0
  113. package/src/create-portal/index.ts +39 -9
  114. package/src/gate-demand.ts +109 -0
  115. package/src/index.ts +2 -0
  116. package/src/modules/animated/create-animated-component.ts +23 -4
  117. package/src/primitives/index.ts +28 -1
  118. package/src/register.ts +43 -0
  119. package/src/render/index.ts +3 -1
  120. package/src/renderer/index.ts +144 -34
  121. package/src/state-style.ts +39 -0
@@ -0,0 +1,109 @@
1
+ // Whether anything downstream actually wants a gated accessibility event, passed from a wrapper
2
+ // component to the inner component that owns the gate.
3
+ //
4
+ // THE PROBLEM. Six Fabric events fire only if a BOOLEAN prop reaches the payload
5
+ // (`.claude/rules/fabric-boolean-event-gates.md`), and the flag follows the VALUE: a handler
6
+ // present means `true`, absent means the key is deleted. Every component that owns such a gate
7
+ // therefore writes it only while its own `@Output()` is `.observed`.
8
+ //
9
+ // Angular has no conditional template binding, so a wrapper rendering an inner component must write
10
+ // `(accessibilityAction)="accessibilityAction.emit($event)"` unconditionally — which subscribes,
11
+ // which makes the inner's `.observed` permanently true, which lights the flag on EVERY instance
12
+ // whether or not any app listens. Fixing the inner component does nothing: the wrapper is the
13
+ // subscriber. The chain is up to four deep here (SectionList -> VirtualizedSectionList ->
14
+ // VirtualizedList -> ScrollView), and every middle link has the same problem for the same reason.
15
+ //
16
+ // WHY THIS IS DI AND NOT AN `@Input`. An input is public API. An app could write
17
+ // `[gatedEvents]="…"` on a `Pressable` and silently switch its accessibility events OFF — a change
18
+ // nothing in this repo could detect and nothing on screen would show. A `viewProviders` entry is
19
+ // not part of the component's binding surface, so an app cannot reach it by accident.
20
+ //
21
+ // WHY `viewProviders` AND NOT `providers` — measured, both arms, 2026-09-02:
22
+ //
23
+ // viewProviders the wrapper's OWN template sees it, projected content does NOT
24
+ // providers both see it
25
+ //
26
+ // The second half is what matters: an app writing `<TouchableOpacity><Pressable/></TouchableOpacity>`
27
+ // projects its own Pressable INTO the wrapper, and that Pressable must behave exactly as it would
28
+ // standing alone. Under `providers` it would inherit the wrapper's demand and the debt would just
29
+ // move one level down.
30
+ //
31
+ // Verified on both rungs — JIT through `mount()`, and again by executing the artifact from a real
32
+ // `ngc --compilationMode partial` + `babel-linker.cjs` chain, because `viewProviders` compiles into
33
+ // the component definition (`ɵɵProvidersFeature`) and this repo has a recorded case of JIT and AOT
34
+ // disagreeing on a compiled binding (`.claude/rules/test-harness-false-greens.md` §21/§21a). Both
35
+ // arms reported the same four cells.
36
+ import {
37
+ EventEmitter,
38
+ InjectionToken,
39
+ forwardRef,
40
+ inject,
41
+ } from '@angular/core';
42
+ import type { Provider, Type } from '@angular/core';
43
+ import type { ISymbioteEvent } from '@symbiote-native/engine';
44
+
45
+ // The four `on*`-gated accessibility events every wrapper forwards. Not the full gate list from the
46
+ // engine: `layout` and `textLayout` are never forwarded wrapper-to-wrapper, and
47
+ // KeyboardAvoidingView READS its own `onLayout` internally, so gating that one on a subscriber
48
+ // would break inset-following (`.claude/rules/keyboard-avoiding-view-rn-contract.md`).
49
+ export const GATED_ACCESSIBILITY_EVENTS = [
50
+ 'accessibilityAction',
51
+ 'accessibilityTap',
52
+ 'magicTap',
53
+ 'accessibilityEscape',
54
+ ] as const;
55
+
56
+ export type IGatedAccessibilityEvent =
57
+ (typeof GATED_ACCESSIBILITY_EVENTS)[number];
58
+
59
+ export interface IGateDemand {
60
+ wantsGate(name: IGatedAccessibilityEvent): boolean;
61
+ }
62
+
63
+ export const GATE_DEMAND = new InjectionToken<IGateDemand>(
64
+ 'symbiote.gate-demand',
65
+ );
66
+
67
+ /**
68
+ * Declare a wrapper as the demand for everything in its own template.
69
+ *
70
+ * Takes a THUNK rather than the class: the entry sits in the decorator of the very class it names,
71
+ * so the reference has to be late.
72
+ */
73
+ export function provideGateDemand(get: () => Type<IGateDemand>): Provider {
74
+ return { provide: GATE_DEMAND, useExisting: forwardRef(get) };
75
+ }
76
+
77
+ /**
78
+ * For a component that OWNS a gate — the innermost one, which writes the flag.
79
+ */
80
+ export function injectGateDemand(): IGateDemand | null {
81
+ return inject(GATE_DEMAND, { optional: true });
82
+ }
83
+
84
+ /**
85
+ * For a component that PROVIDES demand and is itself wrapped.
86
+ *
87
+ * `skipSelf` is not optional here: a component resolves its own `viewProviders`, so without it the
88
+ * lookup reaches this class's own `useExisting` entry and Angular throws NG0200 at construction.
89
+ * Loud rather than silent, but the shape is easy to get wrong, which is why the two lookups are
90
+ * separate functions instead of one with a flag.
91
+ */
92
+ export function injectGateDemandAbove(): IGateDemand | null {
93
+ return inject(GATE_DEMAND, { optional: true, skipSelf: true });
94
+ }
95
+
96
+ /**
97
+ * The one expression both roles evaluate.
98
+ *
99
+ * A demand from above OVERRIDES the local emitter, and that override is the whole mechanism: a
100
+ * middle link's own emitter is `.observed` precisely because the level above bound it, so answering
101
+ * from it would report "wanted" for a chain nobody subscribed to.
102
+ */
103
+ export function gateWanted(
104
+ above: IGateDemand | null,
105
+ name: IGatedAccessibilityEvent,
106
+ own: EventEmitter<ISymbioteEvent>,
107
+ ): boolean {
108
+ return above !== null ? above.wantsGate(name) : own.observed;
109
+ }
package/src/index.ts CHANGED
@@ -5,6 +5,8 @@
5
5
  // SEAM SCAFFOLD: mount/unmount + the renderer seam + host intrinsic selectors. Full RN-like
6
6
  // composed components still flow through the shared @symbiote-native/components bridge.
7
7
 
8
+ import './register';
9
+
8
10
  export {
9
11
  ActivityIndicator,
10
12
  anchorHostStyle,
@@ -53,8 +53,10 @@ import {
53
53
  IMAGE_INPUTS,
54
54
  IMAGE_OUTPUTS,
55
55
  ImageBase,
56
+ isImageEventCallback,
56
57
  resolveImageProps,
57
58
  } from '../../components/image/shared';
59
+ import type { IGatedAccessibilityEvent } from '../../gate-demand';
58
60
  import { SectionList } from '../../components/section-list';
59
61
  import { AnimatedLeafBinder } from './animated-leaf-binder';
60
62
 
@@ -244,10 +246,6 @@ export class AnimatedText extends AnimatedComponentBase {}
244
246
  template: `
245
247
  <symbiote-image
246
248
  [symbioteHostProps]="animatedImageProps"
247
- (accessibilityAction)="handleAccessibilityAction($event)"
248
- (accessibilityTap)="handleAccessibilityTap($event)"
249
- (magicTap)="handleMagicTap($event)"
250
- (accessibilityEscape)="handleAccessibilityEscape($event)"
251
249
  (loadStart)="handleLoadStart($event)"
252
250
  (load)="handleLoad($event)"
253
251
  (loadEnd)="handleLoadEnd($event)"
@@ -283,6 +281,18 @@ export class AnimatedImage
283
281
  );
284
282
  private viewReady = false;
285
283
 
284
+ private gateInto(
285
+ props: Record<string, unknown>,
286
+ name: IGatedAccessibilityEvent,
287
+ key: string,
288
+ ): void {
289
+ const carried = props[key];
290
+ props[key] = this.gatedAccessibilityHandler(
291
+ name,
292
+ isImageEventCallback(carried) ? carried : undefined,
293
+ );
294
+ }
295
+
286
296
  get animatedImageProps(): Record<string, unknown> {
287
297
  const reduced = reduceProps(this.mergedProps());
288
298
  const passthroughStyle = readPassthroughStyle(
@@ -296,6 +306,15 @@ export class AnimatedImage
296
306
  }
297
307
  const resolved = resolveImageProps(reduced);
298
308
  resolved['style'] = [anchorHostStyle(this.elementRef), resolved['style']];
309
+ // The four gated accessibility events, which used to be template bindings on the host tag and
310
+ // therefore lit their Fabric flags on every AnimatedImage in the app
311
+ // (`.claude/rules/fabric-boolean-event-gates.md`). Routed through the base's gate instead, so
312
+ // both channels still reach the app — the `onX` callback carried in the animated prop bag, and
313
+ // the `@Output()` an app may bind on <AnimatedImage> (IMAGE_OUTPUTS declares all four).
314
+ this.gateInto(resolved, 'accessibilityAction', 'onAccessibilityAction');
315
+ this.gateInto(resolved, 'accessibilityTap', 'onAccessibilityTap');
316
+ this.gateInto(resolved, 'magicTap', 'onMagicTap');
317
+ this.gateInto(resolved, 'accessibilityEscape', 'onAccessibilityEscape');
299
318
  return resolved;
300
319
  }
301
320
 
@@ -32,7 +32,7 @@ export class ViewHost extends SymbiotePrimitiveHost {}
32
32
 
33
33
  /**
34
34
  * Text carries the two defaults RN's Text.js applies unconditionally (`ellipsizeMode ?? 'tail'`,
35
- * `allowFontScaling !== false`, Text.js:288-291). Without them native falls back to `clip` and a
35
+ * `allowFontScaling !== false`, Text.js:289 and :291). Without them native falls back to `clip` and a
36
36
  * clamped Text cuts mid-word with no ellipsis — device-observed on the other adapters 2026-08-19.
37
37
  *
38
38
  * They are declared as real `@Input()`s, unlike the pass-through props the base's comment
@@ -107,6 +107,9 @@ export class HorizontalScrollView extends SymbiotePrimitiveHost {}
107
107
  })
108
108
  export class HorizontalScrollContentView extends SymbiotePrimitiveHost {}
109
109
 
110
+ // The pair the LOWERED path commits — declared so the tag alphabet stays complete, though no
111
+ // Angular template renders them: this adapter has no lowering transform. The `-managed` pair below
112
+ // is what its own TextInput renders.
110
113
  @Component({
111
114
  selector: 'symbiote-text-input',
112
115
  standalone: true,
@@ -121,6 +124,20 @@ export class TextInputHost extends SymbiotePrimitiveHost {}
121
124
  })
122
125
  export class MultilineTextInputHost extends SymbiotePrimitiveHost {}
123
126
 
127
+ @Component({
128
+ selector: 'symbiote-text-input-managed',
129
+ standalone: true,
130
+ template: '<ng-content></ng-content>',
131
+ })
132
+ export class ManagedTextInputHost extends SymbiotePrimitiveHost {}
133
+
134
+ @Component({
135
+ selector: 'symbiote-text-input-multiline-managed',
136
+ standalone: true,
137
+ template: '<ng-content></ng-content>',
138
+ })
139
+ export class ManagedMultilineTextInputHost extends SymbiotePrimitiveHost {}
140
+
124
141
  @Component({
125
142
  selector: 'symbiote-switch',
126
143
  standalone: true,
@@ -128,6 +145,16 @@ export class MultilineTextInputHost extends SymbiotePrimitiveHost {}
128
145
  })
129
146
  export class SwitchHost extends SymbiotePrimitiveHost {}
130
147
 
148
+ // The component path's spelling — same native Switch/AndroidSwitch, a tag the engine's Switch
149
+ // behavior does not carry. See `component-names/shared.ts` for why the wrapper may not share the
150
+ // lowered tag; mirrors `ManagedTextInputHost` above.
151
+ @Component({
152
+ selector: 'symbiote-switch-managed',
153
+ standalone: true,
154
+ template: '<ng-content></ng-content>',
155
+ })
156
+ export class ManagedSwitchHost extends SymbiotePrimitiveHost {}
157
+
131
158
  @Component({
132
159
  selector: 'symbiote-activity-indicator',
133
160
  standalone: true,
@@ -0,0 +1,43 @@
1
+ // Side-effect ONLY. Exports nothing, and must never be re-exported from the barrel.
2
+ //
3
+ // A lowered `<symbiote-pressable>`/`<symbiote-text-input>` is a bare custom element with no
4
+ // component behind it, so the press machine and the controlled-input machine have to live on the
5
+ // engine node itself — reached by `registerPressableBehavior()`/`registerTextInputBehavior()`
6
+ // having run before the first node of that tag is created.
7
+ //
8
+ // Metro turns on `inlineRequires` for PRODUCTION only, moving a `require()` down to the first place
9
+ // its binding is used as a VALUE; a barrel's `export { X } from './x'` compiles to a lazy getter,
10
+ // so a module reached only that way never evaluates unless something names it as a value — silent
11
+ // in Release builds alone, invisible to tsc, to vitest, and to grepping the bundle (see CLAUDE.md,
12
+ // "Never make correctness depend on a module's load-time side effect"). A bare
13
+ // `import './register';` placed NEXT TO a re-export of the same specifier does not help either —
14
+ // Babel merges the two into one dependency and the merged one stays lazy. This is the shape that
15
+ // survives both traps, matching `adapters/{vue,svelte,solid}/src/register.ts` and
16
+ // `packages/slider/src/{react,vue,svelte,angular}/index.ts`.
17
+ import {
18
+ registerImageBehavior,
19
+ registerInputAccessoryViewBehavior,
20
+ registerPressableBehavior,
21
+ registerSwitchBehavior,
22
+ registerTextInputBehavior,
23
+ } from '@symbiote-native/components';
24
+
25
+ registerPressableBehavior();
26
+ // Only the LOWERED tags carry this — the wrapper renders `symbiote-text-input-managed` /
27
+ // `symbiote-text-input-multiline-managed` and keeps running its own lifecycle. One owner per node;
28
+ // see `component-names/shared.ts`.
29
+ registerTextInputBehavior();
30
+ // Same reason as TextInput: the wrapper renders `symbiote-switch-managed` and runs its own
31
+ // lastNativeReport/snap-back lifecycle, so the engine's copy attaches only to the bare tag.
32
+ registerSwitchBehavior();
33
+
34
+ // Image owns no runtime — its behavior is a prop FOLD and nothing else, and it is registered on the
35
+ // same `symbiote-image` the wrapper already emits rather than on a `-managed` twin. That is safe
36
+ // only because the mapping is idempotent, which `core/components/src/behaviors/image.test.ts`
37
+ // asserts rather than assumes; a wrapper-built node simply folds a second time and nothing moves.
38
+ registerImageBehavior();
39
+
40
+ // Fold-only, and it shares the wrapper's tag for the same reason Image does: the mapping has no
41
+ // aliasing at all, so a wrapper-built node folding a second time moves nothing.
42
+ // `core/components/src/behaviors/input-accessory-view.test.ts` asserts that rather than assuming it.
43
+ registerInputAccessoryViewBehavior();
@@ -62,7 +62,9 @@ function asAngularHost(hostNode: SymbioteSurface | ISymbioteNode): Element {
62
62
  // that isn't one of our primitives), so the root needs an explicit host too, one we then hand
63
63
  // to the wrapper as projectable content.
64
64
  function createDetachedViewHost(): ISymbioteNode {
65
- const descriptor = descriptorFor('View');
65
+ // 'symbiote-view', not 'View': the public name is not a Fabric view name, and until
66
+ // makeDescriptorFor learned to reject one it fell through to a view literally named `View`.
67
+ const descriptor = descriptorFor('symbiote-view');
66
68
  return toPublicInstance(
67
69
  createEngineElement(descriptor.component, descriptor.isText),
68
70
  );
@@ -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,6 +26,7 @@ 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 {
@@ -55,6 +57,64 @@ function isRecord(value: unknown): value is Record<string, unknown> {
55
57
  return typeof value === 'object' && value !== null;
56
58
  }
57
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
+
58
118
  // Diagnostic-only: tags each anchor with a sequential id so log lines can tell distinct
59
119
  // anchors apart (they all otherwise print the same generic '#anchor' component name).
60
120
  // Gated behind isDebug() so a production build pays zero cost (no WeakMap writes).
@@ -92,6 +152,10 @@ const PRIMITIVE_SELECTOR_ALIAS: Record<string, string> = {
92
152
  // Inserting a bare raw-text node anywhere but inside a <Text> is invalid in Fabric (a
93
153
  // stray RCTRawText would paint). Angular's ɵɵtext only ever lands text inside a <Text>,
94
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.
95
159
  function assertTextPlacement(child: ISymbioteNode, parent: IHostElement): void {
96
160
  if (isRawText(child) && (isSurface(parent) || !parent.isText)) {
97
161
  throw new Error(
@@ -119,22 +183,37 @@ export class SymbioteRenderer implements Renderer2 {
119
183
  // name for a native leaf. Public aliases are normalized to their engine primitive name
120
184
  // before descriptor lookup. descriptorFor resolves it; an unknown `symbiote-*` is a typo,
121
185
  // any other string flows through as a raw Fabric name (events/processors derived from its
122
- // ViewConfig). toPublicInstance grafts the imperative API (measure / setNativeProps /
123
- // 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.
124
188
  countAngular('nodesCreated');
125
189
  noteAngularCreate(name);
126
190
  const engineName = PRIMITIVE_SELECTOR_ALIAS[name] ?? name;
191
+
127
192
  if (isAnchorHostComponent(engineName)) {
128
193
  const anchor = tagAnchorForDebug(createAnchor());
129
- dlog(
130
- `angular createElement ${name} -> anchor host ${describeHost(anchor)}`,
131
- );
194
+ if (isDebug()) {
195
+ dlog(
196
+ `angular createElement ${name} -> anchor host ${describeHost(anchor)}`,
197
+ );
198
+ }
132
199
  return anchor;
133
200
  }
134
201
 
135
202
  const descriptor = descriptorFor(engineName);
136
- const node = createElement(descriptor.component, descriptor.isText);
137
- 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
+ }
138
217
  return toPublicInstance(node);
139
218
  }
140
219
 
@@ -144,7 +223,9 @@ export class SymbioteRenderer implements Renderer2 {
144
223
  // Vue createComment path.
145
224
  countAngular('nodesCreated');
146
225
  const anchor = tagAnchorForDebug(createAnchor());
147
- dlog(`Angular renderer createComment -> ${describeHost(anchor)}`);
226
+ if (isDebug()) {
227
+ dlog(`Angular renderer createComment -> ${describeHost(anchor)}`);
228
+ }
148
229
  return anchor;
149
230
  }
150
231
 
@@ -161,15 +242,19 @@ export class SymbioteRenderer implements Renderer2 {
161
242
  countAngular('nodesInserted');
162
243
  assertTextPlacement(newChild, parent);
163
244
  if (isSurface(parent)) {
164
- dlog(
165
- `Angular renderer appendChild parent=surface child=${describeHost(newChild)}`,
166
- );
245
+ if (isDebug()) {
246
+ dlog(
247
+ `Angular renderer appendChild parent=surface child=${describeHost(newChild)}`,
248
+ );
249
+ }
167
250
  parent.appendChild(newChild);
168
251
  } else {
169
252
  const projection = getScrollViewProjection(parent);
170
- dlog(
171
- `Angular renderer appendChild parent=${describeHost(parent)} child=${describeHost(newChild)} projection=${projection !== undefined}`,
172
- );
253
+ if (isDebug()) {
254
+ dlog(
255
+ `Angular renderer appendChild parent=${describeHost(parent)} child=${describeHost(newChild)} projection=${projection !== undefined}`,
256
+ );
257
+ }
173
258
  if (projection !== undefined) {
174
259
  projection.appendProjectedChild(parent, newChild, (target, child) =>
175
260
  appendChild(target, child),
@@ -190,16 +275,20 @@ export class SymbioteRenderer implements Renderer2 {
190
275
  countAngular('nodesInserted');
191
276
  assertTextPlacement(newChild, parent);
192
277
  if (isSurface(parent)) {
193
- dlog(
194
- `Angular renderer insertBefore parent=surface child=${describeHost(newChild)} ref=${refChild ? describeHost(refChild) : 'null'}`,
195
- );
278
+ if (isDebug()) {
279
+ dlog(
280
+ `Angular renderer insertBefore parent=surface child=${describeHost(newChild)} ref=${refChild ? describeHost(refChild) : 'null'}`,
281
+ );
282
+ }
196
283
  if (refChild) parent.insertBefore(newChild, refChild);
197
284
  else parent.appendChild(newChild);
198
285
  } else {
199
286
  const projection = getScrollViewProjection(parent);
200
- dlog(
201
- `Angular renderer insertBefore parent=${describeHost(parent)} child=${describeHost(newChild)} ref=${refChild ? describeHost(refChild) : 'null'} projection=${projection !== undefined}`,
202
- );
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
+ }
203
292
  if (projection !== undefined) {
204
293
  projection.insertProjectedChild(
205
294
  parent,
@@ -224,16 +313,15 @@ export class SymbioteRenderer implements Renderer2 {
224
313
  // surface.children with no parent). Angular's `parent` arg is ignored in favor of the
225
314
  // authoritative link, mirroring the Vue adapter's remove.
226
315
  countAngular('nodesRemoved');
227
- const angularParent = _parent !== null ? describeHost(_parent) : 'null';
228
- const retainedParent =
229
- oldChild.parent !== undefined ? describeHost(oldChild.parent) : 'none';
230
- const wasProjected = removeScrollViewProjectedChild(
231
- oldChild,
232
- (parent, child) => removeChild(parent, child),
233
- );
234
- dlog(
235
- `Angular renderer removeChild angularParent=${angularParent} retainedParent=${retainedParent} child=${describeHost(oldChild)} viaProjection=${wasProjected}`,
236
- );
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
+ }
237
325
  if (!wasProjected) {
238
326
  const parent = oldChild.parent;
239
327
  if (parent !== undefined) removeChild(parent, oldChild);
@@ -278,7 +366,7 @@ export class SymbioteRenderer implements Renderer2 {
278
366
  if (isSurface(el)) return;
279
367
  countAngular('rendererWrites');
280
368
  noteAngularWrite(name);
281
- routeProp(el, name, value);
369
+ routeProp(el, aliasedPropName(name), value);
282
370
  this.surface.requestCommit();
283
371
  }
284
372
 
@@ -286,7 +374,8 @@ export class SymbioteRenderer implements Renderer2 {
286
374
  if (isSurface(el)) return;
287
375
  countAngular('rendererWrites');
288
376
  noteAngularWrite(name);
289
- routeProp(el, name, undefined);
377
+ const aliased = aliasedPropName(name);
378
+ routeProp(el, aliased, textDefaultFor(el, aliased));
290
379
  this.surface.requestCommit();
291
380
  }
292
381
 
@@ -353,7 +442,12 @@ export class SymbioteRenderer implements Renderer2 {
353
442
  if (isSurface(el)) return;
354
443
  countAngular('rendererWrites');
355
444
  noteAngularWrite(name);
356
- routeProp(el, name, value);
445
+ const aliased = aliasedPropName(name);
446
+ routeProp(
447
+ el,
448
+ aliased,
449
+ value === undefined ? textDefaultFor(el, aliased) : value,
450
+ );
357
451
  this.surface.requestCommit();
358
452
  }
359
453
 
@@ -362,7 +456,9 @@ export class SymbioteRenderer implements Renderer2 {
362
456
  noteAngularWrite('#text');
363
457
  // A useful permanent seam: text mutations are low-frequency and the one place a stale
364
458
  // `{{binding}}` (a change-detection gap) shows up as "the setValue never fired".
365
- dlog(`Angular renderer setValue "${value}" on ${describeHost(node)}`);
459
+ if (isDebug()) {
460
+ dlog(`Angular renderer setValue "${value}" on ${describeHost(node)}`);
461
+ }
366
462
  setText(node, value);
367
463
  this.surface.requestCommit();
368
464
  }
@@ -377,6 +473,20 @@ export class SymbioteRenderer implements Renderer2 {
377
473
  callback: (event: unknown) => boolean | void,
378
474
  ): () => void {
379
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
+ }
380
490
  setEventListener(target, eventName, callback);
381
491
  return () => setEventListener(target, eventName, undefined);
382
492
  }
@@ -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
+ }