@symbiote-native/vue 0.4.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 +24 -9
- package/babel-jsx.cjs +53 -0
- package/babel-lower-host-primitives.cjs +225 -0
- package/build/bootstrap.js +1 -1
- package/build/components/button.js +4 -2
- package/build/components/flat-list/index.js +14 -5
- package/build/components/image-background.js +4 -5
- package/build/components/image.js +3 -1
- package/build/components/keyboard-avoiding-view.js +39 -14
- package/build/components/modal/index.js +5 -2
- package/build/components/pressable.d.ts +1 -1
- package/build/components/pressable.js +39 -20
- package/build/components/safe-area-view.d.ts +1 -0
- package/build/components/scroll-view/index.android.d.ts +1 -1
- package/build/components/scroll-view/index.android.js +3 -1
- package/build/components/scroll-view/index.ios.d.ts +1 -1
- package/build/components/scroll-view/index.ios.js +3 -1
- package/build/components/scroll-view/shared.js +33 -13
- package/build/components/scroll-view/sticky-header.js +18 -5
- package/build/components/section-list/index.js +4 -2
- package/build/components/switch/shared.js +12 -3
- package/build/components/text-input/index.js +25 -2
- package/build/components/touchable-native-feedback.js +11 -4
- package/build/components/touchable.d.ts +7 -1
- package/build/components/touchable.js +184 -33
- package/build/components/virtualized-list/index.js +42 -20
- package/build/components/virtualized-section-list/index.d.ts +5 -0
- package/build/components/virtualized-section-list/index.js +33 -4
- package/build/components.d.ts +2 -0
- package/build/components.js +25 -4
- package/build/composables/use-raw-attrs.d.ts +1 -0
- package/build/composables/use-raw-attrs.js +26 -0
- package/build/create-portal/index.d.ts +28 -0
- package/build/create-portal/index.js +42 -0
- package/build/host-instance/index.js +7 -6
- package/build/index.d.ts +9 -6
- package/build/index.js +14 -3
- package/build/modules/animated/create-animated-component.d.ts +1 -1
- package/build/modules/animated/create-animated-component.js +27 -5
- package/build/modules/animated/index.d.ts +2 -0
- package/build/modules/animated/index.js +18 -5
- package/build/modules/app-registry/index.js +4 -2
- package/build/modules/status-bar.js +4 -2
- package/build/register.d.ts +1 -0
- package/build/register.js +35 -0
- package/build/render.d.ts +3 -1
- package/build/render.js +62 -1
- package/build/renderer/index.js +58 -9
- package/build/runtime-helpers/index.d.ts +48 -24
- package/build/runtime-helpers/index.js +314 -30
- 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 -13
- package/metro-css-parser.cjs +2 -1
- package/metro-vue-transformer.cjs +502 -101
- package/package.json +29 -8
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# @symbiote-native/vue
|
|
2
2
|
|
|
3
3
|
The **Vue 3 adapter** for [SymbioteNative](../../README.md) — render real native iOS/Android views from
|
|
4
|
-
Vue, on the
|
|
4
|
+
Vue, on the _same_ untouched core as React, with React Native's own renderer never in the path. It
|
|
5
5
|
is a `@vue/runtime-core` `createRenderer` whose nodeOps map each mutation onto the engine's
|
|
6
6
|
four-call API; `@symbiote-native/engine` does the clone-on-write commit into Fabric.
|
|
7
7
|
|
|
@@ -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
|
|
@@ -95,19 +109,19 @@ and each adapter supplies only its lifecycle (Vue's `ref`/`watch` + the descript
|
|
|
95
109
|
The one deliberate gap is third-party **React component** packages such as
|
|
96
110
|
`@react-native-community/slider` used directly. Their body calls React hooks off the React
|
|
97
111
|
dispatcher, so they run only under the React adapter — under Vue the dispatcher is null and they
|
|
98
|
-
throw. SymbioteNative makes the
|
|
112
|
+
throw. SymbioteNative makes the _native view_ framework-agnostic, not the library's React _component_; such
|
|
99
113
|
a view becomes reachable from Vue only through a thin wrapper over the same `createNode`-by-ViewConfig
|
|
100
114
|
path SymbioteNative uses for its own primitives. `@symbiote-native/slider` (this repo's own wrapper around
|
|
101
|
-
`@react-native-community/slider`)
|
|
115
|
+
`@react-native-community/slider`) _does_ ship a real Vue build (`@symbiote-native/slider/vue`) through
|
|
102
116
|
exactly that path — it's what makes this one third-party native view usable from Vue at all; any
|
|
103
|
-
|
|
117
|
+
_other_ React-only component package stays React-adapter-only until it gets the same treatment.
|
|
104
118
|
|
|
105
119
|
---
|
|
106
120
|
|
|
107
121
|
## A Vue-specific gotcha — async commit timing
|
|
108
122
|
|
|
109
123
|
Vue batches commits on a microtask (every mutation schedules one `completeRoot`), so a node's
|
|
110
|
-
Fabric tag is assigned
|
|
124
|
+
Fabric tag is assigned _after_ `onMounted` / `watch(flush:'post')` runs. A native bind that reads
|
|
111
125
|
the tag at lifecycle time (native-driver `Animated`, sticky-header scroll attach, `TextInput`
|
|
112
126
|
autoFocus) would race the commit and silently no-op — while the JS-path headless test stays green.
|
|
113
127
|
React doesn't hit this because `react-reconciler` commits synchronously.
|
|
@@ -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
|
+
};
|
package/build/bootstrap.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// AppRegistry actually get wired. Lives OUTSIDE the package's main barrel — see that file's
|
|
4
4
|
// header for why anything importing react-native must stay there.
|
|
5
5
|
import { AppRegistry as RNAppRegistry } from 'react-native';
|
|
6
|
-
import { bootstrapHost } from '@symbiote-native/components/bootstrap';
|
|
6
|
+
import { bootstrapHost, } from '@symbiote-native/components/bootstrap';
|
|
7
7
|
import { AppRegistry, setHostRegistrar } from './modules/app-registry/index.js';
|
|
8
8
|
// Mirrors real Vue's createApp(App).mount(selector) two-step idiom.
|
|
9
9
|
export function createApp(App, options = {}) {
|
|
@@ -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
|
}, {
|
|
@@ -56,7 +56,7 @@ function buildDelegateHandle(getInner) {
|
|
|
56
56
|
recordInteraction: () => getInner()?.recordInteraction(),
|
|
57
57
|
};
|
|
58
58
|
}
|
|
59
|
-
export const FlatList = defineComponent((props, { attrs, expose, emit, slots }) => {
|
|
59
|
+
export const FlatList = defineComponent((props, { attrs, expose, emit, slots, }) => {
|
|
60
60
|
const inner = shallowRef(null);
|
|
61
61
|
const setInner = (instance) => {
|
|
62
62
|
inner.value = isVirtualizedListHandle(instance) ? instance : null;
|
|
@@ -70,11 +70,17 @@ export const FlatList = defineComponent((props, { attrs, expose, emit, slots })
|
|
|
70
70
|
return vnodeProps != null && typeof vnodeProps[onName] === 'function';
|
|
71
71
|
};
|
|
72
72
|
return () => {
|
|
73
|
-
const data = Array.isArray(props.data)
|
|
73
|
+
const data = Array.isArray(props.data)
|
|
74
|
+
? props.data
|
|
75
|
+
: [];
|
|
74
76
|
const keyExtractor = props.keyExtractor;
|
|
75
77
|
const numColumns = typeof props.numColumns === 'number' ? props.numColumns : SINGLE_COLUMN;
|
|
76
78
|
const viewabilityPairs = props.viewabilityConfigCallbackPairs;
|
|
77
|
-
const chromeSlots = {
|
|
79
|
+
const chromeSlots = {
|
|
80
|
+
header: slots.header,
|
|
81
|
+
footer: slots.footer,
|
|
82
|
+
empty: slots.empty,
|
|
83
|
+
};
|
|
78
84
|
const forwarded = normalizeVueAttrs(attrs);
|
|
79
85
|
dlog(`Vue FlatList over ${data.length} items, ${numColumns} column(s)`);
|
|
80
86
|
const endReached = listens('onEndReached')
|
|
@@ -83,7 +89,9 @@ export const FlatList = defineComponent((props, { attrs, expose, emit, slots })
|
|
|
83
89
|
const startReached = listens('onStartReached')
|
|
84
90
|
? (info) => emit('startReached', info)
|
|
85
91
|
: undefined;
|
|
86
|
-
const refresh = listens('onRefresh')
|
|
92
|
+
const refresh = listens('onRefresh')
|
|
93
|
+
? () => emit('refresh')
|
|
94
|
+
: undefined;
|
|
87
95
|
const scrollToIndexFailed = listens('onScrollToIndexFailed')
|
|
88
96
|
? (info) => emit('scrollToIndexFailed', info)
|
|
89
97
|
: undefined;
|
|
@@ -114,7 +122,8 @@ export const FlatList = defineComponent((props, { attrs, expose, emit, slots })
|
|
|
114
122
|
{ flexDirection: 'row' },
|
|
115
123
|
typeof props.columnWrapperStyle === 'string'
|
|
116
124
|
? resolveClassName(props.columnWrapperStyle)
|
|
117
|
-
: isRecord(props.columnWrapperStyle) ||
|
|
125
|
+
: isRecord(props.columnWrapperStyle) ||
|
|
126
|
+
Array.isArray(props.columnWrapperStyle)
|
|
118
127
|
? props.columnWrapperStyle
|
|
119
128
|
: undefined,
|
|
120
129
|
];
|
|
@@ -34,7 +34,9 @@ function asResizeMode(value) {
|
|
|
34
34
|
return isResizeMode(value) ? value : undefined;
|
|
35
35
|
}
|
|
36
36
|
function asCrossOrigin(value) {
|
|
37
|
-
return value === 'anonymous' || value === 'use-credentials'
|
|
37
|
+
return value === 'anonymous' || value === 'use-credentials'
|
|
38
|
+
? value
|
|
39
|
+
: undefined;
|
|
38
40
|
}
|
|
39
41
|
function isStyleProp(value) {
|
|
40
42
|
return typeof value === 'object' && value !== null;
|
|
@@ -94,10 +96,7 @@ const ImageBackgroundComponent = (_props, { attrs: rawAttrs, slots }) => {
|
|
|
94
96
|
});
|
|
95
97
|
// wrapper = symbiote-view > [imageDescriptor]; the slot children paint AFTER the image (on top).
|
|
96
98
|
const slotChildren = slots.default !== undefined ? slots.default() : [];
|
|
97
|
-
return h(wrapper.type, { ...wrapper.props, key: wrapper.key, class: attrs.class }, [
|
|
98
|
-
...wrapper.children.map(toChildVNode),
|
|
99
|
-
...slotChildren,
|
|
100
|
-
]);
|
|
99
|
+
return h(wrapper.type, { ...wrapper.props, key: wrapper.key, class: attrs.class }, [...wrapper.children.map(toChildVNode), ...slotChildren]);
|
|
101
100
|
};
|
|
102
101
|
ImageBackgroundComponent.displayName = 'ImageBackground';
|
|
103
102
|
ImageBackgroundComponent.inheritAttrs = false;
|
|
@@ -35,7 +35,9 @@ function asResizeMode(value) {
|
|
|
35
35
|
return isResizeMode(value) ? value : undefined;
|
|
36
36
|
}
|
|
37
37
|
function asCrossOrigin(value) {
|
|
38
|
-
return value === 'anonymous' || value === 'use-credentials'
|
|
38
|
+
return value === 'anonymous' || value === 'use-credentials'
|
|
39
|
+
? value
|
|
40
|
+
: undefined;
|
|
39
41
|
}
|
|
40
42
|
function isStyleProp(value) {
|
|
41
43
|
return typeof value === 'object' && value !== null;
|
|
@@ -1,20 +1,23 @@
|
|
|
1
1
|
// KeyboardAvoidingView: the Vue lifecycle half. The inset math + the behavior->style/structure
|
|
2
2
|
// decision live in @symbiote-native/components (render-keyboard-avoiding-view), shared verbatim
|
|
3
3
|
// with React; Vue supplies only the reactivity: a ref holds the inset, onMounted subscribes to
|
|
4
|
-
//
|
|
5
|
-
//
|
|
4
|
+
// this host's two keyboard notifications (keyboardAvoidingEventNamesFor — will* on iOS, did* on
|
|
5
|
+
// Android, and never changeFrame), onUnmounted tears the subscriptions down, and onLayout
|
|
6
|
+
// measures the wrapper frame that feeds the next event's inset.
|
|
6
7
|
//
|
|
7
8
|
// Inputs arrive as attrs (untyped), so each is narrowed with a runtime guard, never a cast.
|
|
8
9
|
// rawAttrs runs through normalizeVueAttrs so a template `:keyboard-vertical-offset` resolves.
|
|
9
|
-
import { defineComponent, h, ref, onMounted, onUnmounted } from '@vue/runtime-core';
|
|
10
|
-
import { Keyboard,
|
|
11
|
-
import { computeInset, readKeyboardFrame, readLayoutFrame, resolveKeyboardAvoidingLayout, resolveAccessibilityProps, DEFAULT_VERTICAL_OFFSET, } from '@symbiote-native/components';
|
|
10
|
+
import { defineComponent, h, ref, onMounted, onUnmounted, } from '@vue/runtime-core';
|
|
11
|
+
import { Keyboard, Platform, dlog, } from '@symbiote-native/engine';
|
|
12
|
+
import { computeInset, keyboardAvoidingEventNamesFor, readKeyboardFrame, readPrefersCrossFadeTransitions, readLayoutFrame, resolveKeyboardAvoidingLayout, resolveAccessibilityProps, DEFAULT_VERTICAL_OFFSET, } from '@symbiote-native/components';
|
|
12
13
|
import { normalizeVueAttrs } from '../utils/normalize-attrs.js';
|
|
13
14
|
function isStyleProp(value) {
|
|
14
15
|
return typeof value === 'object' && value !== null;
|
|
15
16
|
}
|
|
16
17
|
function asBehavior(value) {
|
|
17
|
-
return value === 'height' || value === 'position' || value === 'padding'
|
|
18
|
+
return value === 'height' || value === 'position' || value === 'padding'
|
|
19
|
+
? value
|
|
20
|
+
: undefined;
|
|
18
21
|
}
|
|
19
22
|
const HANDLED_ATTRS = [
|
|
20
23
|
'behavior',
|
|
@@ -38,12 +41,26 @@ export const KeyboardAvoidingView = defineComponent((_props, { attrs: rawAttrs,
|
|
|
38
41
|
// next keyboard event's inset math.
|
|
39
42
|
let frame;
|
|
40
43
|
let initialHeight;
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
+
// A device accessibility setting, read once per mount. Deliberately a plain variable, not a
|
|
45
|
+
// ref: it cannot change during a session, so nothing should re-render when it resolves.
|
|
46
|
+
let prefersCrossFadeTransitions = false;
|
|
47
|
+
// Read at EVENT time rather than captured once: attrs is live, and both the offset and the
|
|
48
|
+
// behavior feed every keyboard notification's inset math.
|
|
49
|
+
const currentAttrs = () => normalizeVueAttrs(rawAttrs);
|
|
50
|
+
const verticalOffset = () => {
|
|
51
|
+
const offset = currentAttrs().keyboardVerticalOffset;
|
|
52
|
+
return typeof offset === 'number' ? offset : DEFAULT_VERTICAL_OFFSET;
|
|
53
|
+
};
|
|
44
54
|
const onShow = (payload) => {
|
|
45
55
|
const keyboard = readKeyboardFrame(payload);
|
|
46
|
-
const next = computeInset(frame, keyboard, verticalOffset()
|
|
56
|
+
const next = computeInset(frame, keyboard, verticalOffset(), {
|
|
57
|
+
behavior: asBehavior(currentAttrs().behavior),
|
|
58
|
+
// The inset CURRENTLY applied (RN's this.state.bottom), read live off the reactive cell:
|
|
59
|
+
// in 'height' mode the wrapper is shrunk by it, so the next onLayout under-reports the
|
|
60
|
+
// frame by exactly that much and core adds it back.
|
|
61
|
+
previousInset: inset.value,
|
|
62
|
+
prefersCrossFadeTransitions,
|
|
63
|
+
});
|
|
47
64
|
dlog(`KeyboardAvoidingView show -> inset ${next}`);
|
|
48
65
|
inset.value = next;
|
|
49
66
|
};
|
|
@@ -53,11 +70,19 @@ export const KeyboardAvoidingView = defineComponent((_props, { attrs: rawAttrs,
|
|
|
53
70
|
};
|
|
54
71
|
let subscriptions = [];
|
|
55
72
|
onMounted(() => {
|
|
73
|
+
// TWO notifications, per host: iOS takes the will* pair so the view rides up with the
|
|
74
|
+
// keyboard animation, Android the did* pair. changeFrame is deliberately absent — with an
|
|
75
|
+
// undocked/split/floating iOS keyboard it fires BEFORE the hide, mid-dismissal.
|
|
76
|
+
const events = keyboardAvoidingEventNamesFor(Platform.OS);
|
|
56
77
|
subscriptions = [
|
|
57
|
-
Keyboard.addListener(
|
|
58
|
-
Keyboard.addListener(
|
|
59
|
-
Keyboard.addListener(KEYBOARD_EVENT.didHide, onHide),
|
|
78
|
+
Keyboard.addListener(events.show, onShow),
|
|
79
|
+
Keyboard.addListener(events.hide, onHide),
|
|
60
80
|
];
|
|
81
|
+
// Nobody awaits this, so it goes through the core wrapper, which answers false on a failed
|
|
82
|
+
// native read instead of leaving an unhandled rejection.
|
|
83
|
+
void readPrefersCrossFadeTransitions().then(enabled => {
|
|
84
|
+
prefersCrossFadeTransitions = enabled;
|
|
85
|
+
});
|
|
61
86
|
});
|
|
62
87
|
onUnmounted(() => {
|
|
63
88
|
for (const subscription of subscriptions)
|
|
@@ -74,7 +99,7 @@ export const KeyboardAvoidingView = defineComponent((_props, { attrs: rawAttrs,
|
|
|
74
99
|
emit('layout', event);
|
|
75
100
|
};
|
|
76
101
|
return () => {
|
|
77
|
-
const attrs =
|
|
102
|
+
const attrs = currentAttrs();
|
|
78
103
|
const behavior = asBehavior(attrs.behavior);
|
|
79
104
|
// RN gates every inset on `enabled ?? true`; only an explicit `false` disables.
|
|
80
105
|
const isEnabled = attrs.enabled !== false;
|
|
@@ -21,7 +21,9 @@ function asString(value) {
|
|
|
21
21
|
return typeof value === 'string' ? value : undefined;
|
|
22
22
|
}
|
|
23
23
|
function asAnimationType(value) {
|
|
24
|
-
return value === 'none' || value === 'slide' || value === 'fade'
|
|
24
|
+
return value === 'none' || value === 'slide' || value === 'fade'
|
|
25
|
+
? value
|
|
26
|
+
: undefined;
|
|
25
27
|
}
|
|
26
28
|
function asPresentationStyle(value) {
|
|
27
29
|
return value === 'fullScreen' ||
|
|
@@ -41,7 +43,8 @@ const ORIENTATIONS = [
|
|
|
41
43
|
function isOrientation(value) {
|
|
42
44
|
// .includes() types its arg as IModalOrientation, so a bare string won't pass; .some keeps the
|
|
43
45
|
// ORIENTATIONS list as the single source of truth without an `as` cast.
|
|
44
|
-
return typeof value === 'string' &&
|
|
46
|
+
return (typeof value === 'string' &&
|
|
47
|
+
ORIENTATIONS.some(orientation => orientation === value));
|
|
45
48
|
}
|
|
46
49
|
function asSupportedOrientations(value) {
|
|
47
50
|
if (!Array.isArray(value))
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type EmitFn, type VNode } from '@vue/runtime-core';
|
|
2
2
|
import { type IPressState, type IRectOffset, type IPressableAndroidRippleConfig, type IAccessibilityProps, type IAriaProps } from '@symbiote-native/components';
|
|
3
3
|
import { type IClassNameValue, type ISymbioteEvent, type IStyleProp, type IViewStyle } from '@symbiote-native/engine';
|
|
4
|
-
export type { IPressState, IPressableAndroidRippleConfig } from '@symbiote-native/components';
|
|
4
|
+
export type { IPressState, IPressableAndroidRippleConfig, } from '@symbiote-native/components';
|
|
5
5
|
export type IPressableEmits = {
|
|
6
6
|
press: (event: ISymbioteEvent) => boolean;
|
|
7
7
|
pressIn: (event: ISymbioteEvent) => boolean;
|