@symbiote-native/react 0.2.8 → 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 (74) hide show
  1. package/README.md +5 -5
  2. package/build/bootstrap.js +5 -6
  3. package/build/components/activity-indicator/index.android.js +5 -7
  4. package/build/components/activity-indicator/shared.js +1 -1
  5. package/build/components/button.js +1 -1
  6. package/build/components/flat-list/index.js +5 -2
  7. package/build/components/input-accessory-view/index.js +6 -1
  8. package/build/components/keyboard-avoiding-view/index.js +32 -12
  9. package/build/components/modal/index.d.ts +3 -2
  10. package/build/components/modal/index.js +1 -1
  11. package/build/components/pressable/index.d.ts +1 -1
  12. package/build/components/pressable/index.js +1 -1
  13. package/build/components/scroll-view/index.android.js +16 -8
  14. package/build/components/scroll-view/index.ios.js +13 -11
  15. package/build/components/scroll-view/shared.js +58 -9
  16. package/build/components/scroll-view/sticky-header.d.ts +11 -2
  17. package/build/components/scroll-view/sticky-header.js +60 -20
  18. package/build/components/section-list/index.js +4 -6
  19. package/build/components/text-input/index.js +7 -2
  20. package/build/components/touchable/index.d.ts +2 -0
  21. package/build/components/touchable/index.js +130 -28
  22. package/build/components/virtualized-list/index.js +87 -22
  23. package/build/components/virtualized-section-list/index.d.ts +5 -0
  24. package/build/components/virtualized-section-list/index.js +24 -4
  25. package/build/components.js +2 -2
  26. package/build/create-portal/index.js +1 -1
  27. package/build/create-tunnel/index.js +15 -21
  28. package/build/hooks/use-color-scheme.d.ts +1 -1
  29. package/build/hooks/use-color-scheme.js +1 -1
  30. package/build/hooks/use-window-dimensions.d.ts +1 -1
  31. package/build/hooks/use-window-dimensions.js +1 -1
  32. package/build/host-config.js +13 -5
  33. package/build/host-instance.js +5 -6
  34. package/build/index.d.ts +12 -37
  35. package/build/index.js +16 -26
  36. package/build/modules/animated/create-animated-component.js +27 -66
  37. package/build/modules/animated/index.d.ts +1 -1
  38. package/build/modules/animated/index.js +1 -1
  39. package/build/modules/status-bar/index.ios.js +1 -1
  40. package/build/render.js +30 -2
  41. package/metro-css-parser.cjs +7 -12
  42. package/package.json +5 -5
  43. package/build/modules/action-sheet-ios.d.ts +0 -2
  44. package/build/modules/action-sheet-ios.js +0 -4
  45. package/build/modules/alert.d.ts +0 -2
  46. package/build/modules/alert.js +0 -5
  47. package/build/modules/app-state.d.ts +0 -2
  48. package/build/modules/app-state.js +0 -3
  49. package/build/modules/appearance.d.ts +0 -2
  50. package/build/modules/appearance.js +0 -3
  51. package/build/modules/back-handler.d.ts +0 -2
  52. package/build/modules/back-handler.js +0 -3
  53. package/build/modules/dimensions.d.ts +0 -2
  54. package/build/modules/dimensions.js +0 -3
  55. package/build/modules/i18n-manager.d.ts +0 -2
  56. package/build/modules/i18n-manager.js +0 -4
  57. package/build/modules/keyboard.d.ts +0 -2
  58. package/build/modules/keyboard.js +0 -3
  59. package/build/modules/layout-animation.d.ts +0 -2
  60. package/build/modules/layout-animation.js +0 -3
  61. package/build/modules/linking.d.ts +0 -2
  62. package/build/modules/linking.js +0 -4
  63. package/build/modules/permissions-android.d.ts +0 -2
  64. package/build/modules/permissions-android.js +0 -3
  65. package/build/modules/settings.d.ts +0 -1
  66. package/build/modules/settings.js +0 -4
  67. package/build/modules/share.d.ts +0 -2
  68. package/build/modules/share.js +0 -4
  69. package/build/modules/text-input-state.d.ts +0 -1
  70. package/build/modules/text-input-state.js +0 -3
  71. package/build/modules/toast-android.d.ts +0 -1
  72. package/build/modules/toast-android.js +0 -4
  73. package/build/modules/vibration.d.ts +0 -1
  74. package/build/modules/vibration.js +0 -4
package/README.md CHANGED
@@ -6,7 +6,7 @@ in **mutation mode** (`appendChild` / `insertBefore` / `removeChild` → the eng
6
6
  mutation API); `@symbiote-native/engine` does the clone-on-write commit into Fabric.
7
7
 
8
8
  This is the **reference adapter**: the known-good driver used to validate the native pipe and the
9
- commit engine before any other framework lands, so a break in Vue/Svelte/Solid isolates to *that*
9
+ commit engine before any other framework lands, so a break in Vue/Svelte/Solid isolates to _that_
10
10
  adapter, not the core.
11
11
 
12
12
  <div align="center">
@@ -16,7 +16,7 @@ adapter, not the core.
16
16
  </div>
17
17
 
18
18
  > New to SymbioteNative? The [root README](../../README.md) has the architecture and the one fact it
19
- > rests on — React is just *one client* of `nativeFabricUIManager`.
19
+ > rests on — React is just _one client_ of `nativeFabricUIManager`.
20
20
 
21
21
  ---
22
22
 
@@ -46,7 +46,7 @@ export default function App() {
46
46
  return (
47
47
  <View style={{ padding: 24 }}>
48
48
  <Text>Taps: {count}</Text>
49
- <Pressable onPress={() => setCount((c) => c + 1)}>
49
+ <Pressable onPress={() => setCount(c => c + 1)}>
50
50
  <Text>Tap me</Text>
51
51
  </Pressable>
52
52
  </View>
@@ -54,7 +54,7 @@ export default function App() {
54
54
  }
55
55
  ```
56
56
 
57
- The native entry registers a low-level *runnable* instead of a React component: RN's Fabric host
57
+ The native entry registers a low-level _runnable_ instead of a React component: RN's Fabric host
58
58
  calls it with the surface's `rootTag`, and the renderer takes over from there — `nativeFabric-
59
59
  UIManager` is driven directly, RN's own renderer never runs.
60
60
 
@@ -94,7 +94,7 @@ path:
94
94
  `Appearance` · `PixelRatio` · `AppState`, plus imperative `Alert` · `ActionSheetIOS` · `Share` ·
95
95
  `Linking` · `Vibration` · `Keyboard` · `StatusBar` — each reaching its real native module on the
96
96
  bridgeless host.
97
- - **`Animated`, both drivers** — JS *and* native driver side by side (`timing` · `spring` · `loop` ·
97
+ - **`Animated`, both drivers** — JS _and_ native driver side by side (`timing` · `spring` · `loop` ·
98
98
  `interpolate` · `ValueXY` · tracking · `diffClamp`). Native offload is proven by jamming the JS
99
99
  thread 1.5 s: the native-driven animations keep moving, the JS-driven one stalls.
100
100
  - **Third-party native views** — `@react-native-community/slider` used straight from the package
@@ -1,11 +1,10 @@
1
- // Zero-config app entry: wires the four RN-backed host seams (via @symbiote-native/components'
1
+ // Zero-config app entry: wires the four RN-backed host seams (via @symbiote-native/components's
2
2
  // bootstrapHost) plus this adapter's own AppRegistry host-registrar bridge, then registers the
3
- // root component — collapsing the canary's manual sequence into one call. Lives OUTSIDE the
4
- // package's main barrel (see package.json's "./bootstrap" export): it imports react-native
5
- // directly, which Vitest's Flow-unaware transform can't parse (see
6
- // @symbiote-native/components/bootstrap for the full reason).
3
+ // root component in one call. Lives OUTSIDE the package's main barrel (package.json's
4
+ // "./bootstrap" export) because it imports react-native directly, which Vitest's Flow-unaware
5
+ // transform can't parse (see @symbiote-native/components/bootstrap for the full reason).
7
6
  import { AppRegistry as RNAppRegistry } from 'react-native';
8
- import { bootstrapHost } from '@symbiote-native/components/bootstrap';
7
+ import { bootstrapHost, } from '@symbiote-native/components/bootstrap';
9
8
  import { AppRegistry, setHostRegistrar } from './modules/app-registry/index.js';
10
9
  // Mirrors bare RN's own `AppRegistry.registerComponent(appName, () => App)` idiom, minus the
11
10
  // manual host wiring in front of it.
@@ -1,10 +1,8 @@
1
- // ActivityIndicator on Android: AndroidProgressBar needs `styleAttr` (it drives the
2
- // ProgressBar's setStyle(); without it the view throws "setStyle() not called") and
3
- // `indeterminate: true`, and its default color is the theme (null), per RN's
4
- // ActivityIndicator.js android branch ({styleAttr:'Normal', indeterminate:true}). Metro
5
- // picks this on an Android host; no Platform.OS read.
6
- // device-verify-pending: prop names mirror RN's AndroidProgressBar, proven on a real
7
- // host by the absence of the "setStyle() not called" red box.
1
+ // AndroidProgressBar needs `styleAttr` (drives ProgressBar.setStyle(); missing it throws
2
+ // "setStyle() not called") and `indeterminate: true`; default color is the theme (null) —
3
+ // mirrors RN's ActivityIndicator.js android branch. Metro selects this file on an Android
4
+ // host, no Platform.OS read needed.
5
+ // device-verify-pending: confirmed by the absence of the setStyle() red box on a real host.
8
6
  import { descriptorToReact } from '../../descriptor-to-react/index.js';
9
7
  import { useActivityIndicatorLogic } from './shared.js';
10
8
  const PLATFORM = {
@@ -11,7 +11,7 @@
11
11
  // index.ios.ts / index.android.ts each declare their OWN top-level `ActivityIndicator`
12
12
  // function that calls this hook, rather than exporting whatever a factory here returns (same
13
13
  // shape as switch/shared.ts's useSwitchLogic).
14
- import { renderActivityIndicator, resolveAccessibilityProps } from '@symbiote-native/components';
14
+ import { renderActivityIndicator, resolveAccessibilityProps, } from '@symbiote-native/components';
15
15
  export function useActivityIndicatorLogic(rawProps, platform) {
16
16
  // The wrapper is a raw symbiote-view, not the View FC, so it never runs
17
17
  // resolveAccessibilityProps itself, so fold aria/role here, then forward the resolved
@@ -5,7 +5,7 @@
5
5
  import { createElement } from 'react';
6
6
  import { Text } from '../components.js';
7
7
  import { TouchableOpacity } from './touchable/index.js';
8
- import { BUTTON_ACCESSIBILITY_ROLE, resolveButtonTextStyle } from '@symbiote-native/components';
8
+ import { BUTTON_ACCESSIBILITY_ROLE, resolveButtonTextStyle, } from '@symbiote-native/components';
9
9
  export const Button = props => {
10
10
  const { title, onPress, color, disabled, touchSoundDisabled, testID, hasTVPreferredFocus, nextFocusDown, nextFocusForward, nextFocusLeft, nextFocusRight, nextFocusUp, ...accessibilityRest } = props;
11
11
  const textStyle = resolveButtonTextStyle(color, disabled);
@@ -6,7 +6,7 @@
6
6
  // transforms are shared from @symbiote-native/components. This file only adapts to React's lifecycle
7
7
  // (element creation + ref threading).
8
8
  import { createElement, } from 'react';
9
- import { dlog, resolveClassName } from '@symbiote-native/engine';
9
+ import { dlog, resolveClassName, } from '@symbiote-native/engine';
10
10
  import { SINGLE_COLUMN, chunkIntoRows, expandRowViewability, firstItemOfRow, lastItemOfRow, rowKeyExtractor, } from '@symbiote-native/components';
11
11
  import { VirtualizedList, } from '../virtualized-list/index.js';
12
12
  export function FlatList(props) {
@@ -38,7 +38,10 @@ export function FlatList(props) {
38
38
  const resolvedColumnWrapperStyle = typeof columnWrapperStyle === 'string'
39
39
  ? resolveClassName(columnWrapperStyle)
40
40
  : columnWrapperStyle;
41
- const rowStyle = [{ flexDirection: 'row' }, resolvedColumnWrapperStyle];
41
+ const rowStyle = [
42
+ { flexDirection: 'row' },
43
+ resolvedColumnWrapperStyle,
44
+ ];
42
45
  const renderRow = (info) => {
43
46
  const cells = info.item.items.map((item, column) => {
44
47
  const index = info.item.startIndex + column;
@@ -8,7 +8,12 @@ export const InputAccessoryView = rawProps => {
8
8
  // Owns its host element (symbiote-input-accessory-view), so it folds aria/role here; the
9
9
  // resolved accessibility* surface rides the node via `...passthrough`.
10
10
  const { nativeID, backgroundColor, style, children, ...passthrough } = resolveAccessibilityProps(rawProps);
11
- const host = renderInputAccessoryView({ nativeID, backgroundColor, style, passthrough });
11
+ const host = renderInputAccessoryView({
12
+ nativeID,
13
+ backgroundColor,
14
+ style,
15
+ passthrough,
16
+ });
12
17
  // host has no structural children; the user children nest directly under it.
13
18
  return createElement(host.type, { key: host.key, ...host.props }, children);
14
19
  };
@@ -5,13 +5,17 @@
5
5
  //
6
6
  // The inset math + the behavior → style/structure decision are framework-agnostic and live in
7
7
  // @symbiote-native/components (render-keyboard-avoiding-view), shared verbatim with the Vue adapter.
8
- // React supplies only the lifecycle: useState for the inset, useRef for the measured frame, a
9
- // useEffect subscription, and the descriptor-free element assembly around its children.
8
+ // React supplies only the lifecycle: useState for the inset, useRef for the measured frame and
9
+ // the cross-fade setting, the useEffect subscriptions, and the descriptor-free element assembly
10
+ // around its children.
10
11
  import { createElement, useEffect, useRef, useState, } from 'react';
11
- import { dlog } from '@symbiote-native/engine';
12
- import { computeInset, readKeyboardFrame, readLayoutFrame, resolveKeyboardAvoidingLayout, DEFAULT_VERTICAL_OFFSET, } from '@symbiote-native/components';
12
+ import { dlog, Keyboard, Platform, } from '@symbiote-native/engine';
13
+ import { computeInset, keyboardAvoidingEventNamesFor, readKeyboardFrame, readLayoutFrame, readPrefersCrossFadeTransitions, resolveKeyboardAvoidingLayout, DEFAULT_VERTICAL_OFFSET, } from '@symbiote-native/components';
13
14
  import { View } from '../../components.js';
14
- import { Keyboard, KEYBOARD_EVENT } from '../../modules/keyboard.js';
15
+ // The two notifications to subscribe to, per host (iOS takes the `will` pair so the view rides
16
+ // up with the keyboard animation; Android has no will-notifications). Resolved ONCE at module
17
+ // scope: Platform.OS is fixed for the process, so recomputing it per render buys nothing.
18
+ const KEYBOARD_EVENTS = keyboardAvoidingEventNamesFor(Platform.OS);
15
19
  export const KeyboardAvoidingView = props => {
16
20
  const { behavior, enabled = true, keyboardVerticalOffset = DEFAULT_VERTICAL_OFFSET, contentContainerStyle, style, children, onLayout,
17
21
  // The wrapper is the View FC, which runs resolveAccessibilityProps itself, so
@@ -23,27 +27,43 @@ export const KeyboardAvoidingView = props => {
23
27
  // next keyboard event's inset math.
24
28
  const frameRef = useRef(undefined);
25
29
  const initialHeightRef = useRef(undefined);
30
+ // A device accessibility setting, not component state: it cannot change mid-session, and
31
+ // learning it must not re-render. Read once per mount, then fed to every computeInset call.
32
+ const prefersCrossFadeRef = useRef(false);
33
+ useEffect(() => {
34
+ void readPrefersCrossFadeTransitions().then(prefers => {
35
+ prefersCrossFadeRef.current = prefers;
36
+ });
37
+ }, []);
26
38
  useEffect(() => {
27
39
  const onShow = (payload) => {
28
40
  const keyboard = readKeyboardFrame(payload);
29
- const next = computeInset(frameRef.current, keyboard, keyboardVerticalOffset);
30
- dlog(`KeyboardAvoidingView show -> inset ${next}`);
31
- setInset(next);
41
+ // Functional update, not a read of `inset`: this handler is built inside the effect, so a
42
+ // direct read would freeze the value from the render that created it — and 'height' mode
43
+ // feeds the LIVE inset back in to cancel the wrapper's own shrink.
44
+ setInset(previousInset => {
45
+ const next = computeInset(frameRef.current, keyboard, keyboardVerticalOffset, {
46
+ behavior,
47
+ previousInset,
48
+ prefersCrossFadeTransitions: prefersCrossFadeRef.current,
49
+ });
50
+ dlog(`KeyboardAvoidingView show -> inset ${next}`);
51
+ return next;
52
+ });
32
53
  };
33
54
  const onHide = () => {
34
55
  dlog('KeyboardAvoidingView hide -> inset 0');
35
56
  setInset(0);
36
57
  };
37
58
  const subscriptions = [
38
- Keyboard.addListener(KEYBOARD_EVENT.didShow, onShow),
39
- Keyboard.addListener(KEYBOARD_EVENT.didChangeFrame, onShow),
40
- Keyboard.addListener(KEYBOARD_EVENT.didHide, onHide),
59
+ Keyboard.addListener(KEYBOARD_EVENTS.show, onShow),
60
+ Keyboard.addListener(KEYBOARD_EVENTS.hide, onHide),
41
61
  ];
42
62
  return () => {
43
63
  for (const subscription of subscriptions)
44
64
  subscription.remove();
45
65
  };
46
- }, [keyboardVerticalOffset]);
66
+ }, [behavior, keyboardVerticalOffset]);
47
67
  const handleLayout = (event) => {
48
68
  const frame = readLayoutFrame(event.nativeEvent.layout);
49
69
  if (frame !== undefined) {
@@ -1,5 +1,6 @@
1
1
  import { type FC, type ReactNode } from 'react';
2
- import { type IAccessibilityProps, type IAriaProps, type IModalAnimationType, type IModalOrientation, type IModalOrientationChangeEvent, type IModalPresentationStyle } from '@symbiote-native/components';
2
+ import { type ISymbioteEvent } from '@symbiote-native/engine';
3
+ import { type IAccessibilityProps, type IAriaProps, type IModalAnimationType, type IModalOrientation, type IModalPresentationStyle } from '@symbiote-native/components';
3
4
  import type { IStyleProp, IViewStyle } from '../../utils/styles';
4
5
  export type { IModalAnimationType, IModalPresentationStyle, IModalOrientation, IModalOrientationChangeEvent, } from '@symbiote-native/components';
5
6
  export interface IModalProps extends IAccessibilityProps, IAriaProps {
@@ -16,7 +17,7 @@ export interface IModalProps extends IAccessibilityProps, IAriaProps {
16
17
  onShow?: () => void;
17
18
  onDismiss?: () => void;
18
19
  onRequestClose?: () => void;
19
- onOrientationChange?: (event: IModalOrientationChangeEvent) => void;
20
+ onOrientationChange?: (event: ISymbioteEvent) => void;
20
21
  style?: IStyleProp<IViewStyle>;
21
22
  className?: string;
22
23
  children?: ReactNode;
@@ -11,7 +11,7 @@
11
11
  // host's onDismiss prop (it rides `...passthrough`), and the keep-alive holds the node mounted
12
12
  // through the exit transition. The native exit-animation timing is what's deferred, not the
13
13
  // callback contract.
14
- import { createElement, useEffect, useReducer } from 'react';
14
+ import { createElement, useEffect, useReducer, } from 'react';
15
15
  import { dlog } from '@symbiote-native/engine';
16
16
  import { createInitialModalState, modalReducer, renderModal, resolveAccessibilityProps, shouldRenderModal, } from '@symbiote-native/components';
17
17
  export const Modal = rawProps => {
@@ -2,7 +2,7 @@ import { type FC, type ReactNode } from 'react';
2
2
  import { type IPressState, type IPressHandler, type IRectOffset, type IPressableAndroidRippleConfig } from '@symbiote-native/components';
3
3
  import type { IAccessibilityProps, IAriaProps } from '@symbiote-native/components';
4
4
  import type { IStyleProp, IViewStyle } from '../../utils/styles';
5
- export type { IPressState, IPressableAndroidRippleConfig } from '@symbiote-native/components';
5
+ export type { IPressState, IPressableAndroidRippleConfig, } from '@symbiote-native/components';
6
6
  type IPressableStyle = IStyleProp<IViewStyle> | ((state: IPressState) => IStyleProp<IViewStyle>);
7
7
  type IChildrenProp = ReactNode | ((state: IPressState) => ReactNode);
8
8
  export interface IPressableProps extends IAccessibilityProps, IAriaProps {
@@ -11,7 +11,7 @@
11
11
  // entirely in JS: android_ripple (an Android native-feedback prop on a dedicated inner View, inert
12
12
  // on iOS), unstable_pressDelay, and pressRetentionOffset (the drift region), all handled by the
13
13
  // shared machine; here we only wire its host + render the View.
14
- import { createElement, useMemo, useRef, useState } from 'react';
14
+ import { createElement, useMemo, useRef, useState, } from 'react';
15
15
  import { createPressHandlers, createPressRuntime, rippleProps, buildPressableListeners, resolveDisabledAccessibilityState, noteHoverNoop, DEFAULT_DELAY_LONG_PRESS_MS, } from '@symbiote-native/components';
16
16
  import { View } from '../../components.js';
17
17
  function resolveStyle(style, state) {
@@ -6,11 +6,9 @@
6
6
  // android branch (cloneElement(refreshControl, {style}, <ScrollView nestedScrollEnabled
7
7
  // style={flex:1}>{content}</ScrollView>)). Metro picks this on an Android host; no
8
8
  // Platform.OS read.
9
- // device-verify-pending: the wrap shape mirrors RN, proven on a real host by the absence
10
- // of the "addViewAt: failed to insert" crash.
11
- import { cloneElement, createElement, forwardRef, useImperativeHandle, useRef } from 'react';
9
+ import { cloneElement, createElement, forwardRef, useImperativeHandle, useRef, } from 'react';
12
10
  import { dlog } from '@symbiote-native/engine';
13
- import { buildScrollViewHandle, splitLayoutProps } from '@symbiote-native/components';
11
+ import { buildScrollViewHandle, splitLayoutProps, } from '@symbiote-native/components';
14
12
  import { usePreparedScrollView, useNativeStickyScrollAttach, } from './shared.js';
15
13
  export const ScrollView = forwardRef((props, forwardedRef) => {
16
14
  const { scrollViewIntrinsic, scrollViewBaseStyle, outerProps, style, layoutSplitStyle, content, refreshControl, scrollAnimatedValue, nativeStickyAvailable, } = usePreparedScrollView(props);
@@ -23,11 +21,14 @@ export const ScrollView = forwardRef((props, forwardedRef) => {
23
21
  // Drive the sticky scroll value on the native UI thread (RN attachNativeEvent). No-op on a
24
22
  // host without the native animated module. The JS sticky path stays in effect.
25
23
  useNativeStickyScrollAttach(ref, scrollAnimatedValue, nativeStickyAvailable);
26
- dlog('ScrollView.ANDROID refreshControl=' + (refreshControl === undefined ? 'NONE(1child)' : 'WRAP'));
24
+ dlog('ScrollView.ANDROID refreshControl=' +
25
+ (refreshControl === undefined ? 'NONE(1child)' : 'WRAP'));
27
26
  if (refreshControl === undefined) {
28
27
  // Base style (flexDirection: row for horizontal) under user style; undefined base
29
28
  // (vertical) passes the user style through unchanged.
30
- const scrollStyle = scrollViewBaseStyle ? [scrollViewBaseStyle, style] : style;
29
+ const scrollStyle = scrollViewBaseStyle
30
+ ? [scrollViewBaseStyle, style]
31
+ : style;
31
32
  return createElement(scrollViewIntrinsic, { ...outerProps, style: scrollStyle, ref }, content);
32
33
  }
33
34
  // RN splits the flattened style across the two boxes (ScrollView.js android branch):
@@ -42,12 +43,19 @@ export const ScrollView = forwardRef((props, forwardedRef) => {
42
43
  // starves the wrapper of its layout style and it collapses to nothing. layoutSplitStyle is
43
44
  // `style` merged with the resolved className style (see usePreparedScrollView).
44
45
  const { outer: outerStyle, inner: innerStyle } = splitLayoutProps(layoutSplitStyle);
45
- const scrollStyle = scrollViewBaseStyle ? [scrollViewBaseStyle, innerStyle] : innerStyle;
46
+ const scrollStyle = scrollViewBaseStyle
47
+ ? [scrollViewBaseStyle, innerStyle]
48
+ : innerStyle;
46
49
  // className is stripped from the spread here: layoutSplitStyle already folded its resolved
47
50
  // value into `outer`/`inner` above, so forwarding the raw prop too would re-apply its LAYOUT
48
51
  // half onto the inner scroll view a second time (on top of the outer wrapper).
49
52
  const { className: _classNameAppliedViaSplit, ...innerOuterProps } = outerProps;
50
- const scrollView = createElement(scrollViewIntrinsic, { ...innerOuterProps, style: scrollStyle, nestedScrollEnabled: true, ref }, content);
53
+ const scrollView = createElement(scrollViewIntrinsic, {
54
+ ...innerOuterProps,
55
+ style: scrollStyle,
56
+ nestedScrollEnabled: true,
57
+ ref,
58
+ }, content);
51
59
  return cloneElement(refreshControl, { style: outerStyle }, scrollView);
52
60
  });
53
61
  ScrollView.displayName = 'ScrollView';
@@ -1,23 +1,25 @@
1
- // ScrollView on iOS: the RefreshControl is a CHILD of the scroll view, rendered as a
2
- // sibling BEFORE the content container (RN ScrollView.js: {refreshControl}{contentContainer}).
3
- // Also the base (scroll-view.ts re-exports it) for headless / web.
1
+ // ScrollView (iOS): RefreshControl renders as a sibling BEFORE the content container, matching
2
+ // RN's ScrollView.js ({refreshControl}{contentContainer}). Also the base export
3
+ // (scroll-view.ts re-exports it) for headless / web.
4
4
  import { createElement, forwardRef, useImperativeHandle, useRef } from 'react';
5
5
  import { buildScrollViewHandle } from '@symbiote-native/components';
6
6
  import { usePreparedScrollView, useNativeStickyScrollAttach, } from './shared.js';
7
7
  export const ScrollView = forwardRef((props, forwardedRef) => {
8
8
  const { scrollViewIntrinsic, scrollViewBaseStyle, outerProps, style, content, refreshControl, scrollAnimatedValue, nativeStickyAvailable, } = usePreparedScrollView(props);
9
- // The node ref backs the imperative handle; it attaches to the scroll-view element below
10
- // (passing `ref` through createElement props binds it to the SymbioteNode, as TextInput does).
9
+ // Backs the imperative handle; passing `ref` through createElement props binds it to the
10
+ // SymbioteNode below, as TextInput does.
11
11
  const ref = useRef(null);
12
- // Lazy getter, not the ref itself: the node is null until the element commits, so the handle
13
- // must read ref.current on each command, an eager capture would freeze null.
12
+ // Lazy getter: the node is null until commit, so the handle reads ref.current per call
13
+ // instead of capturing eagerly, which would freeze null.
14
14
  useImperativeHandle(forwardedRef, () => buildScrollViewHandle(() => ref.current), []);
15
- // Drive the sticky scroll value on the native UI thread (RN attachNativeEvent). No-op on a
16
- // host without the native animated module. The JS sticky path stays in effect.
15
+ // Drives sticky scroll on the native UI thread (RN attachNativeEvent); no-ops without the
16
+ // native animated module, falling back to the JS sticky path.
17
17
  useNativeStickyScrollAttach(ref, scrollAnimatedValue, nativeStickyAvailable);
18
- // Base style under user style so an explicit user value wins; undefined base (vertical)
18
+ // Base style stays under user style so an explicit value wins; undefined base (vertical)
19
19
  // passes the user style through unchanged.
20
- const scrollStyle = scrollViewBaseStyle ? [scrollViewBaseStyle, style] : style;
20
+ const scrollStyle = scrollViewBaseStyle
21
+ ? [scrollViewBaseStyle, style]
22
+ : style;
21
23
  const scrollProps = { ...outerProps, style: scrollStyle, ref };
22
24
  if (refreshControl === undefined) {
23
25
  return createElement(scrollViewIntrinsic, scrollProps, content);
@@ -14,7 +14,7 @@ import { createElement, useEffect, useRef, useState, } from 'react';
14
14
  import { AnimatedValue, dlog, event as animatedEvent, isClassNameValue, isNativeAnimatedAvailable, resolveClassName, } from '@symbiote-native/engine';
15
15
  import { attachStickyScroll, didContentSizeChange, forwardScrollEvent, readLayoutDimension, resolveDecelerationRate, resolveScrollForwarding, selectScrollIntrinsics, } from '@symbiote-native/components';
16
16
  import { resolveAccessibilityProps, } from '@symbiote-native/components';
17
- import { wrapStickyHeaders } from './sticky-header.js';
17
+ import { wrapStickyHeaders, } from './sticky-header.js';
18
18
  export function usePreparedScrollView(rawProps) {
19
19
  // ScrollView forwards its outer props straight to the native scroll view (not a View
20
20
  // wrapper), so it folds aria/role into accessibility* here before forwarding.
@@ -42,13 +42,56 @@ export function usePreparedScrollView(rawProps) {
42
42
  if (headerLayoutYsRef.current === null)
43
43
  headerLayoutYsRef.current = new Map();
44
44
  const headerLayoutYs = headerLayoutYsRef.current;
45
- const [, bumpHeaderLayout] = useState(0);
45
+ // The mounted headers by child index (RN's _stickyHeaderRefs, ScrollView.js:750), so a fresh
46
+ // measurement reaches its predecessor directly.
47
+ const stickyHeaderRefsRef = useRef(null);
48
+ if (stickyHeaderRefsRef.current === null)
49
+ stickyHeaderRefsRef.current = new Map();
50
+ const stickyHeaderRefs = stickyHeaderRefsRef.current;
51
+ const onHeaderRef = (index, handle) => {
52
+ if (handle === null)
53
+ stickyHeaderRefs.delete(index);
54
+ else
55
+ stickyHeaderRefs.set(index, handle);
56
+ };
57
+ // Measurements waiting to be handed to their predecessors, flushed together (see below).
58
+ const pendingHeaderYsRef = useRef(null);
59
+ if (pendingHeaderYsRef.current === null)
60
+ pendingHeaderYsRef.current = new Map();
61
+ const pendingHeaderYs = pendingHeaderYsRef.current;
62
+ const flushScheduledRef = useRef(false);
63
+ const flushHeaderYs = () => {
64
+ flushScheduledRef.current = false;
65
+ if (stickyHeaderIndices === undefined)
66
+ return;
67
+ for (const [index, y] of pendingHeaderYs) {
68
+ const previousIndex = stickyHeaderIndices[stickyHeaderIndices.indexOf(index) - 1];
69
+ if (previousIndex === undefined)
70
+ continue;
71
+ stickyHeaderRefs.get(previousIndex)?.setNextHeaderY(y);
72
+ }
73
+ pendingHeaderYs.clear();
74
+ };
75
+ // RN _onStickyHeaderLayout (ScrollView.js:1136): record this header's y, then hand it to the
76
+ // PREVIOUS sticky header as its push-off collision point.
77
+ //
78
+ // The handoff is COALESCED rather than immediate, and that is the point. Cross-talk runs BACKWARDS
79
+ // (a header learns its collision point from the one after it), so a mount measures every header
80
+ // and walks the chain end to start. Pushing each value the moment it arrives makes every link its
81
+ // own update generation - a setState on the predecessor, whose effect setStates again - and React
82
+ // caps nested updates at 50: three headers go unnoticed, two hundred abort the app with "Maximum
83
+ // update depth exceeded", which reads like an infinite loop but is really depth. Draining on a
84
+ // microtask puts one burst of layouts into ONE render pass, so depth stops tracking header count.
46
85
  const onHeaderLayoutY = (index, y) => {
47
86
  if (headerLayoutYs.get(index) === y)
48
87
  return;
49
88
  headerLayoutYs.set(index, y);
50
89
  dlog(`ScrollView sticky-header layoutY index=${index} y=${y}`);
51
- bumpHeaderLayout(tick => tick + 1);
90
+ pendingHeaderYs.set(index, y);
91
+ if (flushScheduledRef.current)
92
+ return;
93
+ flushScheduledRef.current = true;
94
+ void Promise.resolve().then(flushHeaderYs);
52
95
  };
53
96
  // A class-name string resolves through the shared registry before it reaches the
54
97
  // framework-agnostic selector below, which only understands style objects/arrays.
@@ -58,7 +101,7 @@ export function usePreparedScrollView(rawProps) {
58
101
  // The per-axis intrinsics, base style, and content style come from the shared selector
59
102
  // (@symbiote-native/components): on Android horizontal resolves to its own ViewManager, on iOS both
60
103
  // map back to RCTScrollView; here we only pass the axis.
61
- const { scrollViewIntrinsic, contentIntrinsic, scrollViewBaseStyle, contentStyle } = selectScrollIntrinsics(isHorizontal, resolvedContentContainerStyle);
104
+ const { scrollViewIntrinsic, contentIntrinsic, scrollViewBaseStyle, contentStyle, } = selectScrollIntrinsics(isHorizontal, resolvedContentContainerStyle);
62
105
  const outerProps = { ...outer };
63
106
  // className is pulled out above (unlike the rest of `...outer`) so `layoutSplitStyle` below can
64
107
  // resolve it eagerly; re-added here so the simple single-node paths (iOS, Android with no
@@ -121,7 +164,10 @@ export function usePreparedScrollView(rawProps) {
121
164
  // _handleContentOnLayout): read width/height off nativeEvent.layout and fire only when the
122
165
  // size actually changed (dedupe via a ref, like RN). Composed with any content onLayout.
123
166
  const lastContentSizeRef = useRef(null);
124
- const contentProps = { style: contentStyle, collapsable: false };
167
+ const contentProps = {
168
+ style: contentStyle,
169
+ collapsable: false,
170
+ };
125
171
  // maintainVisibleContentPosition (and Android snapToAlignment) anchor against the metrics
126
172
  // of MOUNTED cell views. Android Fabric view-flattens layout-only cells away, so the native
127
173
  // MaintainVisibleScrollPositionHelper has nothing to anchor to and the list jumps on prepend.
@@ -144,10 +190,10 @@ export function usePreparedScrollView(rawProps) {
144
190
  onContentSizeChange(width, height);
145
191
  };
146
192
  }
147
- // Sticky headers are a pure-JS layer (the native scroll view ignores stickyHeaderIndices);
148
- // wrap the flagged children so they pin to the scroll offset. No-op when none are flagged.
193
+ // Wrap the flagged children so they pin to the scroll offset (see the sticky-headers note
194
+ // above); no-op when none are flagged.
149
195
  const contentChildren = hasStickyHeaders
150
- ? wrapStickyHeaders(children, stickyHeaderIndices, scrollAnimatedValue, invertStickyHeaders, viewportHeight, StickyHeaderComponent, headerLayoutYs, onHeaderLayoutY)
196
+ ? wrapStickyHeaders(children, stickyHeaderIndices, scrollAnimatedValue, invertStickyHeaders, viewportHeight, StickyHeaderComponent, { headerLayoutYs, onHeaderLayoutY, onHeaderRef })
151
197
  : children;
152
198
  // `collapsable: false` is load-bearing on Android. The content container is a
153
199
  // layout-only View, which Android Fabric view-flattens away, hoisting the cells
@@ -159,7 +205,10 @@ export function usePreparedScrollView(rawProps) {
159
205
  const content = createElement(contentIntrinsic, contentProps, contentChildren);
160
206
  // resolveClassName(undefined) is a cheap {} no-op, so this is safe with no className too.
161
207
  const resolvedClassName = isClassNameValue(className) ? className : undefined;
162
- const layoutSplitStyle = [resolveClassName(resolvedClassName), style];
208
+ const layoutSplitStyle = [
209
+ resolveClassName(resolvedClassName),
210
+ style,
211
+ ];
163
212
  return {
164
213
  scrollViewIntrinsic,
165
214
  scrollViewBaseStyle,
@@ -1,9 +1,18 @@
1
- import { type ComponentType, type ReactNode } from 'react';
1
+ import { type ComponentType, type ReactNode, type Ref } from 'react';
2
2
  import { AnimatedValue } from '@symbiote-native/engine';
3
3
  import { type IStickyHeaderProps } from '@symbiote-native/components';
4
+ export type IStickyHeaderHandle = {
5
+ setNextHeaderY: (y: number) => void;
6
+ };
7
+ export type IStickyCrossTalk = {
8
+ headerLayoutYs: ReadonlyMap<number, number>;
9
+ onHeaderLayoutY: (index: number, y: number) => void;
10
+ onHeaderRef: (index: number, handle: IStickyHeaderHandle | null) => void;
11
+ };
4
12
  export type IStickyHeaderComponentProps = IStickyHeaderProps & {
5
13
  children?: ReactNode;
14
+ ref?: Ref<IStickyHeaderHandle>;
6
15
  };
7
16
  export type IStickyHeaderComponentType = ComponentType<IStickyHeaderComponentProps>;
8
17
  export declare const ScrollViewStickyHeader: IStickyHeaderComponentType;
9
- export declare function wrapStickyHeaders(children: ReactNode, stickyHeaderIndices: number[] | undefined, scrollAnimatedValue: AnimatedValue, invertStickyHeaders: boolean | undefined, scrollViewHeight: number | undefined, StickyHeaderComponent: IStickyHeaderComponentType | undefined, headerLayoutYs: ReadonlyMap<number, number>, onHeaderLayoutY: (index: number, y: number) => void): ReactNode;
18
+ export declare function wrapStickyHeaders(children: ReactNode, stickyHeaderIndices: number[] | undefined, scrollAnimatedValue: AnimatedValue, invertStickyHeaders: boolean | undefined, scrollViewHeight: number | undefined, StickyHeaderComponent: IStickyHeaderComponentType | undefined, crossTalk: IStickyCrossTalk): ReactNode;