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