@symbiote-native/vue 0.4.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +24 -9
  2. package/babel-jsx.cjs +53 -0
  3. package/babel-lower-host-primitives.cjs +225 -0
  4. package/build/bootstrap.js +1 -1
  5. package/build/components/button.js +4 -2
  6. package/build/components/flat-list/index.js +14 -5
  7. package/build/components/image-background.js +4 -5
  8. package/build/components/image.js +3 -1
  9. package/build/components/keyboard-avoiding-view.js +39 -14
  10. package/build/components/modal/index.js +5 -2
  11. package/build/components/pressable.d.ts +1 -1
  12. package/build/components/pressable.js +39 -20
  13. package/build/components/safe-area-view.d.ts +1 -0
  14. package/build/components/scroll-view/index.android.d.ts +1 -1
  15. package/build/components/scroll-view/index.android.js +3 -1
  16. package/build/components/scroll-view/index.ios.d.ts +1 -1
  17. package/build/components/scroll-view/index.ios.js +3 -1
  18. package/build/components/scroll-view/shared.js +33 -13
  19. package/build/components/scroll-view/sticky-header.js +18 -5
  20. package/build/components/section-list/index.js +4 -2
  21. package/build/components/switch/shared.js +12 -3
  22. package/build/components/text-input/index.js +25 -2
  23. package/build/components/touchable-native-feedback.js +11 -4
  24. package/build/components/touchable.d.ts +7 -1
  25. package/build/components/touchable.js +184 -33
  26. package/build/components/virtualized-list/index.js +42 -20
  27. package/build/components/virtualized-section-list/index.d.ts +5 -0
  28. package/build/components/virtualized-section-list/index.js +33 -4
  29. package/build/components.d.ts +2 -0
  30. package/build/components.js +25 -4
  31. package/build/composables/use-raw-attrs.d.ts +1 -0
  32. package/build/composables/use-raw-attrs.js +26 -0
  33. package/build/create-portal/index.d.ts +28 -0
  34. package/build/create-portal/index.js +42 -0
  35. package/build/host-instance/index.js +7 -6
  36. package/build/index.d.ts +9 -6
  37. package/build/index.js +14 -3
  38. package/build/modules/animated/create-animated-component.d.ts +1 -1
  39. package/build/modules/animated/create-animated-component.js +27 -5
  40. package/build/modules/animated/index.d.ts +2 -0
  41. package/build/modules/animated/index.js +18 -5
  42. package/build/modules/app-registry/index.js +4 -2
  43. package/build/modules/status-bar.js +4 -2
  44. package/build/register.d.ts +1 -0
  45. package/build/register.js +35 -0
  46. package/build/render.d.ts +3 -1
  47. package/build/render.js +62 -1
  48. package/build/renderer/index.js +58 -9
  49. package/build/runtime-helpers/index.d.ts +48 -24
  50. package/build/runtime-helpers/index.js +314 -30
  51. package/build/state-style.d.ts +1 -0
  52. package/build/state-style.js +13 -0
  53. package/build/utils/normalize-attrs.d.ts +1 -0
  54. package/build/utils/normalize-attrs.js +21 -13
  55. package/metro-css-parser.cjs +2 -1
  56. package/metro-vue-transformer.cjs +502 -101
  57. package/package.json +29 -8
@@ -11,8 +11,21 @@
11
11
  // babelTransformerPath at it.
12
12
 
13
13
  const nodeFs = require('fs');
14
- const { parse, compileScript, registerTS } = require('@vue/compiler-sfc');
15
- const { createCompoundExpression } = require('@vue/compiler-core');
14
+ const {
15
+ parse,
16
+ parseCache,
17
+ compileScript,
18
+ registerTS,
19
+ // Babel's own parser, re-exported. Using it costs no new dependency, which matters: @babel/parser
20
+ // does not resolve from this package under pnpm's isolated layout, and the alternative — adding
21
+ // @babel/{parser,types,generator} to a PUBLISHED adapter — would also force every example through
22
+ // a full reinstall, because the overlay's folder swap cannot install a new dependency.
23
+ babelParse,
24
+ } = require('@vue/compiler-sfc');
25
+ const {
26
+ createCompoundExpression,
27
+ createSimpleExpression,
28
+ } = require('@vue/compiler-core');
16
29
 
17
30
  // A bare-specifier type import (`import type { X } from '@symbiote-native/navigation/vue'`) needs
18
31
  // real node_modules resolution to turn the specifier into a file path - compileScript's own `fs`
@@ -23,15 +36,13 @@ registerTS(() => require('typescript'));
23
36
  // Required directly (not via the ./metro-css-parser public subpath, which exists for CONSUMERS)
24
37
  // so it resolves from this package's own node_modules under pnpm.
25
38
  const {
26
- classTokensIn,
27
39
  compile: compilePreprocessor,
28
40
  compileCssFile,
29
- globalClassNamesIn,
30
- globalClassTokensIn,
41
+ compileCssModule,
42
+ compileCssToRules,
43
+ compileScopedCss,
31
44
  hashFilePath,
32
45
  isStyleFile,
33
- kebabToCamel,
34
- parseCSS,
35
46
  resolveUpstreamTransformer,
36
47
  } = require('@symbiote-native/css-parser');
37
48
 
@@ -53,36 +64,43 @@ const compileScriptFs = {
53
64
  },
54
65
  };
55
66
 
56
- // Rewrites a Vue template AST so every `class`/`:class` binding resolves against this file's
57
- // scoped class names via @symbiote-native/engine's scopeClassName(value, localNames, scopeId).
58
- // AST-level, not a raw-text regex: Vue merges a static class= and a dynamic :class= on the same
59
- // element into ONE codegen entry, and text substitution can't reproduce that merge safely -
60
- // letting Vue's own transformElement do the merge on our rewritten nodes reuses that logic.
67
+ // Rewrites a Vue template AST so every `class`/`:class` binding names a class by the name
68
+ // lightningcss RENAMED it to. AST-level, not a raw-text regex: Vue merges a static class= and a
69
+ // dynamic :class= on the same element into ONE codegen entry, and text substitution can't
70
+ // reproduce that merge safely - letting Vue's own transformElement do the merge on our rewritten
71
+ // nodes reuses that logic.
72
+ //
73
+ // `renames` is compileScopedCss's own name map (authored name -> renamed name), NOT a set of
74
+ // names this file re-suffixes. That is the point of the migration: the rewriter and the style
75
+ // compiler used to derive the scoped name independently, so any disagreement between them was
76
+ // silent. Now there is one source and this half only reads it. One entry per class, keyed as
77
+ // AUTHORED — `class="section-label"` is the lookup, `sectionLabel` is not a second spelling of it.
61
78
  //
62
79
  // A static class="foo bar" (AttributeNode, prop.type === 6) resolves to its final string here
63
- // at compile time - no runtime call needed. Each token is normalized kebab->camel FIRST since
64
- // css-parser always registers the camel form and localNames is camelCase-keyed.
80
+ // at compile time - no runtime call needed. A token the map does not carry belongs to another
81
+ // file (App.css, a `:global()` escape hatch, a parent's class) and passes through untouched.
65
82
  //
66
83
  // A dynamic :class="expr" (bind DirectiveNode, prop.type === 7) targeting `class`: by the time
67
84
  // our transform runs, Vue's own transformExpression has already turned prop.exp into either a
68
85
  // COMPOUND_EXPRESSION (type 8) or, for a bare identifier binding, a SIMPLE_EXPRESSION (type 4).
69
86
  // createCompoundExpression wraps the original exp node as-is inside
70
- // scopeClassName(<original>, __localScopedClassNames, __scopeId), so codegen reproduces the
71
- // original expression unchanged - no per-shape branching needed, and a fully opaque runtime
72
- // value (`:class="dynamicClass"`) still defers correctly to scopeClassName's own token matching.
73
- function createScopeClassNodeTransform(localNames, scopeId) {
87
+ // renameClassTokens(<original>, __scopedClassNames), so codegen reproduces the original
88
+ // expression unchanged - no per-shape branching needed, and a fully opaque runtime value
89
+ // (`:class="dynamicClass"`) still resolves against the same map at runtime.
90
+ function createScopeClassNodeTransform(renames) {
74
91
  return function scopeClassNodeTransform(node) {
75
92
  if (node.type !== 1 /* NodeTypes.ELEMENT */) return;
76
93
 
77
94
  for (const prop of node.props) {
78
- if (prop.type === 6 /* NodeTypes.ATTRIBUTE */ && prop.name === 'class' && prop.value) {
95
+ if (
96
+ prop.type === 6 /* NodeTypes.ATTRIBUTE */ &&
97
+ prop.name === 'class' &&
98
+ prop.value
99
+ ) {
79
100
  prop.value.content = prop.value.content
80
101
  .split(/\s+/)
81
102
  .filter(Boolean)
82
- .map(token => {
83
- const camelToken = kebabToCamel(token);
84
- return localNames.has(camelToken) ? `${camelToken}__${scopeId}` : camelToken;
85
- })
103
+ .map(token => renames.get(token) ?? token)
86
104
  .join(' ');
87
105
  continue;
88
106
  }
@@ -96,7 +114,7 @@ function createScopeClassNodeTransform(localNames, scopeId) {
96
114
  prop.exp
97
115
  ) {
98
116
  prop.exp = createCompoundExpression(
99
- ['__scopeClass(', prop.exp, ', __localScopedClassNames, __scopeId)'],
117
+ ['__scopeClass(', prop.exp, ', __scopedClassNames)'],
100
118
  prop.exp.loc,
101
119
  );
102
120
  }
@@ -104,6 +122,300 @@ function createScopeClassNodeTransform(localNames, scopeId) {
104
122
  };
105
123
  }
106
124
 
125
+ // Which primitives lower, and to which tag, comes from the SHARED SPEC — this file used to keep
126
+ // its own copy of the map, one of four, and the copies had already drifted apart (Vue folded
127
+ // `id` -> `nativeID` on neither tag while Solid did both and Svelte did one).
128
+ //
129
+ // Only the `intrinsic` field is read here. `aliases` deliberately is NOT: Vue applies that fold at
130
+ // RUNTIME in `src/renderer/index.ts`'s patchProp, because Vue reaches a node by four paths and
131
+ // compile time covers only two of them. The spec says so at its `aliases` declaration; a transform
132
+ // does not own a fold just because the spec describes it.
133
+ //
134
+ // WHY lower at all, measured 2026-08-23: Vue charges a full component instance
135
+ // (createComponentInstance + initProps + initSlots + setupRenderEffect) for a functional component
136
+ // too, and our benchmark row is 7 instances (Row + View + 3 Text + 2 Pressable) where React pays 7
137
+ // far cheaper fibers. That is the whole reason Vue is level with React on the web (a <div> is an
138
+ // element) and 1.8x behind here. Same 36 001-node tree with View/Text lowered: 138.0 -> 118.5 ms,
139
+ // 14.1%. It also lets Vue hoist static props out of the render fn and emit patch flags, neither of
140
+ // which a component gets.
141
+ const {
142
+ HOST_PRIMITIVES,
143
+ } = require('@symbiote-native/components/host-primitives');
144
+
145
+
146
+ const LOWERABLE_HOST_PRIMITIVES = new Map(
147
+ Object.entries(HOST_PRIMITIVES).map(([name, spec]) => [
148
+ name,
149
+ {
150
+ intrinsic: spec.intrinsic,
151
+ observesState: spec.observesState === true,
152
+ intrinsicWhen: spec.intrinsicWhen,
153
+ },
154
+ ]),
155
+ );
156
+
157
+ // Lower ONLY a name this file actually imported from us. Matching on the bare tag would silently
158
+ // rewrite an app's own <View>, and the failure would be invisible: the app's component never
159
+ // renders and the intrinsic paints an empty box. Alias-aware, so `import { View as RNView }`
160
+ // lowers RNView and leaves View alone.
161
+ const SYMBIOTE_VUE_IMPORT =
162
+ /import\s*\{([^}]*)\}\s*from\s*['"]@symbiote-native\/vue['"]/g;
163
+
164
+ function lowerableTagsIn(descriptor) {
165
+ const source =
166
+ (descriptor.scriptSetup?.content ?? '') +
167
+ (descriptor.script?.content ?? '');
168
+ const tags = new Map();
169
+ for (const match of source.matchAll(SYMBIOTE_VUE_IMPORT)) {
170
+ for (const clause of match[1].split(',')) {
171
+ const [imported, local] = clause.trim().split(/\s+as\s+/);
172
+ const entry = LOWERABLE_HOST_PRIMITIVES.get(imported);
173
+ if (entry !== undefined) tags.set(local ?? imported, entry);
174
+ }
175
+ }
176
+ return tags;
177
+ }
178
+
179
+ // A primitive that OWNS STATE (Pressable's pressed) can only lower when the template does not read
180
+ // that state — an element has no instance to read it from, so the machine moves to the engine node
181
+ // and there is nothing left to hand a template. Two authoring shapes ask for it, and both are
182
+ // visible here:
183
+ //
184
+ // :style="fnStyle" the style is a function of press state
185
+ // v-slot="{ pressed }" / #default=… the children are a function of press state
186
+ //
187
+ // REFUSING IS ALWAYS SAFE and under-refusing never is: a refused element keeps today's behaviour
188
+ // and merely misses the win, while a wrongly-lowered one is a button that stops responding
189
+ // visually, on device, with every test green. Every judgement below therefore defaults to refusing
190
+ // whenever it cannot PROVE the safe case.
191
+ //
192
+ // The one thing that can be proven is an object literal. `:style="{ borderColor: c }"` provably is
193
+ // not a function; `:style="anything else"` cannot be told apart from one at compile time, because
194
+ // the compiler sees an expression, never its value. That asymmetry is also what makes the
195
+ // ActionButton migration work: move `opacity` into `.action-button:active` and what remains is an
196
+ // object literal, so the same button lowers without the transform learning anything new.
197
+ function refusesLowering(node) {
198
+ for (const prop of node.props) {
199
+ // REFUSAL_CATEGORIES.instanceBoundDirective, and it is checked before the directive filter
200
+ // because `ref="x"` is a plain ATTRIBUTE while `:ref="x"` is a directive. `ref` on a component
201
+ // yields the component instance and on an element the host node, so lowering silently changes
202
+ // what the app receives. Found by the shared verdict table, not by a Vue test.
203
+ if (prop.type === 6 /* ATTRIBUTE */ && prop.name === 'ref') return true;
204
+ if (prop.type !== 7 /* NodeTypes.DIRECTIVE */) continue;
205
+ if (prop.name === 'bind' && prop.arg?.content === 'ref') return true;
206
+
207
+ // `v-bind="obj"` — a whole attribute set this pass cannot enumerate, so it may be hiding a
208
+ // functional `style`. REFUSAL_CATEGORIES.unreadableAttributeSet.
209
+ if (prop.name === 'bind' && !prop.arg) return true;
210
+
211
+ // REFUSAL_CATEGORIES.stateInTemplate no longer fires on a `style`: every shape is covered, by
212
+ // an inert passthrough, a direct call, or the runtime helper. The one thing still unreachable
213
+ // is an expression whose source text cannot be reconstructed at all, which cannot occur in a
214
+ // file that compiles — `styleEmissionKind` returning undefined keeps the component if it does.
215
+ if (
216
+ prop.name === 'bind' &&
217
+ prop.arg?.content === 'style' &&
218
+ !isInertValueExpression(prop.exp) &&
219
+ styleEmissionKind(prop.exp) === undefined
220
+ )
221
+ return true;
222
+
223
+ // `v-slot="{ pressed }"` on the element itself. REFUSAL_CATEGORIES.renderPropChild.
224
+ if (prop.name === 'slot' && prop.exp) return true;
225
+ }
226
+
227
+ // ANY `<template>` child, with or without a slot argument — not only the `#default="{ pressed }"`
228
+ // form this rule was written for. Measured through the real compileSfc: lowering an element that
229
+ // has one makes codegen throw `Codegen node is missing for element/if/for node`, because the
230
+ // element path never builds a codegen node for a child the slot path owns. Loud rather than
231
+ // silent, but still a build failure in an app that writes a perfectly ordinary template, so it
232
+ // refuses here. This is the one place the spec's "zero-arity is not a refusal" carve-out does
233
+ // NOT apply: it is about a JSX function child, and an SFC `<template>` child cannot be lowered
234
+ // at all.
235
+ return node.children.some(
236
+ child =>
237
+ child.type === 1 /* ELEMENT */ && child.tagType === 3 /* TEMPLATE */,
238
+ );
239
+ }
240
+
241
+ // An ALLOW-LIST, never a hunt for a function literal, and the shared spec pins all five transforms
242
+ // to this side of it (`REFUSAL_CATEGORIES.stateInTemplate`). `:style="styleFn"` is an identifier at
243
+ // compile time and NO transform can tell whether it holds an object or a function, so only
244
+ // provably inert value shapes lower — object, array, string/number literal, template literal — and
245
+ // everything else refuses. A narrow "refuse a function literal" reading passes every obvious test
246
+ // and fails on the one call site that hoists its style into a variable, which is exactly what
247
+ // ActionButton does.
248
+ //
249
+ // Which AST node carries the source is NOT what intuition says, and the probe that established it
250
+ // is why this reads both shapes. `transformExpression` runs before this pass and rewrites every
251
+ // setup binding, so `:style="{ borderColor: c }"` arrives as a COMPOUND (type 8) with children
252
+ // `['{ borderColor: ', '$setup.c', ' }']`, while the dangerous `:style="fnStyle"` arrives SIMPLE
253
+ // (type 4) as `'$setup.fnStyle'`. Testing `type === 4` alone therefore refuses exactly the shape it
254
+ // should allow — the first version of this did that, and the probe caught it before any test ran.
255
+ // An object literal with nothing to rewrite (`{ borderColor: 1 }`) stays SIMPLE, so both matter.
256
+ const INERT_VALUE_HEAD = /^[{['"`\d]/;
257
+
258
+ function isInertValueExpression(exp) {
259
+ const head = leadingSource(exp);
260
+ return head !== undefined && INERT_VALUE_HEAD.test(head.trimStart());
261
+ }
262
+
263
+ // The literal source text the expression opens with. An arrow never opens with one of the
264
+ // characters above — it opens with `(` or its parameter name — so reading only the head is sound.
265
+ function leadingSource(exp) {
266
+ if (exp?.type === 4 /* SIMPLE_EXPRESSION */) return exp.content;
267
+ if (exp?.type !== 8 /* COMPOUND_EXPRESSION */) return undefined;
268
+ const head = exp.children?.[0];
269
+ return typeof head === 'string' ? head : undefined;
270
+ }
271
+
272
+ // The FINAL source text of a template expression — what codegen would have emitted. Not
273
+ // `exp.loc.source`, which is the text the AUTHOR wrote: `transformExpression` runs before this pass
274
+ // and rewrites every setup binding, so `loc.source` still says `color` where the compiled render fn
275
+ // must say what the compound's children say. Reading the children is therefore the only correct
276
+ // reconstruction, and re-emitting it verbatim is what keeps the rewrite intact.
277
+ function expressionSource(exp) {
278
+ if (exp === undefined || exp === null) return undefined;
279
+ if (exp.type === 4 /* SIMPLE_EXPRESSION */) return exp.content;
280
+ if (exp.type !== 8 /* COMPOUND_EXPRESSION */) return undefined;
281
+ let source = '';
282
+ for (const child of exp.children) {
283
+ if (typeof child === 'string') {
284
+ source += child;
285
+ continue;
286
+ }
287
+ const nested = expressionSource(child);
288
+ if (nested === undefined) return undefined;
289
+ source += nested;
290
+ }
291
+ return source;
292
+ }
293
+
294
+ // HOW the pair is emitted, never WHETHER the element lowers — every shape lowers. Classified off a
295
+ // real Babel AST so this path and the JSX path cannot drift by reading the same text differently.
296
+ //
297
+ // literal (EXPR)({ pressed: false }) two closures, no read hazard
298
+ // reference typeof (E) === 'function' ? (E)({…}) : (E) E printed twice, but it is a read
299
+ // opaque v-bind="__helper(EXPR)" EXPR printed ONCE — required
300
+ //
301
+ // The third bucket is `REFUSAL_CATEGORIES.emitStyleExpressionOnce`: `getStyle()`, `bag[i]` and
302
+ // `flag ? a : b` change meaning when printed twice. It is not the default because a spread is the
303
+ // only Vue form yielding two props from one evaluation, and a spread costs the element its patch
304
+ // flag — measured on the real compileSfc, `12 /* STYLE, PROPS */` becomes `16 /* FULL_PROPS */`
305
+ // plus a mergeProps on every render. Paid only where a repeated read would actually be wrong.
306
+ function styleEmissionKind(exp) {
307
+ const source = expressionSource(exp);
308
+ if (source === undefined) return undefined;
309
+ let expression;
310
+ try {
311
+ // Wrapped in parens so a bare object or an arrow parses as an EXPRESSION rather than a block.
312
+ // `typescript` because a template in a lang="ts" SFC may carry an annotation on the callback's
313
+ // parameter — which is exactly how the real call sites are written.
314
+ const file = babelParse(`(${source})`, { plugins: ['typescript'] });
315
+ expression = file.program.body[0]?.expression;
316
+ } catch {
317
+ // An expression this parser cannot read is one we cannot classify, so it keeps the component.
318
+ return undefined;
319
+ }
320
+ if (expression === undefined) return undefined;
321
+ if (
322
+ expression.type === 'ArrowFunctionExpression' ||
323
+ expression.type === 'FunctionExpression'
324
+ )
325
+ return 'literal';
326
+ return isCheapReferenceNode(expression) ? 'reference' : 'opaque';
327
+ }
328
+
329
+ function isCheapReferenceNode(node) {
330
+ if (node.type === 'Identifier') return true;
331
+ return (
332
+ node.type === 'MemberExpression' &&
333
+ !node.computed &&
334
+ node.property.type === 'Identifier' &&
335
+ isCheapReferenceNode(node.object)
336
+ );
337
+ }
338
+
339
+
340
+ // The parser has already decided `<View>` is a COMPONENT (capitalized, and a <script setup>
341
+ // binding), so renaming the tag is not enough — isCustomElement is consulted for the ORIGINAL tag
342
+ // and tagType must be flipped in the same pass, before transformElement's exit hook turns the
343
+ // children into withCtx slots. Get either half wrong and codegen emits a component whose children
344
+ // are slots the element path never mounts: a silently empty subtree, which is how the first
345
+ // attempt at this read.
346
+ // REFUSAL_CATEGORIES.dynamicIntrinsicChoice — the SFC twin of babel-lower-host-primitives.cjs's
347
+ // `intrinsicWhenFor`, same rule over a different AST. A primitive whose spec entry carries
348
+ // `intrinsicWhen` selects between TWO Fabric views by one prop's value, and this pass prints a
349
+ // static tag, so it can choose only from a compile-time literal.
350
+ //
351
+ // Why it refuses harder than the value categories do: an unreadable VALUE lands a prop wrong and a
352
+ // later write can still correct it; the wrong choice here commits the wrong native view, which no
353
+ // prop write can move a node between.
354
+ //
355
+ // `multiline="true"` — a static STRING attribute — deliberately does not resolve, even though the
356
+ // component treats it as truthy. `multiline="false"` is truthy too, which is exactly where author
357
+ // intent and runtime behaviour part company; refusing costs the optimisation, guessing costs the
358
+ // view.
359
+ //
360
+ // Returns the tag to emit, or undefined to refuse.
361
+ function intrinsicWhenFor(node, entry) {
362
+ const choice = entry.intrinsicWhen;
363
+ if (choice === undefined) return entry.intrinsic;
364
+
365
+ let resolved = false;
366
+ for (const prop of node.props) {
367
+ // `v-bind="obj"` may carry the selector prop and cannot be enumerated, so it is indistinguish-
368
+ // able from the prop being absent — refuse rather than default to the single-line view.
369
+ if (prop.type === 7 /* DIRECTIVE */ && prop.name === 'bind' && !prop.arg)
370
+ return undefined;
371
+
372
+ if (prop.type === 6 /* ATTRIBUTE */ && prop.name === choice.prop) {
373
+ // A bare `multiline` carries no value, which is the template spelling of `true`. Anything
374
+ // else on a plain attribute is a string.
375
+ if (prop.value === undefined) {
376
+ resolved = true;
377
+ continue;
378
+ }
379
+ return undefined;
380
+ }
381
+
382
+ if (prop.type !== 7 /* DIRECTIVE */) continue;
383
+ if (prop.name !== 'bind' || prop.arg?.content !== choice.prop) continue;
384
+ const source = expressionSource(prop.exp)?.trim();
385
+ if (source !== 'true' && source !== 'false') return undefined;
386
+ resolved = source === 'true';
387
+ }
388
+
389
+ return resolved ? choice.intrinsic : entry.intrinsic;
390
+ }
391
+
392
+ function createHostPrimitiveLowering(tags) {
393
+ return function lowerHostPrimitive(node) {
394
+ if (node.type !== 1 /* NodeTypes.ELEMENT */) return;
395
+ const entry = tags.get(node.tag);
396
+ if (entry === undefined) return;
397
+ // Resolved BEFORE the state refusal and OUTSIDE its `observesState` gate — the two guard
398
+ // different things, and this one applies to a primitive that owns no state. Folding it into
399
+ // refusesLowering would put it behind that flag, where it would never run for TextInput.
400
+ const intrinsic = intrinsicWhenFor(node, entry);
401
+ if (intrinsic === undefined) return;
402
+ if (entry.observesState && refusesLowering(node)) return;
403
+ // NO state-style expansion, deliberately. A functional `style` reaches `routeProp` untouched
404
+ // and the engine resolves it at both values of `pressed` (`isStyleCallback`), so rewriting the
405
+ // attribute into a resting/active pair here would be this transform carrying BEHAVIOUR — what
406
+ // `tests/lowering-transform-carries-no-behaviour.test.ts` exists to keep out.
407
+ //
408
+ // Removing it is also FASTER, which was not the expectation going in. The split emitted TWO
409
+ // props per element and Vue hoists neither — the compiled output builds and invokes the
410
+ // callback twice and allocates two style objects, per element per render. Measured headless
411
+ // through the real compileSfc on 1 000 styled nodes: 5.2/5.1 ms with the split against
412
+ // 4.6/4.5 ms without, with createNode 3002, appendChild 3000, VISITED 3003 and WRITES 4000
413
+ // byte-identical in every arm.
414
+ node.tag = intrinsic;
415
+ node.tagType = 0; /* ElementTypes.ELEMENT */
416
+ };
417
+ }
418
+
107
419
  // A short, stable id per file, used as the SFC scope id regardless of whether the file has
108
420
  // scoped styles - built on css-parser's shared hashFilePath so the algorithm isn't duplicated
109
421
  // against the standalone .module.css compiler's identical need.
@@ -111,6 +423,35 @@ function scopeIdFor(filename) {
111
423
  return 'data-v-' + hashFilePath(filename);
112
424
  }
113
425
 
426
+ // The local the compiled component lands in when a <style module> block needs __cssModules hung
427
+ // off it (compileScript's genDefaultAs). Same role as @vitejs/plugin-vue's own `_sfc_main`.
428
+ const SFC_COMPONENT_VAR = '__sfc__';
429
+
430
+ // Blocks CONCATENATE - each is compiled on its own, so every block's rules restart at order 0 and
431
+ // a later block would tie with an earlier one on the cascade's source-order tie-break, silently
432
+ // reordering equally-specific rules. Renumbering on append gives the file one monotonic sequence,
433
+ // which is what a single stylesheet holding the concatenated blocks would have produced.
434
+ function appendRules(target, rules) {
435
+ for (const rule of rules) {
436
+ target.push({ ...rule, order: target.length });
437
+ }
438
+ }
439
+
440
+ // `combinators` is compile-time-only - the registry matches a rule by token SUBSET and never reads
441
+ // it - so it is stripped rather than shipped in every app bundle. Twin of css-parser's own
442
+ // serializeRules (core/css-parser/src/metro-css-module/index.ts), which does this for a standalone
443
+ // style file.
444
+ function serializeRules(rules) {
445
+ return JSON.stringify(
446
+ rules.map(({ tokens, specificity, order, style }) => ({
447
+ tokens,
448
+ specificity,
449
+ order,
450
+ style,
451
+ })),
452
+ );
453
+ }
454
+
114
455
  // An SFC style block's `lang` attribute names a preprocessor language directly (`lang="scss"`),
115
456
  // unlike a standalone file, which is identified by its extension — so this is its own small
116
457
  // lookup rather than reusing detectLanguage(), which is extension-keyed.
@@ -128,7 +469,9 @@ async function compileStyleBlockContent(style, filename) {
128
469
 
129
470
  const preprocessorLang = SFC_STYLE_LANG_TO_PREPROCESSOR.get(style.lang);
130
471
  if (!preprocessorLang) {
131
- throw new Error(`SFC style lang="${style.lang}" not supported yet — plain CSS only`);
472
+ throw new Error(
473
+ `SFC style lang="${style.lang}" not supported yet — plain CSS only`,
474
+ );
132
475
  }
133
476
 
134
477
  // Sass' `.sass` indented syntax and `.scss` syntax share one compiler entry point that picks
@@ -141,12 +484,33 @@ async function compileStyleBlockContent(style, filename) {
141
484
  }
142
485
 
143
486
  async function compileSfc(src, filename) {
487
+ // `parse` memoizes its descriptor on (source, filename) in a module-global LRU, and the node
488
+ // transforms below MUTATE that descriptor's template AST in place — the lowering one rewrites
489
+ // `node.tag`/`node.tagType`, the scope-class one replaces `prop.exp`. Handing the same object to
490
+ // a second compile therefore walks a half-transformed tree and generation dies with `Codegen
491
+ // node is missing for element/if/for node`.
492
+ //
493
+ // Metro compiles each file once per build, so the cache buys this caller nothing; the only place
494
+ // it was ever exercised is css-parser's golden-corpus determinism check, which compiles one
495
+ // source twice on purpose. That is the ONLY test anywhere that can see this, which is why a
496
+ // transform bug surfaced as a failure in another package's corpus.
497
+ //
498
+ // `clear()` rather than deleting our one key: the key is `source + JSON.stringify(options)`
499
+ // (`@vue/shared`'s genCacheKey), so reproducing it here would couple this file to an internal
500
+ // format across every Vue bump. Clearing a pure memo is always safe — the only cost is a
501
+ // re-parse, which is what we need anyway.
502
+
503
+ parseCache.clear();
144
504
  const { descriptor, errors } = parse(src, { filename });
145
505
  if (errors && errors.length > 0) {
146
- throw new Error(`Vue SFC parse error in ${filename}:\n${errors.map(String).join('\n')}`);
506
+ throw new Error(
507
+ `Vue SFC parse error in ${filename}:\n${errors.map(String).join('\n')}`,
508
+ );
147
509
  }
148
510
  if (descriptor.scriptSetup == null && descriptor.script == null) {
149
- throw new Error(`Vue SFC ${filename} has no <script> / <script setup> block`);
511
+ throw new Error(
512
+ `Vue SFC ${filename} has no <script> / <script setup> block`,
513
+ );
150
514
  }
151
515
 
152
516
  const scopeId = scopeIdFor(filename);
@@ -154,93 +518,118 @@ async function compileSfc(src, filename) {
154
518
  // descriptor.styles is already parsed by @vue/compiler-sfc (one entry per <style> block,
155
519
  // content pre-trimmed, scoped as a plain boolean) - no need to re-extract with a regex.
156
520
  //
157
- // A scoped block's classes get their key SUFFIXED with this file's scopeId (`card` ->
158
- // `card__data-v-xxxxxxxx`) so two components can each define `.card` without colliding in the
159
- // shared global registry - our name-suffix equivalent of Vue's `data-v-hash` attribute (we
160
- // have no DOM/attribute-selector matching). `:global(...)` selectors are exempted from
161
- // suffixing; globalClassNamesIn re-walks the block's selectors to find which keys to exempt.
521
+ // Both scoped forms are ONE mechanism, lightningcss's CSS-Modules renaming, differing only in
522
+ // the pattern string (core/css-parser/src/scoped-classes.ts):
523
+ //
524
+ // <style scoped> [local]__data-v-<hash> compileScopedCss - `.card` -> `card__data-v-h`
525
+ // <style module> [local]__module__<hash> compileCssModule - the same call a standalone
526
+ // .module.css takes, so the two cannot diverge
527
+ // <style> not renamed at all compileCssToRules, classes register globally
162
528
  //
163
- // Multiple blocks cascade last-block-wins, same as CSS, after each block is scoped independently.
529
+ // Renaming is our equivalent of Vue's `data-v-hash` ATTRIBUTE (we have no DOM and no
530
+ // attribute-selector matching, so a scope can only be expressed in the name), and `:global(...)`
531
+ // needs no handling here at all: a name lightningcss did not rename is global by definition.
164
532
  //
165
- // <style module> (CSS Modules) reuses this same suffixing machinery: `.card` still goes
166
- // through parseCSS and registerStyles unchanged, just under a suffixed key - the only new
167
- // output is a name->scopedName object ($style by default) emitted as a preamble const, so
168
- // `:class="$style.card"` passes the already-scoped string straight to resolveClassName's
169
- // exact-match path. Unlike `scoped`, module classes are NEVER auto-applied to a literal
170
- // class="..." (opt-in via $style.x only), so they're kept out of localScopedNames. The
171
- // registry key gets an extra `module` tag (`card__module__<scopeId>` vs scoped's
172
- // `card__<scopeId>`) so a file mixing both kinds can't collide.
173
- const styles = {};
174
- const localScopedNames = new Set();
533
+ // Multiple blocks cascade last-block-wins, same as CSS, after each block is scoped
534
+ // independently — carried by the rules' `order`, which appendRules renumbers across blocks.
535
+ //
536
+ // A module block's classes are NEVER auto-applied to a literal class="..." (opt-in via $style.x
537
+ // only), so they stay out of the template rewriter's name map; its output is the name->scopedName
538
+ // map instead, emitted as a preamble const AND attached to the component as __cssModules.
539
+ const rules = [];
540
+ const scopedClassNames = new Map();
175
541
  const cssModuleBindings = new Map();
176
542
 
177
543
  for (const style of descriptor.styles) {
178
- // Reduces a preprocessor block to plain CSS BEFORE the scoping logic below runs - that
179
- // logic is language-agnostic, it only ever sees parseCSS's plain-CSS output.
544
+ // Reduces a preprocessor block to plain CSS BEFORE the renaming below runs - the renaming is
545
+ // language-agnostic, it only ever sees plain CSS.
180
546
  const content = await compileStyleBlockContent(style, filename);
181
- const parsed = parseCSS(content, { filename });
182
547
 
183
548
  if (style.module) {
184
- const bindingName = typeof style.module === 'string' ? style.module : '$style';
185
- // Scanned against the COMPILED content, not style.content, so it can't drift under
186
- // preprocessor nesting/interpolation.
187
- const exemptFromScope = globalClassNamesIn(content);
188
- const classMap = cssModuleBindings.get(bindingName) ?? {};
189
- for (const [className, props] of Object.entries(parsed)) {
190
- const isExempt = exemptFromScope.has(className);
191
- const registeredName = isExempt ? className : `${className}__module__${scopeId}`;
192
- classMap[className] = registeredName;
193
- styles[registeredName] = { ...styles[registeredName], ...props };
194
- }
195
- cssModuleBindings.set(bindingName, classMap);
549
+ const bindingName =
550
+ typeof style.module === 'string' ? style.module : '$style';
551
+ const compiled = compileCssModule(content, filename);
552
+ appendRules(rules, compiled.rules);
553
+ cssModuleBindings.set(bindingName, {
554
+ ...cssModuleBindings.get(bindingName),
555
+ ...compiled.classMap,
556
+ });
196
557
  } else if (style.scoped) {
197
- const exemptFromScope = globalClassNamesIn(content);
198
- // A compound/descendant selector registers under ONE collapsed key (`.card.big` ->
199
- // `cardBig`) that never appears in the template - the template writes
200
- // `class="card big"` - so the nodeTransform must recognize the individual TOKENS, not
201
- // just the collapsed key.
202
- const tokensByName = classTokensIn(content, { filename });
203
- // ...and a token out of a `:global(...)` payload is the one exception to that: in
204
- // `.card :global(.reset)` the KEY (`cardReset`) is this file's own, because `.card` is,
205
- // but `reset` was written precisely to name markup this file does not own. Suffixing it
206
- // along with the rest of its chain scope-mangles the escape hatch into matching nothing.
207
- const globalTokens = globalClassTokensIn(content, { filename });
208
- for (const [className, props] of Object.entries(parsed)) {
209
- const isExempt = exemptFromScope.has(className);
210
- const registeredName = isExempt ? className : `${className}__${scopeId}`;
211
- if (!isExempt) {
212
- localScopedNames.add(className);
213
- for (const token of tokensByName.get(className) ?? []) {
214
- if (!globalTokens.has(token)) localScopedNames.add(token);
215
- }
216
- }
217
- styles[registeredName] = { ...styles[registeredName], ...props };
218
- }
558
+ const compiled = compileScopedCss(content, {
559
+ filename,
560
+ pattern: `[local]__${scopeId}`,
561
+ });
562
+ appendRules(rules, compiled.rules);
563
+ for (const [name, renamed] of compiled.names)
564
+ scopedClassNames.set(name, renamed);
219
565
  } else {
220
- for (const [className, props] of Object.entries(parsed)) {
221
- styles[className] = { ...styles[className], ...props };
222
- }
566
+ appendRules(rules, compileCssToRules(content, { filename }).rules);
223
567
  }
224
568
  }
225
569
 
226
- // Skipped entirely (not even passed to the compiler) when nothing in this file is scoped, so
227
- // a .vue with only unscoped/no styles compiles with zero added runtime cost.
570
+ // Scoped-class rewriting is skipped entirely (not even passed to the compiler) when nothing in
571
+ // this file is scoped, so a .vue with only unscoped/no styles adds no runtime cost. Host-
572
+ // primitive lowering is independent of it and applies whenever the file imports one.
573
+ const lowerableTags = lowerableTagsIn(descriptor);
574
+ const nodeTransforms = [];
575
+ if (lowerableTags.size > 0)
576
+ nodeTransforms.push(
577
+ createHostPrimitiveLowering(lowerableTags),
578
+ );
579
+ if (scopedClassNames.size > 0)
580
+ nodeTransforms.push(createScopeClassNodeTransform(scopedClassNames));
581
+ // `.values()` yields the SPEC ENTRIES, so this must map to `.intrinsic` — a Set of entry objects
582
+ // makes `loweredTags.has('symbiote-pressable')` permanently false, and the failure is not the
583
+ // obvious one. Pass 1 rewrites the tag and forces tagType, so the file compiles; `@vue/compiler-
584
+ // sfc` then caches the descriptor by (source, filename) and pass 2 walks the ALREADY-REWRITTEN
585
+ // AST, where neither `lowerableTags` nor a broken `loweredTags` recognises `symbiote-pressable`.
586
+ // The node is then an unknown component with no codegen node and generation throws
587
+ // `Codegen node is missing for element/if/for node` — in the only test anywhere that compiles one
588
+ // source twice, which is css-parser's golden-corpus determinism check, i.e. nowhere near here.
589
+ // BOTH tags a spec entry can emit, not just its default — a primitive with an `intrinsicWhen`
590
+ // has a second intrinsic, and the paragraph above is about exactly what happens to a tag missing
591
+ // from this set.
592
+ //
593
+ // HONEST STATUS: extending the guard to the second tag is UNWITNESSED. Removing it leaves every
594
+ // test in the repo green, including a case built specifically to catch it by compiling one source
595
+ // twice. The failure needs pass 2 to walk a rewritten AST, and the only place that happens is
596
+ // css-parser's golden-corpus determinism check — which will not reach this until a corpus file
597
+ // renders a multiline TextInput. Kept because the hazard it extends is documented and cost a real
598
+ // failure once; not kept because a test proves it.
599
+ const loweredTags = new Set(
600
+ [...lowerableTags.values()].flatMap(entry =>
601
+ entry.intrinsicWhen === undefined
602
+ ? [entry.intrinsic]
603
+ : [entry.intrinsic, entry.intrinsicWhen.intrinsic],
604
+ ),
605
+ );
228
606
  const templateOptions =
229
- localScopedNames.size > 0
607
+ nodeTransforms.length > 0
230
608
  ? {
231
609
  compilerOptions: {
232
- nodeTransforms: [createScopeClassNodeTransform(localScopedNames, scopeId)],
610
+ nodeTransforms,
611
+ isCustomElement: tag =>
612
+ lowerableTags.has(tag) || loweredTags.has(tag),
233
613
  },
234
614
  }
235
615
  : undefined;
236
616
 
237
617
  // inlineTemplate folds the <template> render fn into setup(): one module, one `export
238
618
  // default`. Only valid with <script setup>, which the canary uses.
619
+ //
620
+ // genDefaultAs turns that `export default {...}` into `const __sfc__ = {...}`, so a <style
621
+ // module> block can hang __cssModules off the component OPTIONS object before the module
622
+ // exports it (the same thing @vitejs/plugin-vue does). Vue resolves a template's `$style` /
623
+ // `classes` off `instance.type.__cssModules`, NOT off module scope, so without this the
624
+ // emitted const is unreachable from the template and `$style.card` throws at render. Only
625
+ // requested when a module block exists, so every other .vue file keeps identical output.
626
+ const hasCssModules = cssModuleBindings.size > 0;
239
627
  const compiled = compileScript(descriptor, {
240
628
  id: scopeId,
241
629
  inlineTemplate: true,
242
630
  templateOptions,
243
631
  fs: compileScriptFs,
632
+ ...(hasCssModules ? { genDefaultAs: SFC_COMPONENT_VAR } : {}),
244
633
  });
245
634
  // Retargets every Vue import (compiler-injected helpers AND the user's own `from 'vue'`) at
246
635
  // the runtime-helpers shim - no vue/runtime-dom in a native bundle.
@@ -249,35 +638,47 @@ async function compileSfc(src, filename) {
249
638
  'from "@symbiote-native/vue/runtime-helpers"',
250
639
  );
251
640
 
252
- if (Object.keys(styles).length === 0) return code;
641
+ if (rules.length === 0 && !hasCssModules) return code;
253
642
 
254
- // Only a scoped file needs scopeClassName + its two per-file constants, so these stay
643
+ // Only a scoped file needs the runtime rename helper and its per-file map, so these stay
255
644
  // unimported for every non-scoped .vue file.
256
645
  const engineImports =
257
- localScopedNames.size > 0 ? 'registerStyles, scopeClassName as __scopeClass' : 'registerStyles';
646
+ scopedClassNames.size > 0
647
+ ? 'registerRules, renameClassTokens as __scopeClass'
648
+ : 'registerRules';
258
649
 
259
- const preamble = [`registerStyles(${JSON.stringify(styles)});`];
260
- if (localScopedNames.size > 0) {
650
+ const preamble = [
651
+ `import { ${engineImports} } from '@symbiote-native/engine';`,
652
+ `registerRules(${serializeRules(rules)});`,
653
+ ];
654
+ if (scopedClassNames.size > 0) {
261
655
  preamble.push(
262
- `const __localScopedClassNames = new Set(${JSON.stringify([...localScopedNames])});`,
263
- `const __scopeId = ${JSON.stringify(scopeId)};`,
656
+ `const __scopedClassNames = ${JSON.stringify(Object.fromEntries(scopedClassNames))};`,
264
657
  );
265
658
  }
266
659
  // Each <style module> binding becomes a top-level const holding its name->scopedName map,
267
- // placed before the compiled `export default {...}` so it's a closed-over module-scope
268
- // variable usable both from the inlined template and from <script setup> code itself.
660
+ // placed before the component so it's a closed-over module-scope variable usable from
661
+ // <script setup> code itself; the __cssModules tail below is what makes the TEMPLATE see it.
269
662
  for (const [bindingName, classMap] of cssModuleBindings) {
270
663
  preamble.push(`const ${bindingName} = ${JSON.stringify(classMap)};`);
271
664
  }
272
665
 
273
- return (
274
- [`import { ${engineImports} } from '@symbiote-native/engine';`, ...preamble, code].join('\n') +
275
- '\n'
276
- );
666
+ const parts = [preamble.join('\n'), code];
667
+ if (hasCssModules) {
668
+ const bindings = [...cssModuleBindings.keys()]
669
+ .map(name => `${JSON.stringify(name)}: ${name}`)
670
+ .join(', ');
671
+ parts.push(
672
+ `${SFC_COMPONENT_VAR}.__cssModules = { ${bindings} };`,
673
+ `export default ${SFC_COMPONENT_VAR};`,
674
+ );
675
+ }
676
+
677
+ return parts.join('\n') + '\n';
277
678
  }
278
679
 
279
680
  // Exported separately from `transform` so tests can assert on the compiled SFC output
280
- // (imports, injected `registerStyles` call) without driving the full upstream RN Babel preset.
681
+ // (imports, injected `registerRules` call) without driving the full upstream RN Babel preset.
281
682
  module.exports.compileSfc = compileSfc;
282
683
 
283
684
  // Async uniformly, including branches that never touch a preprocessor: compileSfc() itself is