@symbiote-native/react 0.2.7 → 0.2.8

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.
@@ -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>>;
@@ -5,9 +5,15 @@
5
5
  // picks this on an Android host; no Platform.OS read.
6
6
  // device-verify-pending: prop names mirror RN's AndroidProgressBar, proven on a real
7
7
  // host by the absence of the "setStyle() not called" red box.
8
- import { createActivityIndicator } from './shared.js';
9
- export const ActivityIndicator = createActivityIndicator({
8
+ import { descriptorToReact } from '../../descriptor-to-react/index.js';
9
+ import { useActivityIndicatorLogic } from './shared.js';
10
+ const PLATFORM = {
10
11
  // RN: `color = Platform.OS === 'ios' ? GRAY : null`; Android lets the theme color it.
11
12
  defaultColor: null,
12
13
  nativeExtras: { styleAttr: 'Normal', indeterminate: true },
13
- });
14
+ };
15
+ // A top-level named function, not a factory-returned closure: React Compiler's
16
+ // component detection only walks top-level declarations (see shared.ts).
17
+ export function ActivityIndicator(rawProps) {
18
+ return descriptorToReact(useActivityIndicatorLogic(rawProps, PLATFORM));
19
+ }
@@ -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
  }
@@ -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) {
@@ -9,9 +9,9 @@
9
9
  // The interpolation mirrors ScrollViewStickyHeader.js (non-inverted + inverted branches) and now
10
10
  // lives, framework-agnostic, in @symbiote-native/components (computeStickyInterpolation); this
11
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';
12
+ import { Children, createElement, isValidElement, useCallback, useEffect, useReducer, useRef, useState, } from 'react';
13
13
  import { AnimatedInterpolation, AnimatedValue, Platform, dlog, } from '@symbiote-native/engine';
14
- import { computeStickyInterpolation, nextStickyHeaderY, readLayoutNumber, stickyDebounceMs, STICKY_HEADER_Z_INDEX, } from '@symbiote-native/components';
14
+ import { createInitialStickyState, nextStickyHeaderY, readLayoutNumber, reduceSticky, STICKY_HEADER_Z_INDEX, } from '@symbiote-native/components';
15
15
  import { Animated } from '../../modules/animated/index.js';
16
16
  function readChildOnLayout(child) {
17
17
  const childProps = child.props;
@@ -27,79 +27,105 @@ function firstChild(children) {
27
27
  // One sticky header. Measures its own y/height via onLayout, interpolates the shared scroll
28
28
  // offset into a translateY that keeps it pinned to the top (or bottom, inverted) until the next
29
29
  // 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.
30
+ // the pin tracks scroll on the UI thread (no JS jitter). The DECISIONS — the zero-swallow gate, the
31
+ // debounce delay, the rebuild-on-input-change ranges — live in `reduceSticky`
32
+ // (@symbiote-native/components); this component supplies only the React lifecycle: the ONE folded
33
+ // state cell, the interpolation-node + listener wiring, the debounce setTimeout, and the re-render.
32
34
  export const ScrollViewStickyHeader = props => {
33
35
  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.
36
+ // The one folded state cell (RN's scattered useState/useRef collapsed into IStickyHeaderState),
37
+ // mutated in place by reduceSticky. Lazily created once.
38
+ const stateRef = useRef(null);
39
+ const state = (stateRef.current ??= createInitialStickyState());
40
+ const [, forceRender] = useReducer((tick) => tick + 1, 0);
41
+ // The animated node that drives the transform (RN's animatedTranslateY), rebuilt by the
42
+ // rebuild-interpolation effect. When the scroll value is native, this interpolation runs on the
43
+ // UI thread: the smooth pin.
39
44
  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
45
  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;
46
+ // The current interpolation node + its settled-value listener id, held so the next rebuild can
47
+ // detach the old listener (engine calls the reducer does NOT own) and unmount can clean up.
48
+ const interpolationRef = useRef(null);
49
+ const listenerIdRef = useRef(null);
50
+ const inputsRef = useRef({
51
+ os: Platform.OS,
52
+ inverted,
53
+ scrollViewHeight,
54
+ nextHeaderLayoutY,
55
+ });
56
+ inputsRef.current = { os: Platform.OS, inverted, scrollViewHeight, nextHeaderLayoutY };
57
+ // dispatch reaches through a ref because the effect executors dispatch follow-up actions
58
+ // (the listener -> animated-tick, the debounce timer -> debounce-fired).
59
+ const dispatchRef = useRef(() => { });
60
+ const runEffects = useCallback((effects) => {
61
+ for (const effect of effects) {
62
+ switch (effect.kind) {
63
+ case 'rebuild-interpolation': {
64
+ // Detach the old listener, build a fresh interpolation onto the shared scroll value, and
65
+ // wire the settled-value listener (symbiote is always Fabric; RN attaches it only there).
66
+ const previous = interpolationRef.current;
67
+ if (previous !== null && listenerIdRef.current !== null) {
68
+ previous.removeListener(listenerIdRef.current);
69
+ listenerIdRef.current = null;
70
+ }
71
+ const next = scrollAnimatedValue.interpolate({
72
+ inputRange: effect.inputRange,
73
+ outputRange: effect.outputRange,
74
+ });
75
+ listenerIdRef.current = next.addListener(({ value }) => {
76
+ if (typeof value === 'number')
77
+ dispatchRef.current({ kind: 'animated-tick', value });
78
+ });
79
+ interpolationRef.current = next;
80
+ setAnimatedTranslateY(next);
81
+ break;
82
+ }
83
+ case 'schedule-debounce': {
84
+ // The animated value updates several times per frame; debounce the settled value into the
85
+ // committed transform so hit detection stays current (a Fabric issue, worse on Android).
86
+ if (debounceTimer.current !== null)
87
+ clearTimeout(debounceTimer.current);
88
+ debounceTimer.current = setTimeout(() => {
89
+ debounceTimer.current = null;
90
+ dispatchRef.current({ kind: 'debounce-fired', value: effect.value });
91
+ }, effect.delay);
92
+ break;
93
+ }
94
+ case 'apply-passthrough':
95
+ forceRender();
96
+ break;
97
+ case 'record-header-y':
98
+ // React records through the wrapper's onLayout closure (props.onLayout, below), which
99
+ // honors the public IStickyHeaderProps contract; the reducer emits no index for it.
100
+ break;
101
+ }
61
102
  }
103
+ }, [scrollAnimatedValue]);
104
+ const dispatch = useCallback((action) => {
105
+ const current = stateRef.current;
106
+ if (current === null)
107
+ return;
108
+ runEffects(reduceSticky(current, action, inputsRef.current).effects);
109
+ }, [runEffects]);
110
+ dispatchRef.current = dispatch;
111
+ // Rebuild when the collision/viewport inputs change (RN effect deps minus the layout state, which
112
+ // dispatches 'layout' itself); also does the initial identity build on mount.
113
+ useEffect(() => {
114
+ dispatchRef.current({ kind: 'inputs-changed' });
115
+ }, [inverted, scrollViewHeight, nextHeaderLayoutY, scrollAnimatedValue]);
116
+ // Detach the listener + clear the debounce on unmount.
117
+ useEffect(() => () => {
118
+ const previous = interpolationRef.current;
119
+ if (previous !== null && listenerIdRef.current !== null)
120
+ previous.removeListener(listenerIdRef.current);
62
121
  if (debounceTimer.current !== null)
63
122
  clearTimeout(debounceTimer.current);
64
- debounceTimer.current = setTimeout(() => setTranslateY(value), timeout);
65
123
  }, []);
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
124
  const onLayout = (event) => {
96
125
  const y = readLayoutNumber(event, 'y');
97
126
  const height = readLayoutNumber(event, 'height');
98
- if (y !== undefined)
99
- setLayoutY(y);
100
- if (height !== undefined)
101
- setLayoutHeight(height);
102
- setMeasured(true);
127
+ // Keep the previous value when a field is absent (RN sets state only on a defined read).
128
+ dispatch({ kind: 'layout', y: y ?? state.layoutY, height: height ?? state.layoutHeight });
103
129
  props.onLayout(event);
104
130
  const child = firstChild(children);
105
131
  const childOnLayout = child === undefined ? undefined : readChildOnLayout(child);
@@ -107,7 +133,7 @@ export const ScrollViewStickyHeader = props => {
107
133
  };
108
134
  // The EXPLICIT debounced translateY overrides the committed transform for hit-testing, while
109
135
  // `animatedTranslateY` does the smooth (native-driven) pin, per RN ScrollViewStickyHeader.js.
110
- const passthroughAnimatedPropExplicitValues = translateY !== null ? { style: { transform: [{ translateY }] } } : null;
136
+ const passthroughAnimatedPropExplicitValues = state.translateY !== null ? { style: { transform: [{ translateY: state.translateY }] } } : null;
111
137
  // collapsable:false keeps the wrapper a real Yoga node; zIndex makes the pinned header paint
112
138
  // OVER the rows scrolling under it. `style` is `unknown` on Animated.View, so the interpolation
113
139
  // transform passes with no cast.
@@ -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;
@@ -9,51 +9,52 @@
9
9
  // JS commands the old value back down (a plain prop re-push cannot cover the no-op case: the
10
10
  // prop never changes, so the retained tree never diverges). The command name is platform-
11
11
  // specific (iOS setValue / Android setNativeValue) and arrives via `platform`.
12
+ //
13
+ // useSwitchLogic is a plain hook, not a component factory returning a closure: React Compiler's
14
+ // component/hook detection only walks top-level declarations, so index.ios.ts / index.android.ts
15
+ // each declare their OWN top-level `Switch` function that calls this hook, rather than exporting
16
+ // whatever a factory here returns.
12
17
  import { useCallback, useLayoutEffect, useReducer, useRef } from 'react';
13
18
  import { renderSwitch, switchReducer, createInitialSwitchState, shouldSnapBack, valueFromChange, } from '@symbiote-native/components';
14
19
  import { dispatchViewCommand, dlog, } from '@symbiote-native/engine';
15
20
  import { resolveAccessibilityProps } from '@symbiote-native/components';
16
- import { descriptorToReact } from '../../descriptor-to-react/index.js';
17
- export function createSwitch(platform) {
18
- return rawProps => {
19
- // Switch owns its host element rather than rendering through a symbiote View, so it folds
20
- // aria/role into accessibility* here; the resolved accessibility* fields (plus testID etc.)
21
- // ride down to the host node via `passthrough`.
22
- const props = resolveAccessibilityProps(rawProps);
23
- const { value, onValueChange, disabled, trackColor, thumbColor, ios_backgroundColor, style, ...passthrough } = props;
24
- const ref = useRef(null);
25
- const [state, dispatch] = useReducer(switchReducer, undefined, createInitialSwitchState);
26
- const handleChange = useCallback((event) => {
27
- const next = valueFromChange(event);
28
- dlog(`Switch onChange value=${String(next)} eventCount=${String(event.nativeEvent.eventCount)}`);
29
- if (next === undefined)
30
- return;
31
- onValueChange?.(next, event);
32
- dispatch({ type: 'native-reported', value: next });
33
- }, [onValueChange]);
34
- // value is a real Fabric prop, folded to a strict boolean: RN sends `value === true`, so
35
- // an undefined prop reads as "off" rather than absent.
36
- const fabricValue = value === true;
37
- useLayoutEffect(() => {
38
- const node = ref.current;
39
- if (node === null)
40
- return;
41
- if (!shouldSnapBack(state, fabricValue)) {
42
- dlog(`Switch snap-back no-op reported=${String(state.lastNativeReport)} value=${fabricValue}`);
43
- return;
44
- }
45
- dlog(`Switch ${platform.snapBackCommand} snap-back reported=${String(state.lastNativeReport)} value=${fabricValue}`);
46
- dispatchViewCommand(node, platform.snapBackCommand, [fabricValue]);
47
- }, [fabricValue, state]);
48
- const descriptor = renderSwitch({
49
- value: fabricValue,
50
- disabled,
51
- trackColor,
52
- thumbColor,
53
- ios_backgroundColor,
54
- style,
55
- passthrough: { ...passthrough, ref, onChange: handleChange },
56
- }, platform);
57
- return descriptorToReact(descriptor);
58
- };
21
+ export function useSwitchLogic(rawProps, platform) {
22
+ // Switch owns its host element rather than rendering through a symbiote View, so it folds
23
+ // aria/role into accessibility* here; the resolved accessibility* fields (plus testID etc.)
24
+ // ride down to the host node via `passthrough`.
25
+ const props = resolveAccessibilityProps(rawProps);
26
+ const { value, onValueChange, disabled, trackColor, thumbColor, ios_backgroundColor, style, ...passthrough } = props;
27
+ const ref = useRef(null);
28
+ const [state, dispatch] = useReducer(switchReducer, undefined, createInitialSwitchState);
29
+ const handleChange = useCallback((event) => {
30
+ const next = valueFromChange(event);
31
+ dlog(`Switch onChange value=${String(next)} eventCount=${String(event.nativeEvent.eventCount)}`);
32
+ if (next === undefined)
33
+ return;
34
+ onValueChange?.(next, event);
35
+ dispatch({ type: 'native-reported', value: next });
36
+ }, [onValueChange]);
37
+ // value is a real Fabric prop, folded to a strict boolean: RN sends `value === true`, so
38
+ // an undefined prop reads as "off" rather than absent.
39
+ const fabricValue = value === true;
40
+ useLayoutEffect(() => {
41
+ const node = ref.current;
42
+ if (node === null)
43
+ return;
44
+ if (!shouldSnapBack(state, fabricValue)) {
45
+ dlog(`Switch snap-back no-op reported=${String(state.lastNativeReport)} value=${fabricValue}`);
46
+ return;
47
+ }
48
+ dlog(`Switch ${platform.snapBackCommand} snap-back reported=${String(state.lastNativeReport)} value=${fabricValue}`);
49
+ dispatchViewCommand(node, platform.snapBackCommand, [fabricValue]);
50
+ }, [fabricValue, state]);
51
+ return renderSwitch({
52
+ value: fabricValue,
53
+ disabled,
54
+ trackColor,
55
+ thumbColor,
56
+ ios_backgroundColor,
57
+ style,
58
+ passthrough: { ...passthrough, ref, onChange: handleChange },
59
+ }, platform);
59
60
  }