@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
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
@@ -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 {
@@ -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,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;
@@ -7,7 +7,7 @@
7
7
  // layer instead. The interpolation mirrors ScrollViewStickyHeader.js and now lives,
8
8
  // framework-agnostic, in @symbiote-native/components (computeStickyInterpolation); this file
9
9
  // holds only the React component shell, layout state, and child-wrapping.
10
- import { Children, createElement, isValidElement, useCallback, useEffect, useReducer, useRef, useState, } from 'react';
10
+ import { Children, createElement, isValidElement, useCallback, useEffect, useImperativeHandle, useReducer, useRef, useState, } from 'react';
11
11
  import { AnimatedInterpolation, AnimatedValue, Platform, dlog, } from '@symbiote-native/engine';
12
12
  import { createInitialStickyState, nextStickyHeaderY, readLayoutNumber, reduceSticky, STICKY_HEADER_Z_INDEX, } from '@symbiote-native/components';
13
13
  import { Animated } from '../../modules/animated/index.js';
@@ -30,7 +30,24 @@ function firstChild(children) {
30
30
  // (@symbiote-native/components); this component supplies only the React lifecycle: the ONE folded
31
31
  // state cell, the interpolation-node + listener wiring, the debounce setTimeout, and the re-render.
32
32
  export const ScrollViewStickyHeader = props => {
33
- const { inverted, scrollViewHeight, scrollAnimatedValue, nextHeaderLayoutY, children } = props;
33
+ const { inverted, scrollViewHeight, scrollAnimatedValue, nextHeaderLayoutY, children, ref, } = props;
34
+ // The next header's y lives HERE, not in a prop the parent re-renders to update (RN keeps it in
35
+ // the child too, ScrollViewStickyHeader.js:65). The prop only seeds the first render; afterwards
36
+ // the ScrollView pushes updates through setNextHeaderY. That keeps the cost linear - a
37
+ // parent-state round trip re-renders every child on every header's layout, so N headers cost N
38
+ // re-renders of N children. At N=200 it churned enough native animated nodes to trip Fabric's view
39
+ // registry ("Attempt to dequeue already registered component") seconds after mount.
40
+ const [nextHeaderY, setNextHeaderY] = useState(nextHeaderLayoutY);
41
+ useImperativeHandle(ref, () => ({ setNextHeaderY }), []);
42
+ // The imperative push is a fast path, not the source of truth. When the parent DOES re-render
43
+ // with a different value the prop wins, because the successor may be gone: a VirtualizedList
44
+ // unmounts headers as they scroll out, and a value pushed in earlier would otherwise stick
45
+ // around as a collision point that no longer exists.
46
+ const lastNextHeaderProp = useRef(nextHeaderLayoutY);
47
+ if (lastNextHeaderProp.current !== nextHeaderLayoutY) {
48
+ lastNextHeaderProp.current = nextHeaderLayoutY;
49
+ setNextHeaderY(nextHeaderLayoutY);
50
+ }
34
51
  // The one folded state cell (RN's scattered useState/useRef collapsed into IStickyHeaderState),
35
52
  // mutated in place by reduceSticky. Lazily created once.
36
53
  const stateRef = useRef(null);
@@ -39,7 +56,10 @@ export const ScrollViewStickyHeader = props => {
39
56
  // The animated node that drives the transform (RN's animatedTranslateY), rebuilt by the
40
57
  // rebuild-interpolation effect. When the scroll value is native, this interpolation runs on the
41
58
  // UI thread: the smooth pin.
42
- const [animatedTranslateY, setAnimatedTranslateY] = useState(() => scrollAnimatedValue.interpolate({ inputRange: [-1, 0], outputRange: [0, 0] }));
59
+ const [animatedTranslateY, setAnimatedTranslateY] = useState(() => scrollAnimatedValue.interpolate({
60
+ inputRange: [-1, 0],
61
+ outputRange: [0, 0],
62
+ }));
43
63
  const debounceTimer = useRef(null);
44
64
  // The current interpolation node + its settled-value listener id, held so the next rebuild can
45
65
  // detach the old listener (engine calls the reducer does NOT own) and unmount can clean up.
@@ -49,9 +69,14 @@ export const ScrollViewStickyHeader = props => {
49
69
  os: Platform.OS,
50
70
  inverted,
51
71
  scrollViewHeight,
52
- nextHeaderLayoutY,
72
+ nextHeaderLayoutY: nextHeaderY,
53
73
  });
54
- inputsRef.current = { os: Platform.OS, inverted, scrollViewHeight, nextHeaderLayoutY };
74
+ inputsRef.current = {
75
+ os: Platform.OS,
76
+ inverted,
77
+ scrollViewHeight,
78
+ nextHeaderLayoutY: nextHeaderY,
79
+ };
55
80
  // dispatch reaches through a ref because the effect executors dispatch follow-up actions
56
81
  // (the listener -> animated-tick, the debounce timer -> debounce-fired).
57
82
  const dispatchRef = useRef(() => { });
@@ -85,7 +110,10 @@ export const ScrollViewStickyHeader = props => {
85
110
  clearTimeout(debounceTimer.current);
86
111
  debounceTimer.current = setTimeout(() => {
87
112
  debounceTimer.current = null;
88
- dispatchRef.current({ kind: 'debounce-fired', value: effect.value });
113
+ dispatchRef.current({
114
+ kind: 'debounce-fired',
115
+ value: effect.value,
116
+ });
89
117
  }, effect.delay);
90
118
  break;
91
119
  }
@@ -110,7 +138,7 @@ export const ScrollViewStickyHeader = props => {
110
138
  // dispatches 'layout' itself); also does the initial identity build on mount.
111
139
  useEffect(() => {
112
140
  dispatchRef.current({ kind: 'inputs-changed' });
113
- }, [inverted, scrollViewHeight, nextHeaderLayoutY, scrollAnimatedValue]);
141
+ }, [inverted, scrollViewHeight, nextHeaderY, scrollAnimatedValue]);
114
142
  // Detach the listener + clear the debounce on unmount.
115
143
  useEffect(() => () => {
116
144
  const previous = interpolationRef.current;
@@ -123,7 +151,11 @@ export const ScrollViewStickyHeader = props => {
123
151
  const y = readLayoutNumber(event, 'y');
124
152
  const height = readLayoutNumber(event, 'height');
125
153
  // Keep the previous value when a field is absent (RN sets state only on a defined read).
126
- dispatch({ kind: 'layout', y: y ?? state.layoutY, height: height ?? state.layoutHeight });
154
+ dispatch({
155
+ kind: 'layout',
156
+ y: y ?? state.layoutY,
157
+ height: height ?? state.layoutHeight,
158
+ });
127
159
  props.onLayout(event);
128
160
  const child = firstChild(children);
129
161
  const childOnLayout = child === undefined ? undefined : readChildOnLayout(child);
@@ -131,12 +163,17 @@ export const ScrollViewStickyHeader = props => {
131
163
  };
132
164
  // The EXPLICIT debounced translateY overrides the committed transform for hit-testing, while
133
165
  // `animatedTranslateY` does the smooth (native-driven) pin, per RN ScrollViewStickyHeader.js.
134
- const passthroughAnimatedPropExplicitValues = state.translateY !== null ? { style: { transform: [{ translateY: state.translateY }] } } : null;
166
+ const passthroughAnimatedPropExplicitValues = state.translateY !== null
167
+ ? { style: { transform: [{ translateY: state.translateY }] } }
168
+ : null;
135
169
  // collapsable:false keeps the wrapper a real Yoga node; zIndex makes the pinned header paint
136
170
  // OVER the rows scrolling under it. `style` is `unknown` on Animated.View, so the interpolation
137
171
  // transform passes with no cast.
138
172
  return createElement(Animated.View, {
139
- style: { transform: [{ translateY: animatedTranslateY }], zIndex: STICKY_HEADER_Z_INDEX },
173
+ style: {
174
+ transform: [{ translateY: animatedTranslateY }],
175
+ zIndex: STICKY_HEADER_Z_INDEX,
176
+ },
140
177
  onLayout,
141
178
  collapsable: false,
142
179
  passthroughAnimatedPropExplicitValues,
@@ -153,7 +190,8 @@ ScrollViewStickyHeader.displayName = 'ScrollViewStickyHeader';
153
190
  // header (the collision point past which it scrolls off) by looking up its successor's index in
154
191
  // `stickyHeaderIndices`. The LAST flagged header has no successor, so its `nextHeaderLayoutY`
155
192
  // stays undefined and it sticks indefinitely (correct).
156
- export function wrapStickyHeaders(children, stickyHeaderIndices, scrollAnimatedValue, invertStickyHeaders, scrollViewHeight, StickyHeaderComponent, headerLayoutYs, onHeaderLayoutY) {
193
+ export function wrapStickyHeaders(children, stickyHeaderIndices, scrollAnimatedValue, invertStickyHeaders, scrollViewHeight, StickyHeaderComponent, crossTalk) {
194
+ const { headerLayoutYs, onHeaderLayoutY, onHeaderRef } = crossTalk;
157
195
  if (stickyHeaderIndices === undefined || stickyHeaderIndices.length === 0)
158
196
  return children;
159
197
  const Wrapper = StickyHeaderComponent ?? ScrollViewStickyHeader;
@@ -168,6 +206,10 @@ export function wrapStickyHeaders(children, stickyHeaderIndices, scrollAnimatedV
168
206
  dlog(`ScrollView sticky-header wrap index=${index} next=${nextIndex} nextY=${nextHeaderLayoutY}`);
169
207
  return createElement(Wrapper, {
170
208
  key: child.key ?? `sticky-${index}`,
209
+ // Registers this header so the ScrollView can push the NEXT header's y straight in
210
+ // (RN ScrollView.js:1109). Without it a measurement would have to travel through parent
211
+ // state, re-rendering every child.
212
+ ref: (handle) => onHeaderRef(index, handle),
171
213
  nextHeaderLayoutY,
172
214
  // RN _onStickyHeaderLayout: record this header's own y, then push it to the previous
173
215
  // header as its nextHeaderLayoutY. We record into the parent map; the lookup above feeds
@@ -10,7 +10,7 @@
10
10
  // (setTextAndSelection), never a plain prop re-push. See @symbiote-native/components/state/text-input.
11
11
  import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState, } from 'react';
12
12
  import { dispatchViewCommand, dlog, } from '@symbiote-native/engine';
13
- import { blurTextInput, setInputBlurred, setInputFocused } from '@symbiote-native/engine';
13
+ import { blurTextInput, setInputBlurred, setInputFocused, } from '@symbiote-native/engine';
14
14
  import { resolveAccessibilityProps, resolveTextInputProps, renderTextInput, foldText, textFromChange, eventCountFromChange, shouldCommandText, INITIAL_EVENT_COUNT, SELECTION_NONE, } from '@symbiote-native/components';
15
15
  import { descriptorToReact } from '../../descriptor-to-react/index.js';
16
16
  export const TextInput = forwardRef((rawProps, forwardedRef) => {
@@ -132,7 +132,12 @@ export const TextInput = forwardRef((rawProps, forwardedRef) => {
132
132
  const node = ref.current;
133
133
  if (node === null)
134
134
  return;
135
- dispatchViewCommand(node, 'setTextAndSelection', [mostRecentEventCount, '', 0, 0]);
135
+ dispatchViewCommand(node, 'setTextAndSelection', [
136
+ mostRecentEventCount,
137
+ '',
138
+ 0,
139
+ 0,
140
+ ]);
136
141
  lastNativeText.current = '';
137
142
  },
138
143
  isFocused: () => focused.current,
@@ -13,6 +13,8 @@ export declare const TouchableOpacity: FC<ITouchableOpacityProps>;
13
13
  export interface ITouchableHighlightProps extends ITouchableBaseProps {
14
14
  activeOpacity?: number;
15
15
  underlayColor?: string;
16
+ onShowUnderlay?: () => void;
17
+ onHideUnderlay?: () => void;
16
18
  }
17
19
  export declare const TouchableHighlight: FC<ITouchableHighlightProps>;
18
20
  export type ITouchableWithoutFeedbackProps = ITouchableBaseProps;