@symbiote-native/vue 0.5.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 (115) hide show
  1. package/README.md +28 -13
  2. package/babel-jsx.cjs +52 -0
  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/composables/use-raw-attrs.d.ts +1 -0
  44. package/build/composables/use-raw-attrs.js +26 -0
  45. package/build/descriptor-to-vue.js +2 -2
  46. package/build/host-instance/index.js +4 -5
  47. package/build/index.d.ts +25 -27
  48. package/build/index.js +35 -15
  49. package/build/intrinsic-elements.d.ts +19 -0
  50. package/build/intrinsic-elements.js +1 -0
  51. package/build/jsx-runtime.d.ts +67 -0
  52. package/build/jsx-runtime.js +1 -0
  53. package/build/modules/animated/create-animated-component.d.ts +1 -1
  54. package/build/modules/animated/create-animated-component.js +18 -4
  55. package/build/modules/animated/index.d.ts +3 -1
  56. package/build/modules/animated/index.js +20 -19
  57. package/build/modules/image/index.d.ts +3 -0
  58. package/build/modules/image/index.js +13 -0
  59. package/build/register.d.ts +1 -0
  60. package/build/register.js +15 -0
  61. package/build/renderer/index.js +112 -12
  62. package/build/runtime-helpers/index.d.ts +14 -1
  63. package/build/runtime-helpers/index.js +250 -9
  64. package/build/utils/normalize-attrs.d.ts +1 -0
  65. package/build/utils/normalize-attrs.js +21 -15
  66. package/intrinsic-tags.cjs +52 -0
  67. package/metro-css-parser.cjs +2 -1
  68. package/metro-vue-transformer.cjs +83 -13
  69. package/package.json +33 -8
  70. package/build/components/activity-indicator/index.android.d.ts +0 -4
  71. package/build/components/activity-indicator/index.android.js +0 -10
  72. package/build/components/activity-indicator/index.d.ts +0 -1
  73. package/build/components/activity-indicator/index.ios.d.ts +0 -4
  74. package/build/components/activity-indicator/index.ios.js +0 -10
  75. package/build/components/activity-indicator/index.js +0 -4
  76. package/build/components/activity-indicator/shared.d.ts +0 -8
  77. package/build/components/activity-indicator/shared.js +0 -48
  78. package/build/components/button.d.ts +0 -14
  79. package/build/components/button.js +0 -59
  80. package/build/components/image-background.d.ts +0 -9
  81. package/build/components/image-background.js +0 -103
  82. package/build/components/image.d.ts +0 -9
  83. package/build/components/image.js +0 -90
  84. package/build/components/input-accessory-view.js +0 -41
  85. package/build/components/pressable.d.ts +0 -58
  86. package/build/components/pressable.js +0 -214
  87. package/build/components/refresh-control.d.ts +0 -20
  88. package/build/components/refresh-control.js +0 -47
  89. package/build/components/safe-area-view.d.ts +0 -8
  90. package/build/components/safe-area-view.js +0 -33
  91. package/build/components/scroll-view/index.android.d.ts +0 -4
  92. package/build/components/scroll-view/index.android.js +0 -64
  93. package/build/components/scroll-view/index.d.ts +0 -1
  94. package/build/components/scroll-view/index.ios.d.ts +0 -4
  95. package/build/components/scroll-view/index.ios.js +0 -17
  96. package/build/components/scroll-view/index.js +0 -4
  97. package/build/components/scroll-view/shared.js +0 -271
  98. package/build/components/scroll-view/sticky-header.d.ts +0 -7
  99. package/build/components/scroll-view/sticky-header.js +0 -208
  100. package/build/components/switch/index.android.d.ts +0 -12
  101. package/build/components/switch/index.android.js +0 -13
  102. package/build/components/switch/index.d.ts +0 -2
  103. package/build/components/switch/index.ios.d.ts +0 -12
  104. package/build/components/switch/index.ios.js +0 -11
  105. package/build/components/switch/index.js +0 -5
  106. package/build/components/switch/shared.d.ts +0 -23
  107. package/build/components/switch/shared.js +0 -126
  108. package/build/components/text-input/index.d.ts +0 -24
  109. package/build/components/text-input/index.js +0 -242
  110. package/build/components/touchable-native-feedback.d.ts +0 -24
  111. package/build/components/touchable-native-feedback.js +0 -77
  112. package/build/components/touchable.d.ts +0 -50
  113. package/build/components/touchable.js +0 -278
  114. package/build/components.d.ts +0 -51
  115. package/build/components.js +0 -29
@@ -12,8 +12,11 @@ import { normalizeVueAttrs } from '../../utils/normalize-attrs.js';
12
12
  // the COMMITTED props (sticky-header passthrough). Named once here so render/reconcile agree.
13
13
  const PASSTHROUGH_PROP = 'passthroughAnimatedPropExplicitValues';
14
14
  // Reads either a functional component's or a stateful defineComponent's display name, without a
15
- // cast, for the wrapper's devtools name.
15
+ // cast, for the wrapper's devtools name. A string base IS its own name — an intrinsic tag carries
16
+ // no displayName to read.
16
17
  function baseName(component) {
18
+ if (typeof component === 'string')
19
+ return component;
17
20
  if (component === null ||
18
21
  (typeof component !== 'function' && typeof component !== 'object')) {
19
22
  return 'Anonymous';
@@ -26,6 +29,8 @@ function baseName(component) {
26
29
  return name;
27
30
  return 'Anonymous';
28
31
  }
32
+ // The base may be an intrinsic TAG, not only a component: `Animated.View` is built over `View`,
33
+ // and a primitive that becomes a public tag hands this a string. `h()` takes either.
29
34
  export function createAnimatedComponent(Component) {
30
35
  return defineComponent({
31
36
  name: `Animated(${baseName(Component)})`,
@@ -104,9 +109,18 @@ export function createAnimatedComponent(Component) {
104
109
  : [reduced.style, passthroughStyle];
105
110
  }
106
111
  reduced.ref = captureRef;
107
- // Forward the WHOLE slots object, not just `default`: the list containers take their
108
- // content through named scoped slots (`item`, `sectionHeader`, ...), which a default-only
109
- // forward drops silently - Animated.FlatList would commit empty cells.
112
+ // The WHOLE slots object, for a component base AND for a tag base. Not just `default`:
113
+ // the list containers take their content through named scoped slots (`item`,
114
+ // `sectionHeader`, ...), which a default-only forward drops silently — Animated.FlatList
115
+ // would commit empty cells.
116
+ //
117
+ // A string base needs no branch here, and the reason is worth recording because the code
118
+ // that reads it looks like it does. For an element Vue unwraps the object itself, calling
119
+ // `children.default()` and recursing on the RESULT rather than going back through `h`
120
+ // (`normalizeChildren`, the `shapeFlag & (1 | 64)` branch) — so a slot returning a lone
121
+ // VNode would be re-read as an object, found to have no `.default`, and dropped, with no
122
+ // error. That case cannot arise: Vue wraps every slot at initSlots so `slots.default()`
123
+ // returns an ARRAY whatever the author's function returned. Measured both spellings.
110
124
  return h(Component, reduced, slots);
111
125
  };
112
126
  },
@@ -50,7 +50,9 @@ export declare const Animated: {
50
50
  Image: import("vue").DefineComponent<{}, () => import("vue").VNode<import("vue").RendererNode, import("vue").RendererElement, {
51
51
  [key: string]: any;
52
52
  }>, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
53
- ScrollView: ReturnType<typeof createAnimatedComponent>;
53
+ ScrollView: import("vue").DefineComponent<{}, () => import("vue").VNode<import("vue").RendererNode, import("vue").RendererElement, {
54
+ [key: string]: any;
55
+ }>, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
54
56
  FlatList: ReturnType<typeof createAnimatedComponent>;
55
57
  SectionList: ReturnType<typeof createAnimatedComponent>;
56
58
  createAnimatedComponent: typeof createAnimatedComponent;
@@ -4,25 +4,29 @@
4
4
  // meet here so `Animated.timing(new Animated.Value(0), ...).start()` works against the
5
5
  // Vue-driven engine.
6
6
  import { AnimatedValue, AnimatedValueXY, AnimatedColor, AnimatedMock, Easing, Platform, timing, spring, decay, parallel, sequence, stagger, loop, delay, add, subtract, multiply, divide, modulo, diffClamp, event, forkEvent, unforkEvent, } from '@symbiote-native/engine';
7
- import { View, Text } from '../../components.js';
8
- import { Image } from '../../components/image.js';
9
- import { ScrollView } from '../../components/scroll-view';
10
7
  import { FlatList } from '../../components/flat-list/index.js';
11
8
  import { SectionList } from '../../components/section-list/index.js';
12
9
  import { createAnimatedComponent } from './create-animated-component.js';
13
10
  export { createAnimatedComponent } from './create-animated-component.js';
14
- // View/Text are pure host primitives; Image is the functional renderImage wrapper, and all expose
15
- // their host node via ref fall-through, so wrap them eagerly.
16
- const AnimatedView = createAnimatedComponent(View);
17
- const AnimatedText = createAnimatedComponent(Text);
18
- const AnimatedImage = createAnimatedComponent(Image);
19
- // LAZY, memoized getters, mirroring RN's `get ScrollView()`. Every scrolling container reaches
20
- // scroll-view/sticky-header, which imports this Animated namespace back, so a wrap at module
21
- // scope captures whatever the half-evaluated cycle holds at that instant. Under a bundler's ESM
22
- // interop that is `undefined`, not a ReferenceError - the wrapper builds fine and then renders
23
- // nothing. Deferring past module init is what keeps it from firing. FlatList and SectionList
24
- // reach ScrollView through VirtualizedList, so they sit on the same cycle and take the same shape.
25
- let animatedScrollView;
11
+ // The TAGS, not components — there are no View/Text/Image components left to alias, and a string
12
+ // base is what `createAnimatedComponent` already handles (`animated-tag-base.test.ts`): it renders
13
+ // the intrinsic and the ref falls through to the raw engine node, the same fall-through the
14
+ // functional wrappers used to provide.
15
+ //
16
+ // Wrapping is itself vestigial — the engine resolves an AnimatedNode written into any prop of any
17
+ // host node (`core/engine/src/animated/host-binding.ts`), so `<view :style="{ opacity: v }">` needs
18
+ // no wrapper at all. These stay as RN-compatible aliases until the namespace is retired.
19
+ const AnimatedView = createAnimatedComponent('view');
20
+ const AnimatedText = createAnimatedComponent('text');
21
+ const AnimatedImage = createAnimatedComponent('image');
22
+ // `scroll-view` is a TAG now too (no more component, no more scroll-view/sticky-header cycle to
23
+ // dodge), so it takes the same eager shape as View/Text/Image above.
24
+ const AnimatedScrollView = createAnimatedComponent('scroll-view');
25
+ // LAZY, memoized getters, mirroring RN's `get FlatList()`. Kept deferred past module init even
26
+ // though the scroll-view cycle these once rode is gone (ScrollView above is eager now) — neither
27
+ // FlatList nor VirtualizedList imports this module, so there is no proven cycle left, but nothing
28
+ // forces one to stay absent either. Cheap insurance against a half-evaluated module handing back
29
+ // `undefined` and rendering nothing.
26
30
  let animatedFlatList;
27
31
  let animatedSectionList;
28
32
  // The live, JS-driven driver namespace (real frames). RN's AnimatedImplementation.
@@ -58,10 +62,7 @@ export const Animated = {
58
62
  View: AnimatedView,
59
63
  Text: AnimatedText,
60
64
  Image: AnimatedImage,
61
- get ScrollView() {
62
- animatedScrollView ??= createAnimatedComponent(ScrollView);
63
- return animatedScrollView;
64
- },
65
+ ScrollView: AnimatedScrollView,
65
66
  get FlatList() {
66
67
  animatedFlatList ??= createAnimatedComponent(FlatList);
67
68
  return animatedFlatList;
@@ -0,0 +1,3 @@
1
+ import { type IImageStatics } from '@symbiote-native/components';
2
+ export type { IImageStatics };
3
+ export declare const Image: IImageStatics;
@@ -0,0 +1,13 @@
1
+ // RN's Image STATICS — `getSize`, `prefetch`, `queryCache`, `abortPrefetch`, `resolveAssetSource`.
2
+ //
3
+ // IN `modules/`, NOT `components/`. The primitive is the `<image>` tag and its fold runs in the
4
+ // engine (`behaviors/image.ts`); what is left under the name `Image` is an imperative API with no
5
+ // view, which is what this bucket is for. Sitting in `components/` made a statics object read as a
6
+ // component.
7
+ //
8
+ // A NAMESPACE OBJECT rather than five bare functions, deliberately: `Alert`, `Share`, `Linking`
9
+ // and `Vibration` are all `export const X = {…}`, and an app porting from RN writes
10
+ // `Image.getSize(...)` verbatim. Shared verbatim via `imageStatics`, the same source every other
11
+ // adapter's Image uses.
12
+ import { imageStatics } from '@symbiote-native/components';
13
+ export const Image = imageStatics;
@@ -0,0 +1 @@
1
+ import '@symbiote-native/components/register';
@@ -0,0 +1,15 @@
1
+ // Side-effect ONLY. Exports nothing, and must never be re-exported from the barrel.
2
+ //
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.
8
+ //
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,15 +2,86 @@
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';
6
- import { appendChild, createAnchor, createElement, createRawText, dlog, insertBefore, removeChild, routeProp, setText, toPublicInstance, RAW_TEXT_COMPONENT, SymbioteSurface, } from '@symbiote-native/engine';
5
+ import { callWithErrorHandling, createRenderer, ErrorCodes, markRaw, } from '@vue/runtime-core';
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
+ import { normalizeVueAttrKey } from '../utils/normalize-attrs.js';
8
9
  function isSurface(parent) {
9
10
  return parent instanceof SymbioteSurface;
10
11
  }
11
12
  function isRawText(node) {
12
13
  return node.component === RAW_TEXT_COMPONENT;
13
14
  }
15
+ // RN's Text.js applies two defaults on the way to native (core/components/src/text-props.ts:
16
+ // ellipsizeMode 'tail', allowFontScaling true unless literally false). The Vue <Text> wrapper
17
+ // folded them with resolveTextProps; a template that the SFC transformer lowered to the
18
+ // intrinsic `text` has no wrapper, so the renderer seeds them instead. Without this a
19
+ // numberOfLines={1} line clips mid-word with no ellipsis — device-observed, and silent.
20
+ const TEXT_DEFAULTS = new Map([
21
+ ['ellipsizeMode', 'tail'],
22
+ ['allowFontScaling', true],
23
+ ]);
24
+ function seedTextDefaults(node) {
25
+ for (const [key, value] of TEXT_DEFAULTS)
26
+ setProp(node, key, value);
27
+ }
28
+ // RN's `id` is the modern W3C-named alias for `nativeID` — View.js copies it over
29
+ // (`processedProps.nativeID = id`), so the two name ONE native prop. React folds it in its
30
+ // component wrapper and Svelte and Solid in their transforms; Vue had it nowhere, so `<View
31
+ // id="x">` reached Fabric with an unknown `id` and no `nativeID`, silently and on device only.
32
+ // It lives in the renderer rather than in a transform because that covers all four Vue paths at
33
+ // once — lowered SFC, lowered TSX, the component wrapper, and a hand-written
34
+ // `h('view', { id })` no compiler ever sees.
35
+ //
36
+ // Caveat, and it matches what Solid's compile-time rename already does: with BOTH `id` and
37
+ // `nativeID` on one element the last patchProp wins, where upstream gives `id` priority
38
+ // unconditionally. Honouring that needs per-node state to remember an `id` arrived; no example or
39
+ // test sets both, so the state is not worth carrying.
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
+ }
79
+ // An explicit `undefined` must NOT clear one of those defaults: RN treats a missing prop and an
80
+ // explicit undefined alike, and only a literal `false` opts out of allowFontScaling. Reached
81
+ // only when a value is already undefined, so it costs nothing on the hot path.
82
+ function textDefaultFor(node, key) {
83
+ return node.isText ? TEXT_DEFAULTS.get(key) : undefined;
84
+ }
14
85
  // One renderer per mounted surface: the options close over the surface so every mutation
15
86
  // can ask it to (microtask-coalesced) recommit. Vue has no resetAfterCommit; instead
16
87
  // requestCommit() collapses a burst of insert/patchProp within one tick into a single
@@ -19,14 +90,33 @@ export function createSymbioteRenderer(surface) {
19
90
  const options = {
20
91
  createElement(type) {
21
92
  const descriptor = descriptorFor(type);
22
- const node = createElement(descriptor.component, descriptor.isText);
23
- // Graft the imperative public-instance API (measure / setNativeProps / focus / …) onto
24
- // the raw node so a template/function ref to a host element exposes it exactly like
25
- // React's getPublicInstance. toPublicInstance mutates in place and returns the SAME node
26
- // identity, so the engine commit mirror (keyed on the raw node) still resolves it — the
27
- // ref must keep holding this raw node by identity (shallowRef), never a deep ref.
28
- dlog(`vue createElement ${descriptor.component} -> public instance`);
29
- return toPublicInstance(node);
93
+ // `type` as the third argument, not just `descriptor.component`: the behavior registry is
94
+ // keyed by the INTRINSIC TAG (`pressable`), while a node only ever carries the
95
+ // resolved Fabric name (`RCTView`). This is the one place that still holds both, so a
96
+ // lowered primitive whose machine lives on the engine node can be matched at all.
97
+ const node = createElement(descriptor.component, descriptor.isText, type);
98
+ if (descriptor.isText)
99
+ seedTextDefaults(node);
100
+ // The imperative public-instance API (measure / setNativeProps / focus / …) is already on
101
+ // the node's prototype, so a template/function ref to a host element exposes it exactly
102
+ // like React's getPublicInstance and toPublicInstance is the identity. The ref must keep
103
+ // holding this raw node by identity (shallowRef), never a deep ref — the engine commit
104
+ // mirror is keyed on it.
105
+ //
106
+ // The message is a THUNK, not a template literal: this runs once per node — 9 000 times on
107
+ // one benchmark press — and a literal is built at the call site before dlog can decide
108
+ // anything (see core/engine/src/debug.ts).
109
+ dlog(() => `vue createElement ${descriptor.component} -> public instance`);
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));
30
120
  },
31
121
  createText(text) {
32
122
  // Vue mounts Fragment boundaries (v-for / v-if lists / multi-root) as EMPTY text
@@ -107,14 +197,24 @@ export function createSymbioteRenderer(surface) {
107
197
  const index = siblings.indexOf(node);
108
198
  return index >= 0 ? (siblings[index + 1] ?? null) : null;
109
199
  },
110
- patchProp(el, key, _prev, next) {
200
+ patchProp(el, key, _prev, next, _namespace, parentComponent) {
111
201
  if (isSurface(el))
112
202
  return;
203
+ // Kebab -> camel happens HERE, not only inside a component wrapper: the SFC transformer
204
+ // lowers View/Text to their intrinsic tags (metro-vue-transformer.cjs), so those props
205
+ // arrive one key at a time with no component to fold the bag. Idempotent for the wrapped
206
+ // path, which already normalized.
207
+ const normalized = normalizeVueAttrKey(key);
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;
113
213
  // routeProp makes the prop-vs-event decision from the node's ViewConfig (onPress on a
114
214
  // View becomes a listener; onTintColor on a Switch stays a prop), shared with React. The
115
215
  // class/style merge (explicit :style always winning, regardless of which of Vue's two
116
216
  // independent patchProp calls lands last) is centralized there too (core/engine/src/node.ts).
117
- routeProp(el, key, next);
217
+ routeProp(el, name, routed);
118
218
  surface.requestCommit();
119
219
  },
120
220
  // RN has no querySelector / scope-id / innerHTML. The first two are inert; static
@@ -1,8 +1,11 @@
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';
4
+ export declare function normalizeProps(props: Record<string, unknown> | null | undefined): Record<string, unknown> | null;
5
+ export declare function normalizeStyle(value: unknown): unknown;
6
+ export declare function mergeProps(...args: Record<string, unknown>[]): Record<string, unknown>;
5
7
  export declare const vShow: ObjectDirective<ISymbioteNode, boolean>;
8
+ export declare const vModelText: ObjectDirective<ISymbioteNode, unknown>;
6
9
  type ISystemModifier = 'ctrl' | 'shift' | 'alt' | 'meta';
7
10
  type IEventModifier = ISystemModifier | 'stop' | 'prevent' | 'self' | 'left' | 'middle' | 'right' | 'exact';
8
11
  type IModifierGuardableEvent = {
@@ -24,6 +27,16 @@ type IEventHandler<TEvent> = ((event: TEvent, ...args: never[]) => unknown) & {
24
27
  * typed as required, matching upstream Vue's own declaration — its runtime-only `!fn` guard below
25
28
  * (for a compiler-generated call site that could pass a falsy handler) isn't reflected in the
26
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`.
27
40
  */
28
41
  export declare function withModifiers<TEvent extends IModifierGuardableEvent>(fn: IEventHandler<TEvent>, modifiers: readonly IEventModifier[]): IEventHandler<TEvent>;
29
42
  type IKeyedEvent = {
@@ -6,16 +6,110 @@
6
6
  // real HTMLElement - we have no DOM, so this module supplies our OWN implementation under the
7
7
  // same export name instead of leaving `v-show` a silent no-op.
8
8
  //
9
- // Scope: this covers only Vue's own template directives (v-show, Teleport); native-element
10
- // v-model is a separate, out-of-scope case.
9
+ // Scope: Vue's own template directives (v-show, Teleport) and, since host-primitive lowering
10
+ // landed, `v-model` on an ELEMENT — see vModelText below for why that stopped being out of scope.
11
11
  export * from '@vue/runtime-core';
12
- import { getCurrentInstance, warn, } from '@vue/runtime-core';
13
- import { setNativeProps, whenCommitted, } from '@symbiote-native/engine';
14
- // Shadows runtime-core's own Teleport for every compiled `from 'vue'` import (see the header):
15
- // same component, plus a runtime guard on `to`. Lives in ../create-portal so the adapter's portal
16
- // sits where every other adapter's does; re-exported here because THIS is the module Metro
17
- // rewrites `vue` to, and an `export *` alone would resolve `Teleport` to the unguarded original.
18
- export { Teleport } from '../create-portal/index.js';
12
+ import { getCurrentInstance, mergeProps as baseMergeProps, normalizeClass, normalizeStyle as baseNormalizeStyle, warn, } from '@vue/runtime-core';
13
+ import { requestCommitFor, setNativeProps, setProp, whenCommitted, } from '@symbiote-native/engine';
14
+ import { descriptorFor, SWITCH_TAG } from '@symbiote-native/components';
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.
30
+ // Shadows @vue/shared's own `normalizeProps` for every compiled `from 'vue'` import, for ONE
31
+ // divergence: a FUNCTION-valued `style` survives it.
32
+ //
33
+ // `v-bind="obj"` compiles to `_normalizeProps(_guardReactiveProps(obj))`, and Vue's version runs
34
+ // `normalizeStyle` over the style key. That helper knows array / string / object and returns
35
+ // `undefined` for a function — so `style: ({ pressed }) => …` arriving inside a spread is destroyed
36
+ // before any adapter code runs, with no error and nothing red. A functional style is a React Native
37
+ // idiom the engine resolves in `routeProp` (`isStyleCallback`); Vue's normaliser is DOM-shaped and
38
+ // cannot know that.
39
+ //
40
+ // Measured 2026-09-01 through the real SFC pipeline, which is the ONLY path that reaches this:
41
+ // `h(Component, { ...bag })` skips `normalizeProps` entirely and shows the style arriving intact,
42
+ // so a probe written that way reports the bug as absent.
43
+ //
44
+ // <View v-bind="bag"> fn style committed {testID} <- style gone
45
+ // <Pressable v-bind="bag"> fn style committed {testID} <- style gone
46
+ // either, object style committed {testID, opacity} <- fine
47
+ //
48
+ // EVERY primitive, not only the stateful ones — both arms measured. Only `style`: `normalizeClass`
49
+ // has no equivalent hazard that anyone has shown, and an override wider than the measured defect is
50
+ // how one adapter's problem becomes a shared law.
51
+ //
52
+ // MIRRORS Vue's four lines rather than delegating to them, because hiding the key from the original
53
+ // and restoring it afterwards would mutate the caller's object twice. The normalisation itself is
54
+ // still Vue's — only the dispatch is ours, and `normalize-props.test.ts` pins it against the
55
+ // compiled path.
56
+ export function normalizeProps(props) {
57
+ if (!props)
58
+ return null;
59
+ const { class: klass, style } = props;
60
+ if (klass && typeof klass !== 'string')
61
+ props.class = normalizeClass(klass);
62
+ if (style && typeof style !== 'function')
63
+ props.style = baseNormalizeStyle(style);
64
+ return props;
65
+ }
66
+ // The SECOND door to the same defect, and the one the first fix missed. `v-bind="rest"` standing
67
+ // beside a separate `:style="fn"` does not compile to `normalizeProps` at all — it compiles to
68
+ // `mergeProps`, whose own `style` branch is `ret.style = normalizeStyle([ret.style, toMerge.style])`.
69
+ // The array branch of `normalizeStyle` drops a function element, so the callback dies here too:
70
+ //
71
+ // <View v-bind="bag" /> normalizeProps <- fixed above
72
+ // <View v-bind="rest" :style="fn" /> mergeProps <- fixed here
73
+ // <View :style="fn" /> neither <- was never broken
74
+ //
75
+ // Delegates the whole merge — classes, event-handler concatenation, key precedence — and only
76
+ // rescues the callback afterwards, scanning the args in mergeProps' own order so LAST wins. A
77
+ // later non-function `style` clears it again, which is that same precedence.
78
+ //
79
+ // The merge itself cannot be preserved when one side is a callback: a function returns a COMPLETE
80
+ // style, so there is nothing to merge it into. It replaces, which is also how React Native's own
81
+ // contract reads it (`Pressable.js`: `ViewStyleProp | ((state) => ViewStyleProp)` — the callback is
82
+ // top-level, never an array element, so `:style="[a, fn]"` is out of contract here exactly as it is
83
+ // there, and is deliberately NOT rescued).
84
+ // The THIRD door, and the one that decides whether the state-style split can leave the transforms
85
+ // at all. A `:style` binding on a lowered element compiles to `_normalizeStyle(expr)` whenever the
86
+ // compiler cannot keep it on the cheap patch-flag path — an inline arrow and a call expression both
87
+ // do, a bare identifier does not. So `<Pressable :style="({pressed}) => …" />` loses its callback
88
+ // here, and today nothing notices because the lowering transform rewrites that attribute into a
89
+ // resting/active pair before Vue ever emits the helper.
90
+ //
91
+ // A TOP-LEVEL function is preserved; one nested inside an array is not, and that asymmetry is the
92
+ // contract rather than an omission. React Native types the prop
93
+ // `ViewStyleProp | ((state) => ViewStyleProp)` (`Pressable.js`), so the callback is top-level or it
94
+ // is not a callback, and the engine's `isStyleCallback` reads it the same way. Rescuing an array
95
+ // element would invent a shape neither side supports.
96
+ export function normalizeStyle(value) {
97
+ if (typeof value === 'function')
98
+ return value;
99
+ return baseNormalizeStyle(value);
100
+ }
101
+ export function mergeProps(...args) {
102
+ const merged = baseMergeProps(...args);
103
+ let callback;
104
+ for (const arg of args) {
105
+ if (arg === null || arg === undefined || !('style' in arg))
106
+ continue;
107
+ callback = typeof arg.style === 'function' ? arg.style : undefined;
108
+ }
109
+ if (callback !== undefined)
110
+ merged.style = callback;
111
+ return merged;
112
+ }
19
113
  // whenCommitted, not a direct call: Vue's `mounted` hook fires synchronously during the patch
20
114
  // pass, but this renderer coalesces the actual Fabric commit onto a microtask
21
115
  // (surface.requestCommit()), so the node may have no committed tag yet - the same async-commit
@@ -33,6 +127,143 @@ export const vShow = {
33
127
  // Drop a still-pending first-commit wait if the element unmounts before it ever lands.
34
128
  unmounted: el => pendingShowCommits.get(el)?.(),
35
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
+ }
138
+ const modelStates = new WeakMap();
139
+ // Runtime guards, not casts: both slots are read out of untyped bags — `vnode.props` and
140
+ // `node.props` are `Record<string, unknown>` — and `typeof x === 'function'` alone narrows to the
141
+ // bare `Function`, which carries no signature.
142
+ function isModelAssign(value) {
143
+ return typeof value === 'function';
144
+ }
145
+ function isValueChangeListener(value) {
146
+ return typeof value === 'function';
147
+ }
148
+ // Vue merges duplicate handlers into an array, so the assigner is either a function or a list of
149
+ // them — the same shape upstream's `getModelAssigner` normalises.
150
+ function modelAssigner(props) {
151
+ const bound = props?.['onUpdate:modelValue'] ?? props?.['onUpdate:value'];
152
+ if (isModelAssign(bound))
153
+ return bound;
154
+ if (!Array.isArray(bound))
155
+ return undefined;
156
+ const fns = bound.filter(isModelAssign);
157
+ return value => fns.forEach(fn => fn(value));
158
+ }
159
+ function modelText(value) {
160
+ if (typeof value === 'string')
161
+ return value;
162
+ return value === undefined || value === null ? '' : String(value);
163
+ }
164
+ // `.trim` and `.number` are pure transforms of the text and behave exactly as upstream. `.lazy` is
165
+ // NOT: upstream implements it by listening to `change` instead of `input`, and this renderer has
166
+ // only the one native stream (RN fires `onChange` per keystroke), so there is no second event to
167
+ // switch to. It warns rather than silently behaving like the eager form.
168
+ function applyModelModifiers(state, text) {
169
+ const trimmed = state.trim ? text.trim() : text;
170
+ if (!state.number)
171
+ return trimmed;
172
+ const parsed = Number.parseFloat(trimmed);
173
+ return Number.isNaN(parsed) ? trimmed : parsed;
174
+ }
175
+ function modelStateFor(el) {
176
+ const existing = modelStates.get(el);
177
+ if (existing !== undefined)
178
+ return existing;
179
+ const state = {
180
+ assign: undefined,
181
+ appListener: undefined,
182
+ trim: false,
183
+ number: false,
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);
194
+ },
195
+ };
196
+ modelStates.set(el, state);
197
+ return state;
198
+ }
199
+ // Re-read every beat rather than installed once: the app's own `onValueChange` binding is written
200
+ // by `patchProp` during the same patch, so anything captured earlier is either missing or stale. A
201
+ // prop that is already OUR listener means nothing overwrote it and the app half is unchanged.
202
+ function syncModelListener(el, state) {
203
+ const current = el.props.onValueChange;
204
+ if (current === state.listener)
205
+ return;
206
+ state.appListener = isValueChangeListener(current) ? current : undefined;
207
+ setProp(el, 'onValueChange', state.listener);
208
+ }
209
+ // Vue's compiler picks the directive by ELEMENT, and for anything it does not recognise as a DOM
210
+ // input it emits `vModelText` — including a lowered `<switch>`. Stringifying there is what
211
+ // upstream must do (a DOM input's value IS a string) and what we must not: the Switch behavior
212
+ // reads `props.value === true`, so `String(true)` pins the control OFF and no tap can move it.
213
+ // Device-confirmed on `examples/vue-sfc` 2026-09-02, both switches on `CanaryScreen`.
214
+ //
215
+ // Resolved through the shared descriptor table rather than a literal Fabric name, so the check
216
+ // follows the platform the bundle actually loaded (iOS `Switch`, Android `AndroidSwitch`) instead
217
+ // of carrying a copy of both.
218
+ const SWITCH_COMPONENT = descriptorFor(SWITCH_TAG).component;
219
+ function isSwitchNode(el) {
220
+ return el.component === SWITCH_COMPONENT;
221
+ }
222
+ function syncModelValue(el, value) {
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.
226
+ if (isSwitchNode(el)) {
227
+ setProp(el, 'value', value === true);
228
+ requestCommitFor(el);
229
+ return;
230
+ }
231
+ setProp(el, 'value', modelText(value));
232
+ // The behavior's `afterCommit` is what turns a changed `value` into the stale-safe
233
+ // setTextAndSelection command, and nothing else in this patch is going to ask for a commit — the
234
+ // template no longer carries a `value` binding at all once `v-model` owns it.
235
+ requestCommitFor(el);
236
+ }
237
+ export const vModelText = {
238
+ // `created` runs BEFORE props are patched, which is exactly what the value half needs: the
239
+ // behavior seeds its `lastNativeText` mirror from `value` on the FIRST commit, and a value that
240
+ // arrives after that seed reads as a divergence and commands redundant text down to native.
241
+ created(el, { value, modifiers }) {
242
+ const state = modelStateFor(el);
243
+ state.trim = modifiers.trim === true;
244
+ state.number = modifiers.number === true;
245
+ if (modifiers.lazy === true) {
246
+ warn('v-model.lazy has no effect here: this renderer has one native text stream, so there is no ' +
247
+ 'second event to defer to.');
248
+ }
249
+ syncModelValue(el, value);
250
+ },
251
+ mounted(el, { value }, vnode) {
252
+ const state = modelStateFor(el);
253
+ state.assign = modelAssigner(vnode.props);
254
+ syncModelListener(el, state);
255
+ syncModelValue(el, value);
256
+ },
257
+ updated(el, { value }, vnode) {
258
+ const state = modelStateFor(el);
259
+ state.assign = modelAssigner(vnode.props);
260
+ syncModelListener(el, state);
261
+ syncModelValue(el, value);
262
+ },
263
+ unmounted(el) {
264
+ modelStates.delete(el);
265
+ },
266
+ };
36
267
  const SYSTEM_MODIFIERS = [
37
268
  'ctrl',
38
269
  'shift',
@@ -63,6 +294,16 @@ const MODIFIER_GUARDS = {
63
294
  * typed as required, matching upstream Vue's own declaration — its runtime-only `!fn` guard below
64
295
  * (for a compiler-generated call site that could pass a falsy handler) isn't reflected in the
65
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`.
66
307
  */
67
308
  export function withModifiers(fn, modifiers) {
68
309
  if (!fn)