@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.
Files changed (57) hide show
  1. package/README.md +24 -9
  2. package/babel-jsx.cjs +53 -0
  3. package/babel-lower-host-primitives.cjs +225 -0
  4. package/build/bootstrap.js +1 -1
  5. package/build/components/button.js +4 -2
  6. package/build/components/flat-list/index.js +14 -5
  7. package/build/components/image-background.js +4 -5
  8. package/build/components/image.js +3 -1
  9. package/build/components/keyboard-avoiding-view.js +39 -14
  10. package/build/components/modal/index.js +5 -2
  11. package/build/components/pressable.d.ts +1 -1
  12. package/build/components/pressable.js +39 -20
  13. package/build/components/safe-area-view.d.ts +1 -0
  14. package/build/components/scroll-view/index.android.d.ts +1 -1
  15. package/build/components/scroll-view/index.android.js +3 -1
  16. package/build/components/scroll-view/index.ios.d.ts +1 -1
  17. package/build/components/scroll-view/index.ios.js +3 -1
  18. package/build/components/scroll-view/shared.js +33 -13
  19. package/build/components/scroll-view/sticky-header.js +18 -5
  20. package/build/components/section-list/index.js +4 -2
  21. package/build/components/switch/shared.js +12 -3
  22. package/build/components/text-input/index.js +25 -2
  23. package/build/components/touchable-native-feedback.js +11 -4
  24. package/build/components/touchable.d.ts +7 -1
  25. package/build/components/touchable.js +184 -33
  26. package/build/components/virtualized-list/index.js +42 -20
  27. package/build/components/virtualized-section-list/index.d.ts +5 -0
  28. package/build/components/virtualized-section-list/index.js +33 -4
  29. package/build/components.d.ts +2 -0
  30. package/build/components.js +25 -4
  31. package/build/composables/use-raw-attrs.d.ts +1 -0
  32. package/build/composables/use-raw-attrs.js +26 -0
  33. package/build/create-portal/index.d.ts +28 -0
  34. package/build/create-portal/index.js +42 -0
  35. package/build/host-instance/index.js +7 -6
  36. package/build/index.d.ts +9 -6
  37. package/build/index.js +14 -3
  38. package/build/modules/animated/create-animated-component.d.ts +1 -1
  39. package/build/modules/animated/create-animated-component.js +27 -5
  40. package/build/modules/animated/index.d.ts +2 -0
  41. package/build/modules/animated/index.js +18 -5
  42. package/build/modules/app-registry/index.js +4 -2
  43. package/build/modules/status-bar.js +4 -2
  44. package/build/register.d.ts +1 -0
  45. package/build/register.js +35 -0
  46. package/build/render.d.ts +3 -1
  47. package/build/render.js +62 -1
  48. package/build/renderer/index.js +58 -9
  49. package/build/runtime-helpers/index.d.ts +48 -24
  50. package/build/runtime-helpers/index.js +314 -30
  51. package/build/state-style.d.ts +1 -0
  52. package/build/state-style.js +13 -0
  53. package/build/utils/normalize-attrs.d.ts +1 -0
  54. package/build/utils/normalize-attrs.js +21 -13
  55. package/metro-css-parser.cjs +2 -1
  56. package/metro-vue-transformer.cjs +502 -101
  57. 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 *same* untouched core as React, with React Native's own renderer never in the path. It
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 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
@@ -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 *native view* framework-agnostic, not the library's React *component*; such
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`) *does* ship a real Vue build (`@symbiote-native/slider/vue`) through
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
- *other* React-only component package stays React-adapter-only until it gets the same treatment.
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 *after* `onMounted` / `watch(flush:'post')` runs. A native bind that reads
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/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
+ };
@@ -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 { 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
  }, {
@@ -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) ? 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 = { header: slots.header, footer: slots.footer, empty: slots.empty };
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') ? () => emit('refresh') : undefined;
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) || Array.isArray(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' ? value : undefined;
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' ? value : undefined;
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
- // the Keyboard module (show/changeFrame/hide), onUnmounted tears the subscriptions down, and
5
- // onLayout measures the wrapper frame that feeds the next event's inset.
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, KEYBOARD_EVENT, dlog, } from '@symbiote-native/engine';
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' ? value : undefined;
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
- const verticalOffset = () => typeof rawAttrs.keyboardVerticalOffset === 'number'
42
- ? rawAttrs.keyboardVerticalOffset
43
- : DEFAULT_VERTICAL_OFFSET;
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(KEYBOARD_EVENT.didShow, onShow),
58
- Keyboard.addListener(KEYBOARD_EVENT.didChangeFrame, onShow),
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 = normalizeVueAttrs(rawAttrs);
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' ? value : undefined;
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' && ORIENTATIONS.some(orientation => orientation === value);
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;