@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
@@ -116,6 +116,24 @@ export function noteAngularWrite(propName: string): void {
116
116
  writesByProp.set(propName, (writesByProp.get(propName) ?? 0) + 1);
117
117
  }
118
118
 
119
+ /**
120
+ * A style KEY write, named `style.<key>` in the readout — and the prefix is built HERE.
121
+ *
122
+ * `noteAngularWrite` gates itself, but an ARGUMENT is evaluated before the callee can refuse it, so
123
+ * `noteAngularWrite(`style.${key}`)` builds a string on every `setStyle` in a Release build with the
124
+ * detail map off. `setStyle` is Angular's only styling channel — one call per key per node — so the
125
+ * bench row's create allocated ~7 000 of them for nobody.
126
+ *
127
+ * It is the same trap `CLAUDE.md` records for `dlog` arguments on this very renderer, in a different
128
+ * function; `tests/dlog-argument-budget.test.ts` scans for the `dlog` spelling and does not see this
129
+ * one.
130
+ */
131
+ export function noteAngularStyleWrite(key: string): void {
132
+ if (!isDetailEnabled) return;
133
+ const name = `style.${key}`;
134
+ writesByProp.set(name, (writesByProp.get(name) ?? 0) + 1);
135
+ }
136
+
119
137
  export function noteAngularCreate(tagName: string): void {
120
138
  if (!isDetailEnabled) return;
121
139
  createsByTag.set(tagName, (createsByTag.get(tagName) ?? 0) + 1);
@@ -15,8 +15,6 @@
15
15
  // wrapper component accepted. The claim that a directive can reclaim it "solely by executing a
16
16
  // linked AOT artifact" was answered rather than assumed: `elements.test.ts` compiles the binding
17
17
  // through real ngtsc and runs the LINKED output. See `SymbioteElement.style`.
18
- //
19
- // `symbioteStyle` stays as an alias — it is public API and the renderer still folds it.
20
18
  import type {
21
19
  IAccessibilityProps,
22
20
  IAriaProps,
@@ -45,10 +43,8 @@ export interface IElementProps
45
43
  renderToHardwareTextureAndroid?: boolean;
46
44
  shouldRasterizeIOS?: boolean;
47
45
  needsOffscreenAlphaCompositing?: boolean;
48
- /** The array-capable `[style]`, aliased to `style` in the renderer. */
49
- symbioteStyle?: IStyleProp<IViewStyle>;
50
46
  /**
51
- * RN's own spelling. The press-state callback is in the union because a subclass cannot widen an
47
+ * The press-state callback is in the union because a subclass cannot widen an
52
48
  * inherited property and `<pressable>`/`<touchable-*>` are the tags that take one; on every other
53
49
  * tag the engine resolves it at `pressed: false`.
54
50
  */
package/src/elements.ts CHANGED
@@ -36,6 +36,7 @@ import {
36
36
  ChangeDetectorRef,
37
37
  Directive,
38
38
  ElementRef,
39
+ ErrorHandler,
39
40
  EventEmitter,
40
41
  Input,
41
42
  Output,
@@ -52,10 +53,12 @@ import type {
52
53
  import { NG_VALUE_ACCESSOR, type ControlValueAccessor } from '@angular/forms';
53
54
  import { VALUE_CHANGE_EVENT } from './renderer/value-change';
54
55
  import {
55
- createCallbackWrapper,
56
56
  registerViewFlush,
57
57
  unregisterViewFlush,
58
58
  } from './change-detection-flush';
59
+ import { SymbioteCallbackHost } from './callback-host';
60
+ import { withholdFromRuntimeMatching } from './runtime-matching';
61
+ import { SymbioteStyleHost } from './style-host';
59
62
  import type {
60
63
  IActivityIndicatorProps,
61
64
  IImageProps,
@@ -89,21 +92,25 @@ import type { IAngularScrollViewProps } from './components/scroll-view-props';
89
92
  * — so without this loop a bare tag commits nothing at all. It stays generic on purpose: per-prop
90
93
  * forwarding code is a component wrapper by another name, which is what this migration removes.
91
94
  * `renderer.setProperty` lands in the adapter's own renderer, which routes the value through the
92
- * engine's `routeProp` and applies the `id` -> `nativeID` and `symbioteStyle` -> `style` aliases.
95
+ * engine's `routeProp` and applies the `id` -> `nativeID` alias.
93
96
  */
94
97
  @Directive()
95
98
  export abstract class SymbioteElement implements OnChanges {
96
99
  private readonly renderer = inject(Renderer2);
97
100
  protected readonly host = inject(ElementRef);
98
- protected readonly detector = inject(ChangeDetectorRef);
99
101
 
100
- // An `onX` PROP is called by the engine, so Angular is never told it fired. Shared with the
101
- // component path's `SymbioteHostPropsDirective`, which has the identical deficit — see
102
- // `createCallbackWrapper`.
103
- private readonly wrapCallback = createCallbackWrapper(
104
- this.detector,
105
- this.host.nativeElement,
106
- );
102
+ // NO `ChangeDetectorRef` HERE, and its absence is the point. This class is instantiated once per
103
+ // ELEMENT, so injecting one cost ~1.0-2.4 us on every tag of a screen to serve the few that carry
104
+ // an `on*` prop — ten thousand `ViewRef`s on a thousand-row create, read by none of them
105
+ // (`core/engine/cpp/tests/js/angular-directive-cost.itest.ts`). `SymbioteCallbackHost` owns one and
106
+ // MATCHES on the callback attributes instead, registering it against the node; `markViewFor` is how
107
+ // the wrapper reaches it. The inputs did not move, so nothing about this file's public surface did.
108
+
109
+ // THE `on*` WRAPPER LEFT THIS CLASS on 2026-09-18, for `SymbioteRenderer.setProperty`. Most of
110
+ // these directives are withheld from runtime matching now (`./runtime-matching`), so a binding
111
+ // reaches the renderer through `ɵɵproperty` without passing through any directive — a wrapper that
112
+ // lives here would simply stop running. The renderer is reached by every path, which is the home it
113
+ // should have had.
107
114
 
108
115
  @Input() testID?: IElementProps['testID'];
109
116
  @Input() nativeID?: IElementProps['nativeID'];
@@ -183,7 +190,6 @@ export abstract class SymbioteElement implements OnChanges {
183
190
  @Input() shouldRasterizeIOS?: IElementProps['shouldRasterizeIOS'];
184
191
  @Input()
185
192
  needsOffscreenAlphaCompositing?: IElementProps['needsOffscreenAlphaCompositing'];
186
- @Input() symbioteStyle?: IElementProps['symbioteStyle'];
187
193
  // Declared, and the reason is the one `element-props.ts` used to give for NOT declaring it —
188
194
  // reversed by measurement. `[style]` on an element with no directive input reaches Angular's own
189
195
  // CSS styling engine (`ɵɵstyleMap`), which cannot represent an RN StyleProp: an ARRAY decomposes
@@ -205,6 +211,25 @@ export abstract class SymbioteElement implements OnChanges {
205
211
  // in what commits.
206
212
  @Input() style?: IElementProps['style'];
207
213
 
214
+ /**
215
+ * Declared so Angular SHADOWS the `[class]` binding into it instead of decomposing the string.
216
+ *
217
+ * A styling binding goes to a directive input when the directive declares that exact public name
218
+ * — `setShadowStylingInputFlags` sets `hasClassInput`/`hasStyleInput` off the input map, and
219
+ * `checkStylingMap` then hands the whole value over and never calls the renderer per key
220
+ * (`view/directives.ts`, `instructions/styling.ts`). `style` has been declared here all along and
221
+ * is shadowed; `class` was not, so every `[class]` reached the renderer one TOKEN at a time — on
222
+ * the element-directive path as much as the bare one. Measured at ~3.3 us per class binding
223
+ * (`adapter-create-cost.itest.ts`, "prices the class channel"), on the channel every example app
224
+ * uses for its static look.
225
+ *
226
+ * `[class.foo]` and `[ngClass]` are NOT shadowed and keep arriving as `addClass`, so a node can
227
+ * now be told its classes both ways at once; the renderer unions the two sources
228
+ * (`classStringFor`). Typed as a string rather than RN's `className`, because that is what
229
+ * `ɵɵclassMap` hands over after it concatenates any static `class=` prefix.
230
+ */
231
+ @Input() class?: string;
232
+
208
233
  // The flat-bag spelling of the events below. `(press)` and `[onPress]` are both supported and
209
234
  // land in the same place; an app that already holds a handler bag binds the props.
210
235
  @Input() onPress?: IElementProps['onPress'];
@@ -221,7 +246,9 @@ export abstract class SymbioteElement implements OnChanges {
221
246
  this.renderer.setProperty(
222
247
  this.host.nativeElement,
223
248
  name,
224
- this.wrapCallback(name, changes[name]?.currentValue),
249
+ // Unwrapped on purpose: `SymbioteRenderer.setProperty` is where an `on*` value is wrapped
250
+ // now, and it is the call this line makes.
251
+ changes[name]?.currentValue,
225
252
  );
226
253
  }
227
254
  }
@@ -239,11 +266,40 @@ export abstract class SymbioteElement implements OnChanges {
239
266
  */
240
267
  @Directive()
241
268
  abstract class ReadBackElement extends SymbioteElement implements OnDestroy {
269
+ // ITS OWN, now that the base has none. Three tags reach this class, so the injection is paid where
270
+ // it is read instead of on every element of a screen.
271
+ private readonly detector = inject(ChangeDetectorRef);
272
+ private readonly errorHandler = inject(ErrorHandler);
273
+
242
274
  constructor() {
243
275
  super();
244
276
  const node: unknown = this.host.nativeElement;
245
277
  if (typeof node === 'object' && node !== null) {
246
- registerViewFlush(node, () => this.detector.detectChanges());
278
+ registerViewFlush(node, () => this.flush());
279
+ }
280
+ }
281
+
282
+ // REPORTED, NOT RETHROWN, and it is Angular's own contract rather than a swallow. Every other
283
+ // change detection in an app runs inside `ApplicationRef.tick()`, which catches and hands the
284
+ // error to `ErrorHandler` — `render/index.ts` provides `SymbioteErrorHandler` for exactly that,
285
+ // after an unprovided token once turned every async tick exception into a hard crash. This flush
286
+ // is the ONE change detection that runs outside that boundary: the engine calls it from inside a
287
+ // native event dispatch, where a throw has nowhere to go but `RCTFatal`.
288
+ //
289
+ // Device-diagnosed 2026-09-20 on ApiPlaygroundScreen. `PlaygroundLifecycleLogger` emits from
290
+ // `ngDoCheck`/`ngAfterContentChecked`/`ngAfterViewChecked` and the screen's handler writes a
291
+ // signal its own template reads, so the view re-dirties itself on every pass and
292
+ // `detectChangesInViewWhileDirty` throws NG0103 after MAXIMUM_REFRESH_RERUNS. The scheduler's own
293
+ // ticks were already hitting it and reporting it quietly; the first KEYSTROKE took the same throw
294
+ // through here and killed the app — `Terminating app due to uncaught exception
295
+ // 'RCTFatalException: Unhandled JS Exception: Error: NG0103'`, with no redbox because Release has
296
+ // none. So the app bug is the app's, and a flush that turns a reported error into a fatal one is
297
+ // ours: a keystroke must not be stricter than a tick.
298
+ private flush(): void {
299
+ try {
300
+ this.detector.detectChanges();
301
+ } catch (error: unknown) {
302
+ this.errorHandler.handleError(error);
247
303
  }
248
304
  }
249
305
 
@@ -253,13 +309,31 @@ abstract class ReadBackElement extends SymbioteElement implements OnDestroy {
253
309
  }
254
310
  }
255
311
 
256
- @Directive({ selector: 'view', standalone: true })
312
+ // BOTH SPELLINGS ON THE SEVEN DASHLESS TAGS, and it is a correctness fix rather than a convenience.
313
+ //
314
+ // The renderer has always mapped `symbiote-view` onto `view` (`PRIMITIVE_SELECTOR_ALIAS`), because
315
+ // `CUSTOM_ELEMENTS_SCHEMA` admits an unknown element only when the name carries a hyphen — so the
316
+ // hyphenated form is the spelling an app WITHOUT these directives has to use. It is the same tag and
317
+ // commits the same node.
318
+ //
319
+ // It was not the same tag HERE. A selector of `view` alone meant an app that imports
320
+ // `SYMBIOTE_ELEMENTS` and writes `<symbiote-view>` matched nothing: no type check on its props, no
321
+ // declared inputs, none of `SymbioteElement`'s forwarding or callback wrapping — silently, with the
322
+ // tree still looking right. Measured, that shape also ran ~8.6 us per element FASTER, which is what
323
+ // made it look like an optimization instead of a hole (`angular-directive-cost.itest.ts`).
324
+ //
325
+ // Only the seven dashless tags need it: `hyphenatedIntrinsicAliases` skips any tag that already
326
+ // carries a dash, so `symbiote-scroll-view` resolves nowhere on either side and the two halves agree
327
+ // already.
328
+ @Directive({ selector: 'view, symbiote-view', standalone: true })
257
329
  export class ViewElement extends SymbioteElement {}
258
330
 
259
- @Directive({ selector: 'pressable', standalone: true })
331
+ @Directive({ selector: 'pressable, symbiote-pressable', standalone: true })
260
332
  export class PressableElement extends SymbioteElement {
261
333
  @Input() disabled?: IAngularPressableProps['disabled'];
262
334
  @Input() cancelable?: IAngularPressableProps['cancelable'];
335
+ @Input()
336
+ blockNativeResponder?: IAngularPressableProps['blockNativeResponder'];
263
337
  @Input() delayLongPress?: IAngularPressableProps['delayLongPress'];
264
338
  @Input() delayHoverIn?: IAngularPressableProps['delayHoverIn'];
265
339
  @Input() delayHoverOut?: IAngularPressableProps['delayHoverOut'];
@@ -269,6 +343,9 @@ export class PressableElement extends SymbioteElement {
269
343
  pressRetentionOffset?: IAngularPressableProps['pressRetentionOffset'];
270
344
  @Input() unstable_pressDelay?: IAngularPressableProps['unstable_pressDelay'];
271
345
  @Input() android_ripple?: IAngularPressableProps['android_ripple'];
346
+ // ON THE BASE, so both touchables inherit it — the prop is Pressable's AND
347
+ // TouchableHighlight's upstream, and they extend this rather than repeat its surface.
348
+ @Input() testOnly_pressed?: IAngularPressableProps['testOnly_pressed'];
272
349
  @Input()
273
350
  android_disableSound?: IAngularPressableProps['android_disableSound'];
274
351
  @Input() hasTVPreferredFocus?: IAngularPressableProps['hasTVPreferredFocus'];
@@ -300,6 +377,8 @@ export class TouchableHighlightElement extends PressableElement {
300
377
  @Input() delayPressIn?: number;
301
378
  @Input() delayPressOut?: number;
302
379
  @Input() minPressDuration?: number;
380
+ // TouchableHighlight.js:205 — forwarded to Pressability as `android_disableSound`.
381
+ @Input() touchSoundDisabled?: boolean;
303
382
  }
304
383
 
305
384
  // RN's TouchableNativeFeedback is a Pressable that CLONES onto its single child instead of
@@ -310,6 +389,8 @@ export class TouchableHighlightElement extends PressableElement {
310
389
  export class TouchableNativeFeedbackElement extends PressableElement {
311
390
  @Input() background?: INativeFeedbackBackground;
312
391
  @Input() useForeground?: boolean;
392
+ // TouchableNativeFeedback.js:228 — forwarded to Pressability as `android_disableSound`.
393
+ @Input() touchSoundDisabled?: boolean;
313
394
  }
314
395
 
315
396
  // RN's TouchableWithoutFeedback clones onto its single child the same way
@@ -321,19 +402,21 @@ export class TouchableWithoutFeedbackElement extends PressableElement {
321
402
  @Input() delayPressIn?: number;
322
403
  @Input() delayPressOut?: number;
323
404
  @Input() minPressDuration?: number;
405
+ // TouchableWithoutFeedback.js:199 — forwarded to Pressability as `android_disableSound`.
406
+ @Input() touchSoundDisabled?: boolean;
324
407
  }
325
408
 
326
409
  // RN's Button IS a TouchableOpacity (Button.js:384), and the behavior builds the view and the
327
410
  // label under it — so the tag takes the touchable's surface plus the four props Button owns. There
328
411
  // is no `style`: RN's Button has no such prop, and the label/background come from `color`.
329
- @Directive({ selector: 'button', standalone: true })
412
+ @Directive({ selector: 'button, symbiote-button', standalone: true })
330
413
  export class ButtonElement extends TouchableOpacityElement {
331
414
  @Input() title?: string;
332
415
  @Input() color?: string;
333
416
  @Input() touchSoundDisabled?: boolean;
334
417
  }
335
418
 
336
- @Directive({ selector: 'text', standalone: true })
419
+ @Directive({ selector: 'text, symbiote-text', standalone: true })
337
420
  export class TextElement extends SymbioteElement {
338
421
  // Narrows the inherited input to a TEXT style so `fontSize`/`fontWeight` type-check here. The
339
422
  // initializer is what TS2612 asks for to accept a redeclaration as deliberate; `declare` would
@@ -350,7 +433,7 @@ export class TextElement extends SymbioteElement {
350
433
  @Input() disabled?: ITextElementProps['disabled'];
351
434
  }
352
435
 
353
- @Directive({ selector: 'image', standalone: true })
436
+ @Directive({ selector: 'image, symbiote-image', standalone: true })
354
437
  export class ImageElement extends SymbioteElement {
355
438
  @Input() source?: IImageProps['source'];
356
439
  @Input() src?: IImageProps['src'];
@@ -499,7 +582,7 @@ export class HorizontalScrollContentElement extends SymbioteElement {}
499
582
  * obvious reading is wrong: the file header's "an `@Output` CONSUMES the binding" holds for a
500
583
  * COMPONENT, and an element is the other case — Angular attaches the renderer listener for the
501
584
  * event as well, so `Renderer2.listen` still runs and the engine still hears the change. Measured
502
- * under JIT by deleting this whole hook: every case in `lowered-two-way-value.test.ts` stayed
585
+ * under JIT by deleting this whole hook: every case in `renderer/two-way-value.test.ts` stayed
503
586
  * green, delivery included, and exactly ONCE (nothing double-fires when both paths exist).
504
587
  *
505
588
  * It is kept because the measurement is JIT-only and this adapter has a recorded case of JIT and
@@ -580,6 +663,7 @@ export class TextInputElement extends ValueChangeElement {
580
663
  @Input() textContentType?: ITextInputProps['textContentType'];
581
664
  @Input() underlineColorAndroid?: ITextInputProps['underlineColorAndroid'];
582
665
  @Input() onValueChange?: ITextInputProps['onValueChange'];
666
+ @Input() onChangeText?: ITextInputProps['onChangeText'];
583
667
  @Input() onContentSizeChange?: ITextInputProps['onContentSizeChange'];
584
668
  @Input() onEndEditing?: ITextInputProps['onEndEditing'];
585
669
  @Input() onKeyPress?: ITextInputProps['onKeyPress'];
@@ -604,16 +688,7 @@ export class TextInputElement extends ValueChangeElement {
604
688
  @Directive({ selector: 'text-input-multiline', standalone: true })
605
689
  export class MultilineTextInputElement extends TextInputElement {}
606
690
 
607
- // The COMPONENT path's spelling of the pair above — same native views, a tag the behavior registry
608
- // deliberately does not carry (`component-names/shared.ts`). Declared so the tag alphabet is
609
- // complete; an app writes the plain name.
610
- @Directive({ selector: 'text-input-managed', standalone: true })
611
- export class ManagedTextInputElement extends TextInputElement {}
612
-
613
- @Directive({ selector: 'text-input-multiline-managed', standalone: true })
614
- export class ManagedMultilineTextInputElement extends TextInputElement {}
615
-
616
- @Directive({ selector: 'switch', standalone: true })
691
+ @Directive({ selector: 'switch, symbiote-switch', standalone: true })
617
692
  export class SwitchElement extends ValueChangeElement {
618
693
  @Input() value?: ISwitchProps['value'];
619
694
  @Input() disabled?: ISwitchProps['disabled'];
@@ -635,9 +710,6 @@ export class SwitchElement extends ValueChangeElement {
635
710
  }
636
711
  }
637
712
 
638
- @Directive({ selector: 'switch-managed', standalone: true })
639
- export class ManagedSwitchElement extends SwitchElement {}
640
-
641
713
  /**
642
714
  * `[(ngModel)]` / `formControlName` on a `<text-input>` or a `<switch>`.
643
715
  *
@@ -677,6 +749,21 @@ abstract class SymbioteValueAccessor
677
749
  this.host.nativeElement,
678
750
  VALUE_CHANGE_EVENT,
679
751
  (value: unknown) => {
752
+ // THE HALF THE DOM DOES FOR FREE, and without it a controlled input undoes every
753
+ // keystroke. `<text-input>`'s behavior re-commands the native text whenever `props.value`
754
+ // disagrees with what native last reported — that is what makes it controlled — and the
755
+ // read-back flush exists so the app's new value is on the node by the time the commit runs.
756
+ // @angular/forms does not reach the node in that window: `NgModel.ngOnChanges` defers
757
+ // `_updateValue` through `resolvedPromise.then`, so `writeValue` lands a MICROTASK after
758
+ // the flush, and the commit in between still reads the value from before the keystroke.
759
+ //
760
+ // In a browser there is nothing to do here: `input.value` already holds what the user
761
+ // typed. `node.props.value` is the same slot, and nothing else writes it — so the accessor
762
+ // mirrors it, which is the shape rather than a workaround. A later `writeValue` still wins,
763
+ // so an app that transforms or refuses the value keeps doing so, one microtask on.
764
+ //
765
+ // Device-reported 2026-09-20: every character snapped the field back to its mounted text.
766
+ this.setProp('value', value);
680
767
  fn(value);
681
768
  },
682
769
  );
@@ -716,8 +803,7 @@ export class TextInputValueAccessor extends SymbioteValueAccessor {
716
803
  }
717
804
 
718
805
  @Directive({
719
- selector:
720
- 'switch[ngModel], switch[formControl], switch[formControlName], switch-managed[ngModel], switch-managed[formControl], switch-managed[formControlName]',
806
+ selector: 'switch[ngModel], switch[formControl], switch[formControlName]',
721
807
  standalone: true,
722
808
  providers: [
723
809
  {
@@ -753,7 +839,7 @@ export class ActivityIndicatorSpinnerElement extends ActivityIndicatorElement {}
753
839
  @Directive({ selector: 'safe-area-view', standalone: true })
754
840
  export class SafeAreaViewElement extends SymbioteElement {}
755
841
 
756
- @Directive({ selector: 'modal', standalone: true })
842
+ @Directive({ selector: 'modal, symbiote-modal', standalone: true })
757
843
  export class ModalElement extends SymbioteElement {
758
844
  @Input() visible?: IModalViewProps['visible'];
759
845
  @Input() transparent?: IModalViewProps['transparent'];
@@ -824,10 +910,7 @@ export const SYMBIOTE_ELEMENTS = [
824
910
  HorizontalScrollContentElement,
825
911
  TextInputElement,
826
912
  MultilineTextInputElement,
827
- ManagedTextInputElement,
828
- ManagedMultilineTextInputElement,
829
913
  SwitchElement,
830
- ManagedSwitchElement,
831
914
  ActivityIndicatorElement,
832
915
  ActivityIndicatorSpinnerElement,
833
916
  SafeAreaViewElement,
@@ -840,8 +923,46 @@ export const SYMBIOTE_ELEMENTS = [
840
923
  // provided, and `elements.test.ts` subtracts them from its tag-coverage check by name.
841
924
  TextInputValueAccessor,
842
925
  SwitchValueAccessor,
926
+ // Also not a tag: it matches on the callback ATTRIBUTES, so it lands only on the elements that
927
+ // bind one and carries the `ChangeDetectorRef` their wrapper needs. It rides this list for the same
928
+ // reason the accessors do, and `elements.test.ts` subtracts it from the tag-coverage check by name.
929
+ SymbioteCallbackHost,
930
+ // The other attribute-matched one: it claims `[style]` and `[class]` so an RN style ARRAY never
931
+ // reaches Angular's styling engine, which cannot represent one and throws. See `style-host.ts`.
932
+ SymbioteStyleHost,
843
933
  ] as const;
844
934
 
935
+ // THE DIRECTIVES THAT GO ON MATCHING, and the list is the exceptions rather than the rule.
936
+ //
937
+ // A tag directive here is, with four exceptions, nothing but `@Input()` declarations over one
938
+ // inherited `ngOnChanges` that forwards each of them to the renderer — which is the call
939
+ // `ɵɵproperty` makes directly on an element nothing claimed. So the instance buys a compile-time
940
+ // check at ~8.5-9.4 us of run time per element, and `./runtime-matching` keeps the check while
941
+ // dropping the instance.
942
+ //
943
+ // These four cannot go, because they DO something when they are built:
944
+ //
945
+ // text-input, text-input-multiline, switch `ValueChangeElement` — listens for the engine's value
946
+ // event and registers a view flush, which is what stops
947
+ // a controlled value being undone inside one microtask
948
+ // refresh-control `ReadBackElement`, the same flush for the same reason
949
+ //
950
+ // The two form accessors and `SymbioteCallbackHost` are absent from both lists deliberately: they
951
+ // match on an ATTRIBUTE rather than a tag, so they already land only where they are needed.
952
+ withholdFromRuntimeMatching(
953
+ SYMBIOTE_ELEMENTS.filter(
954
+ directive =>
955
+ directive !== TextInputElement &&
956
+ directive !== MultilineTextInputElement &&
957
+ directive !== SwitchElement &&
958
+ directive !== RefreshControlElement &&
959
+ directive !== TextInputValueAccessor &&
960
+ directive !== SwitchValueAccessor &&
961
+ directive !== SymbioteCallbackHost &&
962
+ directive !== SymbioteStyleHost,
963
+ ),
964
+ );
965
+
845
966
  // A prop this file forgets is not a silent gap — it is `Can't bind to 'x'` in the app that tries
846
967
  // it, which is the failure mode a hand-written list produces here. So the lists are checked
847
968
  // against the prop TYPES rather than trusted: each assertion below resolves to `true` only when
package/src/index.ts CHANGED
@@ -4,6 +4,21 @@
4
4
  //
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
+ //
8
+ // `View` AND `Text` ARE NO LONGER WORTH IMPORTING, and on 2026-09-18 the examples stopped. They are
9
+ // `@Component`s on the `view` and `text` SELECTORS — a second mechanism on the same tags
10
+ // `SYMBIOTE_ELEMENTS` covers, kept for one stated reason their own header gives: "Declaring `style`
11
+ // as a real Angular input prevents Angular's CSS style engine from decomposing RN `StyleProp`
12
+ // arrays". `SymbioteStyleHost` does exactly that now, for every tag, at a directive's cost rather
13
+ // than a component's.
14
+ //
15
+ // An app loses nothing it cannot spell better: `@ViewChild('ref')` on a template reference returns
16
+ // Angular's own `ElementRef<IHostInstance>`, whose `nativeElement` IS the engine host node — the same
17
+ // property the component exposed, reached without a component instance per element.
18
+ //
19
+ // They stay EXPORTED because the composed components render them and ngtsc resolves a component's
20
+ // template dependencies to importable names (NG3004, the same rule that put the element directives
21
+ // back below). Retiring them means retiring them from those nine components first.
7
22
 
8
23
  import './register';
9
24
 
@@ -123,10 +138,28 @@ export type {
123
138
  } from './components';
124
139
  export { setImageSourceResolver } from './components';
125
140
  // The element directives that make a HAND-WRITTEN intrinsic tag (`<view>`, `<text-input>`, ...)
126
- // compile under ngtsc with no schema, and with a real type on every declared prop. `imports:
127
- // [SYMBIOTE_ELEMENTS]` is the whole app-facing surface; the individual classes are exported for an
128
- // app that wants a narrower import. See `elements.ts`'s header for why a directive rather than
129
- // `CUSTOM_ELEMENTS_SCHEMA`/`NO_ERRORS_SCHEMA`.
141
+ // compile under ngtsc with no schema, and with a real type on every declared prop. See `elements.ts`
142
+ // for why a directive rather than `CUSTOM_ELEMENTS_SCHEMA`/`NO_ERRORS_SCHEMA`.
143
+ //
144
+ // `imports: [SYMBIOTE_ELEMENTS]` IS THE ONLY SUPPORTED SPELLING — and the individual classes are
145
+ // exported anyway, because ngtsc requires it and a build proves it.
146
+ //
147
+ // They were removed on 2026-09-18 and put back the same hour. The reason to remove them is real: a
148
+ // withheld tag directive (`./runtime-matching`) is a compile-time declaration and nothing else, so
149
+ // what makes `<view [style]="[a, b]">` work at RUN time is `SymbioteStyleHost`, and what makes
150
+ // `<view [onPress]="fn">` work at all is `SymbioteCallbackHost` claiming the name before
151
+ // `setDomProperty` throws NG0306. Both ride this array. A narrow `imports: [ViewElement]` type-checks
152
+ // and then fails on a device the first time the app writes an array style.
153
+ //
154
+ // WHAT PUTS THEM BACK is NG3004: `Unable to import directive ViewElement — the symbol is not
155
+ // exported from index.d.ts`. ngtsc resolves every directive reachable through an imported array to an
156
+ // IMPORTABLE NAME in the package's public types, so a class inside `SYMBIOTE_ELEMENTS` that the
157
+ // barrel does not name breaks AOT for every screen that uses the array. The removal passed its own
158
+ // vitest guard and every headless suite, and failed on the first real `ngc` run — the same
159
+ // wrong-harness green this repo keeps finding.
160
+ //
161
+ // So the narrow spelling is DISCOURAGED and not prevented, and `elements-are-exported-for-aot.test.ts`
162
+ // now asserts the opposite invariant: every member of the array is named here.
130
163
  export {
131
164
  SYMBIOTE_ELEMENTS,
132
165
  SymbioteElement,
@@ -138,9 +171,6 @@ export {
138
171
  ImageBackgroundElement,
139
172
  ImageElement,
140
173
  InputAccessoryViewElement,
141
- ManagedMultilineTextInputElement,
142
- ManagedSwitchElement,
143
- ManagedTextInputElement,
144
174
  ModalElement,
145
175
  MultilineTextInputElement,
146
176
  PressableElement,
@@ -150,22 +180,25 @@ export {
150
180
  ScrollViewElement,
151
181
  StickyHeaderElement,
152
182
  SwitchElement,
183
+ SwitchValueAccessor,
153
184
  TextElement,
154
185
  TextInputElement,
155
- // The `[(ngModel)]` / `formControl*` accessors for the two controlled tags — where the deleted
156
- // wrappers' `NG_VALUE_ACCESSOR` went. Both ride `SYMBIOTE_ELEMENTS`; named separately for an app
157
- // that imports narrowly.
158
- SwitchValueAccessor,
159
186
  TextInputValueAccessor,
160
- // The three the list omitted while their components still existed. These are now the ONLY
161
- // replacements for their deleted components in an app's `imports`, so a narrower import has to be
162
- // able to name them.
163
187
  TouchableHighlightElement,
164
188
  TouchableNativeFeedbackElement,
165
189
  TouchableOpacityElement,
166
190
  TouchableWithoutFeedbackElement,
167
191
  ViewElement,
168
192
  } from './elements';
193
+ // `CALLBACK_ATTRIBUTE_SELECTOR` is exported so a MEASUREMENT can carry the real string rather than a
194
+ // copy of it — the ladder in `core/engine/cpp/tests/js/angular-directive-cost.itest.ts` prices what
195
+ // this selector costs to match, and a second copy there would price a different one the day either
196
+ // drifts. The two host classes are exported for the same reason the array is: they are part of it.
197
+ export {
198
+ CALLBACK_ATTRIBUTE_SELECTOR,
199
+ SymbioteCallbackHost,
200
+ } from './callback-host';
201
+ export { STYLE_HOST_SELECTOR, SymbioteStyleHost } from './style-host';
169
202
  export type {
170
203
  IElementProps,
171
204
  IStickyHeaderElementProps,
@@ -3,16 +3,7 @@
3
3
  // component imports them and renders them in its template. Declaring `style` as a real
4
4
  // Angular input prevents Angular's CSS style engine from decomposing RN `StyleProp` arrays.
5
5
 
6
- import {
7
- Component,
8
- Input,
9
- type OnInit,
10
- type SimpleChanges,
11
- } from '@angular/core';
12
- import {
13
- resolveTextProps,
14
- type IEllipsizeMode,
15
- } from '@symbiote-native/components';
6
+ import { Component } from '@angular/core';
16
7
  import { SymbiotePrimitiveHost } from './shared';
17
8
 
18
9
  export {
@@ -31,46 +22,26 @@ export {
31
22
  export class ViewHost extends SymbiotePrimitiveHost {}
32
23
 
33
24
  /**
34
- * Text carries the two defaults RN's Text.js applies unconditionally (`ellipsizeMode ?? 'tail'`,
35
- * `allowFontScaling !== false`, Text.js:289 and :291). Without them native falls back to `clip` and a
36
- * clamped Text cuts mid-word with no ellipsis — device-observed on the other adapters 2026-08-19.
25
+ * Text is an ORDINARY primitive host as of 2026-09-18, and the two `@Input()`s it used to declare
26
+ * are gone with the code that needed them.
37
27
  *
38
- * They are declared as real `@Input()`s, unlike the pass-through props the base's comment
39
- * describes, for one reason: the default can only be applied by code that can SEE whether the
40
- * caller supplied a value. A host-binding pass-through is invisible here, so blindly writing
41
- * 'tail' would silently overwrite an explicit `ellipsizeMode="clip"`.
28
+ * They existed for one stated reason: a default can only be applied by code that can SEE whether the
29
+ * caller supplied a value, and a pass-through host binding is invisible to the component so the
30
+ * defaults had to be applied where the inputs were readable. That argument was sound and it is now
31
+ * answered one layer down. The payload builder reads the AUTHORED bag, so it can tell an absent
32
+ * `ellipsizeMode` from an explicit `clip` without anyone declaring anything; the rule is keyed on the
33
+ * component (`foldTextDefaults`, `SymbioteFabricProps.cpp`) and reaches every `RCTText` however it
34
+ * was spelled.
35
+ *
36
+ * The pass-through is therefore the CORRECT path for both props now, and `text-defaults.test.ts`'s
37
+ * "never overwrites a value the caller supplied" is what proves the authored value still arrives.
42
38
  */
43
39
  @Component({
44
40
  selector: 'text',
45
41
  standalone: true,
46
42
  template: '<ng-content></ng-content>',
47
43
  })
48
- export class TextHost extends SymbiotePrimitiveHost implements OnInit {
49
- @Input() ellipsizeMode?: IEllipsizeMode;
50
- @Input() allowFontScaling?: boolean;
51
-
52
- // ngOnChanges fires before ngOnInit when a binding exists, and not at all when none does —
53
- // so both hooks are needed to guarantee the defaults land exactly once per settled value.
54
- ngOnInit(): void {
55
- this.applyTextDefaults();
56
- }
57
-
58
- override ngOnChanges(changes: SimpleChanges): void {
59
- super.ngOnChanges(changes);
60
- if ('ellipsizeMode' in changes || 'allowFontScaling' in changes) {
61
- this.applyTextDefaults();
62
- }
63
- }
64
-
65
- private applyTextDefaults(): void {
66
- const resolved = resolveTextProps({
67
- ellipsizeMode: this.ellipsizeMode,
68
- allowFontScaling: this.allowFontScaling,
69
- });
70
- this.setHostProp('ellipsizeMode', resolved.ellipsizeMode);
71
- this.setHostProp('allowFontScaling', resolved.allowFontScaling);
72
- }
73
- }
44
+ export class TextHost extends SymbiotePrimitiveHost {}
74
45
 
75
46
  @Component({
76
47
  selector: 'image',
@@ -107,9 +78,6 @@ export class HorizontalScrollView extends SymbiotePrimitiveHost {}
107
78
  })
108
79
  export class HorizontalScrollContentView extends SymbiotePrimitiveHost {}
109
80
 
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.
113
81
  @Component({
114
82
  selector: 'text-input',
115
83
  standalone: true,
@@ -124,20 +92,6 @@ export class TextInputHost extends SymbiotePrimitiveHost {}
124
92
  })
125
93
  export class MultilineTextInputHost extends SymbiotePrimitiveHost {}
126
94
 
127
- @Component({
128
- selector: 'text-input-managed',
129
- standalone: true,
130
- template: '<ng-content></ng-content>',
131
- })
132
- export class ManagedTextInputHost extends SymbiotePrimitiveHost {}
133
-
134
- @Component({
135
- selector: 'text-input-multiline-managed',
136
- standalone: true,
137
- template: '<ng-content></ng-content>',
138
- })
139
- export class ManagedMultilineTextInputHost extends SymbiotePrimitiveHost {}
140
-
141
95
  @Component({
142
96
  selector: 'switch',
143
97
  standalone: true,
@@ -145,16 +99,6 @@ export class ManagedMultilineTextInputHost extends SymbiotePrimitiveHost {}
145
99
  })
146
100
  export class SwitchHost extends SymbiotePrimitiveHost {}
147
101
 
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: 'switch-managed',
153
- standalone: true,
154
- template: '<ng-content></ng-content>',
155
- })
156
- export class ManagedSwitchHost extends SymbiotePrimitiveHost {}
157
-
158
102
  // The centering RCTView RN wraps the spinner in (ActivityIndicator.js:112), not the spinner —
159
103
  // which is what this tag resolved to until 2026-09-09. The native view moved to
160
104
  // `activity-indicator-spinner`, built by the engine's ActivityIndicator behavior, so nothing writes