@symbiote-native/vue 0.3.7 → 0.4.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.
@@ -1,19 +1,17 @@
1
1
  // Sticky headers: the Vue twin of adapters/react/src/scroll-view-sticky-header.tsx, the JS layer
2
2
  // RN implements in ScrollView.js / ScrollViewStickyHeader.js.
3
3
  //
4
- // Source-based: RN does stickiness PURELY IN JS. ScrollView.js (render, ~line 1690)
5
- // wraps each child whose index is in `stickyHeaderIndices` in a ScrollViewStickyHeader, fed by a
6
- // single `_scrollAnimatedValue` an Animated.event drives from `onScroll` (ScrollView.js ~line
7
- // 1095). The native Fabric scroll view does NOT honor the index array on its own, so forwarding
8
- // `stickyHeaderIndices` to native is a silent no-op. So we replicate the JS layer: subscribe each
9
- // flagged child to the scroll offset and translate it to stay pinned. The interpolation mirrors
10
- // ScrollViewStickyHeader.js (non-inverted + inverted branches) and lives, framework-agnostic, in
4
+ // RN does stickiness PURELY IN JS: ScrollView.js wraps each flagged child in a
5
+ // ScrollViewStickyHeader fed by a single _scrollAnimatedValue that Animated.event drives from
6
+ // onScroll. The native scroll view does NOT honor the index array itself, so forwarding
7
+ // stickyHeaderIndices to native is a silent no-op - we replicate the JS layer instead. The
8
+ // interpolation math (non-inverted + inverted) lives framework-agnostic in
11
9
  // @symbiote-native/components (computeStickyInterpolation); this file holds the Vue component
12
- // shell, the layout state, and the child-wrapping. Render shared verbatim with React via the math.
13
- // Vue supplies only the reactive lifecycle (refs/watch instead of useState/useEffect).
14
- import { defineComponent, h, isVNode, ref, shallowRef, watch, watchEffect, markRaw, } from '@vue/runtime-core';
10
+ // shell, layout state, and child-wrapping. Vue supplies only the reactive lifecycle
11
+ // (refs/watch instead of useState/useEffect).
12
+ import { defineComponent, h, isVNode, onBeforeUnmount, ref, shallowRef, watchEffect, markRaw, } from '@vue/runtime-core';
15
13
  import { AnimatedValue, AnimatedInterpolation, Platform, dlog, } from '@symbiote-native/engine';
16
- import { computeStickyInterpolation, nextStickyHeaderY, readLayoutNumber, stickyDebounceMs, STICKY_HEADER_Z_INDEX, } from '@symbiote-native/components';
14
+ import { createInitialStickyState, nextStickyHeaderY, readLayoutNumber, reduceSticky, STICKY_HEADER_Z_INDEX, } from '@symbiote-native/components';
17
15
  import { Animated } from '../../modules/animated/index.js';
18
16
  function isHandler(value) {
19
17
  return typeof value === 'function';
@@ -24,104 +22,109 @@ function isRecord(value) {
24
22
  function isAnimatedValue(value) {
25
23
  return value instanceof AnimatedValue;
26
24
  }
27
- // Read this header's wrapped child's own onLayout off its VNode props, so the sticky wrapper can
28
- // forward layout to it (RN ScrollViewStickyHeader.js calls the child's onLayout after its own).
25
+ // So the sticky wrapper can forward layout to the child (RN calls the child's onLayout after its own).
29
26
  function readChildOnLayout(child) {
30
27
  if (!isRecord(child.props))
31
28
  return undefined;
32
29
  const handler = child.props.onLayout;
33
30
  return isHandler(handler) ? handler : undefined;
34
31
  }
35
- // One sticky header. Measures its own y/height via onLayout, interpolates the shared scroll offset
36
- // into a translateY that keeps it pinned to the top (or bottom, inverted) until the next header
37
- // collides with it, and drives that translate through the native driver when available so the pin
38
- // tracks scroll on the UI thread (no JS jitter). Ported from ScrollViewStickyHeader.js,
39
- // including the Fabric ShadowTree debounce path. inheritAttrs:false so the IStickyHeaderProps
40
- // inputs (scrollAnimatedValue/nextHeaderLayoutY/…) never fall through onto Animated.View and reach
41
- // Fabric as props (scrollAnimatedValue on a host node would crash Android's folly::dynamic).
32
+ // One sticky header. Measures its own y/height via onLayout, interpolates the shared scroll
33
+ // offset into a translateY pinning it to the top (or bottom, inverted) until the next header
34
+ // collides with it, driven through the native driver when available (no JS jitter). Ported from
35
+ // ScrollViewStickyHeader.js, including the Fabric ShadowTree debounce path. inheritAttrs:false
36
+ // so the IStickyHeaderProps inputs never fall through onto Animated.View and reach Fabric as
37
+ // props (scrollAnimatedValue on a host node would crash Android's folly::dynamic).
42
38
  export const ScrollViewStickyHeader = defineComponent({
43
39
  name: 'ScrollViewStickyHeader',
44
40
  inheritAttrs: false,
45
41
  setup(_props, { attrs, slots }) {
46
- // The scroll-offset value the parent shares, read once (it is stable across renders, the same
47
- // markRaw'd AnimatedValue), held by IDENTITY in a const (never run through toReactive). A fresh
48
- // fallback keeps working if the invariant (wrapStickyHeaders always supplies it) ever breaks.
42
+ // Read once - stable across renders (the same markRaw'd AnimatedValue). Held by IDENTITY, never
43
+ // run through toReactive. A fresh fallback keeps working if wrapStickyHeaders ever fails to supply it.
49
44
  const scrollAnimatedValue = isAnimatedValue(attrs.scrollAnimatedValue)
50
45
  ? attrs.scrollAnimatedValue
51
46
  : markRaw(new AnimatedValue(0));
52
- const measured = ref(false);
53
- const layoutY = ref(0);
54
- const layoutHeight = ref(0);
55
- // The animated node that drives the transform (RN's animatedTranslateY). When the scroll value
56
- // is native (attachStickyScroll), this interpolation runs on the UI thread: the smooth pin.
57
- // Engine node → shallowRef (held by identity, the reactivity rule); the un-measured identity
58
- // stub until the effect below rebuilds it.
47
+ // Mutated in place by reduceSticky. A plain object, not a ref: render reads it gated by the
48
+ // reactive `version` bump + `animatedTranslateY` below. DECISIONS (zero-swallow gate,
49
+ // debounce delay, rebuild ranges) all live in reduceSticky.
50
+ const state = createInitialStickyState();
51
+ // Bumped when the reducer commits a new translateY, forcing render to re-read state.translateY.
52
+ const version = ref(0);
53
+ // Engine node -> shallowRef (identity rule); the un-measured identity stub until the
54
+ // rebuild-interpolation effect below replaces it.
59
55
  const animatedTranslateY = shallowRef(scrollAnimatedValue.interpolate({ inputRange: [-1, 0], outputRange: [0, 0] }));
60
- // The debounced EXPLICIT translateY pushed to the committed transform via
61
- // passthroughAnimatedPropExplicitValues, so the Fabric ShadowTree (hit-testing) knows the pinned
62
- // position while the native driver animates. null until the listener first fires.
63
- const translateY = ref(null);
64
- let haveReceivedInitialZeroTranslateY = true;
65
56
  let debounceTimer;
66
- watch(translateY, value => {
67
- if (value !== 0 && value !== null)
68
- haveReceivedInitialZeroTranslateY = false;
57
+ // Held so the next rebuild detaches the old listener and onBeforeUnmount cleans up.
58
+ let interpolation;
59
+ let listenerId;
60
+ const inputs = () => ({
61
+ os: Platform.OS,
62
+ inverted: typeof attrs.inverted === 'boolean' ? attrs.inverted : undefined,
63
+ scrollViewHeight: typeof attrs.scrollViewHeight === 'number' ? attrs.scrollViewHeight : undefined,
64
+ nextHeaderLayoutY: typeof attrs.nextHeaderLayoutY === 'number' ? attrs.nextHeaderLayoutY : undefined,
69
65
  });
70
- // The animated value updates several times per frame during scroll; debounce it and push the
71
- // settled value into the committed transform so hit detection stays current (RN: a Fabric-only
72
- // issue; symbiote is always Fabric, and worse on Android).
73
- const animatedValueListener = ({ value }) => {
74
- if (typeof value !== 'number')
75
- return;
76
- const timeout = stickyDebounceMs(Platform.OS);
77
- // A freshly-rebuilt interpolation re-emits 0 to its listeners; swallow that first zero (RN).
78
- if (value === 0 && !haveReceivedInitialZeroTranslateY) {
79
- haveReceivedInitialZeroTranslateY = true;
80
- return;
66
+ const runEffects = (effects) => {
67
+ for (const effect of effects) {
68
+ switch (effect.kind) {
69
+ case 'rebuild-interpolation': {
70
+ // Detach the old listener, build a fresh interpolation, and wire the settled-value listener.
71
+ if (interpolation !== undefined && listenerId !== undefined) {
72
+ interpolation.removeListener(listenerId);
73
+ listenerId = undefined;
74
+ }
75
+ const next = scrollAnimatedValue.interpolate({
76
+ inputRange: effect.inputRange,
77
+ outputRange: effect.outputRange,
78
+ });
79
+ listenerId = next.addListener(({ value }) => {
80
+ if (typeof value === 'number')
81
+ dispatch({ kind: 'animated-tick', value });
82
+ });
83
+ interpolation = next;
84
+ animatedTranslateY.value = next;
85
+ break;
86
+ }
87
+ case 'schedule-debounce':
88
+ // The animated value updates several times per frame; debounce the settled value so
89
+ // hit detection stays current (a Fabric issue, worse on Android).
90
+ if (debounceTimer !== undefined)
91
+ clearTimeout(debounceTimer);
92
+ debounceTimer = setTimeout(() => {
93
+ debounceTimer = undefined;
94
+ dispatch({ kind: 'debounce-fired', value: effect.value });
95
+ }, effect.delay);
96
+ break;
97
+ case 'apply-passthrough':
98
+ version.value += 1;
99
+ break;
100
+ case 'record-header-y':
101
+ // Vue records through attrs.onLayout (the wrapper closure); the reducer emits no index for it.
102
+ break;
103
+ }
81
104
  }
82
- if (debounceTimer !== undefined)
83
- clearTimeout(debounceTimer);
84
- debounceTimer = setTimeout(() => {
85
- translateY.value = value;
86
- }, timeout);
87
105
  };
88
- // Rebuild the interpolation whenever the layout / collision inputs change (the Vue twin of RN's
89
- // effect, deps [measured, layoutY, layoutHeight, scrollViewHeight, nextHeaderLayoutY, inverted]).
90
- // symbiote is always Fabric: listen to the settled value to keep the ShadowTree transform current
91
- // for hit-testing (RN attaches this listener only under Fabric). scrollAnimatedValue is the stable
92
- // const (RN lists it in deps; it never changes for one ScrollView, so the const is faithful).
93
- watchEffect(onCleanup => {
94
- const inverted = typeof attrs.inverted === 'boolean' ? attrs.inverted : undefined;
95
- const scrollViewHeight = typeof attrs.scrollViewHeight === 'number' ? attrs.scrollViewHeight : undefined;
96
- const nextHeaderLayoutY = typeof attrs.nextHeaderLayoutY === 'number' ? attrs.nextHeaderLayoutY : undefined;
97
- const { inputRange, outputRange } = computeStickyInterpolation({
98
- measured: measured.value,
99
- inverted,
100
- scrollViewHeight,
101
- layoutY: layoutY.value,
102
- layoutHeight: layoutHeight.value,
103
- nextHeaderLayoutY,
104
- });
105
- const interpolation = scrollAnimatedValue.interpolate({ inputRange, outputRange });
106
- const listenerId = interpolation.addListener(animatedValueListener);
107
- animatedTranslateY.value = interpolation;
108
- onCleanup(() => {
106
+ const dispatch = (action) => {
107
+ runEffects(reduceSticky(state, action, inputs()).effects);
108
+ };
109
+ // Rebuild whenever the collision/viewport inputs change ([inverted, scrollViewHeight,
110
+ // nextHeaderLayoutY]); also runs once on mount.
111
+ watchEffect(() => {
112
+ dispatch({ kind: 'inputs-changed' });
113
+ });
114
+ onBeforeUnmount(() => {
115
+ if (interpolation !== undefined && listenerId !== undefined) {
109
116
  interpolation.removeListener(listenerId);
110
- if (debounceTimer !== undefined)
111
- clearTimeout(debounceTimer);
112
- });
117
+ }
118
+ if (debounceTimer !== undefined)
119
+ clearTimeout(debounceTimer);
113
120
  });
114
- // Stable function ref (defined once, not re-created per render): record own y/height, mark
115
- // measured, fire the wrapper's recorder (onHeaderLayoutY, in attrs.onLayout), then the child's
116
- // own onLayout. Matches RN ScrollViewStickyHeader.js._onLayout order.
121
+ // Dispatch the layout, fire the wrapper's recorder (onHeaderLayoutY via attrs.onLayout), then
122
+ // the child's own onLayout. Matches RN ScrollViewStickyHeader.js._onLayout.
117
123
  const onLayout = (event) => {
118
124
  const y = readLayoutNumber(event, 'y');
119
125
  const height = readLayoutNumber(event, 'height');
120
- if (y !== undefined)
121
- layoutY.value = y;
122
- if (height !== undefined)
123
- layoutHeight.value = height;
124
- measured.value = true;
126
+ // Keep the previous value when a field is absent (RN sets state only on a defined read).
127
+ dispatch({ kind: 'layout', y: y ?? state.layoutY, height: height ?? state.layoutHeight });
125
128
  const recorder = attrs.onLayout;
126
129
  if (isHandler(recorder))
127
130
  recorder(event);
@@ -132,14 +135,15 @@ export const ScrollViewStickyHeader = defineComponent({
132
135
  childOnLayout(event);
133
136
  };
134
137
  return () => {
138
+ // Read the version bump so a committed translateY re-runs render.
139
+ void version.value;
135
140
  // The EXPLICIT debounced translateY overrides the committed transform for hit-testing, while
136
- // `animatedTranslateY` does the smooth (native-driven) pin. See RN ScrollViewStickyHeader.js.
137
- const passthroughAnimatedPropExplicitValues = translateY.value !== null
138
- ? { style: { transform: [{ translateY: translateY.value }] } }
141
+ // animatedTranslateY does the smooth (native-driven) pin.
142
+ const passthroughAnimatedPropExplicitValues = state.translateY !== null
143
+ ? { style: { transform: [{ translateY: state.translateY }] } }
139
144
  : null;
140
145
  // collapsable:false keeps the wrapper a real Yoga node; zIndex makes the pinned header paint
141
- // OVER the rows scrolling under it. The interpolation node passes inside the transform; the
142
- // Animated wrapper's reduceProps rasterizes it into a numeric translateY for the committed tree.
146
+ // OVER the rows scrolling under it.
143
147
  return h(Animated.View, {
144
148
  style: {
145
149
  transform: [{ translateY: animatedTranslateY.value }],
@@ -153,15 +157,13 @@ export const ScrollViewStickyHeader = defineComponent({
153
157
  },
154
158
  });
155
159
  // Wrap each child flagged by `stickyHeaderIndices` in the sticky header component, fed by the
156
- // shared scroll AnimatedValue. Mirrors ScrollView.js's render-time children.map (~line 1690) and
157
- // the React adapter's wrapStickyHeaders. Returns the children unchanged when no indices are flagged.
160
+ // shared scroll AnimatedValue. Returns the children unchanged when no indices are flagged.
158
161
  //
159
- // Cross-talk plumbing (RN's _headerLayoutYs + _onStickyHeaderLayout, ScrollView.js:1115-1143):
160
- // `headerLayoutYs` is a child-index→measured-y map the parent keeps; each header reports its own y
161
- // through `onHeaderLayoutY` as it measures, and we feed every header the y of the NEXT flagged
162
- // header (the collision point past which it scrolls off) by looking up its successor's index in
163
- // `stickyHeaderIndices`. The LAST flagged header has no successor, so its `nextHeaderLayoutY` stays
164
- // undefined and it sticks indefinitely (correct).
162
+ // Cross-talk plumbing (RN's _headerLayoutYs + _onStickyHeaderLayout): headerLayoutYs is a
163
+ // child-index -> measured-y map; each header reports its own y through onHeaderLayoutY, and we
164
+ // feed every header the y of the NEXT flagged header (the collision point past which it scrolls
165
+ // off). The LAST flagged header has no successor, so its nextHeaderLayoutY stays undefined and
166
+ // it sticks indefinitely (correct).
165
167
  export function wrapStickyHeaders(children, stickyHeaderIndices, scrollAnimatedValue, invertStickyHeaders, scrollViewHeight, StickyHeaderComponent, headerLayoutYs, onHeaderLayoutY) {
166
168
  if (stickyHeaderIndices === undefined || stickyHeaderIndices.length === 0)
167
169
  return children;
@@ -170,17 +172,15 @@ export function wrapStickyHeaders(children, stickyHeaderIndices, scrollAnimatedV
170
172
  const indexOfIndex = stickyHeaderIndices.indexOf(index);
171
173
  if (indexOfIndex === -1 || !isVNode(child))
172
174
  return child;
173
- // The next flagged header's measured y, by index order in stickyHeaderIndices (RN
174
- // ScrollView.js:1695 nextIndex). undefined until that header has measured (or for the last).
175
+ // The next flagged header's measured y. undefined until that header has measured (or for the last).
175
176
  const nextIndex = stickyHeaderIndices[indexOfIndex + 1];
176
177
  const nextHeaderLayoutY = nextStickyHeaderY(stickyHeaderIndices, indexOfIndex, headerLayoutYs);
177
178
  dlog(`Vue ScrollView sticky-header wrap index=${index} next=${nextIndex} nextY=${nextHeaderLayoutY}`);
178
179
  const props = {
179
180
  key: child.key ?? `sticky-${index}`,
180
181
  nextHeaderLayoutY,
181
- // RN _onStickyHeaderLayout: record this header's own y, then push it to the previous header as
182
- // its nextHeaderLayoutY. We record into the parent map; the lookup above feeds it forward on
183
- // the resulting re-render (the headerLayoutYs bump in scroll-view-shared).
182
+ // Record this header's own y into the parent map; the lookup above feeds it forward to the
183
+ // previous header on the resulting re-render (the headerLayoutYs bump in scroll-view-shared).
184
184
  onLayout: (event) => {
185
185
  const y = readLayoutNumber(event, 'y');
186
186
  if (y !== undefined)
@@ -1,21 +1,18 @@
1
1
  // SectionList, the Vue public list-of-sections component. A thin wrapper over
2
2
  // VirtualizedSectionList, mirroring RN's layering (SectionList -> VirtualizedSectionList ->
3
- // VirtualizedList). All section-flattening / windowing / imperative-scroll logic lives below;
4
- // this layer re-exposes the same surface under the SectionList name and re-exposes the handle
5
- // (Vue resolves a parent ref to the exposed object, so the wrapper delegates). The Vue twin of
6
- // the React adapter's SectionList.
3
+ // VirtualizedList). All section-flattening/windowing/imperative-scroll logic lives below; this
4
+ // layer re-exposes the same surface and re-exposes the handle (Vue resolves a parent ref to the
5
+ // exposed object, so the wrapper delegates).
7
6
  //
8
- // Typed-emits generic component (mirrors FlatList / VirtualizedList): a GENERIC setup function
9
- // `<ItemT,>(props: ISectionListProps<ItemT>, ctx: ICtx<ISectionListEmits>)` so the section inputs
10
- // (sections/renderItem/…) infer ItemT at the call site. As a pure forwarder it consumes nothing
11
- // itself — every input rides through $attrs straight onto VirtualizedSectionList; only the three
12
- // synthesized events (endReached/startReached/refresh) are bridged, gated on listener presence so
13
- // the inner list keeps its RefreshControl / edge-reached gating.
7
+ // Typed-emits generic component: a GENERIC setup function so section inputs infer ItemT at the
8
+ // call site. As a pure forwarder it consumes nothing itself - every input rides through $attrs
9
+ // straight onto VirtualizedSectionList; only the three synthesized events are bridged, gated on
10
+ // listener presence so the inner list keeps its RefreshControl/edge-reached gating.
14
11
  import { defineComponent, getCurrentInstance, h, shallowRef, } from '@vue/runtime-core';
15
12
  import { VirtualizedSectionList, } from '../virtualized-section-list/index.js';
16
13
  import { normalizeVueAttrs } from '../../utils/normalize-attrs.js';
17
- // VirtualizedSectionList is a generic component (generic construct signature), which h()'s overloads
18
- // can't resolve. Drive it through a loose functional-component handle (generic-component h() limit).
14
+ // VirtualizedSectionList's generic construct signature can't be resolved by h()'s overloads, so
15
+ // drive it through a loose functional-component handle.
19
16
  const VirtualizedSectionListHost = VirtualizedSectionList;
20
17
  function isRecord(value) {
21
18
  return typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -36,17 +33,15 @@ function buildDelegate(getInner) {
36
33
  };
37
34
  }
38
35
  export const SectionList = defineComponent(
39
- // _props is read only by the type system: ISectionListProps<ItemT> is what lets ItemT infer at
40
- // the call site. As a pure forwarder, SectionList consumes nothing at runtime — it spreads $attrs.
36
+ // _props is read only by the type system, to let ItemT infer at the call site.
41
37
  (_props, { attrs, expose, emit, slots }) => {
42
38
  const inner = shallowRef(null);
43
39
  const setInner = (instance) => {
44
40
  inner.value = isSectionHandle(instance) ? instance : null;
45
41
  };
46
42
  expose(buildDelegate(() => inner.value));
47
- // The three section-list events are emits, so Vue strips their onX listeners from $attrs. Detect
48
- // listener presence off the instance's own vnode props and bridge each ONLY when listened, so
49
- // the inner VirtualizedSectionList (and its inner VirtualizedList) keeps its on-demand gating.
43
+ // Emits are stripped from $attrs by Vue; detect listener presence off the instance's own
44
+ // vnode props and bridge each ONLY when listened.
50
45
  const instance = getCurrentInstance();
51
46
  const listens = (onName) => {
52
47
  const vnodeProps = instance?.vnode.props;
@@ -60,8 +55,6 @@ export const SectionList = defineComponent(
60
55
  ? (info) => emit('startReached', info)
61
56
  : undefined;
62
57
  const refresh = listens('onRefresh') ? () => emit('refresh') : undefined;
63
- // Pure forwarder: spread $attrs as props and pass the consumer's scoped slots (#item /
64
- // #sectionHeader / … ) straight down to VirtualizedSectionList untouched.
65
58
  return h(VirtualizedSectionListHost, {
66
59
  ...normalizeVueAttrs(attrs),
67
60
  ref: setInner,
@@ -1,14 +1,12 @@
1
- // Switch, the Vue lifecycle half. The logic (the lastNativeReport reducer, valueFromChange,
2
- // the snap-back decision) lives in @symbiote-native/components/state and the render in
3
- // @symbiote-native/components/view, both shared verbatim with the React adapter. Here Vue supplies
4
- // the reactivity: a ref holds what native last reported, a function ref grabs the host node,
5
- // and a post-flush watch snaps native back when the parent rejects a toggle. This is the Vue
6
- // twin of the React adapter's useReducer + useLayoutEffect + dispatchViewCommand.
1
+ // Switch, the Vue lifecycle half. The logic (lastNativeReport reducer, valueFromChange, the
2
+ // snap-back decision) lives in @symbiote-native/components/state and the render in
3
+ // @symbiote-native/components/view, both shared verbatim with React. Vue supplies the
4
+ // reactivity: a ref holds what native last reported, a function ref grabs the host node, and a
5
+ // post-flush watch snaps native back when the parent rejects a toggle.
7
6
  //
8
- // Inputs arrive as attrs (untyped), so each is narrowed with a runtime guard rather than a
9
- // cast. onValueChange MUST be stripped from the forwarded attrs: it is not a ViewConfig event
10
- // (it's derived from the native onChange), so leaking it would reach Fabric as a function prop
11
- // and crash Android's folly::dynamic.
7
+ // Inputs arrive as attrs (untyped), so each is narrowed with a runtime guard, never a cast.
8
+ // onValueChange MUST be stripped from the forwarded attrs: it is not a ViewConfig event, so
9
+ // leaking it would reach Fabric as a function prop and crash Android's folly::dynamic.
12
10
  import { defineComponent, ref, shallowRef, watch } from '@vue/runtime-core';
13
11
  import { renderSwitch, switchReducer, createInitialSwitchState, shouldSnapBack, valueFromChange, } from '@symbiote-native/components';
14
12
  import { dispatchViewCommand, isSymbioteNode, dlog, } from '@symbiote-native/engine';
@@ -24,7 +22,6 @@ function asString(value) {
24
22
  function isBoolean(value) {
25
23
  return typeof value === 'boolean';
26
24
  }
27
- // trackColor arrives untyped; keep only the string false/true entries the render fn reads.
28
25
  function normalizeTrackColor(value) {
29
26
  if (!isRecord(value))
30
27
  return undefined;
@@ -36,12 +33,11 @@ function normalizeTrackColor(value) {
36
33
  return trackColor;
37
34
  }
38
35
  // The render fn's style param is a plain object; array/registered styles degrade to undefined
39
- // (the engine flattens those on its own). Same narrowing the Vue ActivityIndicator uses.
36
+ // (the engine flattens those on its own).
40
37
  function isViewStyleObject(value) {
41
38
  return typeof value === 'object' && value !== null && !Array.isArray(value);
42
39
  }
43
- // The prop/handler keys the lifecycle consumes itself; everything else (accessibility,
44
- // testID, …) forwards onto the host node. onValueChange is pure JS and must never reach Fabric.
40
+ // Everything else forwards onto the host node. onValueChange is pure JS and must never reach Fabric.
45
41
  const HANDLED_ATTRS = [
46
42
  'value',
47
43
  'modelValue',
@@ -62,22 +58,15 @@ function forwardAttrs(attrs) {
62
58
  }
63
59
  export function createSwitch(platform) {
64
60
  return defineComponent((_props, { attrs: rawAttrs, emit }) => {
65
- // shallowRef, NOT ref: the engine node must be held by IDENTITY. A plain ref() runs the
66
- // assigned object through Vue's toReactive(), so reading nodeRef.value back yields a
67
- // reactive Proxy, a different object than the raw node. The engine's mirror is a WeakMap
68
- // keyed on the raw node, so every imperative command (dispatchViewCommand / measure /
69
- // setNativeProps) would miss on the Proxy and silently no-op. shallowRef stores the raw
70
- // node untouched. General rule for this adapter: host nodes live in shallowRef / markRaw.
61
+ // shallowRef, NOT ref: a plain ref() would hand back a reactive Proxy the engine's WeakMap
62
+ // mirror doesn't recognize, so every imperative command would silently no-op. General rule
63
+ // for this adapter: host nodes live in shallowRef / markRaw.
71
64
  const nodeRef = shallowRef(null);
72
65
  const state = ref(createInitialSwitchState());
73
- // The host node our Vue renderer creates IS an engine SymbioteNode; Vue hands it to this
74
- // function ref on mount. A stable function ref (defined once) isn't re-invoked per patch.
75
66
  const setNodeRef = (el) => {
76
67
  nodeRef.value = isSymbioteNode(el) ? el : null;
77
68
  };
78
69
  const handleChange = (event) => {
79
- // Vue-facing event is emitted here; the host still receives this internal onChange
80
- // handler so the controlled snap-back state stays synchronized with native.
81
70
  const next = valueFromChange(event);
82
71
  dlog(`Switch onChange value=${String(next)} eventCount=${String(event.nativeEvent.eventCount)}`);
83
72
  if (next === undefined)
@@ -86,11 +75,10 @@ export function createSwitch(platform) {
86
75
  emitModelUpdate(emit, next);
87
76
  state.value = switchReducer(state.value, { type: 'native-reported', value: next });
88
77
  };
89
- // Snap-back: when native reported a value the parent rejected (the value prop did not
90
- // change), command the JS value back down: the controlled-Switch correction RN does via
91
- // SwitchCommands.setValue. The decision is shared with React (shouldSnapBack); only the
92
- // command name is platform-imperative. flush:'post' so the engine has committed the node
93
- // before the command reads its Fabric handle.
78
+ // Snap-back: when native reported a value the parent rejected, command the JS value back
79
+ // down (the controlled-Switch correction RN does via SwitchCommands.setValue). The
80
+ // decision is shared with React (shouldSnapBack); only the command name is platform-
81
+ // imperative. flush:'post' so the node is committed before the command reads its Fabric handle.
94
82
  watch(() => ({
95
83
  fabricValue: resolveModelValue(rawAttrs, isBoolean) === true,
96
84
  switchState: state.value,
@@ -1,17 +1,13 @@
1
- // TextInput, the Vue lifecycle half. The folds/maps (value->text, the W3C/alias resolution)
2
- // and the controlled-write predicate live in @symbiote-native/components/state, the render (intrinsic
3
- // + native-prop mapping) in @symbiote-native/components/view, both shared verbatim with the React
4
- // adapter. Here Vue supplies only the reactivity: a shallowRef holds the host node, a ref holds
5
- // the acknowledged event count (so the exposed handle echoes the latest), setup-scope `let`s
6
- // hold the last native text + focus flag, a post-flush watch runs the controlled-write command,
7
- // and expose() wires the imperative handle. The Vue twin of the React useState/useRef +
8
- // useLayoutEffect + useImperativeHandle.
1
+ // TextInput, the Vue lifecycle half. The folds/maps (value->text, W3C/alias resolution) and the
2
+ // controlled-write predicate live in @symbiote-native/components/state, the render in
3
+ // @symbiote-native/components/view, both shared verbatim with React. Vue supplies only the
4
+ // reactivity: a shallowRef holds the host node, a ref holds the acknowledged event count, setup-
5
+ // scope `let`s hold the last native text + focus flag, a post-flush watch runs the
6
+ // controlled-write command, and expose() wires the imperative handle.
9
7
  //
10
- // Inputs arrive as attrs (untyped), so each is narrowed with a runtime guard rather than a cast.
11
- // onValueChange MUST be stripped from the forwarded attrs: it is not a ViewConfig event (it's
12
- // derived from the native onChange), so leaking it would reach Fabric as a function prop and
13
- // crash Android's folly::dynamic. The imperative module (blurTextInput / setInput*) is imported
14
- // from @symbiote-native/engine, the same framework-agnostic singleton both adapters share.
8
+ // Inputs arrive as attrs (untyped), so each is narrowed with a runtime guard, never a cast.
9
+ // onValueChange MUST be stripped from the forwarded attrs: it is not a ViewConfig event, so
10
+ // leaking it would reach Fabric as a function prop and crash Android's folly::dynamic.
15
11
  import { defineComponent, onBeforeUnmount, ref, shallowRef, watch } from '@vue/runtime-core';
16
12
  import { resolveAccessibilityProps, resolveTextInputProps, renderTextInput, foldText, textFromChange, eventCountFromChange, shouldCommandText, INITIAL_EVENT_COUNT, SELECTION_NONE, } from '@symbiote-native/components';
17
13
  import { dispatchViewCommand, isSymbioteNode, dlog, blurTextInput, setInputFocused, setInputBlurred, whenCommitted, } from '@symbiote-native/engine';
@@ -30,7 +26,6 @@ function isString(value) {
30
26
  function asBoolean(value) {
31
27
  return typeof value === 'boolean' ? value : undefined;
32
28
  }
33
- // selection arrives untyped; keep only the numeric start/end the render fn + controlled write read.
34
29
  function normalizeSelection(value) {
35
30
  if (!isRecord(value))
36
31
  return undefined;
@@ -41,9 +36,7 @@ function normalizeSelection(value) {
41
36
  selection.end = value.end;
42
37
  return selection;
43
38
  }
44
- // The prop/handler keys the lifecycle consumes itself (mirrors the React adapter's destructure);
45
- // everything else (placeholder, secureTextEntry, the remaining native events, accessibility,
46
- // testID, style…) forwards onto the host node. onFocus/onBlur are re-supplied as our wrapped
39
+ // Everything else forwards onto the host node. onFocus/onBlur are re-supplied as our wrapped
47
40
  // handlers; onValueChange is pure JS and must never reach Fabric.
48
41
  const HANDLED_ATTRS = [
49
42
  'value',
@@ -80,38 +73,30 @@ function forwardAttrs(attrs) {
80
73
  return result;
81
74
  }
82
75
  export const TextInput = defineComponent((_props, { attrs: rawAttrs, emit, expose }) => {
83
- // shallowRef, NOT ref: the engine node must be held by IDENTITY. A plain ref() runs the node
84
- // through Vue's toReactive(), handing back a reactive Proxy, a different object than the raw
85
- // node the engine's WeakMap mirror is keyed on, so every imperative command
86
- // (dispatchViewCommand / focus / blur / setTextAndSelection) would miss and silently no-op.
87
- // Same rule applies to the Switch / ScrollView host node.
76
+ // shallowRef, NOT ref: a plain ref() would hand back a reactive Proxy the engine's WeakMap
77
+ // mirror doesn't recognize, so every imperative command would silently no-op. Same rule as
78
+ // the Switch / ScrollView host node.
88
79
  const nodeRef = shallowRef(null);
89
80
  const setNodeRef = (el) => {
90
81
  nodeRef.value = isSymbioteNode(el) ? el : null;
91
82
  };
92
- // The count native last acknowledged. A ref so the render echoes it back as
93
- // mostRecentEventCount and the exposed handle reads the latest; the controlled write commands
94
- // it so native's eventLag lands on 0.
83
+ // The count native last acknowledged. A ref so render echoes it as mostRecentEventCount; the
84
+ // controlled write commands it so native's eventLag lands on 0.
95
85
  const mostRecentEventCount = ref(INITIAL_EVENT_COUNT);
96
- // The last text native holds, as far as JS knows. Seeded from the mount-time value (the `text`
97
- // prop already carries it down via createNode, so the FIRST controlled value is not a
98
- // divergence and must NOT re-command). A setup-scope `let`: no render needed when it changes.
86
+ // Seeded from the mount-time value (the `text` prop already carries it down via createNode,
87
+ // so the FIRST controlled value is not a divergence and must NOT re-command).
99
88
  let lastNativeText = foldText(resolveModelValue(rawAttrs, isString), asString(rawAttrs.defaultValue));
100
- // JS-side focus state, mirrored from the focus/blur events for isFocused(): native exposes no
101
- // synchronous focus getter (RN's TextInputState holds the same).
89
+ // Mirrored from focus/blur events for isFocused(): native exposes no synchronous focus getter.
102
90
  let focused = false;
103
- // autoFocus fires once when the node first commits; guard so a later node identity change does
104
- // not re-focus.
91
+ // Guard so a later node identity change does not re-focus.
105
92
  let autoFocused = false;
106
93
  const handleChange = (event) => {
107
- // Event seam: the controlled handshake hinges on the change payload carrying `text`
108
- // (+ `eventCount`). iOS and Android Fabric can key these differently, so log the actual shape.
94
+ // iOS and Android Fabric can key the change payload differently, so log the actual shape.
109
95
  dlog(`TextInput change keys=[${Object.keys(event.nativeEvent).join(',')}] ` +
110
96
  `text=${JSON.stringify(event.nativeEvent.text)} count=${JSON.stringify(event.nativeEvent.eventCount)}`);
111
97
  const text = textFromChange(event);
112
98
  if (text !== undefined) {
113
- // Record the text first, then the count, so the count never runs ahead of the text it
114
- // stands for.
99
+ // Record the text first, so the count never runs ahead of the text it stands for.
115
100
  lastNativeText = text;
116
101
  emit('valueChange', text, event);
117
102
  emitModelUpdate(emit, text);
@@ -136,10 +121,8 @@ export const TextInput = defineComponent((_props, { attrs: rawAttrs, emit, expos
136
121
  emit('blur', event);
137
122
  };
138
123
  // Controlled write: when JS-side `value` diverges from what native reported, command the new
139
- // text down with the acknowledged count; a plain prop re-push would race the user's
140
- // keystrokes. Watching `value` covers every divergence (the parent only rewrites `value` after
141
- // a change). flush:'post' so the engine has committed the node before the command reads its
142
- // Fabric handle; the predicate makes it a no-op on mount (value === the seed).
124
+ // text down with the acknowledged count - a plain prop re-push would race the user's
125
+ // keystrokes. flush:'post' so the node is committed before the command reads its Fabric handle.
143
126
  watch(() => resolveModelValue(rawAttrs, isString), value => {
144
127
  const node = nodeRef.value;
145
128
  if (node === null)
@@ -158,11 +141,10 @@ export const TextInput = defineComponent((_props, { attrs: rawAttrs, emit, expos
158
141
  ]);
159
142
  lastNativeText = value;
160
143
  }, { flush: 'post' });
161
- // autoFocus is driven in JS, not as a native prop: once the node first commits, command `focus`
162
- // down once (RN does the same via TextInputState.focusInput). The watch fires when nodeRef is
163
- // set (mount), but under Vue's async-batched commit the node has no Fabric tag yet at post-flush,
164
- // so the focus command would be skipped with no retry. whenCommitted defers it to the commit that
165
- // assigns the tag. Cancelled on unmount so an un-committed pending focus can't leak.
144
+ // autoFocus is driven in JS: once the node first commits, command `focus` down once. Under
145
+ // Vue's async-batched commit the node has no Fabric tag yet at post-flush, so
146
+ // whenCommitted defers it to the commit that assigns the tag. Cancelled on unmount so an
147
+ // un-committed pending focus can't leak.
166
148
  let cancelAutoFocus;
167
149
  watch(nodeRef, node => {
168
150
  if (autoFocused || node === null || rawAttrs.autoFocus !== true)
@@ -172,10 +154,8 @@ export const TextInput = defineComponent((_props, { attrs: rawAttrs, emit, expos
172
154
  cancelAutoFocus = whenCommitted(node, () => dispatchViewCommand(node, 'focus', []));
173
155
  }, { flush: 'post' });
174
156
  onBeforeUnmount(() => cancelAutoFocus?.());
175
- // The imperative API RN exposes on the ref. The methods read nodeRef.value / the count LIVE,
176
- // so no stale capture: the Vue twin of React's useImperativeHandle. focus/blur drive native
177
- // view commands; clear and setSelection reuse setTextAndSelection (the same stale-safe path as
178
- // a controlled write) echoing the acknowledged event count.
157
+ // Methods read nodeRef.value / the count LIVE, so no stale capture. clear/setSelection reuse
158
+ // setTextAndSelection, the same path as a controlled write.
179
159
  expose({
180
160
  focus: () => {
181
161
  const node = nodeRef.value;