@symbiote-native/react 0.2.7 → 0.3.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 (68) hide show
  1. package/build/bootstrap.js +4 -5
  2. package/build/components/activity-indicator/index.android.d.ts +2 -1
  3. package/build/components/activity-indicator/index.android.js +14 -10
  4. package/build/components/activity-indicator/index.ios.d.ts +2 -1
  5. package/build/components/activity-indicator/index.ios.js +9 -3
  6. package/build/components/activity-indicator/shared.d.ts +1 -2
  7. package/build/components/activity-indicator/shared.js +13 -11
  8. package/build/components/keyboard-avoiding-view/index.js +1 -1
  9. package/build/components/modal/index.d.ts +3 -2
  10. package/build/components/scroll-view/index.android.js +0 -2
  11. package/build/components/scroll-view/index.ios.js +10 -10
  12. package/build/components/scroll-view/shared.js +28 -32
  13. package/build/components/scroll-view/sticky-header.js +98 -74
  14. package/build/components/section-list/index.js +4 -6
  15. package/build/components/switch/index.android.d.ts +2 -1
  16. package/build/components/switch/index.android.js +9 -3
  17. package/build/components/switch/index.ios.d.ts +2 -1
  18. package/build/components/switch/index.ios.js +9 -3
  19. package/build/components/switch/shared.d.ts +2 -3
  20. package/build/components/switch/shared.js +44 -43
  21. package/build/components/text-input/index.js +1 -1
  22. package/build/components/touchable/index.js +22 -54
  23. package/build/components/virtualized-list/index.js +219 -305
  24. package/build/components/virtualized-section-list/index.js +2 -4
  25. package/build/create-tunnel/index.js +14 -20
  26. package/build/hooks/use-color-scheme.d.ts +1 -1
  27. package/build/hooks/use-color-scheme.js +1 -1
  28. package/build/hooks/use-window-dimensions.d.ts +1 -1
  29. package/build/hooks/use-window-dimensions.js +1 -1
  30. package/build/host-instance.js +5 -6
  31. package/build/index.d.ts +6 -31
  32. package/build/index.js +15 -25
  33. package/build/modules/animated/create-animated-component.js +24 -65
  34. package/build/render.js +28 -2
  35. package/metro-css-parser.cjs +7 -12
  36. package/package.json +4 -4
  37. package/build/modules/action-sheet-ios.d.ts +0 -2
  38. package/build/modules/action-sheet-ios.js +0 -4
  39. package/build/modules/alert.d.ts +0 -2
  40. package/build/modules/alert.js +0 -5
  41. package/build/modules/app-state.d.ts +0 -2
  42. package/build/modules/app-state.js +0 -3
  43. package/build/modules/appearance.d.ts +0 -2
  44. package/build/modules/appearance.js +0 -3
  45. package/build/modules/back-handler.d.ts +0 -2
  46. package/build/modules/back-handler.js +0 -3
  47. package/build/modules/dimensions.d.ts +0 -2
  48. package/build/modules/dimensions.js +0 -3
  49. package/build/modules/i18n-manager.d.ts +0 -2
  50. package/build/modules/i18n-manager.js +0 -4
  51. package/build/modules/keyboard.d.ts +0 -2
  52. package/build/modules/keyboard.js +0 -3
  53. package/build/modules/layout-animation.d.ts +0 -2
  54. package/build/modules/layout-animation.js +0 -3
  55. package/build/modules/linking.d.ts +0 -2
  56. package/build/modules/linking.js +0 -4
  57. package/build/modules/permissions-android.d.ts +0 -2
  58. package/build/modules/permissions-android.js +0 -3
  59. package/build/modules/settings.d.ts +0 -1
  60. package/build/modules/settings.js +0 -4
  61. package/build/modules/share.d.ts +0 -2
  62. package/build/modules/share.js +0 -4
  63. package/build/modules/text-input-state.d.ts +0 -1
  64. package/build/modules/text-input-state.js +0 -3
  65. package/build/modules/toast-android.d.ts +0 -1
  66. package/build/modules/toast-android.js +0 -4
  67. package/build/modules/vibration.d.ts +0 -1
  68. package/build/modules/vibration.js +0 -4
@@ -1,9 +1,8 @@
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
7
  import { bootstrapHost } from '@symbiote-native/components/bootstrap';
9
8
  import { AppRegistry, setHostRegistrar } from './modules/app-registry/index.js';
@@ -1,2 +1,3 @@
1
+ import type { IActivityIndicatorProps } from './shared';
1
2
  export type { IActivityIndicatorProps } from './shared';
2
- export declare const ActivityIndicator: import("react").FC<import("./shared").IActivityIndicatorProps>;
3
+ export declare function ActivityIndicator(rawProps: IActivityIndicatorProps): import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
@@ -1,13 +1,17 @@
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.
8
- import { createActivityIndicator } from './shared.js';
9
- export const ActivityIndicator = createActivityIndicator({
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.
6
+ import { descriptorToReact } from '../../descriptor-to-react/index.js';
7
+ import { useActivityIndicatorLogic } from './shared.js';
8
+ const PLATFORM = {
10
9
  // RN: `color = Platform.OS === 'ios' ? GRAY : null`; Android lets the theme color it.
11
10
  defaultColor: null,
12
11
  nativeExtras: { styleAttr: 'Normal', indeterminate: true },
13
- });
12
+ };
13
+ // A top-level named function, not a factory-returned closure: React Compiler's
14
+ // component detection only walks top-level declarations (see shared.ts).
15
+ export function ActivityIndicator(rawProps) {
16
+ return descriptorToReact(useActivityIndicatorLogic(rawProps, PLATFORM));
17
+ }
@@ -1,2 +1,3 @@
1
+ import type { IActivityIndicatorProps } from './shared';
1
2
  export type { IActivityIndicatorProps } from './shared';
2
- export declare const ActivityIndicator: import("react").FC<import("./shared").IActivityIndicatorProps>;
3
+ export declare function ActivityIndicator(rawProps: IActivityIndicatorProps): import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
@@ -1,10 +1,16 @@
1
1
  // ActivityIndicator on iOS: RCTActivityIndicatorView takes the size enum + a GRAY
2
2
  // default color and no extra native props. Also the base (activity-indicator.ts
3
3
  // re-exports it) for headless / web.
4
- import { createActivityIndicator } from './shared.js';
4
+ import { descriptorToReact } from '../../descriptor-to-react/index.js';
5
+ import { useActivityIndicatorLogic } from './shared.js';
5
6
  // RN's iOS default spinner color (Libraries/.../ActivityIndicator.js GRAY).
6
7
  const IOS_DEFAULT_COLOR = '#999999';
7
- export const ActivityIndicator = createActivityIndicator({
8
+ const PLATFORM = {
8
9
  defaultColor: IOS_DEFAULT_COLOR,
9
10
  nativeExtras: {},
10
- });
11
+ };
12
+ // A top-level named function, not a factory-returned closure: React Compiler's
13
+ // component detection only walks top-level declarations (see shared.ts).
14
+ export function ActivityIndicator(rawProps) {
15
+ return descriptorToReact(useActivityIndicatorLogic(rawProps, PLATFORM));
16
+ }
@@ -1,7 +1,6 @@
1
- import type { FC } from 'react';
2
1
  import type { IActivityIndicatorPlatform, IActivityIndicatorProps as IActivityIndicatorBaseProps } from '@symbiote-native/components';
3
2
  export type { IActivityIndicatorPlatform } from '@symbiote-native/components';
4
3
  export type IActivityIndicatorProps = IActivityIndicatorBaseProps & {
5
4
  className?: string;
6
5
  };
7
- export declare function createActivityIndicator(platform: IActivityIndicatorPlatform): FC<IActivityIndicatorProps>;
6
+ export declare function useActivityIndicatorLogic(rawProps: IActivityIndicatorProps, platform: IActivityIndicatorPlatform): import("@symbiote-native/components").IDescriptor;
@@ -5,16 +5,18 @@
5
5
  //
6
6
  // The per-platform bits (iOS GRAY default + no extras; Android theme/null + styleAttr +
7
7
  // indeterminate) are supplied by the .ios/.android files via `IActivityIndicatorPlatform`.
8
+ //
9
+ // useActivityIndicatorLogic is a plain top-level hook, not a component factory returning a
10
+ // closure: React Compiler's component/hook detection only walks top-level declarations, so
11
+ // index.ios.ts / index.android.ts each declare their OWN top-level `ActivityIndicator`
12
+ // function that calls this hook, rather than exporting whatever a factory here returns (same
13
+ // shape as switch/shared.ts's useSwitchLogic).
8
14
  import { renderActivityIndicator, resolveAccessibilityProps } from '@symbiote-native/components';
9
- import { descriptorToReact } from '../../descriptor-to-react/index.js';
10
- export function createActivityIndicator(platform) {
11
- return rawProps => {
12
- // The wrapper is a raw symbiote-view, not the View FC, so it never runs
13
- // resolveAccessibilityProps itself, so fold aria/role here, then forward the resolved
14
- // accessibility* surface (plus testID / onLayout) onto the wrapper via `passthrough`.
15
- const props = resolveAccessibilityProps(rawProps);
16
- const { animating = true, color, hidesWhenStopped = true, size = 'small', style, ...passthrough } = props;
17
- const descriptor = renderActivityIndicator({ animating, color, hidesWhenStopped, size, style, passthrough }, platform);
18
- return descriptorToReact(descriptor);
19
- };
15
+ export function useActivityIndicatorLogic(rawProps, platform) {
16
+ // The wrapper is a raw symbiote-view, not the View FC, so it never runs
17
+ // resolveAccessibilityProps itself, so fold aria/role here, then forward the resolved
18
+ // accessibility* surface (plus testID / onLayout) onto the wrapper via `passthrough`.
19
+ const props = resolveAccessibilityProps(rawProps);
20
+ const { animating = true, color, hidesWhenStopped = true, size = 'small', style, ...passthrough } = props;
21
+ return renderActivityIndicator({ animating, color, hidesWhenStopped, size, style, passthrough }, platform);
20
22
  }
@@ -11,7 +11,7 @@ import { createElement, useEffect, useRef, useState, } from 'react';
11
11
  import { dlog } from '@symbiote-native/engine';
12
12
  import { computeInset, readKeyboardFrame, readLayoutFrame, resolveKeyboardAvoidingLayout, DEFAULT_VERTICAL_OFFSET, } from '@symbiote-native/components';
13
13
  import { View } from '../../components.js';
14
- import { Keyboard, KEYBOARD_EVENT } from '../../modules/keyboard.js';
14
+ import { Keyboard, KEYBOARD_EVENT } from '@symbiote-native/engine';
15
15
  export const KeyboardAvoidingView = props => {
16
16
  const { behavior, enabled = true, keyboardVerticalOffset = DEFAULT_VERTICAL_OFFSET, contentContainerStyle, style, children, onLayout,
17
17
  // The wrapper is the View FC, which runs resolveAccessibilityProps itself, so
@@ -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;
@@ -6,8 +6,6 @@
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
9
  import { cloneElement, createElement, forwardRef, useImperativeHandle, useRef } from 'react';
12
10
  import { dlog } from '@symbiote-native/engine';
13
11
  import { buildScrollViewHandle, splitLayoutProps } from '@symbiote-native/components';
@@ -1,21 +1,21 @@
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
20
  const scrollStyle = scrollViewBaseStyle ? [scrollViewBaseStyle, style] : style;
21
21
  const scrollProps = { ...outerProps, style: scrollStyle, ref };
@@ -12,7 +12,7 @@
12
12
  // lifecycle (refs/state/effects) and the element assembly that consumes them.
13
13
  import { createElement, useEffect, useRef, useState, } from 'react';
14
14
  import { AnimatedValue, dlog, event as animatedEvent, isClassNameValue, isNativeAnimatedAvailable, resolveClassName, } from '@symbiote-native/engine';
15
- import { attachStickyScroll, didContentSizeChange, forwardScrollEvent, readLayoutDimension, resolveDecelerationRate, selectScrollIntrinsics, } from '@symbiote-native/components';
15
+ import { attachStickyScroll, didContentSizeChange, forwardScrollEvent, readLayoutDimension, resolveDecelerationRate, resolveScrollForwarding, selectScrollIntrinsics, } from '@symbiote-native/components';
16
16
  import { resolveAccessibilityProps, } from '@symbiote-native/components';
17
17
  import { wrapStickyHeaders } from './sticky-header.js';
18
18
  export function usePreparedScrollView(rawProps) {
@@ -77,39 +77,35 @@ export function usePreparedScrollView(rawProps) {
77
77
  if (decelerationRate !== undefined) {
78
78
  outerProps.decelerationRate = resolveDecelerationRate(decelerationRate);
79
79
  }
80
- // onScroll: when sticky headers are active, the offset must reach the AnimatedValue, so
81
- // we wrap the user's handler with Animated.event (it fires the listener passthrough). RN
82
- // does the same with _scrollAnimatedValueAttachment. Without sticky headers, forward as-is.
80
+ // The scroll-forwarding DECISIONS (which onScroll path, the 1/16 throttle defaults, whether to
81
+ // capture the viewport height for inverted sticky, whether to keep cells un-flattened) are folded
82
+ // out to the shared resolveScrollForwarding; here React only EXECUTES them with its own primitives.
83
83
  const nativeStickyAvailable = hasStickyHeaders && isNativeAnimatedAvailable();
84
- if (hasStickyHeaders) {
85
- if (nativeStickyAvailable) {
86
- // Native path (RN attachNativeEvent): the scroll value is driven on the UI thread by the
87
- // imperative attach in the platform component's effect (useNativeStickyScrollAttach), so
88
- // onScroll only forwards to the user, zero JS per frame. RN uses throttle 1 when sticky
89
- // (ScrollView.js:1798); the native driver can afford it.
90
- if (onScroll !== undefined)
91
- outerProps.onScroll = onScroll;
92
- outerProps.scrollEventThrottle = scrollEventThrottle ?? 1;
93
- }
94
- else {
95
- // JS fallback (no native module): Animated.event drives the value each frame and forwards
96
- // the user's handler as the listener passthrough. Correct, but lags a frame under fast
97
- // scroll (the jitter), which the native path above removes on a real host.
98
- outerProps.onScroll = animatedEvent([{ nativeEvent: { contentOffset: { y: scrollAnimatedValue } } }], onScroll === undefined
99
- ? undefined
100
- : { listener: (...args) => forwardScrollEvent(onScroll, args) });
101
- outerProps.scrollEventThrottle = scrollEventThrottle ?? 16;
102
- }
84
+ const forwarding = resolveScrollForwarding({
85
+ hasStickyHeaders,
86
+ nativeStickyAvailable,
87
+ invertStickyHeaders,
88
+ scrollEventThrottle,
89
+ maintainVisibleContentPosition: props.maintainVisibleContentPosition,
90
+ snapToAlignment: props.snapToAlignment,
91
+ });
92
+ // onScroll: the JS-fallback path wraps the user's handler in Animated.event so the offset drives
93
+ // the AnimatedValue each frame (RN _scrollAnimatedValueAttachment); the native + plain paths
94
+ // forward the user's handler as-is (the native driver attaches the value on the UI thread).
95
+ if (forwarding.mode === 'sticky-js') {
96
+ outerProps.onScroll = animatedEvent([{ nativeEvent: { contentOffset: { y: scrollAnimatedValue } } }], onScroll === undefined
97
+ ? undefined
98
+ : { listener: (...args) => forwardScrollEvent(onScroll, args) });
99
+ }
100
+ else if (onScroll !== undefined) {
101
+ outerProps.onScroll = onScroll;
103
102
  }
104
- else {
105
- if (onScroll !== undefined)
106
- outerProps.onScroll = onScroll;
107
- if (scrollEventThrottle !== undefined)
108
- outerProps.scrollEventThrottle = scrollEventThrottle;
103
+ if (forwarding.scrollEventThrottle !== undefined) {
104
+ outerProps.scrollEventThrottle = forwarding.scrollEventThrottle;
109
105
  }
110
106
  // onLayout on the scroll-view node: capture the viewport height for inverted sticky headers
111
107
  // (RN _handleLayout), then call the user's handler. Pass through unchanged otherwise.
112
- if (hasStickyHeaders && invertStickyHeaders === true) {
108
+ if (forwarding.capturesViewportHeight) {
113
109
  outerProps.onLayout = (layoutEvent) => {
114
110
  const height = readLayoutDimension(layoutEvent, 'height');
115
111
  if (height !== undefined)
@@ -131,7 +127,7 @@ export function usePreparedScrollView(rawProps) {
131
127
  // MaintainVisibleScrollPositionHelper has nothing to anchor to and the list jumps on prepend.
132
128
  // RN keeps the cells as real views via collapsableChildren={false} on the content container
133
129
  // (ScrollView.js:1731-1748 `preserveChildren`). iOS never flattens, so it is a no-op there.
134
- if (props.maintainVisibleContentPosition !== undefined || props.snapToAlignment !== undefined) {
130
+ if (forwarding.collapsableChildren) {
135
131
  contentProps.collapsableChildren = false;
136
132
  }
137
133
  if (onContentSizeChange !== undefined) {
@@ -148,8 +144,8 @@ export function usePreparedScrollView(rawProps) {
148
144
  onContentSizeChange(width, height);
149
145
  };
150
146
  }
151
- // Sticky headers are a pure-JS layer (the native scroll view ignores stickyHeaderIndices);
152
- // wrap the flagged children so they pin to the scroll offset. No-op when none are flagged.
147
+ // Wrap the flagged children so they pin to the scroll offset (see the sticky-headers note
148
+ // above); no-op when none are flagged.
153
149
  const contentChildren = hasStickyHeaders
154
150
  ? wrapStickyHeaders(children, stickyHeaderIndices, scrollAnimatedValue, invertStickyHeaders, viewportHeight, StickyHeaderComponent, headerLayoutYs, onHeaderLayoutY)
155
151
  : children;
@@ -1,17 +1,15 @@
1
1
  // Sticky headers: the JS layer RN implements in ScrollView.js / ScrollViewStickyHeader.js.
2
2
  //
3
- // VERDICT (source-based): RN does stickiness PURELY IN JS. ScrollView.js (render, ~line
4
- // 1690) wraps each child whose index is in `stickyHeaderIndices` in a ScrollViewStickyHeader,
5
- // fed by a single `_scrollAnimatedValue` an Animated.event drives from `onScroll`
6
- // (ScrollView.js ~line 1095). The native Fabric scroll view does NOT honor the index array on
7
- // its own. Forwarding `stickyHeaderIndices` to native is a silent no-op. So we replicate the
8
- // JS layer: subscribe each flagged child to the scroll offset and translate it to stay pinned.
9
- // The interpolation mirrors ScrollViewStickyHeader.js (non-inverted + inverted branches) and now
10
- // lives, framework-agnostic, in @symbiote-native/components (computeStickyInterpolation); this
11
- // file holds the React component shell, the layout state, and the child-wrapping.
12
- import { Children, createElement, isValidElement, useCallback, useEffect, useRef, useState, } from 'react';
3
+ // RN does stickiness purely in JS: ScrollView.js (~line 1690) wraps each flagged child in a
4
+ // ScrollViewStickyHeader, fed by a single `_scrollAnimatedValue` that Animated.event drives
5
+ // from `onScroll` (~line 1095). The native Fabric scroll view does not honor the index array,
6
+ // so forwarding `stickyHeaderIndices` to native would be a silent no-op - we replicate the JS
7
+ // layer instead. The interpolation mirrors ScrollViewStickyHeader.js and now lives,
8
+ // framework-agnostic, in @symbiote-native/components (computeStickyInterpolation); this file
9
+ // holds only the React component shell, layout state, and child-wrapping.
10
+ import { Children, createElement, isValidElement, useCallback, useEffect, useReducer, useRef, useState, } from 'react';
13
11
  import { AnimatedInterpolation, AnimatedValue, Platform, dlog, } from '@symbiote-native/engine';
14
- import { computeStickyInterpolation, nextStickyHeaderY, readLayoutNumber, stickyDebounceMs, STICKY_HEADER_Z_INDEX, } from '@symbiote-native/components';
12
+ import { createInitialStickyState, nextStickyHeaderY, readLayoutNumber, reduceSticky, STICKY_HEADER_Z_INDEX, } from '@symbiote-native/components';
15
13
  import { Animated } from '../../modules/animated/index.js';
16
14
  function readChildOnLayout(child) {
17
15
  const childProps = child.props;
@@ -27,79 +25,105 @@ function firstChild(children) {
27
25
  // One sticky header. Measures its own y/height via onLayout, interpolates the shared scroll
28
26
  // offset into a translateY that keeps it pinned to the top (or bottom, inverted) until the next
29
27
  // header collides with it, and drives that translate through the native driver when available so
30
- // the pin tracks scroll on the UI thread (no JS jitter). Ported from
31
- // ScrollViewStickyHeader.js, including the Fabric ShadowTree debounce path.
28
+ // the pin tracks scroll on the UI thread (no JS jitter). The DECISIONS — the zero-swallow gate, the
29
+ // debounce delay, the rebuild-on-input-change ranges — live in `reduceSticky`
30
+ // (@symbiote-native/components); this component supplies only the React lifecycle: the ONE folded
31
+ // state cell, the interpolation-node + listener wiring, the debounce setTimeout, and the re-render.
32
32
  export const ScrollViewStickyHeader = props => {
33
33
  const { inverted, scrollViewHeight, scrollAnimatedValue, nextHeaderLayoutY, children } = props;
34
- const [measured, setMeasured] = useState(false);
35
- const [layoutY, setLayoutY] = useState(0);
36
- const [layoutHeight, setLayoutHeight] = useState(0);
37
- // The animated node that drives the transform (RN's animatedTranslateY). When the scroll value
38
- // is native (attachNativeEvent), this interpolation runs on the UI thread: the smooth pin.
34
+ // The one folded state cell (RN's scattered useState/useRef collapsed into IStickyHeaderState),
35
+ // mutated in place by reduceSticky. Lazily created once.
36
+ const stateRef = useRef(null);
37
+ const state = (stateRef.current ??= createInitialStickyState());
38
+ const [, forceRender] = useReducer((tick) => tick + 1, 0);
39
+ // The animated node that drives the transform (RN's animatedTranslateY), rebuilt by the
40
+ // rebuild-interpolation effect. When the scroll value is native, this interpolation runs on the
41
+ // UI thread: the smooth pin.
39
42
  const [animatedTranslateY, setAnimatedTranslateY] = useState(() => scrollAnimatedValue.interpolate({ inputRange: [-1, 0], outputRange: [0, 0] }));
40
- // The debounced EXPLICIT translateY pushed to the committed transform via
41
- // passthroughAnimatedPropExplicitValues, so the Fabric ShadowTree (hit-testing) knows the pinned
42
- // position while the native driver animates. null until the listener first fires.
43
- const [translateY, setTranslateY] = useState(null);
44
- const haveReceivedInitialZeroTranslateY = useRef(true);
45
43
  const debounceTimer = useRef(null);
46
- useEffect(() => {
47
- if (translateY !== 0 && translateY !== null)
48
- haveReceivedInitialZeroTranslateY.current = false;
49
- }, [translateY]);
50
- // The animated value updates several times per frame during scroll; debounce it and push the
51
- // settled value into the committed transform so hit detection stays current (RN: a Fabric-only
52
- // issue, symbiote is always Fabric, and worse on Android).
53
- const animatedValueListener = useCallback(({ value }) => {
54
- if (typeof value !== 'number')
55
- return;
56
- const timeout = stickyDebounceMs(Platform.OS);
57
- // A freshly-rebuilt interpolation re-emits 0 to its listeners; swallow that first zero (RN).
58
- if (value === 0 && !haveReceivedInitialZeroTranslateY.current) {
59
- haveReceivedInitialZeroTranslateY.current = true;
60
- return;
44
+ // The current interpolation node + its settled-value listener id, held so the next rebuild can
45
+ // detach the old listener (engine calls the reducer does NOT own) and unmount can clean up.
46
+ const interpolationRef = useRef(null);
47
+ const listenerIdRef = useRef(null);
48
+ const inputsRef = useRef({
49
+ os: Platform.OS,
50
+ inverted,
51
+ scrollViewHeight,
52
+ nextHeaderLayoutY,
53
+ });
54
+ inputsRef.current = { os: Platform.OS, inverted, scrollViewHeight, nextHeaderLayoutY };
55
+ // dispatch reaches through a ref because the effect executors dispatch follow-up actions
56
+ // (the listener -> animated-tick, the debounce timer -> debounce-fired).
57
+ const dispatchRef = useRef(() => { });
58
+ const runEffects = useCallback((effects) => {
59
+ for (const effect of effects) {
60
+ switch (effect.kind) {
61
+ case 'rebuild-interpolation': {
62
+ // Detach the old listener, build a fresh interpolation onto the shared scroll value, and
63
+ // wire the settled-value listener (symbiote is always Fabric; RN attaches it only there).
64
+ const previous = interpolationRef.current;
65
+ if (previous !== null && listenerIdRef.current !== null) {
66
+ previous.removeListener(listenerIdRef.current);
67
+ listenerIdRef.current = null;
68
+ }
69
+ const next = scrollAnimatedValue.interpolate({
70
+ inputRange: effect.inputRange,
71
+ outputRange: effect.outputRange,
72
+ });
73
+ listenerIdRef.current = next.addListener(({ value }) => {
74
+ if (typeof value === 'number')
75
+ dispatchRef.current({ kind: 'animated-tick', value });
76
+ });
77
+ interpolationRef.current = next;
78
+ setAnimatedTranslateY(next);
79
+ break;
80
+ }
81
+ case 'schedule-debounce': {
82
+ // The animated value updates several times per frame; debounce the settled value into the
83
+ // committed transform so hit detection stays current (a Fabric issue, worse on Android).
84
+ if (debounceTimer.current !== null)
85
+ clearTimeout(debounceTimer.current);
86
+ debounceTimer.current = setTimeout(() => {
87
+ debounceTimer.current = null;
88
+ dispatchRef.current({ kind: 'debounce-fired', value: effect.value });
89
+ }, effect.delay);
90
+ break;
91
+ }
92
+ case 'apply-passthrough':
93
+ forceRender();
94
+ break;
95
+ case 'record-header-y':
96
+ // React records through the wrapper's onLayout closure (props.onLayout, below), which
97
+ // honors the public IStickyHeaderProps contract; the reducer emits no index for it.
98
+ break;
99
+ }
61
100
  }
101
+ }, [scrollAnimatedValue]);
102
+ const dispatch = useCallback((action) => {
103
+ const current = stateRef.current;
104
+ if (current === null)
105
+ return;
106
+ runEffects(reduceSticky(current, action, inputsRef.current).effects);
107
+ }, [runEffects]);
108
+ dispatchRef.current = dispatch;
109
+ // Rebuild when the collision/viewport inputs change (RN effect deps minus the layout state, which
110
+ // dispatches 'layout' itself); also does the initial identity build on mount.
111
+ useEffect(() => {
112
+ dispatchRef.current({ kind: 'inputs-changed' });
113
+ }, [inverted, scrollViewHeight, nextHeaderLayoutY, scrollAnimatedValue]);
114
+ // Detach the listener + clear the debounce on unmount.
115
+ useEffect(() => () => {
116
+ const previous = interpolationRef.current;
117
+ if (previous !== null && listenerIdRef.current !== null)
118
+ previous.removeListener(listenerIdRef.current);
62
119
  if (debounceTimer.current !== null)
63
120
  clearTimeout(debounceTimer.current);
64
- debounceTimer.current = setTimeout(() => setTranslateY(value), timeout);
65
121
  }, []);
66
- useEffect(() => {
67
- const { inputRange, outputRange } = computeStickyInterpolation({
68
- measured,
69
- inverted,
70
- scrollViewHeight,
71
- layoutY,
72
- layoutHeight,
73
- nextHeaderLayoutY,
74
- });
75
- const newAnimatedTranslateY = scrollAnimatedValue.interpolate({ inputRange, outputRange });
76
- // symbiote is always Fabric: listen to the settled value to keep the ShadowTree transform
77
- // current for hit-testing (RN attaches this listener only under Fabric).
78
- const listenerId = newAnimatedTranslateY.addListener(animatedValueListener);
79
- setAnimatedTranslateY(newAnimatedTranslateY);
80
- return () => {
81
- newAnimatedTranslateY.removeListener(listenerId);
82
- if (debounceTimer.current !== null)
83
- clearTimeout(debounceTimer.current);
84
- };
85
- }, [
86
- measured,
87
- layoutY,
88
- layoutHeight,
89
- scrollViewHeight,
90
- nextHeaderLayoutY,
91
- inverted,
92
- scrollAnimatedValue,
93
- animatedValueListener,
94
- ]);
95
122
  const onLayout = (event) => {
96
123
  const y = readLayoutNumber(event, 'y');
97
124
  const height = readLayoutNumber(event, 'height');
98
- if (y !== undefined)
99
- setLayoutY(y);
100
- if (height !== undefined)
101
- setLayoutHeight(height);
102
- setMeasured(true);
125
+ // 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 });
103
127
  props.onLayout(event);
104
128
  const child = firstChild(children);
105
129
  const childOnLayout = child === undefined ? undefined : readChildOnLayout(child);
@@ -107,7 +131,7 @@ export const ScrollViewStickyHeader = props => {
107
131
  };
108
132
  // The EXPLICIT debounced translateY overrides the committed transform for hit-testing, while
109
133
  // `animatedTranslateY` does the smooth (native-driven) pin, per RN ScrollViewStickyHeader.js.
110
- const passthroughAnimatedPropExplicitValues = translateY !== null ? { style: { transform: [{ translateY }] } } : null;
134
+ const passthroughAnimatedPropExplicitValues = state.translateY !== null ? { style: { transform: [{ translateY: state.translateY }] } } : null;
111
135
  // collapsable:false keeps the wrapper a real Yoga node; zIndex makes the pinned header paint
112
136
  // OVER the rows scrolling under it. `style` is `unknown` on Animated.View, so the interpolation
113
137
  // transform passes with no cast.
@@ -1,10 +1,8 @@
1
- // SectionList: the public, high-level list-of-sections component. It is a thin
2
- // wrapper over VirtualizedSectionList, mirroring RN's layering
3
- // (SectionList -> VirtualizedSectionList -> VirtualizedList). All the
1
+ // SectionList: public wrapper over VirtualizedSectionList, mirroring RN's
2
+ // SectionList -> VirtualizedSectionList -> VirtualizedList layering. All
4
3
  // section-flattening / windowing / imperative-scroll logic lives in
5
- // VirtualizedSectionList; this layer only re-exposes the same surface under the
6
- // SectionList name so existing consumers and the canary keep importing it from
7
- // here, threading the imperative ref straight through.
4
+ // VirtualizedSectionList; this re-exposes the same surface under the
5
+ // SectionList name, threading the imperative ref straight through.
8
6
  import { createElement } from 'react';
9
7
  import { VirtualizedSectionList, } from '../virtualized-section-list/index.js';
10
8
  export function SectionList(props) {
@@ -1,2 +1,3 @@
1
+ import type { ISwitchProps } from './shared';
1
2
  export type { ISwitchProps, ISwitchTrackColor } from './shared';
2
- export declare const Switch: import("react").FC<import("./shared").ISwitchProps>;
3
+ export declare function Switch(rawProps: ISwitchProps): import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
@@ -1,12 +1,18 @@
1
1
  // Switch, Android host binding. AndroidSwitch takes trackColorForTrue / trackColorForFalse
2
2
  // plus trackTintColor (the color for the CURRENT value, which RN computes as value ? true :
3
3
  // false), and snaps native back via the `setNativeValue` command (Switch.js:221-225).
4
- import { createSwitch } from './shared.js';
5
- export const Switch = createSwitch({
4
+ import { descriptorToReact } from '../../descriptor-to-react/index.js';
5
+ import { useSwitchLogic } from './shared.js';
6
+ const PLATFORM = {
6
7
  snapBackCommand: 'setNativeValue',
7
8
  trackColorProps: (value, trackColor) => ({
8
9
  trackColorForFalse: trackColor?.false,
9
10
  trackColorForTrue: trackColor?.true,
10
11
  trackTintColor: value ? trackColor?.true : trackColor?.false,
11
12
  }),
12
- });
13
+ };
14
+ // A top-level named function, not a factory-returned closure: React Compiler's
15
+ // component detection only walks top-level declarations (see shared.ts).
16
+ export function Switch(rawProps) {
17
+ return descriptorToReact(useSwitchLogic(rawProps, PLATFORM));
18
+ }
@@ -1,2 +1,3 @@
1
+ import type { ISwitchProps } from './shared';
1
2
  export type { ISwitchProps, ISwitchTrackColor } from './shared';
2
- export declare const Switch: import("react").FC<import("./shared").ISwitchProps>;
3
+ export declare function Switch(rawProps: ISwitchProps): import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
@@ -1,10 +1,16 @@
1
1
  // Switch, iOS host binding. iOS's native Switch takes onTintColor (ON-track) / tintColor
2
2
  // (OFF-track), and snaps native back via the `setValue` command (Switch.js:221-225).
3
- import { createSwitch } from './shared.js';
4
- export const Switch = createSwitch({
3
+ import { descriptorToReact } from '../../descriptor-to-react/index.js';
4
+ import { useSwitchLogic } from './shared.js';
5
+ const PLATFORM = {
5
6
  snapBackCommand: 'setValue',
6
7
  trackColorProps: (_value, trackColor) => ({
7
8
  onTintColor: trackColor?.true,
8
9
  tintColor: trackColor?.false,
9
10
  }),
10
- });
11
+ };
12
+ // A top-level named function, not a factory-returned closure: React Compiler's
13
+ // component detection only walks top-level declarations (see shared.ts).
14
+ export function Switch(rawProps) {
15
+ return descriptorToReact(useSwitchLogic(rawProps, PLATFORM));
16
+ }
@@ -1,10 +1,9 @@
1
- import type { FC } from 'react';
2
1
  import type { ISwitchPlatform, ISwitchProps as ISwitchBaseProps } from '@symbiote-native/components';
3
2
  export type { ISwitchTrackColor } from '@symbiote-native/components';
4
3
  export type ISwitchProps = ISwitchBaseProps & {
5
4
  className?: string;
6
5
  };
7
- type ISwitchHostPlatform = ISwitchPlatform & {
6
+ export type ISwitchHostPlatform = ISwitchPlatform & {
8
7
  snapBackCommand: string;
9
8
  };
10
- export declare function createSwitch(platform: ISwitchHostPlatform): FC<ISwitchProps>;
9
+ export declare function useSwitchLogic(rawProps: ISwitchProps, platform: ISwitchHostPlatform): import("@symbiote-native/components").IDescriptor;