@symbiote-native/angular 0.7.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/babel-lower-host-primitives.cjs +636 -0
- package/babel-register-composed.cjs +21 -3
- package/build/angular/anchor-host-registry.js +3 -1
- package/build/angular/anchor-host-registry.js.map +1 -1
- package/build/angular/bootstrap.js +1 -1
- package/build/angular/bootstrap.js.map +1 -1
- package/build/angular/components/activity-indicator/index.android.js +7 -2
- package/build/angular/components/activity-indicator/index.android.js.map +1 -1
- package/build/angular/components/activity-indicator/index.ios.js +3 -1
- package/build/angular/components/activity-indicator/index.ios.js.map +1 -1
- package/build/angular/components/activity-indicator/shared.d.ts +1 -1
- package/build/angular/components/activity-indicator/shared.js +1 -1
- package/build/angular/components/activity-indicator/shared.js.map +1 -1
- package/build/angular/components/button.d.ts +3 -0
- package/build/angular/components/button.js +22 -5
- package/build/angular/components/button.js.map +1 -1
- package/build/angular/components/flat-list/index.d.ts +1 -1
- package/build/angular/components/flat-list/index.js +47 -14
- package/build/angular/components/flat-list/index.js.map +1 -1
- package/build/angular/components/image/index.android.js +4 -10
- package/build/angular/components/image/index.android.js.map +1 -1
- package/build/angular/components/image/index.ios.js +4 -10
- package/build/angular/components/image/index.ios.js.map +1 -1
- package/build/angular/components/image/shared.d.ts +4 -4
- package/build/angular/components/image/shared.js +51 -33
- package/build/angular/components/image/shared.js.map +1 -1
- package/build/angular/components/image-background.d.ts +3 -0
- package/build/angular/components/image-background.js +32 -9
- package/build/angular/components/image-background.js.map +1 -1
- package/build/angular/components/input-accessory-view/index.d.ts +2 -1
- package/build/angular/components/input-accessory-view/index.js +18 -16
- package/build/angular/components/input-accessory-view/index.js.map +1 -1
- package/build/angular/components/keyboard-avoiding-view/index.d.ts +3 -1
- package/build/angular/components/keyboard-avoiding-view/index.js +56 -19
- package/build/angular/components/keyboard-avoiding-view/index.js.map +1 -1
- package/build/angular/components/modal/index.d.ts +1 -0
- package/build/angular/components/modal/index.js +21 -10
- package/build/angular/components/modal/index.js.map +1 -1
- package/build/angular/components/pressable/index.d.ts +10 -5
- package/build/angular/components/pressable/index.js +61 -16
- package/build/angular/components/pressable/index.js.map +1 -1
- package/build/angular/components/refresh-control/index.d.ts +2 -1
- package/build/angular/components/refresh-control/index.js +13 -9
- package/build/angular/components/refresh-control/index.js.map +1 -1
- package/build/angular/components/safe-area-view/index.d.ts +3 -1
- package/build/angular/components/safe-area-view/index.js +22 -20
- package/build/angular/components/safe-area-view/index.js.map +1 -1
- package/build/angular/components/scroll-view/index.android.js +44 -14
- package/build/angular/components/scroll-view/index.android.js.map +1 -1
- package/build/angular/components/scroll-view/index.ios.js +13 -5
- package/build/angular/components/scroll-view/index.ios.js.map +1 -1
- package/build/angular/components/scroll-view/projection.d.ts +32 -0
- package/build/angular/components/scroll-view/projection.js +212 -47
- package/build/angular/components/scroll-view/projection.js.map +1 -1
- package/build/angular/components/scroll-view/shared.d.ts +2 -0
- package/build/angular/components/scroll-view/shared.js +48 -11
- package/build/angular/components/scroll-view/shared.js.map +1 -1
- package/build/angular/components/scroll-view/sticky-header.js +7 -3
- package/build/angular/components/scroll-view/sticky-header.js.map +1 -1
- package/build/angular/components/section-list/index.d.ts +9 -1
- package/build/angular/components/section-list/index.js +72 -18
- package/build/angular/components/section-list/index.js.map +1 -1
- package/build/angular/components/switch/index.android.js +23 -9
- package/build/angular/components/switch/index.android.js.map +1 -1
- package/build/angular/components/switch/index.ios.js +23 -9
- package/build/angular/components/switch/index.ios.js.map +1 -1
- package/build/angular/components/switch/shared.d.ts +1 -1
- package/build/angular/components/switch/shared.js +5 -2
- package/build/angular/components/switch/shared.js.map +1 -1
- package/build/angular/components/text-input.d.ts +8 -2
- package/build/angular/components/text-input.js +79 -34
- package/build/angular/components/text-input.js.map +1 -1
- package/build/angular/components/touchable/index.d.ts +36 -7
- package/build/angular/components/touchable/index.js +280 -72
- package/build/angular/components/touchable/index.js.map +1 -1
- package/build/angular/components/touchable-native-feedback/index.d.ts +2 -1
- package/build/angular/components/touchable-native-feedback/index.js +19 -17
- package/build/angular/components/touchable-native-feedback/index.js.map +1 -1
- package/build/angular/components/virtualized-list/directives.js +4 -0
- package/build/angular/components/virtualized-list/directives.js.map +1 -1
- package/build/angular/components/virtualized-list/index.d.ts +8 -2
- package/build/angular/components/virtualized-list/index.js +207 -61
- package/build/angular/components/virtualized-list/index.js.map +1 -1
- package/build/angular/components/virtualized-section-list/directives.js.map +1 -1
- package/build/angular/components/virtualized-section-list/index.d.ts +21 -1
- package/build/angular/components/virtualized-section-list/index.js +100 -22
- package/build/angular/components/virtualized-section-list/index.js.map +1 -1
- package/build/angular/components.d.ts +2 -2
- package/build/angular/components.js +1 -1
- package/build/angular/components.js.map +1 -1
- package/build/angular/create-portal/index.d.ts +11 -2
- package/build/angular/create-portal/index.js +42 -12
- package/build/angular/create-portal/index.js.map +1 -1
- package/build/angular/create-tunnel/index.js.map +1 -1
- package/build/angular/descriptor-to-angular/index.js +3 -1
- package/build/angular/descriptor-to-angular/index.js.map +1 -1
- package/build/angular/diagnostics.d.ts +33 -0
- package/build/angular/diagnostics.js +82 -0
- package/build/angular/diagnostics.js.map +1 -0
- package/build/angular/gate-demand.d.ts +37 -0
- package/build/angular/gate-demand.js +84 -0
- package/build/angular/gate-demand.js.map +1 -0
- package/build/angular/host-instance/index.js +1 -1
- package/build/angular/host-instance/index.js.map +1 -1
- package/build/angular/index.d.ts +6 -2
- package/build/angular/index.js +16 -1
- package/build/angular/index.js.map +1 -1
- package/build/angular/modules/animated/create-animated-component.d.ts +1 -0
- package/build/angular/modules/animated/create-animated-component.js +41 -18
- package/build/angular/modules/animated/create-animated-component.js.map +1 -1
- package/build/angular/modules/app-registry/index.js.map +1 -1
- package/build/angular/modules/status-bar/index.js +1 -1
- package/build/angular/modules/status-bar/index.js.map +1 -1
- package/build/angular/primitives/index.d.ts +31 -2
- package/build/angular/primitives/index.js +81 -3
- package/build/angular/primitives/index.js.map +1 -1
- package/build/angular/primitives/shared.d.ts +9 -1
- package/build/angular/primitives/shared.js +49 -3
- package/build/angular/primitives/shared.js.map +1 -1
- package/build/angular/register.d.ts +1 -0
- package/build/angular/register.js +35 -0
- package/build/angular/register.js.map +1 -0
- package/build/angular/render/index.js +51 -6
- package/build/angular/render/index.js.map +1 -1
- package/build/angular/renderer/index.d.ts +1 -0
- package/build/angular/renderer/index.js +157 -21
- package/build/angular/renderer/index.js.map +1 -1
- package/build/angular/services/color-scheme.service.js.map +1 -1
- package/build/angular/services/window-dimensions.service.js +1 -1
- package/build/angular/services/window-dimensions.service.js.map +1 -1
- package/build/angular/state-style.d.ts +9 -0
- package/build/angular/state-style.js +39 -0
- package/build/angular/state-style.js.map +1 -0
- package/metro-transformer.cjs +69 -0
- package/package.json +27 -8
- package/src/anchor-host-registry.ts +3 -1
- package/src/bootstrap.ts +4 -1
- package/src/components/activity-indicator/index.android.ts +7 -2
- package/src/components/activity-indicator/index.ios.ts +3 -1
- package/src/components/activity-indicator/shared.ts +32 -13
- package/src/components/button.ts +36 -6
- package/src/components/flat-list/index.ts +91 -29
- package/src/components/image/index.android.ts +9 -6
- package/src/components/image/index.ios.ts +9 -6
- package/src/components/image/shared.ts +110 -45
- package/src/components/image-background.ts +47 -10
- package/src/components/input-accessory-view/index.ts +41 -18
- package/src/components/keyboard-avoiding-view/index.ts +76 -22
- package/src/components/modal/index.ts +39 -12
- package/src/components/pressable/index.ts +121 -31
- package/src/components/refresh-control/index.ts +36 -13
- package/src/components/safe-area-view/index.ts +44 -19
- package/src/components/scroll-view/index.android.ts +31 -9
- package/src/components/scroll-view/index.ios.ts +17 -4
- package/src/components/scroll-view/projection.ts +290 -63
- package/src/components/scroll-view/shared.ts +124 -30
- package/src/components/scroll-view/sticky-header.ts +25 -8
- package/src/components/section-list/index.ts +79 -19
- package/src/components/switch/index.android.ts +18 -7
- package/src/components/switch/index.ios.ts +18 -7
- package/src/components/switch/shared.ts +15 -4
- package/src/components/text-input.ts +119 -31
- package/src/components/touchable/index.ts +339 -72
- package/src/components/touchable-native-feedback/index.ts +50 -18
- package/src/components/virtualized-list/directives.ts +19 -5
- package/src/components/virtualized-list/index.ts +292 -72
- package/src/components/virtualized-section-list/directives.ts +6 -3
- package/src/components/virtualized-section-list/index.ts +164 -34
- package/src/components.ts +6 -1
- package/src/create-portal/index.ts +47 -11
- package/src/create-tunnel/index.ts +3 -1
- package/src/descriptor-to-angular/index.ts +29 -8
- package/src/diagnostics.ts +138 -0
- package/src/gate-demand.ts +109 -0
- package/src/host-instance/index.ts +9 -3
- package/src/index.ts +33 -2
- package/src/modules/animated/create-animated-component.ts +67 -19
- package/src/modules/app-registry/index.ts +3 -1
- package/src/modules/status-bar/index.ts +13 -4
- package/src/primitives/index.ts +73 -2
- package/src/primitives/shared.ts +70 -9
- package/src/register.ts +43 -0
- package/src/render/index.ts +59 -7
- package/src/renderer/index.ts +209 -37
- package/src/services/color-scheme.service.ts +3 -1
- package/src/services/window-dimensions.service.ts +11 -4
- package/src/state-style.ts +39 -0
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# @symbiote-native/angular
|
|
2
2
|
|
|
3
3
|
The **Angular adapter** for [SymbioteNative](../../README.md) — render real native iOS/Android views
|
|
4
|
-
from Angular, on the
|
|
4
|
+
from Angular, on the _same_ untouched core as React and Vue, with React Native's own renderer
|
|
5
5
|
never in the path. It is a `Renderer2`/`RendererFactory2` whose calls map onto the engine's
|
|
6
6
|
four-call mutation API; `@symbiote-native/engine` does the clone-on-write commit into Fabric.
|
|
7
7
|
|
|
@@ -34,7 +34,7 @@ own top-level dependencies. There's no `create-symbiote` scaffolder yet, so the
|
|
|
34
34
|
|
|
35
35
|
## Use it
|
|
36
36
|
|
|
37
|
-
The native entry reaches the
|
|
37
|
+
The native entry reaches the _same_ `registerRunnable` seam as React and Vue — only the adapter
|
|
38
38
|
changes. It hands the surface's `rootTag` to `mount` from `@symbiote-native/angular`, which drives the
|
|
39
39
|
engine through Angular's `Renderer2`:
|
|
40
40
|
|
|
@@ -94,7 +94,7 @@ detection + the descriptor→`createElement` bridge).
|
|
|
94
94
|
One deliberate gap, tracked, not blocking the canary — **third-party React component packages**
|
|
95
95
|
(`@react-native-community/slider`) run only under the React adapter: their body calls React hooks
|
|
96
96
|
off the React dispatcher, which is null under Angular. `@symbiote-native/slider` (this repo's own
|
|
97
|
-
wrapper)
|
|
97
|
+
wrapper) _does_ ship a real Angular build, reachable through the same `createNode`-by-ViewConfig
|
|
98
98
|
path Angular uses for its own primitives — that wrapper is what makes a third-party native view
|
|
99
99
|
usable from a non-React adapter at all.
|
|
100
100
|
|
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
// Compile-time lowering of `<View>`/`<Text>`/`<Pressable>`/`<TextInput>` to their intrinsic tags —
|
|
2
|
+
// the automated form of the hand-written technique already proven in
|
|
3
|
+
// examples/angular/src/screens/BenchmarkScreen.ts's `LoweredBenchmarkRows` (33.2% headless cut on
|
|
4
|
+
// Create-1000, angular-adapter-change-detection §19). That technique needs an author to spell the
|
|
5
|
+
// intrinsic tag AND keep the wrapper out of the template's own `imports` — this plugin does both
|
|
6
|
+
// mechanically, for any ordinary app component.
|
|
7
|
+
//
|
|
8
|
+
// THE SEAM. Runs in the SAME Babel pass as `babel-register-composed.cjs`, BEFORE the linker
|
|
9
|
+
// (`babel-linker.cjs`) — Stage A (`ngc --compilationMode partial`) has already emitted
|
|
10
|
+
// `ɵɵngDeclareComponent({ template: \`...\`, dependencies: [...] })`, and at THIS stage `template`
|
|
11
|
+
// is a plain string and `dependencies` a plain array the linker has not yet turned into Ivy. Two
|
|
12
|
+
// edits on that ONE object, matched by the same CallExpression:
|
|
13
|
+
//
|
|
14
|
+
// template `<View class="x">` -> `<symbiote-view class="x">`
|
|
15
|
+
// dependencies [{ type: View, selector: "symbiote-view, View" }, ...] -> entry REMOVED
|
|
16
|
+
//
|
|
17
|
+
// The second edit is not cleanup, it IS the mechanism. `dependencies`' `selector` string is BOTH
|
|
18
|
+
// spellings of View's dual selector at once (`'symbiote-view, View'` — see
|
|
19
|
+
// `adapters/angular/src/primitives/index.ts`), so leaving the entry in place while rewriting only
|
|
20
|
+
// the template text makes `symbiote-view` keep resolving to the real component: a rewritten tag
|
|
21
|
+
// with its dependency intact is not lowered, it looks lowered and silently is not. Confirmed
|
|
22
|
+
// against the installed compiler-cli (2026-08-31): removing a component's `ɵɵngDeclareComponent`
|
|
23
|
+
// `dependencies` entry with NO corresponding `schemas` change still links and runs — `schemas`
|
|
24
|
+
// (`CUSTOM_ELEMENTS_SCHEMA`) is consulted only by Stage A's ngtsc type-checker against the
|
|
25
|
+
// ORIGINAL source, never by the linker against this declared metadata, so an ordinary app
|
|
26
|
+
// component needs no `schemas`/`CUSTOM_ELEMENTS_SCHEMA` change at all to become lowerable — unlike
|
|
27
|
+
// `LoweredBenchmarkRows`, which types `<symbiote-view>` directly in source and therefore DOES need
|
|
28
|
+
// it, for ngtsc's sake, not the linker's. `Pressable`/`TextInput` carry a SINGLE-name selector
|
|
29
|
+
// (`'Pressable'`, `'TextInput'`, no comma), so removing their dependency entry is the same
|
|
30
|
+
// single-token removal, not a dual-spelling split.
|
|
31
|
+
//
|
|
32
|
+
// WHY View/Text/Pressable/TextInput AND NOT MORE — `HOST_PRIMITIVES` lists five; `Image` is not
|
|
33
|
+
// here because it has no `observesState`/`intrinsicWhen` prerequisite blocking it, it is simply
|
|
34
|
+
// not yet done. The other four are done as of 2026-09-01:
|
|
35
|
+
//
|
|
36
|
+
// Pressable `observesState: true`. Landed here once two prerequisites closed. First, the
|
|
37
|
+
// engine-side press machine (`registerPressableBehavior`, called from
|
|
38
|
+
// `register.ts`, below). Second — RETIRED 2026-08-31, corrected here because the
|
|
39
|
+
// header used to describe it as still open — the anchor-registry collision:
|
|
40
|
+
// `symbiote-pressable` used to be one spelling of the COMPOSED Pressable's own dual
|
|
41
|
+
// selector (`'Pressable, symbiote-pressable'`), which forced it unconditionally into
|
|
42
|
+
// `ANCHOR_HOST_COMPONENTS` and made a bare lowered tag collide with its own anchor.
|
|
43
|
+
// The composed selector dropped the vestigial second spelling (nothing ever rendered
|
|
44
|
+
// or matched it) and `symbiote-pressable` came out of `ANCHOR_HOST_COMPONENTS`, so
|
|
45
|
+
// the bare tag now resolves unambiguously to the engine-node behavior — see
|
|
46
|
+
// Pressable's own `@Component` comment and `.claude/rules/host-primitive-tier.md`.
|
|
47
|
+
// TextInput `intrinsicWhen: { prop: 'multiline', intrinsic: 'symbiote-text-input-multiline' }`
|
|
48
|
+
// — `multiline` selects between two DIFFERENT native views, not one view with a
|
|
49
|
+
// flag, so a runtime value refuses (`REFUSAL_CATEGORIES.dynamicIntrinsicChoice`,
|
|
50
|
+
// `staticTruthOf`/`intrinsicFor` below). The wrapper renders `-managed`/
|
|
51
|
+
// `-multiline-managed` tags (`component-names/shared.ts`), so the LOWERED plain pair
|
|
52
|
+
// never collides with a wrapper-built node the way a shared tag would have.
|
|
53
|
+
//
|
|
54
|
+
// THE REFUSAL CRITERION (agreed across cross-session work, replaces a per-category checklist): a
|
|
55
|
+
// compile-time refusal is owed exactly where the transform would make a decision FROM THE STATIC
|
|
56
|
+
// ATTRIBUTE LIST that the runtime cannot replay. A prop FOLD is never a reason to refuse — the
|
|
57
|
+
// engine folds by key, at commit time, on a lowered node exactly as it would on a wrapped one
|
|
58
|
+
// (`core/engine/src/accessibility-props.ts`'s aria fold is the reference case). Tag CHOICE is
|
|
59
|
+
// (TextInput's `intrinsicWhen`, above). For View/Text, with `observesState` unset, the one thing
|
|
60
|
+
// this criterion reaches is `#ref`:
|
|
61
|
+
//
|
|
62
|
+
// `#ref` on a WRAPPED `<View>`/`<Text>` yields the `ViewHost`/`TextHost` COMPONENT INSTANCE
|
|
63
|
+
// (`SymbiotePrimitiveHost`'s own `nativeElement` getter, `style` @Input, `ngDoCheck`) — Angular's
|
|
64
|
+
// own default resolves a bare template reference to a matched component/directive instance
|
|
65
|
+
// before falling back to the element. A lowered bare `<symbiote-view #ref>` matches no directive
|
|
66
|
+
// (see the dependency-removal step above) and hands back the raw engine node DIRECTLY instead.
|
|
67
|
+
// Same object, reached two different ways, is fine; a DIFFERENT surface is the hazard this
|
|
68
|
+
// project's own `instance-bound-directive` fixture row exists for (`core/components/
|
|
69
|
+
// lowering-fixtures.cjs`) — lowering must not change what an app's `ref` receives, in EITHER
|
|
70
|
+
// direction. Unlike Solid (whose View/Text explicitly forward the SAME node either way, so only
|
|
71
|
+
// Pressable there needs the refusal — `.claude/rules/solid-host-primitive-lowering.md`), Angular's
|
|
72
|
+
// wrapper does NOT forward identically, so this refuses UNIVERSALLY here, the same width as Vue's
|
|
73
|
+
// — same verdict, different per-adapter reason, which is the right shape
|
|
74
|
+
// (`<prop_types_split_agnostic_vs_per_adapter>`'s "must every adapter answer identically by
|
|
75
|
+
// construction" test says no). `#ref` on Pressable would ADD a handle it never had (no public
|
|
76
|
+
// ref on any adapter's Pressable, per `lowering-fixtures.cjs`'s own `instance-bound-directive`
|
|
77
|
+
// row) and on TextInput would SWAP its imperative handle for a bare public instance — both
|
|
78
|
+
// refuse for the same reason View/Text do, so the check below applies to all four names
|
|
79
|
+
// uniformly rather than needing a per-primitive allow-list the way Solid's does.
|
|
80
|
+
//
|
|
81
|
+
// THE ALL-OR-NOTHING RULE, per tag name per template. If ONE `<View>` in a template carries `#ref`
|
|
82
|
+
// (or, for `TextInput`, one instance has a dynamic `multiline`) and refuses, EVERY occurrence of
|
|
83
|
+
// that name in this SAME template stays a component — not just the refusing one.
|
|
84
|
+
// `dependencies`' selector covers the WHOLE template at once, so keeping the entry (because the
|
|
85
|
+
// refusing element still needs it) means the bare tag keeps resolving to the component everywhere
|
|
86
|
+
// in this template regardless of which individual tags this plugin rewrote the text of. Rewriting
|
|
87
|
+
// only SOME occurrences while the dependency survives is not partial coverage, it is every
|
|
88
|
+
// "lowered" spelling silently still being the component — the same failure shape
|
|
89
|
+
// `LoweredBenchmarkRows`'s own header names for the hand-written technique.
|
|
90
|
+
//
|
|
91
|
+
// STYLE ON A STATE-OBSERVING PRIMITIVE (`observesState`, Pressable only today).
|
|
92
|
+
// `REFUSAL_CATEGORIES.stateInTemplate` never fires here or in any transform — coverage is decided
|
|
93
|
+
// by INVOCATION, not by proving the shape (`core/components/lowering-fixtures.cjs`'s own header:
|
|
94
|
+
// "a transform that reports `refuse` here has wired substitution as the mechanism instead of as
|
|
95
|
+
// the optimisation"). Every `style` shape lowers; only the EMISSION differs, and
|
|
96
|
+
// `REFUSAL_CATEGORIES.emitStyleExpressionOnce` sets the one real constraint — an expression capable
|
|
97
|
+
// of doing work (a call, a computed member, a conditional…) is printed in the output exactly ONCE.
|
|
98
|
+
//
|
|
99
|
+
// Angular's template grammar cannot write an inline arrow function at all (`parseTemplate` throws
|
|
100
|
+
// on `[style]="({pressed}) => ({...})"`, verified 2026-08-31 — `specialisable-state-style`/
|
|
101
|
+
// `nested-function-state-style` are UNWRITABLE here, see `lowering-parity.test.ts`), so the
|
|
102
|
+
// "literal function, call it directly" bucket other transforms have simply does not exist in this
|
|
103
|
+
// adapter. Two buckets only:
|
|
104
|
+
//
|
|
105
|
+
// inert object/array/string LITERAL — provably not a function, left untouched.
|
|
106
|
+
// reference a bare identifier or a non-computed property chain (`fnStyle`, `this.a.b`) — safe
|
|
107
|
+
// to print TWICE (a read, not work): `typeof (e) === 'function' ? (e)({pressed}) :
|
|
108
|
+
// (e)`, split across `[style]`/`[activeStyle]`.
|
|
109
|
+
// opaque anything else (`getStyle()`, `bag[i]`, `flag ? a : b`, …) — CANNOT be read twice.
|
|
110
|
+
// Angular templates cannot call an arbitrary imported function (`resolveStateStyle
|
|
111
|
+
// (e)` in a template resolves to `ctx.resolveStateStyle(e)`, a component-INSTANCE
|
|
112
|
+
// method lookup this transform has no component to add one to — verified against the
|
|
113
|
+
// real linker, `state-style.ts`'s own header). A PIPE is the one thing that resolves
|
|
114
|
+
// through `dependencies` like a component does, so this emits
|
|
115
|
+
// `@let v = (e | resolveStateStyle); [style]="v.style" [activeStyle]="v.activeStyle"`
|
|
116
|
+
// — `e` evaluated exactly once (`ɵɵpipeBind1`, one call), `v` read twice for free.
|
|
117
|
+
//
|
|
118
|
+
// .cjs because Babel `require()`s it directly (Metro loads it via a raw require, no transpile step
|
|
119
|
+
// available) and this package is otherwise ESM-flavoured.
|
|
120
|
+
|
|
121
|
+
const {
|
|
122
|
+
HOST_PRIMITIVES,
|
|
123
|
+
REFUSAL_CATEGORIES,
|
|
124
|
+
} = require('@symbiote-native/components/host-primitives');
|
|
125
|
+
const { parseTemplate } = require('@angular/compiler');
|
|
126
|
+
|
|
127
|
+
const SOURCE = '@symbiote-native/angular';
|
|
128
|
+
const PIPE_NAME = 'resolveStateStyle';
|
|
129
|
+
const PIPE_EXPORT = 'SymbioteStateStylePipe';
|
|
130
|
+
const PIPE_MODULE = `${SOURCE}/state-style`;
|
|
131
|
+
|
|
132
|
+
const LOWERABLE_NAMES = [
|
|
133
|
+
'View',
|
|
134
|
+
'Text',
|
|
135
|
+
'Pressable',
|
|
136
|
+
'TextInput',
|
|
137
|
+
// Fold-only: neither sets `observesState` nor `intrinsicWhen`, so every occurrence lowers
|
|
138
|
+
// through the same generic path as View/Text — no new refusal category needed.
|
|
139
|
+
'Image',
|
|
140
|
+
'InputAccessoryView',
|
|
141
|
+
// Carries an ENGINE machine (`registerSwitchBehavior`) but sets neither `observesState` nor
|
|
142
|
+
// `intrinsicWhen`, so this transform's verdict is unaffected by it — same generic path.
|
|
143
|
+
'Switch',
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
// name -> a projection of the shared spec. Built once at plugin load. This is a WHITELIST — see
|
|
147
|
+
// Solid's own LOWERABLE map for why a field the spec grows and this list does not copy simply
|
|
148
|
+
// never reaches the detections below (measured there: `intrinsicWhen` implemented end to end and
|
|
149
|
+
// silently inert because the projection dropped the field).
|
|
150
|
+
const SPECS = new Map(
|
|
151
|
+
LOWERABLE_NAMES.map(name => [
|
|
152
|
+
name,
|
|
153
|
+
{
|
|
154
|
+
intrinsic: HOST_PRIMITIVES[name].intrinsic,
|
|
155
|
+
observesState: HOST_PRIMITIVES[name].observesState === true,
|
|
156
|
+
intrinsicWhen: HOST_PRIMITIVES[name].intrinsicWhen,
|
|
157
|
+
},
|
|
158
|
+
]),
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
function isNgDeclareComponentCall(node) {
|
|
162
|
+
return (
|
|
163
|
+
node.type === 'CallExpression' &&
|
|
164
|
+
node.callee.type === 'MemberExpression' &&
|
|
165
|
+
!node.callee.computed &&
|
|
166
|
+
node.callee.property.type === 'Identifier' &&
|
|
167
|
+
node.callee.property.name === 'ɵɵngDeclareComponent'
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function propertyOf(objectExpression, name) {
|
|
172
|
+
return objectExpression.properties.find(
|
|
173
|
+
prop =>
|
|
174
|
+
prop.type === 'ObjectProperty' &&
|
|
175
|
+
!prop.computed &&
|
|
176
|
+
((prop.key.type === 'Identifier' && prop.key.name === name) ||
|
|
177
|
+
(prop.key.type === 'StringLiteral' && prop.key.value === name)),
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ngc's own partial-declare emit always writes `template` as a backtick template literal with NO
|
|
182
|
+
// interpolation, never a plain StringLiteral (verified against the installed compiler-cli,
|
|
183
|
+
// 2026-08-31) — presumably to dodge quote-escaping the author's own HTML. Read/write through the
|
|
184
|
+
// one quasi; bail (return undefined) on anything else rather than guess.
|
|
185
|
+
function templateTextOf(valueNode) {
|
|
186
|
+
if (
|
|
187
|
+
valueNode.type === 'TemplateLiteral' &&
|
|
188
|
+
valueNode.expressions.length === 0 &&
|
|
189
|
+
valueNode.quasis.length === 1
|
|
190
|
+
) {
|
|
191
|
+
return valueNode.quasis[0].value.raw;
|
|
192
|
+
}
|
|
193
|
+
if (valueNode.type === 'StringLiteral') return valueNode.value;
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Per-file flag for the Program-exit guard below, set the moment this transform edits anything.
|
|
198
|
+
const EDITED_KEY = 'symbiote-lower-host-primitives/edited';
|
|
199
|
+
|
|
200
|
+
function setTemplateText(valueNode, text) {
|
|
201
|
+
if (valueNode.type === 'TemplateLiteral') {
|
|
202
|
+
valueNode.quasis[0].value.raw = text;
|
|
203
|
+
valueNode.quasis[0].value.cooked = text;
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
valueNode.value = text;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Every shape this codebase's templates actually nest a lowerable element under, empirically
|
|
210
|
+
// checked against the installed `@angular/compiler` (2026-08-31): a plain Element/Template holds
|
|
211
|
+
// `.children`; `@if`/`@else` holds `.branches[].children`; `@switch` holds `.groups[].children`
|
|
212
|
+
// (the group, not each case, carries the children); `@for` holds `.children` plus a SEPARATE
|
|
213
|
+
// `.empty` block. NOT walked: `@defer`'s placeholder/loading/error sub-blocks — grepped, nothing
|
|
214
|
+
// in this repo's templates uses `@defer` today; add those four properties here before the first
|
|
215
|
+
// one does.
|
|
216
|
+
function childrenOf(node) {
|
|
217
|
+
const out = [];
|
|
218
|
+
if (Array.isArray(node.children)) out.push(...node.children);
|
|
219
|
+
if (Array.isArray(node.branches)) {
|
|
220
|
+
for (const branch of node.branches) out.push(...(branch.children || []));
|
|
221
|
+
}
|
|
222
|
+
if (Array.isArray(node.groups)) {
|
|
223
|
+
for (const group of node.groups) out.push(...(group.children || []));
|
|
224
|
+
}
|
|
225
|
+
if (node.empty !== undefined && node.empty !== null) {
|
|
226
|
+
out.push(...(node.empty.children || []));
|
|
227
|
+
}
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function walk(nodes, visit) {
|
|
232
|
+
for (const node of nodes) {
|
|
233
|
+
visit(node);
|
|
234
|
+
walk(childrenOf(node), visit);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// `.name` alone is not enough to prove this is a real TmplAstElement — check the element-specific
|
|
239
|
+
// array fields too, so a differently-shaped node that merely happens to carry a `.name` (unlikely
|
|
240
|
+
// today, cheap to guard) cannot be misread as a lowerable tag.
|
|
241
|
+
function isElementNamed(node, name) {
|
|
242
|
+
return (
|
|
243
|
+
node.name === name &&
|
|
244
|
+
Array.isArray(node.attributes) &&
|
|
245
|
+
Array.isArray(node.inputs) &&
|
|
246
|
+
Array.isArray(node.outputs)
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// REFUSAL_CATEGORIES.instanceBoundDirective — see the header's `#ref` section for why this refuses
|
|
251
|
+
// UNIVERSALLY, on all four names, rather than being scoped the way Solid's per-primitive list is.
|
|
252
|
+
function hasInstanceBoundRef(element) {
|
|
253
|
+
return Array.isArray(element.references) && element.references.length > 0;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// REFUSAL_CATEGORIES.dynamicIntrinsicChoice. IDENTITY, not truthiness — see host-primitives.cjs's
|
|
257
|
+
// own comment on `intrinsicWhen`: a bare attribute is `true`, an explicit boolean literal is
|
|
258
|
+
// itself, absence is `false`, and everything else (including a truthy non-boolean literal like
|
|
259
|
+
// `multiline={1}`) refuses. Verified against the installed `@angular/compiler`, 2026-09-01: a bare
|
|
260
|
+
// `multiline` parses as a plain TextAttribute (`.attributes`), a bound `[multiline]="expr"` as a
|
|
261
|
+
// BoundAttribute (`.inputs`) whose `.value.ast` is a `LiteralPrimitive` for a literal and something
|
|
262
|
+
// else (`PropertyRead`, `Call`, …) for anything the transform cannot resolve.
|
|
263
|
+
function staticTruthOf(element, propName) {
|
|
264
|
+
const plain = element.attributes.find(attribute => attribute.name === propName);
|
|
265
|
+
if (plain !== undefined) return true;
|
|
266
|
+
const bound = element.inputs.find(input => input.name === propName);
|
|
267
|
+
if (bound === undefined) return false;
|
|
268
|
+
const ast = bound.value.ast;
|
|
269
|
+
if (ast.constructor.name !== 'LiteralPrimitive') return undefined;
|
|
270
|
+
return typeof ast.value === 'boolean' ? ast.value : undefined;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function selectsIntrinsicStatically(element, spec) {
|
|
274
|
+
return (
|
|
275
|
+
spec.intrinsicWhen === undefined ||
|
|
276
|
+
staticTruthOf(element, spec.intrinsicWhen.prop) !== undefined
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function intrinsicFor(element, spec) {
|
|
281
|
+
if (spec.intrinsicWhen === undefined) return spec.intrinsic;
|
|
282
|
+
return staticTruthOf(element, spec.intrinsicWhen.prop) === true
|
|
283
|
+
? spec.intrinsicWhen.intrinsic
|
|
284
|
+
: spec.intrinsic;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// `ngModel` needs the ControlValueAccessor the component PROVIDES, and an element cannot be one at
|
|
288
|
+
// all — no rename rescues it, so it refuses on every primitive.
|
|
289
|
+
//
|
|
290
|
+
// `[(value)]` used to refuse here and no longer does, which is worth stating: it desugars to
|
|
291
|
+
// `(valueChange)`, an @Output the component derives from the raw `change` payload — and the LOWERED
|
|
292
|
+
// path already carries the same fold under RN's own spelling, `node.props.onValueChange(value,
|
|
293
|
+
// event)`, called by both behaviors. The renderer routes the binding there (`renderer/index.ts`,
|
|
294
|
+
// `listen()`), so both paths agree. Refusing was tried first and cost the lowering of BOTH
|
|
295
|
+
// primitives outright: `[(value)]` is not one spelling among several, it is how every Angular
|
|
296
|
+
// template writes a Switch or a TextInput, and an optimisation that asks consumers to write
|
|
297
|
+
// differently does not exist.
|
|
298
|
+
//
|
|
299
|
+
// `valueChange` is not a native event and no behavior emits it: `text-input.ts` and `switch/
|
|
300
|
+
// shared.ts` DERIVE it from `change` purely so `[(value)]` works, which is the idiom every Angular
|
|
301
|
+
// template actually writes. Lowered, `[(value)]="name"` desugars to a `(valueChange)` listener that
|
|
302
|
+
// the engine registers and nothing ever fires — the native field echoes keystrokes on its own, so
|
|
303
|
+
// the input looks alive while every value read off it stays frozen. Device-diagnosed 2026-09-02 on
|
|
304
|
+
// `CanaryScreen`; nothing is red, on either side.
|
|
305
|
+
//
|
|
306
|
+
// `ngModel` is the same class one step further out: it needs the ControlValueAccessor the component
|
|
307
|
+
// PROVIDES, and an element cannot be one at all — so it refuses on every primitive, not per name.
|
|
308
|
+
//
|
|
309
|
+
// Per-adapter by construction, so it stays here rather than in `host-primitives.cjs`: which outputs
|
|
310
|
+
// are derived is a fact about Angular's component surface, and the shared spec must not carry one
|
|
311
|
+
// answer for five adapters (`<prop_types_split_agnostic_vs_per_adapter>`, same test).
|
|
312
|
+
const FORMS_BINDINGS = ['ngModel', 'ngModelChange'];
|
|
313
|
+
|
|
314
|
+
// `style` is the one binding name Angular will not hand to a plain element as a VALUE. It compiles
|
|
315
|
+
// to the styling instructions, which decompose the value key by key through Angular's own CSS
|
|
316
|
+
// engine — and that engine cannot represent an RN StyleProp: an ARRAY makes `applyStyling` use
|
|
317
|
+
// each element as a style KEY and throw `prop.indexOf is not a function` mid-change-detection,
|
|
318
|
+
// killing the tick for the whole app. `primitives/shared.ts` states the constraint from the other
|
|
319
|
+
// side: every composed component declares `style` as a real `@Input()` precisely so the binding
|
|
320
|
+
// never reaches that engine. A component input SHADOWS the instruction, so an un-lowered element
|
|
321
|
+
// is safe and a lowered one is not — this transform is what moves it across that line.
|
|
322
|
+
// Device-diagnosed 2026-09-02 on `ImageBackground`'s `<Image [style]="imageStyle">`.
|
|
323
|
+
//
|
|
324
|
+
// A BOUND `[style]` is not refused, it is RENAMED (`styleKeyRenameEdit` below) so the instruction
|
|
325
|
+
// never sees it. A STATIC `style="…"` is refused instead: its value is CSS text, which the engine's
|
|
326
|
+
// prop layer would read as a registered class name rather than as declarations.
|
|
327
|
+
//
|
|
328
|
+
// `class` is NOT in the same class of problem: `ɵɵclassMap` resolves through
|
|
329
|
+
// `addClass`/`removeClass`, which this adapter's renderer implements against the engine's class
|
|
330
|
+
// registry, so a lowered element keeps its classes.
|
|
331
|
+
const LOWERED_STYLE_PROP = 'symbioteStyle';
|
|
332
|
+
|
|
333
|
+
function bindsStaticStyle(element) {
|
|
334
|
+
return element.attributes.some(attribute => attribute.name === 'style');
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Renames the BINDING, never the value — `[style]="x"` becomes `[symbioteStyle]="x"`, which is an
|
|
338
|
+
// ordinary property binding and reaches `Renderer2.setProperty`, where the renderer's PROP_ALIASES
|
|
339
|
+
// folds it straight back to `style`. Runs for every lowered element, alongside (and independent of)
|
|
340
|
+
// the state-style specialisation, which edits only the VALUE span.
|
|
341
|
+
function styleKeyRenameEdit(element) {
|
|
342
|
+
const styleAttr = element.inputs.find(input => input.name === 'style');
|
|
343
|
+
if (styleAttr === undefined || styleAttr.keySpan === undefined) return [];
|
|
344
|
+
return [
|
|
345
|
+
{
|
|
346
|
+
start: styleAttr.keySpan.start.offset,
|
|
347
|
+
end: styleAttr.keySpan.end.offset,
|
|
348
|
+
text: LOWERED_STYLE_PROP,
|
|
349
|
+
},
|
|
350
|
+
];
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function bindsComponentOnlyName(element) {
|
|
354
|
+
const banned = new Set(FORMS_BINDINGS);
|
|
355
|
+
const named = binding => banned.has(binding.name);
|
|
356
|
+
return (
|
|
357
|
+
element.outputs.some(named) ||
|
|
358
|
+
element.inputs.some(named) ||
|
|
359
|
+
element.attributes.some(named)
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// The single per-element refusal check the all-or-nothing gate below asks of every occurrence of
|
|
364
|
+
// a name in a template — `#ref` for all four, a runtime-valued `intrinsicWhen` selector for
|
|
365
|
+
// TextInput, and a binding only the component can deliver. `void REFUSAL_CATEGORIES` below keeps
|
|
366
|
+
// the import a real, checkable binding rather than a string copy of the category names used only
|
|
367
|
+
// in comments.
|
|
368
|
+
function elementRefuses(element, spec, name) {
|
|
369
|
+
return (
|
|
370
|
+
hasInstanceBoundRef(element) ||
|
|
371
|
+
!selectsIntrinsicStatically(element, spec) ||
|
|
372
|
+
bindsComponentOnlyName(element) ||
|
|
373
|
+
bindsStaticStyle(element)
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
void REFUSAL_CATEGORIES;
|
|
377
|
+
|
|
378
|
+
// A LITERAL is provably not a function, so the template cannot be reading press state through it —
|
|
379
|
+
// `{ borderColor: c }` (LiteralMap), `[a, b]` (LiteralArray), `'x'` (LiteralPrimitive: a string
|
|
380
|
+
// literal specifically, though any LiteralPrimitive is inert here — a number/boolean/null style
|
|
381
|
+
// value is not a function either way).
|
|
382
|
+
function isLiteralStyleValue(ast) {
|
|
383
|
+
const kind = ast.constructor.name;
|
|
384
|
+
return kind === 'LiteralMap' || kind === 'LiteralArray' || kind === 'LiteralPrimitive';
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// A bare identifier or a non-computed property chain — `fnStyle`, `this.fnStyle`, `this.a.b` — is
|
|
388
|
+
// cheap to print twice: reading a name twice is not work, unlike calling a function or indexing an
|
|
389
|
+
// array twice. `ImplicitReceiver`/`ThisReceiver` are the two base cases `parseTemplate` produces
|
|
390
|
+
// for a bare name and a `this.`-qualified one respectively (verified against the installed
|
|
391
|
+
// `@angular/compiler`, 2026-09-01).
|
|
392
|
+
function isCheapReference(ast) {
|
|
393
|
+
const kind = ast.constructor.name;
|
|
394
|
+
if (kind === 'ImplicitReceiver' || kind === 'ThisReceiver') return true;
|
|
395
|
+
return kind === 'PropertyRead' && isCheapReference(ast.receiver);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Splits a state-observing primitive's `style` into `style`/`activeStyle`, INVOCATION rather than
|
|
399
|
+
// substitution (see the header) — every shape lowers, only the emission differs. Returns text
|
|
400
|
+
// edits in the same `{start, end, text}` shape the tag-rewrite edits use, so both flow through one
|
|
401
|
+
// sort-and-splice pass. `letState` is per-template (reset per `ɵɵngDeclareComponent` call): a
|
|
402
|
+
// counter for unique `@let` names, and a flag telling the caller whether the `resolveStateStyle`
|
|
403
|
+
// pipe import/dependency needs adding at all.
|
|
404
|
+
function styleEditsFor(element, templateText, letState) {
|
|
405
|
+
const styleAttr = element.inputs.find(input => input.name === 'style');
|
|
406
|
+
if (styleAttr === undefined) return [];
|
|
407
|
+
const ast = styleAttr.value.ast;
|
|
408
|
+
if (isLiteralStyleValue(ast)) return [];
|
|
409
|
+
|
|
410
|
+
const valueSpan = styleAttr.valueSpan;
|
|
411
|
+
const exprText = templateText.slice(
|
|
412
|
+
valueSpan.start.offset,
|
|
413
|
+
valueSpan.end.offset,
|
|
414
|
+
);
|
|
415
|
+
const attrEnd = styleAttr.sourceSpan.end.offset;
|
|
416
|
+
|
|
417
|
+
if (isCheapReference(ast)) {
|
|
418
|
+
const resting = `typeof (${exprText}) === 'function' ? (${exprText})({ pressed: false }) : (${exprText})`;
|
|
419
|
+
const pressed = `typeof (${exprText}) === 'function' ? (${exprText})({ pressed: true }) : (${exprText})`;
|
|
420
|
+
return [
|
|
421
|
+
{ start: valueSpan.start.offset, end: valueSpan.end.offset, text: resting },
|
|
422
|
+
{ start: attrEnd, end: attrEnd, text: ` [activeStyle]="${pressed}"` },
|
|
423
|
+
];
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const varName = `symbioteStateStyle${letState.counter}`;
|
|
427
|
+
letState.counter += 1;
|
|
428
|
+
letState.usesPipe = true;
|
|
429
|
+
const letStart = element.startSourceSpan.start.offset;
|
|
430
|
+
return [
|
|
431
|
+
{
|
|
432
|
+
start: letStart,
|
|
433
|
+
end: letStart,
|
|
434
|
+
text: `@let ${varName} = (${exprText} | ${PIPE_NAME}); `,
|
|
435
|
+
},
|
|
436
|
+
{
|
|
437
|
+
start: valueSpan.start.offset,
|
|
438
|
+
end: valueSpan.end.offset,
|
|
439
|
+
text: `${varName}.style`,
|
|
440
|
+
},
|
|
441
|
+
{ start: attrEnd, end: attrEnd, text: ` [activeStyle]="${varName}.activeStyle"` },
|
|
442
|
+
];
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function findDependencyIndex(dependenciesNode, tagName) {
|
|
446
|
+
if (dependenciesNode === undefined || dependenciesNode.type !== 'ArrayExpression') {
|
|
447
|
+
return -1;
|
|
448
|
+
}
|
|
449
|
+
return dependenciesNode.elements.findIndex(element => {
|
|
450
|
+
if (element === null || element.type !== 'ObjectExpression') return false;
|
|
451
|
+
const selectorProp = propertyOf(element, 'selector');
|
|
452
|
+
if (!selectorProp || selectorProp.value.type !== 'StringLiteral') return false;
|
|
453
|
+
const tokens = selectorProp.value.value.split(',').map(token => token.trim());
|
|
454
|
+
return tokens.includes(tagName);
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
module.exports = function lowerHostPrimitivesPlugin({ types }) {
|
|
459
|
+
// Shared across every `ɵɵngDeclareComponent` call in the FILE, so a second template needing the
|
|
460
|
+
// pipe reuses the one import instead of adding a second binding of the same name.
|
|
461
|
+
let pipeImportLocal;
|
|
462
|
+
|
|
463
|
+
function pipeDependencyEntry(programPath) {
|
|
464
|
+
if (pipeImportLocal === undefined) {
|
|
465
|
+
pipeImportLocal = programPath.scope.generateUidIdentifier(PIPE_EXPORT);
|
|
466
|
+
programPath.unshiftContainer(
|
|
467
|
+
'body',
|
|
468
|
+
types.importDeclaration(
|
|
469
|
+
[types.importSpecifier(pipeImportLocal, types.identifier(PIPE_EXPORT))],
|
|
470
|
+
types.stringLiteral(PIPE_MODULE),
|
|
471
|
+
),
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
return types.objectExpression([
|
|
475
|
+
types.objectProperty(types.identifier('kind'), types.stringLiteral('pipe')),
|
|
476
|
+
types.objectProperty(types.identifier('type'), types.cloneNode(pipeImportLocal, true)),
|
|
477
|
+
types.objectProperty(types.identifier('name'), types.stringLiteral(PIPE_NAME)),
|
|
478
|
+
]);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return {
|
|
482
|
+
name: 'symbiote-lower-host-primitives',
|
|
483
|
+
visitor: {
|
|
484
|
+
Program: {
|
|
485
|
+
enter(programPath, state) {
|
|
486
|
+
programPath.traverse({
|
|
487
|
+
CallExpression(path) {
|
|
488
|
+
if (!isNgDeclareComponentCall(path.node)) return;
|
|
489
|
+
const [arg] = path.node.arguments;
|
|
490
|
+
if (!arg || arg.type !== 'ObjectExpression') return;
|
|
491
|
+
|
|
492
|
+
const templateProp = propertyOf(arg, 'template');
|
|
493
|
+
if (!templateProp) return;
|
|
494
|
+
const templateText = templateTextOf(templateProp.value);
|
|
495
|
+
if (templateText === undefined) return;
|
|
496
|
+
|
|
497
|
+
const dependenciesProp = propertyOf(arg, 'dependencies');
|
|
498
|
+
let dependenciesNode = dependenciesProp
|
|
499
|
+
? dependenciesProp.value
|
|
500
|
+
: undefined;
|
|
501
|
+
|
|
502
|
+
// Never touch a template this parser rejects — refusing the WHOLE component is always
|
|
503
|
+
// safe, and this transform has no business guessing past a real parse error.
|
|
504
|
+
let parsed;
|
|
505
|
+
try {
|
|
506
|
+
parsed = parseTemplate(templateText, 'inline.html', {
|
|
507
|
+
preserveWhitespaces: true,
|
|
508
|
+
});
|
|
509
|
+
} catch {
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
if (parsed.errors !== null && parsed.errors.length > 0) return;
|
|
513
|
+
|
|
514
|
+
const occurrencesByName = new Map(
|
|
515
|
+
LOWERABLE_NAMES.map(name => [name, []]),
|
|
516
|
+
);
|
|
517
|
+
walk(parsed.nodes, node => {
|
|
518
|
+
for (const name of LOWERABLE_NAMES) {
|
|
519
|
+
if (isElementNamed(node, name)) occurrencesByName.get(name).push(node);
|
|
520
|
+
}
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
const edits = [];
|
|
524
|
+
const removedDependencyIndices = new Set();
|
|
525
|
+
const letState = { counter: 0, usesPipe: false };
|
|
526
|
+
|
|
527
|
+
for (const name of LOWERABLE_NAMES) {
|
|
528
|
+
const elements = occurrencesByName.get(name);
|
|
529
|
+
if (elements.length === 0) continue;
|
|
530
|
+
const spec = SPECS.get(name);
|
|
531
|
+
// The all-or-nothing rule — see the header. One refusal in the family keeps every
|
|
532
|
+
// occurrence of this name as the component, in this template.
|
|
533
|
+
if (elements.some(element => elementRefuses(element, spec, name)))
|
|
534
|
+
continue;
|
|
535
|
+
|
|
536
|
+
for (const element of elements) {
|
|
537
|
+
const openStart = element.startSourceSpan.start.offset;
|
|
538
|
+
const intrinsic = intrinsicFor(element, spec);
|
|
539
|
+
edits.push({
|
|
540
|
+
start: openStart + 1,
|
|
541
|
+
end: openStart + 1 + name.length,
|
|
542
|
+
text: intrinsic,
|
|
543
|
+
});
|
|
544
|
+
// A self-closing tag (`<View />`) carries an IDENTICAL start/end source span (the
|
|
545
|
+
// whole tag, verified against the installed compiler-cli) — nothing to rewrite
|
|
546
|
+
// there.
|
|
547
|
+
const closeStart = element.endSourceSpan.start.offset;
|
|
548
|
+
if (closeStart !== openStart) {
|
|
549
|
+
edits.push({
|
|
550
|
+
start: closeStart + 2,
|
|
551
|
+
end: closeStart + 2 + name.length,
|
|
552
|
+
text: intrinsic,
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
if (spec.observesState) {
|
|
556
|
+
edits.push(...styleEditsFor(element, templateText, letState));
|
|
557
|
+
}
|
|
558
|
+
edits.push(...styleKeyRenameEdit(element));
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const depIndex = findDependencyIndex(dependenciesNode, name);
|
|
562
|
+
if (depIndex !== -1) removedDependencyIndices.add(depIndex);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (edits.length === 0) return;
|
|
566
|
+
|
|
567
|
+
// Descending by start: each splice only ever touches text strictly AFTER every
|
|
568
|
+
// not-yet-applied (smaller-offset) edit, so offsets never need re-basing mid-loop.
|
|
569
|
+
edits.sort((a, b) => b.start - a.start);
|
|
570
|
+
let nextText = templateText;
|
|
571
|
+
for (const edit of edits) {
|
|
572
|
+
nextText =
|
|
573
|
+
nextText.slice(0, edit.start) + edit.text + nextText.slice(edit.end);
|
|
574
|
+
}
|
|
575
|
+
setTemplateText(templateProp.value, nextText);
|
|
576
|
+
state.set(EDITED_KEY, true);
|
|
577
|
+
|
|
578
|
+
if (removedDependencyIndices.size > 0 && dependenciesNode !== undefined) {
|
|
579
|
+
dependenciesNode.elements = dependenciesNode.elements.filter(
|
|
580
|
+
(_element, index) => !removedDependencyIndices.has(index),
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (letState.usesPipe) {
|
|
585
|
+
const pipeEntry = pipeDependencyEntry(programPath);
|
|
586
|
+
if (dependenciesNode === undefined) {
|
|
587
|
+
dependenciesNode = types.arrayExpression([pipeEntry]);
|
|
588
|
+
arg.properties.push(
|
|
589
|
+
types.objectProperty(
|
|
590
|
+
types.identifier('dependencies'),
|
|
591
|
+
dependenciesNode,
|
|
592
|
+
),
|
|
593
|
+
);
|
|
594
|
+
} else {
|
|
595
|
+
dependenciesNode.elements.push(pipeEntry);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
},
|
|
599
|
+
});
|
|
600
|
+
},
|
|
601
|
+
// This transform edits TWO fields and only one of them survives a linker running in the SAME
|
|
602
|
+
// babel pass: Angular reads an inline template by slicing the FILE'S SOURCE TEXT at the AST
|
|
603
|
+
// node's byte range (`templateFromPartialCode`), so the rewritten `template` string is
|
|
604
|
+
// invisible to it, while the `dependencies` removal — an ordinary array read — lands. What
|
|
605
|
+
// ships then is neither lowered nor un-lowered: the tag is still `<View>` and the directive
|
|
606
|
+
// that answers it has been deleted, so nothing matches, no component template runs, and the
|
|
607
|
+
// screen loses its styles and its handlers. Nothing goes red — every test in this suite
|
|
608
|
+
// links the plugin's PRINTED output, which is a second pass. Device-diagnosed 2026-09-02.
|
|
609
|
+
//
|
|
610
|
+
// Detected by EFFECT rather than by plugin name (the linker's babel key is a generated
|
|
611
|
+
// `base$N`): if `ɵɵdefineComponent` exists at Program exit in a file we edited, the linker
|
|
612
|
+
// ran here. Use `@symbiote-native/angular/metro-transformer`, which prints the lowered code
|
|
613
|
+
// back to text before Metro parses it.
|
|
614
|
+
exit(programPath, state) {
|
|
615
|
+
if (state.get(EDITED_KEY) !== true) return;
|
|
616
|
+
let linkedHere = false;
|
|
617
|
+
programPath.traverse({
|
|
618
|
+
Identifier(path) {
|
|
619
|
+
if (path.node.name === 'ɵɵdefineComponent') linkedHere = true;
|
|
620
|
+
},
|
|
621
|
+
});
|
|
622
|
+
if (!linkedHere) return;
|
|
623
|
+
throw new Error(
|
|
624
|
+
'symbiote-lower-host-primitives: the Angular linker ran in the same Babel pass, so the ' +
|
|
625
|
+
'rewritten template never reaches it and only the dependency removal lands — a ' +
|
|
626
|
+
'half-lowered component that matches no directive at all. Remove this plugin from ' +
|
|
627
|
+
"babel.config.js and set metro.config.js's babelTransformerPath to " +
|
|
628
|
+
"'@symbiote-native/angular/metro-transformer'.",
|
|
629
|
+
);
|
|
630
|
+
},
|
|
631
|
+
},
|
|
632
|
+
},
|
|
633
|
+
};
|
|
634
|
+
};
|
|
635
|
+
|
|
636
|
+
module.exports.LOWERABLE_NAMES = LOWERABLE_NAMES;
|