@symbiote-native/components 0.4.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 +30 -8
- package/build/accessibility-props.d.ts +11 -0
- package/build/accessibility-props.js +30 -117
- package/build/behaviors/image.d.ts +3 -0
- package/build/behaviors/image.js +123 -0
- package/build/behaviors/input-accessory-view.d.ts +3 -0
- package/build/behaviors/input-accessory-view.js +70 -0
- package/build/behaviors/pressable.d.ts +2 -0
- package/build/behaviors/pressable.js +310 -0
- package/build/behaviors/switch.d.ts +2 -0
- package/build/behaviors/switch.js +182 -0
- package/build/behaviors/text-input.d.ts +14 -0
- package/build/behaviors/text-input.js +291 -0
- package/build/bootstrap/index.js +1 -1
- package/build/component-names/index.android.js +23 -1
- package/build/component-names/index.ios.js +9 -1
- package/build/component-names/shared.d.ts +1 -1
- package/build/component-names/shared.js +40 -1
- package/build/descriptor.d.ts +8 -0
- package/build/descriptor.js +27 -0
- package/build/fold-host-bag.d.ts +15 -0
- package/build/fold-host-bag.js +99 -0
- package/build/index.d.ts +25 -12
- package/build/index.js +18 -6
- package/build/resolve-intrinsic.d.ts +7 -0
- package/build/resolve-intrinsic.js +49 -0
- package/build/state/pressable.d.ts +9 -0
- package/build/state/pressable.js +133 -37
- package/build/state/sticky-header-reducer.js +28 -5
- package/build/state/switch.js +1 -1
- package/build/state/text-input.d.ts +7 -1
- package/build/state/text-input.js +46 -8
- package/build/state/touchable.d.ts +30 -1
- package/build/state/touchable.js +94 -5
- package/build/state/virtualized-list-diagnostics.d.ts +31 -0
- package/build/state/virtualized-list-diagnostics.js +33 -0
- package/build/state/virtualized-list-reducer.d.ts +14 -0
- package/build/state/virtualized-list-reducer.js +179 -45
- package/build/state/virtualized-list.d.ts +5 -2
- package/build/state/virtualized-list.js +143 -34
- package/build/state-style.d.ts +15 -0
- package/build/state-style.js +47 -0
- package/build/text-props.d.ts +9 -0
- package/build/text-props.js +25 -0
- package/build/view/render-activity-indicator.js +37 -3
- package/build/view/render-image/index.d.ts +2 -0
- package/build/view/render-image/index.js +42 -5
- package/build/view/render-input-accessory-view.d.ts +2 -0
- package/build/view/render-input-accessory-view.js +41 -6
- package/build/view/render-keyboard-avoiding-view.d.ts +14 -2
- package/build/view/render-keyboard-avoiding-view.js +52 -6
- package/build/view/render-modal.js +4 -2
- package/build/view/render-pressable/index.js +3 -1
- package/build/view/render-scroll-sticky.js +1 -1
- package/build/view/render-scroll-view.js +9 -3
- package/build/view/render-switch.js +11 -2
- package/build/view/render-text-input.js +6 -2
- package/build/view/render-touchable-highlight.d.ts +11 -1
- package/build/view/render-touchable-highlight.js +11 -10
- package/build/view/render-touchable-native-feedback.js +5 -1
- package/host-primitives.cjs +380 -0
- package/host-primitives.d.cts +35 -0
- package/lowering-fixtures.cjs +259 -0
- package/lowering-fixtures.d.cts +17 -0
- package/package.json +42 -5
- package/specialize-state-style.cjs +219 -0
- package/specialize-state-style.d.cts +15 -0
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
// The lowering SPEC — one description of which primitives compile to an intrinsic tag, what each
|
|
2
|
+
// folds, and when a transform must refuse. Data only: no AST, no framework, no code to share.
|
|
3
|
+
//
|
|
4
|
+
// WHY IT IS A `.cjs` AND NOT PART OF `src/`. Its consumers are Babel plugins and Metro
|
|
5
|
+
// transformers (`adapters/vue/metro-vue-transformer.cjs`, `adapters/{vue,solid}/babel-lower-host-
|
|
6
|
+
// primitives.cjs`), which run before any TS exists and cannot import from `src/`. That constraint
|
|
7
|
+
// is why the map was copied per adapter in the first place — `adapters/vue/src/components.ts`
|
|
8
|
+
// says so out loud: "keeps its own copy of this map (a .cjs cannot import from here)".
|
|
9
|
+
//
|
|
10
|
+
// WHAT IT DOES NOT UNIFY, stated here so nobody hunts for a contradiction that is not one: this
|
|
11
|
+
// says WHAT a fold is, never WHICH LAYER applies it. Svelte folds Text's defaults at compile time
|
|
12
|
+
// because a lowered `symbiote-text` has no wrapper left to do it; Vue and Solid apply the same
|
|
13
|
+
// fold at runtime in their renderers, where the wrapper used to. Both are correct.
|
|
14
|
+
//
|
|
15
|
+
// Four transforms carried their own copy of this before it existed, and it had already produced a
|
|
16
|
+
// real behaviour split — see `aliases` below.
|
|
17
|
+
|
|
18
|
+
// `intrinsicWhen` DECLARED AHEAD OF ITS FIRST ENTRY, like the refusal categories below it.
|
|
19
|
+
//
|
|
20
|
+
// WHAT IT IS FOR. `TextInput` is the first primitive whose TAG depends on a prop: `multiline`
|
|
21
|
+
// selects between two different Fabric views, `symbiote-text-input` and
|
|
22
|
+
// `symbiote-text-input-multiline` (`src/view/render-text-input.ts:33`), not between two values of
|
|
23
|
+
// one view. A transform prints a static tag, so it can resolve the choice only for a literal.
|
|
24
|
+
//
|
|
25
|
+
// ONE selector and ONE alternative, deliberately — not a map and not a list. There is exactly one
|
|
26
|
+
// such prop in the whole surface, and a wider field would be invented rather than needed. Absent
|
|
27
|
+
// `intrinsicWhen` means "one tag", so no existing entry changes.
|
|
28
|
+
//
|
|
29
|
+
// THE ACCEPTED STATIC FORMS ARE THREE, and the boundary is IDENTITY, not truthiness: a bare
|
|
30
|
+
// attribute is `true`, an explicit boolean literal is itself, absence is `false`. Everything else
|
|
31
|
+
// refuses — including a truthy non-boolean literal like `multiline={1}`, which a type-shaped check
|
|
32
|
+
// would wave through. The spec types the selector as a boolean; guessing past that is exactly how a
|
|
33
|
+
// silently wrong native view gets committed, and no later prop write can correct one.
|
|
34
|
+
// THE TYPEDEF BELOW IS NOT WHAT TYPESCRIPT READS. `host-primitives.d.cts` is a hand-written
|
|
35
|
+
// declaration file, and a field added here and not there compiles fine in every `.cjs` transform
|
|
36
|
+
// while failing `tsc` in the one consumer written in TypeScript — measured 2026-08-31, when
|
|
37
|
+
// `intrinsicWhen` landed here alone and reddened Svelte's preprocessor with the whole suite green
|
|
38
|
+
// (vitest does not typecheck). The reverse is worse and silent: a field in the `.d.cts` and not
|
|
39
|
+
// here typechecks everywhere and arrives `undefined` in all five transforms. Change both, together.
|
|
40
|
+
/**
|
|
41
|
+
* @typedef {{ op: 'nullish', value: unknown } | { op: 'notFalse' }} IFoldOp
|
|
42
|
+
* @typedef {{ prop: string, intrinsic: string }} IIntrinsicWhen
|
|
43
|
+
* @typedef {{ intrinsic: string, aliases: Record<string, string>, defaults: Record<string, IFoldOp>, intrinsicWhen?: IIntrinsicWhen }} IHostPrimitive
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
// `id` is RN's W3C-named alias for `nativeID` and it WINS when both are set. Verified against RN
|
|
47
|
+
// 0.86 rather than against our own adapters, because the three that implemented it disagreed:
|
|
48
|
+
//
|
|
49
|
+
// View.js:77-79 if (id !== undefined) processedProps.nativeID = id;
|
|
50
|
+
// Text.js:222 const _nativeID = id ?? nativeID;
|
|
51
|
+
//
|
|
52
|
+
// Same outcome on both tags: `nativeID = id ?? nativeID`, and the raw `id` key must NOT reach
|
|
53
|
+
// Fabric (no ViewConfig declares it, so it is silently dropped). What the adapters actually did:
|
|
54
|
+
// Solid folded it on both tags, Svelte on View only (its `else` branch skips Text), and Vue on
|
|
55
|
+
// neither — not in either transform, not in its renderer, not in `routeProp`. Vue's gap is OLDER
|
|
56
|
+
// than lowering (its `View` wrapper was always a bare pass-through), so it is a standing
|
|
57
|
+
// <adapters_reach_full_feature_parity> miss, not a lowering regression.
|
|
58
|
+
//
|
|
59
|
+
// WHICH LAYER APPLIES IT IS THE ADAPTER'S CHOICE, exactly as for `defaults` below. Solid and
|
|
60
|
+
// Svelte rename at COMPILE time inside the lowering transform; Vue applies it at RUNTIME
|
|
61
|
+
// (`PROP_ALIASES` in `adapters/vue/src/renderer/index.ts`, in `patchProp`) because Vue has FOUR
|
|
62
|
+
// paths to a node — lowered SFC, lowered TSX, the component wrapper, and a hand-written
|
|
63
|
+
// `h('symbiote-view', {id})` — and compile time only covers two of them. A transform reading this
|
|
64
|
+
// spec must therefore not assume it owns the fold. Applying it at both layers happens to be
|
|
65
|
+
// harmless here (the rename deletes `id`, so the second pass sees nothing), but that is a property
|
|
66
|
+
// of THIS alias, not a licence.
|
|
67
|
+
//
|
|
68
|
+
// KNOWN DIVERGENCE FROM UPSTREAM, present in two adapters and not introduced by this file: when an
|
|
69
|
+
// element carries BOTH `id` and `nativeID`, RN gives `id` unconditional priority, while a per-key
|
|
70
|
+
// rename (Vue's runtime patchProp, Solid's compile-time rename producing two `nativeID`
|
|
71
|
+
// attributes) lets the LAST one win. Honest parity needs per-node state; no example and no test
|
|
72
|
+
// sets both today.
|
|
73
|
+
const ID_ALIAS = { id: 'nativeID' };
|
|
74
|
+
|
|
75
|
+
/** @type {Record<string, IHostPrimitive>} */
|
|
76
|
+
const HOST_PRIMITIVES = {
|
|
77
|
+
View: {
|
|
78
|
+
intrinsic: 'symbiote-view',
|
|
79
|
+
aliases: ID_ALIAS,
|
|
80
|
+
defaults: {},
|
|
81
|
+
},
|
|
82
|
+
// The five-way switch, thrown 2026-08-23 once all three transforms carried the refusals
|
|
83
|
+
// (`observesState` below). `symbiote-pressable` resolves to the SAME `RCTView` a plain view does
|
|
84
|
+
// — the tag exists only so the host-behavior registry, which is keyed by TAG and never by
|
|
85
|
+
// resolved name, can find the press machine.
|
|
86
|
+
//
|
|
87
|
+
// No defaults, and only the `id` -> `nativeID` alias every primitive carries: a lowered Pressable
|
|
88
|
+
// forwards its props otherwise untouched, and the machine reads them off `node.props` at event
|
|
89
|
+
// time. (This read "No aliases and no defaults" while the line below already said `ID_ALIAS`, and
|
|
90
|
+
// a Solid test injected a `Pressable` entry with `aliases: {}` on the strength of it.)
|
|
91
|
+
Pressable: {
|
|
92
|
+
intrinsic: 'symbiote-pressable',
|
|
93
|
+
aliases: ID_ALIAS,
|
|
94
|
+
defaults: {},
|
|
95
|
+
// Turns on `stateInTemplate` and `renderPropChild`. Without them a render-prop button becomes
|
|
96
|
+
// a tag with no machine — the whole reason this entry landed last.
|
|
97
|
+
observesState: true,
|
|
98
|
+
},
|
|
99
|
+
// Landed 2026-08-31, on the second attempt. The first threw the switch with the runtime half
|
|
100
|
+
// unwired and was reverted the same hour; both gaps it exposed are closed here, and the record is
|
|
101
|
+
// kept because the SEQUENCE is the reusable part — an entry here is a switch for four transforms
|
|
102
|
+
// at once, so it goes in after every side is ready, never to prove the transforms work.
|
|
103
|
+
//
|
|
104
|
+
// 1. `registerTextInputBehavior()` is now called by `adapters/{vue,svelte,solid}/src/register.ts`,
|
|
105
|
+
// the three that lower. React and Angular have no lowering transform, so no lowered node ever
|
|
106
|
+
// exists there and neither carries a `register.ts` — the same reason they skip Pressable's.
|
|
107
|
+
//
|
|
108
|
+
// 2. The component path no longer shares these tags. It renders `symbiote-text-input-managed`
|
|
109
|
+
// (`component-names/shared.ts`), because the registry is keyed by TAG and the wrappers run the
|
|
110
|
+
// same machine in their own lifecycle — one shared tag would have installed both copies on a
|
|
111
|
+
// wrapper-built node and fired `setInputFocused` twice per focus.
|
|
112
|
+
TextInput: {
|
|
113
|
+
intrinsic: 'symbiote-text-input',
|
|
114
|
+
aliases: ID_ALIAS,
|
|
115
|
+
defaults: {},
|
|
116
|
+
// `multiline` picks between two SEPARATE native views, not one view with a flag, so the tag is
|
|
117
|
+
// decided at compile time and a runtime selector must refuse — a wrong view here is
|
|
118
|
+
// uncorrectable by any later prop write. `REFUSAL_CATEGORIES.dynamicIntrinsicChoice`.
|
|
119
|
+
intrinsicWhen: {
|
|
120
|
+
prop: 'multiline',
|
|
121
|
+
intrinsic: 'symbiote-text-input-multiline',
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
// Landed 2026-09-01, same order as TextInput and Image: runtime half built
|
|
125
|
+
// (`core/components/src/behaviors/switch.ts`), registered by the four lowering adapters, proven
|
|
126
|
+
// against the wrapper's payload (positive + negative controls, a break-tested async-timing case)
|
|
127
|
+
// before this key existed.
|
|
128
|
+
//
|
|
129
|
+
// `-managed` twin, same reason as TextInput: the behavior carries a machine (mirrors the last
|
|
130
|
+
// value native reported, sends a platform snap-back command on disagreement), so a wrapper-built
|
|
131
|
+
// node — which already runs that same machine in its own lifecycle — must not also get the
|
|
132
|
+
// engine's copy. `render-switch.ts` emits `symbiote-switch-managed`; this key's `intrinsic` is
|
|
133
|
+
// the bare tag the behavior registry attaches to.
|
|
134
|
+
//
|
|
135
|
+
// IDEMPOTENCE OF THE FOLD IS MOOT HERE FOR A DIFFERENT REASON THAN IMAGE'S. Image's entry has no
|
|
136
|
+
// `-managed` twin, so its fold genuinely CAN run twice (component then lowered, same tag), and
|
|
137
|
+
// idempotence is what makes that safe — asserted, not assumed. Switch's fold is NOT trivial (it
|
|
138
|
+
// maps `trackColor`/`thumbColor`/`ios_backgroundColor` to native prop names, keyed on
|
|
139
|
+
// `Platform.OS`) and running it twice would NOT be a no-op — but the question never arises: the
|
|
140
|
+
// `-managed` split means only the bare `symbiote-switch` tag ever carries this behavior, and the
|
|
141
|
+
// wrapper never emits that tag, so no node's payload ever passes through this fold more than
|
|
142
|
+
// once. Unreachable by construction, not idempotent by property — the same distinction
|
|
143
|
+
// TextInput's own entry draws for its fold.
|
|
144
|
+
//
|
|
145
|
+
// No `observesState`: nothing in Switch's public surface is a function-valued style or a
|
|
146
|
+
// render-prop child (`style?: IStyleProp<IViewStyle>`, never a callback), so neither
|
|
147
|
+
// `stateInTemplate` nor `renderPropChild` applies — unlike Pressable, whose machine is what
|
|
148
|
+
// forced that flag.
|
|
149
|
+
Switch: {
|
|
150
|
+
intrinsic: 'symbiote-switch',
|
|
151
|
+
aliases: ID_ALIAS,
|
|
152
|
+
defaults: {},
|
|
153
|
+
},
|
|
154
|
+
Text: {
|
|
155
|
+
intrinsic: 'symbiote-text',
|
|
156
|
+
aliases: ID_ALIAS,
|
|
157
|
+
// RN's Text.js applies both unconditionally on the non-virtual path. Each key below cites
|
|
158
|
+
// the upstream line verbatim, because THIS DATA is now the thing that must not drift from RN.
|
|
159
|
+
// The
|
|
160
|
+
// authority on what they MEAN is `src/text-props.ts`'s resolveTextProps, which every wrapper
|
|
161
|
+
// path already calls; this is the same fold expressed as data so a COMPILE-time transform can
|
|
162
|
+
// emit it too. `notFalse`, never `nullish` — RN treats an explicit `undefined` like a missing
|
|
163
|
+
// prop and only a literal `false` opts out. Emit both keys unconditionally: a fold whose two
|
|
164
|
+
// branches emit different key sets is the hazard `.claude/rules/solid-descriptor-bridge.md` §1
|
|
165
|
+
// exists for.
|
|
166
|
+
defaults: {
|
|
167
|
+
// Text.js:291 processedProps.ellipsizeMode = ellipsizeMode ?? 'tail';
|
|
168
|
+
ellipsizeMode: { op: 'nullish', value: 'tail' },
|
|
169
|
+
// Text.js:289 processedProps.allowFontScaling = allowFontScaling !== false;
|
|
170
|
+
allowFontScaling: { op: 'notFalse' },
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
// FOLD-ONLY: the behavior registered for this tag carries a prop fold and nothing else — no
|
|
174
|
+
// listeners, no commit hook, no per-node runtime (`core/components/src/behaviors/image.ts`). The
|
|
175
|
+
// whole of the wrapper's body was prop mapping, so the lowered form owes exactly that.
|
|
176
|
+
//
|
|
177
|
+
// No `-managed` twin, unlike TextInput, and the reason is a measured PROPERTY rather than a
|
|
178
|
+
// precedent: `mapImageProps` is idempotent, so registering the fold on the tag `renderImage`
|
|
179
|
+
// already emits means a wrapper-built node simply folds a second time to no effect. Asserted in
|
|
180
|
+
// `behaviors/image.test.ts`; break-tested. TextInput's split is NOT about idempotence (its fold
|
|
181
|
+
// is idempotent too) — it is about one owner per node, because that behavior carries a machine.
|
|
182
|
+
//
|
|
183
|
+
// Entered LAST, after the runtime half was built, registered by all four lowering adapters and
|
|
184
|
+
// proven against the wrapper's payload. Adding this key is what makes every transform start
|
|
185
|
+
// lowering `Image` at once, so a fold that had not landed would surface as a raw `src` reaching
|
|
186
|
+
// Fabric — a key no ViewConfig declares, which throws nothing and paints nothing.
|
|
187
|
+
Image: {
|
|
188
|
+
intrinsic: 'symbiote-image',
|
|
189
|
+
aliases: ID_ALIAS,
|
|
190
|
+
// None. Every default RN's Image applies is already inside the shared mapping (the source
|
|
191
|
+
// array shape, the width/height style fold, `alt` -> accessibilityLabel), which the behavior
|
|
192
|
+
// runs at commit — so there is nothing left for a compile-time seed to do.
|
|
193
|
+
defaults: {},
|
|
194
|
+
},
|
|
195
|
+
// Entered LAST, same order Image used: runtime half built, registered by the four lowering
|
|
196
|
+
// adapters, and proven against the wrapper's payload before this key existed.
|
|
197
|
+
//
|
|
198
|
+
// The ONLY primitive so far whose intrinsic resolves to a different Fabric component per
|
|
199
|
+
// platform — `RCTInputAccessoryView` on iOS, a plain `RCTView` on Android. The fold is
|
|
200
|
+
// platform-invariant on purpose (it reproduces the wrapper's mapping on both, so the lowered and
|
|
201
|
+
// wrapped paths cannot diverge per platform); what it does NOT do is repair what sits underneath,
|
|
202
|
+
// where upstream RN renders nothing at all off iOS. That divergence predates the lowering, is
|
|
203
|
+
// identical on both paths, and is with the owner as its own decision.
|
|
204
|
+
InputAccessoryView: {
|
|
205
|
+
intrinsic: 'symbiote-input-accessory-view',
|
|
206
|
+
aliases: ID_ALIAS,
|
|
207
|
+
// None. The mapping has no aliasing and no derived value — every consumed name leaves under the
|
|
208
|
+
// same name — so there is nothing for a compile-time seed to do.
|
|
209
|
+
defaults: {},
|
|
210
|
+
},
|
|
211
|
+
// The emptiest entry here, and deliberately so — the withholding protocol has nothing to protect
|
|
212
|
+
// for this one. Every other primitive was held back until its runtime half existed and was proven
|
|
213
|
+
// against the wrapper's payload; SafeAreaView has no runtime half to build. All five adapters fold
|
|
214
|
+
// exactly one thing, `resolveAccessibilityProps`, and that fold already runs in the engine at
|
|
215
|
+
// `fabricProps` on both commit paths (the `aria-bag-fold` row). So there is no
|
|
216
|
+
// `behaviors/safe-area-view.ts`, and a reader who assumes one exists will go looking for a file
|
|
217
|
+
// that was never needed.
|
|
218
|
+
//
|
|
219
|
+
// Counted before writing, which is the only thing standing behind that claim: five
|
|
220
|
+
// implementations, zero shared, none synthesizing a node — each renders ONE
|
|
221
|
+
// `symbiote-safe-area-view` with children on its framework's own channel (React's third argument,
|
|
222
|
+
// a Vue slot, a Solid JSX child, Angular's `<ng-content>`, a Svelte snippet). That clears the
|
|
223
|
+
// disqualifier in `.claude/rules/host-primitive-tier.md`.
|
|
224
|
+
//
|
|
225
|
+
// NO `ID_ALIAS`, and this is the one place SafeAreaView departs from every entry above it. The
|
|
226
|
+
// alias exists to REPRODUCE a fold the wrapper performs; not one of the five SafeAreaView wrappers
|
|
227
|
+
// folds `id`, and none declares it. Adding the alias here would make the lowered element fold a
|
|
228
|
+
// prop its component spelling passes through untouched — a lowering that ADDS a capability, which
|
|
229
|
+
// `.claude/rules/adapter-parity-audit.md` records as a bug in the same way as one that drops it.
|
|
230
|
+
// That the five entries above all share `ID_ALIAS` is a property of those five primitives, not a
|
|
231
|
+
// house style to copy: the sixth is where "every case so far did X" stops being a rule.
|
|
232
|
+
//
|
|
233
|
+
// The `id` surface gap itself is real and PRE-EXISTING — upstream's SafeAreaView takes `ViewProps`,
|
|
234
|
+
// so RN accepts `id` where our wrappers do not. It predates lowering, is identical on both paths,
|
|
235
|
+
// and closing it means adding `id` to five wrappers AND this alias together, never one of the two.
|
|
236
|
+
SafeAreaView: {
|
|
237
|
+
intrinsic: 'symbiote-safe-area-view',
|
|
238
|
+
// ID_ALIAS was deliberately ABSENT here until 2026-09-01, because none of the five wrappers
|
|
239
|
+
// declared `id` and aliasing on the lowered path alone would have made lowering ADD a fold the
|
|
240
|
+
// component spelling does not perform. That exposed a real divergence — Solid's renderer folds
|
|
241
|
+
// `id` from two string constants on the write path, so it aliased for a primitive whose spec
|
|
242
|
+
// said not to (`adapters/solid/src/renderer-alias-fold.test.ts`, whose header predicted exactly
|
|
243
|
+
// this the day a primitive stopped sharing the pair).
|
|
244
|
+
//
|
|
245
|
+
// Resolved by closing the gap rather than routing around it: `id` is now declared on all five
|
|
246
|
+
// wrappers and folded here. That keeps Solid's constant-pair fast path (32 001 writes on a
|
|
247
|
+
// benchmark create) and removes a real parity deficit — upstream's SafeAreaView takes the full
|
|
248
|
+
// ViewProps surface, so RN accepts `id` where none of ours did. Half of this is not an option
|
|
249
|
+
// in either direction: the alias without the prop folds a key nobody can pass, and the prop
|
|
250
|
+
// without the alias sends a raw `id` to a view whose ViewConfig declares no such key.
|
|
251
|
+
aliases: ID_ALIAS,
|
|
252
|
+
defaults: {},
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
// A transform must refuse — leave the element a component — whenever it hits one of these. The
|
|
257
|
+
// categories are framework-independent; how each compiler DETECTS one is not, and stays in that
|
|
258
|
+
// transform. Modelled on the Svelte preprocessor's set, which is the most developed of the three.
|
|
259
|
+
//
|
|
260
|
+
// Refusing is always safe: a refused element simply keeps today's behaviour. Guessing is not — a
|
|
261
|
+
// half-read attribute set is a silently wrong render, on device only. Design every new category
|
|
262
|
+
// around that asymmetry.
|
|
263
|
+
// A stateful primitive carries `observesState: true`, and that flag is what turns the two refusal
|
|
264
|
+
// categories below ON in a transform. Declared in one place so five transforms agree on the
|
|
265
|
+
// spelling rather than inventing five.
|
|
266
|
+
//
|
|
267
|
+
// ADDING A STATEFUL ENTRY TO HOST_PRIMITIVES IS A FIVE-WAY SWITCH and must be the LAST step. Every
|
|
268
|
+
// transform lowers whatever the spec lists, so an entry that lands before a transform can refuse
|
|
269
|
+
// turns a render-prop button into a tag with no machine: a button that does not press, with nothing
|
|
270
|
+
// red in any suite. `Pressable` was held back for exactly that and thrown only once all three
|
|
271
|
+
// transforms carried the detections — verified by grep for `observesState` in
|
|
272
|
+
// adapters/{solid,vue}/*.cjs and adapters/svelte/src/preprocessor/, not by asking.
|
|
273
|
+
const REFUSAL_CATEGORIES = {
|
|
274
|
+
// `{...spread}` / `v-bind="obj"` — the attribute set cannot be read whole.
|
|
275
|
+
unreadableAttributeSet: 'an attribute set this transform cannot enumerate',
|
|
276
|
+
// A computed or otherwise non-literal attribute KEY.
|
|
277
|
+
unreadableKey: 'an attribute key this transform cannot resolve to a string',
|
|
278
|
+
// A value shape the transform cannot reproduce in the lowered form.
|
|
279
|
+
unreadableValue: 'an attribute value this transform cannot read whole',
|
|
280
|
+
// `bind:` / `use:` / `{@attach}` / a template ref — binds the COMPONENT INSTANCE, which a
|
|
281
|
+
// lowered element does not have.
|
|
282
|
+
instanceBoundDirective:
|
|
283
|
+
'a directive that binds the component instance, not the host node',
|
|
284
|
+
// RETIRED 2026-08-31 — `bagFold`, "an attribute whose fold needs to see its siblings (role,
|
|
285
|
+
// aria-*)". Kept as a comment because the retirement carries two lessons the entry itself never
|
|
286
|
+
// could.
|
|
287
|
+
//
|
|
288
|
+
// WHY IT IS GONE. The aria fold moved into the engine — `core/engine/src/accessibility-props.ts`,
|
|
289
|
+
// called from `fabricProps`, the one point where the whole bag is known on BOTH commit paths — so
|
|
290
|
+
// a lowered element gets it exactly like a wrapped one. The premise that a per-key element path
|
|
291
|
+
// cannot fold a composite was right; the conclusion that a TRANSFORM had to refuse was not. The
|
|
292
|
+
// fold belongs at the layer every path goes through, not at the one layer lowering removes.
|
|
293
|
+
//
|
|
294
|
+
// AND IT WAS NEVER IN FORCE. Measured before removing it: of the four transforms, only Solid's
|
|
295
|
+
// consulted this category. Vue's two lowered such elements happily and Svelte's preprocessor does
|
|
296
|
+
// not contain the string `role` at all. So every lowered `aria-label` had been reaching Fabric as
|
|
297
|
+
// a key no ViewConfig declares — the accessibility label silently dropped, on device only. This
|
|
298
|
+
// constant is a VOCABULARY, not an enforcement point: writing a category down binds nobody, and
|
|
299
|
+
// a transform that never consults it breaks nothing visible.
|
|
300
|
+
//
|
|
301
|
+
// What replaced it is a ROW, not a rule: `aria-bag-fold` in `lowering-fixtures.cjs`, verdict
|
|
302
|
+
// `lower`, which every transform's runner must answer. Retiring or adding a category owes a row
|
|
303
|
+
// there in the same change, or the prose goes unenforced again
|
|
304
|
+
// (`.claude/rules/adapter-parity-audit.md`).
|
|
305
|
+
// The two below exist for a STATEFUL primitive (Pressable) and nothing refuses on them yet.
|
|
306
|
+
// They are declared ahead of the spec entry on purpose: the moment a stateful tag appears in
|
|
307
|
+
// HOST_PRIMITIVES, every transform lowers it, and a transform that cannot yet refuse lowers a
|
|
308
|
+
// render-prop button into a tag with no machine — a button that does not press, with nothing
|
|
309
|
+
// red anywhere. So the spec entry goes in LAST, after all five can refuse, and the names live
|
|
310
|
+
// here from the start so five detections do not invent five spellings of one rule.
|
|
311
|
+
//
|
|
312
|
+
// A functional `style` — `style={({pressed}) => …}`. The TEMPLATE reads the press state, which
|
|
313
|
+
// is exactly what tier 2 cannot do: the state resolves below the framework, through the style
|
|
314
|
+
// registry's `:active`, and never crosses back up. The migration target is a `:active` CSS rule,
|
|
315
|
+
// not a smarter transform (`.claude/rules/host-primitive-tier.md`).
|
|
316
|
+
//
|
|
317
|
+
// DETECT IT AS AN ALLOW-LIST, NOT AS A HUNT FOR A FUNCTION LITERAL. All five transforms must land
|
|
318
|
+
// the same side of this or they diverge on the one call site that hoists its style: `style={fn}`
|
|
319
|
+
// is an Identifier at compile time and no transform can tell whether it holds an object or a
|
|
320
|
+
// function. So only provably inert value shapes lower — object / array / literal / template
|
|
321
|
+
// literal — and everything else refuses. The asymmetry is what settles it: a refused element
|
|
322
|
+
// keeps exactly today's behaviour, while a wrongly lowered one is a button that renders and does
|
|
323
|
+
// not respond.
|
|
324
|
+
stateInTemplate: 'a prop whose value is not provably inert at compile time',
|
|
325
|
+
// NOT A REFUSAL — kept as a named requirement because it was briefly written as one, and the
|
|
326
|
+
// difference is worth the paragraph.
|
|
327
|
+
//
|
|
328
|
+
// The resting/pressed pair needs the style callback invoked twice. A transform that emits the
|
|
329
|
+
// guard INLINE — `typeof f === 'function' ? f({pressed:false}) : f` — puts the expression in its
|
|
330
|
+
// own output three times, so `style={getStyle()}` calls the author's function three times,
|
|
331
|
+
// `style={bag[i]}` evaluates the index three times, and `style={flag ? a : b}` can take
|
|
332
|
+
// different branches on different reads. Faced with that, the first instinct is to refuse those
|
|
333
|
+
// shapes.
|
|
334
|
+
//
|
|
335
|
+
// THAT IS THE WRONG FIX, and encoding it in this file would have made one transform's emission
|
|
336
|
+
// defect a law binding the others. The double read is a property of the EMISSION SHAPE, not of
|
|
337
|
+
// the expression: wrap once — `resolveStateStyle(expr)` — and the expression is evaluated
|
|
338
|
+
// exactly once while its RESULT is what gets called twice. All three shapes above then lower and
|
|
339
|
+
// stay correct.
|
|
340
|
+
//
|
|
341
|
+
// So the rule for every transform is: **emit the style expression exactly once**, and assert it
|
|
342
|
+
// on the output text (`occurrences(out, expr) === 1`) rather than trusting the shape. What
|
|
343
|
+
// survives as a real contract is only that the callback must be PURE in `pressed` — its result is
|
|
344
|
+
// invoked twice under any emission.
|
|
345
|
+
//
|
|
346
|
+
// Caught by the Svelte session after this had been written here as a refusal and sent to two
|
|
347
|
+
// adapters; the Vue session found the underlying double-read in the first place.
|
|
348
|
+
emitStyleExpressionOnce:
|
|
349
|
+
'REQUIREMENT, not a refusal: wrap the style expression once, never repeat it in the output',
|
|
350
|
+
// A function child with arity >= 1 — `{({pressed}) => …}`. Same rule, through children rather
|
|
351
|
+
// than props. Arity ZERO is an ordinary lazy child, not a render prop, and must NOT refuse.
|
|
352
|
+
renderPropChild:
|
|
353
|
+
'a function child that takes the primitive own state as an argument',
|
|
354
|
+
// DECLARED AHEAD OF ITS SPEC ENTRY, the same way the two above were, and for the same reason: the
|
|
355
|
+
// moment `TextInput` appears in HOST_PRIMITIVES every transform lowers it at once, and one that
|
|
356
|
+
// cannot yet refuse would pick the WRONG Fabric view. Four detections written against a name that
|
|
357
|
+
// already exists cannot invent four spellings of one rule.
|
|
358
|
+
//
|
|
359
|
+
// `multiline` is the first prop in this project that selects between TWO intrinsics —
|
|
360
|
+
// `symbiote-text-input` and `symbiote-text-input-multiline` are different Fabric views, not one
|
|
361
|
+
// view with a flag (`core/components/src/view/render-text-input.ts`). A transform prints a static
|
|
362
|
+
// tag name, so it can resolve `multiline` only when the value is a literal. `multiline={isLong}`
|
|
363
|
+
// is a RUNTIME value and there is no tag to print — the element must stay a component.
|
|
364
|
+
//
|
|
365
|
+
// NOT the same hazard as an unreadable attribute VALUE. A value the transform cannot read is a
|
|
366
|
+
// prop that ends up wrong; this one ends up committing the wrong native view, which no prop write
|
|
367
|
+
// can correct afterwards. Refusing keeps today's behaviour exactly.
|
|
368
|
+
dynamicIntrinsicChoice:
|
|
369
|
+
'a prop that selects between two intrinsics and is not a compile-time literal',
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
// Lowering rewrites `class="x"` into an opaque bag expression, so any pass that matches on literal
|
|
373
|
+
// attributes can no longer see it. Reversed against Svelte's style scoper, every scoped class
|
|
374
|
+
// silently stopped being scoped — nothing threw, the styles just stopped applying on device. The
|
|
375
|
+
// hazard belongs to any transform that folds attributes into an expression, not to Svelte, so it
|
|
376
|
+
// is stated once here as an ordering contract.
|
|
377
|
+
const LOWERING_RUNS_LAST =
|
|
378
|
+
'lowering is the LAST attribute-rewriting pass in its pipeline';
|
|
379
|
+
|
|
380
|
+
module.exports = { HOST_PRIMITIVES, REFUSAL_CATEGORIES, LOWERING_RUNS_LAST };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Types for the lowering spec. Hand-written because the spec itself must stay `.cjs` — its
|
|
2
|
+
// consumers are Babel/Metro transforms that run before any TS exists (see the module's header).
|
|
3
|
+
// Svelte's preprocessor is TS and reads the same file through these.
|
|
4
|
+
|
|
5
|
+
export type IFoldOp = { op: 'nullish'; value: unknown } | { op: 'notFalse' };
|
|
6
|
+
|
|
7
|
+
export interface IHostPrimitive {
|
|
8
|
+
intrinsic: string;
|
|
9
|
+
aliases: Readonly<Record<string, string>>;
|
|
10
|
+
defaults: Readonly<Record<string, IFoldOp>>;
|
|
11
|
+
/**
|
|
12
|
+
* The primitive's own state is observable from the TEMPLATE, so a call site that reads it
|
|
13
|
+
* cannot be lowered — the state resolves below the framework (`:active` in the style registry)
|
|
14
|
+
* and never travels back up. Turns on the `stateInTemplate` and `renderPropChild` refusals.
|
|
15
|
+
* Absent means false; only a stateful primitive ever sets it.
|
|
16
|
+
*/
|
|
17
|
+
observesState?: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* The primitive's TAG depends on a prop. `TextInput` is the only one: `multiline` selects between
|
|
20
|
+
* two different Fabric views (`symbiote-text-input` / `symbiote-text-input-multiline`), not
|
|
21
|
+
* between two values of one view. A transform prints a static tag, so it resolves the choice only
|
|
22
|
+
* for a literal — and the boundary is IDENTITY, not truthiness: a bare attribute is `true`, an
|
|
23
|
+
* explicit boolean literal is itself, absence is `false`, and everything else refuses (including a
|
|
24
|
+
* truthy non-boolean like `multiline={1}`, which a type-shaped check waves through).
|
|
25
|
+
*
|
|
26
|
+
* ONE selector and ONE alternative, deliberately. Absent means "one tag", so no existing entry
|
|
27
|
+
* changes. Full rationale in `host-primitives.cjs`, which this file must be kept in step with —
|
|
28
|
+
* see the note there.
|
|
29
|
+
*/
|
|
30
|
+
intrinsicWhen?: { prop: string; intrinsic: string };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export declare const HOST_PRIMITIVES: Readonly<Record<string, IHostPrimitive>>;
|
|
34
|
+
export declare const REFUSAL_CATEGORIES: Readonly<Record<string, string>>;
|
|
35
|
+
export declare const LOWERING_RUNS_LAST: string;
|