@symbiote-native/vue 0.5.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/index.js CHANGED
@@ -1,6 +1,11 @@
1
1
  // @symbiote-native/vue: a thin Vue 3 reconciler over @symbiote-native/engine. createRenderer maps
2
2
  // each RendererOptions call onto the engine's mutation API; all Fabric clone-on-write
3
3
  // lives in the engine, shared with every other adapter. App code names only @symbiote-native/vue.
4
+ // Side-effect import, FIRST and deliberately not a re-export: it installs the engine-side press
5
+ // machine that a lowered `<symbiote-pressable>` needs. `export * from './register'` or a bare
6
+ // import sitting beside a re-export of the same specifier both go lazy under Metro's production
7
+ // `inlineRequires` and the registration silently never runs in a release build. See register.ts.
8
+ import './register.js';
4
9
  export { mount, unmount, setAppConfigurator } from './render.js';
5
10
  // The portal: Vue's own <Teleport>, guarded so `to` must be a node/surface this renderer actually
6
11
  // mounted (there is no querySelector). It MOVES host nodes, so it reaches any already-mounted
@@ -1,4 +1,4 @@
1
1
  import { type Component } from '@vue/runtime-core';
2
- export declare function createAnimatedComponent(Component: Component): import("vue").DefineComponent<{}, () => import("vue").VNode<import("vue").RendererNode, import("vue").RendererElement, {
2
+ export declare function createAnimatedComponent(Component: Component | string): import("vue").DefineComponent<{}, () => import("vue").VNode<import("vue").RendererNode, import("vue").RendererElement, {
3
3
  [key: string]: any;
4
4
  }>, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
@@ -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
  },
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,35 @@
1
+ // Side-effect ONLY. This module exports nothing, on purpose.
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';`.
7
+ //
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();
@@ -3,14 +3,47 @@
3
3
  // drives the exact same retained tree React does: the proof the core is framework-
4
4
  // agnostic.
5
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';
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 `symbiote-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('symbiote-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
+ // An explicit `undefined` must NOT clear one of those defaults: RN treats a missing prop and an
42
+ // explicit undefined alike, and only a literal `false` opts out of allowFontScaling. Reached
43
+ // only when a value is already undefined, so it costs nothing on the hot path.
44
+ function textDefaultFor(node, key) {
45
+ return node.isText ? TEXT_DEFAULTS.get(key) : undefined;
46
+ }
14
47
  // One renderer per mounted surface: the options close over the surface so every mutation
15
48
  // can ask it to (microtask-coalesced) recommit. Vue has no resetAfterCommit; instead
16
49
  // requestCommit() collapses a burst of insert/patchProp within one tick into a single
@@ -19,13 +52,23 @@ export function createSymbioteRenderer(surface) {
19
52
  const options = {
20
53
  createElement(type) {
21
54
  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`);
55
+ // `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
57
+ // resolved Fabric name (`RCTView`). This is the one place that still holds both, so a
58
+ // lowered primitive whose machine lives on the engine node can be matched at all.
59
+ const node = createElement(descriptor.component, descriptor.isText, type);
60
+ if (descriptor.isText)
61
+ seedTextDefaults(node);
62
+ // The imperative public-instance API (measure / setNativeProps / focus / …) is already on
63
+ // the node's prototype, so a template/function ref to a host element exposes it exactly
64
+ // like React's getPublicInstance and toPublicInstance is the identity. The ref must keep
65
+ // holding this raw node by identity (shallowRef), never a deep ref — the engine commit
66
+ // mirror is keyed on it.
67
+ //
68
+ // The message is a THUNK, not a template literal: this runs once per node — 9 000 times on
69
+ // one benchmark press — and a literal is built at the call site before dlog can decide
70
+ // anything (see core/engine/src/debug.ts).
71
+ dlog(() => `vue createElement ${descriptor.component} -> public instance`);
29
72
  return toPublicInstance(node);
30
73
  },
31
74
  createText(text) {
@@ -110,11 +153,17 @@ export function createSymbioteRenderer(surface) {
110
153
  patchProp(el, key, _prev, next) {
111
154
  if (isSurface(el))
112
155
  return;
156
+ // Kebab -> camel happens HERE, not only inside a component wrapper: the SFC transformer
157
+ // lowers View/Text to their intrinsic tags (metro-vue-transformer.cjs), so those props
158
+ // arrive one key at a time with no component to fold the bag. Idempotent for the wrapped
159
+ // path, which already normalized.
160
+ const normalized = normalizeVueAttrKey(key);
161
+ const name = PROP_ALIASES.get(normalized) ?? normalized;
113
162
  // routeProp makes the prop-vs-event decision from the node's ViewConfig (onPress on a
114
163
  // View becomes a listener; onTintColor on a Switch stays a prop), shared with React. The
115
164
  // class/style merge (explicit :style always winning, regardless of which of Vue's two
116
165
  // independent patchProp calls lands last) is centralized there too (core/engine/src/node.ts).
117
- routeProp(el, key, next);
166
+ routeProp(el, name, next === undefined ? textDefaultFor(el, name) : next);
118
167
  surface.requestCommit();
119
168
  },
120
169
  // RN has no querySelector / scope-id / innerHTML. The first two are inert; static
@@ -2,7 +2,11 @@ export * from '@vue/runtime-core';
2
2
  import { type ObjectDirective } from '@vue/runtime-core';
3
3
  import { type ISymbioteNode } from '@symbiote-native/engine';
4
4
  export { Teleport, type ITeleportTarget } from '../create-portal';
5
+ export declare function normalizeProps(props: Record<string, unknown> | null | undefined): Record<string, unknown> | null;
6
+ export declare function normalizeStyle(value: unknown): unknown;
7
+ export declare function mergeProps(...args: Record<string, unknown>[]): Record<string, unknown>;
5
8
  export declare const vShow: ObjectDirective<ISymbioteNode, boolean>;
9
+ export declare const vModelText: ObjectDirective<ISymbioteNode, unknown>;
6
10
  type ISystemModifier = 'ctrl' | 'shift' | 'alt' | 'meta';
7
11
  type IEventModifier = ISystemModifier | 'stop' | 'prevent' | 'self' | 'left' | 'middle' | 'right' | 'exact';
8
12
  type IModifierGuardableEvent = {
@@ -6,16 +6,100 @@
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';
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';
14
15
  // Shadows runtime-core's own Teleport for every compiled `from 'vue'` import (see the header):
15
16
  // same component, plus a runtime guard on `to`. Lives in ../create-portal so the adapter's portal
16
17
  // sits where every other adapter's does; re-exported here because THIS is the module Metro
17
18
  // rewrites `vue` to, and an `export *` alone would resolve `Teleport` to the unguarded original.
18
19
  export { Teleport } from '../create-portal/index.js';
20
+ // Shadows @vue/shared's own `normalizeProps` for every compiled `from 'vue'` import, for ONE
21
+ // divergence: a FUNCTION-valued `style` survives it.
22
+ //
23
+ // `v-bind="obj"` compiles to `_normalizeProps(_guardReactiveProps(obj))`, and Vue's version runs
24
+ // `normalizeStyle` over the style key. That helper knows array / string / object and returns
25
+ // `undefined` for a function — so `style: ({ pressed }) => …` arriving inside a spread is destroyed
26
+ // before any adapter code runs, with no error and nothing red. A functional style is a React Native
27
+ // idiom the engine resolves in `routeProp` (`isStyleCallback`); Vue's normaliser is DOM-shaped and
28
+ // cannot know that.
29
+ //
30
+ // Measured 2026-09-01 through the real SFC pipeline, which is the ONLY path that reaches this:
31
+ // `h(Component, { ...bag })` skips `normalizeProps` entirely and shows the style arriving intact,
32
+ // so a probe written that way reports the bug as absent.
33
+ //
34
+ // <View v-bind="bag"> fn style committed {testID} <- style gone
35
+ // <Pressable v-bind="bag"> fn style committed {testID} <- style gone
36
+ // either, object style committed {testID, opacity} <- fine
37
+ //
38
+ // EVERY primitive, not only the stateful ones — both arms measured. Only `style`: `normalizeClass`
39
+ // has no equivalent hazard that anyone has shown, and an override wider than the measured defect is
40
+ // how one adapter's problem becomes a shared law.
41
+ //
42
+ // MIRRORS Vue's four lines rather than delegating to them, because hiding the key from the original
43
+ // and restoring it afterwards would mutate the caller's object twice. The normalisation itself is
44
+ // still Vue's — only the dispatch is ours, and `normalize-props.test.ts` pins it against the
45
+ // compiled path.
46
+ export function normalizeProps(props) {
47
+ if (!props)
48
+ return null;
49
+ const { class: klass, style } = props;
50
+ if (klass && typeof klass !== 'string')
51
+ props.class = normalizeClass(klass);
52
+ if (style && typeof style !== 'function')
53
+ props.style = baseNormalizeStyle(style);
54
+ return props;
55
+ }
56
+ // The SECOND door to the same defect, and the one the first fix missed. `v-bind="rest"` standing
57
+ // beside a separate `:style="fn"` does not compile to `normalizeProps` at all — it compiles to
58
+ // `mergeProps`, whose own `style` branch is `ret.style = normalizeStyle([ret.style, toMerge.style])`.
59
+ // The array branch of `normalizeStyle` drops a function element, so the callback dies here too:
60
+ //
61
+ // <View v-bind="bag" /> normalizeProps <- fixed above
62
+ // <View v-bind="rest" :style="fn" /> mergeProps <- fixed here
63
+ // <View :style="fn" /> neither <- was never broken
64
+ //
65
+ // Delegates the whole merge — classes, event-handler concatenation, key precedence — and only
66
+ // rescues the callback afterwards, scanning the args in mergeProps' own order so LAST wins. A
67
+ // later non-function `style` clears it again, which is that same precedence.
68
+ //
69
+ // The merge itself cannot be preserved when one side is a callback: a function returns a COMPLETE
70
+ // style, so there is nothing to merge it into. It replaces, which is also how React Native's own
71
+ // contract reads it (`Pressable.js`: `ViewStyleProp | ((state) => ViewStyleProp)` — the callback is
72
+ // top-level, never an array element, so `:style="[a, fn]"` is out of contract here exactly as it is
73
+ // there, and is deliberately NOT rescued).
74
+ // The THIRD door, and the one that decides whether the state-style split can leave the transforms
75
+ // at all. A `:style` binding on a lowered element compiles to `_normalizeStyle(expr)` whenever the
76
+ // compiler cannot keep it on the cheap patch-flag path — an inline arrow and a call expression both
77
+ // do, a bare identifier does not. So `<Pressable :style="({pressed}) => …" />` loses its callback
78
+ // here, and today nothing notices because the lowering transform rewrites that attribute into a
79
+ // resting/active pair before Vue ever emits the helper.
80
+ //
81
+ // A TOP-LEVEL function is preserved; one nested inside an array is not, and that asymmetry is the
82
+ // contract rather than an omission. React Native types the prop
83
+ // `ViewStyleProp | ((state) => ViewStyleProp)` (`Pressable.js`), so the callback is top-level or it
84
+ // is not a callback, and the engine's `isStyleCallback` reads it the same way. Rescuing an array
85
+ // element would invent a shape neither side supports.
86
+ export function normalizeStyle(value) {
87
+ if (typeof value === 'function')
88
+ return value;
89
+ return baseNormalizeStyle(value);
90
+ }
91
+ export function mergeProps(...args) {
92
+ const merged = baseMergeProps(...args);
93
+ let callback;
94
+ for (const arg of args) {
95
+ if (arg === null || arg === undefined || !('style' in arg))
96
+ continue;
97
+ callback = typeof arg.style === 'function' ? arg.style : undefined;
98
+ }
99
+ if (callback !== undefined)
100
+ merged.style = callback;
101
+ return merged;
102
+ }
19
103
  // whenCommitted, not a direct call: Vue's `mounted` hook fires synchronously during the patch
20
104
  // pass, but this renderer coalesces the actual Fabric commit onto a microtask
21
105
  // (surface.requestCommit()), so the node may have no committed tag yet - the same async-commit
@@ -33,6 +117,127 @@ export const vShow = {
33
117
  // Drop a still-pending first-commit wait if the element unmounts before it ever lands.
34
118
  unmounted: el => pendingShowCommits.get(el)?.(),
35
119
  };
120
+ const modelStates = new WeakMap();
121
+ // Runtime guards, not casts: both slots are read out of untyped bags — `vnode.props` and
122
+ // `node.props` are `Record<string, unknown>` — and `typeof x === 'function'` alone narrows to the
123
+ // bare `Function`, which carries no signature.
124
+ function isModelAssign(value) {
125
+ return typeof value === 'function';
126
+ }
127
+ function isValueChangeListener(value) {
128
+ return typeof value === 'function';
129
+ }
130
+ // Vue merges duplicate handlers into an array, so the assigner is either a function or a list of
131
+ // them — the same shape upstream's `getModelAssigner` normalises.
132
+ function modelAssigner(props) {
133
+ const bound = props?.['onUpdate:modelValue'] ?? props?.['onUpdate:value'];
134
+ if (isModelAssign(bound))
135
+ return bound;
136
+ if (!Array.isArray(bound))
137
+ return undefined;
138
+ const fns = bound.filter(isModelAssign);
139
+ return value => fns.forEach(fn => fn(value));
140
+ }
141
+ function modelText(value) {
142
+ if (typeof value === 'string')
143
+ return value;
144
+ return value === undefined || value === null ? '' : String(value);
145
+ }
146
+ // `.trim` and `.number` are pure transforms of the text and behave exactly as upstream. `.lazy` is
147
+ // NOT: upstream implements it by listening to `change` instead of `input`, and this renderer has
148
+ // only the one native stream (RN fires `onChange` per keystroke), so there is no second event to
149
+ // switch to. It warns rather than silently behaving like the eager form.
150
+ function applyModelModifiers(state, text) {
151
+ const trimmed = state.trim ? text.trim() : text;
152
+ if (!state.number)
153
+ return trimmed;
154
+ const parsed = Number.parseFloat(trimmed);
155
+ return Number.isNaN(parsed) ? trimmed : parsed;
156
+ }
157
+ function modelStateFor(el) {
158
+ const existing = modelStates.get(el);
159
+ if (existing !== undefined)
160
+ return existing;
161
+ const state = {
162
+ assign: undefined,
163
+ appListener: undefined,
164
+ trim: false,
165
+ number: false,
166
+ listener: (text, event) => {
167
+ state.appListener?.(text, event);
168
+ state.assign?.(applyModelModifiers(state, text));
169
+ },
170
+ };
171
+ modelStates.set(el, state);
172
+ return state;
173
+ }
174
+ // Re-read every beat rather than installed once: the app's own `onValueChange` binding is written
175
+ // by `patchProp` during the same patch, so anything captured earlier is either missing or stale. A
176
+ // prop that is already OUR listener means nothing overwrote it and the app half is unchanged.
177
+ function syncModelListener(el, state) {
178
+ const current = el.props.onValueChange;
179
+ if (current === state.listener)
180
+ return;
181
+ state.appListener = isValueChangeListener(current) ? current : undefined;
182
+ setProp(el, 'onValueChange', state.listener);
183
+ }
184
+ // 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
186
+ // upstream must do (a DOM input's value IS a string) and what we must not: the Switch behavior
187
+ // reads `props.value === true`, so `String(true)` pins the control OFF and no tap can move it.
188
+ // Device-confirmed on `examples/vue-sfc` 2026-09-02, both switches on `CanaryScreen`.
189
+ //
190
+ // Resolved through the shared descriptor table rather than a literal Fabric name, so the check
191
+ // follows the platform the bundle actually loaded (iOS `Switch`, Android `AndroidSwitch`) instead
192
+ // of carrying a copy of both.
193
+ const SWITCH_COMPONENT = descriptorFor(SWITCH_TAG).component;
194
+ function isSwitchNode(el) {
195
+ return el.component === SWITCH_COMPONENT;
196
+ }
197
+ 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.
200
+ if (isSwitchNode(el)) {
201
+ setProp(el, 'value', value === true);
202
+ requestCommitFor(el);
203
+ return;
204
+ }
205
+ setProp(el, 'value', modelText(value));
206
+ // The behavior's `afterCommit` is what turns a changed `value` into the stale-safe
207
+ // setTextAndSelection command, and nothing else in this patch is going to ask for a commit — the
208
+ // template no longer carries a `value` binding at all once `v-model` owns it.
209
+ requestCommitFor(el);
210
+ }
211
+ export const vModelText = {
212
+ // `created` runs BEFORE props are patched, which is exactly what the value half needs: the
213
+ // behavior seeds its `lastNativeText` mirror from `value` on the FIRST commit, and a value that
214
+ // arrives after that seed reads as a divergence and commands redundant text down to native.
215
+ created(el, { value, modifiers }) {
216
+ const state = modelStateFor(el);
217
+ state.trim = modifiers.trim === true;
218
+ state.number = modifiers.number === true;
219
+ if (modifiers.lazy === true) {
220
+ warn('v-model.lazy has no effect here: this renderer has one native text stream, so there is no ' +
221
+ 'second event to defer to.');
222
+ }
223
+ syncModelValue(el, value);
224
+ },
225
+ mounted(el, { value }, vnode) {
226
+ const state = modelStateFor(el);
227
+ state.assign = modelAssigner(vnode.props);
228
+ syncModelListener(el, state);
229
+ syncModelValue(el, value);
230
+ },
231
+ updated(el, { value }, vnode) {
232
+ const state = modelStateFor(el);
233
+ state.assign = modelAssigner(vnode.props);
234
+ syncModelListener(el, state);
235
+ syncModelValue(el, value);
236
+ },
237
+ unmounted(el) {
238
+ modelStates.delete(el);
239
+ },
240
+ };
36
241
  const SYSTEM_MODIFIERS = [
37
242
  'ctrl',
38
243
  'shift',
@@ -0,0 +1 @@
1
+ export { resolveStateStyle, type IPressStateArgument, type IResolvedStateStyle, } from '@symbiote-native/components/state-style';
@@ -0,0 +1,13 @@
1
+ // Re-export only. The implementation is shared (`@symbiote-native/components`) because two adapters
2
+ // had written byte-identical copies; this file exists so the code a transform EMITS keeps importing
3
+ // from the adapter package the app already depends on, rather than reaching past it.
4
+ //
5
+ // Imported from the SUBPATH, not the barrel: every adapter barrel re-exports
6
+ // `@symbiote-native/components` wholesale, so a name on that barrel silently becomes public API on
7
+ // all five — which is how a transform internal nearly shipped as a supported export.
8
+ //
9
+ // Present on every adapter, including those with no lowering transform yet: a per-framework subpath
10
+ // that four of five declare is exactly the silent gap `tests/package-subpath-parity.test.ts` exists
11
+ // to catch, and the file costs a re-export. Deliberately NOT on the public barrel — the emitted
12
+ // code names it, app code does not.
13
+ export { resolveStateStyle, } from '@symbiote-native/components/state-style';
@@ -1 +1,2 @@
1
+ export declare function normalizeVueAttrKey(key: string): string;
1
2
  export declare function normalizeVueAttrs(attrs: Record<string, unknown>): Record<string, unknown>;
@@ -12,21 +12,27 @@ const KEBAB_SEGMENT = /-([a-z])/g;
12
12
  function toCamel(key) {
13
13
  return key.replace(KEBAB_SEGMENT, (_match, char) => char.toUpperCase());
14
14
  }
15
+ // The per-KEY half, for the path that never sees a whole attrs bag: a host primitive lowered to
16
+ // its intrinsic tag by the SFC transformer reaches the renderer one patchProp call at a time,
17
+ // with no component in between to fold the bag. Returns the key unchanged (same string identity)
18
+ // whenever there is nothing to convert, which is every key on the hot path.
19
+ export function normalizeVueAttrKey(key) {
20
+ if (!key.includes('-'))
21
+ return key;
22
+ if (key.startsWith('aria-') || key.startsWith('data-'))
23
+ return key;
24
+ return toCamel(key);
25
+ }
26
+ // Always returns a PLAIN copy, never the input. An earlier version handed the input back when no
27
+ // key needed converting - which is every SFC/TSX bag, since `@press` / `:style` already arrive
28
+ // camel - and that gave callers Vue's attrs PROXY to read from. In production, not just dev, that
29
+ // proxy's get trap is `track(target, "get", "")` before every value
30
+ // (runtime-core.esm-bundler.js:8357), so each of Pressable's ~16 per-render attr reads paid two
31
+ // map lookups it did not need. The copy loop reads every key through the trap once regardless, so
32
+ // the only thing the early return ever saved was this one object.
15
33
  export function normalizeVueAttrs(attrs) {
16
- let changed = false;
17
34
  const out = {};
18
- for (const key of Object.keys(attrs)) {
19
- if (key.includes('-') &&
20
- !key.startsWith('aria-') &&
21
- !key.startsWith('data-')) {
22
- out[toCamel(key)] = attrs[key];
23
- changed = true;
24
- }
25
- else {
26
- out[key] = attrs[key];
27
- }
28
- }
29
- // Return the original object when nothing converted (the already-camel / TSX path): avoids a
30
- // per-render allocation and keeps attrs identity stable.
31
- return changed ? out : attrs;
35
+ for (const key of Object.keys(attrs))
36
+ out[normalizeVueAttrKey(key)] = attrs[key];
37
+ return out;
32
38
  }
@@ -9,4 +9,5 @@
9
9
  // directly instead, since it needs the individual parse/compile functions, not a Metro transformer
10
10
  // object. createCssMetroTransformer is a factory — babelTransformerPath needs the actual
11
11
  // {transform, getCacheKey} object it returns, not the css-parser package barrel.
12
- module.exports = require('@symbiote-native/css-parser').createCssMetroTransformer();
12
+ module.exports =
13
+ require('@symbiote-native/css-parser').createCssMetroTransformer();