@symbiote-native/svelte 0.3.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.
@@ -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
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,33 @@
1
+ // Side-effect ONLY — this module exports nothing, and that is the whole design.
2
+ //
3
+ // The host-behavior registry has to know that `symbiote-pressable` carries a press machine before
4
+ // any node of that tag is created, and a registration is exactly the shape Metro's
5
+ // `inlineRequires` silently drops in RELEASE builds: it moves a `require` down to the first place
6
+ // its binding is used as a VALUE, and a barrel's `export { X } from './x'` compiles to a lazy
7
+ // getter. A module nobody names as a value therefore never evaluates — dev is perfect, release has
8
+ // no behavior at all.
9
+ //
10
+ // Two shapes survive that, and only two. Deleting the indirection is unavailable here: the engine
11
+ // cannot import from `@symbiote-native/components` because the dependency runs the other way, so
12
+ // the registry is forced rather than chosen. That leaves a BARE side-effect import that is never
13
+ // re-exported, the pattern `packages/slider/src/{react,vue,svelte,angular}/index.ts` already uses.
14
+ // `index.ts` carries `import './register';` and must never carry `export * from './register'`, nor
15
+ // sit beside a re-export of this same specifier — Babel merges two imports of one specifier into a
16
+ // single dependency and the merged one stays lazy.
17
+ import { registerImageBehavior, registerInputAccessoryViewBehavior, registerPressableBehavior, registerSwitchBehavior, registerTextInputBehavior, } from '@symbiote-native/components';
18
+ registerPressableBehavior();
19
+ // Only the LOWERED tags carry this — the wrapper renders `symbiote-text-input-managed` and
20
+ // keeps running its own lifecycle. One owner per node; see `component-names/shared.ts`.
21
+ registerTextInputBehavior();
22
+ // Same reason as TextInput: the wrapper renders `symbiote-switch-managed` and runs its own
23
+ // lastNativeReport/snap-back lifecycle, so the engine's copy attaches only to the bare tag.
24
+ registerSwitchBehavior();
25
+ // Image owns no runtime — its behavior is a prop FOLD and nothing else, and it is registered on the
26
+ // same `symbiote-image` the wrapper already emits rather than on a `-managed` twin. That is safe
27
+ // only because the mapping is idempotent, which `core/components/src/behaviors/image.test.ts`
28
+ // asserts rather than assumes; a wrapper-built node simply folds a second time and nothing moves.
29
+ registerImageBehavior();
30
+ // Fold-only, and it shares the wrapper's tag for the same reason Image does: the mapping has no
31
+ // aliasing at all, so a wrapper-built node folding a second time moves nothing.
32
+ // `core/components/src/behaviors/input-accessory-view.test.ts` asserts that rather than assuming it.
33
+ registerInputAccessoryViewBehavior();
@@ -0,0 +1 @@
1
+ export { resolveStateStyle, type IPressStateArgument, type IResolvedStateStyle, } from '@symbiote-native/components/state-style';
@@ -0,0 +1,8 @@
1
+ // Re-export only. The implementation is shared (`@symbiote-native/components/state-style`) because
2
+ // two adapters had written byte-identical copies; this file exists so the code a transform EMITS
3
+ // keeps importing from the adapter package the app already depends on, rather than reaching past it.
4
+ //
5
+ // Both sides are a SUBPATH, not a barrel, and deliberately: an adapter barrel re-exports the core
6
+ // barrel wholesale, so a symbol placed there becomes public API in five packages at once — which is
7
+ // what briefly happened to this one.
8
+ export { resolveStateStyle, } from '@symbiote-native/components/state-style';
@@ -35,7 +35,11 @@ const upstreamTransformer = resolveUpstreamTransformer();
35
35
  // svelte-check, the language server, Vite), while this file is `require()`d by Metro directly
36
36
  // and must stay pure CommonJS with no build step of its own, exactly like
37
37
  // metro-vue-transformer.cjs.
38
- const COMPILER_OPTIONS = { fragments: 'tree', css: 'external', generate: 'client' };
38
+ const COMPILER_OPTIONS = {
39
+ fragments: 'tree',
40
+ css: 'external',
41
+ generate: 'client',
42
+ };
39
43
 
40
44
  // The web-only-construct guard (svelte-adapter-dom-shim skill §7/§22) RUNS HERE, on every
41
45
  // `.svelte` Metro compiles — not just via svelte-check/the language server. `{@html}` compiles
@@ -50,8 +54,8 @@ const COMPILER_OPTIONS = { fragments: 'tree', css: 'external', generate: 'client
50
54
  // `require()` cannot pull an ESM/TS module, but `transform` is already async.
51
55
  let preprocessorPromise;
52
56
  function webOnlyConstructGuard() {
53
- preprocessorPromise ??= import('@symbiote-native/svelte/preprocessor').then(mod =>
54
- mod.forbidWebOnlyConstructs(),
57
+ preprocessorPromise ??= import('@symbiote-native/svelte/preprocessor').then(
58
+ mod => mod.forbidWebOnlyConstructs(),
55
59
  );
56
60
  return preprocessorPromise;
57
61
  }
@@ -63,8 +67,8 @@ function webOnlyConstructGuard() {
63
67
  // src/preprocessor/scoped-styles.ts for why Svelte's own `result.css` cannot be used instead.
64
68
  let scopedStylesPromise;
65
69
  function scopedStylesPreprocessor() {
66
- scopedStylesPromise ??= import('@symbiote-native/svelte/scoped-styles').then(mod =>
67
- mod.scopedStyles(),
70
+ scopedStylesPromise ??= import('@symbiote-native/svelte/scoped-styles').then(
71
+ mod => mod.scopedStyles(),
68
72
  );
69
73
  return scopedStylesPromise;
70
74
  }
@@ -75,11 +79,24 @@ function scopedStylesPreprocessor() {
75
79
  // renders as a forced line break on device. Loaded lazily for the same reason as the two above.
76
80
  let collapseTextWhitespacePromise;
77
81
  function collapseTextWhitespacePreprocessor() {
78
- collapseTextWhitespacePromise ??= import('@symbiote-native/svelte/collapse-text-whitespace').then(
79
- mod => mod.collapseTextWhitespace(),
80
- );
82
+ collapseTextWhitespacePromise ??=
83
+ import('@symbiote-native/svelte/collapse-text-whitespace').then(mod =>
84
+ mod.collapseTextWhitespace(),
85
+ );
81
86
  return collapseTextWhitespacePromise;
82
87
  }
88
+
89
+ // Lowers <View>/<Text> to their intrinsic tags so a primitive stops costing a Svelte component
90
+ // boundary — which in Svelte is paid in ANCHOR NODES, 12 per benchmark row (see the file header).
91
+ // Loaded lazily for the same reason as the three above.
92
+ let lowerHostPrimitivesPromise;
93
+ function lowerHostPrimitivesPreprocessor() {
94
+ lowerHostPrimitivesPromise ??=
95
+ import('@symbiote-native/svelte/lower-host-primitives').then(mod =>
96
+ mod.lowerHostPrimitives(),
97
+ );
98
+ return lowerHostPrimitivesPromise;
99
+ }
83
100
  //
84
101
  // Svelte 5's compiler strips <script lang="ts"> types structurally, with no external type
85
102
  // resolution needed (unlike @vue/compiler-sfc's compileScript, which needs registerTS + a real
@@ -108,13 +125,19 @@ function compileSvelteFile(src, filename) {
108
125
  function stripTypeScript(src, filename) {
109
126
  const { outputText } = ts.transpileModule(src, {
110
127
  fileName: filename,
111
- compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ESNext },
128
+ compilerOptions: {
129
+ module: ts.ModuleKind.ESNext,
130
+ target: ts.ScriptTarget.ESNext,
131
+ },
112
132
  });
113
133
  return outputText;
114
134
  }
115
135
 
116
136
  function compileSvelteModuleFile(src, filename) {
117
- const { js } = compileModule(stripTypeScript(src, filename), { generate: 'client', filename });
137
+ const { js } = compileModule(stripTypeScript(src, filename), {
138
+ generate: 'client',
139
+ filename,
140
+ });
118
141
  return js.code;
119
142
  }
120
143
 
@@ -127,14 +150,22 @@ module.exports.transform = async function transform(params) {
127
150
  if (params.filename.endsWith('.svelte')) {
128
151
  // Throws with a message naming the RN alternative. Deliberately BEFORE compile(), so the
129
152
  // author sees the real diagnosis rather than a downstream symptom.
130
- (await webOnlyConstructGuard()).markup({ content: params.src, filename: params.filename });
153
+ (await webOnlyConstructGuard()).markup({
154
+ content: params.src,
155
+ filename: params.filename,
156
+ });
131
157
  const stylePreprocessed = await (
132
158
  await scopedStylesPreprocessor()
133
159
  ).markup({ content: params.src, filename: params.filename });
134
160
  const preprocessed = await (
135
161
  await collapseTextWhitespacePreprocessor()
136
162
  ).markup({ content: stylePreprocessed.code, filename: params.filename });
137
- const code = compileSvelteFile(preprocessed.code, params.filename);
163
+ // LAST, and after scopedStyles specifically — see svelte.config.js for why the order is
164
+ // load-bearing rather than stylistic.
165
+ const lowered = await (
166
+ await lowerHostPrimitivesPreprocessor()
167
+ ).markup({ content: preprocessed.code, filename: params.filename });
168
+ const code = compileSvelteFile(lowered.code, params.filename);
138
169
  // Re-label as .tsx so RN's transformer processes the module exactly like app source; Metro
139
170
  // tracks the real path separately. Matches metro-vue-transformer.cjs's identical trick.
140
171
  return upstreamTransformer.transform({
@@ -143,7 +174,10 @@ module.exports.transform = async function transform(params) {
143
174
  filename: params.filename + '.tsx',
144
175
  });
145
176
  }
146
- if (params.filename.endsWith('.svelte.ts') || params.filename.endsWith('.svelte.js')) {
177
+ if (
178
+ params.filename.endsWith('.svelte.ts') ||
179
+ params.filename.endsWith('.svelte.js')
180
+ ) {
147
181
  const code = compileSvelteModuleFile(params.src, params.filename);
148
182
  return upstreamTransformer.transform({
149
183
  ...params,