@symbiote-native/react 0.3.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 (44) hide show
  1. package/README.md +24 -9
  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 +2 -1
  10. package/build/components/pressable/index.js +17 -5
  11. package/build/components/safe-area-view/index.d.ts +1 -0
  12. package/build/components/scroll-view/index.android.js +16 -6
  13. package/build/components/scroll-view/index.ios.js +3 -1
  14. package/build/components/scroll-view/shared.js +56 -7
  15. package/build/components/scroll-view/sticky-header.d.ts +11 -2
  16. package/build/components/scroll-view/sticky-header.js +53 -11
  17. package/build/components/text-input/index.js +15 -2
  18. package/build/components/touchable/index.d.ts +2 -0
  19. package/build/components/touchable/index.js +131 -29
  20. package/build/components/touchable-native-feedback/index.js +1 -1
  21. package/build/components/virtualized-list/index.js +64 -24
  22. package/build/components/virtualized-section-list/index.d.ts +5 -0
  23. package/build/components/virtualized-section-list/index.js +24 -4
  24. package/build/components.d.ts +4 -4
  25. package/build/components.js +19 -17
  26. package/build/create-portal/index.js +1 -1
  27. package/build/create-tunnel/index.js +1 -1
  28. package/build/hooks/use-window-dimensions.js +1 -1
  29. package/build/host-config.d.ts +2 -2
  30. package/build/host-config.js +42 -11
  31. package/build/host-instance.js +1 -1
  32. package/build/index.d.ts +8 -7
  33. package/build/index.js +1 -1
  34. package/build/jsx.d.ts +18 -0
  35. package/build/jsx.js +1 -0
  36. package/build/modules/animated/create-animated-component.d.ts +2 -1
  37. package/build/modules/animated/create-animated-component.js +10 -2
  38. package/build/modules/animated/index.d.ts +1 -1
  39. package/build/modules/animated/index.js +1 -1
  40. package/build/modules/status-bar/index.ios.js +1 -1
  41. package/build/render.js +16 -8
  42. package/build/state-style.d.ts +1 -0
  43. package/build/state-style.js +13 -0
  44. package/package.json +24 -8
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,9 +54,24 @@ 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
58
- calls it with the surface's `rootTag`, and the renderer takes over from there — `nativeFabric-
59
- UIManager` is driven directly, RN's own renderer never runs.
57
+ The zero-config entry wires the RN-backed host seams (colors, images, device events,
58
+ third-party ViewConfigs) and registers the app in one call — this is what
59
+ [`examples/react`](../../examples/react) actually uses:
60
+
61
+ ```js
62
+ // index.js
63
+ import { registerApp } from '@symbiote-native/react/bootstrap';
64
+ import App from './App';
65
+ import { name as appName } from './app.json';
66
+
67
+ registerApp(App, { appName });
68
+ ```
69
+
70
+ `registerApp` mirrors bare RN's own `AppRegistry.registerComponent(appName, () => App)` idiom,
71
+ minus the manual host wiring in front of it — RN's own renderer never runs; `nativeFabric-
72
+ UIManager` is driven directly. For anything the defaults don't cover (a custom color processor,
73
+ image resolver, or manual control over the mount callback), drive the lower-level seam directly —
74
+ a raw _runnable_ instead of a registered component:
60
75
 
61
76
  ```js
62
77
  // index.js
@@ -77,8 +92,8 @@ AppRegistry.registerRunnable(appName, ({ rootTag }) => {
77
92
  });
78
93
  ```
79
94
 
80
- The runnable example is [`examples/react`](../../examples/react) — a stock RN 0.86 app whose full
81
- canary ([`App.tsx`](../../examples/react/App.tsx)) exercises every block of the surface below.
95
+ Either way, the full canary ([`App.tsx`](../../examples/react/App.tsx)) exercises every block of
96
+ the surface below.
82
97
 
83
98
  ---
84
99
 
@@ -94,7 +109,7 @@ path:
94
109
  `Appearance` · `PixelRatio` · `AppState`, plus imperative `Alert` · `ActionSheetIOS` · `Share` ·
95
110
  `Linking` · `Vibration` · `Keyboard` · `StatusBar` — each reaching its real native module on the
96
111
  bridgeless host.
97
- - **`Animated`, both drivers** — JS *and* native driver side by side (`timing` · `spring` · `loop` ·
112
+ - **`Animated`, both drivers** — JS _and_ native driver side by side (`timing` · `spring` · `loop` ·
98
113
  `interpolate` · `ValueXY` · tracking · `diffClamp`). Native offload is proven by jamming the JS
99
114
  thread 1.5 s: the native-driven animations keep moving, the JS-driven one stalls.
100
115
  - **Third-party native views** — `@react-native-community/slider` used straight from the package
@@ -4,7 +4,7 @@
4
4
  // "./bootstrap" export) because it imports react-native directly, which Vitest's Flow-unaware
5
5
  // transform can't parse (see @symbiote-native/components/bootstrap for the full reason).
6
6
  import { AppRegistry as RNAppRegistry } from 'react-native';
7
- import { bootstrapHost } from '@symbiote-native/components/bootstrap';
7
+ import { bootstrapHost, } from '@symbiote-native/components/bootstrap';
8
8
  import { AppRegistry, setHostRegistrar } from './modules/app-registry/index.js';
9
9
  // Mirrors bare RN's own `AppRegistry.registerComponent(appName, () => App)` idiom, minus the
10
10
  // manual host wiring in front of it.
@@ -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 '@symbiote-native/engine';
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) {
@@ -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 {
@@ -28,3 +28,4 @@ export interface IPressableProps extends IAccessibilityProps, IAriaProps {
28
28
  children?: IChildrenProp;
29
29
  }
30
30
  export declare const Pressable: FC<IPressableProps>;
31
+ export declare const TouchablePressable: FC<IPressableProps>;
@@ -11,9 +11,10 @@
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';
15
- import { createPressHandlers, createPressRuntime, rippleProps, buildPressableListeners, resolveDisabledAccessibilityState, noteHoverNoop, DEFAULT_DELAY_LONG_PRESS_MS, } from '@symbiote-native/components';
14
+ import { createElement, useEffect, useMemo, useRef, useState, } from 'react';
15
+ import { createPressHandlers, createPressRuntime, disposePressRuntime, rippleProps, buildPressableListeners, resolveDisabledAccessibilityState, noteHoverNoop, DEFAULT_DELAY_LONG_PRESS_MS, DEFAULT_MIN_PRESS_DURATION_MS, } from '@symbiote-native/components';
16
16
  import { View } from '../../components.js';
17
+ import { flushExternalUpdate } from '../../host-config.js';
17
18
  function resolveStyle(style, state) {
18
19
  if (typeof style === 'function')
19
20
  return style(state);
@@ -24,8 +25,8 @@ function resolveChildren(children, state) {
24
25
  return children(state);
25
26
  return children;
26
27
  }
27
- export const Pressable = props => {
28
- const { onPress, onPressIn, onPressOut, onPressMove, onLongPress, delayLongPress = DEFAULT_DELAY_LONG_PRESS_MS, disabled, cancelable, hitSlop, pressRetentionOffset, unstable_pressDelay = 0, android_ripple, android_disableSound, onHoverIn, onHoverOut, delayHoverIn, delayHoverOut, accessibilityState, testID, style, children,
28
+ const PressableImpl = props => {
29
+ const { onPress, onPressIn, onPressOut, onPressMove, onLongPress, delayLongPress = DEFAULT_DELAY_LONG_PRESS_MS, disabled, cancelable, hitSlop, pressRetentionOffset, unstable_pressDelay = 0, __minPressDuration = DEFAULT_MIN_PRESS_DURATION_MS, android_ripple, android_disableSound, onHoverIn, onHoverOut, delayHoverIn, delayHoverOut, accessibilityState, testID, style, children,
29
30
  // The remaining accessibility / aria props forward to View untouched; View runs
30
31
  // resolveAccessibilityProps, so aria/role fold there, once.
31
32
  ...accessibilityRest } = props;
@@ -45,10 +46,16 @@ export const Pressable = props => {
45
46
  return callback => view.measure(callback);
46
47
  },
47
48
  schedule: (callback, ms) => {
48
- const id = setTimeout(callback, ms);
49
+ const id = setTimeout(() => flushExternalUpdate(callback), ms);
49
50
  return () => clearTimeout(id);
50
51
  },
52
+ now: Date.now,
51
53
  }), []);
54
+ // This renderer tears insertion effects down during host updates, so use the stable passive
55
+ // unmount seam to cancel delayed press-in, long-press and press-out callbacks.
56
+ useEffect(() => () => {
57
+ disposePressRuntime(runtime);
58
+ }, [runtime]);
52
59
  const handlers = useMemo(() => createPressHandlers({
53
60
  onPress,
54
61
  onPressIn,
@@ -57,6 +64,7 @@ export const Pressable = props => {
57
64
  onLongPress,
58
65
  delayLongPress,
59
66
  unstable_pressDelay,
67
+ minPressDuration: __minPressDuration,
60
68
  hitSlop,
61
69
  pressRetentionOffset,
62
70
  }, runtime, host), [
@@ -67,6 +75,7 @@ export const Pressable = props => {
67
75
  onLongPress,
68
76
  delayLongPress,
69
77
  unstable_pressDelay,
78
+ __minPressDuration,
70
79
  hitSlop,
71
80
  pressRetentionOffset,
72
81
  runtime,
@@ -98,3 +107,6 @@ export const Pressable = props => {
98
107
  const inner = ripple !== undefined ? createElement(View, ripple, content) : content;
99
108
  return createElement(View, viewProps, inner);
100
109
  };
110
+ export const Pressable = PressableImpl;
111
+ // Relative-only composition seam; it is deliberately absent from the adapter barrel.
112
+ export const TouchablePressable = props => createElement(PressableImpl, { ...props, __minPressDuration: 0 });
@@ -4,6 +4,7 @@ import { type IAccessibilityProps, type IAriaProps } from '@symbiote-native/comp
4
4
  import type { IStyleProp, IViewStyle } from '../../utils/styles';
5
5
  export interface ISafeAreaViewProps extends IAccessibilityProps, IAriaProps {
6
6
  style?: IStyleProp<IViewStyle>;
7
+ id?: string;
7
8
  className?: string;
8
9
  children?: ReactNode;
9
10
  onLayout?: (event: ISymbioteEvent) => void;
@@ -6,9 +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
- import { cloneElement, createElement, forwardRef, useImperativeHandle, useRef } from 'react';
9
+ import { cloneElement, createElement, forwardRef, useImperativeHandle, useRef, } from 'react';
10
10
  import { dlog } from '@symbiote-native/engine';
11
- import { buildScrollViewHandle, splitLayoutProps } from '@symbiote-native/components';
11
+ import { buildScrollViewHandle, splitLayoutProps, } from '@symbiote-native/components';
12
12
  import { usePreparedScrollView, useNativeStickyScrollAttach, } from './shared.js';
13
13
  export const ScrollView = forwardRef((props, forwardedRef) => {
14
14
  const { scrollViewIntrinsic, scrollViewBaseStyle, outerProps, style, layoutSplitStyle, content, refreshControl, scrollAnimatedValue, nativeStickyAvailable, } = usePreparedScrollView(props);
@@ -21,11 +21,14 @@ export const ScrollView = forwardRef((props, forwardedRef) => {
21
21
  // Drive the sticky scroll value on the native UI thread (RN attachNativeEvent). No-op on a
22
22
  // host without the native animated module. The JS sticky path stays in effect.
23
23
  useNativeStickyScrollAttach(ref, scrollAnimatedValue, nativeStickyAvailable);
24
- dlog('ScrollView.ANDROID refreshControl=' + (refreshControl === undefined ? 'NONE(1child)' : 'WRAP'));
24
+ dlog('ScrollView.ANDROID refreshControl=' +
25
+ (refreshControl === undefined ? 'NONE(1child)' : 'WRAP'));
25
26
  if (refreshControl === undefined) {
26
27
  // Base style (flexDirection: row for horizontal) under user style; undefined base
27
28
  // (vertical) passes the user style through unchanged.
28
- const scrollStyle = scrollViewBaseStyle ? [scrollViewBaseStyle, style] : style;
29
+ const scrollStyle = scrollViewBaseStyle
30
+ ? [scrollViewBaseStyle, style]
31
+ : style;
29
32
  return createElement(scrollViewIntrinsic, { ...outerProps, style: scrollStyle, ref }, content);
30
33
  }
31
34
  // RN splits the flattened style across the two boxes (ScrollView.js android branch):
@@ -40,12 +43,19 @@ export const ScrollView = forwardRef((props, forwardedRef) => {
40
43
  // starves the wrapper of its layout style and it collapses to nothing. layoutSplitStyle is
41
44
  // `style` merged with the resolved className style (see usePreparedScrollView).
42
45
  const { outer: outerStyle, inner: innerStyle } = splitLayoutProps(layoutSplitStyle);
43
- const scrollStyle = scrollViewBaseStyle ? [scrollViewBaseStyle, innerStyle] : innerStyle;
46
+ const scrollStyle = scrollViewBaseStyle
47
+ ? [scrollViewBaseStyle, innerStyle]
48
+ : innerStyle;
44
49
  // className is stripped from the spread here: layoutSplitStyle already folded its resolved
45
50
  // value into `outer`/`inner` above, so forwarding the raw prop too would re-apply its LAYOUT
46
51
  // half onto the inner scroll view a second time (on top of the outer wrapper).
47
52
  const { className: _classNameAppliedViaSplit, ...innerOuterProps } = outerProps;
48
- 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);
49
59
  return cloneElement(refreshControl, { style: outerStyle }, scrollView);
50
60
  });
51
61
  ScrollView.displayName = 'ScrollView';
@@ -17,7 +17,9 @@ export const ScrollView = forwardRef((props, forwardedRef) => {
17
17
  useNativeStickyScrollAttach(ref, scrollAnimatedValue, nativeStickyAvailable);
18
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.
@@ -147,7 +193,7 @@ export function usePreparedScrollView(rawProps) {
147
193
  // Wrap the flagged children so they pin to the scroll offset (see the sticky-headers note
148
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;