@symbiote-native/react 0.3.0 → 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.
Files changed (34) hide show
  1. package/README.md +5 -5
  2. package/build/bootstrap.js +1 -1
  3. package/build/components/activity-indicator/shared.js +1 -1
  4. package/build/components/button.js +1 -1
  5. package/build/components/flat-list/index.js +5 -2
  6. package/build/components/input-accessory-view/index.js +6 -1
  7. package/build/components/keyboard-avoiding-view/index.js +32 -12
  8. package/build/components/modal/index.js +1 -1
  9. package/build/components/pressable/index.d.ts +1 -1
  10. package/build/components/pressable/index.js +1 -1
  11. package/build/components/scroll-view/index.android.js +16 -6
  12. package/build/components/scroll-view/index.ios.js +3 -1
  13. package/build/components/scroll-view/shared.js +56 -7
  14. package/build/components/scroll-view/sticky-header.d.ts +11 -2
  15. package/build/components/scroll-view/sticky-header.js +53 -11
  16. package/build/components/text-input/index.js +7 -2
  17. package/build/components/touchable/index.d.ts +2 -0
  18. package/build/components/touchable/index.js +130 -28
  19. package/build/components/virtualized-list/index.js +64 -24
  20. package/build/components/virtualized-section-list/index.d.ts +5 -0
  21. package/build/components/virtualized-section-list/index.js +24 -4
  22. package/build/components.js +2 -2
  23. package/build/create-portal/index.js +1 -1
  24. package/build/create-tunnel/index.js +1 -1
  25. package/build/hooks/use-window-dimensions.js +1 -1
  26. package/build/host-config.js +13 -5
  27. package/build/index.d.ts +7 -7
  28. package/build/index.js +1 -1
  29. package/build/modules/animated/create-animated-component.js +3 -1
  30. package/build/modules/animated/index.d.ts +1 -1
  31. package/build/modules/animated/index.js +1 -1
  32. package/build/modules/status-bar/index.ios.js +1 -1
  33. package/build/render.js +14 -4
  34. package/package.json +5 -5
@@ -1,18 +1,20 @@
1
- // The Touchable* family, all built on Pressable. RN realizes their feedback with Animated, and so
2
- // do we. The press-timing constants and the deactivation-floor math are shared with every adapter
3
- // (@symbiote-native/components/state/touchable); here React owns only the Animated wiring + the press
4
- // scheduling refs:
5
- // TouchableOpacity: animate an Animated.Value opacity toward activeOpacity on press-in and
6
- // back to 1 on press-out, driven imperatively from onPressIn/onPressOut.
7
- // TouchableHighlight: paint underlayColor + lower child opacity while pressed, via Pressable's
8
- // pressed-state style (RN drives this with setState, not Animated, faithfully).
9
- // TouchableWithoutFeedback: no visual change, just the press wiring.
10
- import { createElement, useRef } from 'react';
11
- 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';
1
+ // The Touchable* family, 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; the shared halves live in @symbiote-native/components and React owns only the
4
+ // lifecycle — refs, effects, the real timers, the Animated wiring, and the child clone:
5
+ // TouchableOpacity: an Animated.Value fades toward activeOpacity on press-in and back to the
6
+ // style's own resting opacity on press-out, driven from onPressIn/onPressOut.
7
+ // TouchableHighlight: the underlay machine drives a `shown` flag (NOT `pressed` — RN holds the
8
+ // underlay for delayPressOut past the tap); the container takes the underlay color and the
9
+ // child takes the lowered opacity, RN's split.
10
+ // TouchableWithoutFeedback: the same press-timing machine with the visual half left empty —
11
+ // "without feedback" means no VISUAL, not no timing.
12
+ import { Children, cloneElement, createElement, isValidElement, useEffect, useRef, useState, } from 'react';
13
+ 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';
12
14
  import { Pressable } from '../pressable/index.js';
13
15
  import { Animated } from '../../modules/animated/index.js';
14
- // The real setTimeout the shared feedback machine schedules its deferred activation/deactivation on
15
- // (core/components has no timer globals). Returns a canceller so an early release flushes the timer.
16
+ // The real setTimeout the shared machines schedule on (core/components has no timer globals).
17
+ // Returns a canceller so an early release flushes the timer.
16
18
  function scheduleTimeout(callback, ms) {
17
19
  const id = setTimeout(callback, ms);
18
20
  return () => clearTimeout(id);
@@ -22,42 +24,142 @@ export const TouchableOpacity = props => {
22
24
  // className is pulled out here, like style, and applied to the inner Animated.View below —
23
25
  // left in ...rest it would land on the outer Pressable instead, which is not where `style`
24
26
  // (the opacity-fade node) goes.
25
- className, children, onPressIn, onPressOut, delayPressIn = 0, delayPressOut = 0, minPressDuration = DEFAULT_MIN_PRESS_DURATION_MS, ...rest } = props;
26
- // One Animated.Value per mount, resting at full opacity. The Animated.View leaf commits its
27
- // current value every frame, so timing it animates the real view.
28
- const opacity = useRef(new Animated.Value(RESTING_OPACITY)).current;
27
+ className, children, disabled, onPressIn, onPressOut, delayPressIn = 0, delayPressOut = 0,
28
+ // RN's Touchables override Pressability's 130ms floor with 0 (TouchableOpacity.js:195); the
29
+ // active visual is held by the fade's own duration instead.
30
+ minPressDuration = TOUCHABLE_MIN_PRESS_DURATION_MS, ...rest } = props;
31
+ // RN's _getChildStyleOpacityWithDefault: the fade settles at the opacity the CALLER's style
32
+ // asks for, not at a hard 1 — and must START there, or the first paint jumps to fully opaque.
33
+ const restingOpacity = restingOpacityFromStyle(style);
34
+ // One Animated.Value per mount. The Animated.View leaf commits its current value every frame,
35
+ // so timing it animates the real view.
36
+ const opacity = useRef(new Animated.Value(restingOpacity)).current;
29
37
  // The shared press-scheduling cell (delayPressIn timer + activation clock), held across renders.
30
38
  const runtime = useRef(createTouchableFeedbackRuntime()).current;
31
39
  function setOpacityTo(toValue, duration) {
40
+ // useNativeDriver: true is RN's own (TouchableOpacity.js:242). opacity is natively drivable
41
+ // and the fade then survives a busy JS thread, which is the whole point of the feedback.
32
42
  Animated.timing(opacity, {
33
43
  toValue,
34
44
  duration,
35
45
  easing: Animated.Easing.inOut(Animated.Easing.quad),
36
- useNativeDriver: false,
46
+ useNativeDriver: true,
37
47
  }).start();
38
48
  }
49
+ // RN's componentDidUpdate: a changed `disabled` or a changed style opacity re-settles the view,
50
+ // so a Touchable disabled mid-press does not stay stuck at its active opacity.
51
+ const hasRendered = useRef(false);
52
+ useEffect(() => {
53
+ // RN runs this on UPDATE only. Firing at mount would animate over the value the
54
+ // Animated.Value was just constructed with.
55
+ if (!hasRendered.current) {
56
+ hasRendered.current = true;
57
+ return;
58
+ }
59
+ setOpacityTo(restingOpacity, OPACITY_INACTIVE_DURATION_MS);
60
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- setOpacityTo is re-made per render
61
+ }, [disabled, restingOpacity]);
62
+ // RN's componentWillUnmount: stop the fade so a teardown mid-animation leaves no driver running
63
+ // against a node that is gone.
64
+ useEffect(() => () => {
65
+ opacity.resetAnimation();
66
+ }, [opacity]);
39
67
  // The shared machine owns the delayPressIn/minPressDuration scheduling; the adapter supplies only
40
68
  // the native seam — the Animated opacity fade + the framework emit — as activate/deactivate.
41
- const { handlePressIn, handlePressOut } = createTouchableFeedbackHandlers({ delayPressIn, delayPressOut, minPressDuration, schedule: scheduleTimeout, now: Date.now }, runtime, {
69
+ const { handlePressIn, handlePressOut } = createTouchableFeedbackHandlers({
70
+ delayPressIn,
71
+ delayPressOut,
72
+ minPressDuration,
73
+ schedule: scheduleTimeout,
74
+ now: Date.now,
75
+ }, runtime, {
42
76
  activate(event) {
43
- setOpacityTo(activeOpacity, OPACITY_ACTIVE_DURATION_MS);
77
+ setOpacityTo(activeOpacity, OPACITY_ACTIVE_GRANT_DURATION_MS);
44
78
  onPressIn?.(event);
45
79
  },
46
80
  deactivate(event) {
47
- setOpacityTo(RESTING_OPACITY, OPACITY_INACTIVE_DURATION_MS);
81
+ setOpacityTo(restingOpacity, OPACITY_INACTIVE_DURATION_MS);
48
82
  onPressOut?.(event);
49
83
  },
50
84
  });
51
- return createElement(Pressable, { ...rest, onPressIn: handlePressIn, onPressOut: handlePressOut }, createElement(Animated.View, { style: [style, { opacity }], className }, children));
85
+ return createElement(Pressable, { ...rest, disabled, onPressIn: handlePressIn, onPressOut: handlePressOut }, createElement(Animated.View, { style: [style, { opacity }], className }, children));
52
86
  };
87
+ // RN's render: the lowered opacity is cloned onto the CHILD while the container keeps an opaque
88
+ // underlay (TouchableHighlight.js). Fold both onto the container instead and the opacity fades the
89
+ // very underlay it is meant to reveal — `underlayColor: 'black'` paints grey. React is the one
90
+ // adapter that can reach the child without a wrapper view perturbing the flex chain.
91
+ // A non-element child (text, a fragment, nothing at all) has no style prop to compose onto, so it
92
+ // passes through untouched rather than throwing the way RN's Children.only would.
93
+ function withChildStyle(children, extra) {
94
+ const child = Children.count(children) === 1 ? children : undefined;
95
+ if (!isValidElement(child))
96
+ return children;
97
+ return cloneElement(child, { style: [child.props.style, extra] });
98
+ }
53
99
  export const TouchableHighlight = props => {
54
- const { activeOpacity = DEFAULT_HIGHLIGHT_CHILD_OPACITY, underlayColor = DEFAULT_UNDERLAY_COLOR, style, children, ...rest } = props;
55
- function pressedStyle({ pressed }) {
56
- return highlightPressedStyle(pressed, style, underlayColor, activeOpacity);
100
+ const { activeOpacity, underlayColor, style, children, onPress, onPressIn, onPressOut, onShowUnderlay, onHideUnderlay, delayPressOut = 0, ...rest } = props;
101
+ // `shown` is NOT `pressed`: RN holds the underlay for delayPressOut past the tap so a press too
102
+ // fast to see still flashes, and the pressed flag is already false by then.
103
+ const [shown, setShown] = useState(false);
104
+ const runtime = useRef(createHighlightUnderlayRuntime()).current;
105
+ // RN's _hasPressHandler gate: no press handler, no underlay. onLongPress is only READ here —
106
+ // it stays in `rest` for Pressable to fire directly.
107
+ const hasPressHandler = hasTouchablePressHandler({
108
+ onPress,
109
+ onPressIn,
110
+ onPressOut,
111
+ onLongPress: rest.onLongPress,
112
+ });
113
+ const underlay = createHighlightUnderlayHandlers({ delayPressOut, hasPressHandler, schedule: scheduleTimeout }, runtime, { setShown, onShowUnderlay, onHideUnderlay });
114
+ const extraStyles = resolveHighlightExtraStyles({
115
+ shown,
116
+ hasPressHandler,
117
+ underlayColor,
118
+ activeOpacity,
119
+ });
120
+ // Visual first, then the caller's callback — RN's order in _createPressabilityConfig.
121
+ function handlePressIn(event) {
122
+ underlay.handlePressIn(event);
123
+ onPressIn?.(event);
124
+ }
125
+ function handlePress(event) {
126
+ underlay.handlePress(event);
127
+ onPress?.(event);
57
128
  }
58
- return createElement(Pressable, { ...rest, style: pressedStyle }, children);
129
+ function handlePressOut(event) {
130
+ underlay.handlePressOut(event);
131
+ onPressOut?.(event);
132
+ }
133
+ return createElement(Pressable, {
134
+ ...rest,
135
+ style: extraStyles === undefined ? style : [style, extraStyles.underlay],
136
+ onPress: handlePress,
137
+ onPressIn: handlePressIn,
138
+ onPressOut: handlePressOut,
139
+ }, extraStyles === undefined
140
+ ? children
141
+ : withChildStyle(children, extraStyles.child));
59
142
  };
60
143
  export const TouchableWithoutFeedback = props => {
61
- const { children, ...rest } = props;
62
- return createElement(Pressable, rest, children);
144
+ const { children, onPressIn, onPressOut, delayPressIn = 0, delayPressOut = 0, minPressDuration = TOUCHABLE_MIN_PRESS_DURATION_MS, ...rest } = props;
145
+ // RN's TouchableWithoutFeedback builds a FULL Pressability config with delayPressIn /
146
+ // delayPressOut / minPressDuration: 0 — the same shared machine TouchableOpacity uses, with the
147
+ // visual half left empty. Spreading the delay props onto Pressable instead did nothing: it does
148
+ // not read them, and they leaked to the host as unknown props.
149
+ const runtime = useRef(createTouchableFeedbackRuntime()).current;
150
+ const { handlePressIn, handlePressOut } = createTouchableFeedbackHandlers({
151
+ delayPressIn,
152
+ delayPressOut,
153
+ minPressDuration,
154
+ schedule: scheduleTimeout,
155
+ now: Date.now,
156
+ }, runtime, {
157
+ activate(event) {
158
+ onPressIn?.(event);
159
+ },
160
+ deactivate(event) {
161
+ onPressOut?.(event);
162
+ },
163
+ });
164
+ return createElement(Pressable, { ...rest, onPressIn: handlePressIn, onPressOut: handlePressOut }, children);
63
165
  };
@@ -18,9 +18,9 @@
18
18
  // scrollTo command via its handle ref, animated by default. The `contentOffset` prop is only a
19
19
  // fallback for the pre-mount window (handle not yet attached).
20
20
  import { createElement, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useReducer, useRef, useState, } from 'react';
21
- import { dlog } from '@symbiote-native/engine';
22
- 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';
23
- import { ScrollView } from '../scroll-view';
21
+ import { dlog, } from '@symbiote-native/engine';
22
+ 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';
23
+ import { ScrollView, } from '../scroll-view';
24
24
  import { RefreshControl } from '../refresh-control/index.js';
25
25
  function resolveElement(component) {
26
26
  if (component === undefined)
@@ -88,7 +88,12 @@ export function VirtualizedList(props) {
88
88
  onScrollToIndexFailed,
89
89
  viewabilityPairs,
90
90
  });
91
- handlersRef.current = { onEndReached, onStartReached, onScrollToIndexFailed, viewabilityPairs };
91
+ handlersRef.current = {
92
+ onEndReached,
93
+ onStartReached,
94
+ onScrollToIndexFailed,
95
+ viewabilityPairs,
96
+ };
92
97
  // The one folded state cell (RN's scattered refs collapsed into IListState). Lazily created once.
93
98
  const stateRef = useRef(null);
94
99
  const state = (stateRef.current ??= createInitialListState());
@@ -109,7 +114,9 @@ export function VirtualizedList(props) {
109
114
  const scrollToPixel = useCallback((offset, animated) => {
110
115
  const clamped = Math.max(EMPTY_OFFSET, offset);
111
116
  const isHorizontal = inputsRef.current.horizontal;
112
- const target = isHorizontal ? { x: clamped, y: EMPTY_OFFSET } : { x: EMPTY_OFFSET, y: clamped };
117
+ const target = isHorizontal
118
+ ? { x: clamped, y: EMPTY_OFFSET }
119
+ : { x: EMPTY_OFFSET, y: clamped };
113
120
  if (scrollViewRef.current !== null) {
114
121
  dlog(`VirtualizedList scrollTo offset=${clamped} animated=${animated} (horizontal=${isHorizontal})`);
115
122
  scrollViewRef.current.scrollTo({ x: target.x, y: target.y, animated });
@@ -129,10 +136,14 @@ export function VirtualizedList(props) {
129
136
  scrollToPixel(effect.offset, effect.animated);
130
137
  break;
131
138
  case 'fire-end-reached':
132
- handlers.onEndReached?.({ distanceFromEnd: effect.distanceFromEnd });
139
+ handlers.onEndReached?.({
140
+ distanceFromEnd: effect.distanceFromEnd,
141
+ });
133
142
  break;
134
143
  case 'fire-start-reached':
135
- handlers.onStartReached?.({ distanceFromStart: effect.distanceFromStart });
144
+ handlers.onStartReached?.({
145
+ distanceFromStart: effect.distanceFromStart,
146
+ });
136
147
  break;
137
148
  case 'fire-scroll-to-index-failed':
138
149
  handlers.onScrollToIndexFailed?.({
@@ -217,8 +228,9 @@ export function VirtualizedList(props) {
217
228
  const length = readLayoutLength(event, horizontal);
218
229
  if (length === undefined)
219
230
  return;
220
- dlog(`VirtualizedList cell ${index} measured length=${length}`);
221
- dispatch({ kind: 'measure', index, length });
231
+ const offset = readLayoutOffset(event, horizontal);
232
+ dlog(`VirtualizedList cell ${index} measured length=${length} offset=${offset ?? 'none'}`);
233
+ dispatch({ kind: 'measure', index, length, offset });
222
234
  }, [horizontal, dispatch]);
223
235
  // Merge an override onto the separator at a given gap and request a re-render. A gap index
224
236
  // outside [0, count-2] has no separator, so the write is a no-op (RN bails the same way).
@@ -313,7 +325,9 @@ export function VirtualizedList(props) {
313
325
  // extraData) already re-renders and re-runs renderItem. Voided to mark the deliberate no-op.
314
326
  void extraData;
315
327
  const children = [];
316
- const stickySet = stickyHeaderIndices !== undefined ? new Set(stickyHeaderIndices) : undefined;
328
+ const stickySet = stickyHeaderIndices !== undefined
329
+ ? new Set(stickyHeaderIndices)
330
+ : undefined;
317
331
  let renderedStickyIndices = [];
318
332
  const header = resolveElement(ListHeaderComponent);
319
333
  if (header !== undefined) {
@@ -337,13 +351,14 @@ export function VirtualizedList(props) {
337
351
  keyFor: keyForIndex,
338
352
  stickyIndices: stickySet,
339
353
  hasHeader: header !== undefined,
340
- hasSeparators: ItemSeparatorComponent !== undefined,
341
354
  });
342
355
  renderedStickyIndices = plan.stickyChildPositions;
343
356
  if (plan.leadingExtent > EMPTY_OFFSET) {
344
357
  children.push(createElement('symbiote-view', {
345
358
  key: 'spacer-leading',
346
- style: horizontal ? { width: plan.leadingExtent } : { height: plan.leadingExtent },
359
+ style: horizontal
360
+ ? { width: plan.leadingExtent }
361
+ : { height: plan.leadingExtent },
347
362
  }));
348
363
  }
349
364
  // The force-mounted sticky cell (RN _ensureClosestStickyHeader): the nearest sticky index
@@ -362,13 +377,19 @@ export function VirtualizedList(props) {
362
377
  children.push(createElement('symbiote-view', {
363
378
  key: `cell-${plan.forcedStickyCell.key}`,
364
379
  onLayout: makeCellMeasure(forcedIndex),
365
- style: inverted ? (horizontal ? INVERTED_X_STYLE : INVERTED_Y_STYLE) : undefined,
380
+ style: inverted
381
+ ? horizontal
382
+ ? INVERTED_X_STYLE
383
+ : INVERTED_Y_STYLE
384
+ : undefined,
366
385
  }, forcedCell));
367
386
  }
368
387
  if (plan.gapExtent > EMPTY_OFFSET) {
369
388
  children.push(createElement('symbiote-view', {
370
389
  key: 'spacer-gap',
371
- style: horizontal ? { width: plan.gapExtent } : { height: plan.gapExtent },
390
+ style: horizontal
391
+ ? { width: plan.gapExtent }
392
+ : { height: plan.gapExtent },
372
393
  }));
373
394
  }
374
395
  for (const planCell of plan.cells) {
@@ -379,24 +400,42 @@ export function VirtualizedList(props) {
379
400
  index: planCell.index,
380
401
  separators: makeSeparators(planCell.index),
381
402
  });
403
+ // RN renders the separator INSIDE the cell's own measuring wrapper
404
+ // (VirtualizedListCellRenderer.js:218-221), and that placement is load-bearing rather than
405
+ // cosmetic. As a SIBLING it is an extra flex child, so the chrome between two cells becomes
406
+ // gap + separator + gap while a spacer collapsing that region replaces it with one gap — the
407
+ // leading spacer then lands every cell below it short by (separator + gap), and the content
408
+ // visibly jumps by that amount each time the window's first index moves. Measured at exactly
409
+ // 17px on device 2026-08-19 (a 1px divider under a 16px container gap); see
410
+ // .claude/rules/list-geometry-feedback-loop.md. Inside the wrapper it is part of the cell's
411
+ // measured length instead, and every cell stays exactly one child.
412
+ const separator =
413
+ // RN gates the separator on the last index of the DATA, not of the WINDOW
414
+ // (VirtualizedList.js:793 `const end = getItemCount(data) - 1`), and now that the separator
415
+ // lives INSIDE the measuring wrapper that distinction is load-bearing: gating on the window
416
+ // would make a cell's own measured height change as the window slides past it. Device-measured
417
+ // 2026-08-19 as a run of cells all shifting by exactly the divider's 1px.
418
+ planCell.index < count - 1
419
+ ? renderSeparatorElement(ItemSeparatorComponent, item, getItem(data, planCell.index + 1), separatorOverridesRef.current.get(planCell.index))
420
+ : undefined;
382
421
  // Wrap each cell in a measuring View. When inverted, each cell carries the counter-flip so
383
422
  // its content reads upright inside the flipped content container.
384
423
  children.push(createElement('symbiote-view', {
385
424
  key: `cell-${planCell.key}`,
386
425
  onLayout: makeCellMeasure(planCell.index),
387
- style: inverted ? (horizontal ? INVERTED_X_STYLE : INVERTED_Y_STYLE) : undefined,
388
- }, cell));
389
- const separator = planCell.index < last
390
- ? renderSeparatorElement(ItemSeparatorComponent, item, getItem(data, planCell.index + 1), separatorOverridesRef.current.get(planCell.index))
391
- : undefined;
392
- if (separator !== undefined) {
393
- children.push(createElement('symbiote-view', { key: `sep-${planCell.key}` }, separator));
394
- }
426
+ style: inverted
427
+ ? horizontal
428
+ ? INVERTED_X_STYLE
429
+ : INVERTED_Y_STYLE
430
+ : undefined,
431
+ }, cell, separator));
395
432
  }
396
433
  if (plan.trailingExtent > EMPTY_OFFSET) {
397
434
  children.push(createElement('symbiote-view', {
398
435
  key: 'spacer-trailing',
399
- style: horizontal ? { width: plan.trailingExtent } : { height: plan.trailingExtent },
436
+ style: horizontal
437
+ ? { width: plan.trailingExtent }
438
+ : { height: plan.trailingExtent },
400
439
  }));
401
440
  }
402
441
  }
@@ -448,7 +487,8 @@ export function VirtualizedList(props) {
448
487
  if (maintainVisibleContentPosition !== undefined) {
449
488
  scrollProps.maintainVisibleContentPosition = {
450
489
  ...maintainVisibleContentPosition,
451
- minIndexForVisible: maintainVisibleContentPosition.minIndexForVisible + (header !== undefined ? 1 : 0),
490
+ minIndexForVisible: maintainVisibleContentPosition.minIndexForVisible +
491
+ (header !== undefined ? 1 : 0),
452
492
  };
453
493
  }
454
494
  // Pull-to-refresh: when onRefresh is set, build a RefreshControl for the ScrollView's
@@ -22,6 +22,11 @@ export interface IVirtualizedSectionListProps<ItemT> extends IAccessibilityProps
22
22
  }) => ReactNode;
23
23
  SectionSeparatorComponent?: ComponentType<Record<string, never>> | ReactElement;
24
24
  keyExtractor?: (item: ItemT, index: number) => string;
25
+ getItemLayout?: (data: ReadonlyArray<ISection<ItemT>> | null, index: number) => {
26
+ length: number;
27
+ offset: number;
28
+ index: number;
29
+ };
25
30
  stickySectionHeadersEnabled?: boolean;
26
31
  extraData?: unknown;
27
32
  ItemSeparatorComponent?: ComponentType<ISeparatorProps<ItemT>>;
@@ -6,7 +6,7 @@
6
6
  // useImperativeHandle + the per-entry render dispatch). Lower layer in RN's
7
7
  // SectionList -> VirtualizedSectionList -> VirtualizedList stack.
8
8
  import { createElement, useImperativeHandle, useRef, } from 'react';
9
- import { dlog, Platform } from '@symbiote-native/engine';
9
+ import { dlog, Platform, } from '@symbiote-native/engine';
10
10
  import { flattenSections, resolveStickySectionHeaders, scrollLocationToFlatIndex, sectionEntryKey, unwrapEntryItem, } from '@symbiote-native/components';
11
11
  import { VirtualizedList, } from '../virtualized-list/index.js';
12
12
  function resolveSeparator(component) {
@@ -20,7 +20,10 @@ export function VirtualizedSectionList(props) {
20
20
  const { ref, sections, renderItem, renderSectionHeader, renderSectionFooter, SectionSeparatorComponent,
21
21
  // Pulled out of `rest`: the user's separator is typed on ItemT, but the inner VirtualizedList
22
22
  // streams ISectionEntry<ItemT>, so we wrap it to unwrap each entry back to its ItemT.
23
- ItemSeparatorComponent, keyExtractor, stickySectionHeadersEnabled, ...rest } = props;
23
+ ItemSeparatorComponent, keyExtractor,
24
+ // Also pulled out of `rest`: the inner list streams entries, the user's callback expects
25
+ // sections. See entryItemLayout below.
26
+ getItemLayout, stickySectionHeadersEnabled, ...rest } = props;
24
27
  const { entries, headerIndices } = flattenSections(sections, SectionSeparatorComponent !== undefined);
25
28
  // RN sticks section headers by default only on iOS; Android does not unless asked. headerIndices
26
29
  // are the flat positions of every section header; VirtualizedList forwards the in-window ones.
@@ -58,10 +61,14 @@ export function VirtualizedSectionList(props) {
58
61
  const renderEntry = (info) => {
59
62
  const entry = info.item;
60
63
  if (entry.kind === 'header') {
61
- return renderSectionHeader ? renderSectionHeader({ section: entry.section }) : undefined;
64
+ return renderSectionHeader
65
+ ? renderSectionHeader({ section: entry.section })
66
+ : undefined;
62
67
  }
63
68
  if (entry.kind === 'footer') {
64
- return renderSectionFooter ? renderSectionFooter({ section: entry.section }) : undefined;
69
+ return renderSectionFooter
70
+ ? renderSectionFooter({ section: entry.section })
71
+ : undefined;
65
72
  }
66
73
  if (entry.kind === 'section-separator') {
67
74
  return resolveSeparator(SectionSeparatorComponent);
@@ -83,6 +90,18 @@ export function VirtualizedSectionList(props) {
83
90
  trailingItem: unwrapEntryItem(entryProps.trailingItem),
84
91
  });
85
92
  const entryKeyExtractor = (entry, index) => sectionEntryKey(entry, index, keyExtractor);
93
+ // Hand the callback `sections`, not the entries: RN's inner VirtualizedList gets
94
+ // `data={this.props.sections}` (VirtualizedSectionList.js:216) while ours streams the FLATTENED
95
+ // entries, so the same user code would otherwise see a different argument here than on RN.
96
+ //
97
+ // UPSTREAM-DIVERGENCE(react-native): the flat INDEX matches RN's (two rows per section, header
98
+ // and footer) only while SectionSeparatorComponent is unset. With it, flattenSections emits an
99
+ // extra 'section-separator' row per boundary that RN renders inside the neighbouring cell, so
100
+ // indices shift by one per boundary from the second section on. Deliberate - that row is how this
101
+ // adapter paints the separator; a caller combining the two must account for it.
102
+ const entryItemLayout = getItemLayout === undefined
103
+ ? undefined
104
+ : (_entries, index) => getItemLayout(sections, index);
86
105
  return createElement((VirtualizedList), {
87
106
  ref: listRef,
88
107
  data: entries,
@@ -90,6 +109,7 @@ export function VirtualizedSectionList(props) {
90
109
  getItemCount: () => entries.length,
91
110
  renderItem: renderEntry,
92
111
  keyExtractor: entryKeyExtractor,
112
+ getItemLayout: entryItemLayout,
93
113
  stickyHeaderIndices,
94
114
  ItemSeparatorComponent: entrySeparatorComponent,
95
115
  ...rest,
@@ -3,7 +3,7 @@
3
3
  // reconciler maps those to shared's mutation API, which resolves them to Fabric
4
4
  // view names at commit.
5
5
  import { createElement } from 'react';
6
- import { resolveAccessibilityProps, } from '@symbiote-native/components';
6
+ import { resolveAccessibilityProps, resolveTextProps, } from '@symbiote-native/components';
7
7
  // RN's modern `id` is just a W3C-named alias for `nativeID`: View.js copies it over
8
8
  // (`processedProps.nativeID = id`), so `id` wins when both are set. We fold it here and
9
9
  // blank the alias so a raw `id` never reaches Fabric (every non-function prop passes
@@ -14,4 +14,4 @@ function resolveId({ id, ...rest }) {
14
14
  return { ...rest, nativeID: id };
15
15
  }
16
16
  export const View = props => createElement('symbiote-view', resolveAccessibilityProps(resolveId(props)));
17
- export const Text = props => createElement('symbiote-text', resolveAccessibilityProps(props));
17
+ export const Text = props => createElement('symbiote-text', resolveTextProps(resolveAccessibilityProps(props)));
@@ -10,7 +10,7 @@
10
10
  // call site (e.g. a ref to a persistent "overlay host" View near your app root) — not a second,
11
11
  // independently-mounted SymbioteSurface. Cross-surface targets aren't wired: resetAfterCommit only
12
12
  // fires for the primary root's own container, so a different surface's tree would never repaint.
13
- import { isSymbioteNode, SymbioteSurface } from '@symbiote-native/engine';
13
+ import { isSymbioteNode, SymbioteSurface, } from '@symbiote-native/engine';
14
14
  import reconciler from '../host-config.js';
15
15
  export function createPortal(children, container, key) {
16
16
  if (!(container instanceof SymbioteSurface) && !isSymbioteNode(container)) {
@@ -15,7 +15,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
15
15
  // nowhere to bounce back to. `Out` lives in whichever surface should PAINT the content; `In`
16
16
  // lives anywhere - same surface or a different one - and never touches a Fabric node
17
17
  // directly, so there's no "target must already be mounted" guard to satisfy.
18
- import { Fragment, useEffect, useId, useSyncExternalStore } from 'react';
18
+ import { Fragment, useEffect, useId, useSyncExternalStore, } from 'react';
19
19
  export function createTunnel() {
20
20
  const items = new Map();
21
21
  const listeners = new Set();
@@ -4,7 +4,7 @@
4
4
  // subscribing to close the gap between the render-time get and the effect-time
5
5
  // listener. Only the window metrics changing triggers a re-render.
6
6
  import { useEffect, useState } from 'react';
7
- import { Dimensions } from '@symbiote-native/engine';
7
+ import { Dimensions, } from '@symbiote-native/engine';
8
8
  export function useWindowDimensions() {
9
9
  const [dimensions, setDimensions] = useState(() => Dimensions.get('window'));
10
10
  useEffect(() => {
@@ -4,7 +4,7 @@
4
4
  // canary: a known-good driver exercising shared end to end.
5
5
  import createReconciler from 'react-reconciler';
6
6
  import { createContext } from 'react';
7
- import { appendChild, createElement, createRawText, insertBefore, removeChild, routeProp, setText, SymbioteSurface, } from '@symbiote-native/engine';
7
+ import { appendChild, createElement, createRawText, insertBefore, removeChild, routeProp, setNodeHidden, setText, SymbioteSurface, } from '@symbiote-native/engine';
8
8
  import { DefaultEventPriority, DiscreteEventPriority, NoEventPriority, } from './reconciler-constants.js';
9
9
  import { toPublicInstance } from './host-instance.js';
10
10
  // Intrinsic JSX type -> Fabric component name. The name table + resolver live once in
@@ -70,7 +70,9 @@ const reconciler = createReconciler({
70
70
  getRootHostContext: () => ({ isInsideText: false }),
71
71
  getChildHostContext(parentHostContext, type) {
72
72
  const isInsideText = descriptorFor(type).isText;
73
- return parentHostContext.isInsideText === isInsideText ? parentHostContext : { isInsideText };
73
+ return parentHostContext.isInsideText === isInsideText
74
+ ? parentHostContext
75
+ : { isInsideText };
74
76
  },
75
77
  getPublicInstance: instance => toPublicInstance(instance),
76
78
  prepareForCommit: () => null,
@@ -134,8 +136,12 @@ const reconciler = createReconciler({
134
136
  resetTextContent: () => { },
135
137
  hideTextInstance: node => setText(node, ''),
136
138
  unhideTextInstance: (node, text) => setText(node, text),
137
- hideInstance: () => { },
138
- unhideInstance: () => { },
139
+ // `Activity mode="hidden"` and a re-suspending `Suspense` both reach the tree through these.
140
+ // They were no-ops, so hidden content went on painting — stacked on top of whatever replaced
141
+ // it — and only the mount/unmount half of Activity appeared to work. The engine owns the
142
+ // reversible half (see setNodeHidden): the author's own style stays untouched underneath.
143
+ hideInstance: node => setNodeHidden(node, true),
144
+ unhideInstance: node => setNodeHidden(node, false),
139
145
  beforeActiveInstanceBlur: () => { },
140
146
  afterActiveInstanceBlur: () => { },
141
147
  detachDeletedInstance: () => { },
@@ -146,7 +152,9 @@ const reconciler = createReconciler({
146
152
  currentUpdatePriority = priority;
147
153
  },
148
154
  getCurrentUpdatePriority: () => currentUpdatePriority,
149
- resolveUpdatePriority: () => currentUpdatePriority !== NoEventPriority ? currentUpdatePriority : DefaultEventPriority,
155
+ resolveUpdatePriority: () => currentUpdatePriority !== NoEventPriority
156
+ ? currentUpdatePriority
157
+ : DefaultEventPriority,
150
158
  maySuspendCommit: () => false,
151
159
  NotPendingTransition: null,
152
160
  // react's Context IS the runtime value react-reconciler's ReactContext wants
package/build/index.d.ts CHANGED
@@ -7,9 +7,9 @@ export type { IImageProps, IImageSource, IImageSourceProp, IResizeMode, IImageSi
7
7
  export { ImageBackground } from './components/image-background';
8
8
  export type { IImageBackgroundProps } from './components/image-background';
9
9
  export { ScrollView } from './components/scroll-view';
10
- export type { IScrollViewProps, IScrollViewHandle } from './components/scroll-view';
10
+ export type { IScrollViewProps, IScrollViewHandle, } from './components/scroll-view';
11
11
  export { TextInput } from './components/text-input';
12
- export type { ITextInputProps, ITextInputHandle } from './components/text-input';
12
+ export type { ITextInputProps, ITextInputHandle, } from './components/text-input';
13
13
  export { InputAccessoryView } from './components/input-accessory-view';
14
14
  export type { IInputAccessoryViewProps } from './components/input-accessory-view';
15
15
  export { KeyboardAvoidingView } from './components/keyboard-avoiding-view';
@@ -35,12 +35,12 @@ export type { IButtonProps } from './components/button';
35
35
  export { FlatList } from './components/flat-list';
36
36
  export type { IFlatListProps, IFlatListHandle } from './components/flat-list';
37
37
  export { SectionList } from './components/section-list';
38
- export type { ISectionListProps, ISectionListHandle, ISection } from './components/section-list';
38
+ export type { ISectionListProps, ISectionListHandle, ISection, } from './components/section-list';
39
39
  export { VirtualizedSectionList } from './components/virtualized-section-list';
40
40
  export type { IVirtualizedSectionListProps, IVirtualizedSectionListHandle, } from './components/virtualized-section-list';
41
41
  export { VirtualizedList } from './components/virtualized-list';
42
42
  export type { IVirtualizedListProps, IVirtualizedListHandle, IViewToken, IViewableItemsChangedInfo, IViewabilityConfig, IViewabilityConfigCallbackPair, } from './components/virtualized-list';
43
- export type { IViewStyle, ITextStyle, IFlexAlign, IFlexJustify } from './utils/styles';
43
+ export type { IViewStyle, ITextStyle, IFlexAlign, IFlexJustify, } from './utils/styles';
44
44
  export { mount, unmount } from './render';
45
45
  export { createPortal, type IPortalContainer } from './create-portal';
46
46
  export { createTunnel, type ITunnel } from './create-tunnel';
@@ -51,11 +51,11 @@ export { AppRegistry, setHostRegistrar } from './modules/app-registry';
51
51
  export type { IComponentProvider, IAppParameters, IRunnable, IHostRegistrar, IWrapperComponentProvider, IRegistry, IHeadlessTask, ITaskProvider, ITaskCanceller, ITaskCancelProvider, } from './modules/app-registry';
52
52
  export { Animated, createAnimatedComponent } from './modules/animated';
53
53
  export { Platform, StyleSheet } from '@symbiote-native/engine';
54
- export { PlatformColor, DynamicColorIOS, processColor } from '@symbiote-native/engine';
54
+ export { PlatformColor, DynamicColorIOS, processColor, } from '@symbiote-native/engine';
55
55
  export type { IColorValue, IOpaqueColorValue, IDynamicColorIOSTuple, } from '@symbiote-native/engine';
56
56
  export { setNativeViewConfigSource, setColorProcessor, setDeviceEventSource, } from '@symbiote-native/engine';
57
57
  export { dlog, isDebug } from '@symbiote-native/engine';
58
- export type { INativeViewConfig, INativeViewConfigSource } from '@symbiote-native/engine';
58
+ export type { INativeViewConfig, INativeViewConfigSource, } from '@symbiote-native/engine';
59
59
  export type { IPlatformStatic, IPlatformOSType, IPlatformConstantsIOS, IPlatformConstantsAndroid, IPlatformSelectSpec, } from '@symbiote-native/engine';
60
60
  export { Alert, Share, ActionSheetIOS, Linking, Vibration, ToastAndroid, Settings, I18nManager, Dimensions, Appearance, AppState, Keyboard, KEYBOARD_EVENT, BackHandler, PermissionsAndroid, PERMISSIONS, RESULTS, LayoutAnimation, PixelRatio, } from '@symbiote-native/engine';
61
61
  export type { IAlertType, IAlertButtonStyle, IAlertButton, IAlertButtons, IAlertOptions, IShareContent, IShareOptions, IShareAction, IActionSheetIOSOptions, IShareActionSheetIOSOptions, IShareActionSheetError, IUrlEvent, II18nManagerConstants, IDisplayMetrics, IDisplayMetricsAndroid, IDimensionsPayload, IDimensionsSet, IDimensionsKey, IDimensionsChangeListener, IDimensionsStatic, IColorSchemeName, IColorSchemePreference, IAppStateStatus, IAppStateEvent, IKeyboardEventName, IKeyboardEvent, IKeyboardMetrics, IBackPressEventName, IBackPressHandler, IPermission, IPermissionStatus, IRationale, ILayoutAnimationType, ILayoutAnimationProperty, ILayoutAnimationConfig, ILayoutAnimationAnim, IPixelRatioStatic, } from '@symbiote-native/engine';
@@ -69,5 +69,5 @@ export { InteractionManager } from '@symbiote-native/engine';
69
69
  export type { IInteractionEvent, ISimpleTask, IPromiseTask, ITask, IHandle, } from '@symbiote-native/engine';
70
70
  export { TouchableNativeFeedback } from './components/touchable-native-feedback';
71
71
  export type { ITouchableNativeFeedbackProps, INativeFeedbackBackground, IThemeAttrBackground, IRippleBackground, } from './components/touchable-native-feedback';
72
- export type { ISymbioteEvent, ISymbioteNode, IRootTag } from '@symbiote-native/engine';
72
+ export type { ISymbioteEvent, ISymbioteNode, IRootTag, } from '@symbiote-native/engine';
73
73
  export type { ICellLayout, ISeparatorProps, ISeparators, IModalOrientationChangeEvent, IPressableAndroidRippleConfig, IEnterKeyHint, IInputMode, ISubmitBehavior, ITextInputSelection, IImageStatics, } from '@symbiote-native/components';
package/build/index.js CHANGED
@@ -49,7 +49,7 @@ export { Platform, StyleSheet } from '@symbiote-native/engine';
49
49
  // Color utilities: PlatformColor / DynamicColorIOS build opaque platform colors;
50
50
  // processColor runs a color through the injected platform processor. All pure /
51
51
  // seam-backed, so they live in shared and the adapter re-exports them.
52
- export { PlatformColor, DynamicColorIOS, processColor } from '@symbiote-native/engine';
52
+ export { PlatformColor, DynamicColorIOS, processColor, } from '@symbiote-native/engine';
53
53
  // The three app-entry seams, wired once on a real host, so the barrel exposes them together.
54
54
  // setNativeViewConfigSource hands the engine RN's ViewConfig registry, which is how third-party
55
55
  // Fabric views auto-derive their metadata:
@@ -74,7 +74,9 @@ export function createAnimatedComponent(Component) {
74
74
  const passthroughStyle = readPassthroughStyle(passthrough);
75
75
  if (passthroughStyle !== undefined) {
76
76
  reduced.style =
77
- reduced.style === undefined ? passthroughStyle : [reduced.style, passthroughStyle];
77
+ reduced.style === undefined
78
+ ? passthroughStyle
79
+ : [reduced.style, passthroughStyle];
78
80
  }
79
81
  const childProps = Object.assign(Object.create(null), reduced, { ref: captureRef });
80
82
  return createElement(Component, childProps);
@@ -1,7 +1,7 @@
1
1
  import { AnimatedValue, AnimatedValueXY, AnimatedColor, timing, spring, decay, parallel, sequence, stagger, loop, delay, add, subtract, multiply, divide, modulo, diffClamp, event, forkEvent, unforkEvent } from '@symbiote-native/engine';
2
2
  import { createAnimatedComponent } from './create-animated-component';
3
3
  export { createAnimatedComponent } from './create-animated-component';
4
- export { AnimatedProps, AnimatedStyle, AnimatedTransform } from '@symbiote-native/engine';
4
+ export { AnimatedProps, AnimatedStyle, AnimatedTransform, } from '@symbiote-native/engine';
5
5
  export declare const Animated: {
6
6
  Value: typeof AnimatedValue;
7
7
  ValueXY: typeof AnimatedValueXY;