@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
package/build/register.js CHANGED
@@ -1,35 +1,15 @@
1
- // Side-effect ONLY. This module exports nothing, on purpose.
1
+ // Side-effect ONLY. Exports nothing, and must never be re-exported from the barrel.
2
2
  //
3
- // A lowered `<symbiote-pressable>` is an element, so the press machine lives on the engine node
4
- // instead of inside a component — and it gets there by `registerPressableBehavior()` having run
5
- // before the first node of that tag is created. The entry imports this file as a bare
6
- // `import './register';`.
3
+ // The fourteen calls live once, in `@symbiote-native/components/register` — they were identical in
4
+ // all five adapters, and each copy carried a migration ledger justifying a call against a wrapper
5
+ // that no longer exists. What is per-adapter is only the SPELLING of the import below: every
6
+ // `*-tag.test.*` here reaches the registry through `import './register';`, and this adapter's own
7
+ // barrel guard pins that line.
7
8
  //
8
- // WHY IT CANNOT BE A RE-EXPORT, and why this file has no exports to re-export in the first place.
9
- // Metro turns on `inlineRequires` for production only: it moves a `require()` down to the first
10
- // place its binding is USED, and a barrel's `export { X } from './x'` compiles to a lazy getter. A
11
- // module reached only that way never evaluates unless something names X as a VALUE, so its
12
- // registration silently never happens — in RELEASE builds only, invisible to tsc, to vitest, and
13
- // even to grepping the bundle, because the code IS bundled and simply never runs. A bare
14
- // `import './register';` placed NEXT TO a re-export of the same specifier does not help either:
15
- // Babel merges the two into one dependency and the merged one stays lazy. See CLAUDE.md, "Never
16
- // make correctness depend on a module's load-time side effect".
17
- //
18
- // `adapters/vue/src/index.test.ts` pins the barrel against both halves of that.
19
- import { registerImageBehavior, registerInputAccessoryViewBehavior, registerPressableBehavior, registerSwitchBehavior, registerTextInputBehavior, } from '@symbiote-native/components';
20
- registerPressableBehavior();
21
- // Only the LOWERED tags carry this — the wrapper renders `symbiote-text-input-managed` and
22
- // keeps running its own lifecycle. One owner per node; see `component-names/shared.ts`.
23
- registerTextInputBehavior();
24
- // Same reason as TextInput: the wrapper renders `symbiote-switch-managed` and runs its own
25
- // lastNativeReport/snap-back lifecycle, so the engine's copy attaches only to the bare tag.
26
- registerSwitchBehavior();
27
- // Image owns no runtime — its behavior is a prop FOLD and nothing else, and it is registered on the
28
- // same `symbiote-image` the wrapper already emits rather than on a `-managed` twin. That is safe
29
- // only because the mapping is idempotent, which `core/components/src/behaviors/image.test.ts`
30
- // asserts rather than assumes; a wrapper-built node simply folds a second time and nothing moves.
31
- registerImageBehavior();
32
- // Fold-only, and it shares the wrapper's tag for the same reason Image does: the mapping has no
33
- // aliasing at all, so a wrapper-built node folding a second time moves nothing.
34
- // `core/components/src/behaviors/input-accessory-view.test.ts` asserts that rather than assuming it.
35
- registerInputAccessoryViewBehavior();
9
+ // WHY A SIDE-EFFECT SUBPATH AND NOT A BARREL EXPORT. Metro turns on `inlineRequires` for PRODUCTION
10
+ // only: it moves a `require` down to the first place its binding is used as a VALUE, and
11
+ // `export { X } from './x'` compiles to a lazy getter. A registration reached that way never
12
+ // evaluates in a Release build — invisible to tsc, to vitest, and to grepping the bundle. A bare
13
+ // `import` placed NEXT TO a re-export of the same specifier does not help either: Babel merges the
14
+ // two into one dependency and the merged dependency stays lazy.
15
+ import '@symbiote-native/components/register';
@@ -2,7 +2,7 @@
2
2
  // the engine's tiny mutation API; the engine owns all Fabric clone-on-write, so Vue
3
3
  // drives the exact same retained tree React does: the proof the core is framework-
4
4
  // agnostic.
5
- import { createRenderer } from '@vue/runtime-core';
5
+ import { callWithErrorHandling, createRenderer, ErrorCodes, markRaw, } from '@vue/runtime-core';
6
6
  import { appendChild, createAnchor, createElement, createRawText, dlog, insertBefore, removeChild, routeProp, setProp, setText, toPublicInstance, RAW_TEXT_COMPONENT, SymbioteSurface, } from '@symbiote-native/engine';
7
7
  import { descriptorFor } from '@symbiote-native/components';
8
8
  import { normalizeVueAttrKey } from '../utils/normalize-attrs.js';
@@ -15,7 +15,7 @@ function isRawText(node) {
15
15
  // RN's Text.js applies two defaults on the way to native (core/components/src/text-props.ts:
16
16
  // ellipsizeMode 'tail', allowFontScaling true unless literally false). The Vue <Text> wrapper
17
17
  // folded them with resolveTextProps; a template that the SFC transformer lowered to the
18
- // intrinsic `symbiote-text` has no wrapper, so the renderer seeds them instead. Without this a
18
+ // intrinsic `text` has no wrapper, so the renderer seeds them instead. Without this a
19
19
  // numberOfLines={1} line clips mid-word with no ellipsis — device-observed, and silent.
20
20
  const TEXT_DEFAULTS = new Map([
21
21
  ['ellipsizeMode', 'tail'],
@@ -31,13 +31,51 @@ function seedTextDefaults(node) {
31
31
  // id="x">` reached Fabric with an unknown `id` and no `nativeID`, silently and on device only.
32
32
  // It lives in the renderer rather than in a transform because that covers all four Vue paths at
33
33
  // once — lowered SFC, lowered TSX, the component wrapper, and a hand-written
34
- // `h('symbiote-view', { id })` no compiler ever sees.
34
+ // `h('view', { id })` no compiler ever sees.
35
35
  //
36
36
  // Caveat, and it matches what Solid's compile-time rename already does: with BOTH `id` and
37
37
  // `nativeID` on one element the last patchProp wins, where upstream gives `id` priority
38
38
  // unconditionally. Honouring that needs per-node state to remember an `id` arrived; no example or
39
39
  // test sets both, so the state is not worth carrying.
40
40
  const PROP_ALIASES = new Map([['id', 'nativeID']]);
41
+ // RN-style event prop naming ('onPress', 'onValueChange', ...), the same convention JSX itself
42
+ // uses to separate an event from a plain value prop — good enough to decide whether to wrap,
43
+ // without duplicating routeProp's own ViewConfig-derived event lists here.
44
+ const EVENT_PROP_NAME = /^on[A-Z]/;
45
+ // A native event (a Fabric touch, a responder gesture) invokes a Pressable/TextInput/etc.
46
+ // listener straight out of the engine's own event dispatch (core/engine/src/dispatch.ts,
47
+ // core/components/src/behaviors/pressable.ts's `dispatch`), entirely outside anything Vue ever
48
+ // wraps — no render, no watcher, no lifecycle hook sits between the native event and the app's
49
+ // callback. So a throw inside `onPress={throwNow}` was a genuinely UNCAUGHT JS exception: it
50
+ // skipped `onErrorCaptured`, skipped `app.config.errorHandler`, skipped even this adapter's own
51
+ // default reporter (render.ts's `reportToHost`) and reached Hermes's own top-level handler
52
+ // directly — the raw, component-stack-less redbox this was device-reported as, not the "vue
53
+ // render (...)" framed one `reportToHost` produces. Real Vue DOM has the identical seam
54
+ // (`patchEvent`'s `createInvoker`) for the identical reason: a DOM event is native too.
55
+ //
56
+ // Wrapping here, at `patchProp`, needs the OWNING instance — and `getCurrentInstance()` cannot
57
+ // supply it: Vue resets `currentRenderingInstance` at the END of `renderComponentRoot`, BEFORE
58
+ // the subsequent `patch()` call that actually reaches `mountElement`/`patchProp`, so by the time
59
+ // this runs it always reads null (measured via a real thrown press listener — `handleError` never
60
+ // found the `onErrorCaptured` ancestor because `if (instance)` was false). Real Vue DOM does not
61
+ // use `getCurrentInstance()` for this either: `hostPatchProp` is called with an explicit 6th
62
+ // argument, `parentComponent`, threaded down through `patch`/`mountElement` from the component
63
+ // whose render produced the vnode — the same instance the DOM renderer's own `patchEvent` passes
64
+ // to `callWithAsyncErrorHandling`. That parameter, not the global pointer, is the correct capture.
65
+ //
66
+ // `callWithErrorHandling` (the SYNC form, not `callWithAsyncErrorHandling`) is deliberate: a
67
+ // responder-negotiation callback (`onStartShouldSetResponder`, ...) returns a boolean the engine
68
+ // reads synchronously, and the sync form is the one that returns the call's result directly
69
+ // rather than folding it into promise-handling for the multi-hook case.
70
+ function wrapListenerForErrorHandling(listener, instance) {
71
+ const wrapper = (...args) => callWithErrorHandling(listener, instance, ErrorCodes.NATIVE_EVENT_HANDLER, args);
72
+ // Some listeners are marker-carrying functions, not plain callbacks — Animated.event's
73
+ // __getHandler() (core/engine/src/animated/event.ts) hands back exactly this shape,
74
+ // `Object.assign((...) => {...}, { __getEvent: () => this })`, and the engine's native-driver
75
+ // attach path reads `__getEvent` off the prop value it was given. A fresh closure with none of
76
+ // the original's own properties silently breaks that — carry them forward.
77
+ return Object.assign(wrapper, listener);
78
+ }
41
79
  // An explicit `undefined` must NOT clear one of those defaults: RN treats a missing prop and an
42
80
  // explicit undefined alike, and only a literal `false` opts out of allowFontScaling. Reached
43
81
  // only when a value is already undefined, so it costs nothing on the hot path.
@@ -53,7 +91,7 @@ export function createSymbioteRenderer(surface) {
53
91
  createElement(type) {
54
92
  const descriptor = descriptorFor(type);
55
93
  // `type` as the third argument, not just `descriptor.component`: the behavior registry is
56
- // keyed by the INTRINSIC TAG (`symbiote-pressable`), while a node only ever carries the
94
+ // keyed by the INTRINSIC TAG (`pressable`), while a node only ever carries the
57
95
  // resolved Fabric name (`RCTView`). This is the one place that still holds both, so a
58
96
  // lowered primitive whose machine lives on the engine node can be matched at all.
59
97
  const node = createElement(descriptor.component, descriptor.isText, type);
@@ -69,7 +107,16 @@ export function createSymbioteRenderer(surface) {
69
107
  // one benchmark press — and a literal is built at the call site before dlog can decide
70
108
  // anything (see core/engine/src/debug.ts).
71
109
  dlog(() => `vue createElement ${descriptor.component} -> public instance`);
72
- return toPublicInstance(node);
110
+ // markRaw is load-bearing, not an optimization: useTemplateRef()'s return value is
111
+ // `readonly(shallowRef(null))` (runtime-core.cjs.js), and Vue's readonly() wraps ANY
112
+ // `.value` whose Object.prototype.toString reads "[object Object]" — true of a plain class
113
+ // instance, unlike a real DOM Element, which fails that check for free. Unmarked, a node
114
+ // reached through useTemplateRef() (not a plain ref()/shallowRef(), both of which skip the
115
+ // wrap) came back as a deep-readonly Proxy: reads worked, so `committedOf`/`whenCommitted`
116
+ // saw a "committed" node, but `setNativeProps`'s `node.props.style = …` silently no-op'd
117
+ // with a dev-only "Set operation… target is readonly" warning — device-reported 2026-09-11
118
+ // as "flash the right chip" doing nothing on press.
119
+ return markRaw(toPublicInstance(node));
73
120
  },
74
121
  createText(text) {
75
122
  // Vue mounts Fragment boundaries (v-for / v-if lists / multi-root) as EMPTY text
@@ -150,7 +197,7 @@ export function createSymbioteRenderer(surface) {
150
197
  const index = siblings.indexOf(node);
151
198
  return index >= 0 ? (siblings[index + 1] ?? null) : null;
152
199
  },
153
- patchProp(el, key, _prev, next) {
200
+ patchProp(el, key, _prev, next, _namespace, parentComponent) {
154
201
  if (isSurface(el))
155
202
  return;
156
203
  // Kebab -> camel happens HERE, not only inside a component wrapper: the SFC transformer
@@ -159,11 +206,15 @@ export function createSymbioteRenderer(surface) {
159
206
  // path, which already normalized.
160
207
  const normalized = normalizeVueAttrKey(key);
161
208
  const name = PROP_ALIASES.get(normalized) ?? normalized;
209
+ const value = next === undefined ? textDefaultFor(el, name) : next;
210
+ const routed = EVENT_PROP_NAME.test(name) && typeof value === 'function'
211
+ ? wrapListenerForErrorHandling(value, parentComponent ?? null)
212
+ : value;
162
213
  // routeProp makes the prop-vs-event decision from the node's ViewConfig (onPress on a
163
214
  // View becomes a listener; onTintColor on a Switch stays a prop), shared with React. The
164
215
  // class/style merge (explicit :style always winning, regardless of which of Vue's two
165
216
  // independent patchProp calls lands last) is centralized there too (core/engine/src/node.ts).
166
- routeProp(el, name, next === undefined ? textDefaultFor(el, name) : next);
217
+ routeProp(el, name, routed);
167
218
  surface.requestCommit();
168
219
  },
169
220
  // RN has no querySelector / scope-id / innerHTML. The first two are inert; static
@@ -1,7 +1,6 @@
1
1
  export * from '@vue/runtime-core';
2
2
  import { type ObjectDirective } from '@vue/runtime-core';
3
3
  import { type ISymbioteNode } from '@symbiote-native/engine';
4
- export { Teleport, type ITeleportTarget } from '../create-portal';
5
4
  export declare function normalizeProps(props: Record<string, unknown> | null | undefined): Record<string, unknown> | null;
6
5
  export declare function normalizeStyle(value: unknown): unknown;
7
6
  export declare function mergeProps(...args: Record<string, unknown>[]): Record<string, unknown>;
@@ -28,6 +27,16 @@ type IEventHandler<TEvent> = ((event: TEvent, ...args: never[]) => unknown) & {
28
27
  * typed as required, matching upstream Vue's own declaration — its runtime-only `!fn` guard below
29
28
  * (for a compiler-generated call site that could pass a falsy handler) isn't reflected in the
30
29
  * type there either.
30
+ *
31
+ * `@press.self` on a LOWERED element reaches this, and both halves were measured 2026-09-11 rather
32
+ * than assumed — the question arose because a modifier on a COMPONENT takes Vue's own event path
33
+ * and only an element emits the helper. `<pressable @press.self>` compiles to
34
+ * `_withModifiers(fn, ["self"])` imported `from "@symbiote-native/vue/runtime-helpers"` (the Metro
35
+ * transformer retargets every compiled `from 'vue'`), so the compiler-emitted call lands here and
36
+ * not on @vue/runtime-dom's. And `ISymbioteEvent` (`core/engine/src/node.ts`) declares `target`,
37
+ * `currentTarget` and `stopPropagation` as REQUIRED fields with the DOM semantics these guards
38
+ * read, so `.self` genuinely filters a bubbled press instead of degenerating to
39
+ * `undefined !== undefined`.
31
40
  */
32
41
  export declare function withModifiers<TEvent extends IModifierGuardableEvent>(fn: IEventHandler<TEvent>, modifiers: readonly IEventModifier[]): IEventHandler<TEvent>;
33
42
  type IKeyedEvent = {
@@ -12,11 +12,21 @@ export * from '@vue/runtime-core';
12
12
  import { getCurrentInstance, mergeProps as baseMergeProps, normalizeClass, normalizeStyle as baseNormalizeStyle, warn, } from '@vue/runtime-core';
13
13
  import { requestCommitFor, setNativeProps, setProp, whenCommitted, } from '@symbiote-native/engine';
14
14
  import { descriptorFor, SWITCH_TAG } from '@symbiote-native/components';
15
- // Shadows runtime-core's own Teleport for every compiled `from 'vue'` import (see the header):
16
- // same component, plus a runtime guard on `to`. Lives in ../create-portal so the adapter's portal
17
- // sits where every other adapter's does; re-exported here because THIS is the module Metro
18
- // rewrites `vue` to, and an `export *` alone would resolve `Teleport` to the unguarded original.
19
- export { Teleport } from '../create-portal/index.js';
15
+ // Do NOT shadow runtime-core's own Teleport here, however tempting the guarded ../create-portal
16
+ // wrapper looks (same reasoning that shadowed it in the first place, and it is wrong for this
17
+ // file specifically). @vue/compiler-sfc recognises the literal tag name "Teleport" as one of
18
+ // Vue's CORE built-ins at compile time — regardless of which value the name resolves to at
19
+ // runtime — and compiles its children as a raw array with a PROPS-only patchFlag (`dynamicProps:
20
+ // ["to"]`), never the DYNAMIC_SLOTS-flagged shape a real user component gets. Substituting a real
21
+ // `defineComponent` under that name makes it go through Vue's ordinary component update path
22
+ // (shouldUpdateComponent), which — for a PROPS-only patchFlag — checks ONLY the props named in
23
+ // `dynamicProps` and never re-renders on a children/slot change at all. Confirmed by reproducing
24
+ // through the real compiler (compileSfc) + a live mount: the wrapper's render() fires once at
25
+ // mount and never again, so a Teleport whose content is gated by v-if (the ordinary "toast"/
26
+ // "modal" shape) silently stops updating the moment `to` itself stays the same. `export *` above
27
+ // already re-exports the real, unwrapped Teleport for this path — leave it there. The guarded
28
+ // wrapper (../create-portal) is still exported from the adapter's main barrel for anyone who
29
+ // constructs the vnode by hand (`h(Teleport, ...)`, e.g. in TSX), where no such codegen applies.
20
30
  // Shadows @vue/shared's own `normalizeProps` for every compiled `from 'vue'` import, for ONE
21
31
  // divergence: a FUNCTION-valued `style` survives it.
22
32
  //
@@ -117,6 +127,14 @@ export const vShow = {
117
127
  // Drop a still-pending first-commit wait if the element unmounts before it ever lands.
118
128
  unmounted: el => pendingShowCommits.get(el)?.(),
119
129
  };
130
+ function textFromChangeEvent(event) {
131
+ const text = Reflect.get(event, 'text');
132
+ return typeof text === 'string' ? text : undefined;
133
+ }
134
+ function valueFromChangeEvent(event) {
135
+ const value = Reflect.get(event, 'value');
136
+ return typeof value === 'boolean' ? value : undefined;
137
+ }
120
138
  const modelStates = new WeakMap();
121
139
  // Runtime guards, not casts: both slots are read out of untyped bags — `vnode.props` and
122
140
  // `node.props` are `Record<string, unknown>` — and `typeof x === 'function'` alone narrows to the
@@ -163,9 +181,16 @@ function modelStateFor(el) {
163
181
  appListener: undefined,
164
182
  trim: false,
165
183
  number: false,
166
- listener: (text, event) => {
167
- state.appListener?.(text, event);
168
- state.assign?.(applyModelModifiers(state, text));
184
+ listener: event => {
185
+ state.appListener?.(event);
186
+ const text = textFromChangeEvent(event);
187
+ if (text !== undefined) {
188
+ state.assign?.(applyModelModifiers(state, text));
189
+ return;
190
+ }
191
+ const value = valueFromChangeEvent(event);
192
+ if (value !== undefined)
193
+ state.assign?.(value);
169
194
  },
170
195
  };
171
196
  modelStates.set(el, state);
@@ -182,7 +207,7 @@ function syncModelListener(el, state) {
182
207
  setProp(el, 'onValueChange', state.listener);
183
208
  }
184
209
  // Vue's compiler picks the directive by ELEMENT, and for anything it does not recognise as a DOM
185
- // input it emits `vModelText` — including a lowered `<symbiote-switch>`. Stringifying there is what
210
+ // input it emits `vModelText` — including a lowered `<switch>`. Stringifying there is what
186
211
  // upstream must do (a DOM input's value IS a string) and what we must not: the Switch behavior
187
212
  // reads `props.value === true`, so `String(true)` pins the control OFF and no tap can move it.
188
213
  // Device-confirmed on `examples/vue-sfc` 2026-09-02, both switches on `CanaryScreen`.
@@ -195,8 +220,9 @@ function isSwitchNode(el) {
195
220
  return el.component === SWITCH_COMPONENT;
196
221
  }
197
222
  function syncModelValue(el, value) {
198
- // The READ half needs no branch: the behavior calls `onValueChange(value, event)` for both
199
- // primitives, and `applyModelModifiers` passes a non-string through untouched.
223
+ // The READ half needs no branch: the behavior calls `onValueChange(event)` for both primitives
224
+ // with `text`/`value` carried on the event, and `applyModelModifiers` passes a non-string through
225
+ // untouched.
200
226
  if (isSwitchNode(el)) {
201
227
  setProp(el, 'value', value === true);
202
228
  requestCommitFor(el);
@@ -268,6 +294,16 @@ const MODIFIER_GUARDS = {
268
294
  * typed as required, matching upstream Vue's own declaration — its runtime-only `!fn` guard below
269
295
  * (for a compiler-generated call site that could pass a falsy handler) isn't reflected in the
270
296
  * type there either.
297
+ *
298
+ * `@press.self` on a LOWERED element reaches this, and both halves were measured 2026-09-11 rather
299
+ * than assumed — the question arose because a modifier on a COMPONENT takes Vue's own event path
300
+ * and only an element emits the helper. `<pressable @press.self>` compiles to
301
+ * `_withModifiers(fn, ["self"])` imported `from "@symbiote-native/vue/runtime-helpers"` (the Metro
302
+ * transformer retargets every compiled `from 'vue'`), so the compiler-emitted call lands here and
303
+ * not on @vue/runtime-dom's. And `ISymbioteEvent` (`core/engine/src/node.ts`) declares `target`,
304
+ * `currentTarget` and `stopPropagation` as REQUIRED fields with the DOM semantics these guards
305
+ * read, so `.self` genuinely filters a bubbled press instead of degenerating to
306
+ * `undefined !== undefined`.
271
307
  */
272
308
  export function withModifiers(fn, modifiers) {
273
309
  if (!fn)
@@ -0,0 +1,52 @@
1
+ // Every tag both Vue compilers must treat as an ELEMENT rather than as a component to resolve.
2
+ //
3
+ // Vue decides element-vs-component in two unrelated places — `@vue/compiler-sfc`'s
4
+ // `isCustomElement` and `@vue/babel-plugin-jsx`'s option of the same name — so the answer has to
5
+ // come from one set or the two paths drift. That drift is not loud: a tag Vue takes for a component
6
+ // compiles to `resolveComponent("text-input")` with its children as a slot the element path never
7
+ // reads, which renders a blank subtree with no error.
8
+ //
9
+ // DERIVED, never enumerated. Until 2026-09-07 this was the prefix test `tag.startsWith('symbiote-')`
10
+ // in ./babel-jsx.cjs — correct while every intrinsic carried that marker, and answering FALSE for
11
+ // every tag since the prefix was dropped (`core/components/src/component-names/shared.ts` says why
12
+ // it went). What hid it is the tag alphabet: `view` / `text` / `image` / `switch` are real SVG
13
+ // element names, so both compilers resolve those four as elements with no configuration at all, and
14
+ // only the hyphenated half degraded.
15
+ //
16
+ // SCOPE, stated because it is smaller than `ISymbioteIntrinsic`. That union also holds
17
+ // `scroll-view`, `modal`, `activity-indicator` and the rest, but it is a TypeScript type and these
18
+ // consumers are Babel/Metro plugins that run before TS exists. `HOST_PRIMITIVES` is the only
19
+ // runtime-readable table reachable from here, so a hand-written `<scroll-view>` still resolves as a
20
+ // component. Closing that needs a CJS intrinsic table in `core/components`.
21
+ //
22
+ // WHAT THAT GAP COSTS, measured 2026-09-11 rather than reasoned, because the first guess was that
23
+ // the tag simply would not work. It works: `resolveComponent` falls back to the tag STRING, and
24
+ // `normalizeChildren` then unwraps the `withCtx` default slot for an element shapeFlag, so
25
+ // `<scroll-view><text>hi</text></scroll-view>` committed `RCTView > RCTScrollView > RCTText >
26
+ // RCTRawText`. What it costs is a dev-mode `[Vue warn]: Failed to resolve component: scroll-view`
27
+ // on every such element plus the component codegen path (a slot closure per element) instead of
28
+ // `_createElementBlock`. That is a divergence from every other tag, so `ScrollView`,
29
+ // `TouchableOpacity` and `TouchableHighlight` stay COMPONENTS on Vue until their
30
+ // `HOST_PRIMITIVES` entries land — and `intrinsic-elements.ts` already types all three as valid
31
+ // markup off `ISymbioteIntrinsic`, so vue-tsc accepts what the runtime compiler warns about.
32
+ //
33
+ // The tell that the three are still wrapped rather than merely unlisted: `src/register.ts` does
34
+ // NOT call their behaviors, because a registered behavior plus a surviving wrapper is two owners
35
+ // on one node. Those two facts move together or not at all.
36
+ //
37
+ // .cjs, and its own file rather than a copy in each consumer: two transforms carrying one
38
+ // derivation is the drift the shared spec exists to prevent.
39
+
40
+ const {
41
+ HOST_PRIMITIVES,
42
+ } = require('@symbiote-native/components/host-primitives');
43
+
44
+ // BOTH tags a spec entry can emit. A primitive with an `intrinsicWhen` selects between two native
45
+ // views by one prop, so its second intrinsic is as hand-writable as its first.
46
+ module.exports = new Set(
47
+ Object.values(HOST_PRIMITIVES).flatMap(entry =>
48
+ entry.intrinsicWhen === undefined
49
+ ? [entry.intrinsic]
50
+ : [entry.intrinsic, entry.intrinsicWhen.intrinsic],
51
+ ),
52
+ );