@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,48 +1,34 @@
1
- // ScrollView, the Vue lifecycle half. The Fabric tree is nested:
2
- // a scroll view wraps a content view that holds the children (RN's ScrollView.js shape). The
3
- // platform-invariant math (decelerationRate, the per-axis intrinsics/base style, the
4
- // content-size dedupe, the imperative handle, the aria/role fold) lives in @symbiote-native/components,
5
- // shared verbatim with React. Here Vue supplies only the reactivity: a shallowRef holds the host
6
- // node, a setup-scope `lastContentSize` dedupes onContentSizeChange, and expose() wires the
7
- // imperative handle. This is the Vue twin of the React adapter's useRef + buildScrollViewHandle.
1
+ // ScrollView, the Vue lifecycle half. The Fabric tree is nested: a scroll view wraps a
2
+ // content view holding the children (RN's ScrollView.js shape). Platform-invariant math
3
+ // (decelerationRate, per-axis intrinsics/base style, content-size dedupe, imperative handle,
4
+ // aria/role fold) lives in @symbiote-native/components, shared with React; Vue supplies only
5
+ // the reactivity - shallowRef host node, setup-scope lastContentSize dedupe, expose() handle -
6
+ // the twin of React's useRef + buildScrollViewHandle.
8
7
  //
9
- // Inputs arrive as attrs (untyped), so each is narrowed with a runtime guard rather than a cast.
10
- // contentSizeChange is synthesized as a typed Vue emit from the content onLayout. The legacy
11
- // onContentSizeChange callback key MUST be consumed if it arrives (it is NOT a ViewConfig event;
12
- // forwarding a function prop would reach Fabric and crash Android's folly::dynamic). Scroll events
13
- // (onScroll/onLayout/…) ARE ViewConfig events, so they forward raw and routeProp turns them into
14
- // listeners.
8
+ // Attrs are untyped, so every field is narrowed with a runtime guard, never a cast. The legacy
9
+ // onContentSizeChange callback key MUST be consumed if present: it is NOT a ViewConfig event,
10
+ // and forwarding a function prop reaches Fabric and crashes Android's folly::dynamic.
15
11
  //
16
- // RefreshControl is wired through the platform assemble (iOS sibling /
17
- // Android wrap). Sticky headers are real. The scroll AnimatedValue (markRaw, held by
18
- // identity), the headerLayoutYs cross-talk map + bump, the viewport-height capture, and the
19
- // onScroll composition (native attach vs Animated.event) all live here; the per-header component and
20
- // the children wrap live in scroll-view-sticky-header.ts (the Vue twin of the React file).
12
+ // Sticky headers are real: the scroll AnimatedValue, headerLayoutYs cross-talk map, viewport-
13
+ // height capture, and onScroll composition (native attach vs Animated.event) live here; the
14
+ // per-header component and children wrap live in sticky-header.ts.
21
15
  import { defineComponent, h, isVNode, markRaw, onBeforeUnmount, ref, shallowRef, watch, } from '@vue/runtime-core';
22
- import { attachStickyScroll, buildScrollViewHandle, didContentSizeChange, forwardScrollEvent, readLayoutDimension, resolveAccessibilityProps, resolveDecelerationRate, selectScrollIntrinsics, } from '@symbiote-native/components';
16
+ import { attachStickyScroll, buildScrollViewHandle, didContentSizeChange, forwardScrollEvent, readLayoutDimension, resolveAccessibilityProps, resolveDecelerationRate, resolveScrollForwarding, selectScrollIntrinsics, } from '@symbiote-native/components';
23
17
  import { AnimatedValue, dlog, event as animatedEvent, isClassNameValue, isNativeAnimatedAvailable, isSymbioteNode, resolveClassName, } from '@symbiote-native/engine';
24
18
  import { wrapStickyHeaders } from './sticky-header.js';
25
19
  import { normalizeVueAttrs } from '../../utils/normalize-attrs.js';
26
20
  function isHandler(value) {
27
21
  return typeof value === 'function';
28
22
  }
29
- // Objects and arrays are valid StyleProp<ViewStyle> (ViewStyle | RecursiveArray | falsy; the
30
- // engine omits RN's RegisteredStyle brand, so no numeric form). Primitives/null degrade to
31
- // undefined; the engine flattens whatever object/array reaches it.
23
+ // Objects and arrays are valid StyleProp<ViewStyle>; primitives/null degrade to undefined.
32
24
  function isStyleProp(value) {
33
25
  return typeof value === 'object' && value !== null;
34
26
  }
35
- // `class` is never in HANDLED_ATTRS (inheritAttrs:false means it also isn't auto-merged onto a
36
- // root), so it forwards raw to the inner scroll-view node via forwardAttrs, resolved later by
37
- // the renderer's own patchProp — fine for the single-node Phase 1 path. But the Android
38
- // RefreshControl wrap (index.android.ts) reads userStyle alone to splitLayoutProps() the outer
39
- // wrapper's layout style, BEFORE that later resolution ever runs, so a class-only layout prop
40
- // (flex, height, gap, …) never reaches the wrapper and it collapses to nothing.
41
- // isClassNameProp is @symbiote-native/engine's own isClassNameValue guard (shared, not redeclared —
42
- // routeProp's centralized class+style merge needs the identical narrowing).
27
+ // `class` forwards raw via forwardAttrs, resolved later by the renderer's patchProp. But the
28
+ // Android RefreshControl wrap (index.android.ts) reads userStyle alone to splitLayoutProps()
29
+ // the outer wrapper BEFORE that later resolution runs, so a class-only layout prop (flex,
30
+ // height, gap, …) would otherwise never reach the wrapper.
43
31
  const isClassNameProp = isClassNameValue;
44
- // decelerationRate is resolved per-platform (resolveDecelerationRate), so it must be narrowed to
45
- // its declared shape before that call, unlike the raw pass-through props.
46
32
  function asDecelerationRate(value) {
47
33
  if (typeof value === 'number')
48
34
  return value;
@@ -50,21 +36,16 @@ function asDecelerationRate(value) {
50
36
  return value;
51
37
  return undefined;
52
38
  }
53
- // stickyHeaderIndices arrives untyped (attrs); narrow to a number[] before the wrap reads it.
54
39
  function isNumberArray(value) {
55
40
  return Array.isArray(value) && value.every(entry => typeof entry === 'number');
56
41
  }
57
- // A StickyHeaderComponent override is a Vue component: a function (functional) or an object
58
- // (defineComponent). Narrowed before it is handed to wrapStickyHeaders (no cast).
59
42
  function isComponent(value) {
60
43
  return typeof value === 'function' || (typeof value === 'object' && value !== null);
61
44
  }
62
- // The prop/handler keys the lifecycle consumes itself; everything else (the scroll events,
63
- // snap/keyboard/zoom families, accessibility, testID, …) forwards onto the scroll-view node.
64
- // onContentSizeChange is consumed (synthesized from the content onLayout, never forwarded);
65
- // refreshControl is consumed by the platform assemble; the sticky-header props (indices / invert /
66
- // StickyHeaderComponent) are lifecycle-consumed by the children wrap and must NEVER reach Fabric.
67
- // style / contentContainerStyle / horizontal / decelerationRate are recomposed.
45
+ // Prop/handler keys the lifecycle consumes itself; everything else forwards onto the scroll-view
46
+ // node. onContentSizeChange is consumed (synthesized from the content onLayout); refreshControl
47
+ // and the sticky-header props are consumed by the platform assemble / children wrap and must
48
+ // NEVER reach Fabric.
68
49
  const HANDLED_ATTRS = [
69
50
  'style',
70
51
  'contentContainerStyle',
@@ -86,39 +67,29 @@ function forwardAttrs(attrs) {
86
67
  }
87
68
  export function createScrollView(platform) {
88
69
  return defineComponent((_props, { slots, attrs: rawAttrs, expose, emit }) => {
89
- // shallowRef, NOT ref: the engine node must be held by IDENTITY. A plain ref() runs the
90
- // node through Vue's toReactive(), handing back a reactive Proxy, a different object than
91
- // the raw node the engine's mirror (a WeakMap) is keyed on, so dispatchViewCommand would
92
- // miss and every scrollTo/scrollToEnd/flashScrollIndicators silently no-op. This is the
93
- // same rule as the Switch host node.
70
+ // shallowRef, NOT ref: a plain ref() would run the node through Vue's toReactive(),
71
+ // handing back a Proxy the engine's WeakMap mirror doesn't recognize, so
72
+ // scrollTo/scrollToEnd/flashScrollIndicators would silently no-op. Same rule as Switch.
94
73
  const nodeRef = shallowRef(null);
95
74
  const setNodeRef = (el) => {
96
75
  nodeRef.value = isSymbioteNode(el) ? el : null;
97
76
  };
98
- // The imperative handle reads the node through a LAZY getter (() => nodeRef.value), not the
99
- // node captured once: it is null until the element commits, so an eager capture would freeze
100
- // null and every command would no-op. expose() makes it the value a parent ref
101
- // sees: the Vue twin of React's useImperativeHandle(forwardedRef, buildScrollViewHandle(…)).
77
+ // Lazy getter, not the node captured once: it is null until the element commits, so an
78
+ // eager capture would freeze null. Vue twin of useImperativeHandle(ref, buildScrollViewHandle).
102
79
  expose(buildScrollViewHandle(() => nodeRef.value));
103
- // The last-seen content size, kept here (setup scope, persists across renders) to dedupe
104
- // contentSizeChange: RN fires the content onLayout on every layout pass; only real size
105
- // changes emit to the user (didContentSizeChange).
80
+ // RN fires the content onLayout on every layout pass; only a real size change emits.
106
81
  let lastContentSize = null;
107
- // A single AnimatedValue tracks the scroll offset and drives every sticky header's translateY
108
- // (RN's _scrollAnimatedValue). A setup-scope const (allocated once, stable across renders) so
109
- // the headers' bindings survive re-renders, the Vue twin of React's useRef-stable value.
110
- // markRaw: it is an engine object, held by IDENTITY, never run through toReactive (the
111
- // reactivity rule; a deep ref would hand back a Proxy the engine's mirror misses). Allocated
112
- // unconditionally (like React's unconditional hook); unused when no sticky headers are flagged.
82
+ // Drives every sticky header's translateY (RN's _scrollAnimatedValue). markRaw: an engine
83
+ // object held by identity, never run through toReactive. Allocated unconditionally
84
+ // (unused when no sticky headers are flagged), like React's unconditional hook.
113
85
  const scrollAnimatedValue = markRaw(new AnimatedValue(0));
114
- // Inverted sticky headers stick to the BOTTOM, so they need the viewport height (RN reads it
115
- // in _handleLayout). Tracked here and fed back into the wrapped headers on the next render.
86
+ // Inverted sticky headers stick to the BOTTOM, so they need the viewport height (RN's
87
+ // _handleLayout). Fed back into the wrapped headers on the next render.
116
88
  const viewportHeight = ref(undefined);
117
- // Sticky-header cross-talk (RN ScrollView.js _headerLayoutYs, line 754): a child-index→measured-y
118
- // map the parent keeps so each header can learn where the NEXT sticky header starts (its push-off
119
- // collision point). The map is a setup-scope const mutated imperatively from each header's onLayout
120
- // (like RN's _onStickyHeaderLayout); a reactive bump ref forces the re-render that feeds the
121
- // freshly-recorded y forward into the previous header's nextHeaderLayoutY (via nextStickyHeaderY).
89
+ // Sticky-header cross-talk (RN ScrollView.js _headerLayoutYs): a child-index -> measured-y
90
+ // map so each header learns where the NEXT sticky header starts (its push-off collision
91
+ // point). Mutated imperatively from each header's onLayout; the bump ref forces the
92
+ // re-render that feeds the freshly-recorded y into the previous header's nextStickyHeaderY.
122
93
  const headerLayoutYs = new Map();
123
94
  const bumpHeaderLayout = ref(0);
124
95
  const onHeaderLayoutY = (index, y) => {
@@ -128,13 +99,11 @@ export function createScrollView(platform) {
128
99
  dlog(`Vue ScrollView sticky-header layoutY index=${index} y=${y}`);
129
100
  bumpHeaderLayout.value += 1;
130
101
  };
131
- // Native sticky-scroll attach (RN attachNativeEvent / _updateAnimatedNodeAttachment): when the
132
- // native module is available, the scroll value is driven on the UI thread so the interpolations
133
- // ride scroll natively (no JS jitter). A plain flag set in render (like createAnimatedComponent's
134
- // wantsNative: non-reactive so writing it in render triggers no effect); the post-commit watch
135
- // reads it once the node commits. The JS path needs no attach: Animated.event drives the value
136
- // each frame. flush:'post' so the engine has committed the node before attachStickyScroll reads
137
- // its Fabric handle. Detached on unmount (and re-detached if the node identity changes).
102
+ // Native sticky-scroll attach (RN attachNativeEvent): when the native module is available,
103
+ // the scroll value is driven on the UI thread so interpolations ride scroll natively (no
104
+ // JS jitter). A plain non-reactive flag set in render; the post-commit watch reads it once
105
+ // the node commits. flush:'post' so the node has a Fabric handle before attachStickyScroll
106
+ // reads it. Detached on unmount and re-detached if the node identity changes.
138
107
  let nativeStickyWanted = false;
139
108
  let detachStickyScroll;
140
109
  watch(() => nodeRef.value, node => {
@@ -153,24 +122,17 @@ export function createScrollView(platform) {
153
122
  return () => {
154
123
  // Read the bump so a recorded header y re-runs render and feeds nextStickyHeaderY forward.
155
124
  void bumpHeaderLayout.value;
156
- // Fold kebab template props (:content-container-style) to the RN camelCase contract; idiomatic
157
- // Vue templates use kebab, but the prop surface (and HANDLED_ATTRS below) is camelCase.
125
+ // Fold kebab template props (:content-container-style) to the camelCase prop surface.
158
126
  const attrs = normalizeVueAttrs(rawAttrs);
159
127
  const isHorizontal = attrs.horizontal === true;
160
128
  const userStyle = isStyleProp(attrs.style) ? attrs.style : undefined;
161
- // resolveClassName(undefined) is a cheap {} no-op, so this is safe with no class prop too.
162
129
  const classProp = isClassNameProp(attrs.class) ? attrs.class : undefined;
163
130
  const layoutSplitStyle = [resolveClassName(classProp), userStyle];
164
- // A class-name string resolves through the same style registry as `class`/`style` above;
165
- // an object/array is already style-shaped and passes through as-is.
166
131
  const contentContainerStyle = typeof attrs.contentContainerStyle === 'string'
167
132
  ? resolveClassName(attrs.contentContainerStyle)
168
133
  : isStyleProp(attrs.contentContainerStyle)
169
134
  ? attrs.contentContainerStyle
170
135
  : undefined;
171
- // Sticky headers are a pure-JS layer; the native scroll view ignores
172
- // stickyHeaderIndices, so we wrap the flagged children below and drive their translateY off
173
- // the scroll offset. invertStickyHeaders narrows to the inverted (stick-to-bottom) branch.
174
136
  const stickyHeaderIndices = isNumberArray(attrs.stickyHeaderIndices)
175
137
  ? attrs.stickyHeaderIndices
176
138
  : undefined;
@@ -180,10 +142,8 @@ export function createScrollView(platform) {
180
142
  ? attrs.StickyHeaderComponent
181
143
  : undefined;
182
144
  const { scrollViewIntrinsic, contentIntrinsic, scrollViewBaseStyle, contentStyle } = selectScrollIntrinsics(isHorizontal, contentContainerStyle);
183
- // Outer props: fold aria/role, then layer the lifecycle-managed values on top. RN defaults
184
- // nested scrolling ON (ScrollView.js `nestedScrollEnabled ?? true`); horizontal forwards
185
- // only when defined (load-bearing on iOS's RCTScrollView axis, ignored by Android's
186
- // dedicated manager); decelerationRate is resolved per-platform.
145
+ // RN defaults nested scrolling ON (ScrollView.js `nestedScrollEnabled ?? true`);
146
+ // horizontal forwards only when defined (load-bearing on iOS's RCTScrollView axis).
187
147
  const outerProps = {
188
148
  ...resolveAccessibilityProps(forwardAttrs(attrs)),
189
149
  };
@@ -194,33 +154,38 @@ export function createScrollView(platform) {
194
154
  const decel = asDecelerationRate(attrs.decelerationRate);
195
155
  if (decel !== undefined)
196
156
  outerProps.decelerationRate = resolveDecelerationRate(decel);
197
- // onScroll: when sticky headers are active, the offset must reach the AnimatedValue. RN does
198
- // the same with _scrollAnimatedValueAttachment. forwardAttrs already put the user's onScroll /
199
- // onLayout / scrollEventThrottle on outerProps; here we override them for the sticky branch.
157
+ // When sticky headers are active, the offset must reach the AnimatedValue (RN's
158
+ // _scrollAnimatedValueAttachment). forwardAttrs already put the user's onScroll/onLayout/
159
+ // scrollEventThrottle on outerProps; here we override per resolveScrollForwarding's
160
+ // decisions (which path, throttle default, inverted capture).
200
161
  const nativeStickyAvailable = hasStickyHeaders && isNativeAnimatedAvailable();
201
162
  nativeStickyWanted = nativeStickyAvailable;
163
+ const userThrottle = typeof attrs.scrollEventThrottle === 'number' ? attrs.scrollEventThrottle : undefined;
164
+ const forwarding = resolveScrollForwarding({
165
+ hasStickyHeaders,
166
+ nativeStickyAvailable,
167
+ invertStickyHeaders,
168
+ scrollEventThrottle: userThrottle,
169
+ maintainVisibleContentPosition: attrs.maintainVisibleContentPosition,
170
+ snapToAlignment: attrs.snapToAlignment,
171
+ });
202
172
  if (hasStickyHeaders) {
203
173
  const userOnScroll = isHandler(attrs.onScroll) ? attrs.onScroll : undefined;
204
- const userThrottle = typeof attrs.scrollEventThrottle === 'number' ? attrs.scrollEventThrottle : undefined;
205
- if (nativeStickyAvailable) {
206
- // Native path (RN attachNativeEvent): the scroll value is driven on the UI thread by the
207
- // post-commit watch above, so onScroll only forwards to the user: zero JS per frame. RN
208
- // uses throttle 1 when sticky (ScrollView.js:1798); the native driver can afford it. The
209
- // user onScroll is already on outerProps via forwardAttrs.
210
- outerProps.scrollEventThrottle = userThrottle ?? 1;
211
- }
212
- else {
213
- // JS fallback (no native module): Animated.event drives the value each frame and forwards
214
- // the user's handler as the listener passthrough. Correct, but lags a frame under fast
215
- // scroll (the jitter), which the native path above removes on a real host.
174
+ if (forwarding.mode === 'sticky-js') {
175
+ // JS fallback (no native module): correct, but lags a frame under fast scroll, which
176
+ // the native path removes on a real host.
216
177
  outerProps.onScroll = animatedEvent([{ nativeEvent: { contentOffset: { y: scrollAnimatedValue } } }], userOnScroll === undefined
217
178
  ? undefined
218
179
  : { listener: (...args) => forwardScrollEvent(userOnScroll, args) });
219
- outerProps.scrollEventThrottle = userThrottle ?? 16;
220
180
  }
221
- // onLayout on the scroll-view node: capture the viewport height for inverted sticky headers
222
- // (RN _handleLayout), then call the user's handler. Non-inverted leaves onLayout as forwarded.
223
- if (invertStickyHeaders === true) {
181
+ // Native path: the value is driven on the UI thread by the post-commit watch above, so
182
+ // onScroll forwards untouched.
183
+ if (forwarding.scrollEventThrottle !== undefined) {
184
+ outerProps.scrollEventThrottle = forwarding.scrollEventThrottle;
185
+ }
186
+ // Capture the viewport height for inverted sticky headers (RN _handleLayout), then call
187
+ // the user's handler.
188
+ if (forwarding.capturesViewportHeight) {
224
189
  const userOnLayout = isHandler(attrs.onLayout) ? attrs.onLayout : undefined;
225
190
  outerProps.onLayout = (event) => {
226
191
  const height = readLayoutDimension(event, 'height');
@@ -232,19 +197,15 @@ export function createScrollView(platform) {
232
197
  }
233
198
  }
234
199
  dlog(`Vue ScrollView -> ${scrollViewIntrinsic} (horizontal=${isHorizontal} sticky=${hasStickyHeaders})`);
235
- // Content props: `collapsable: false` keeps the layout-only content view as a real native
236
- // view: Android Fabric view-flattens it away otherwise, hoisting the cells as direct
237
- // children of the scroll view (which hosts exactly one), an addViewAt crash. collapsableChildren
238
- // false also preserves the cell views maintainVisibleContentPosition / snapToAlignment
239
- // anchor against (RN preserveChildren). iOS never flattens; both are no-ops there.
200
+ // `collapsable: false` keeps the layout-only content view as a real native view: Android
201
+ // Fabric view-flattens it away otherwise, hoisting the cells as direct children of the
202
+ // scroll view (which hosts exactly one) - an addViewAt crash. iOS never flattens.
240
203
  const contentProps = { style: contentStyle, collapsable: false };
241
- if (attrs.maintainVisibleContentPosition !== undefined ||
242
- attrs.snapToAlignment !== undefined) {
204
+ if (forwarding.collapsableChildren) {
243
205
  contentProps.collapsableChildren = false;
244
206
  }
245
- // contentSizeChange is synthesized from the content view's own onLayout (RN
246
- // _handleContentOnLayout): read width/height and emit only on a real size change (dedupe via
247
- // the setup-scope lastContentSize).
207
+ // Synthesized from the content view's own onLayout (RN _handleContentOnLayout); emit only
208
+ // on a real size change.
248
209
  contentProps.onLayout = (event) => {
249
210
  const width = readLayoutDimension(event, 'width');
250
211
  const height = readLayoutDimension(event, 'height');
@@ -256,23 +217,17 @@ export function createScrollView(platform) {
256
217
  dlog(`Vue ScrollView contentSizeChange ${width}x${height}`);
257
218
  emit('contentSizeChange', width, height);
258
219
  };
259
- // Sticky headers are a pure-JS layer (the native scroll view ignores stickyHeaderIndices);
260
- // wrap the flagged children so they pin to the scroll offset. No-op when none are flagged.
261
220
  const slotChildren = slots.default !== undefined ? slots.default() : [];
262
221
  const contentChildren = hasStickyHeaders
263
222
  ? wrapStickyHeaders(slotChildren, stickyHeaderIndices, scrollAnimatedValue, invertStickyHeaders, viewportHeight.value, stickyHeaderComponent, headerLayoutYs, onHeaderLayoutY)
264
223
  : slotChildren;
265
224
  const content = h(contentIntrinsic, contentProps, contentChildren);
266
- // Base style UNDER user style so an explicit user value (height, flexDirection) still wins;
267
- // the scroll node carries overflow:'scroll' (frame clipping) + the per-axis flexDirection.
225
+ // Base style UNDER user style so an explicit user value (height, flexDirection) still wins.
268
226
  const scrollProps = {
269
227
  ...outerProps,
270
228
  style: [scrollViewBaseStyle, userStyle],
271
229
  ref: setNodeRef,
272
230
  };
273
- // refreshControl arrives as a Vue VNode the app passes (h(RefreshControl, …)); narrow it
274
- // with isVNode (no cast). Stripped from forwardAttrs (it is in HANDLED_ATTRS) so it never
275
- // reaches the host as a prop: it is lifecycle-consumed by the platform assemble.
276
231
  const refreshControl = isVNode(attrs.refreshControl) ? attrs.refreshControl : undefined;
277
232
  return platform.assemble({
278
233
  scrollViewIntrinsic,