@symbiote-native/vue 1.0.0 → 2.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 (110) hide show
  1. package/README.md +9 -9
  2. package/babel-jsx.cjs +7 -8
  3. package/build/components/activity-indicator-props.d.ts +5 -0
  4. package/build/components/activity-indicator-props.js +1 -0
  5. package/build/components/button-props.d.ts +5 -0
  6. package/build/components/button-props.js +1 -0
  7. package/build/components/flat-list/index.d.ts +3 -3
  8. package/build/components/flat-list/index.js +2 -2
  9. package/build/components/image-background-props.d.ts +7 -0
  10. package/build/components/image-background-props.js +1 -0
  11. package/build/components/image-props.d.ts +5 -0
  12. package/build/components/image-props.js +5 -0
  13. package/build/components/{input-accessory-view.d.ts → input-accessory-view-props.d.ts} +1 -3
  14. package/build/components/input-accessory-view-props.js +5 -0
  15. package/build/components/keyboard-avoiding-view.js +3 -3
  16. package/build/components/modal/index.js +1 -1
  17. package/build/components/pressable-props.d.ts +29 -0
  18. package/build/components/pressable-props.js +12 -0
  19. package/build/components/refresh-control-props.d.ts +16 -0
  20. package/build/components/refresh-control-props.js +9 -0
  21. package/build/components/safe-area-view-props.d.ts +6 -0
  22. package/build/components/safe-area-view-props.js +5 -0
  23. package/build/components/scroll-view/{shared.d.ts → scroll-view-props.d.ts} +4 -27
  24. package/build/components/scroll-view/scroll-view-props.js +1 -0
  25. package/build/components/section-list/index.d.ts +3 -3
  26. package/build/components/switch/switch-props.d.ts +6 -0
  27. package/build/components/switch/switch-props.js +1 -0
  28. package/build/components/text-input/text-input-props.d.ts +5 -0
  29. package/build/components/text-input/text-input-props.js +1 -0
  30. package/build/components/text-props.d.ts +24 -0
  31. package/build/components/text-props.js +6 -0
  32. package/build/components/touchable-native-feedback-props.d.ts +6 -0
  33. package/build/components/touchable-native-feedback-props.js +1 -0
  34. package/build/components/touchable-props.d.ts +15 -0
  35. package/build/components/touchable-props.js +1 -0
  36. package/build/components/touchable-without-feedback-props.d.ts +3 -0
  37. package/build/components/touchable-without-feedback-props.js +1 -0
  38. package/build/components/view-props.d.ts +29 -0
  39. package/build/components/view-props.js +5 -0
  40. package/build/components/virtualized-list/index.d.ts +3 -3
  41. package/build/components/virtualized-list/index.js +42 -37
  42. package/build/components/virtualized-section-list/index.d.ts +3 -3
  43. package/build/descriptor-to-vue.js +2 -2
  44. package/build/index.d.ts +24 -27
  45. package/build/index.js +31 -16
  46. package/build/intrinsic-elements.d.ts +19 -0
  47. package/build/intrinsic-elements.js +1 -0
  48. package/build/jsx-runtime.d.ts +67 -0
  49. package/build/jsx-runtime.js +1 -0
  50. package/build/modules/animated/index.d.ts +3 -1
  51. package/build/modules/animated/index.js +20 -19
  52. package/build/modules/image/index.d.ts +3 -0
  53. package/build/modules/image/index.js +13 -0
  54. package/build/register.d.ts +1 -1
  55. package/build/register.js +13 -33
  56. package/build/renderer/index.js +58 -7
  57. package/build/runtime-helpers/index.d.ts +10 -1
  58. package/build/runtime-helpers/index.js +47 -11
  59. package/intrinsic-tags.cjs +52 -0
  60. package/metro-vue-transformer.cjs +55 -345
  61. package/package.json +12 -8
  62. package/babel-lower-host-primitives.cjs +0 -225
  63. package/build/components/activity-indicator/index.android.d.ts +0 -4
  64. package/build/components/activity-indicator/index.android.js +0 -10
  65. package/build/components/activity-indicator/index.d.ts +0 -1
  66. package/build/components/activity-indicator/index.ios.d.ts +0 -4
  67. package/build/components/activity-indicator/index.ios.js +0 -10
  68. package/build/components/activity-indicator/index.js +0 -4
  69. package/build/components/activity-indicator/shared.d.ts +0 -8
  70. package/build/components/activity-indicator/shared.js +0 -48
  71. package/build/components/button.d.ts +0 -14
  72. package/build/components/button.js +0 -61
  73. package/build/components/image-background.d.ts +0 -9
  74. package/build/components/image-background.js +0 -103
  75. package/build/components/image.d.ts +0 -9
  76. package/build/components/image.js +0 -90
  77. package/build/components/input-accessory-view.js +0 -41
  78. package/build/components/pressable.d.ts +0 -58
  79. package/build/components/pressable.js +0 -233
  80. package/build/components/refresh-control.d.ts +0 -20
  81. package/build/components/refresh-control.js +0 -47
  82. package/build/components/safe-area-view.d.ts +0 -9
  83. package/build/components/safe-area-view.js +0 -33
  84. package/build/components/scroll-view/index.android.d.ts +0 -4
  85. package/build/components/scroll-view/index.android.js +0 -64
  86. package/build/components/scroll-view/index.d.ts +0 -1
  87. package/build/components/scroll-view/index.ios.d.ts +0 -4
  88. package/build/components/scroll-view/index.ios.js +0 -17
  89. package/build/components/scroll-view/index.js +0 -4
  90. package/build/components/scroll-view/shared.js +0 -271
  91. package/build/components/scroll-view/sticky-header.d.ts +0 -7
  92. package/build/components/scroll-view/sticky-header.js +0 -208
  93. package/build/components/switch/index.android.d.ts +0 -12
  94. package/build/components/switch/index.android.js +0 -13
  95. package/build/components/switch/index.d.ts +0 -2
  96. package/build/components/switch/index.ios.d.ts +0 -12
  97. package/build/components/switch/index.ios.js +0 -11
  98. package/build/components/switch/index.js +0 -5
  99. package/build/components/switch/shared.d.ts +0 -23
  100. package/build/components/switch/shared.js +0 -126
  101. package/build/components/text-input/index.d.ts +0 -24
  102. package/build/components/text-input/index.js +0 -260
  103. package/build/components/touchable-native-feedback.d.ts +0 -24
  104. package/build/components/touchable-native-feedback.js +0 -82
  105. package/build/components/touchable.d.ts +0 -50
  106. package/build/components/touchable.js +0 -281
  107. package/build/components.d.ts +0 -53
  108. package/build/components.js +0 -45
  109. package/build/state-style.d.ts +0 -1
  110. package/build/state-style.js +0 -13
@@ -16,16 +16,8 @@ const {
16
16
  parseCache,
17
17
  compileScript,
18
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
19
  } = require('@vue/compiler-sfc');
25
- const {
26
- createCompoundExpression,
27
- createSimpleExpression,
28
- } = require('@vue/compiler-core');
20
+ const { createCompoundExpression } = require('@vue/compiler-core');
29
21
 
30
22
  // A bare-specifier type import (`import type { X } from '@symbiote-native/navigation/vue'`) needs
31
23
  // real node_modules resolution to turn the specifier into a file path - compileScript's own `fs`
@@ -122,299 +114,38 @@ function createScopeClassNodeTransform(renames) {
122
114
  };
123
115
  }
124
116
 
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;
117
+ // The parser's own condense() collapses a text node's multi-line whitespace to a single boundary
118
+ // space but never trims it: `<text>\n plain\n</text>` compiles to " plain " instead of "plain" -
119
+ // a real leading/trailing space on the rendered RN Text. Babel's JSX whitespace cleaner
120
+ // (cleanJSXElementLiteralChild, used by every other adapter) DOES trim this; Vue's does not, so we
121
+ // do it here, once, for every template. Matches Babel's own rule: only a text node whose SOURCE
122
+ // crosses a line break is touched, so a genuinely single-line `<text>Score: {{ x }}</text>` keeps
123
+ // its intentional trailing space untouched (see examples/vue-sfc's CompoundClassDemo.vue for the
124
+ // device-measured bug this closes).
125
+ function trimReflowedTextNodeTransform(node) {
126
+ if (node.type !== 1 /* NodeTypes.ELEMENT */ || node.children.length === 0)
127
+ return;
128
+ const first = node.children[0];
129
+ if (
130
+ first.type === 2 /* NodeTypes.TEXT */ &&
131
+ first.loc.source.includes('\n') &&
132
+ first.content.startsWith(' ')
133
+ ) {
134
+ first.content = first.content.slice(1);
319
135
  }
320
- if (expression === undefined) return undefined;
136
+ const last = node.children[node.children.length - 1];
321
137
  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';
138
+ last.type === 2 /* NodeTypes.TEXT */ &&
139
+ last.loc.source.includes('\n') &&
140
+ last.content.endsWith(' ')
141
+ ) {
142
+ last.content = last.content.slice(0, -1);
387
143
  }
388
-
389
- return resolved ? choice.intrinsic : entry.intrinsic;
390
144
  }
391
145
 
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
- }
146
+ // Shared with ./babel-jsx.cjs: Vue's two compilers must give one answer to element-vs-component.
147
+ const INTRINSIC_TAGS = require('./intrinsic-tags.cjs');
148
+
418
149
 
419
150
  // A short, stable id per file, used as the SFC scope id regardless of whether the file has
420
151
  // scoped styles - built on css-parser's shared hashFilePath so the algorithm isn't duplicated
@@ -485,10 +216,9 @@ async function compileStyleBlockContent(style, filename) {
485
216
 
486
217
  async function compileSfc(src, filename) {
487
218
  // `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`.
219
+ // scope-class transform below MUTATES that descriptor's template AST in place, replacing
220
+ // `prop.exp`. Handing the same object to a second compile therefore walks a half-transformed
221
+ // tree and generation dies with `Codegen node is missing for element/if/for node`.
492
222
  //
493
223
  // Metro compiles each file once per build, so the cache buys this caller nothing; the only place
494
224
  // it was ever exercised is css-parser's golden-corpus determinism check, which compiles one
@@ -501,7 +231,15 @@ async function compileSfc(src, filename) {
501
231
  // re-parse, which is what we need anyway.
502
232
 
503
233
  parseCache.clear();
504
- const { descriptor, errors } = parse(src, { filename });
234
+ // element-vs-component is decided by the PARSER, not by the later `isCustomElement` in
235
+ // templateOptions — `compileScript` reuses this descriptor's already-parsed AST, where `tagType`
236
+ // is already fixed. So a hand-written `<pressable>` needs the answer here or it stays a component
237
+ // whatever templateOptions says, which is how it read until 2026-09-07:
238
+ // `resolveComponent("pressable")`, a blank subtree with no error.
239
+ const { descriptor, errors } = parse(src, {
240
+ filename,
241
+ templateParseOptions: { isCustomElement: tag => INTRINSIC_TAGS.has(tag) },
242
+ });
505
243
  if (errors && errors.length > 0) {
506
244
  throw new Error(
507
245
  `Vue SFC parse error in ${filename}:\n${errors.map(String).join('\n')}`,
@@ -568,51 +306,23 @@ async function compileSfc(src, filename) {
568
306
  }
569
307
 
570
308
  // 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
- );
309
+ // this file is scoped, so a .vue with only unscoped/no styles adds no runtime cost.
310
+ const nodeTransforms = [trimReflowedTextNodeTransform];
579
311
  if (scopedClassNames.size > 0)
580
312
  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
- );
606
- const templateOptions =
607
- nodeTransforms.length > 0
608
- ? {
609
- compilerOptions: {
610
- nodeTransforms,
611
- isCustomElement: tag =>
612
- lowerableTags.has(tag) || loweredTags.has(tag),
613
- },
614
- }
615
- : undefined;
313
+ // UNCONDITIONAL, and covering EVERY intrinsic rather than the ones this file happens to import.
314
+ // Both narrowings were live until 2026-09-07 and both broke a hand-written tag: a file importing
315
+ // no wrapper got no `isCustomElement` at all, and a file importing one got an answer scoped to
316
+ // that import, so `<pressable>` written by hand compiled to `resolveComponent("pressable")` —
317
+ // a blank subtree with no error — whichever way the file was written. `<switch v-model>` failed
318
+ // louder: `@vue/compiler-dom`'s transformModel rejects v-model on an element that is neither
319
+ // input/textarea/select nor a custom element, so it was a hard compile error.
320
+ const templateOptions = {
321
+ compilerOptions: {
322
+ ...(nodeTransforms.length > 0 ? { nodeTransforms } : {}),
323
+ isCustomElement: tag => INTRINSIC_TAGS.has(tag),
324
+ },
325
+ };
616
326
 
617
327
  // inlineTemplate folds the <template> render fn into setup(): one module, one `export
618
328
  // default`. Only valid with <script setup>, which the canary uses.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@symbiote-native/vue",
3
- "version": "1.0.0",
3
+ "version": "2.0.0",
4
4
  "description": "SymbioteNative's Vue adapter — a @vue/runtime-core createRenderer driving real native iOS/Android views through the same engine as the React adapter.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -45,15 +45,19 @@
45
45
  "./metro-css-parser": "./metro-css-parser.cjs",
46
46
  "./metro-vue-transformer": "./metro-vue-transformer.cjs",
47
47
  "./babel-jsx": "./babel-jsx.cjs",
48
- "./state-style": {
49
- "types": "./build/state-style.d.ts",
50
- "default": "./build/state-style.js"
48
+ "./jsx-runtime": {
49
+ "types": "./build/jsx-runtime.d.ts",
50
+ "default": "./build/jsx-runtime.js"
51
+ },
52
+ "./jsx-dev-runtime": {
53
+ "types": "./build/jsx-runtime.d.ts",
54
+ "default": "./build/jsx-runtime.js"
51
55
  }
52
56
  },
53
57
  "files": [
54
58
  "babel-jsx.cjs",
55
- "babel-lower-host-primitives.cjs",
56
59
  "build",
60
+ "intrinsic-tags.cjs",
57
61
  "metro-css-parser.cjs",
58
62
  "metro-vue-transformer.cjs"
59
63
  ],
@@ -66,17 +70,17 @@
66
70
  "@vue/compiler-sfc": "^3.5.13",
67
71
  "@vue/runtime-core": "^3.5.13",
68
72
  "typescript": "~6.0.0",
69
- "@symbiote-native/components": "1.0.0",
73
+ "@symbiote-native/components": "2.0.0",
70
74
  "@symbiote-native/css-parser": "0.5.0"
71
75
  },
72
76
  "peerDependencies": {
73
77
  "vue": ">=3.5.0",
74
78
  "react-native": ">=0.86",
75
- "@symbiote-native/engine": "^0.4.0"
79
+ "@symbiote-native/engine": "^0.5.0"
76
80
  },
77
81
  "devDependencies": {
78
82
  "@babel/core": "^7.25.2",
79
- "@symbiote-native/engine": "0.4.0",
83
+ "@symbiote-native/engine": "0.5.0",
80
84
  "@symbiote-native/test-utils": "0.3.0"
81
85
  },
82
86
  "scripts": {