@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/README.md CHANGED
@@ -34,9 +34,23 @@ additionally needs a Metro transformer for `.vue` files (see
34
34
 
35
35
  ## Use it
36
36
 
37
- The native entry reaches the _same_ `registerRunnable` seam as React — only the adapter changes. It
38
- hands the surface's `rootTag` to `mount` from `@symbiote-native/vue`, which drives the engine through Vue's
39
- `createRenderer`:
37
+ The zero-config entry mirrors real Vue's own `createApp(App).mount(...)` idiom and wires the same
38
+ RN-backed host seams React's `registerApp` does — this is what
39
+ [`examples/vue-tsx`](../../examples/vue-tsx) and [`examples/vue-sfc`](../../examples/vue-sfc)
40
+ actually use:
41
+
42
+ ```js
43
+ // index.js
44
+ import { createApp } from '@symbiote-native/vue/bootstrap';
45
+ import App from './App';
46
+ import { name as appName } from './app.json';
47
+
48
+ createApp(App).mount(appName);
49
+ ```
50
+
51
+ For anything the defaults don't cover, drive the lower-level seam directly — the same
52
+ `registerRunnable` seam React uses, with `mount` from `@symbiote-native/vue` driving the engine
53
+ through Vue's `createRenderer`:
40
54
 
41
55
  ```js
42
56
  // index.js
@@ -115,7 +129,8 @@ React doesn't hit this because `react-reconciler` commits synchronously.
115
129
  The fix lives in the engine: `whenCommitted(node, action)` runs `action` now if the node already
116
130
  has a tag, else after the commit that assigns it. Any native/imperative call wired at Vue lifecycle
117
131
  time must go through it. This is the only place the Vue adapter's timing differs from React's; the
118
- implementation is in `core/engine/src/post-commit.ts`.
132
+ implementation is `whenCommitted` in `core/engine/src/commit.ts`, built on the generic hook
133
+ registry in `core/engine/src/post-commit.ts`.
119
134
 
120
135
  ---
121
136
 
package/babel-jsx.cjs ADDED
@@ -0,0 +1,53 @@
1
+ // The Babel plugins a Vue TSX/JSX app puts in its babel.config.js, as ONE list:
2
+ //
3
+ // const symbioteVueJsx = require('@symbiote-native/vue/babel-jsx');
4
+ // module.exports = {
5
+ // presets: ['module:@react-native/babel-preset'],
6
+ // plugins: [...symbioteVueJsx(), myOtherPlugin],
7
+ // };
8
+ //
9
+ // A list rather than a preset, on purpose. `@vue/babel-plugin-jsx` MUST run before the RN preset's
10
+ // React-JSX transform claims the same JSXElements, and Babel applies `plugins` before `presets` —
11
+ // so it has to stay in the app's plugins array, where the app also controls its position relative
12
+ // to its own plugins. A preset would move it after every plugin and silently change that order.
13
+ //
14
+ // The two entries are handed out together because either alone is broken:
15
+ //
16
+ // lowering only -> `symbiote-view` compiles to resolveComponent("symbiote-view"), a
17
+ // component that resolves to nothing, with SLOT children an element path
18
+ // never mounts. Blank subtree, no error.
19
+ // isCustomElement only -> nothing was rewritten, so <View> is still a Vue component and the whole
20
+ // point (one component instance per node on ~73% of the tree) is unpaid.
21
+ //
22
+ // Same "both halves or nothing" invariant the SFC path states in metro-vue-transformer.cjs, and the
23
+ // reason it is expressed as one require() here rather than two lines of documentation.
24
+ //
25
+ // @vue/babel-plugin-jsx is OUR dependency and require() resolves relative to this file, so the app
26
+ // declares no extra devDependency — same reasoning as ./metro-css-parser.cjs.
27
+
28
+ const vueJsx = require('@vue/babel-plugin-jsx');
29
+ const lowerHostPrimitives = require('./babel-lower-host-primitives.cjs');
30
+
31
+ // Every `symbiote-*` tag is an intrinsic the renderer resolves through descriptorFor, never a Vue
32
+ // component — including one an app writes by hand, which is why this is a prefix test rather than a
33
+ // list of the two tags the lowering emits.
34
+ const SYMBIOTE_TAG_PREFIX = 'symbiote-';
35
+
36
+ function isSymbioteIntrinsic(tag) {
37
+ return tag.startsWith(SYMBIOTE_TAG_PREFIX);
38
+ }
39
+
40
+ module.exports = function symbioteVueJsx(options = {}) {
41
+ return [
42
+ lowerHostPrimitives,
43
+ [
44
+ vueJsx,
45
+ {
46
+ ...options,
47
+ // Last, deliberately: an app may pass other @vue/babel-plugin-jsx options through, but
48
+ // overriding this one silently produces output this adapter cannot render.
49
+ isCustomElement: isSymbioteIntrinsic,
50
+ },
51
+ ],
52
+ ];
53
+ };
@@ -0,0 +1,225 @@
1
+ // Compile-time lowering of <View>/<Text> to the intrinsic tags the renderer maps to Fabric, for the
2
+ // TSX/JSX authoring path. The SFC twin is the nodeTransform in metro-vue-transformer.cjs — same
3
+ // rewrite, different vehicle, and until this existed TSX paid a tax SFC no longer did.
4
+ //
5
+ // WHY. Vue charges a full component instance even for a FUNCTIONAL component
6
+ // (createComponentInstance + initProps + initSlots + setupRenderEffect). View and Text are ~73% of
7
+ // the static tags in a real screen, so that is one instance per node on nearly three quarters of
8
+ // the tree. Lowered, they become plain element vnodes the renderer's nodeOps handle directly.
9
+ // Measured on the SFC path: 25-29% off all three create-shaped rows on device.
10
+ //
11
+ // Do NOT ship this plugin without also configuring @vue/babel-plugin-jsx's `isCustomElement` to
12
+ // answer true for `symbiote-*` — see ./babel-jsx.cjs, which is why the two are handed out together
13
+ // and should not be wired separately. Without it a lowered tag compiles to
14
+ // `createVNode(resolveComponent("symbiote-view"), …, {default: () => […]})`: a component that
15
+ // resolves to nothing, with slot children an element path would never mount.
16
+ //
17
+ // .cjs because Babel require()s it and this package is "type": "module".
18
+
19
+ const SOURCE = '@symbiote-native/vue';
20
+
21
+ // The tag map is the SHARED SPEC's, not this file's — four transforms each carried a copy before
22
+ // it existed and they had already drifted. Only `intrinsic` is read: Vue applies the `id` ->
23
+ // `nativeID` alias at RUNTIME (patchProp), because compile time reaches two of Vue's four paths to
24
+ // a node. See the spec's own `aliases` comment.
25
+ const {
26
+ HOST_PRIMITIVES,
27
+ } = require('@symbiote-native/components/host-primitives');
28
+
29
+ // A functional `style` keeps the RN idiom AND lowers, by being CALLED once per state at bag-build
30
+ // time instead of being handed to a component that calls it per press.
31
+ //
32
+
33
+ const LOWERABLE = new Map(
34
+ Object.entries(HOST_PRIMITIVES).map(([name, spec]) => [
35
+ name,
36
+ {
37
+ intrinsic: spec.intrinsic,
38
+ observesState: spec.observesState === true,
39
+ intrinsicWhen: spec.intrinsicWhen,
40
+ },
41
+ ]),
42
+ );
43
+
44
+ // REFUSAL_CATEGORIES.dynamicIntrinsicChoice. A primitive whose spec entry carries `intrinsicWhen`
45
+ // picks between TWO Fabric views by the value of one prop, and the transform prints a STATIC tag
46
+ // name — so it can only choose when that value is a compile-time literal.
47
+ //
48
+ // This is not the unreadable-VALUE hazard wearing a new hat, and the difference decides how hard to
49
+ // refuse: an unreadable value lands a prop wrong, which a later write can still correct; the wrong
50
+ // choice here commits the wrong native view, and no prop write moves a node between views. So only
51
+ // the two provable shapes resolve and everything else keeps the component.
52
+ //
53
+ // `multiline="true"` — a STRING attribute — is deliberately NOT one of them, though the component
54
+ // would treat it as truthy. A string that reads as a boolean is the shape where an author and the
55
+ // runtime disagree most often (`multiline="false"` is truthy), and refusing costs only the
56
+ // optimisation while guessing costs the right view.
57
+ //
58
+ // Returns the tag to emit, or undefined to refuse.
59
+ function intrinsicWhenFor(openingElement, entry) {
60
+ const choice = entry.intrinsicWhen;
61
+ if (choice === undefined) return entry.intrinsic;
62
+
63
+ let resolved = false;
64
+ for (const attribute of openingElement.attributes) {
65
+ // A spread may carry the selector prop, and the transform cannot see inside it. Nothing else
66
+ // in this loop can tell that apart from "the prop is absent", so it must refuse outright.
67
+ if (attribute.type === 'JSXSpreadAttribute') return undefined;
68
+ if (attribute.type !== 'JSXAttribute') continue;
69
+ if (attribute.name.type !== 'JSXIdentifier') continue;
70
+ if (attribute.name.name !== choice.prop) continue;
71
+
72
+ // A bare `multiline` with no value is JSX for `true`.
73
+ if (attribute.value === null || attribute.value === undefined) {
74
+ resolved = true;
75
+ continue;
76
+ }
77
+ if (attribute.value.type !== 'JSXExpressionContainer') return undefined;
78
+ const expression = attribute.value.expression;
79
+ if (expression.type !== 'BooleanLiteral') return undefined;
80
+ resolved = expression.value;
81
+ }
82
+
83
+ return resolved ? choice.intrinsic : entry.intrinsic;
84
+ }
85
+
86
+ // The shared spec's allow-list for `stateInTemplate`, spelled the same in all five transforms:
87
+ // only a provably inert value shape lowers. `style={styleFn}` is an Identifier at compile time and
88
+ // no transform can tell an object from a function, so anything that is not one of these refuses. A
89
+ // narrow "refuse a function literal" reading passes every obvious test and then fails on the one
90
+ // call site that hoists its style into a variable — which is what ActionButton does.
91
+ const INERT_VALUE_TYPES = new Set([
92
+ 'ObjectExpression',
93
+ 'ArrayExpression',
94
+ 'StringLiteral',
95
+ 'NumericLiteral',
96
+ 'BooleanLiteral',
97
+ 'NullLiteral',
98
+ 'TemplateLiteral',
99
+ ]);
100
+
101
+ // A bare `style="x"` attribute value is a JSXText-ish literal and inert; `style={…}` is inert only
102
+ // when the expression inside is.
103
+ function isInertValueAttribute(value) {
104
+ if (value === null || value === undefined) return true;
105
+ if (value.type !== 'JSXExpressionContainer')
106
+ return INERT_VALUE_TYPES.has(value.type);
107
+ return INERT_VALUE_TYPES.has(value.expression.type);
108
+ }
109
+
110
+ // The JSX twin of metro-vue-transformer.cjs's refusesLowering — same three categories, different
111
+ // AST. A primitive that owns state can lower only when the template does not read that state,
112
+ // because a lowered element has no instance to read it from. Refusing is always safe; lowering a
113
+ // button that reads `pressed` gives a button that renders and does not respond, on device, with
114
+ // nothing red.
115
+ function refusesLowering(openingElement, children, types) {
116
+ for (const attribute of openingElement.attributes) {
117
+ // `{...props}` — an attribute set this pass cannot enumerate, so it may hide a functional
118
+ // `style`. REFUSAL_CATEGORIES.unreadableAttributeSet.
119
+ if (attribute.type === 'JSXSpreadAttribute') return true;
120
+ if (attribute.type !== 'JSXAttribute') continue;
121
+ if (attribute.name.type !== 'JSXIdentifier') continue;
122
+
123
+ // REFUSAL_CATEGORIES.instanceBoundDirective. `ref` on a COMPONENT yields the component
124
+ // instance; on an element it yields the host node. Lowering silently changes which one the app
125
+ // receives, so a `pressableRef.value.measure()` starts reaching a different object — a runtime
126
+ // failure with nothing red at build time. Found by the shared verdict table, not by a Vue test.
127
+ if (attribute.name.name === 'ref') return true;
128
+
129
+ if (attribute.name.name !== 'style') continue;
130
+
131
+ // REFUSAL_CATEGORIES.stateInTemplate no longer fires on a `style` at all. Every shape is
132
+ // covered: an inert value rides through, and anything else is either called directly or handed
133
+ // to the runtime helper, which decides with a `typeof` what no compile-time analysis could.
134
+ // What still refuses is above (an unreadable attribute set) and below (a child reading state).
135
+ if (!isInertValueAttribute(attribute.value)) continue;
136
+ }
137
+
138
+ // A function child that TAKES an argument is the render-prop form, i.e. children as a function
139
+ // of press state. A zero-arity function child is an ordinary lazy child and lowers fine — the
140
+ // distinction the shared spec spells out for renderPropChild.
141
+ return children.some(child => {
142
+ if (child.type !== 'JSXExpressionContainer') return false;
143
+ const { expression } = child;
144
+ return (
145
+ (expression.type === 'ArrowFunctionExpression' ||
146
+ expression.type === 'FunctionExpression') &&
147
+ expression.params.length >= 1
148
+ );
149
+ });
150
+ }
151
+
152
+ // Every local name bound to a lowerable export of @symbiote-native/vue in THIS file, honouring
153
+ // `import { View as Box }`. Matching bare tag names instead would rewrite an app's own <View>.
154
+ function lowerableLocalNames(programPath) {
155
+ const names = new Map();
156
+ programPath.node.body.forEach(statement => {
157
+ if (
158
+ statement.type !== 'ImportDeclaration' ||
159
+ statement.source.value !== SOURCE
160
+ )
161
+ return;
162
+ statement.specifiers.forEach(specifier => {
163
+ if (specifier.type !== 'ImportSpecifier') return;
164
+ const imported =
165
+ specifier.imported.type === 'Identifier'
166
+ ? specifier.imported.name
167
+ : specifier.imported.value;
168
+ const entry = LOWERABLE.get(imported);
169
+ if (entry !== undefined) names.set(specifier.local.name, entry);
170
+ });
171
+ });
172
+ return names;
173
+ }
174
+
175
+ // Refusals apply ONLY to a primitive that owns state. View and Text still lower unconditionally,
176
+ // and that remains deliberate: their only folds are kebab->camel and RN's Text defaults, both of
177
+ // which moved into the renderer (normalizeVueAttrKey in patchProp, seedTextDefaults/textDefaultFor
178
+ // in createElement) when the SFC lowering landed, so nothing a spread could hide is left. Adding a
179
+ // refusal for them would make TSX diverge from SFC, which lowers them unconditionally too.
180
+ module.exports = function lowerHostPrimitives({ types }) {
181
+ return {
182
+ name: 'symbiote-vue-lower-host-primitives',
183
+ visitor: {
184
+ Program(programPath) {
185
+ const names = lowerableLocalNames(programPath);
186
+ if (names.size === 0) return;
187
+ programPath.traverse({
188
+ JSXElement(elementPath) {
189
+ const { openingElement, closingElement } = elementPath.node;
190
+ const tag = openingElement.name;
191
+ if (tag.type !== 'JSXIdentifier') return;
192
+ const entry = names.get(tag.name);
193
+ if (entry === undefined) return;
194
+ // Resolved BEFORE the state refusal and outside its `observesState` gate: the two
195
+ // guard different things, and this one applies to a primitive that owns no state at
196
+ // all. Reading it as "another case for refusesLowering" would put it behind that flag
197
+ // and it would never run for TextInput.
198
+ const intrinsic = intrinsicWhenFor(openingElement, entry);
199
+ if (intrinsic === undefined) return;
200
+ if (
201
+ entry.observesState &&
202
+ refusesLowering(openingElement, elementPath.node.children, types)
203
+ )
204
+ return;
205
+ // A shadowing local binding (a parameter, or a `const View = …` in scope) is NOT our
206
+ // import even though the name matches. Only rewrite where the binding at this use site
207
+ // is still the module-level import.
208
+ const binding = elementPath.scope.getBinding(tag.name);
209
+ if (binding === undefined || binding.kind !== 'module') return;
210
+ // NO state-style expansion. A functional `style` reaches `routeProp` untouched and
211
+ // the engine resolves it at both values of `pressed` (`isStyleCallback`), so rewriting
212
+ // the attribute into a pair here would be this transform carrying BEHAVIOUR — what
213
+ // `tests/lowering-transform-carries-no-behaviour.test.ts` exists to keep out. Removed
214
+ // from BOTH Vue paths in one change: the SFC twin sees an expression as source text
215
+ // where this one has the AST, so a split removed from one and not the other is the
216
+ // exact drift `.claude/rules/adapter-parity-audit.md` records for this pair.
217
+ openingElement.name = types.jsxIdentifier(intrinsic);
218
+ if (closingElement)
219
+ closingElement.name = types.jsxIdentifier(intrinsic);
220
+ },
221
+ });
222
+ },
223
+ },
224
+ };
225
+ };
@@ -4,7 +4,7 @@
4
4
  // forwards the native-only props. The Vue twin of the React adapter's Button.
5
5
  import { defineComponent, h } from '@vue/runtime-core';
6
6
  import { BUTTON_ACCESSIBILITY_ROLE, resolveButtonTextStyle, } from '@symbiote-native/components';
7
- import { Text } from '../components.js';
7
+ import { HOST_TEXT } from '../components.js';
8
8
  import { TouchableOpacity } from './touchable.js';
9
9
  import { normalizeVueAttrs } from '../utils/normalize-attrs.js';
10
10
  // The props Button consumes / re-maps itself; everything else (accessibility, aria, TV-focus,
@@ -47,7 +47,9 @@ export const Button = defineComponent((_props, { attrs: rawAttrs, emit }) => {
47
47
  onPress: (event) => emit('press', event),
48
48
  };
49
49
  return h(TouchableOpacity, touchableProps, {
50
- default: () => [h(Text, { style: textStyle }, () => title)],
50
+ // The intrinsic tag, not our <Text> component — see the note at HOST_VIEW. RN's Text
51
+ // defaults are seeded by the renderer for a bare tag (seedTextDefaults).
52
+ default: () => [h(HOST_TEXT, { style: textStyle }, title)],
51
53
  });
52
54
  };
53
55
  }, {
@@ -11,10 +11,11 @@
11
11
  // forwarded attrs (pure-JS callbacks); the machine's SYNTHESIZED handlers go on the View instead.
12
12
  // Children arrive as a (scoped) default slot so `v-slot="{ pressed }"` mirrors React's
13
13
  // children-as-function.
14
- import { defineComponent, h, ref, shallowRef, } from '@vue/runtime-core';
15
- import { createPressHandlers, createPressRuntime, rippleProps, buildPressableListeners, resolveDisabledAccessibilityState, noteHoverNoop, resolveAccessibilityProps, DEFAULT_DELAY_LONG_PRESS_MS, } from '@symbiote-native/components';
14
+ import { defineComponent, h, onUnmounted, ref, shallowRef, } from '@vue/runtime-core';
15
+ import { createPressHandlers, createPressRuntime, disposePressRuntime, rippleProps, buildPressableListeners, resolveDisabledAccessibilityState, noteHoverNoop, resolveAccessibilityProps, DEFAULT_DELAY_LONG_PRESS_MS, DEFAULT_MIN_PRESS_DURATION_MS, } from '@symbiote-native/components';
16
16
  import { measure, isSymbioteNode, } from '@symbiote-native/engine';
17
- import { View } from '../components.js';
17
+ import { HOST_VIEW } from '../components.js';
18
+ import { useRawAttrs } from '../composables/use-raw-attrs.js';
18
19
  import { normalizeVueAttrs } from '../utils/normalize-attrs.js';
19
20
  // Reused verbatim as the `emits` of every Touchable* wrapper, so the runtime list lives once.
20
21
  export const PRESSABLE_EMITS = {
@@ -106,7 +107,10 @@ function resolveStyle(value, state) {
106
107
  }
107
108
  // Everything else forwards onto the View. User press callbacks are pure JS and must never reach
108
109
  // the host; the machine's synthesized handlers go on via buildPressableListeners.
109
- const HANDLED_ATTRS = [
110
+ // A Set, not an array: forwardAttrs asks this question once per attr per Pressable instance, and
111
+ // `Array.prototype.includes` walks all eighteen entries for every key that is NOT handled — which
112
+ // is most of them. Membership is the whole use; the order never mattered.
113
+ const HANDLED_ATTRS = new Set([
110
114
  'onPress',
111
115
  'onPressIn',
112
116
  'onPressOut',
@@ -117,6 +121,7 @@ const HANDLED_ATTRS = [
117
121
  'cancelable',
118
122
  'pressRetentionOffset',
119
123
  'unstable_pressDelay',
124
+ '__minPressDuration',
120
125
  'android_ripple',
121
126
  'android_disableSound',
122
127
  'onHoverIn',
@@ -125,16 +130,19 @@ const HANDLED_ATTRS = [
125
130
  'delayHoverOut',
126
131
  'style',
127
132
  'accessibilityState',
128
- ];
133
+ ]);
129
134
  function forwardAttrs(attrs) {
130
135
  const result = {};
131
136
  for (const key of Object.keys(attrs)) {
132
- if (!HANDLED_ATTRS.includes(key))
137
+ if (!HANDLED_ATTRS.has(key))
133
138
  result[key] = attrs[key];
134
139
  }
135
140
  return result;
136
141
  }
137
- export const Pressable = defineComponent((_props, { slots, attrs: rawAttrs, emit }) => {
142
+ export const Pressable = defineComponent((_props, { slots, attrs: contextAttrs, emit, }) => {
143
+ // The unproxied bag, captured once. See use-raw-attrs.ts for why the context's own attrs cost
144
+ // a track() per read and why capturing here (a setup body) is the only safe place to do it.
145
+ const rawAttrs = useRawAttrs(contextAttrs);
138
146
  const pressed = ref(false);
139
147
  // The mutable press runtime (timers, suppression flags, measured region). A plain setup-scope
140
148
  // object, never a ref: mutated by the machine, never reactively read.
@@ -161,7 +169,11 @@ export const Pressable = defineComponent((_props, { slots, attrs: rawAttrs, emit
161
169
  const id = setTimeout(callback, ms);
162
170
  return () => clearTimeout(id);
163
171
  },
172
+ now: Date.now,
164
173
  };
174
+ onUnmounted(() => {
175
+ disposePressRuntime(runtime);
176
+ });
165
177
  return () => {
166
178
  const attrs = normalizeVueAttrs(rawAttrs);
167
179
  const disabled = attrs.disabled === true ? true : undefined;
@@ -174,6 +186,7 @@ export const Pressable = defineComponent((_props, { slots, attrs: rawAttrs, emit
174
186
  onLongPress: event => emit('longPress', event),
175
187
  delayLongPress: numberOr(attrs.delayLongPress, DEFAULT_DELAY_LONG_PRESS_MS),
176
188
  unstable_pressDelay: numberOr(attrs.unstable_pressDelay, 0),
189
+ minPressDuration: numberOr(attrs.__minPressDuration, DEFAULT_MIN_PRESS_DURATION_MS),
177
190
  hitSlop: asRectOffset(attrs.hitSlop),
178
191
  pressRetentionOffset: asRectOffset(attrs.pressRetentionOffset),
179
192
  };
@@ -182,14 +195,15 @@ export const Pressable = defineComponent((_props, { slots, attrs: rawAttrs, emit
182
195
  const state = { pressed: pressed.value };
183
196
  // Vue's View is a bare host primitive, so Pressable folds disabled into accessibilityState
184
197
  // and aria/role itself, rather than the View folding it (as React's does).
198
+ // One bag, built once and then written into. The spread this replaces rebuilt the whole
199
+ // forwarded set a second time just to add three keys; resolveAccessibilityProps hands back
200
+ // the SAME object whenever no aria key is present, which is the common case, so on that path
201
+ // the render now allocates one bag instead of two.
185
202
  const forwarded = forwardAttrs(attrs);
186
203
  forwarded.accessibilityState = resolveDisabledAccessibilityState(asAccessibilityState(attrs.accessibilityState), disabled);
187
- const folded = resolveAccessibilityProps(forwarded);
188
- const viewProps = {
189
- ...folded,
190
- ref: setNodeRef,
191
- style: resolveStyle(attrs.style, state),
192
- };
204
+ const viewProps = resolveAccessibilityProps(forwarded);
205
+ viewProps.ref = setNodeRef;
206
+ viewProps.style = resolveStyle(attrs.style, state);
193
207
  if (typeof attrs.android_disableSound === 'boolean')
194
208
  viewProps.android_disableSound = attrs.android_disableSound;
195
209
  Object.assign(viewProps, buildPressableListeners(handlers, { disabled, cancelable }));
@@ -199,13 +213,18 @@ export const Pressable = defineComponent((_props, { slots, attrs: rawAttrs, emit
199
213
  const ripple = isRecord(attrs.android_ripple)
200
214
  ? rippleProps(asRippleConfig(attrs.android_ripple) ?? {})
201
215
  : undefined;
202
- const inner = ripple !== undefined ? [h(View, ripple, () => content)] : content;
203
- // Children go to the host View as a FUNCTION slot, never a raw array: View is a
204
- // functional component, and an array child makes Vue normalize it to a default
205
- // slot with a dev warn ("Prefer function slots"). Benign under SFC, but in JSX the
206
- // warn's trace formats the __self/__source dev props (native HostObjects) and that
207
- // read throws, unwinding the whole mount → blank screen. A function slot skips it.
208
- return h(View, viewProps, () => inner);
216
+ const inner = ripple !== undefined ? [h(HOST_VIEW, ripple, content)] : content;
217
+ // The host node is the intrinsic TAG, not our <View> component: a Vue component instance
218
+ // costs createComponentInstance + initProps + initSlots + setupRenderEffect even when the
219
+ // component is functional, and this one only forwards attrs to the same tag. Pressable is
220
+ // 2 of the 6 primitives in a benchmark row, so this is one instance per row, twice.
221
+ //
222
+ // Children are therefore an ARRAY, and that also RETIRES a hazard: passing an array to the
223
+ // functional <View> made Vue normalize it to a default slot with a "Prefer function slots"
224
+ // dev warn, whose trace formats the __self/__source dev props (native HostObjects) — a read
225
+ // that throws under JSX and unwinds the whole mount into a blank screen. An element takes
226
+ // array children natively and never reaches that path.
227
+ return h(HOST_VIEW, viewProps, inner);
209
228
  };
210
229
  }, {
211
230
  name: 'Pressable',
@@ -1,6 +1,7 @@
1
1
  import { type IClassNameValue } from '@symbiote-native/engine';
2
2
  import { type IAccessibilityProps, type IAriaProps } from '@symbiote-native/components';
3
3
  export type ISafeAreaViewProps = IAccessibilityProps & IAriaProps & {
4
+ id?: string;
4
5
  class?: IClassNameValue;
5
6
  };
6
7
  export declare const SafeAreaView: import("vue").DefineComponent<{}, () => import("vue").VNode<import("vue").RendererNode, import("vue").RendererElement, {
@@ -191,6 +191,24 @@ export const TextInput = defineComponent((_props, { attrs: rawAttrs, emit, expos
191
191
  end,
192
192
  ]);
193
193
  },
194
+ // Forwarded from the node, because `expose()` REPLACES the public instance rather than
195
+ // extending it: without these four, a `<TextInput>` ref could not measure while a `<View>`
196
+ // ref could, and an app moved between the component and lowered paths — which it does by
197
+ // writing `:multiline="isLong"` instead of `multiline` — silently swapped which four methods
198
+ // it had. The lowered path answers all nine through `buildTextInputHandle`; this is the same
199
+ // set on the component path, so the two surfaces are one.
200
+ measure: (callback) => {
201
+ nodeRef.value?.measure(callback);
202
+ },
203
+ measureInWindow: (callback) => {
204
+ nodeRef.value?.measureInWindow(callback);
205
+ },
206
+ measureLayout: (relativeToNativeNode, onSuccess, onFail) => {
207
+ nodeRef.value?.measureLayout(relativeToNativeNode, onSuccess, onFail);
208
+ },
209
+ setNativeProps: (nativeProps) => {
210
+ nodeRef.value?.setNativeProps(nativeProps);
211
+ },
194
212
  });
195
213
  return () => {
196
214
  const attrs = normalizeVueAttrs(rawAttrs);
@@ -6,7 +6,7 @@
6
6
  import { defineComponent, h } from '@vue/runtime-core';
7
7
  import { backgroundProps, canUseNativeForeground, rippleBackground, selectableBackground, selectableBackgroundBorderless, } from '@symbiote-native/components';
8
8
  import { dlog } from '@symbiote-native/engine';
9
- import { View } from '../components.js';
9
+ import { HOST_VIEW } from '../components.js';
10
10
  import { Pressable, emitPressableEvents, PRESSABLE_EMITS, } from './pressable.js';
11
11
  import { normalizeVueAttrs } from '../utils/normalize-attrs.js';
12
12
  const HANDLED = ['background', 'useForeground'];
@@ -59,8 +59,13 @@ const TouchableNativeFeedbackImpl = defineComponent((_props, { slots, attrs: raw
59
59
  dlog(`TouchableNativeFeedback render ${resolved.type} useForeground ${useForeground}`);
60
60
  const nativeProps = backgroundProps(resolved, useForeground);
61
61
  const children = slots.default !== undefined ? slots.default() : [];
62
- const feedback = h(View, nativeProps, () => children);
63
- return h(Pressable, { ...forwardAttrs(attrs), ...emitPressableEvents(emit) }, { default: () => [feedback] });
62
+ // The intrinsic tag, not our <View> component — see the note at HOST_VIEW.
63
+ const feedback = h(HOST_VIEW, nativeProps, children);
64
+ return h(Pressable, {
65
+ ...forwardAttrs(attrs),
66
+ ...emitPressableEvents(emit),
67
+ __minPressDuration: 0,
68
+ }, { default: () => [feedback] });
64
69
  };
65
70
  }, {
66
71
  name: 'TouchableNativeFeedback',
@@ -112,6 +112,7 @@ export const TouchableOpacity = defineComponent((_props, { slots, attrs: rawAttr
112
112
  },
113
113
  });
114
114
  const pressableProps = {
115
+ __minPressDuration: 0,
115
116
  ...forwardExcept(attrs, TOUCHABLE_OPACITY_HANDLED),
116
117
  ...emitPressableEvents(emit),
117
118
  onPressIn: handlePressIn,
@@ -211,6 +212,7 @@ export const TouchableHighlight = defineComponent((_props, { slots, attrs: rawAt
211
212
  ? [attrs.style, extra.underlay]
212
213
  : [attrs.style, extra.underlay, extra.child];
213
214
  const pressableProps = {
215
+ __minPressDuration: 0,
214
216
  ...forwardExcept(attrs, TOUCHABLE_HIGHLIGHT_HANDLED),
215
217
  ...emitPressableEvents(emit),
216
218
  style: containerStyle,
@@ -263,6 +265,7 @@ export const TouchableWithoutFeedback = defineComponent((_props, { slots, attrs:
263
265
  },
264
266
  });
265
267
  const pressableProps = {
268
+ __minPressDuration: 0,
266
269
  ...forwardExcept(attrs, TOUCHABLE_WITHOUT_FEEDBACK_HANDLED),
267
270
  ...emitPressableEvents(emit),
268
271
  onPressIn: handlePressIn,
@@ -47,5 +47,7 @@ export interface ITextProps extends IAccessibilityProps, IAriaProps {
47
47
  ref?: VNodeRef;
48
48
  key?: string | number | symbol;
49
49
  }
50
+ export declare const HOST_VIEW = "symbiote-view";
51
+ export declare const HOST_TEXT = "symbiote-text";
50
52
  export declare const View: FunctionalComponent<IViewProps, {}, any, {}>;
51
53
  export declare const Text: FunctionalComponent<ITextProps, {}, any, {}>;
@@ -21,8 +21,24 @@ fold = attrs => attrs) {
21
21
  component.inheritAttrs = false;
22
22
  return component;
23
23
  }
24
- export const View = hostComponent('symbiote-view', 'View');
25
- export const Text = hostComponent('symbiote-text', 'Text', resolveTextProps);
24
+ // The intrinsic tags the two bare primitives wrap. Exported because ADAPTER-INTERNAL renders must
25
+ // use the TAG, never the component: Vue charges a full component instance even for a functional
26
+ // one, so `h(View, …)` inside our own Pressable/Button costs an instance per node for a wrapper
27
+ // that only forwards attrs.
28
+ //
29
+ // These stay LITERALS rather than being read out of `@symbiote-native/components/host-primitives`,
30
+ // which is where the two transforms now get them: the spec types `intrinsic` as `string`, and
31
+ // reading it here would widen `HOST_VIEW` from the literal type that `hostComponent` and every
32
+ // adapter-internal `h(HOST_VIEW, …)` rely on. So this is the last remaining second encoding, and
33
+ // `components.test.ts` pins it against the spec rather than trusting the two to stay in step.
34
+ //
35
+ // Children go to a tag as an ARRAY (or a bare string / vnode), never a slot function — an element
36
+ // ignores slot children entirely and renders nothing. The reverse of the rule that applies to a
37
+ // component.
38
+ export const HOST_VIEW = 'symbiote-view';
39
+ export const HOST_TEXT = 'symbiote-text';
40
+ export const View = hostComponent(HOST_VIEW, 'View');
41
+ export const Text = hostComponent(HOST_TEXT, 'Text', resolveTextProps);
26
42
  // Text is no longer bare either — it carries RN's Text.js defaults through resolveTextProps.
27
43
  // Image is NOT a bare host primitive: it needs the shared fold (source/src/srcSet resolution,
28
44
  // width/height -> style, alt -> accessibility) + the Image statics, so it lives in ./image as a
@@ -0,0 +1 @@
1
+ export declare function useRawAttrs(contextAttrs: Record<string, unknown>): Record<string, unknown>;
@@ -0,0 +1,26 @@
1
+ // The unproxied twin of a setup context's `attrs`.
2
+ //
3
+ // Vue hands `setupContext.attrs` out as `new Proxy(instance.attrs, attrsProxyHandlers)`, and in
4
+ // PRODUCTION as well as dev that proxy's only trap is `get(target, key) { track(target, "get", "");
5
+ // return target[key]; }` (runtime-core.esm-bundler.js:8357). So every attribute read costs a
6
+ // WeakMap get, a Map get and a `dep.track()`, and the first read of each instance also allocates
7
+ // a Map and a Dep into the module-global `targetMap` that live as long as the component does.
8
+ // Pressable reads ~16 attrs per render and a benchmark create mounts 2 000 of them.
9
+ //
10
+ // `instance.attrs` is the raw object behind that proxy. Capturing it is safe because its identity
11
+ // never changes: `initProps` assigns it once (:4907) and `updateProps` mutates that same object in
12
+ // place (`attrs[key] = value` / `delete attrs[key]`), so a reference taken at setup always reads
13
+ // current values. The tracking the proxy performs is for reads in a setup BODY; a render fn does
14
+ // not need it, because the parent's patch re-runs the child render through `updateComponentPreRender`
15
+ // whether or not the child subscribed.
16
+ //
17
+ // CALL THIS FROM A SETUP BODY ONLY, never from a render fn, a `computed`, or any other deferred
18
+ // callback. `getCurrentInstance()` resolves to `currentInstance || currentRenderingInstance`, so in
19
+ // a callback that runs while a DIFFERENT component is rendering it returns that component and this
20
+ // would silently hand back the wrong bag. `virtualized-list/index.ts` calls normalizeVueAttrs
21
+ // inside a `computed` for exactly that reason and must keep using the context attrs.
22
+ import { getCurrentInstance } from '@vue/runtime-core';
23
+ export function useRawAttrs(contextAttrs) {
24
+ const instance = getCurrentInstance();
25
+ return instance === null ? contextAttrs : instance.attrs;
26
+ }
@@ -1,11 +1,10 @@
1
1
  // findNodeHandle, the Vue adapter twin of adapters/react/src/host-instance.ts. RN's
2
2
  // "ref/instance -> native reactTag" lookup, the seam imperative-interop libraries
3
3
  // (reanimated, gesture-handler, react-navigation) reach through. A Vue template/function ref
4
- // to a symbiote host element falls through to the raw engine SymbioteNode, and the Vue
5
- // renderer grafts the public-instance API onto it (toPublicInstance in renderer.ts), so the
6
- // node a ref receives carries measure / setNativeProps / focus exactly like React's
7
- // getPublicInstance. IHostInstance is therefore that grafted node — re-exported from the
8
- // engine so React and Vue share one definition.
4
+ // to a symbiote host element falls through to the raw engine SymbioteNode, which already carries
5
+ // measure / setNativeProps / focus on its prototype, exactly like React's getPublicInstance.
6
+ // IHostInstance is therefore that node — re-exported from the engine so React and Vue share one
7
+ // definition.
9
8
  //
10
9
  // The node -> tag resolution itself belongs to the engine (getNativeTag, keyed on the raw
11
10
  // node in the commit mirror); this only unwraps the Vue-shaped inputs (a Ref or a bare node)
package/build/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import './register';
1
2
  export { mount, unmount, setAppConfigurator } from './render';
2
3
  export type { IAppConfigurator } from './render';
3
4
  export { Teleport, type ITeleportTarget } from './create-portal';