@symbiote-native/solid 0.1.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 +25 -14
- package/babel-lower-host-primitives.cjs +323 -0
- package/babel-preset.cjs +5 -0
- package/build/components/pressable.d.ts +1 -0
- package/build/components/pressable.jsx +30 -4
- package/build/components/safe-area-view.d.ts +1 -0
- package/build/components/text-input.js +22 -0
- package/build/components/touchable/index.jsx +1 -1
- package/build/components/touchable-native-feedback/index.jsx +1 -1
- package/build/host-instance.js +3 -3
- package/build/index.d.ts +1 -0
- package/build/index.js +2 -0
- package/build/modules/animated/create-animated-component.d.ts +1 -1
- package/build/modules/animated/create-animated-component.jsx +22 -0
- package/build/register.d.ts +1 -0
- package/build/register.js +31 -0
- package/build/renderer.d.ts +1 -0
- package/build/renderer.js +67 -3
- package/build/state-style.d.ts +1 -0
- package/build/state-style.js +13 -0
- package/package.json +24 -8
package/README.md
CHANGED
|
@@ -3,24 +3,24 @@
|
|
|
3
3
|
SolidJS adapter for SymbioteNative. Compiled Solid JSX drives real native iOS/Android views through
|
|
4
4
|
the same `@symbiote-native/engine` every other adapter uses — React's renderer is never in the path.
|
|
5
5
|
|
|
6
|
-
**Status: L4
|
|
7
|
-
stateful touch components, the Touchable family, Button / ImageBackground /
|
|
8
|
-
StatusBar and the list family all ship at full parity with the React
|
|
9
|
-
`@symbiote-native/components`, via `descriptorToSolid` where the component has a
|
|
10
|
-
The engine-owned runtime modules are re-exported from the barrel.
|
|
11
|
-
|
|
12
|
-
`
|
|
13
|
-
|
|
6
|
+
**Status: L4 done (parity phase complete).** The renderer seam, `mount`/`unmount`, the
|
|
7
|
+
primitives, the stateful touch components, the Touchable family, Button / ImageBackground /
|
|
8
|
+
InputAccessoryView, StatusBar and the list family all ship at full parity with the React
|
|
9
|
+
reference — off `@symbiote-native/components`, via `descriptorToSolid` where the component has a
|
|
10
|
+
shared render fn. The engine-owned runtime modules are re-exported from the barrel. `Portal`
|
|
11
|
+
(same-surface content relocation, this adapter's twin of React's `createPortal`) and
|
|
12
|
+
`createTunnel` (cross-surface) both ship, built over the universal renderer rather than
|
|
13
|
+
`solid-js/web`'s DOM-bound `Portal`/`Dynamic`. `Dynamic` itself stays absent by design — nothing
|
|
14
|
+
here needs it, and no other adapter ships a twin for parity to require one. See
|
|
15
|
+
`symbiote-new-adapter` §7 for what each layer means.
|
|
14
16
|
|
|
15
17
|
Solid's own control-flow `Switch`/`Match` are NOT re-exported from this package: `Switch` collides
|
|
16
18
|
with RN's Switch component, which every adapter must export under that name. Import the control-flow
|
|
17
19
|
pair from `solid-js` directly.
|
|
18
20
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
be published on the next release run, shipping a reduced surface and violating the repo's
|
|
23
|
-
`<adapters_reach_full_feature_parity>` invariant. Remove the flag in the commit that finishes parity.
|
|
21
|
+
The adapter is a regular publishable package (`@symbiote-native/solid` on npm, alongside React,
|
|
22
|
+
Vue, Svelte and Angular) — it carries no `"private"` flag, so `changeset publish` and the
|
|
23
|
+
pkg.pr.new canary cover it the same as every other adapter.
|
|
24
24
|
|
|
25
25
|
## Why Solid needs no DOM shim and no Metro transformer
|
|
26
26
|
|
|
@@ -61,6 +61,17 @@ exactly what react-refresh's `isLikelyComponentType` heuristic looks for. It the
|
|
|
61
61
|
React Fiber tree that does not exist in this path, and the update is swallowed with no error. Force a
|
|
62
62
|
full reload instead: `unstable_forceFullRefreshPatterns: [/\.tsx$/]`.
|
|
63
63
|
|
|
64
|
+
## Host-primitive lowering — `<View>`/`<Text>`/`<Pressable>` compile to intrinsics, not components
|
|
65
|
+
|
|
66
|
+
`babel-preset-solid` (wired above) runs `babel-lower-host-primitives.cjs` before compiling: a
|
|
67
|
+
`<View>`/`<Text>` with no reactive prop, and a `<Pressable>` whose `style`/children don't need a
|
|
68
|
+
component instance, rewrite to the engine's intrinsic tags (`symbiote-view`, `symbiote-text`,
|
|
69
|
+
`symbiote-pressable`) instead of going through `createComponent` + a props Proxy. Nothing in app
|
|
70
|
+
code has to opt in — the preset does it transparently, and a call site that can't be lowered
|
|
71
|
+
(a spread, a render-prop child, an unreadable attribute) falls back to the component path
|
|
72
|
+
unchanged. See root `CLAUDE.md`, "Where we stand against stock React Native", for the measured
|
|
73
|
+
device numbers this bought — do not hand-copy them here, they move with every engine/adapter cut.
|
|
74
|
+
|
|
64
75
|
## `./renderer` is a compiler target, not a convenience export
|
|
65
76
|
|
|
66
77
|
`babel-preset-solid` with `generate: 'universal'` rewrites JSX into direct calls imported from the
|
|
@@ -123,7 +134,7 @@ reached the same single-root conclusion for its own reasons.
|
|
|
123
134
|
|
|
124
135
|
## Reference
|
|
125
136
|
|
|
126
|
-
- The canary app: `examples/solid` (its README covers
|
|
137
|
+
- The canary app: `examples/solid` (its README covers running it and the checks it carries).
|
|
127
138
|
- The engine API this targets: the `symbiote-engine-core` skill, `core/engine/src/node.ts`.
|
|
128
139
|
- Building/porting an adapter: the `symbiote-new-adapter` skill.
|
|
129
140
|
- The nodeOps table this mirrors: `adapters/vue/src/renderer/index.ts`.
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
// Compile-time lowering of <View>/<Text> to the intrinsic tags the renderer maps to Fabric.
|
|
2
|
+
//
|
|
3
|
+
// WHY. Solid charges a props PROXY per component: `<View>` compiles to `createComponent(View, …)`,
|
|
4
|
+
// and the component body then runs splitProps + withStableKeys + mergeProps + spread. Measured
|
|
5
|
+
// 2026-08-23 on a 4 000-row create: keys/get/getOwnPropertyDescriptor traps 16.2% of the whole
|
|
6
|
+
// create, splitProps another 6.1%. Lowered, the same element compiles to `createElement(tag)` plus
|
|
7
|
+
// one `setProp` per attribute — no Proxy, no bag object, no spread render effect. Headless A/B with
|
|
8
|
+
// both arms compiled for real and createNode asserted equal at 36 002: -24.3% min / -30.0% median.
|
|
9
|
+
//
|
|
10
|
+
// The Vue twin is the nodeTransform in adapters/vue/metro-vue-transformer.cjs, and the two share
|
|
11
|
+
// their central rule: lower ONLY a tag this file imported from us, never a bare name match, or an
|
|
12
|
+
// app's own <View> gets rewritten.
|
|
13
|
+
//
|
|
14
|
+
// THE SECOND RULE IS THE ONE THAT KEEPS IT HONEST: lower only when the WHOLE attribute set is
|
|
15
|
+
// visible. The wrapper components do real work besides forwarding — resolveAccessibilityProps
|
|
16
|
+
// folds aria-*/role into the composite accessibilityState/accessibilityValue, and `id` is RN's
|
|
17
|
+
// W3C alias for `nativeID`. Deleting the wrapper without carrying those over breaks accessibility
|
|
18
|
+
// silently. `id` is a pure rename and is done below FROM THE SHARED SPEC; the aria fold is a BAG
|
|
19
|
+
// operation, so an
|
|
20
|
+
// element carrying aria-*/role — or a spread whose keys cannot be read — is left as a component.
|
|
21
|
+
// A folded-at-compile-time aria bag is a possible follow-up (the bag exists statically, so it
|
|
22
|
+
// would cost nothing at runtime); refusing is what makes shipping this safe without it.
|
|
23
|
+
//
|
|
24
|
+
// .cjs because Babel require()s it and this package is "type": "module".
|
|
25
|
+
|
|
26
|
+
// The tag map and the per-tag folds come from the SHARED spec, not from a copy here. Four
|
|
27
|
+
// transforms carried their own before it existed and they had already disagreed on `id` (see that
|
|
28
|
+
// file's `aliases` comment). Data only — every line of AST work below stays this adapter's, and so
|
|
29
|
+
// does the whole refusal mechanism; only the SPECIFICATION is shared.
|
|
30
|
+
const {
|
|
31
|
+
HOST_PRIMITIVES,
|
|
32
|
+
REFUSAL_CATEGORIES,
|
|
33
|
+
} = require('@symbiote-native/components/host-primitives');
|
|
34
|
+
|
|
35
|
+
const SOURCE = '@symbiote-native/solid';
|
|
36
|
+
|
|
37
|
+
// name -> a projection of the shared spec. Built once at plugin load, so the per-element path stays
|
|
38
|
+
// two Map lookups.
|
|
39
|
+
//
|
|
40
|
+
// This is a WHITELIST, and it fails silently in one direction: a field the shared spec grows and
|
|
41
|
+
// this list does not copy simply never reaches the detections, which then behave as if it were
|
|
42
|
+
// absent. Cost measured 2026-08-31 — `intrinsicWhen` was implemented end to end and every
|
|
43
|
+
// multiline case still lowered to the single-line tag, because the field was dropped here. Add a
|
|
44
|
+
// field to the spec, add it here.
|
|
45
|
+
const LOWERABLE = new Map(
|
|
46
|
+
Object.entries(HOST_PRIMITIVES).map(([name, spec]) => [
|
|
47
|
+
name,
|
|
48
|
+
{
|
|
49
|
+
intrinsic: spec.intrinsic,
|
|
50
|
+
aliases: new Map(Object.entries(spec.aliases)),
|
|
51
|
+
// Every primitive refuses on the bag fold and on anything unreadable. A primitive whose
|
|
52
|
+
// STATE the template can observe refuses on more, and only the spec knows which those are —
|
|
53
|
+
// `observesState` is set for a tag the shared spec marks that way, so a transform that has
|
|
54
|
+
// not implemented the extra detections simply never sees such a tag in its map.
|
|
55
|
+
observesState: spec.observesState === true,
|
|
56
|
+
// The two-intrinsic selector (`{ prop, intrinsic }`), absent for a primitive with one tag.
|
|
57
|
+
intrinsicWhen: spec.intrinsicWhen,
|
|
58
|
+
},
|
|
59
|
+
]),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
function attributeName(attribute) {
|
|
63
|
+
const { name } = attribute;
|
|
64
|
+
if (name.type === 'JSXIdentifier') return name.name;
|
|
65
|
+
// JSXNamespacedName — `aria-label` parses as an identifier, but `xml:lang` style names land here.
|
|
66
|
+
return `${name.namespace.name}:${name.name.name}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Every local name bound to a lowerable export of @symbiote-native/solid in THIS file, honouring
|
|
70
|
+
// `import { View as Box }`. A file that imports nothing from us yields an empty map and is skipped
|
|
71
|
+
// whole.
|
|
72
|
+
function lowerableLocalNames(programPath) {
|
|
73
|
+
const names = new Map();
|
|
74
|
+
programPath.node.body.forEach(statement => {
|
|
75
|
+
if (
|
|
76
|
+
statement.type !== 'ImportDeclaration' ||
|
|
77
|
+
statement.source.value !== SOURCE
|
|
78
|
+
)
|
|
79
|
+
return;
|
|
80
|
+
statement.specifiers.forEach(specifier => {
|
|
81
|
+
if (specifier.type !== 'ImportSpecifier') return;
|
|
82
|
+
const imported =
|
|
83
|
+
specifier.imported.type === 'Identifier'
|
|
84
|
+
? specifier.imported.name
|
|
85
|
+
: specifier.imported.value;
|
|
86
|
+
const spec = LOWERABLE.get(imported);
|
|
87
|
+
if (spec !== undefined) names.set(specifier.local.name, spec);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
return names;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// REFUSAL_CATEGORIES.renderPropChild — a child function that TAKES the state.
|
|
94
|
+
//
|
|
95
|
+
// ARITY, not `typeof`, and that distinction is the whole rule. A zero-argument arrow is an ordinary
|
|
96
|
+
// Solid child (`JSX.Element` covers `() => Element`), exactly as `<For>`'s map fn is; only a
|
|
97
|
+
// function that accepts the state argument is a render prop. Measured over `examples/solid`: 12 of
|
|
98
|
+
// the 19 `<Pressable>` sites pass a function child and TEN of those are zero-arity, so refusing on
|
|
99
|
+
// `typeof` alone would throw away ten lowerable sites — including `ActionButton`, whose one
|
|
100
|
+
// definition is instantiated 146 times.
|
|
101
|
+
function isRenderPropChild(child) {
|
|
102
|
+
if (child.type !== 'JSXExpressionContainer') return false;
|
|
103
|
+
const { expression } = child;
|
|
104
|
+
return (
|
|
105
|
+
(expression.type === 'ArrowFunctionExpression' ||
|
|
106
|
+
expression.type === 'FunctionExpression') &&
|
|
107
|
+
expression.params.length >= 1
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// A JSX element is lowerable only if every attribute is a plain, named one we can account for —
|
|
112
|
+
// plus, for a state-observing primitive, only if nothing in it reads that state.
|
|
113
|
+
function canLower(element, spec, types) {
|
|
114
|
+
const { openingElement } = element;
|
|
115
|
+
// Named attributes only — a spread cannot be enumerated
|
|
116
|
+
// (REFUSAL_CATEGORIES.unreadableAttributeSet).
|
|
117
|
+
//
|
|
118
|
+
// The category's STATED reason — that a transform cannot fold `id` -> `nativeID` inside a bag — is
|
|
119
|
+
// DEAD here, measured 2026-08-31: this adapter also folds at runtime (`renderer.ts`,
|
|
120
|
+
// `foldAliasKey`), which sees every key whatever shape it arrived in, and a lowered
|
|
121
|
+
// `<symbiote-view {...bag} />` commits a payload byte-identical to the wrapper's
|
|
122
|
+
// (`src/spread-fold-parity.test.tsx`). The refusal stands on two OTHER grounds it never named,
|
|
123
|
+
// both measured by lifting it and compiling:
|
|
124
|
+
//
|
|
125
|
+
// <TextInput {...bag} /> -> symbiote-text-input, whatever `bag.multiline` holds. The
|
|
126
|
+
// single-line tag is a different NATIVE VIEW and no later prop
|
|
127
|
+
// write repairs it (REFUSAL_CATEGORIES.dynamicIntrinsicChoice).
|
|
128
|
+
// <Pressable {...bag} /> -> no `activeStyle` emitted. The same functional style written
|
|
129
|
+
// directly emits it; inside a bag the expansion never runs, so a
|
|
130
|
+
// pressed style silently stops existing.
|
|
131
|
+
//
|
|
132
|
+
// So: a refuted rationale is not a refuted verdict. Do not lift this because the alias half is
|
|
133
|
+
// covered — check what ELSE reads the attribute list first.
|
|
134
|
+
//
|
|
135
|
+
// `role` / `aria-*` used to refuse here too, on the
|
|
136
|
+
// grounds that their fold needs to see sibling keys and a per-key element path cannot reproduce
|
|
137
|
+
// it. That was true of the ELEMENT path and is no longer true of anything: the fold moved into
|
|
138
|
+
// the engine (`core/engine/src/accessibility-props.ts`, called from `fabricProps` — the one place
|
|
139
|
+
// the whole bag is visible on both commit paths), so a lowered element gets it exactly as a
|
|
140
|
+
// wrapped one does. Proven on the committed payload, both arms, in `src/aria-fold-parity.test.tsx`.
|
|
141
|
+
const attributesAreReadable = openingElement.attributes.every(
|
|
142
|
+
attribute => attribute.type === 'JSXAttribute',
|
|
143
|
+
);
|
|
144
|
+
if (!attributesAreReadable) return false;
|
|
145
|
+
if (!selectsIntrinsicStatically(openingElement, spec)) return false;
|
|
146
|
+
// BEFORE the `observesState` early-return, not after. It rode below it while the only ref-refusing
|
|
147
|
+
// primitive was Pressable, which observes state — a coincidence, and TextInput broke it: it
|
|
148
|
+
// observes nothing, so the early-return fired first and a `<TextInput ref>` lowered anyway. The
|
|
149
|
+
// two questions are independent, so the checks are ordered by what they ask, not by what happened
|
|
150
|
+
// to work.
|
|
151
|
+
if (refWouldChangeSurface(spec) && openingElement.attributes.some(isInstanceBinding))
|
|
152
|
+
return false;
|
|
153
|
+
// Everything below is about a primitive whose STATE the template can observe.
|
|
154
|
+
if (!spec.observesState) return true;
|
|
155
|
+
// A functional `style` NEVER refuses, and since 2026-09-01 it is not rewritten either: the
|
|
156
|
+
// expression rides through unchanged and `routeProp` resolves it at both values of `pressed`
|
|
157
|
+
// (`isStyleCallback`, `core/engine/src/node.ts`). The build-time split that used to live here was
|
|
158
|
+
// removed after measuring it — `src/state-style-cost.bench.test.ts` — because it emitted TWO
|
|
159
|
+
// writes per node where the callback emits one, so it cost run time rather than saving it.
|
|
160
|
+
return !element.children.some(isRenderPropChild);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// REFUSAL_CATEGORIES.dynamicIntrinsicChoice.
|
|
164
|
+
//
|
|
165
|
+
// A primitive whose `intrinsicWhen` is set picks between TWO Fabric views by a prop —
|
|
166
|
+
// `symbiote-text-input` vs `symbiote-text-input-multiline`, which are different native views rather
|
|
167
|
+
// than one view with a flag (`core/components/src/view/render-text-input.ts`). The transform prints
|
|
168
|
+
// a STATIC tag name, so it can only make that choice when the value is a compile-time literal.
|
|
169
|
+
//
|
|
170
|
+
// The refusal is stricter than the neighbouring ones and the difference is worth keeping in view:
|
|
171
|
+
// an unreadable ATTRIBUTE VALUE means a prop arrives wrong, and a later write can still correct it.
|
|
172
|
+
// Here the wrong NATIVE VIEW is committed, and no subsequent prop write repairs that — the node
|
|
173
|
+
// would have to be destroyed and recreated. So a value this cannot read statically keeps the
|
|
174
|
+
// component, which resolves the choice at runtime where the value is known.
|
|
175
|
+
//
|
|
176
|
+
// IDENTITY, not truthiness. The render fn is `view.multiline ? MULTILINE : SINGLE`, so truthiness
|
|
177
|
+
// is what the RUNTIME does, and describing the compile-time check that way makes `multiline={1}`
|
|
178
|
+
// look like something this should resolve. It must not: only three shapes are accepted — a bare
|
|
179
|
+
// attribute is `true`, a boolean LITERAL is itself, an absent prop is `false` — and everything else
|
|
180
|
+
// refuses, a truthy non-boolean literal included. A check justified by truthiness would let `{1}`
|
|
181
|
+
// through at the next edit, which is exactly the silently-wrong native view the refusal exists to
|
|
182
|
+
// prevent. Recorded as identity in the shared spec for the same reason.
|
|
183
|
+
function staticTruthOf(openingElement, propName) {
|
|
184
|
+
const attribute = openingElement.attributes.find(
|
|
185
|
+
candidate =>
|
|
186
|
+
candidate.type === 'JSXAttribute' &&
|
|
187
|
+
attributeName(candidate) === propName,
|
|
188
|
+
);
|
|
189
|
+
if (attribute === undefined) return false;
|
|
190
|
+
if (attribute.value === null) return true;
|
|
191
|
+
if (attribute.value.type !== 'JSXExpressionContainer') return undefined;
|
|
192
|
+
const { expression } = attribute.value;
|
|
193
|
+
return expression.type === 'BooleanLiteral' ? expression.value : undefined;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function selectsIntrinsicStatically(openingElement, spec) {
|
|
197
|
+
return (
|
|
198
|
+
spec.intrinsicWhen === undefined ||
|
|
199
|
+
staticTruthOf(openingElement, spec.intrinsicWhen.prop) !== undefined
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function intrinsicFor(openingElement, spec) {
|
|
204
|
+
if (spec.intrinsicWhen === undefined) return spec.intrinsic;
|
|
205
|
+
return staticTruthOf(openingElement, spec.intrinsicWhen.prop) === true
|
|
206
|
+
? spec.intrinsicWhen.intrinsic
|
|
207
|
+
: spec.intrinsic;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// REFUSAL_CATEGORIES.instanceBoundDirective. The criterion is NOT "does the component expose a
|
|
211
|
+
// ref" — that was a proxy, and it held only while two primitives existed. The rule is: would
|
|
212
|
+
// lowering hand the app something DIFFERENT from what the component hands it? A lowering transform
|
|
213
|
+
// is an optimisation, and an optimisation that moves the observable surface in EITHER direction is
|
|
214
|
+
// a bug. Three shapes, one rule, all readable from the components' own props types:
|
|
215
|
+
//
|
|
216
|
+
// View, Text ref?: Ref<IHostInstance> lowered yields the same node -> lower
|
|
217
|
+
// Pressable no ref declared lowering would ADD a handle -> refuse
|
|
218
|
+
// TextInput ref?: Ref<ITextInputHandle> lowering would SWAP the handle -> refuse
|
|
219
|
+
//
|
|
220
|
+
// TextInput is the case that exposed the proxy: it declares a ref, so the old criterion said
|
|
221
|
+
// "lower", and a lowered `<TextInput ref>` silently dropped `clear`, `isFocused` and
|
|
222
|
+
// `setSelection` — the component's handle replaced by a bare public instance. Costing an app three
|
|
223
|
+
// methods is exactly as wrong as granting it one it never had.
|
|
224
|
+
//
|
|
225
|
+
// PER-ADAPTER by construction, which is why this is a list here and not a field on the shared
|
|
226
|
+
// spec: what a ref yields is a fact about THIS adapter's props types, and a correct adapter can
|
|
227
|
+
// answer differently — Vue refuses on every primitive, because a Vue template ref yields the
|
|
228
|
+
// component instance rather than the host node whatever the component declares.
|
|
229
|
+
//
|
|
230
|
+
// Kept honest by `ref-refusal-matches-components.test.ts`, which re-derives the answer from each
|
|
231
|
+
// component source: a hand-written list of adapter members is what went stale three times this
|
|
232
|
+
// month.
|
|
233
|
+
// INVERTED 2026-09-01, and the inversion is the point rather than the contents. This was a
|
|
234
|
+
// denylist of intrinsics that refuse a `ref`, so every primitive added to the spec defaulted to
|
|
235
|
+
// LOWERING one — and the newest member falls out of a hand-written list every time, which is the
|
|
236
|
+
// failure mode this repo has now recorded four times. As a denylist that default is a correctness
|
|
237
|
+
// bug (a lowered element hands back an object the component never did); as an allowlist the same
|
|
238
|
+
// omission costs only coverage, which is the direction a default should fail in.
|
|
239
|
+
//
|
|
240
|
+
// A name earns its place here by the criterion in `ref-refusal-matches-components.test.ts`: the
|
|
241
|
+
// component hands a `ref` the ENGINE NODE, either by declaring `ref?: Ref<IHostInstance>` or by
|
|
242
|
+
// rendering through `descriptorToSolid`, whose `spread` calls a bag ref with the node it built. Any
|
|
243
|
+
// other answer — a different handle, or no ref at all — means lowering would move the surface.
|
|
244
|
+
const INTRINSICS_YIELDING_HOST_REF = new Set([
|
|
245
|
+
'symbiote-view',
|
|
246
|
+
'symbiote-text',
|
|
247
|
+
'symbiote-image',
|
|
248
|
+
]);
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
function refWouldChangeSurface(spec) {
|
|
252
|
+
return !INTRINSICS_YIELDING_HOST_REF.has(spec.intrinsic);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function isInstanceBinding(attribute) {
|
|
256
|
+
return (
|
|
257
|
+
attribute.type === 'JSXAttribute' && attributeName(attribute) === 'ref'
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Per TAG, not global: the spec gives each primitive its own alias map, and reading it per tag is
|
|
262
|
+
// what keeps this honest if they ever stop agreeing.
|
|
263
|
+
function renameAliasedAttributes(openingElement, aliases, types) {
|
|
264
|
+
openingElement.attributes.forEach(attribute => {
|
|
265
|
+
// A spread has no name, and the author's own spread refuses in `canLower`, so nothing this
|
|
266
|
+
// loop sees is one. It used to be reachable: the state-style pass emitted a spread for an
|
|
267
|
+
// opaque style, and that pass is gone.
|
|
268
|
+
if (attribute.type !== 'JSXAttribute') return;
|
|
269
|
+
const renamed = aliases.get(attributeName(attribute));
|
|
270
|
+
if (renamed !== undefined) attribute.name = types.jsxIdentifier(renamed);
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
module.exports = function lowerHostPrimitives({ types }) {
|
|
275
|
+
return {
|
|
276
|
+
name: 'symbiote-solid-lower-host-primitives',
|
|
277
|
+
visitor: {
|
|
278
|
+
Program(programPath) {
|
|
279
|
+
const names = lowerableLocalNames(programPath);
|
|
280
|
+
if (names.size === 0) return;
|
|
281
|
+
programPath.traverse({
|
|
282
|
+
JSXElement(elementPath) {
|
|
283
|
+
const { openingElement, closingElement } = elementPath.node;
|
|
284
|
+
const tag = openingElement.name;
|
|
285
|
+
if (tag.type !== 'JSXIdentifier') return;
|
|
286
|
+
const spec = names.get(tag.name);
|
|
287
|
+
if (spec === undefined) return;
|
|
288
|
+
// A shadowing local binding (a parameter or a `const View = …` in scope) is NOT our
|
|
289
|
+
// import, even though the name matches. Only rewrite when the binding at this use site
|
|
290
|
+
// is still the module-level import.
|
|
291
|
+
const binding = elementPath.scope.getBinding(tag.name);
|
|
292
|
+
if (binding === undefined || binding.kind !== 'module') return;
|
|
293
|
+
if (!canLower(elementPath.node, spec, types)) return;
|
|
294
|
+
// Read BEFORE the passes below rewrite attributes — `renameAliasedAttributes` could
|
|
295
|
+
// in principle rename the selector out from under the choice.
|
|
296
|
+
const intrinsic = intrinsicFor(openingElement, spec);
|
|
297
|
+
renameAliasedAttributes(openingElement, spec.aliases, types);
|
|
298
|
+
openingElement.name = types.jsxIdentifier(intrinsic);
|
|
299
|
+
if (closingElement)
|
|
300
|
+
closingElement.name = types.jsxIdentifier(intrinsic);
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
};
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
// Attached AFTER the plugin assignment on purpose: `module.exports = fn` replaces the object, so a
|
|
309
|
+
// named export written above it is silently discarded. Exists only so the test below can compare
|
|
310
|
+
// this list against the component sources.
|
|
311
|
+
module.exports.INTRINSICS_YIELDING_HOST_REF = INTRINSICS_YIELDING_HOST_REF;
|
|
312
|
+
|
|
313
|
+
// The spec fields this transform reads, named so `spec-projection-covers-fields.test.ts` can check
|
|
314
|
+
// the whitelist above against what `HOST_PRIMITIVES` actually carries. `defaults` is deliberately
|
|
315
|
+
// absent: prop defaults are seeded at RUNTIME by the renderer (`seedTextDefaults`), where a patch
|
|
316
|
+
// that clears a prop can restore them — a compile-time copy would cover only the initial render.
|
|
317
|
+
module.exports.SPEC_FIELDS_READ = [
|
|
318
|
+
'intrinsic',
|
|
319
|
+
'aliases',
|
|
320
|
+
'observesState',
|
|
321
|
+
'intrinsicWhen',
|
|
322
|
+
];
|
|
323
|
+
module.exports.SPEC_FIELDS_IGNORED = ['defaults'];
|
package/babel-preset.cjs
CHANGED
|
@@ -24,6 +24,11 @@
|
|
|
24
24
|
// React-JSX transform gets a chance to claim the same JSX nodes.
|
|
25
25
|
module.exports = function symbioteSolidBabelPreset(_api, options = {}) {
|
|
26
26
|
return {
|
|
27
|
+
// Babel runs PLUGINS before PRESETS, which is exactly the order this needs: the lowering
|
|
28
|
+
// rewrites <View>/<Text> to their intrinsic tags while they are still JSX, so babel-preset-solid
|
|
29
|
+
// then compiles them as elements (createElement + setProp) instead of as components
|
|
30
|
+
// (createComponent + a props Proxy). Reversed, there would be nothing left to rewrite.
|
|
31
|
+
plugins: [require('./babel-lower-host-primitives.cjs')],
|
|
27
32
|
presets: [
|
|
28
33
|
[
|
|
29
34
|
require('babel-preset-solid'),
|
|
@@ -28,3 +28,4 @@ export interface IPressableProps extends IAccessibilityProps, IAriaProps {
|
|
|
28
28
|
children?: IPressableChildren;
|
|
29
29
|
}
|
|
30
30
|
export declare function Pressable(props: IPressableProps): JSX.Element;
|
|
31
|
+
export declare function TouchablePressable(props: IPressableProps): JSX.Element;
|
|
@@ -22,9 +22,10 @@
|
|
|
22
22
|
// NOTHING here destructures `props`. Solid props are getters and a component body runs ONCE, so a
|
|
23
23
|
// destructure would freeze the Pressable at its mount-time config; every read below sits inside an
|
|
24
24
|
// accessor, a memo or an event handler.
|
|
25
|
-
import { createEffect, createMemo, createSignal, splitProps, untrack, } from 'solid-js';
|
|
26
|
-
import { buildPressableListeners, createPressHandlers, createPressRuntime, noteHoverNoop, resolveDisabledAccessibilityState, rippleProps, DEFAULT_DELAY_LONG_PRESS_MS, } from '@symbiote-native/components';
|
|
25
|
+
import { createEffect, createMemo, onCleanup, createSignal, splitProps, untrack, } from 'solid-js';
|
|
26
|
+
import { buildPressableListeners, createPressHandlers, createPressRuntime, disposePressRuntime, noteHoverNoop, resolveDisabledAccessibilityState, rippleProps, DEFAULT_DELAY_LONG_PRESS_MS, DEFAULT_MIN_PRESS_DURATION_MS, } from '@symbiote-native/components';
|
|
27
27
|
import { dlog, } from '@symbiote-native/engine';
|
|
28
|
+
import { setHostPressed } from '../renderer.js';
|
|
28
29
|
import { View } from './view.jsx';
|
|
29
30
|
// Read by Pressable itself; everything else forwards onto View. The user's press callbacks MUST be
|
|
30
31
|
// split off — they are pure JS, and it is the machine's SYNTHESIZED handlers that go on the host.
|
|
@@ -51,7 +52,7 @@ const HANDLED_PROPS = [
|
|
|
51
52
|
];
|
|
52
53
|
// `hitSlop` is deliberately NOT in that list: the machine reads it for the retention test AND the
|
|
53
54
|
// host needs it to enlarge the touch target, so it forwards through `rest` untouched.
|
|
54
|
-
|
|
55
|
+
function PressableImpl(props, minPressDuration) {
|
|
55
56
|
const [local, rest] = splitProps(props, HANDLED_PROPS);
|
|
56
57
|
const [pressed, setPressed] = createSignal(false);
|
|
57
58
|
// The mutable press runtime (in-flight timers, suppression flags, measured region). A plain
|
|
@@ -71,7 +72,20 @@ export function Pressable(props) {
|
|
|
71
72
|
// and not a whenCommitted case: the measure is triggered by a touch, which cannot arrive before
|
|
72
73
|
// the view exists.
|
|
73
74
|
const host = {
|
|
74
|
-
|
|
75
|
+
// Two sinks for one fact, and both are needed. The signal drives the FRAMEWORK-visible half —
|
|
76
|
+
// a functional `style`, a render-prop child — which is what forces this element to stay a
|
|
77
|
+
// component at all. `setHostPressed` drives the ENGINE-visible half, so `.btn:active` applies
|
|
78
|
+
// to a refused Pressable exactly as it would to a lowered `symbiote-pressable`. That is what
|
|
79
|
+
// makes a refusal cost the component instance and NOT the pressed styling
|
|
80
|
+
// (`.claude/rules/host-primitive-tier.md`).
|
|
81
|
+
//
|
|
82
|
+
// Before the first commit `responder` is null and the engine call is skipped: nothing is
|
|
83
|
+
// painted yet, and a press cannot arrive before the view exists.
|
|
84
|
+
setPressed: next => {
|
|
85
|
+
setPressed(next);
|
|
86
|
+
if (responder !== null)
|
|
87
|
+
setHostPressed(responder, next);
|
|
88
|
+
},
|
|
75
89
|
getMeasureFn: () => {
|
|
76
90
|
const node = responder;
|
|
77
91
|
if (node === null)
|
|
@@ -86,7 +100,11 @@ export function Pressable(props) {
|
|
|
86
100
|
clearTimeout(id);
|
|
87
101
|
};
|
|
88
102
|
},
|
|
103
|
+
now: Date.now,
|
|
89
104
|
};
|
|
105
|
+
onCleanup(() => {
|
|
106
|
+
disposePressRuntime(runtime);
|
|
107
|
+
});
|
|
90
108
|
// Rebuilt when the caller's config changes (the closures capture live values); the runtime
|
|
91
109
|
// persists across rebuilds, so an in-flight timer or drift flag survives one. Deliberately does
|
|
92
110
|
// NOT read `pressed` — a press must not churn the handler identities the host node holds.
|
|
@@ -98,6 +116,7 @@ export function Pressable(props) {
|
|
|
98
116
|
onLongPress: local.onLongPress,
|
|
99
117
|
delayLongPress: local.delayLongPress ?? DEFAULT_DELAY_LONG_PRESS_MS,
|
|
100
118
|
unstable_pressDelay: local.unstable_pressDelay ?? 0,
|
|
119
|
+
minPressDuration,
|
|
101
120
|
hitSlop: rest.hitSlop,
|
|
102
121
|
pressRetentionOffset: local.pressRetentionOffset,
|
|
103
122
|
}, runtime, host));
|
|
@@ -183,3 +202,10 @@ export function Pressable(props) {
|
|
|
183
202
|
{renderContent()}
|
|
184
203
|
</View>);
|
|
185
204
|
}
|
|
205
|
+
export function Pressable(props) {
|
|
206
|
+
return PressableImpl(props, DEFAULT_MIN_PRESS_DURATION_MS);
|
|
207
|
+
}
|
|
208
|
+
// Relative-only composition seam; the public component barrel exports Pressable, never this.
|
|
209
|
+
export function TouchablePressable(props) {
|
|
210
|
+
return PressableImpl(props, 0);
|
|
211
|
+
}
|
|
@@ -3,6 +3,7 @@ import { type IAccessibilityProps, type IAriaProps } from '@symbiote-native/comp
|
|
|
3
3
|
import { type IClassNameValue, type IStyleProp, type ISymbioteEvent, type IViewStyle } from '@symbiote-native/engine';
|
|
4
4
|
export interface ISafeAreaViewProps extends IAccessibilityProps, IAriaProps {
|
|
5
5
|
style?: IStyleProp<IViewStyle>;
|
|
6
|
+
id?: string;
|
|
6
7
|
class?: IClassNameValue;
|
|
7
8
|
onLayout?: (event: ISymbioteEvent) => void;
|
|
8
9
|
children?: JSX.Element;
|
|
@@ -206,6 +206,28 @@ export function TextInput(props) {
|
|
|
206
206
|
// echoing the acknowledged count so native applies them. Every method reads `host` LIVE, so a
|
|
207
207
|
// multiline swap cannot leave the handle pointing at a dead node.
|
|
208
208
|
const handle = {
|
|
209
|
+
// Forwarded to the node so a TextInput ref is not poorer than any other host ref. Measured
|
|
210
|
+
// 2026-08-31: the wrapper's handle used to be five methods and thereby CLOSED OVER the node,
|
|
211
|
+
// costing the component path `measure`/`measureInWindow`/`measureLayout`/`setNativeProps`,
|
|
212
|
+
// while a lowered element handed back the bare node and lost `clear`/`isFocused`/
|
|
213
|
+
// `setSelection`. Two different surfaces, crossed by writing `multiline={isLong}` instead of
|
|
214
|
+
// `multiline`. The union is the fix; refusing to lower would only swap which four go missing.
|
|
215
|
+
measure: (callback) => {
|
|
216
|
+
if (host !== null)
|
|
217
|
+
host.measure(callback);
|
|
218
|
+
},
|
|
219
|
+
measureInWindow: (callback) => {
|
|
220
|
+
if (host !== null)
|
|
221
|
+
host.measureInWindow(callback);
|
|
222
|
+
},
|
|
223
|
+
measureLayout: (relativeToNativeNode, onSuccess, onFail) => {
|
|
224
|
+
if (host !== null)
|
|
225
|
+
host.measureLayout(relativeToNativeNode, onSuccess, onFail);
|
|
226
|
+
},
|
|
227
|
+
setNativeProps: (nativeProps) => {
|
|
228
|
+
if (host !== null)
|
|
229
|
+
host.setNativeProps(nativeProps);
|
|
230
|
+
},
|
|
209
231
|
focus: () => {
|
|
210
232
|
if (host !== null)
|
|
211
233
|
dispatchViewCommand(host, 'focus', []);
|
|
@@ -20,7 +20,7 @@ import { createEffect, createMemo, createSignal, onCleanup, splitProps, untrack,
|
|
|
20
20
|
import { createHighlightUnderlayHandlers, createHighlightUnderlayRuntime, createTouchableFeedbackHandlers, createTouchableFeedbackRuntime, hasTouchablePressHandler, resolveHighlightExtraStyles, restingOpacityFromStyle, DEFAULT_ACTIVE_OPACITY, OPACITY_ACTIVE_GRANT_DURATION_MS, OPACITY_INACTIVE_DURATION_MS, TOUCHABLE_MIN_PRESS_DURATION_MS, } from '@symbiote-native/components';
|
|
21
21
|
import { dlog, } from '@symbiote-native/engine';
|
|
22
22
|
import { Animated } from '../../modules/animated/index.js';
|
|
23
|
-
import { Pressable } from '../pressable.jsx';
|
|
23
|
+
import { TouchablePressable as Pressable, } from '../pressable.jsx';
|
|
24
24
|
// The real timers both shared machines schedule on — core/components carries no timer globals, so
|
|
25
25
|
// scheduling is the adapter's half. Every canceller is retained so unmount cancels what is still in
|
|
26
26
|
// flight: a Solid body runs once, so there is no re-render that would otherwise drop a stale timer,
|
|
@@ -22,7 +22,7 @@ import { splitProps } from 'solid-js';
|
|
|
22
22
|
import { backgroundProps, canUseNativeForeground, rippleBackground, selectableBackground, selectableBackgroundBorderless, } from '@symbiote-native/components';
|
|
23
23
|
import { dlog } from '@symbiote-native/engine';
|
|
24
24
|
import { withStableKeys } from '../../utils/stable-keys.js';
|
|
25
|
-
import { Pressable } from '../pressable.jsx';
|
|
25
|
+
import { TouchablePressable as Pressable, } from '../pressable.jsx';
|
|
26
26
|
function TouchableNativeFeedbackImpl(props) {
|
|
27
27
|
const [local, rest] = splitProps(props, [
|
|
28
28
|
'background',
|
package/build/host-instance.js
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
// adapters/vue/src/host-instance. RN's "ref/instance -> native reactTag" lookup, the seam
|
|
3
3
|
// imperative-interop libraries (reanimated, gesture-handler, react-navigation) reach through.
|
|
4
4
|
//
|
|
5
|
-
// A Solid `ref={el}` on a symbiote host tag assigns the raw engine SymbioteNode,
|
|
6
|
-
//
|
|
7
|
-
//
|
|
5
|
+
// A Solid `ref={el}` on a symbiote host tag assigns the raw engine SymbioteNode, which already
|
|
6
|
+
// carries measure / setNativeProps / focus on its prototype, exactly like React's
|
|
7
|
+
// getPublicInstance.
|
|
8
8
|
//
|
|
9
9
|
// The node -> tag resolution itself belongs to the engine (getNativeTag, keyed on the raw node in the
|
|
10
10
|
// commit mirror); this only unwraps the Solid-shaped inputs onto it. An uncommitted or unknown input
|
package/build/index.d.ts
CHANGED
package/build/index.js
CHANGED
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
// ./jsx-runtime, which an app reaches by config rather than by import:
|
|
22
22
|
// `"jsxImportSource": "@symbiote-native/solid"` (see that file). The earlier
|
|
23
23
|
// `declare module 'solid-js'` shim is gone with it — one mechanism, not two.
|
|
24
|
+
// Bare, side-effect only, and deliberately NOT `export * from './register'` — see that file.
|
|
25
|
+
import './register.js';
|
|
24
26
|
export { mount, unmount } from './render.js';
|
|
25
27
|
export { findNodeHandle } from './host-instance.js';
|
|
26
28
|
export * from './components.js';
|
|
@@ -7,4 +7,4 @@ export interface IAnimatedComponentProps {
|
|
|
7
7
|
children?: JSX.Element;
|
|
8
8
|
[key: string]: unknown;
|
|
9
9
|
}
|
|
10
|
-
export declare function createAnimatedComponent(Base: Component<any>): (props: IAnimatedComponentProps) => JSX.Element;
|
|
10
|
+
export declare function createAnimatedComponent(Base: Component<any> | string): (props: IAnimatedComponentProps) => JSX.Element;
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
// next reconcile drops.
|
|
24
24
|
import { createEffect, mergeProps, onCleanup, splitProps, } from 'solid-js';
|
|
25
25
|
import { createAnimatedLeafLifecycle, isNativeAnimatedAvailable, isSymbioteNode, readPassthroughStyle, reduceProps, resolveHostNode, whenCommitted, } from '@symbiote-native/engine';
|
|
26
|
+
import { createElement, spread } from '../../renderer.js';
|
|
26
27
|
import { withStableKeys } from '../../utils/stable-keys.js';
|
|
27
28
|
// RN's prop carrying already-rasterized values that OVERRIDE the animated prop in the committed
|
|
28
29
|
// props, so the ShadowTree (hit-testing) stays current while the native driver animates.
|
|
@@ -31,6 +32,15 @@ const PASSTHROUGH_PROP = 'passthroughAnimatedPropExplicitValues';
|
|
|
31
32
|
// from). A precise generic cannot work: the bag handed to the base is assembled at RUNTIME from a
|
|
32
33
|
// key set that grows, so no static prop type describes it. The RETURN type stays precise, so a
|
|
33
34
|
// caller of Animated.View still gets a real prop type.
|
|
35
|
+
// `Base` accepts a TAG as well as a component, and the string branch is not symmetry — it is the
|
|
36
|
+
// only shape that works once a primitive becomes a public intrinsic. `<Base {...props} />` on a
|
|
37
|
+
// capitalized identifier compiles to `createComponent(Base, …)`, which is `untrack(() => Comp(props))`
|
|
38
|
+
// in solid-js, so a string base is a `TypeError: Comp is not a function` at first paint. There is
|
|
39
|
+
// nothing to widen INTO either: `createRenderer()` from solid-js/universal returns twelve names and
|
|
40
|
+
// `Dynamic` is not among them (solid-js/web's Dynamic is DOM-only), so the element has to be built
|
|
41
|
+
// through this renderer's own createElement + spread — the same two calls solid's own Dynamic makes
|
|
42
|
+
// on its string branch. `spread` handles `ref` and `children` itself (universal's spreadExpression
|
|
43
|
+
// skips both in its prop loop and drives them separately), which is why nothing is threaded by hand.
|
|
34
44
|
export function createAnimatedComponent(Base) {
|
|
35
45
|
return function AnimatedComponent(props) {
|
|
36
46
|
// The passthrough is CONSUMED (it only overrides the committed style below); `children` and
|
|
@@ -100,6 +110,18 @@ export function createAnimatedComponent(Base) {
|
|
|
100
110
|
return local.children;
|
|
101
111
|
},
|
|
102
112
|
});
|
|
113
|
+
// Narrowed HERE and not around the whole factory: `Base` is a parameter captured by this inner
|
|
114
|
+
// function, so a check outside it does not reach the JSX below.
|
|
115
|
+
if (typeof Base === 'string') {
|
|
116
|
+
const element = createElement(Base);
|
|
117
|
+
spread(element, childProps, false);
|
|
118
|
+
// createElement's return type is the renderer's node union, which includes the SURFACE — a
|
|
119
|
+
// root the renderer is handed, never something a tag produces. Narrowed rather than cast, and
|
|
120
|
+
// thrown rather than defaulted: returning null here would paint nothing and stay green.
|
|
121
|
+
if (!isSymbioteNode(element))
|
|
122
|
+
throw new Error(`createAnimatedComponent: <${Base}> did not build a host node`);
|
|
123
|
+
return element;
|
|
124
|
+
}
|
|
103
125
|
return <Base {...childProps}/>;
|
|
104
126
|
};
|
|
105
127
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Side-effect ONLY. Exports nothing, and must never be re-exported from the barrel.
|
|
2
|
+
//
|
|
3
|
+
// The engine's host-behavior registry is reached through a `register*` call, and CLAUDE.md records
|
|
4
|
+
// what happens to one of those behind a barrel: Metro turns on `inlineRequires` for PRODUCTION
|
|
5
|
+
// only, moving a `require` down to the first place its binding is used as a VALUE, and
|
|
6
|
+
// `export { X } from './x'` compiles to a lazy getter. If nothing ever names the module as a value,
|
|
7
|
+
// it never evaluates and the registration silently never happens — in Release builds alone,
|
|
8
|
+
// invisible to tsc, to vitest, and to grepping the bundle.
|
|
9
|
+
//
|
|
10
|
+
// A bare `import './register';` next to a re-export of the same specifier does NOT help: Babel
|
|
11
|
+
// merges the two into one dependency and the merged dependency stays lazy. The shape that works is
|
|
12
|
+
// this one — a module imported ONLY for its side effect and never re-exported — the same shape
|
|
13
|
+
// `packages/slider/src/{react,vue,svelte,angular}/index.ts` uses. `barrel-side-effect.test.ts` is
|
|
14
|
+
// what stops a later tidy-up from "fixing" the bare import into a re-export.
|
|
15
|
+
import { registerImageBehavior, registerInputAccessoryViewBehavior, registerPressableBehavior, registerSwitchBehavior, registerTextInputBehavior, } from '@symbiote-native/components';
|
|
16
|
+
registerPressableBehavior();
|
|
17
|
+
// Only the LOWERED tags carry this — the wrapper renders `symbiote-text-input-managed` and
|
|
18
|
+
// keeps running its own lifecycle. One owner per node; see `component-names/shared.ts`.
|
|
19
|
+
registerTextInputBehavior();
|
|
20
|
+
// Same reason as TextInput: the wrapper renders `symbiote-switch-managed` and runs its own
|
|
21
|
+
// lastNativeReport/snap-back lifecycle, so the engine's copy attaches only to the bare tag.
|
|
22
|
+
registerSwitchBehavior();
|
|
23
|
+
// Image owns no runtime — its behavior is a prop FOLD and nothing else, and it is registered on the
|
|
24
|
+
// same `symbiote-image` the wrapper already emits rather than on a `-managed` twin. That is safe
|
|
25
|
+
// only because the mapping is idempotent, which `core/components/src/behaviors/image.test.ts`
|
|
26
|
+
// asserts rather than assumes; a wrapper-built node simply folds a second time and nothing moves.
|
|
27
|
+
registerImageBehavior();
|
|
28
|
+
// Fold-only, and it shares the wrapper's tag for the same reason Image does: the mapping has no
|
|
29
|
+
// aliasing at all, so a wrapper-built node folding a second time moves nothing.
|
|
30
|
+
// `core/components/src/behaviors/input-accessory-view.test.ts` asserts that rather than assuming it.
|
|
31
|
+
registerInputAccessoryViewBehavior();
|
package/build/renderer.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { SymbioteSurface, type ISymbioteNode } from '@symbiote-native/engine';
|
|
|
2
2
|
type IHostNode = ISymbioteNode | SymbioteSurface;
|
|
3
3
|
export declare function setActiveSurface(surface: SymbioteSurface | undefined): void;
|
|
4
4
|
export declare function replaceText(textNode: IHostNode, value: string): void;
|
|
5
|
+
export declare function setHostPressed(node: IHostNode, pressed: boolean): void;
|
|
5
6
|
export declare function removeNode(parent: IHostNode, node: IHostNode): void;
|
|
6
7
|
export declare const render: (code: () => IHostNode, node: IHostNode) => () => void, effect: <T>(fn: (prev?: T) => T, init?: T) => void, memo: <T>(fn: () => T, equal: boolean) => () => T, createComponent: <T>(Comp: (props: T) => IHostNode, props: T) => IHostNode, createElement: (tag: string) => IHostNode, createTextNode: (value: string) => IHostNode, insertNode: (parent: IHostNode, node: IHostNode, anchor?: IHostNode | undefined) => void, insert: <T>(parent: any, accessor: T | (() => T), marker?: any | null, initial?: any) => IHostNode, spread: <T>(node: any, accessor: (() => T) | T, skipChildren?: boolean) => void, setProp: <T>(node: IHostNode, name: string, value: T, prev?: T | undefined) => T, mergeProps: (...sources: unknown[]) => unknown, use: <A, T>(fn: (element: IHostNode, arg: A) => T, element: IHostNode, arg: A) => T;
|
|
7
8
|
export {};
|
package/build/renderer.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
// babel.transformSync over representative JSX (elements, dynamic text, <Show>, <For>, spread,
|
|
18
18
|
// ref) rather than read off the docs.
|
|
19
19
|
import { createRenderer } from 'solid-js/universal';
|
|
20
|
-
import { appendChild, createAnchor, createElement as createEngineElement, createRawText, dlog, insertBefore, removeChild as removeEngineChild, routeProp, setText as setEngineText, toPublicInstance, RAW_TEXT_COMPONENT, SymbioteSurface, } from '@symbiote-native/engine';
|
|
20
|
+
import { appendChild, createAnchor, createElement as createEngineElement, createRawText, dlog, insertBefore, removeChild as removeEngineChild, routeProp, setNodePressed, setProp as setEngineProp, setText as setEngineText, toPublicInstance, RAW_TEXT_COMPONENT, SymbioteSurface, } from '@symbiote-native/engine';
|
|
21
21
|
import { descriptorFor } from '@symbiote-native/components';
|
|
22
22
|
function isSurface(node) {
|
|
23
23
|
return node instanceof SymbioteSurface;
|
|
@@ -84,6 +84,23 @@ function asText(value) {
|
|
|
84
84
|
// has to DETACH its anchor host from the portal target on cleanup. Going through here rather than
|
|
85
85
|
// calling the engine's removeChild directly is what keeps the mutation paired with requestCommit(),
|
|
86
86
|
// and keeps the surface-vs-node branch in one place.
|
|
87
|
+
// The REFUSED path's half of `:active`, and the reason a refusal costs the component instance
|
|
88
|
+
// rather than the pressed styling. A `<Pressable>` the lowering left as a component still owns its
|
|
89
|
+
// own press machine, and one call here puts the node into its pressed state so `.btn:active`
|
|
90
|
+
// applies exactly as it would on a lowered tag.
|
|
91
|
+
//
|
|
92
|
+
// Exported paired with requestCommit() for the same reason removeNode below is: the press arrives
|
|
93
|
+
// from a NATIVE EVENT, outside any renderer mutation, so nothing else schedules a commit. React's
|
|
94
|
+
// twin (`setNodeHidden` from hideInstance) needs none because its reconciler is already mid-commit
|
|
95
|
+
// when it calls; Solid's is not. Costs nothing when no `:active` rule is registered — the engine
|
|
96
|
+
// hands back the same style object and `isAlreadyPublished` turns the re-push away without dirtying
|
|
97
|
+
// the node.
|
|
98
|
+
export function setHostPressed(node, pressed) {
|
|
99
|
+
if (isSurface(node))
|
|
100
|
+
return;
|
|
101
|
+
setNodePressed(node, pressed);
|
|
102
|
+
requestCommit();
|
|
103
|
+
}
|
|
87
104
|
export function removeNode(parent, node) {
|
|
88
105
|
if (isSurface(node))
|
|
89
106
|
return;
|
|
@@ -93,10 +110,57 @@ export function removeNode(parent, node) {
|
|
|
93
110
|
removeEngineChild(parent, node);
|
|
94
111
|
requestCommit();
|
|
95
112
|
}
|
|
113
|
+
const TEXT_FOLDS = new Map([
|
|
114
|
+
['ellipsizeMode', value => value ?? 'tail'],
|
|
115
|
+
['allowFontScaling', value => value !== false],
|
|
116
|
+
]);
|
|
117
|
+
function seedTextDefaults(node) {
|
|
118
|
+
for (const [key, fold] of TEXT_FOLDS)
|
|
119
|
+
setEngineProp(node, key, fold(undefined));
|
|
120
|
+
}
|
|
121
|
+
// Seeding at CREATE is not enough, and the gap is device-only. A framework that clears a prop it
|
|
122
|
+
// set earlier hands us an explicit `undefined` at PATCH time, and the default has to come BACK
|
|
123
|
+
// rather than stay cleared — RN treats a missing prop and an explicit undefined alike, and only a
|
|
124
|
+
// literal `false` opts out of allowFontScaling. Two Map lookups on text nodes only, and none at
|
|
125
|
+
// all on a View, so it stays off the hot path.
|
|
126
|
+
function foldTextValue(node, key, value) {
|
|
127
|
+
if (!node.isText)
|
|
128
|
+
return value;
|
|
129
|
+
const fold = TEXT_FOLDS.get(key);
|
|
130
|
+
return fold === undefined ? value : fold(value);
|
|
131
|
+
}
|
|
132
|
+
// The alias fold, at the RENDERER and not only in the transform — the defect class Angular paid for
|
|
133
|
+
// twice on 2026-08-31. A lowered element inherits nothing the component wrapper did, and the
|
|
134
|
+
// compile-time rename in `babel-lower-host-primitives.cjs` covers exactly the call sites the
|
|
135
|
+
// transform REWROTE: `<View id={x} />` is fine (the attribute name is renamed before the preset
|
|
136
|
+
// compiles it, dynamic value included), but a hand-written `<symbiote-view id="x">` is not, and it
|
|
137
|
+
// committed `id` — a key Fabric does not know — while the component committed `nativeID`. Measured
|
|
138
|
+
// by mounting both forms and diffing committed key NAMES; totals were identical and said nothing.
|
|
139
|
+
//
|
|
140
|
+
// The transform's rename STAYS. It is not redundant: it means the markup path arrives here already
|
|
141
|
+
// spelled `nativeID`, so the common case never takes the branch below with a key to rewrite.
|
|
142
|
+
//
|
|
143
|
+
// One string comparison rather than a Map lookup, because this sits on the per-prop write path —
|
|
144
|
+
// 32 001 prop writes on a benchmark create, where a Map.get is the kind of cost the engine spent
|
|
145
|
+
// this month removing. That is only safe while every primitive shares ONE alias pair, which is a
|
|
146
|
+
// property of the shared spec and not of this file, so `renderer-alias-fold.test.ts` re-derives
|
|
147
|
+
// both constants from `HOST_PRIMITIVES` and fails the moment a second pair appears.
|
|
148
|
+
const ALIAS_FROM = 'id';
|
|
149
|
+
const ALIAS_TO = 'nativeID';
|
|
150
|
+
function foldAliasKey(name) {
|
|
151
|
+
return name === ALIAS_FROM ? ALIAS_TO : name;
|
|
152
|
+
}
|
|
96
153
|
const nodeOps = {
|
|
97
154
|
createElement(tag) {
|
|
98
155
|
const descriptor = descriptorFor(tag);
|
|
99
|
-
|
|
156
|
+
// The TAG goes over as well, not just the resolved Fabric name. The host-behavior registry is
|
|
157
|
+
// keyed by intrinsic tag while a node only ever carries the resolved view name — `symbiote-
|
|
158
|
+
// pressable` resolves to `RCTView` — so without this the lookup asks for `RCTView` and finds
|
|
159
|
+
// nothing, and the press machine silently never attaches. Registering under the Fabric name
|
|
160
|
+
// instead would be worse: every plain `View` would get a press machine.
|
|
161
|
+
const node = createEngineElement(descriptor.component, descriptor.isText, tag);
|
|
162
|
+
if (descriptor.isText)
|
|
163
|
+
seedTextDefaults(node);
|
|
100
164
|
// Graft the imperative public-instance API (measure / setNativeProps / focus / …) onto the raw
|
|
101
165
|
// node so a `ref` to a host element exposes it exactly like React's getPublicInstance.
|
|
102
166
|
// toPublicInstance mutates in place and returns the SAME node identity, so the engine's commit
|
|
@@ -136,7 +200,7 @@ const nodeOps = {
|
|
|
136
200
|
// becomes a listener; onTintColor on a Switch stays a prop), and centralizes the class+style
|
|
137
201
|
// merge. Shared with React and Vue — never re-implement an `onX` check here
|
|
138
202
|
// (symbiote-engine-core §2).
|
|
139
|
-
routeProp(node, name, value);
|
|
203
|
+
routeProp(node, foldAliasKey(name), foldTextValue(node, name, value));
|
|
140
204
|
requestCommit();
|
|
141
205
|
},
|
|
142
206
|
insertNode(parent, node, anchor) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { resolveStateStyle, type IPressStateArgument, type IResolvedStateStyle, } from '@symbiote-native/components/state-style';
|
|
@@ -0,0 +1,13 @@
|
|
|
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 { resolveStateStyle, } from '@symbiote-native/components/state-style';
|
package/package.json
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@symbiote-native/solid",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "SymbioteNative's Solid adapter — a solid-js/universal createRenderer driving real native iOS/Android views through the same engine as every other adapter.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"react-native",
|
|
7
|
+
"symbiote-native",
|
|
8
|
+
"solid",
|
|
9
|
+
"solid-js",
|
|
10
|
+
"fabric",
|
|
11
|
+
"cross-platform",
|
|
12
|
+
"ios",
|
|
13
|
+
"android"
|
|
14
|
+
],
|
|
5
15
|
"license": "MIT",
|
|
6
16
|
"repository": {
|
|
7
17
|
"type": "git",
|
|
@@ -39,11 +49,16 @@
|
|
|
39
49
|
"default": "./build/jsx-runtime.js"
|
|
40
50
|
},
|
|
41
51
|
"./babel-preset": "./babel-preset.cjs",
|
|
42
|
-
"./metro-css-parser": "./metro-css-parser.cjs"
|
|
52
|
+
"./metro-css-parser": "./metro-css-parser.cjs",
|
|
53
|
+
"./state-style": {
|
|
54
|
+
"types": "./build/state-style.d.ts",
|
|
55
|
+
"default": "./build/state-style.js"
|
|
56
|
+
}
|
|
43
57
|
},
|
|
44
58
|
"files": [
|
|
45
59
|
"build",
|
|
46
60
|
"babel-preset.cjs",
|
|
61
|
+
"babel-lower-host-primitives.cjs",
|
|
47
62
|
"metro-css-parser.cjs"
|
|
48
63
|
],
|
|
49
64
|
"publishConfig": {
|
|
@@ -52,18 +67,19 @@
|
|
|
52
67
|
"dependencies": {
|
|
53
68
|
"babel-preset-solid": "^1.9.12",
|
|
54
69
|
"typescript": "~6.0.0",
|
|
55
|
-
"@symbiote-native/components": "0.
|
|
56
|
-
"@symbiote-native/css-parser": "0.
|
|
70
|
+
"@symbiote-native/components": "1.0.0",
|
|
71
|
+
"@symbiote-native/css-parser": "0.5.0"
|
|
57
72
|
},
|
|
58
73
|
"peerDependencies": {
|
|
59
|
-
"@symbiote-native/engine": ">=0.1.7",
|
|
60
74
|
"solid-js": ">=1.9.0",
|
|
61
|
-
"react-native": ">=0.86"
|
|
75
|
+
"react-native": ">=0.86",
|
|
76
|
+
"@symbiote-native/engine": "^0.4.0"
|
|
62
77
|
},
|
|
63
78
|
"devDependencies": {
|
|
79
|
+
"@babel/core": "^7.25.2",
|
|
64
80
|
"solid-js": "^1.9.14",
|
|
65
|
-
"@symbiote-native/engine": "0.
|
|
66
|
-
"@symbiote-native/test-utils": "0.
|
|
81
|
+
"@symbiote-native/engine": "0.4.0",
|
|
82
|
+
"@symbiote-native/test-utils": "0.3.0"
|
|
67
83
|
},
|
|
68
84
|
"scripts": {
|
|
69
85
|
"typecheck": "tsc --build",
|