@symbiote-native/angular 0.8.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/components/button.d.ts +3 -0
- package/build/angular/components/button.js +10 -1
- package/build/angular/components/button.js.map +1 -1
- package/build/angular/components/image/index.android.js +0 -8
- package/build/angular/components/image/index.android.js.map +1 -1
- package/build/angular/components/image/index.ios.js +0 -8
- 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 +33 -27
- 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 +10 -1
- 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 +11 -14
- package/build/angular/components/input-accessory-view/index.js.map +1 -1
- package/build/angular/components/keyboard-avoiding-view/index.d.ts +2 -1
- package/build/angular/components/keyboard-avoiding-view/index.js +17 -10
- 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 +14 -8
- package/build/angular/components/modal/index.js.map +1 -1
- package/build/angular/components/pressable/index.d.ts +9 -4
- package/build/angular/components/pressable/index.js +55 -14
- 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 +10 -8
- 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 +19 -19
- package/build/angular/components/safe-area-view/index.js.map +1 -1
- package/build/angular/components/scroll-view/shared.d.ts +2 -0
- package/build/angular/components/scroll-view/shared.js +20 -4
- package/build/angular/components/scroll-view/shared.js.map +1 -1
- package/build/angular/components/section-list/index.d.ts +3 -0
- package/build/angular/components/section-list/index.js +10 -1
- package/build/angular/components/section-list/index.js.map +1 -1
- package/build/angular/components/switch/index.android.js +6 -6
- package/build/angular/components/switch/index.android.js.map +1 -1
- package/build/angular/components/switch/index.ios.js +6 -6
- package/build/angular/components/switch/index.ios.js.map +1 -1
- package/build/angular/components/text-input.d.ts +7 -1
- package/build/angular/components/text-input.js +54 -29
- package/build/angular/components/text-input.js.map +1 -1
- package/build/angular/components/touchable/index.d.ts +7 -0
- package/build/angular/components/touchable/index.js +34 -3
- 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 +17 -15
- package/build/angular/components/touchable-native-feedback/index.js.map +1 -1
- package/build/angular/components/virtualized-list/index.d.ts +3 -0
- package/build/angular/components/virtualized-list/index.js +10 -1
- package/build/angular/components/virtualized-list/index.js.map +1 -1
- package/build/angular/components/virtualized-section-list/index.d.ts +3 -0
- package/build/angular/components/virtualized-section-list/index.js +10 -1
- package/build/angular/components/virtualized-section-list/index.js.map +1 -1
- package/build/angular/components.d.ts +1 -1
- 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 +37 -11
- package/build/angular/create-portal/index.js.map +1 -1
- 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/index.d.ts +1 -0
- package/build/angular/index.js +1 -0
- 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 +14 -9
- package/build/angular/modules/animated/create-animated-component.js.map +1 -1
- package/build/angular/primitives/index.d.ts +13 -1
- package/build/angular/primitives/index.js +43 -1
- package/build/angular/primitives/index.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 +3 -1
- package/build/angular/render/index.js.map +1 -1
- package/build/angular/renderer/index.js +120 -19
- package/build/angular/renderer/index.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/components/button.ts +17 -0
- package/src/components/image/index.android.ts +0 -4
- package/src/components/image/index.ios.ts +0 -4
- package/src/components/image/shared.ts +52 -26
- package/src/components/image-background.ts +18 -0
- package/src/components/input-accessory-view/index.ts +14 -8
- package/src/components/keyboard-avoiding-view/index.ts +21 -7
- package/src/components/modal/index.ts +17 -4
- package/src/components/pressable/index.ts +70 -10
- package/src/components/refresh-control/index.ts +14 -5
- package/src/components/safe-area-view/index.ts +28 -12
- package/src/components/scroll-view/shared.ts +31 -4
- package/src/components/section-list/index.ts +18 -0
- package/src/components/switch/index.android.ts +4 -4
- package/src/components/switch/index.ios.ts +4 -4
- package/src/components/text-input.ts +73 -17
- package/src/components/touchable/index.ts +43 -0
- package/src/components/touchable-native-feedback/index.ts +18 -8
- package/src/components/virtualized-list/index.ts +18 -0
- package/src/components/virtualized-section-list/index.ts +18 -0
- package/src/components.ts +2 -0
- package/src/create-portal/index.ts +39 -9
- package/src/gate-demand.ts +109 -0
- package/src/index.ts +2 -0
- package/src/modules/animated/create-animated-component.ts +23 -4
- package/src/primitives/index.ts +28 -1
- package/src/register.ts +43 -0
- package/src/render/index.ts +3 -1
- package/src/renderer/index.ts +144 -34
- package/src/state-style.ts +39 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Whether anything downstream actually wants a gated accessibility event, passed from a wrapper
|
|
2
|
+
// component to the inner component that owns the gate.
|
|
3
|
+
//
|
|
4
|
+
// THE PROBLEM. Six Fabric events fire only if a BOOLEAN prop reaches the payload
|
|
5
|
+
// (`.claude/rules/fabric-boolean-event-gates.md`), and the flag follows the VALUE: a handler
|
|
6
|
+
// present means `true`, absent means the key is deleted. Every component that owns such a gate
|
|
7
|
+
// therefore writes it only while its own `@Output()` is `.observed`.
|
|
8
|
+
//
|
|
9
|
+
// Angular has no conditional template binding, so a wrapper rendering an inner component must write
|
|
10
|
+
// `(accessibilityAction)="accessibilityAction.emit($event)"` unconditionally — which subscribes,
|
|
11
|
+
// which makes the inner's `.observed` permanently true, which lights the flag on EVERY instance
|
|
12
|
+
// whether or not any app listens. Fixing the inner component does nothing: the wrapper is the
|
|
13
|
+
// subscriber. The chain is up to four deep here (SectionList -> VirtualizedSectionList ->
|
|
14
|
+
// VirtualizedList -> ScrollView), and every middle link has the same problem for the same reason.
|
|
15
|
+
//
|
|
16
|
+
// WHY THIS IS DI AND NOT AN `@Input`. An input is public API. An app could write
|
|
17
|
+
// `[gatedEvents]="…"` on a `Pressable` and silently switch its accessibility events OFF — a change
|
|
18
|
+
// nothing in this repo could detect and nothing on screen would show. A `viewProviders` entry is
|
|
19
|
+
// not part of the component's binding surface, so an app cannot reach it by accident.
|
|
20
|
+
//
|
|
21
|
+
// WHY `viewProviders` AND NOT `providers` — measured, both arms, 2026-09-02:
|
|
22
|
+
//
|
|
23
|
+
// viewProviders the wrapper's OWN template sees it, projected content does NOT
|
|
24
|
+
// providers both see it
|
|
25
|
+
//
|
|
26
|
+
// The second half is what matters: an app writing `<TouchableOpacity><Pressable/></TouchableOpacity>`
|
|
27
|
+
// projects its own Pressable INTO the wrapper, and that Pressable must behave exactly as it would
|
|
28
|
+
// standing alone. Under `providers` it would inherit the wrapper's demand and the debt would just
|
|
29
|
+
// move one level down.
|
|
30
|
+
//
|
|
31
|
+
// Verified on both rungs — JIT through `mount()`, and again by executing the artifact from a real
|
|
32
|
+
// `ngc --compilationMode partial` + `babel-linker.cjs` chain, because `viewProviders` compiles into
|
|
33
|
+
// the component definition (`ɵɵProvidersFeature`) and this repo has a recorded case of JIT and AOT
|
|
34
|
+
// disagreeing on a compiled binding (`.claude/rules/test-harness-false-greens.md` §21/§21a). Both
|
|
35
|
+
// arms reported the same four cells.
|
|
36
|
+
import {
|
|
37
|
+
EventEmitter,
|
|
38
|
+
InjectionToken,
|
|
39
|
+
forwardRef,
|
|
40
|
+
inject,
|
|
41
|
+
} from '@angular/core';
|
|
42
|
+
import type { Provider, Type } from '@angular/core';
|
|
43
|
+
import type { ISymbioteEvent } from '@symbiote-native/engine';
|
|
44
|
+
|
|
45
|
+
// The four `on*`-gated accessibility events every wrapper forwards. Not the full gate list from the
|
|
46
|
+
// engine: `layout` and `textLayout` are never forwarded wrapper-to-wrapper, and
|
|
47
|
+
// KeyboardAvoidingView READS its own `onLayout` internally, so gating that one on a subscriber
|
|
48
|
+
// would break inset-following (`.claude/rules/keyboard-avoiding-view-rn-contract.md`).
|
|
49
|
+
export const GATED_ACCESSIBILITY_EVENTS = [
|
|
50
|
+
'accessibilityAction',
|
|
51
|
+
'accessibilityTap',
|
|
52
|
+
'magicTap',
|
|
53
|
+
'accessibilityEscape',
|
|
54
|
+
] as const;
|
|
55
|
+
|
|
56
|
+
export type IGatedAccessibilityEvent =
|
|
57
|
+
(typeof GATED_ACCESSIBILITY_EVENTS)[number];
|
|
58
|
+
|
|
59
|
+
export interface IGateDemand {
|
|
60
|
+
wantsGate(name: IGatedAccessibilityEvent): boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const GATE_DEMAND = new InjectionToken<IGateDemand>(
|
|
64
|
+
'symbiote.gate-demand',
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Declare a wrapper as the demand for everything in its own template.
|
|
69
|
+
*
|
|
70
|
+
* Takes a THUNK rather than the class: the entry sits in the decorator of the very class it names,
|
|
71
|
+
* so the reference has to be late.
|
|
72
|
+
*/
|
|
73
|
+
export function provideGateDemand(get: () => Type<IGateDemand>): Provider {
|
|
74
|
+
return { provide: GATE_DEMAND, useExisting: forwardRef(get) };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* For a component that OWNS a gate — the innermost one, which writes the flag.
|
|
79
|
+
*/
|
|
80
|
+
export function injectGateDemand(): IGateDemand | null {
|
|
81
|
+
return inject(GATE_DEMAND, { optional: true });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* For a component that PROVIDES demand and is itself wrapped.
|
|
86
|
+
*
|
|
87
|
+
* `skipSelf` is not optional here: a component resolves its own `viewProviders`, so without it the
|
|
88
|
+
* lookup reaches this class's own `useExisting` entry and Angular throws NG0200 at construction.
|
|
89
|
+
* Loud rather than silent, but the shape is easy to get wrong, which is why the two lookups are
|
|
90
|
+
* separate functions instead of one with a flag.
|
|
91
|
+
*/
|
|
92
|
+
export function injectGateDemandAbove(): IGateDemand | null {
|
|
93
|
+
return inject(GATE_DEMAND, { optional: true, skipSelf: true });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The one expression both roles evaluate.
|
|
98
|
+
*
|
|
99
|
+
* A demand from above OVERRIDES the local emitter, and that override is the whole mechanism: a
|
|
100
|
+
* middle link's own emitter is `.observed` precisely because the level above bound it, so answering
|
|
101
|
+
* from it would report "wanted" for a chain nobody subscribed to.
|
|
102
|
+
*/
|
|
103
|
+
export function gateWanted(
|
|
104
|
+
above: IGateDemand | null,
|
|
105
|
+
name: IGatedAccessibilityEvent,
|
|
106
|
+
own: EventEmitter<ISymbioteEvent>,
|
|
107
|
+
): boolean {
|
|
108
|
+
return above !== null ? above.wantsGate(name) : own.observed;
|
|
109
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -53,8 +53,10 @@ import {
|
|
|
53
53
|
IMAGE_INPUTS,
|
|
54
54
|
IMAGE_OUTPUTS,
|
|
55
55
|
ImageBase,
|
|
56
|
+
isImageEventCallback,
|
|
56
57
|
resolveImageProps,
|
|
57
58
|
} from '../../components/image/shared';
|
|
59
|
+
import type { IGatedAccessibilityEvent } from '../../gate-demand';
|
|
58
60
|
import { SectionList } from '../../components/section-list';
|
|
59
61
|
import { AnimatedLeafBinder } from './animated-leaf-binder';
|
|
60
62
|
|
|
@@ -244,10 +246,6 @@ export class AnimatedText extends AnimatedComponentBase {}
|
|
|
244
246
|
template: `
|
|
245
247
|
<symbiote-image
|
|
246
248
|
[symbioteHostProps]="animatedImageProps"
|
|
247
|
-
(accessibilityAction)="handleAccessibilityAction($event)"
|
|
248
|
-
(accessibilityTap)="handleAccessibilityTap($event)"
|
|
249
|
-
(magicTap)="handleMagicTap($event)"
|
|
250
|
-
(accessibilityEscape)="handleAccessibilityEscape($event)"
|
|
251
249
|
(loadStart)="handleLoadStart($event)"
|
|
252
250
|
(load)="handleLoad($event)"
|
|
253
251
|
(loadEnd)="handleLoadEnd($event)"
|
|
@@ -283,6 +281,18 @@ export class AnimatedImage
|
|
|
283
281
|
);
|
|
284
282
|
private viewReady = false;
|
|
285
283
|
|
|
284
|
+
private gateInto(
|
|
285
|
+
props: Record<string, unknown>,
|
|
286
|
+
name: IGatedAccessibilityEvent,
|
|
287
|
+
key: string,
|
|
288
|
+
): void {
|
|
289
|
+
const carried = props[key];
|
|
290
|
+
props[key] = this.gatedAccessibilityHandler(
|
|
291
|
+
name,
|
|
292
|
+
isImageEventCallback(carried) ? carried : undefined,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
286
296
|
get animatedImageProps(): Record<string, unknown> {
|
|
287
297
|
const reduced = reduceProps(this.mergedProps());
|
|
288
298
|
const passthroughStyle = readPassthroughStyle(
|
|
@@ -296,6 +306,15 @@ export class AnimatedImage
|
|
|
296
306
|
}
|
|
297
307
|
const resolved = resolveImageProps(reduced);
|
|
298
308
|
resolved['style'] = [anchorHostStyle(this.elementRef), resolved['style']];
|
|
309
|
+
// The four gated accessibility events, which used to be template bindings on the host tag and
|
|
310
|
+
// therefore lit their Fabric flags on every AnimatedImage in the app
|
|
311
|
+
// (`.claude/rules/fabric-boolean-event-gates.md`). Routed through the base's gate instead, so
|
|
312
|
+
// both channels still reach the app — the `onX` callback carried in the animated prop bag, and
|
|
313
|
+
// the `@Output()` an app may bind on <AnimatedImage> (IMAGE_OUTPUTS declares all four).
|
|
314
|
+
this.gateInto(resolved, 'accessibilityAction', 'onAccessibilityAction');
|
|
315
|
+
this.gateInto(resolved, 'accessibilityTap', 'onAccessibilityTap');
|
|
316
|
+
this.gateInto(resolved, 'magicTap', 'onMagicTap');
|
|
317
|
+
this.gateInto(resolved, 'accessibilityEscape', 'onAccessibilityEscape');
|
|
299
318
|
return resolved;
|
|
300
319
|
}
|
|
301
320
|
|
package/src/primitives/index.ts
CHANGED
|
@@ -32,7 +32,7 @@ export class ViewHost extends SymbiotePrimitiveHost {}
|
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
34
|
* Text carries the two defaults RN's Text.js applies unconditionally (`ellipsizeMode ?? 'tail'`,
|
|
35
|
-
* `allowFontScaling !== false`, Text.js:
|
|
35
|
+
* `allowFontScaling !== false`, Text.js:289 and :291). Without them native falls back to `clip` and a
|
|
36
36
|
* clamped Text cuts mid-word with no ellipsis — device-observed on the other adapters 2026-08-19.
|
|
37
37
|
*
|
|
38
38
|
* They are declared as real `@Input()`s, unlike the pass-through props the base's comment
|
|
@@ -107,6 +107,9 @@ export class HorizontalScrollView extends SymbiotePrimitiveHost {}
|
|
|
107
107
|
})
|
|
108
108
|
export class HorizontalScrollContentView extends SymbiotePrimitiveHost {}
|
|
109
109
|
|
|
110
|
+
// The pair the LOWERED path commits — declared so the tag alphabet stays complete, though no
|
|
111
|
+
// Angular template renders them: this adapter has no lowering transform. The `-managed` pair below
|
|
112
|
+
// is what its own TextInput renders.
|
|
110
113
|
@Component({
|
|
111
114
|
selector: 'symbiote-text-input',
|
|
112
115
|
standalone: true,
|
|
@@ -121,6 +124,20 @@ export class TextInputHost extends SymbiotePrimitiveHost {}
|
|
|
121
124
|
})
|
|
122
125
|
export class MultilineTextInputHost extends SymbiotePrimitiveHost {}
|
|
123
126
|
|
|
127
|
+
@Component({
|
|
128
|
+
selector: 'symbiote-text-input-managed',
|
|
129
|
+
standalone: true,
|
|
130
|
+
template: '<ng-content></ng-content>',
|
|
131
|
+
})
|
|
132
|
+
export class ManagedTextInputHost extends SymbiotePrimitiveHost {}
|
|
133
|
+
|
|
134
|
+
@Component({
|
|
135
|
+
selector: 'symbiote-text-input-multiline-managed',
|
|
136
|
+
standalone: true,
|
|
137
|
+
template: '<ng-content></ng-content>',
|
|
138
|
+
})
|
|
139
|
+
export class ManagedMultilineTextInputHost extends SymbiotePrimitiveHost {}
|
|
140
|
+
|
|
124
141
|
@Component({
|
|
125
142
|
selector: 'symbiote-switch',
|
|
126
143
|
standalone: true,
|
|
@@ -128,6 +145,16 @@ export class MultilineTextInputHost extends SymbiotePrimitiveHost {}
|
|
|
128
145
|
})
|
|
129
146
|
export class SwitchHost extends SymbiotePrimitiveHost {}
|
|
130
147
|
|
|
148
|
+
// The component path's spelling — same native Switch/AndroidSwitch, a tag the engine's Switch
|
|
149
|
+
// behavior does not carry. See `component-names/shared.ts` for why the wrapper may not share the
|
|
150
|
+
// lowered tag; mirrors `ManagedTextInputHost` above.
|
|
151
|
+
@Component({
|
|
152
|
+
selector: 'symbiote-switch-managed',
|
|
153
|
+
standalone: true,
|
|
154
|
+
template: '<ng-content></ng-content>',
|
|
155
|
+
})
|
|
156
|
+
export class ManagedSwitchHost extends SymbiotePrimitiveHost {}
|
|
157
|
+
|
|
131
158
|
@Component({
|
|
132
159
|
selector: 'symbiote-activity-indicator',
|
|
133
160
|
standalone: true,
|
package/src/register.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Side-effect ONLY. Exports nothing, and must never be re-exported from the barrel.
|
|
2
|
+
//
|
|
3
|
+
// A lowered `<symbiote-pressable>`/`<symbiote-text-input>` is a bare custom element with no
|
|
4
|
+
// component behind it, so the press machine and the controlled-input machine have to live on the
|
|
5
|
+
// engine node itself — reached by `registerPressableBehavior()`/`registerTextInputBehavior()`
|
|
6
|
+
// having run before the first node of that tag is created.
|
|
7
|
+
//
|
|
8
|
+
// Metro turns on `inlineRequires` for PRODUCTION only, moving a `require()` down to the first place
|
|
9
|
+
// its binding is used as a VALUE; a barrel's `export { X } from './x'` compiles to a lazy getter,
|
|
10
|
+
// so a module reached only that way never evaluates unless something names it as a value — silent
|
|
11
|
+
// in Release builds alone, invisible to tsc, to vitest, and to grepping the bundle (see CLAUDE.md,
|
|
12
|
+
// "Never make correctness depend on a module's load-time side effect"). A bare
|
|
13
|
+
// `import './register';` placed NEXT TO a re-export of the same specifier does not help either —
|
|
14
|
+
// Babel merges the two into one dependency and the merged one stays lazy. This is the shape that
|
|
15
|
+
// survives both traps, matching `adapters/{vue,svelte,solid}/src/register.ts` and
|
|
16
|
+
// `packages/slider/src/{react,vue,svelte,angular}/index.ts`.
|
|
17
|
+
import {
|
|
18
|
+
registerImageBehavior,
|
|
19
|
+
registerInputAccessoryViewBehavior,
|
|
20
|
+
registerPressableBehavior,
|
|
21
|
+
registerSwitchBehavior,
|
|
22
|
+
registerTextInputBehavior,
|
|
23
|
+
} from '@symbiote-native/components';
|
|
24
|
+
|
|
25
|
+
registerPressableBehavior();
|
|
26
|
+
// Only the LOWERED tags carry this — the wrapper renders `symbiote-text-input-managed` /
|
|
27
|
+
// `symbiote-text-input-multiline-managed` and keeps running its own lifecycle. One owner per node;
|
|
28
|
+
// see `component-names/shared.ts`.
|
|
29
|
+
registerTextInputBehavior();
|
|
30
|
+
// Same reason as TextInput: the wrapper renders `symbiote-switch-managed` and runs its own
|
|
31
|
+
// lastNativeReport/snap-back lifecycle, so the engine's copy attaches only to the bare tag.
|
|
32
|
+
registerSwitchBehavior();
|
|
33
|
+
|
|
34
|
+
// Image owns no runtime — its behavior is a prop FOLD and nothing else, and it is registered on the
|
|
35
|
+
// same `symbiote-image` the wrapper already emits rather than on a `-managed` twin. That is safe
|
|
36
|
+
// only because the mapping is idempotent, which `core/components/src/behaviors/image.test.ts`
|
|
37
|
+
// asserts rather than assumes; a wrapper-built node simply folds a second time and nothing moves.
|
|
38
|
+
registerImageBehavior();
|
|
39
|
+
|
|
40
|
+
// Fold-only, and it shares the wrapper's tag for the same reason Image does: the mapping has no
|
|
41
|
+
// aliasing at all, so a wrapper-built node folding a second time moves nothing.
|
|
42
|
+
// `core/components/src/behaviors/input-accessory-view.test.ts` asserts that rather than assuming it.
|
|
43
|
+
registerInputAccessoryViewBehavior();
|
package/src/render/index.ts
CHANGED
|
@@ -62,7 +62,9 @@ function asAngularHost(hostNode: SymbioteSurface | ISymbioteNode): Element {
|
|
|
62
62
|
// that isn't one of our primitives), so the root needs an explicit host too, one we then hand
|
|
63
63
|
// to the wrapper as projectable content.
|
|
64
64
|
function createDetachedViewHost(): ISymbioteNode {
|
|
65
|
-
|
|
65
|
+
// 'symbiote-view', not 'View': the public name is not a Fabric view name, and until
|
|
66
|
+
// makeDescriptorFor learned to reject one it fell through to a view literally named `View`.
|
|
67
|
+
const descriptor = descriptorFor('symbiote-view');
|
|
66
68
|
return toPublicInstance(
|
|
67
69
|
createEngineElement(descriptor.component, descriptor.isText),
|
|
68
70
|
);
|
package/src/renderer/index.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
removeChild,
|
|
19
19
|
routeProp,
|
|
20
20
|
setEventListener,
|
|
21
|
+
setProp,
|
|
21
22
|
setText,
|
|
22
23
|
toPublicInstance,
|
|
23
24
|
RAW_TEXT_COMPONENT,
|
|
@@ -25,6 +26,7 @@ import {
|
|
|
25
26
|
type ISymbioteNode,
|
|
26
27
|
} from '@symbiote-native/engine';
|
|
27
28
|
import { descriptorFor } from '@symbiote-native/components';
|
|
29
|
+
import { foldHostBag } from '@symbiote-native/components/fold-host-bag';
|
|
28
30
|
import type { Renderer2, RendererFactory2, RendererType2 } from '@angular/core';
|
|
29
31
|
import { isAnchorHostComponent } from '../anchor-host-registry';
|
|
30
32
|
import {
|
|
@@ -55,6 +57,64 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
55
57
|
return typeof value === 'object' && value !== null;
|
|
56
58
|
}
|
|
57
59
|
|
|
60
|
+
// RN's Text.js applies two defaults on the way to native (core/components/host-primitives.cjs's
|
|
61
|
+
// `Text.defaults`; the authority on what they MEAN is core/components/src/text-props.ts's
|
|
62
|
+
// resolveTextProps, which the composed `Text` @Component already calls). That component's own
|
|
63
|
+
// host paints directly (Text is not anchor-hosted — see the top-level "View/Text's own component
|
|
64
|
+
// doesn't have this split" reasoning elsewhere in this file), so createElement runs for its INNER
|
|
65
|
+
// symbiote-text node too; seeding here therefore covers both the composed Text and any bare
|
|
66
|
+
// `symbiote-text` a future lowering emits, uniformly. Found missing 2026-08-31 (a cross-adapter
|
|
67
|
+
// key-count diff against Vue's real BenchmarkRow.vue) — without this a lowered Text's
|
|
68
|
+
// `numberOfLines` clips with no ellipsis, silently, on device only. Vue's renderer already does
|
|
69
|
+
// this (`adapters/vue/src/renderer/index.ts`'s `seedTextDefaults`); Angular's simply never did.
|
|
70
|
+
//
|
|
71
|
+
// Sourced from `foldHostBag` (`@symbiote-native/components/fold-host-bag`, driven by
|
|
72
|
+
// `HOST_PRIMITIVES.Text.defaults`) rather than a second hardcoded copy — React and Svelte call the
|
|
73
|
+
// same function directly; this used to be a THIRD, independent restatement of the same two
|
|
74
|
+
// defaults, with nothing to catch it drifting from the spec if a default's value ever changed.
|
|
75
|
+
// `foldHostBag('symbiote-text', {})` on an EMPTY bag folds every default with no authored value to
|
|
76
|
+
// override it (the alias loop has nothing to fold — `id` is only rewritten when present), which is
|
|
77
|
+
// exactly the seed this function needs.
|
|
78
|
+
function seedTextDefaults(node: ISymbioteNode): void {
|
|
79
|
+
const seeded = foldHostBag('symbiote-text', {});
|
|
80
|
+
for (const [key, value] of Object.entries(seeded)) setProp(node, key, value);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// An explicit `undefined` must NOT clear one of those defaults — RN treats a missing prop and an
|
|
84
|
+
// explicit undefined alike, and only a literal `false` opts allowFontScaling out. Reached only
|
|
85
|
+
// when a later write clears a key back to undefined, so it costs nothing on the common path.
|
|
86
|
+
// `foldHostBag` folds every Text default when called this way (not just `key`), because its
|
|
87
|
+
// contract is "fold a whole bag" — the extra key computed alongside `key` is simply unread here.
|
|
88
|
+
function textDefaultFor(el: IHostElement, key: string): unknown {
|
|
89
|
+
if (isSurface(el) || !el.isText) return undefined;
|
|
90
|
+
return foldHostBag('symbiote-text', { [key]: undefined })[key];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// RN's `id` is the modern W3C-named alias for `nativeID` (core/components/host-primitives.cjs's
|
|
94
|
+
// `ID_ALIAS`) — View.js/Text.js copy it over unconditionally, so the two name ONE native prop.
|
|
95
|
+
// React/Solid/Svelte fold it in a wrapper or transform; Angular had it nowhere, so `<View
|
|
96
|
+
// id="x">`/`[id]="x"` reached Fabric with an unknown `id` key and no `nativeID` — silently, on
|
|
97
|
+
// device only. Lives in the renderer (mirroring Vue's `PROP_ALIASES`) so it covers every path
|
|
98
|
+
// that can set a prop — `setAttribute`, `setProperty`, and (should a future lowering emit one) a
|
|
99
|
+
// hand-built call — not just the composed component's own `id` @Input.
|
|
100
|
+
// `symbioteStyle` is what the lowering transform emits in place of `[style]`: Angular routes a
|
|
101
|
+
// `style` binding to its own CSS styling engine, which cannot represent an RN StyleProp (an array
|
|
102
|
+
// throws inside change detection). Under any other name it is an ordinary property binding and
|
|
103
|
+
// arrives here.
|
|
104
|
+
// Angular's two-way sugar `[(value)]` compiles to a `(valueChange)` binding; the engine knows the
|
|
105
|
+
// same fold as the function prop `onValueChange`. See `listen()`.
|
|
106
|
+
const VALUE_CHANGE_EVENT = 'valueChange';
|
|
107
|
+
const VALUE_CHANGE_PROP = 'onValueChange';
|
|
108
|
+
|
|
109
|
+
const PROP_ALIASES: ReadonlyMap<string, string> = new Map([
|
|
110
|
+
['id', 'nativeID'],
|
|
111
|
+
['symbioteStyle', 'style'],
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
function aliasedPropName(name: string): string {
|
|
115
|
+
return PROP_ALIASES.get(name) ?? name;
|
|
116
|
+
}
|
|
117
|
+
|
|
58
118
|
// Diagnostic-only: tags each anchor with a sequential id so log lines can tell distinct
|
|
59
119
|
// anchors apart (they all otherwise print the same generic '#anchor' component name).
|
|
60
120
|
// Gated behind isDebug() so a production build pays zero cost (no WeakMap writes).
|
|
@@ -92,6 +152,10 @@ const PRIMITIVE_SELECTOR_ALIAS: Record<string, string> = {
|
|
|
92
152
|
// Inserting a bare raw-text node anywhere but inside a <Text> is invalid in Fabric (a
|
|
93
153
|
// stray RCTRawText would paint). Angular's ɵɵtext only ever lands text inside a <Text>,
|
|
94
154
|
// but guard anyway for parity with the Vue adapter and to fail loudly on a bad template.
|
|
155
|
+
// `removeScrollViewProjectedChild` takes a remove callback and uses it only when the child turns
|
|
156
|
+
// out to be projected, which almost none are — so the engine's own `removeChild` is passed by
|
|
157
|
+
// reference rather than wrapped in an arrow. The wrapper was allocated on EVERY removed node:
|
|
158
|
+
// 10 000 closures on a Clear whose engine window is 0.1 ms.
|
|
95
159
|
function assertTextPlacement(child: ISymbioteNode, parent: IHostElement): void {
|
|
96
160
|
if (isRawText(child) && (isSurface(parent) || !parent.isText)) {
|
|
97
161
|
throw new Error(
|
|
@@ -119,22 +183,37 @@ export class SymbioteRenderer implements Renderer2 {
|
|
|
119
183
|
// name for a native leaf. Public aliases are normalized to their engine primitive name
|
|
120
184
|
// before descriptor lookup. descriptorFor resolves it; an unknown `symbiote-*` is a typo,
|
|
121
185
|
// any other string flows through as a raw Fabric name (events/processors derived from its
|
|
122
|
-
// ViewConfig).
|
|
123
|
-
//
|
|
186
|
+
// ViewConfig). The imperative API (measure / setNativeProps / focus) is on the node's
|
|
187
|
+
// prototype, so toPublicInstance hands back the SAME identity the commit mirror keys on.
|
|
124
188
|
countAngular('nodesCreated');
|
|
125
189
|
noteAngularCreate(name);
|
|
126
190
|
const engineName = PRIMITIVE_SELECTOR_ALIAS[name] ?? name;
|
|
191
|
+
|
|
127
192
|
if (isAnchorHostComponent(engineName)) {
|
|
128
193
|
const anchor = tagAnchorForDebug(createAnchor());
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
194
|
+
if (isDebug()) {
|
|
195
|
+
dlog(
|
|
196
|
+
`angular createElement ${name} -> anchor host ${describeHost(anchor)}`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
132
199
|
return anchor;
|
|
133
200
|
}
|
|
134
201
|
|
|
135
202
|
const descriptor = descriptorFor(engineName);
|
|
136
|
-
|
|
137
|
-
|
|
203
|
+
// The tag, not just the resolved Fabric name: the host-behavior registry is keyed by the
|
|
204
|
+
// INTRINSIC tag (host-behavior.ts's own comment), and a node only ever carries the resolved
|
|
205
|
+
// name afterward. Omitting this argument means attachHostBehavior looks up the WRONG key
|
|
206
|
+
// (e.g. 'RCTView') and any registered behavior silently never attaches — Vue's renderer
|
|
207
|
+
// already passes this correctly (`createElement(descriptor.component, descriptor.isText, type)`).
|
|
208
|
+
const node = createElement(
|
|
209
|
+
descriptor.component,
|
|
210
|
+
descriptor.isText,
|
|
211
|
+
engineName,
|
|
212
|
+
);
|
|
213
|
+
if (descriptor.isText) seedTextDefaults(node);
|
|
214
|
+
if (isDebug()) {
|
|
215
|
+
dlog(`angular createElement ${name} -> ${descriptor.component}`);
|
|
216
|
+
}
|
|
138
217
|
return toPublicInstance(node);
|
|
139
218
|
}
|
|
140
219
|
|
|
@@ -144,7 +223,9 @@ export class SymbioteRenderer implements Renderer2 {
|
|
|
144
223
|
// Vue createComment path.
|
|
145
224
|
countAngular('nodesCreated');
|
|
146
225
|
const anchor = tagAnchorForDebug(createAnchor());
|
|
147
|
-
|
|
226
|
+
if (isDebug()) {
|
|
227
|
+
dlog(`Angular renderer createComment -> ${describeHost(anchor)}`);
|
|
228
|
+
}
|
|
148
229
|
return anchor;
|
|
149
230
|
}
|
|
150
231
|
|
|
@@ -161,15 +242,19 @@ export class SymbioteRenderer implements Renderer2 {
|
|
|
161
242
|
countAngular('nodesInserted');
|
|
162
243
|
assertTextPlacement(newChild, parent);
|
|
163
244
|
if (isSurface(parent)) {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
245
|
+
if (isDebug()) {
|
|
246
|
+
dlog(
|
|
247
|
+
`Angular renderer appendChild parent=surface child=${describeHost(newChild)}`,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
167
250
|
parent.appendChild(newChild);
|
|
168
251
|
} else {
|
|
169
252
|
const projection = getScrollViewProjection(parent);
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
253
|
+
if (isDebug()) {
|
|
254
|
+
dlog(
|
|
255
|
+
`Angular renderer appendChild parent=${describeHost(parent)} child=${describeHost(newChild)} projection=${projection !== undefined}`,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
173
258
|
if (projection !== undefined) {
|
|
174
259
|
projection.appendProjectedChild(parent, newChild, (target, child) =>
|
|
175
260
|
appendChild(target, child),
|
|
@@ -190,16 +275,20 @@ export class SymbioteRenderer implements Renderer2 {
|
|
|
190
275
|
countAngular('nodesInserted');
|
|
191
276
|
assertTextPlacement(newChild, parent);
|
|
192
277
|
if (isSurface(parent)) {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
278
|
+
if (isDebug()) {
|
|
279
|
+
dlog(
|
|
280
|
+
`Angular renderer insertBefore parent=surface child=${describeHost(newChild)} ref=${refChild ? describeHost(refChild) : 'null'}`,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
196
283
|
if (refChild) parent.insertBefore(newChild, refChild);
|
|
197
284
|
else parent.appendChild(newChild);
|
|
198
285
|
} else {
|
|
199
286
|
const projection = getScrollViewProjection(parent);
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
287
|
+
if (isDebug()) {
|
|
288
|
+
dlog(
|
|
289
|
+
`Angular renderer insertBefore parent=${describeHost(parent)} child=${describeHost(newChild)} ref=${refChild ? describeHost(refChild) : 'null'} projection=${projection !== undefined}`,
|
|
290
|
+
);
|
|
291
|
+
}
|
|
203
292
|
if (projection !== undefined) {
|
|
204
293
|
projection.insertProjectedChild(
|
|
205
294
|
parent,
|
|
@@ -224,16 +313,15 @@ export class SymbioteRenderer implements Renderer2 {
|
|
|
224
313
|
// surface.children with no parent). Angular's `parent` arg is ignored in favor of the
|
|
225
314
|
// authoritative link, mirroring the Vue adapter's remove.
|
|
226
315
|
countAngular('nodesRemoved');
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
(
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
);
|
|
316
|
+
const wasProjected = removeScrollViewProjectedChild(oldChild, removeChild);
|
|
317
|
+
if (isDebug()) {
|
|
318
|
+
const angularParent = _parent !== null ? describeHost(_parent) : 'null';
|
|
319
|
+
const retainedParent =
|
|
320
|
+
oldChild.parent !== undefined ? describeHost(oldChild.parent) : 'none';
|
|
321
|
+
dlog(
|
|
322
|
+
`Angular renderer removeChild angularParent=${angularParent} retainedParent=${retainedParent} child=${describeHost(oldChild)} viaProjection=${wasProjected}`,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
237
325
|
if (!wasProjected) {
|
|
238
326
|
const parent = oldChild.parent;
|
|
239
327
|
if (parent !== undefined) removeChild(parent, oldChild);
|
|
@@ -278,7 +366,7 @@ export class SymbioteRenderer implements Renderer2 {
|
|
|
278
366
|
if (isSurface(el)) return;
|
|
279
367
|
countAngular('rendererWrites');
|
|
280
368
|
noteAngularWrite(name);
|
|
281
|
-
routeProp(el, name, value);
|
|
369
|
+
routeProp(el, aliasedPropName(name), value);
|
|
282
370
|
this.surface.requestCommit();
|
|
283
371
|
}
|
|
284
372
|
|
|
@@ -286,7 +374,8 @@ export class SymbioteRenderer implements Renderer2 {
|
|
|
286
374
|
if (isSurface(el)) return;
|
|
287
375
|
countAngular('rendererWrites');
|
|
288
376
|
noteAngularWrite(name);
|
|
289
|
-
|
|
377
|
+
const aliased = aliasedPropName(name);
|
|
378
|
+
routeProp(el, aliased, textDefaultFor(el, aliased));
|
|
290
379
|
this.surface.requestCommit();
|
|
291
380
|
}
|
|
292
381
|
|
|
@@ -353,7 +442,12 @@ export class SymbioteRenderer implements Renderer2 {
|
|
|
353
442
|
if (isSurface(el)) return;
|
|
354
443
|
countAngular('rendererWrites');
|
|
355
444
|
noteAngularWrite(name);
|
|
356
|
-
|
|
445
|
+
const aliased = aliasedPropName(name);
|
|
446
|
+
routeProp(
|
|
447
|
+
el,
|
|
448
|
+
aliased,
|
|
449
|
+
value === undefined ? textDefaultFor(el, aliased) : value,
|
|
450
|
+
);
|
|
357
451
|
this.surface.requestCommit();
|
|
358
452
|
}
|
|
359
453
|
|
|
@@ -362,7 +456,9 @@ export class SymbioteRenderer implements Renderer2 {
|
|
|
362
456
|
noteAngularWrite('#text');
|
|
363
457
|
// A useful permanent seam: text mutations are low-frequency and the one place a stale
|
|
364
458
|
// `{{binding}}` (a change-detection gap) shows up as "the setValue never fired".
|
|
365
|
-
|
|
459
|
+
if (isDebug()) {
|
|
460
|
+
dlog(`Angular renderer setValue "${value}" on ${describeHost(node)}`);
|
|
461
|
+
}
|
|
366
462
|
setText(node, value);
|
|
367
463
|
this.surface.requestCommit();
|
|
368
464
|
}
|
|
@@ -377,6 +473,20 @@ export class SymbioteRenderer implements Renderer2 {
|
|
|
377
473
|
callback: (event: unknown) => boolean | void,
|
|
378
474
|
): () => void {
|
|
379
475
|
if (!isSymbioteNode(target)) return () => {};
|
|
476
|
+
// `[(value)]` desugars to `(valueChange)`, which is the spelling every Angular template writes
|
|
477
|
+
// for a Switch or a TextInput. On the COMPONENT path it is an `@Output()` the wrapper derives
|
|
478
|
+
// from the raw `change` payload; on a LOWERED element there is no component, and registering
|
|
479
|
+
// `valueChange` as an engine event would wait forever for a Fabric event of that name.
|
|
480
|
+
//
|
|
481
|
+
// The lowered path already carries the same fold under RN's own spelling: both behaviors call
|
|
482
|
+
// `node.props.onValueChange(value, event)` — a plain function PROP, not an event
|
|
483
|
+
// (`behaviors/switch.ts`, `behaviors/text-input.ts`). So this is a rename, not a mechanism:
|
|
484
|
+
// route the binding to that prop and `[(value)]` behaves identically on both paths. Without it
|
|
485
|
+
// the transform had to refuse to lower the two primitives whose idiomatic spelling this is.
|
|
486
|
+
if (eventName === VALUE_CHANGE_EVENT) {
|
|
487
|
+
routeProp(target, VALUE_CHANGE_PROP, callback);
|
|
488
|
+
return () => routeProp(target, VALUE_CHANGE_PROP, undefined);
|
|
489
|
+
}
|
|
380
490
|
setEventListener(target, eventName, callback);
|
|
381
491
|
return () => setEventListener(target, eventName, undefined);
|
|
382
492
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Re-export only. The implementation is shared (`@symbiote-native/components`) because two adapters
|
|
2
|
+
// had written byte-identical copies; this file exists so the code a transform EMITS keeps importing
|
|
3
|
+
// from the adapter package the app already depends on, rather than reaching past it.
|
|
4
|
+
//
|
|
5
|
+
// Imported from the SUBPATH, not the barrel: every adapter barrel re-exports
|
|
6
|
+
// `@symbiote-native/components` wholesale, so a name on that barrel silently becomes public API on
|
|
7
|
+
// all five — which is how a transform internal nearly shipped as a supported export.
|
|
8
|
+
//
|
|
9
|
+
// Present on every adapter, including those with no lowering transform yet: a per-framework subpath
|
|
10
|
+
// that four of five declare is exactly the silent gap `tests/package-subpath-parity.test.ts` exists
|
|
11
|
+
// to catch, and the file costs a re-export. Deliberately NOT on the public barrel — the emitted
|
|
12
|
+
// code names it, app code does not.
|
|
13
|
+
export {
|
|
14
|
+
resolveStateStyle,
|
|
15
|
+
type IPressStateArgument,
|
|
16
|
+
type IResolvedStateStyle,
|
|
17
|
+
} from '@symbiote-native/components/state-style';
|
|
18
|
+
|
|
19
|
+
// Angular-only addition to this otherwise byte-identical file: a template expression can call a
|
|
20
|
+
// PIPE, never an arbitrary imported function — `resolveStateStyle(x)` in a template resolves as
|
|
21
|
+
// `ctx.resolveStateStyle(x)`, a component-instance method lookup, which the lowering transform has
|
|
22
|
+
// no component to add one to (verified against the installed compiler-cli, 2026-09-01: the linker
|
|
23
|
+
// compiles it straight to a `ctx.` property read with no error, so this fails at RUNTIME —
|
|
24
|
+
// `ctx.resolveStateStyle is not a function` — not at compile time). A pipe is the one thing that
|
|
25
|
+
// resolves through `dependencies` the way a component/directive does.
|
|
26
|
+
//
|
|
27
|
+
// `emitStyleExpressionOnce` still applies: the transform's `@let pair = (expr | resolveStateStyle);`
|
|
28
|
+
// evaluates `expr` exactly once (verified via the compiled `ɵɵpipeBind1` call) and `pair.style` /
|
|
29
|
+
// `pair.activeStyle` are two cheap reads of the local, never a second evaluation of `expr`.
|
|
30
|
+
import { Pipe, type PipeTransform } from '@angular/core';
|
|
31
|
+
import { resolveStateStyle as resolveStateStyleImpl } from '@symbiote-native/components/state-style';
|
|
32
|
+
import type { IResolvedStateStyle } from '@symbiote-native/components/state-style';
|
|
33
|
+
|
|
34
|
+
@Pipe({ name: 'resolveStateStyle', standalone: true })
|
|
35
|
+
export class SymbioteStateStylePipe implements PipeTransform {
|
|
36
|
+
transform(value: unknown): IResolvedStateStyle {
|
|
37
|
+
return resolveStateStyleImpl(value);
|
|
38
|
+
}
|
|
39
|
+
}
|