@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 +19 -4
- package/babel-jsx.cjs +53 -0
- package/babel-lower-host-primitives.cjs +225 -0
- package/build/components/button.js +4 -2
- package/build/components/pressable.js +39 -20
- package/build/components/safe-area-view.d.ts +1 -0
- package/build/components/text-input/index.js +18 -0
- package/build/components/touchable-native-feedback.js +8 -3
- package/build/components/touchable.js +3 -0
- package/build/components.d.ts +2 -0
- package/build/components.js +18 -2
- package/build/composables/use-raw-attrs.d.ts +1 -0
- package/build/composables/use-raw-attrs.js +26 -0
- package/build/host-instance/index.js +4 -5
- package/build/index.d.ts +1 -0
- package/build/index.js +5 -0
- package/build/modules/animated/create-animated-component.d.ts +1 -1
- package/build/modules/animated/create-animated-component.js +18 -4
- package/build/register.d.ts +1 -0
- package/build/register.js +35 -0
- package/build/renderer/index.js +58 -9
- package/build/runtime-helpers/index.d.ts +4 -0
- package/build/runtime-helpers/index.js +209 -4
- package/build/state-style.d.ts +1 -0
- package/build/state-style.js +13 -0
- package/build/utils/normalize-attrs.d.ts +1 -0
- package/build/utils/normalize-attrs.js +21 -15
- package/metro-css-parser.cjs +2 -1
- package/metro-vue-transformer.cjs +367 -7
- package/package.json +29 -8
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
|
|
38
|
-
|
|
39
|
-
`
|
|
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/
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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
|
|
188
|
-
|
|
189
|
-
|
|
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(
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
|
|
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 {
|
|
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
|
-
|
|
63
|
-
|
|
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,
|
package/build/components.d.ts
CHANGED
|
@@ -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, {}>;
|
package/build/components.js
CHANGED
|
@@ -21,8 +21,24 @@ fold = attrs => attrs) {
|
|
|
21
21
|
component.inheritAttrs = false;
|
|
22
22
|
return component;
|
|
23
23
|
}
|
|
24
|
-
|
|
25
|
-
|
|
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,
|
|
5
|
-
//
|
|
6
|
-
// node
|
|
7
|
-
//
|
|
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