@symbiote-native/svelte 0.2.1 → 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/build/bootstrap.js +1 -1
- package/build/class-value.js +1 -1
- package/build/components/RefreshControl.svelte +4 -2
- package/build/components/Text.svelte +11 -2
- package/build/components/View.svelte +3 -1
- package/build/components/activity-indicator/index.svelte +8 -2
- package/build/components/button.svelte +8 -3
- package/build/components/flat-list/index.svelte +46 -11
- package/build/components/image/image-logic.js +13 -3
- package/build/components/image-background/index.svelte +8 -2
- package/build/components/index.d.ts +2 -2
- package/build/components/input-accessory-view/index.svelte +4 -1
- package/build/components/keyboard-avoiding-view/index.svelte +34 -11
- package/build/components/modal/index.svelte +4 -1
- package/build/components/pressable/index.svelte +48 -23
- package/build/components/pressable/index.svelte.d.ts +5 -1
- package/build/components/pressable/pressable-props.d.ts +1 -1
- package/build/components/safe-area-view-props.d.ts +1 -0
- package/build/components/scroll-view/index.svelte +54 -21
- package/build/components/scroll-view/sticky-header.svelte +49 -16
- package/build/components/section-list/index.svelte +6 -1
- package/build/components/switch/index.svelte +16 -5
- package/build/components/text-input/index.svelte +80 -8
- package/build/components/text-input/index.svelte.d.ts +5 -0
- package/build/components/touchable-highlight/index.svelte +115 -15
- package/build/components/touchable-highlight/touchable-highlight-props.d.ts +2 -0
- package/build/components/touchable-native-feedback/touchable-native-feedback.svelte +8 -3
- package/build/components/touchable-opacity/index.svelte +113 -56
- package/build/components/touchable-without-feedback/index.svelte +60 -4
- package/build/components/virtualized-list/index.svelte +233 -70
- package/build/components/virtualized-list/virtualized-list-props.js +8 -4
- package/build/components/virtualized-section-list/index.svelte +56 -9
- package/build/components/virtualized-section-list/index.svelte.d.ts +1 -1
- package/build/components/virtualized-section-list/virtualized-section-list-props.d.ts +5 -0
- package/build/create-portal/index.d.ts +22 -0
- package/build/create-portal/index.js +160 -0
- package/build/descriptor-to-svelte.js +15 -21
- package/build/dom-shim/element.d.ts +9 -3
- package/build/dom-shim/element.js +77 -25
- package/build/dom-shim/fold-host-bag.d.ts +1 -0
- package/build/dom-shim/fold-host-bag.js +7 -0
- package/build/dom-shim/index.d.ts +1 -0
- package/build/dom-shim/index.js +3 -0
- package/build/dom-shim/patch-globals.js +4 -2
- package/build/dom-shim/shim-node.js +11 -0
- package/build/dom-shim/text.js +38 -1
- package/build/host-instance.js +9 -5
- package/build/index.d.ts +6 -3
- package/build/index.js +20 -11
- package/build/modules/animated/create-animated-component.d.ts +5 -0
- package/build/modules/animated/create-animated-component.js +131 -0
- package/build/modules/animated/index.d.ts +24 -14
- package/build/modules/animated/index.js +30 -30
- package/build/modules/status-bar/index.js +1 -1
- package/build/modules/status-bar/index.svelte +4 -1
- package/build/native-view-bridge.d.ts +1 -1
- package/build/native-view-bridge.js +1 -1
- package/build/preprocessor/collapse-text-whitespace.js +20 -7
- package/build/preprocessor/forbid-web-only-constructs.js +8 -2
- package/build/preprocessor/lower-host-primitives.d.ts +10 -0
- package/build/preprocessor/lower-host-primitives.js +455 -0
- package/build/preprocessor/scoped-styles.js +141 -104
- package/build/register.d.ts +1 -0
- package/build/register.js +33 -0
- package/build/render.js +48 -3
- package/build/scope-token.d.ts +3 -2
- package/build/scope-token.js +6 -17
- package/build/state-style.d.ts +1 -0
- package/build/state-style.js +8 -0
- package/build/style-scope.d.ts +2 -1
- package/build/style-scope.js +10 -8
- package/metro-svelte-transformer.cjs +48 -14
- package/package.json +28 -10
- package/build/modules/animated/AnimatedImage.svelte +0 -115
- package/build/modules/animated/AnimatedImage.svelte.d.ts +0 -5
- package/build/modules/animated/AnimatedScrollView.svelte +0 -92
- package/build/modules/animated/AnimatedScrollView.svelte.d.ts +0 -17
- package/build/modules/animated/AnimatedText.svelte +0 -58
- package/build/modules/animated/AnimatedText.svelte.d.ts +0 -5
- package/build/modules/animated/AnimatedView.svelte +0 -95
- package/build/modules/animated/AnimatedView.svelte.d.ts +0 -5
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
// Lower `<View>` / `<Text>` to their intrinsic custom-element tags before svelte/compiler sees
|
|
2
|
+
// the file, so a primitive stops costing a Svelte COMPONENT BOUNDARY.
|
|
3
|
+
//
|
|
4
|
+
// Why it matters here is not the same currency as Vue's. Vue charges a component instance;
|
|
5
|
+
// Svelte charges ANCHOR NODES. Measured 2026-08-23 on the 1 000-row benchmark row: the retained
|
|
6
|
+
// tree held 23 006 nodes where every other adapter holds 9 001 — renderable 9 002 (identical) plus
|
|
7
|
+
// 14 004 anchors, of which 12 002 were Svelte block/component comments, i.e. 12 per row for 6
|
|
8
|
+
// component instances. Fabric saw the same 9000/8000 calls either way, so the native side was
|
|
9
|
+
// never the problem; the cost is 14 004 extra retained objects plus `renderableChildren`
|
|
10
|
+
// (core/engine/src/commit.ts) losing its fast path 6 002 times per commit, re-scanning and
|
|
11
|
+
// re-allocating a child array for every anchor-bearing parent. An A/B against the intrinsic tags,
|
|
12
|
+
// with both arms asserted to build the SAME 9 002 renderable nodes and the SAME 9 000 createNode
|
|
13
|
+
// calls, read 89.3 ms -> 47.5 ms and 14 004 -> 8 002 anchors. Headless sizes nothing (it has
|
|
14
|
+
// mis-sized three such changes in a row, in both directions) — the mechanism is what that number
|
|
15
|
+
// establishes.
|
|
16
|
+
//
|
|
17
|
+
// TWO HALVES OR NOTHING, the lesson Vue's twin paid for: the wrappers do real work, and lowering
|
|
18
|
+
// deletes them. `View.svelte` folds RN's `id` onto `nativeID`; `Text.svelte` applies RN's Text.js
|
|
19
|
+
// defaults through resolveTextProps. Both are reproduced BELOW, at compile time, which is strictly
|
|
20
|
+
// cheaper than any runtime seam because the whole attribute set is visible here. Drop either and
|
|
21
|
+
// the failure is device-only and silent — a clamped <Text> that clips mid-word instead of
|
|
22
|
+
// ellipsising.
|
|
23
|
+
//
|
|
24
|
+
// THE REFUSAL RULE IS THE SAFETY PROPERTY. An element is lowered only when every one of its
|
|
25
|
+
// attributes is a plain name/value pair this file can read. A `{...spread}`, a `bind:`, a `use:`,
|
|
26
|
+
// an `{@attach}` or any other directive means the attribute set is NOT fully visible (or the
|
|
27
|
+
// binding targets the component instance rather than the host), so the element stays a component
|
|
28
|
+
// and simply keeps today's behaviour. Refusing is always safe; guessing is not.
|
|
29
|
+
import { parse } from 'svelte/compiler';
|
|
30
|
+
// The lowering SPEC, shared with Vue's and Solid's transforms. Which primitives lower, what each
|
|
31
|
+
// folds, and the refusal categories are DATA now — this file owns only how Svelte's AST is read
|
|
32
|
+
// and how the lowered form is emitted, which is the half that genuinely differs (our custom-
|
|
33
|
+
// element codegen needs one `p={{…}}` bag where Vue and Solid keep individual bindings).
|
|
34
|
+
//
|
|
35
|
+
// It is a `.cjs` because its other consumers are Babel/Metro transforms that run before any TS
|
|
36
|
+
// exists; a hand-written `.d.cts` beside it is what lets this file read it through real types.
|
|
37
|
+
import { HOST_PRIMITIVES } from '@symbiote-native/components/host-primitives';
|
|
38
|
+
const PACKAGE_IMPORT = /import\s*\{([^}]*)\}\s*from\s*['"]@symbiote-native\/svelte['"]/g;
|
|
39
|
+
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
40
|
+
// The runtime half of the state-style split, and the local name it is injected under. A sentinel
|
|
41
|
+
// KEY carries the spread through the same entry map every other attribute uses, so the fold and
|
|
42
|
+
// alias passes need no special case; only the serializer knows the difference.
|
|
43
|
+
const STATE_STYLE_MODULE = '@symbiote-native/svelte/state-style';
|
|
44
|
+
const STATE_STYLE_LOCAL = '__symbioteStateStyle';
|
|
45
|
+
const STATE_STYLE_SPREAD = '\u0000state-style-spread';
|
|
46
|
+
function isRecord(value) {
|
|
47
|
+
return typeof value === 'object' && value !== null;
|
|
48
|
+
}
|
|
49
|
+
function numberAt(node, key) {
|
|
50
|
+
const value = node[key];
|
|
51
|
+
return typeof value === 'number' ? value : undefined;
|
|
52
|
+
}
|
|
53
|
+
function stringAt(node, key) {
|
|
54
|
+
const value = node[key];
|
|
55
|
+
return typeof value === 'string' ? value : undefined;
|
|
56
|
+
}
|
|
57
|
+
// Which LOCAL names in this file are our primitives. Only names this file imported from
|
|
58
|
+
// `@symbiote-native/svelte` count — matching a bare tag name would rewrite an app's own
|
|
59
|
+
// `<View>`. An alias (`View as Box`) maps the alias, since that is the name the markup uses.
|
|
60
|
+
function lowerableTagsIn(content) {
|
|
61
|
+
const tags = new Map();
|
|
62
|
+
for (const match of content.matchAll(PACKAGE_IMPORT)) {
|
|
63
|
+
for (const clause of match[1].split(',')) {
|
|
64
|
+
const [imported, local] = clause.trim().split(/\s+as\s+/);
|
|
65
|
+
const primitive = HOST_PRIMITIVES[imported];
|
|
66
|
+
if (primitive !== undefined)
|
|
67
|
+
tags.set(local ?? imported, primitive);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return tags;
|
|
71
|
+
}
|
|
72
|
+
function nestedNodes(node) {
|
|
73
|
+
const nested = [];
|
|
74
|
+
for (const value of Object.values(node)) {
|
|
75
|
+
if (Array.isArray(value))
|
|
76
|
+
nested.push(...value);
|
|
77
|
+
else if (isRecord(value))
|
|
78
|
+
nested.push(value);
|
|
79
|
+
}
|
|
80
|
+
return nested;
|
|
81
|
+
}
|
|
82
|
+
function slice(content, node) {
|
|
83
|
+
const start = numberAt(node, 'start');
|
|
84
|
+
const end = numberAt(node, 'end');
|
|
85
|
+
if (start === undefined || end === undefined)
|
|
86
|
+
return '';
|
|
87
|
+
return content.slice(start, end);
|
|
88
|
+
}
|
|
89
|
+
// One attribute -> the JS expression its bag value should hold, or undefined to REFUSE the whole
|
|
90
|
+
// element. Refusing on anything unrecognised is deliberate: a value shape this file cannot read
|
|
91
|
+
// is a value shape it cannot preserve.
|
|
92
|
+
function attributeValue(content, attribute) {
|
|
93
|
+
const value = attribute.value;
|
|
94
|
+
// `<View focusable>` — boolean shorthand.
|
|
95
|
+
if (value === true)
|
|
96
|
+
return 'true';
|
|
97
|
+
const parts = Array.isArray(value) ? value : [value];
|
|
98
|
+
if (parts.length === 0)
|
|
99
|
+
return 'true';
|
|
100
|
+
if (parts.length === 1) {
|
|
101
|
+
const only = parts[0];
|
|
102
|
+
if (!isRecord(only))
|
|
103
|
+
return undefined;
|
|
104
|
+
// `class="row"` — a literal. JSON.stringify, not the raw source slice: the slice carries the
|
|
105
|
+
// author's quote style and any HTML entity, and the bag needs a JS string.
|
|
106
|
+
if (only.type === 'Text') {
|
|
107
|
+
const data = stringAt(only, 'data');
|
|
108
|
+
return data === undefined ? undefined : JSON.stringify(data);
|
|
109
|
+
}
|
|
110
|
+
// `onPress={fn}` — parenthesised so a sequence or arrow keeps its meaning inside the literal.
|
|
111
|
+
if (only.type === 'ExpressionTag') {
|
|
112
|
+
const expression = only.expression;
|
|
113
|
+
if (!isRecord(expression))
|
|
114
|
+
return undefined;
|
|
115
|
+
return `(${slice(content, expression)})`;
|
|
116
|
+
}
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
// `class="a {b} c"` — a quoted value mixing literals and expressions becomes a template literal,
|
|
120
|
+
// which is what the browser/Svelte concatenation produces anyway.
|
|
121
|
+
const chunks = [];
|
|
122
|
+
for (const part of parts) {
|
|
123
|
+
if (!isRecord(part))
|
|
124
|
+
return undefined;
|
|
125
|
+
if (part.type === 'Text') {
|
|
126
|
+
const data = stringAt(part, 'data');
|
|
127
|
+
if (data === undefined)
|
|
128
|
+
return undefined;
|
|
129
|
+
chunks.push(data
|
|
130
|
+
.replaceAll('\\', '\\\\')
|
|
131
|
+
.replaceAll('`', '\\`')
|
|
132
|
+
.replaceAll('${', '\\${'));
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (part.type === 'ExpressionTag') {
|
|
136
|
+
const expression = part.expression;
|
|
137
|
+
if (!isRecord(expression))
|
|
138
|
+
return undefined;
|
|
139
|
+
chunks.push(`\${${slice(content, expression)}}`);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
return `\`${chunks.join('')}\``;
|
|
145
|
+
}
|
|
146
|
+
// ---- the intrinsic CHOICE, and why it is a refusal category of its own -----------------------
|
|
147
|
+
//
|
|
148
|
+
// `TextInput` is the first primitive whose TAG depends on a prop: `multiline` selects between two
|
|
149
|
+
// different Fabric views (`core/components/src/view/render-text-input.ts`, `renderTextInput`), not
|
|
150
|
+
// between two flavours of one. This file prints a static tag name, so it can only resolve the
|
|
151
|
+
// choice when the value is a compile-time literal.
|
|
152
|
+
//
|
|
153
|
+
// It is NOT the same failure as an unreadable attribute value, and the difference is why it earns
|
|
154
|
+
// its own category (`dynamicIntrinsicChoice`) rather than reusing `unreadableValue`: there, a prop
|
|
155
|
+
// arrives wrong on the right node; here the WRONG NATIVE VIEW is committed, and no later prop write
|
|
156
|
+
// corrects it. A refusal keeps the component, which resolves the same choice at runtime.
|
|
157
|
+
//
|
|
158
|
+
// The spec declares `intrinsicWhen` ahead of its first entry, so this reads it off `IHostPrimitive`
|
|
159
|
+
// directly — no entry exists yet, and a primitive without the field keeps exactly today's behaviour.
|
|
160
|
+
//
|
|
161
|
+
/**
|
|
162
|
+
* The tag this element lowers to, or undefined to REFUSE it.
|
|
163
|
+
*
|
|
164
|
+
* `<TextInput multiline />` is Svelte's boolean shorthand and parses as `value === true` — a
|
|
165
|
+
* literal, not a missing value, so it resolves rather than refusing. That distinction is the whole
|
|
166
|
+
* difference between lowering to the multiline tag and refusing something that should lower, so it
|
|
167
|
+
* is asserted rather than assumed.
|
|
168
|
+
*/
|
|
169
|
+
function resolveIntrinsic(primitive, attributes) {
|
|
170
|
+
const choice = primitive.intrinsicWhen;
|
|
171
|
+
if (choice === undefined)
|
|
172
|
+
return primitive.intrinsic;
|
|
173
|
+
const selector = attributes.find(attribute => stringAt(attribute, 'name') === choice.prop);
|
|
174
|
+
if (selector === undefined)
|
|
175
|
+
return primitive.intrinsic;
|
|
176
|
+
const value = selector.value;
|
|
177
|
+
if (value === true)
|
|
178
|
+
return choice.intrinsic;
|
|
179
|
+
if (!isRecord(value) || value.type !== 'ExpressionTag')
|
|
180
|
+
return undefined;
|
|
181
|
+
const expression = value.expression;
|
|
182
|
+
if (!isRecord(expression))
|
|
183
|
+
return undefined;
|
|
184
|
+
// IDENTITY against the two booleans, never truthiness, and no check on the node TYPE. Only a
|
|
185
|
+
// `Literal` node carries a parsed JS value here, so `=== true` already implies "a boolean literal"
|
|
186
|
+
// — an `expression.type === 'Literal'` guard beside it was UNFALSIFIABLE and was removed when a
|
|
187
|
+
// deliberate break of it changed nothing. What the identity buys instead is falsifiable and was
|
|
188
|
+
// measured: relaxing it to truthiness lowers `{isLong}` and `{1}` to a real tag, which commits a
|
|
189
|
+
// native view the author never chose.
|
|
190
|
+
if (expression.value === true)
|
|
191
|
+
return choice.intrinsic;
|
|
192
|
+
if (expression.value === false)
|
|
193
|
+
return primitive.intrinsic;
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
function bagKey(name) {
|
|
197
|
+
return IDENTIFIER.test(name) ? name : JSON.stringify(name);
|
|
198
|
+
}
|
|
199
|
+
// A spec `defaults` entry, emitted as a JS EXPRESSION rather than a value — an absent attribute
|
|
200
|
+
// becomes the literal, a present one keeps its expression and has the operation applied around it.
|
|
201
|
+
// That distinction is the whole point: a value map would produce `null` for `<Text
|
|
202
|
+
// ellipsizeMode={null}>` on the lowered tag while the wrapper path yields 'tail', which is the
|
|
203
|
+
// divergence that shipped in Solid's plugin. `lower-host-primitives.test.ts` holds the emitted bag
|
|
204
|
+
// against `resolveTextProps` for exactly that reason.
|
|
205
|
+
function foldExpression(op, authored) {
|
|
206
|
+
if (op.op === 'notFalse')
|
|
207
|
+
return authored === undefined ? 'true' : `${authored} !== false`;
|
|
208
|
+
const fallback = JSON.stringify(op.value);
|
|
209
|
+
return authored === undefined ? fallback : `${authored} ?? ${fallback}`;
|
|
210
|
+
}
|
|
211
|
+
function applyDefaults(entries, defaults) {
|
|
212
|
+
// Every key is emitted UNCONDITIONALLY, present or not — a fold whose two branches emit
|
|
213
|
+
// different key sets is its own hazard, and the spec says so.
|
|
214
|
+
for (const key of Object.keys(defaults)) {
|
|
215
|
+
entries.set(key, foldExpression(defaults[key], entries.get(key)));
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
// The spec's `aliases` — today just RN's `id` -> `nativeID`, which WINS when both are set. The raw
|
|
219
|
+
// key must not survive into the bag: no ViewConfig declares `id`, so Fabric drops it silently.
|
|
220
|
+
//
|
|
221
|
+
// Driven by the spec rather than hand-written, and that CHANGED behaviour here: this file used to
|
|
222
|
+
// apply the alias to View only (an `else` branch that skipped Text), so `<Text id="x">` lowered to
|
|
223
|
+
// a raw `id` that never reached the native view. The spec carries the alias on BOTH tags, verified
|
|
224
|
+
// against Text.js:222 `const _nativeID = id ?? nativeID;`.
|
|
225
|
+
function applyAliases(entries, aliases) {
|
|
226
|
+
for (const from of Object.keys(aliases)) {
|
|
227
|
+
const authored = entries.get(from);
|
|
228
|
+
if (authored === undefined)
|
|
229
|
+
continue;
|
|
230
|
+
entries.delete(from);
|
|
231
|
+
entries.set(aliases[from], authored);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
// The `>` that closes this element's open tag. Scanning starts past the last attribute, so a `>`
|
|
235
|
+
// inside an attribute expression can never be mistaken for it.
|
|
236
|
+
function openTagEnd(content, node, attributes) {
|
|
237
|
+
const nameStart = numberAt(node, 'start');
|
|
238
|
+
if (nameStart === undefined)
|
|
239
|
+
return undefined;
|
|
240
|
+
let cursor = nameStart;
|
|
241
|
+
for (const attribute of attributes) {
|
|
242
|
+
const end = numberAt(attribute, 'end');
|
|
243
|
+
if (end !== undefined && end > cursor)
|
|
244
|
+
cursor = end;
|
|
245
|
+
}
|
|
246
|
+
const index = content.indexOf('>', cursor);
|
|
247
|
+
return index === -1 ? undefined : index + 1;
|
|
248
|
+
}
|
|
249
|
+
function collectEdits(content, tags, nodes, edits) {
|
|
250
|
+
let needsStateStyleHelper = false;
|
|
251
|
+
for (const node of nodes) {
|
|
252
|
+
if (!isRecord(node))
|
|
253
|
+
continue;
|
|
254
|
+
const name = stringAt(node, 'name');
|
|
255
|
+
const primitive = name === undefined ? undefined : tags.get(name);
|
|
256
|
+
if (node.type === 'Component' && primitive !== undefined)
|
|
257
|
+
needsStateStyleHelper =
|
|
258
|
+
lowerElement(content, node, name ?? '', primitive, edits) ||
|
|
259
|
+
needsStateStyleHelper;
|
|
260
|
+
needsStateStyleHelper =
|
|
261
|
+
collectEdits(content, tags, nestedNodes(node), edits) ||
|
|
262
|
+
needsStateStyleHelper;
|
|
263
|
+
}
|
|
264
|
+
return needsStateStyleHelper;
|
|
265
|
+
}
|
|
266
|
+
// ---- the two refusals a STATEFUL primitive adds, on top of the ones every primitive has -------
|
|
267
|
+
//
|
|
268
|
+
// Both are gated on the spec's `observesState`. They exist because tier-2 resolves a primitive's
|
|
269
|
+
// own state BELOW the framework — `:active` in the style registry — and that state never travels
|
|
270
|
+
// back up. A call site that reads it therefore cannot be lowered, and the failure if we lower it
|
|
271
|
+
// anyway is a button that renders and does not respond, with nothing red anywhere.
|
|
272
|
+
// `style` is the channel a press-aware call site reads state through
|
|
273
|
+
// (`style={({pressed}) => …}`), and it no longer REFUSES — it splits.
|
|
274
|
+
//
|
|
275
|
+
// The allow-list below is now about which styles need the split, not which are allowed. Anything
|
|
276
|
+
// not provably inert goes through `resolveStateStyle` at runtime, which calls the callback once per
|
|
277
|
+
// press state and hands the engine both looks. An Identifier is the case that makes invocation
|
|
278
|
+
// strictly better than compile-time substitution: `style={styleFn}` cannot be proven to be an
|
|
279
|
+
// object or a function by any transform, and a runtime `typeof` decides it for free.
|
|
280
|
+
//
|
|
281
|
+
// The inert set stays because those styles need no split at all — one key instead of a helper call
|
|
282
|
+
// and an extra object, on the overwhelmingly common shape.
|
|
283
|
+
const INERT_STYLE_EXPRESSIONS = new Set([
|
|
284
|
+
'ObjectExpression',
|
|
285
|
+
'ArrayExpression',
|
|
286
|
+
'Literal',
|
|
287
|
+
'TemplateLiteral',
|
|
288
|
+
]);
|
|
289
|
+
function needsStateStyleSplit(attribute) {
|
|
290
|
+
if (stringAt(attribute, 'name') !== 'style')
|
|
291
|
+
return false;
|
|
292
|
+
const value = attribute.value;
|
|
293
|
+
// A quoted literal (`style="…"`) arrives as a Text array and is inert by construction.
|
|
294
|
+
if (!isRecord(value))
|
|
295
|
+
return false;
|
|
296
|
+
if (value.type !== 'ExpressionTag')
|
|
297
|
+
return false;
|
|
298
|
+
const expression = value.expression;
|
|
299
|
+
if (!isRecord(expression))
|
|
300
|
+
return true;
|
|
301
|
+
const kind = stringAt(expression, 'type');
|
|
302
|
+
return kind === undefined || !INERT_STYLE_EXPRESSIONS.has(kind);
|
|
303
|
+
}
|
|
304
|
+
// A children SNIPPET that takes a parameter is Svelte's render prop, and the parameter is the
|
|
305
|
+
// press state. Zero-arity is NOT a refusal — `{#snippet children()}` and a plain child are
|
|
306
|
+
// ordinary lazy children, and that distinction matters more here than in the other adapters
|
|
307
|
+
// because every child of a Svelte component is a snippet whether the author wrote one or not.
|
|
308
|
+
function hasRenderPropChild(node) {
|
|
309
|
+
const fragment = node.fragment;
|
|
310
|
+
if (!isRecord(fragment) || !Array.isArray(fragment.nodes))
|
|
311
|
+
return false;
|
|
312
|
+
for (const child of fragment.nodes) {
|
|
313
|
+
if (!isRecord(child) || child.type !== 'SnippetBlock')
|
|
314
|
+
continue;
|
|
315
|
+
const parameters = child.parameters;
|
|
316
|
+
if (Array.isArray(parameters) && parameters.length > 0)
|
|
317
|
+
return true;
|
|
318
|
+
}
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
function lowerElement(content, node, name, primitive, edits) {
|
|
322
|
+
const rawAttributes = node.attributes;
|
|
323
|
+
if (!Array.isArray(rawAttributes))
|
|
324
|
+
return false;
|
|
325
|
+
const attributes = [];
|
|
326
|
+
for (const attribute of rawAttributes) {
|
|
327
|
+
// THE REFUSAL. A spread or any directive means the attribute set is not fully visible here.
|
|
328
|
+
if (!isRecord(attribute) || attribute.type !== 'Attribute')
|
|
329
|
+
return false;
|
|
330
|
+
// THE SECOND DOORWAY, same hazard. A lowered element carries its whole prop surface in one
|
|
331
|
+
// `p={{…}}` bag and every key of it goes through `routeProp` — where a snippet is not markup,
|
|
332
|
+
// so the subtree never mounts and nothing goes red. Measured 2026-09-01: the component arm
|
|
333
|
+
// commits the child, the lowered arm does not. That is an optimisation changing the observable
|
|
334
|
+
// surface, which is the one thing lowering may never do.
|
|
335
|
+
//
|
|
336
|
+
// Keyed on the NAME, not on the value being a snippet literal: `children={kids}` is the
|
|
337
|
+
// spelling an app actually writes, and the identifier says nothing about what it holds.
|
|
338
|
+
//
|
|
339
|
+
// Only `children` is named because only `children` can be. The general case is any
|
|
340
|
+
// snippet-valued prop a primitive declares, and nothing enumerates those — `HOST_PRIMITIVES`
|
|
341
|
+
// must not gain the list either, since which props take a framework element is a per-adapter
|
|
342
|
+
// fact and the spec is cross-adapter (`.claude/rules/adapter-parity-audit.md`). A primitive
|
|
343
|
+
// that grows a second snippet prop needs its own entry here.
|
|
344
|
+
if (stringAt(attribute, 'name') === 'children')
|
|
345
|
+
return false;
|
|
346
|
+
attributes.push(attribute);
|
|
347
|
+
}
|
|
348
|
+
// A stateful primitive still refuses a render-prop child — that one is not a style and no
|
|
349
|
+
// runtime call can recover the parameter the template wants to read.
|
|
350
|
+
if (primitive.observesState === true && hasRenderPropChild(node))
|
|
351
|
+
return false;
|
|
352
|
+
const entries = new Map();
|
|
353
|
+
let splitStateStyle = false;
|
|
354
|
+
for (const attribute of attributes) {
|
|
355
|
+
const key = stringAt(attribute, 'name');
|
|
356
|
+
if (key === undefined)
|
|
357
|
+
return false;
|
|
358
|
+
const value = attributeValue(content, attribute);
|
|
359
|
+
if (value === undefined)
|
|
360
|
+
return false; // unreadable value shape — refuse the element
|
|
361
|
+
if (primitive.observesState === true && needsStateStyleSplit(attribute)) {
|
|
362
|
+
// Spread ONE call rather than emitting two: the author's expression is evaluated once, and a
|
|
363
|
+
// function literal allocates one closure instead of two.
|
|
364
|
+
entries.set(STATE_STYLE_SPREAD, `...${STATE_STYLE_LOCAL}(${value})`);
|
|
365
|
+
splitStateStyle = true;
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
entries.set(key, value);
|
|
369
|
+
}
|
|
370
|
+
applyAliases(entries, primitive.aliases);
|
|
371
|
+
applyDefaults(entries, primitive.defaults);
|
|
372
|
+
const intrinsic = resolveIntrinsic(primitive, attributes);
|
|
373
|
+
if (intrinsic === undefined)
|
|
374
|
+
return false; // dynamicIntrinsicChoice — keep the component
|
|
375
|
+
const start = numberAt(node, 'start');
|
|
376
|
+
const end = numberAt(node, 'end');
|
|
377
|
+
const tagEnd = openTagEnd(content, node, attributes);
|
|
378
|
+
if (start === undefined || end === undefined || tagEnd === undefined)
|
|
379
|
+
return false;
|
|
380
|
+
const bag = [...entries]
|
|
381
|
+
.map(([key, value]) => key === STATE_STYLE_SPREAD ? value : `${bagKey(key)}: ${value}`)
|
|
382
|
+
.join(', ');
|
|
383
|
+
const isSelfClosing = content.slice(tagEnd - 2, tagEnd) === '/>';
|
|
384
|
+
const open = `<${intrinsic} p={{${bag}}}${isSelfClosing ? ' />' : '>'}`;
|
|
385
|
+
edits.push({ start, end: tagEnd, text: open });
|
|
386
|
+
if (isSelfClosing)
|
|
387
|
+
return splitStateStyle;
|
|
388
|
+
// The children between the tags are left byte-for-byte alone; only the closing name changes.
|
|
389
|
+
const closing = `</${name}>`;
|
|
390
|
+
if (content.slice(end - closing.length, end) !== closing)
|
|
391
|
+
return splitStateStyle;
|
|
392
|
+
edits.push({
|
|
393
|
+
start: end - closing.length,
|
|
394
|
+
end,
|
|
395
|
+
text: `</${intrinsic}>`,
|
|
396
|
+
});
|
|
397
|
+
return splitStateStyle;
|
|
398
|
+
}
|
|
399
|
+
// Back-to-front, so an earlier edit's offsets stay valid — same as the sibling preprocessors.
|
|
400
|
+
function applyEdits(content, edits) {
|
|
401
|
+
return [...edits]
|
|
402
|
+
.sort((left, right) => right.start - left.start)
|
|
403
|
+
.reduce((source, edit) => source.slice(0, edit.start) + edit.text + source.slice(edit.end), content);
|
|
404
|
+
}
|
|
405
|
+
// The helper import, placed immediately after the `@symbiote-native/svelte` import that made this
|
|
406
|
+
// file lowerable at all. That statement is guaranteed to exist — lowering only fires on names
|
|
407
|
+
// imported from the package — so no `<script>` block has to be created or parsed, and Svelte hoists
|
|
408
|
+
// an instance-script import to module scope anyway.
|
|
409
|
+
//
|
|
410
|
+
// Returning undefined means the anchor could not be found, and the caller then emits the file
|
|
411
|
+
// UNCHANGED rather than a lowered file whose helper is missing. Same asymmetry as every other
|
|
412
|
+
// refusal here: doing nothing is always safe, emitting a half-wired file is not.
|
|
413
|
+
function stateStyleImportEdit(content) {
|
|
414
|
+
// `matchAll`, never `exec`. PACKAGE_IMPORT is a module-level /g regex shared with
|
|
415
|
+
// `lowerableTagsIn`, and `exec` leaves `lastIndex` pointing past the match — which `matchAll`
|
|
416
|
+
// then SEEDS from on the next file, so the import is not found, the file is not lowered, and it
|
|
417
|
+
// happens only when more than one file runs. Cost an order-dependent test failure that passed in
|
|
418
|
+
// isolation. `matchAll` reads `lastIndex` but clones the regex, so it leaves nothing behind.
|
|
419
|
+
const [match] = [...content.matchAll(PACKAGE_IMPORT)];
|
|
420
|
+
if (match === undefined)
|
|
421
|
+
return undefined;
|
|
422
|
+
const afterImport = match.index + match[0].length;
|
|
423
|
+
const semicolon = content[afterImport] === ';' ? 1 : 0;
|
|
424
|
+
const at = afterImport + semicolon;
|
|
425
|
+
return {
|
|
426
|
+
start: at,
|
|
427
|
+
end: at,
|
|
428
|
+
text: `\nimport { resolveStateStyle as ${STATE_STYLE_LOCAL} } from '${STATE_STYLE_MODULE}';`,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
export function lowerHostPrimitives() {
|
|
432
|
+
return {
|
|
433
|
+
markup({ content, filename }) {
|
|
434
|
+
const tags = lowerableTagsIn(content);
|
|
435
|
+
if (tags.size === 0)
|
|
436
|
+
return { code: content };
|
|
437
|
+
const ast = parse(content, { filename, modern: true });
|
|
438
|
+
const edits = [];
|
|
439
|
+
const fragment = isRecord(ast) ? ast.fragment : undefined;
|
|
440
|
+
const nodes = isRecord(fragment) && Array.isArray(fragment.nodes)
|
|
441
|
+
? fragment.nodes
|
|
442
|
+
: [];
|
|
443
|
+
const needsStateStyleHelper = collectEdits(content, tags, nodes, edits);
|
|
444
|
+
if (edits.length === 0)
|
|
445
|
+
return { code: content };
|
|
446
|
+
if (needsStateStyleHelper) {
|
|
447
|
+
const injected = stateStyleImportEdit(content);
|
|
448
|
+
if (injected === undefined)
|
|
449
|
+
return { code: content }; // cannot inject — refuse the file
|
|
450
|
+
edits.push(injected);
|
|
451
|
+
}
|
|
452
|
+
return { code: applyEdits(content, edits) };
|
|
453
|
+
},
|
|
454
|
+
};
|
|
455
|
+
}
|