@symbiote-native/vue 0.4.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +24 -9
  2. package/babel-jsx.cjs +53 -0
  3. package/babel-lower-host-primitives.cjs +225 -0
  4. package/build/bootstrap.js +1 -1
  5. package/build/components/button.js +4 -2
  6. package/build/components/flat-list/index.js +14 -5
  7. package/build/components/image-background.js +4 -5
  8. package/build/components/image.js +3 -1
  9. package/build/components/keyboard-avoiding-view.js +39 -14
  10. package/build/components/modal/index.js +5 -2
  11. package/build/components/pressable.d.ts +1 -1
  12. package/build/components/pressable.js +39 -20
  13. package/build/components/safe-area-view.d.ts +1 -0
  14. package/build/components/scroll-view/index.android.d.ts +1 -1
  15. package/build/components/scroll-view/index.android.js +3 -1
  16. package/build/components/scroll-view/index.ios.d.ts +1 -1
  17. package/build/components/scroll-view/index.ios.js +3 -1
  18. package/build/components/scroll-view/shared.js +33 -13
  19. package/build/components/scroll-view/sticky-header.js +18 -5
  20. package/build/components/section-list/index.js +4 -2
  21. package/build/components/switch/shared.js +12 -3
  22. package/build/components/text-input/index.js +25 -2
  23. package/build/components/touchable-native-feedback.js +11 -4
  24. package/build/components/touchable.d.ts +7 -1
  25. package/build/components/touchable.js +184 -33
  26. package/build/components/virtualized-list/index.js +42 -20
  27. package/build/components/virtualized-section-list/index.d.ts +5 -0
  28. package/build/components/virtualized-section-list/index.js +33 -4
  29. package/build/components.d.ts +2 -0
  30. package/build/components.js +25 -4
  31. package/build/composables/use-raw-attrs.d.ts +1 -0
  32. package/build/composables/use-raw-attrs.js +26 -0
  33. package/build/create-portal/index.d.ts +28 -0
  34. package/build/create-portal/index.js +42 -0
  35. package/build/host-instance/index.js +7 -6
  36. package/build/index.d.ts +9 -6
  37. package/build/index.js +14 -3
  38. package/build/modules/animated/create-animated-component.d.ts +1 -1
  39. package/build/modules/animated/create-animated-component.js +27 -5
  40. package/build/modules/animated/index.d.ts +2 -0
  41. package/build/modules/animated/index.js +18 -5
  42. package/build/modules/app-registry/index.js +4 -2
  43. package/build/modules/status-bar.js +4 -2
  44. package/build/register.d.ts +1 -0
  45. package/build/register.js +35 -0
  46. package/build/render.d.ts +3 -1
  47. package/build/render.js +62 -1
  48. package/build/renderer/index.js +58 -9
  49. package/build/runtime-helpers/index.d.ts +48 -24
  50. package/build/runtime-helpers/index.js +314 -30
  51. package/build/state-style.d.ts +1 -0
  52. package/build/state-style.js +13 -0
  53. package/build/utils/normalize-attrs.d.ts +1 -0
  54. package/build/utils/normalize-attrs.js +21 -13
  55. package/metro-css-parser.cjs +2 -1
  56. package/metro-vue-transformer.cjs +502 -101
  57. package/package.json +29 -8
@@ -1,26 +1,35 @@
1
- // The Touchable* family for Vue, all built on Pressable, the Vue twin of the React adapter. The
2
- // press-timing constants and the deactivation-floor math are shared with React
3
- // (@symbiote-native/components/state/touchable); here Vue owns only the Animated wiring + the press
4
- // scheduling state:
5
- // TouchableOpacity: animate an Animated.Value opacity toward activeOpacity on press-in, back
6
- // to 1 on press-out, driven from Pressable's onPressIn/onPressOut.
7
- // TouchableHighlight: paint underlayColor + lower child opacity while pressed, via Pressable's
8
- // style-as-function (the pressed state RN drives with setState).
9
- // TouchableWithoutFeedback: no visual change, just the press wiring.
1
+ // The Touchable* family for Vue, all built on Pressable. Ported against RN's own sources
2
+ // (.vendors/react-native/.../Components/Touchable/*.js) after the 2026-08-19 audit found ten
3
+ // divergences shared by the React/Vue/Svelte/Angular ports; Solid migrated first, this is Vue.
4
+ //
5
+ // Shared (framework-agnostic) half: the press-scheduling machine, the underlay machine, the
6
+ // resting-opacity math, the press-handler gate, the extra-style split. Vue owns only the
7
+ // lifecycle — refs, the real timers, the Animated wiring, and the emit bridge:
8
+ // TouchableOpacity: an Animated.Value fading toward activeOpacity on press-in and back to the
9
+ // style's own opacity on press-out.
10
+ // TouchableHighlight: the underlay machine flipping a `shown` ref; underlayColor lands on the
11
+ // container, the lowered opacity on the cloned child.
12
+ // TouchableWithoutFeedback: the same press-scheduling machine with the visual half empty.
10
13
  //
11
14
  // Inputs arrive as attrs (untyped), narrowed with runtime guards. The handlers read attrs LIVE
12
15
  // (they fire on events, not render) so a re-supplied callback / timing is always honored.
13
- import { defineComponent, h } from '@vue/runtime-core';
14
- import { createTouchableFeedbackHandlers, createTouchableFeedbackRuntime, highlightPressedStyle, DEFAULT_ACTIVE_OPACITY, DEFAULT_HIGHLIGHT_CHILD_OPACITY, DEFAULT_MIN_PRESS_DURATION_MS, DEFAULT_UNDERLAY_COLOR, OPACITY_ACTIVE_DURATION_MS, OPACITY_INACTIVE_DURATION_MS, RESTING_OPACITY, } from '@symbiote-native/components';
15
- import {} from '@symbiote-native/engine';
16
+ import { cloneVNode, defineComponent, getCurrentInstance, h, onUnmounted, ref, watch, } from '@vue/runtime-core';
17
+ import { createHighlightUnderlayHandlers, createHighlightUnderlayRuntime, createTouchableFeedbackHandlers, createTouchableFeedbackRuntime, hasTouchablePressHandler, resolveHighlightExtraStyles, restingOpacityFromStyle, DEFAULT_ACTIVE_OPACITY, OPACITY_ACTIVE_GRANT_DURATION_MS, OPACITY_INACTIVE_DURATION_MS, TOUCHABLE_MIN_PRESS_DURATION_MS, } from '@symbiote-native/components';
18
+ import { dlog, } from '@symbiote-native/engine';
16
19
  import { Pressable, emitPressableEvents, PRESSABLE_EMITS, } from './pressable.js';
17
20
  import { Animated } from '../modules/animated/index.js';
18
21
  import { normalizeVueAttrs } from '../utils/normalize-attrs.js';
22
+ const TOUCHABLE_HIGHLIGHT_EMITS = {
23
+ ...PRESSABLE_EMITS,
24
+ showUnderlay: () => true,
25
+ hideUnderlay: () => true,
26
+ };
19
27
  function numberOr(value, fallback) {
20
28
  return typeof value === 'number' ? value : fallback;
21
29
  }
22
- // The real setTimeout the shared feedback machine schedules its deferred activation/deactivation on
23
- // (core/components has no timer globals). Returns a canceller so an early release flushes the timer.
30
+ // The real setTimeout the shared machines schedule their deferred activation/deactivation/underlay
31
+ // hide on (core/components has no timer globals). Returns a canceller so an early release flushes
32
+ // the timer.
24
33
  function scheduleTimeout(callback, ms) {
25
34
  const id = setTimeout(callback, ms);
26
35
  return () => clearTimeout(id);
@@ -44,20 +53,41 @@ const TOUCHABLE_OPACITY_HANDLED = [
44
53
  'minPressDuration',
45
54
  ];
46
55
  export const TouchableOpacity = defineComponent((_props, { slots, attrs: rawAttrs, emit }) => {
47
- // One Animated.Value per mount, resting at full opacity. Held by identity in setup scope (an
48
- // engine object, never a reactive ref). The Animated.View leaf commits it every frame.
49
- const opacity = new Animated.Value(RESTING_OPACITY);
56
+ // RN's _getChildStyleOpacityWithDefault: the fade settles at the opacity the CALLER's style
57
+ // asks for, not at a hard 1 — and the Animated.Value is SEEDED there, or the first paint jumps
58
+ // to fully opaque on a Touchable styled `opacity: 0.6`.
59
+ const restingOpacity = () => restingOpacityFromStyle(normalizeVueAttrs(rawAttrs).style);
60
+ // One Animated.Value per mount. Held by identity in setup scope (an engine object, never a
61
+ // reactive ref — a Proxy would miss the graph's bookkeeping). The Animated.View leaf commits
62
+ // it every frame.
63
+ const opacity = new Animated.Value(restingOpacity());
50
64
  // The shared press-scheduling cell (delayPressIn timer + activation clock), persisted across
51
65
  // renders in setup scope; the handlers are rebuilt each render over live attrs.
52
66
  const runtime = createTouchableFeedbackRuntime();
53
67
  function setOpacityTo(toValue, duration) {
68
+ dlog(`TouchableOpacity opacity -> ${toValue} over ${duration}ms`);
69
+ // useNativeDriver: true is RN's own (TouchableOpacity.js:242). opacity is natively drivable,
70
+ // so the fade survives a busy JS thread — the whole point of press feedback.
54
71
  Animated.timing(opacity, {
55
72
  toValue,
56
73
  duration,
57
74
  easing: Animated.Easing.inOut(Animated.Easing.quad),
58
- useNativeDriver: false,
75
+ useNativeDriver: true,
59
76
  }).start();
60
77
  }
78
+ // RN's componentDidUpdate: a changed `disabled` or a changed style opacity re-settles the view,
79
+ // so a Touchable disabled mid-press does not stay stuck at its active opacity. NO `immediate` —
80
+ // RN does this on UPDATE only, and firing at mount would animate over the value the
81
+ // Animated.Value was just seeded with. Two getter sources, not one object: Vue compares an
82
+ // array source element-wise, so an unrelated attr change does not re-settle the opacity.
83
+ watch([() => normalizeVueAttrs(rawAttrs).disabled, restingOpacity], ([, resting]) => {
84
+ setOpacityTo(resting, OPACITY_INACTIVE_DURATION_MS);
85
+ });
86
+ // RN's componentWillUnmount: stop the animation and drop the value back, so a teardown
87
+ // mid-fade leaves no driver running against a node that is gone.
88
+ onUnmounted(() => {
89
+ opacity.resetAnimation();
90
+ });
61
91
  return () => {
62
92
  const attrs = normalizeVueAttrs(rawAttrs);
63
93
  const style = attrs.style;
@@ -66,20 +96,23 @@ export const TouchableOpacity = defineComponent((_props, { slots, attrs: rawAttr
66
96
  const { handlePressIn, handlePressOut } = createTouchableFeedbackHandlers({
67
97
  delayPressIn: numberOr(attrs.delayPressIn, 0),
68
98
  delayPressOut: numberOr(attrs.delayPressOut, 0),
69
- minPressDuration: numberOr(attrs.minPressDuration, DEFAULT_MIN_PRESS_DURATION_MS),
99
+ // RN's Touchables override Pressability's 130ms floor with 0; what holds the active
100
+ // visual is the fade's own duration, not a press-duration floor.
101
+ minPressDuration: numberOr(attrs.minPressDuration, TOUCHABLE_MIN_PRESS_DURATION_MS),
70
102
  schedule: scheduleTimeout,
71
103
  now: Date.now,
72
104
  }, runtime, {
73
105
  activate(event) {
74
- setOpacityTo(numberOr(attrs.activeOpacity, DEFAULT_ACTIVE_OPACITY), OPACITY_ACTIVE_DURATION_MS);
106
+ setOpacityTo(numberOr(attrs.activeOpacity, DEFAULT_ACTIVE_OPACITY), OPACITY_ACTIVE_GRANT_DURATION_MS);
75
107
  emit('pressIn', event);
76
108
  },
77
109
  deactivate(event) {
78
- setOpacityTo(RESTING_OPACITY, OPACITY_INACTIVE_DURATION_MS);
110
+ setOpacityTo(restingOpacity(), OPACITY_INACTIVE_DURATION_MS);
79
111
  emit('pressOut', event);
80
112
  },
81
113
  });
82
114
  const pressableProps = {
115
+ __minPressDuration: 0,
83
116
  ...forwardExcept(attrs, TOUCHABLE_OPACITY_HANDLED),
84
117
  ...emitPressableEvents(emit),
85
118
  onPressIn: handlePressIn,
@@ -94,34 +127,152 @@ export const TouchableOpacity = defineComponent((_props, { slots, attrs: rawAttr
94
127
  inheritAttrs: false,
95
128
  emits: PRESSABLE_EMITS,
96
129
  });
97
- const TOUCHABLE_HIGHLIGHT_HANDLED = ['activeOpacity', 'underlayColor', 'style'];
130
+ // delayPressOut is CONSUMED here (the underlay hold), so it must not also forward to the host.
131
+ // onPress/onPressIn/onPressOut/onShowUnderlay/onHideUnderlay never appear in attrs at all — they
132
+ // are declared emits, which Vue strips from $attrs.
133
+ const TOUCHABLE_HIGHLIGHT_HANDLED = [
134
+ 'activeOpacity',
135
+ 'underlayColor',
136
+ 'style',
137
+ 'delayPressOut',
138
+ ];
139
+ // A vnode that can carry a style prop: a host tag (string) or a component (object/function).
140
+ // Fragment / Text / Comment / Static are Symbols and fail all three — cloning one with a style
141
+ // would silently drop it.
142
+ function isStylableVNode(vnode) {
143
+ const type = vnode.type;
144
+ return (typeof type === 'string' ||
145
+ typeof type === 'function' ||
146
+ (typeof type === 'object' && type !== null));
147
+ }
98
148
  export const TouchableHighlight = defineComponent((_props, { slots, attrs: rawAttrs, emit }) => {
149
+ // `shown` is NOT `pressed`: RN holds the underlay for delayPressOut past the tap so a fast tap
150
+ // still flashes, and the pressed flag is already false by then.
151
+ const shown = ref(false);
152
+ // The mutable underlay cell (the in-flight hide canceller). A plain setup-scope object, never
153
+ // a ref: the machine mutates it on every event and nothing reads it reactively.
154
+ const runtime = createHighlightUnderlayRuntime();
155
+ // The press callbacks are declared emits, so Vue has stripped their onX listeners from $attrs.
156
+ // RN's _hasPressHandler gate therefore reads what the PARENT actually passed, off the
157
+ // instance's own vnode props — the same shape FlatList/VirtualizedList use for this.
158
+ const instance = getCurrentInstance();
159
+ const listener = (onName) => {
160
+ const vnodeProps = instance?.vnode.props;
161
+ return vnodeProps == null ? undefined : vnodeProps[onName];
162
+ };
99
163
  return () => {
100
164
  const attrs = normalizeVueAttrs(rawAttrs);
101
- const activeOpacity = numberOr(attrs.activeOpacity, DEFAULT_HIGHLIGHT_CHILD_OPACITY);
102
- const underlayColor = typeof attrs.underlayColor === 'string' ? attrs.underlayColor : DEFAULT_UNDERLAY_COLOR;
103
- const style = attrs.style;
104
- function pressedStyle({ pressed }) {
105
- return highlightPressedStyle(pressed, style, underlayColor, activeOpacity);
106
- }
165
+ const hasPressHandler = hasTouchablePressHandler({
166
+ onPress: listener('onPress'),
167
+ onPressIn: listener('onPressIn'),
168
+ onPressOut: listener('onPressOut'),
169
+ onLongPress: listener('onLongPress'),
170
+ });
171
+ // Rebuilt each render over live attrs; the runtime persists, so an in-flight hide timer
172
+ // survives a rebuild.
173
+ const underlay = createHighlightUnderlayHandlers({
174
+ delayPressOut: numberOr(attrs.delayPressOut, 0),
175
+ hasPressHandler,
176
+ schedule: scheduleTimeout,
177
+ }, runtime, {
178
+ setShown: next => {
179
+ shown.value = next;
180
+ },
181
+ onShowUnderlay: () => emit('showUnderlay'),
182
+ onHideUnderlay: () => emit('hideUnderlay'),
183
+ });
184
+ const extra = resolveHighlightExtraStyles({
185
+ shown: shown.value,
186
+ hasPressHandler,
187
+ underlayColor: typeof attrs.underlayColor === 'string'
188
+ ? attrs.underlayColor
189
+ : undefined,
190
+ activeOpacity: typeof attrs.activeOpacity === 'number'
191
+ ? attrs.activeOpacity
192
+ : undefined,
193
+ });
194
+ const children = slots.default !== undefined ? slots.default() : [];
195
+ // RN's _createExtraStyles split: the backgroundColor on the CONTAINER, the lowered opacity
196
+ // on the CHILD (TouchableHighlight.js clones its single child). Vue can reach the child
197
+ // without a wrapper node — cloneVNode merges the style onto exactly one existing vnode, so
198
+ // nothing is inserted into the flex chain. It needs RN's own single-element-child shape
199
+ // though (React.Children.only): a fragment, a raw text node or several roots have no one
200
+ // child to clone, and there both styles fold onto the container — the pre-audit
201
+ // approximation, where the opacity fades the very underlay it should reveal.
202
+ const onlyChild = children.length === 1 && children[0] !== undefined
203
+ ? children[0]
204
+ : undefined;
205
+ const canStyleChild = onlyChild !== undefined && isStylableVNode(onlyChild);
206
+ const styledChildren = extra !== undefined && canStyleChild && onlyChild !== undefined
207
+ ? [cloneVNode(onlyChild, { style: extra.child })]
208
+ : children;
209
+ const containerStyle = extra === undefined
210
+ ? attrs.style
211
+ : canStyleChild
212
+ ? [attrs.style, extra.underlay]
213
+ : [attrs.style, extra.underlay, extra.child];
107
214
  const pressableProps = {
215
+ __minPressDuration: 0,
108
216
  ...forwardExcept(attrs, TOUCHABLE_HIGHLIGHT_HANDLED),
109
217
  ...emitPressableEvents(emit),
110
- style: pressedStyle,
218
+ style: containerStyle,
219
+ // Visual first, then the caller's callback — RN's order in _createPressabilityConfig.
220
+ onPress: (event) => {
221
+ underlay.handlePress(event);
222
+ emit('press', event);
223
+ },
224
+ onPressIn: (event) => {
225
+ underlay.handlePressIn(event);
226
+ emit('pressIn', event);
227
+ },
228
+ onPressOut: (event) => {
229
+ underlay.handlePressOut(event);
230
+ emit('pressOut', event);
231
+ },
111
232
  };
112
- const children = slots.default !== undefined ? slots.default() : [];
113
- return h(Pressable, pressableProps, { default: () => children });
233
+ return h(Pressable, pressableProps, { default: () => styledChildren });
114
234
  };
115
235
  }, {
116
236
  name: 'TouchableHighlight',
117
237
  inheritAttrs: false,
118
- emits: PRESSABLE_EMITS,
238
+ emits: TOUCHABLE_HIGHLIGHT_EMITS,
119
239
  });
240
+ // RN's TouchableWithoutFeedback builds a FULL Pressability config — "without feedback" means no
241
+ // VISUAL, not no timing — so the delay props are consumed by the shared machine here instead of
242
+ // forwarding to the host as unknown Fabric props.
243
+ const TOUCHABLE_WITHOUT_FEEDBACK_HANDLED = [
244
+ 'delayPressIn',
245
+ 'delayPressOut',
246
+ 'minPressDuration',
247
+ ];
120
248
  export const TouchableWithoutFeedback = defineComponent((_props, { slots, attrs: rawAttrs, emit }) => {
249
+ const runtime = createTouchableFeedbackRuntime();
121
250
  return () => {
122
251
  const attrs = normalizeVueAttrs(rawAttrs);
252
+ // The same machine TouchableOpacity runs, with the visual half left empty.
253
+ const { handlePressIn, handlePressOut } = createTouchableFeedbackHandlers({
254
+ delayPressIn: numberOr(attrs.delayPressIn, 0),
255
+ delayPressOut: numberOr(attrs.delayPressOut, 0),
256
+ minPressDuration: numberOr(attrs.minPressDuration, TOUCHABLE_MIN_PRESS_DURATION_MS),
257
+ schedule: scheduleTimeout,
258
+ now: Date.now,
259
+ }, runtime, {
260
+ activate(event) {
261
+ emit('pressIn', event);
262
+ },
263
+ deactivate(event) {
264
+ emit('pressOut', event);
265
+ },
266
+ });
267
+ const pressableProps = {
268
+ __minPressDuration: 0,
269
+ ...forwardExcept(attrs, TOUCHABLE_WITHOUT_FEEDBACK_HANDLED),
270
+ ...emitPressableEvents(emit),
271
+ onPressIn: handlePressIn,
272
+ onPressOut: handlePressOut,
273
+ };
123
274
  const children = slots.default !== undefined ? slots.default() : [];
124
- return h(Pressable, { ...attrs, ...emitPressableEvents(emit) }, { default: () => children });
275
+ return h(Pressable, pressableProps, { default: () => children });
125
276
  };
126
277
  }, {
127
278
  name: 'TouchableWithoutFeedback',
@@ -27,7 +27,7 @@
27
27
  // scroll commands would silently no-op. The folded listState is a PLAIN object (not reactive) —
28
28
  // mutating it never triggers Vue; the `version` ref is bumped when a transition changes render state.
29
29
  import { computed, defineComponent, getCurrentInstance, h, isVNode, onBeforeUnmount, ref, shallowRef, watch, } from '@vue/runtime-core';
30
- import { DEFAULT_END_REACHED_THRESHOLD, DEFAULT_INITIAL_NUM_TO_RENDER, DEFAULT_MAX_TO_RENDER_PER_BATCH, DEFAULT_START_REACHED_THRESHOLD, DEFAULT_UPDATE_CELLS_BATCHING_PERIOD, DEFAULT_WINDOW_SIZE, EMPTY_OFFSET, FIRST_INDEX, INVERTED_X_STYLE, INVERTED_Y_STYLE, buildListPlan, buildViewabilityPairs, createInitialListState, isSeparatorGapInRange, listEffectSignature, readLayoutLength, readScrollOffset, reduceList, resolveItemKey, } from '@symbiote-native/components';
30
+ import { DEFAULT_END_REACHED_THRESHOLD, DEFAULT_INITIAL_NUM_TO_RENDER, DEFAULT_MAX_TO_RENDER_PER_BATCH, DEFAULT_START_REACHED_THRESHOLD, DEFAULT_UPDATE_CELLS_BATCHING_PERIOD, DEFAULT_WINDOW_SIZE, EMPTY_OFFSET, FIRST_INDEX, INVERTED_X_STYLE, INVERTED_Y_STYLE, buildListPlan, buildViewabilityPairs, createInitialListState, isSeparatorGapInRange, listEffectSignature, readLayoutLength, readLayoutOffset, readScrollOffset, reduceList, resolveItemKey, } from '@symbiote-native/components';
31
31
  import { dlog, } from '@symbiote-native/engine';
32
32
  import { ScrollView } from '../scroll-view';
33
33
  import { RefreshControl } from '../refresh-control.js';
@@ -78,7 +78,7 @@ function isRecord(value) {
78
78
  return typeof value === 'object' && value !== null && !Array.isArray(value);
79
79
  }
80
80
  function isComponent(value) {
81
- return typeof value === 'function' || (typeof value === 'object' && value !== null);
81
+ return (typeof value === 'function' || (typeof value === 'object' && value !== null));
82
82
  }
83
83
  function asNumber(value, fallback) {
84
84
  return typeof value === 'number' ? value : fallback;
@@ -181,7 +181,9 @@ export const VirtualizedList = defineComponent((props, { attrs, expose, emit, sl
181
181
  ? (info) => emit('startReached', info)
182
182
  : undefined,
183
183
  onStartReachedThreshold: asNumber(props.onStartReachedThreshold, DEFAULT_START_REACHED_THRESHOLD),
184
- onRefresh: listens('onRefresh') ? () => emit('refresh') : undefined,
184
+ onRefresh: listens('onRefresh')
185
+ ? () => emit('refresh')
186
+ : undefined,
185
187
  refreshing: props.refreshing === true,
186
188
  progressViewOffset: props.progressViewOffset,
187
189
  onViewableItemsChanged: listens('onViewableItemsChanged')
@@ -351,13 +353,17 @@ export const VirtualizedList = defineComponent((props, { attrs, expose, emit, sl
351
353
  const length = readLayoutLength(event, narrowed.value.horizontal);
352
354
  if (length === undefined)
353
355
  return;
354
- dlog(`Vue VirtualizedList cell ${index} measured length=${length}`);
355
- dispatch({ kind: 'measure', index, length });
356
+ const offset = readLayoutOffset(event, narrowed.value.horizontal);
357
+ dlog(`Vue VirtualizedList cell ${index} measured length=${length} offset=${offset ?? 'none'}`);
358
+ dispatch({ kind: 'measure', index, length, offset });
356
359
  };
357
360
  const mergeSeparator = (gapIndex, patch) => {
358
361
  if (!isSeparatorGapInRange(gapIndex, listState.metrics.count))
359
362
  return;
360
- separatorOverrides.set(gapIndex, { ...separatorOverrides.get(gapIndex), ...patch });
363
+ separatorOverrides.set(gapIndex, {
364
+ ...separatorOverrides.get(gapIndex),
365
+ ...patch,
366
+ });
361
367
  separatorVersion.value += 1;
362
368
  };
363
369
  const makeSeparators = (index) => ({
@@ -440,7 +446,9 @@ export const VirtualizedList = defineComponent((props, { attrs, expose, emit, sl
440
446
  dlog(`Vue VirtualizedList window [${m.first}, ${m.last}] of ${m.count} ` +
441
447
  `(offset=${listState.scrollOffset}, viewport=${listState.viewportLength}, rendered=${Math.max(0, m.last - m.first + 1)})`);
442
448
  const children = [];
443
- const stickySet = p.stickyHeaderIndices !== undefined ? new Set(p.stickyHeaderIndices) : undefined;
449
+ const stickySet = p.stickyHeaderIndices !== undefined
450
+ ? new Set(p.stickyHeaderIndices)
451
+ : undefined;
444
452
  const header = resolveElement(p.listHeaderComponent);
445
453
  if (header !== undefined) {
446
454
  children.push(h('symbiote-view', { key: 'list-header' }, [header]));
@@ -463,13 +471,14 @@ export const VirtualizedList = defineComponent((props, { attrs, expose, emit, sl
463
471
  keyFor,
464
472
  stickyIndices: stickySet,
465
473
  hasHeader: header !== undefined,
466
- hasSeparators: p.itemSeparatorComponent !== undefined,
467
474
  });
468
475
  renderedStickyIndices = plan.stickyChildPositions;
469
476
  if (plan.leadingExtent > EMPTY_OFFSET) {
470
477
  children.push(h('symbiote-view', {
471
478
  key: 'spacer-leading',
472
- style: p.horizontal ? { width: plan.leadingExtent } : { height: plan.leadingExtent },
479
+ style: p.horizontal
480
+ ? { width: plan.leadingExtent }
481
+ : { height: plan.leadingExtent },
473
482
  }));
474
483
  }
475
484
  const cellInverted = p.inverted
@@ -479,6 +488,14 @@ export const VirtualizedList = defineComponent((props, { attrs, expose, emit, sl
479
488
  : undefined;
480
489
  // Wraps one cell identically whether it's the force-mounted sticky cell ahead of the
481
490
  // window or an in-window cell from the loop below — same measuring View, same key shape.
491
+ // RN renders the separator INSIDE the cell's own measuring wrapper
492
+ // (VirtualizedListCellRenderer.js:218-221), and that placement is load-bearing rather than
493
+ // cosmetic. As a SIBLING it is an extra flex child, so the chrome between two cells becomes
494
+ // gap + separator + gap while a spacer collapsing that region replaces it with one gap —
495
+ // the leading spacer then lands every cell below it short by (separator + gap), and the
496
+ // content visibly jumps by that amount each time the window's first index moves. Measured
497
+ // at exactly 17px on device 2026-08-19 (a 1px divider under a 16px container gap); see
498
+ // .claude/rules/list-geometry-feedback-loop.md.
482
499
  const pushCell = (cell) => {
483
500
  const item = p.getItem(p.data, cell.index);
484
501
  const content = p.renderItem?.({
@@ -486,11 +503,20 @@ export const VirtualizedList = defineComponent((props, { attrs, expose, emit, sl
486
503
  index: cell.index,
487
504
  separators: makeSeparators(cell.index),
488
505
  });
506
+ const separator =
507
+ // RN gates the separator on the last index of the DATA, not of the WINDOW
508
+ // (VirtualizedList.js:793 `const end = getItemCount(data) - 1`), and now that the separator
509
+ // lives INSIDE the measuring wrapper that distinction is load-bearing: gating on the window
510
+ // would make a cell's own measured height change as the window slides past it. Device-measured
511
+ // 2026-08-19 as a run of cells all shifting by exactly the divider's 1px.
512
+ cell.index < m.count - 1
513
+ ? renderSeparatorElement(p.itemSeparatorComponent, item, p.getItem(p.data, cell.index + 1), separatorOverrides.get(cell.index))
514
+ : undefined;
489
515
  children.push(h('symbiote-view', {
490
516
  key: `cell-${cell.key}`,
491
517
  onLayout: makeCellMeasure(cell.index),
492
518
  style: cellInverted,
493
- }, [content]));
519
+ }, separator === undefined ? [content] : [content, separator]));
494
520
  };
495
521
  // The nearest sticky section header below the window (RN _ensureClosestStickyHeader):
496
522
  // force-mounted so it never gets destroyed/recreated while pinned off-window. Rendered
@@ -500,19 +526,14 @@ export const VirtualizedList = defineComponent((props, { attrs, expose, emit, sl
500
526
  if (plan.gapExtent > EMPTY_OFFSET) {
501
527
  children.push(h('symbiote-view', {
502
528
  key: 'spacer-gap',
503
- style: p.horizontal ? { width: plan.gapExtent } : { height: plan.gapExtent },
529
+ style: p.horizontal
530
+ ? { width: plan.gapExtent }
531
+ : { height: plan.gapExtent },
504
532
  }));
505
533
  }
506
534
  }
507
535
  for (let cellPos = 0; cellPos < plan.cells.length; cellPos += 1) {
508
- const cell = plan.cells[cellPos];
509
- pushCell(cell);
510
- if (cell.index < m.last) {
511
- const separator = renderSeparatorElement(p.itemSeparatorComponent, p.getItem(p.data, cell.index), p.getItem(p.data, cell.index + 1), separatorOverrides.get(cell.index));
512
- if (separator !== undefined) {
513
- children.push(h('symbiote-view', { key: `sep-${cell.key}` }, [separator]));
514
- }
515
- }
536
+ pushCell(plan.cells[cellPos]);
516
537
  }
517
538
  if (plan.trailingExtent > EMPTY_OFFSET) {
518
539
  children.push(h('symbiote-view', {
@@ -563,7 +584,8 @@ export const VirtualizedList = defineComponent((props, { attrs, expose, emit, sl
563
584
  if (p.maintainVisibleContentPosition !== undefined) {
564
585
  scrollProps.maintainVisibleContentPosition = {
565
586
  ...p.maintainVisibleContentPosition,
566
- minIndexForVisible: p.maintainVisibleContentPosition.minIndexForVisible + (header !== undefined ? 1 : 0),
587
+ minIndexForVisible: p.maintainVisibleContentPosition.minIndexForVisible +
588
+ (header !== undefined ? 1 : 0),
567
589
  };
568
590
  }
569
591
  // Pull-to-refresh: when a @refresh listener is present, build a RefreshControl for the
@@ -6,6 +6,11 @@ export type { IVirtualizedSectionListHandle };
6
6
  export interface IVirtualizedSectionListProps<ItemT> {
7
7
  sections: ReadonlyArray<ISection<ItemT>>;
8
8
  keyExtractor?: (item: ItemT, index: number) => string;
9
+ getItemLayout?: (data: ReadonlyArray<ISection<ItemT>> | null, index: number) => {
10
+ length: number;
11
+ offset: number;
12
+ index: number;
13
+ };
9
14
  stickySectionHeadersEnabled?: boolean;
10
15
  extraData?: unknown;
11
16
  onEndReachedThreshold?: number;
@@ -21,7 +21,14 @@ import { normalizeVueAttrs } from '../../utils/normalize-attrs.js';
21
21
  const VirtualizedListHost = VirtualizedList;
22
22
  // Listed for the runtime `props` declaration (keyof can't derive it: the index signature widens
23
23
  // keyof to `string`). The three emit events are deliberately absent (declared as emits below).
24
- const PROP_KEYS = ['sections', 'keyExtractor', 'stickySectionHeadersEnabled'];
24
+ // getItemLayout is declared here, not left to $attrs: falling through would hand the user's
25
+ // callback the flattened ENTRIES as its `data` argument instead of the sections (see below).
26
+ const PROP_KEYS = [
27
+ 'sections',
28
+ 'keyExtractor',
29
+ 'getItemLayout',
30
+ 'stickySectionHeadersEnabled',
31
+ ];
25
32
  const EMIT_KEYS = ['endReached', 'startReached', 'refresh'];
26
33
  function isRecord(value) {
27
34
  return typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -87,10 +94,14 @@ export const VirtualizedSectionList = defineComponent((props, { attrs, expose, e
87
94
  const renderEntry = (info) => {
88
95
  const entry = info.item;
89
96
  if (entry.kind === 'header') {
90
- return slots.sectionHeader ? slots.sectionHeader({ section: entry.section }) : [];
97
+ return slots.sectionHeader
98
+ ? slots.sectionHeader({ section: entry.section })
99
+ : [];
91
100
  }
92
101
  if (entry.kind === 'footer') {
93
- return slots.sectionFooter ? slots.sectionFooter({ section: entry.section }) : [];
102
+ return slots.sectionFooter
103
+ ? slots.sectionFooter({ section: entry.section })
104
+ : [];
94
105
  }
95
106
  if (entry.kind === 'section-separator') {
96
107
  return slots.sectionSeparator ? slots.sectionSeparator() : [];
@@ -114,6 +125,21 @@ export const VirtualizedSectionList = defineComponent((props, { attrs, expose, e
114
125
  trailingItem: unwrapEntryItem(entryProps.trailingItem),
115
126
  });
116
127
  const entryKeyExtractor = (entry, index) => sectionEntryKey(entry, index, keyExtractor);
128
+ // Hand the callback `sections`, not the entries: RN's inner VirtualizedList gets
129
+ // `data={this.props.sections}` (VirtualizedSectionList.js:216) while ours streams the
130
+ // FLATTENED entries, so the same user code would otherwise see a different argument here
131
+ // than on RN.
132
+ //
133
+ // UPSTREAM-DIVERGENCE(react-native): the flat INDEX matches RN's (two rows per section,
134
+ // header and footer) only while the #sectionSeparator slot is unset. With it,
135
+ // flattenSections emits an extra 'section-separator' row per boundary that RN renders inside
136
+ // the neighbouring cell, so indices shift by one per boundary from the second section on.
137
+ // Deliberate - that row is how this adapter paints the separator; a caller combining the two
138
+ // must account for it.
139
+ const getItemLayout = props.getItemLayout;
140
+ const entryItemLayout = getItemLayout === undefined
141
+ ? undefined
142
+ : (_entries, index) => getItemLayout(sections, index);
117
143
  // The three synthesized events become inner-VL handlers ONLY when listened, so the inner list
118
144
  // keeps gating (no parasitic RefreshControl / edge-reached work for an unlistened event).
119
145
  const endReached = listens('onEndReached')
@@ -122,7 +148,9 @@ export const VirtualizedSectionList = defineComponent((props, { attrs, expose, e
122
148
  const startReached = listens('onStartReached')
123
149
  ? (eventInfo) => emit('startReached', eventInfo)
124
150
  : undefined;
125
- const refresh = listens('onRefresh') ? () => emit('refresh') : undefined;
151
+ const refresh = listens('onRefresh')
152
+ ? () => emit('refresh')
153
+ : undefined;
126
154
  return h(VirtualizedListHost, {
127
155
  ...forwarded,
128
156
  ref: setInner,
@@ -130,6 +158,7 @@ export const VirtualizedSectionList = defineComponent((props, { attrs, expose, e
130
158
  getItem: (_source, index) => entries[index],
131
159
  getItemCount: () => entries.length,
132
160
  keyExtractor: entryKeyExtractor,
161
+ getItemLayout: entryItemLayout,
133
162
  stickyHeaderIndices,
134
163
  onEndReached: endReached,
135
164
  onStartReached: startReached,
@@ -47,5 +47,7 @@ export interface ITextProps extends IAccessibilityProps, IAriaProps {
47
47
  ref?: VNodeRef;
48
48
  key?: string | number | symbol;
49
49
  }
50
+ export declare const HOST_VIEW = "symbiote-view";
51
+ export declare const HOST_TEXT = "symbiote-text";
50
52
  export declare const View: FunctionalComponent<IViewProps, {}, any, {}>;
51
53
  export declare const Text: FunctionalComponent<ITextProps, {}, any, {}>;
@@ -9,16 +9,37 @@
9
9
  // stateful component's ref would resolve to a useless component proxy, which is why
10
10
  // createAnimatedComponent (which captures the host node via that ref) needs the fall-through.
11
11
  import { h } from '@vue/runtime-core';
12
+ import { resolveTextProps } from '@symbiote-native/components';
12
13
  import { normalizeVueAttrs } from './utils/normalize-attrs.js';
13
- function hostComponent(intrinsic, name) {
14
+ function hostComponent(intrinsic, name,
15
+ // An optional last fold over the normalized attrs. Text needs one (RN's ellipsizeMode /
16
+ // allowFontScaling defaults); View does not, so it stays a straight pass-through.
17
+ fold = attrs => attrs) {
14
18
  // normalizeVueAttrs folds kebab template props (:accessibility-label) to the RN camelCase contract.
15
- const component = (_props, { slots, attrs }) => h(intrinsic, normalizeVueAttrs(attrs), slots.default !== undefined ? slots.default() : undefined);
19
+ const component = (_props, { slots, attrs }) => h(intrinsic, fold(normalizeVueAttrs(attrs)), slots.default !== undefined ? slots.default() : undefined);
16
20
  component.displayName = name;
17
21
  component.inheritAttrs = false;
18
22
  return component;
19
23
  }
20
- export const View = hostComponent('symbiote-view', 'View');
21
- export const Text = hostComponent('symbiote-text', 'Text');
24
+ // The intrinsic tags the two bare primitives wrap. Exported because ADAPTER-INTERNAL renders must
25
+ // use the TAG, never the component: Vue charges a full component instance even for a functional
26
+ // one, so `h(View, …)` inside our own Pressable/Button costs an instance per node for a wrapper
27
+ // that only forwards attrs.
28
+ //
29
+ // These stay LITERALS rather than being read out of `@symbiote-native/components/host-primitives`,
30
+ // which is where the two transforms now get them: the spec types `intrinsic` as `string`, and
31
+ // reading it here would widen `HOST_VIEW` from the literal type that `hostComponent` and every
32
+ // adapter-internal `h(HOST_VIEW, …)` rely on. So this is the last remaining second encoding, and
33
+ // `components.test.ts` pins it against the spec rather than trusting the two to stay in step.
34
+ //
35
+ // Children go to a tag as an ARRAY (or a bare string / vnode), never a slot function — an element
36
+ // ignores slot children entirely and renders nothing. The reverse of the rule that applies to a
37
+ // component.
38
+ export const HOST_VIEW = 'symbiote-view';
39
+ export const HOST_TEXT = 'symbiote-text';
40
+ export const View = hostComponent(HOST_VIEW, 'View');
41
+ export const Text = hostComponent(HOST_TEXT, 'Text', resolveTextProps);
42
+ // Text is no longer bare either — it carries RN's Text.js defaults through resolveTextProps.
22
43
  // Image is NOT a bare host primitive: it needs the shared fold (source/src/srcSet resolution,
23
44
  // width/height -> style, alt -> accessibility) + the Image statics, so it lives in ./image as a
24
45
  // functional component over renderImage. View/Text stay bare; they forward attrs verbatim.
@@ -0,0 +1 @@
1
+ export declare function useRawAttrs(contextAttrs: Record<string, unknown>): Record<string, unknown>;
@@ -0,0 +1,26 @@
1
+ // The unproxied twin of a setup context's `attrs`.
2
+ //
3
+ // Vue hands `setupContext.attrs` out as `new Proxy(instance.attrs, attrsProxyHandlers)`, and in
4
+ // PRODUCTION as well as dev that proxy's only trap is `get(target, key) { track(target, "get", "");
5
+ // return target[key]; }` (runtime-core.esm-bundler.js:8357). So every attribute read costs a
6
+ // WeakMap get, a Map get and a `dep.track()`, and the first read of each instance also allocates
7
+ // a Map and a Dep into the module-global `targetMap` that live as long as the component does.
8
+ // Pressable reads ~16 attrs per render and a benchmark create mounts 2 000 of them.
9
+ //
10
+ // `instance.attrs` is the raw object behind that proxy. Capturing it is safe because its identity
11
+ // never changes: `initProps` assigns it once (:4907) and `updateProps` mutates that same object in
12
+ // place (`attrs[key] = value` / `delete attrs[key]`), so a reference taken at setup always reads
13
+ // current values. The tracking the proxy performs is for reads in a setup BODY; a render fn does
14
+ // not need it, because the parent's patch re-runs the child render through `updateComponentPreRender`
15
+ // whether or not the child subscribed.
16
+ //
17
+ // CALL THIS FROM A SETUP BODY ONLY, never from a render fn, a `computed`, or any other deferred
18
+ // callback. `getCurrentInstance()` resolves to `currentInstance || currentRenderingInstance`, so in
19
+ // a callback that runs while a DIFFERENT component is rendering it returns that component and this
20
+ // would silently hand back the wrong bag. `virtualized-list/index.ts` calls normalizeVueAttrs
21
+ // inside a `computed` for exactly that reason and must keep using the context attrs.
22
+ import { getCurrentInstance } from '@vue/runtime-core';
23
+ export function useRawAttrs(contextAttrs) {
24
+ const instance = getCurrentInstance();
25
+ return instance === null ? contextAttrs : instance.attrs;
26
+ }
@@ -0,0 +1,28 @@
1
+ import { SymbioteSurface, type ISymbioteNode } from '@symbiote-native/engine';
2
+ /** Anything already mounted in THIS surface: a host-node ref, or the surface root itself. */
3
+ export type ITeleportTarget = ISymbioteNode | SymbioteSurface;
4
+ export declare function isTeleportTarget(target: unknown): target is ITeleportTarget;
5
+ export declare const Teleport: import("vue").DefineComponent<import("vue").ExtractPropTypes<{
6
+ to: {
7
+ type: null;
8
+ default: null;
9
+ };
10
+ disabled: {
11
+ type: BooleanConstructor;
12
+ default: boolean;
13
+ };
14
+ }>, () => import("vue").VNode<import("vue").RendererNode, import("vue").RendererElement, {
15
+ [key: string]: any;
16
+ }>, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
17
+ to: {
18
+ type: null;
19
+ default: null;
20
+ };
21
+ disabled: {
22
+ type: BooleanConstructor;
23
+ default: boolean;
24
+ };
25
+ }>> & Readonly<{}>, {
26
+ to: any;
27
+ disabled: boolean;
28
+ }, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;