@symbiote-native/react 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +12 -11
  2. package/build/components/activity-indicator-props.d.ts +4 -0
  3. package/build/components/{button.d.ts → button-props.d.ts} +0 -2
  4. package/build/components/button-props.js +1 -0
  5. package/build/components/flat-list/index.js +2 -2
  6. package/build/components/image/image-props.d.ts +4 -0
  7. package/build/components/image/image-props.js +1 -0
  8. package/build/components/image-background-props.d.ts +9 -0
  9. package/build/components/image-background-props.js +1 -0
  10. package/build/components/input-accessory-view-props.d.ts +10 -0
  11. package/build/components/input-accessory-view-props.js +1 -0
  12. package/build/components/keyboard-avoiding-view/index.js +4 -4
  13. package/build/components/modal/index.js +3 -3
  14. package/build/components/pressable/{index.d.ts → pressable-props.d.ts} +4 -5
  15. package/build/components/pressable/pressable-props.js +1 -0
  16. package/build/components/{refresh-control/index.d.ts → refresh-control-props.d.ts} +3 -3
  17. package/build/components/refresh-control-props.js +1 -0
  18. package/build/components/safe-area-view-props.d.ts +11 -0
  19. package/build/components/safe-area-view-props.js +1 -0
  20. package/build/components/scroll-view/{shared.d.ts → scroll-view-props.d.ts} +4 -26
  21. package/build/components/scroll-view/scroll-view-props.js +1 -0
  22. package/build/components/switch/switch-props.d.ts +4 -0
  23. package/build/components/switch/switch-props.js +1 -0
  24. package/build/components/text-input/text-input-props.d.ts +4 -0
  25. package/build/components/text-input/text-input-props.js +1 -0
  26. package/build/components/touchable/touchable-props.d.ts +17 -0
  27. package/build/components/touchable/touchable-props.js +1 -0
  28. package/build/components/touchable-native-feedback/touchable-native-feedback-props.d.ts +8 -0
  29. package/build/components/touchable-native-feedback/touchable-native-feedback-props.js +1 -0
  30. package/build/components/touchable-without-feedback/touchable-without-feedback-props.d.ts +6 -0
  31. package/build/components/touchable-without-feedback/touchable-without-feedback-props.js +1 -0
  32. package/build/components/virtualized-list/index.js +83 -38
  33. package/build/components.d.ts +1 -2
  34. package/build/components.js +5 -19
  35. package/build/create-portal/index.js +1 -1
  36. package/build/create-tunnel/index.js +1 -1
  37. package/build/descriptor-to-react/index.js +1 -1
  38. package/build/host-config.js +22 -4
  39. package/build/index.d.ts +23 -28
  40. package/build/index.js +14 -14
  41. package/build/jsx-dev-runtime.d.ts +3 -0
  42. package/build/jsx-dev-runtime.js +10 -0
  43. package/build/jsx-namespace.type-check.d.ts +1 -0
  44. package/build/jsx-namespace.type-check.js +7 -0
  45. package/build/jsx-runtime.d.ts +62 -0
  46. package/build/jsx-runtime.js +1 -0
  47. package/build/modules/animated/index.d.ts +0 -1
  48. package/build/modules/animated/index.js +16 -22
  49. package/build/modules/image/index.d.ts +3 -0
  50. package/build/modules/image/index.js +14 -0
  51. package/build/register.d.ts +1 -0
  52. package/build/register.js +15 -0
  53. package/metro-css-parser.cjs +2 -1
  54. package/package.json +12 -8
  55. package/build/components/activity-indicator/index.android.d.ts +0 -3
  56. package/build/components/activity-indicator/index.android.js +0 -17
  57. package/build/components/activity-indicator/index.d.ts +0 -1
  58. package/build/components/activity-indicator/index.ios.d.ts +0 -3
  59. package/build/components/activity-indicator/index.ios.js +0 -16
  60. package/build/components/activity-indicator/index.js +0 -4
  61. package/build/components/activity-indicator/shared.d.ts +0 -6
  62. package/build/components/activity-indicator/shared.js +0 -22
  63. package/build/components/button.js +0 -42
  64. package/build/components/image/index.d.ts +0 -9
  65. package/build/components/image/index.js +0 -28
  66. package/build/components/image-background/index.d.ts +0 -10
  67. package/build/components/image-background/index.js +0 -46
  68. package/build/components/input-accessory-view/index.d.ts +0 -11
  69. package/build/components/input-accessory-view/index.js +0 -19
  70. package/build/components/pressable/index.js +0 -112
  71. package/build/components/refresh-control/index.js +0 -21
  72. package/build/components/safe-area-view/index.d.ts +0 -12
  73. package/build/components/safe-area-view/index.js +0 -18
  74. package/build/components/scroll-view/index.android.d.ts +0 -3
  75. package/build/components/scroll-view/index.android.js +0 -61
  76. package/build/components/scroll-view/index.d.ts +0 -1
  77. package/build/components/scroll-view/index.ios.d.ts +0 -3
  78. package/build/components/scroll-view/index.ios.js +0 -29
  79. package/build/components/scroll-view/index.js +0 -4
  80. package/build/components/scroll-view/shared.js +0 -240
  81. package/build/components/scroll-view/sticky-header.d.ts +0 -18
  82. package/build/components/scroll-view/sticky-header.js +0 -227
  83. package/build/components/switch/index.android.d.ts +0 -3
  84. package/build/components/switch/index.android.js +0 -18
  85. package/build/components/switch/index.d.ts +0 -1
  86. package/build/components/switch/index.ios.d.ts +0 -3
  87. package/build/components/switch/index.ios.js +0 -16
  88. package/build/components/switch/index.js +0 -6
  89. package/build/components/switch/shared.d.ts +0 -9
  90. package/build/components/switch/shared.js +0 -60
  91. package/build/components/text-input/index.d.ts +0 -50
  92. package/build/components/text-input/index.js +0 -180
  93. package/build/components/touchable/index.d.ts +0 -22
  94. package/build/components/touchable/index.js +0 -165
  95. package/build/components/touchable-native-feedback/index.d.ts +0 -18
  96. package/build/components/touchable-native-feedback/index.js +0 -32
  97. package/build/jsx.d.ts +0 -18
  98. package/build/state-style.d.ts +0 -1
  99. package/build/state-style.js +0 -13
  100. /package/build/{jsx.js → components/activity-indicator-props.js} +0 -0
@@ -1,240 +0,0 @@
1
- // ScrollView: shared core. The Fabric tree is nested: the scroll view wraps a content
2
- // view that holds the children (RN's own ScrollView.js shape). Building that content
3
- // node, resolving decelerationRate, and the prop plumbing are platform-invariant and
4
- // live here. What diverges is how a RefreshControl integrates: on iOS it is a
5
- // CHILD of the scroll view (sibling of the content), on Android it WRAPS the scroll view
6
- // (AndroidSwipeRefreshLayout is the parent, ScrollView nested inside). So the .ios/.android
7
- // files assemble the final element; the filename selects, no Platform.OS read.
8
- //
9
- // The framework-agnostic pieces (decelerationRate, the per-axis intrinsics/base style, the
10
- // content-size dedupe, the imperative handle, splitLayoutProps, the sticky math, the native
11
- // scroll-attach) live in @symbiote-native/components; this file holds only the React
12
- // lifecycle (refs/state/effects) and the element assembly that consumes them.
13
- import { createElement, useEffect, useRef, useState, } from 'react';
14
- import { AnimatedValue, dlog, event as animatedEvent, isClassNameValue, isNativeAnimatedAvailable, resolveClassName, } from '@symbiote-native/engine';
15
- import { attachStickyScroll, didContentSizeChange, forwardScrollEvent, readLayoutDimension, resolveDecelerationRate, resolveScrollForwarding, selectScrollIntrinsics, } from '@symbiote-native/components';
16
- import { resolveAccessibilityProps, } from '@symbiote-native/components';
17
- import { wrapStickyHeaders, } from './sticky-header.js';
18
- export function usePreparedScrollView(rawProps) {
19
- // ScrollView forwards its outer props straight to the native scroll view (not a View
20
- // wrapper), so it folds aria/role into accessibility* here before forwarding.
21
- const props = resolveAccessibilityProps(rawProps);
22
- const { style, className, contentContainerStyle, horizontal, decelerationRate, refreshControl, children, onContentSizeChange, stickyHeaderIndices, invertStickyHeaders, StickyHeaderComponent, onLayout, onScroll, scrollEventThrottle, ...outer } = props;
23
- const isHorizontal = horizontal === true;
24
- const hasStickyHeaders = stickyHeaderIndices !== undefined && stickyHeaderIndices.length > 0;
25
- // A single AnimatedValue tracks the scroll offset and drives every sticky header's
26
- // translateY (RN's _scrollAnimatedValue). Stable across renders via a ref so the headers'
27
- // bindings survive re-renders. Allocated even when no sticky headers are present (hooks
28
- // run unconditionally: usePreparedScrollView is always called at the top of the render body).
29
- const scrollAnimatedValueRef = useRef(null);
30
- if (scrollAnimatedValueRef.current === null)
31
- scrollAnimatedValueRef.current = new AnimatedValue(0);
32
- const scrollAnimatedValue = scrollAnimatedValueRef.current;
33
- // Inverted sticky headers stick to the BOTTOM, so they need the viewport height (RN reads
34
- // it in _handleLayout). Tracked here and fed back into the wrapped headers.
35
- const [viewportHeight, setViewportHeight] = useState(undefined);
36
- // Sticky-header cross-talk (RN ScrollView.js _headerLayoutYs, line 754): a child-index→measured-y
37
- // map the parent keeps so each header can learn where the NEXT sticky header starts (its push-off
38
- // collision point). The map lives in a ref (mutated imperatively from each header's onLayout, like
39
- // RN's _onStickyHeaderLayout), and a state bump forces the re-render that feeds the freshly-recorded
40
- // y forward into the previous header's nextHeaderLayoutY prop.
41
- const headerLayoutYsRef = useRef(null);
42
- if (headerLayoutYsRef.current === null)
43
- headerLayoutYsRef.current = new Map();
44
- const headerLayoutYs = headerLayoutYsRef.current;
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.
85
- const onHeaderLayoutY = (index, y) => {
86
- if (headerLayoutYs.get(index) === y)
87
- return;
88
- headerLayoutYs.set(index, y);
89
- dlog(`ScrollView sticky-header layoutY index=${index} y=${y}`);
90
- pendingHeaderYs.set(index, y);
91
- if (flushScheduledRef.current)
92
- return;
93
- flushScheduledRef.current = true;
94
- void Promise.resolve().then(flushHeaderYs);
95
- };
96
- // A class-name string resolves through the shared registry before it reaches the
97
- // framework-agnostic selector below, which only understands style objects/arrays.
98
- const resolvedContentContainerStyle = typeof contentContainerStyle === 'string'
99
- ? resolveClassName(contentContainerStyle)
100
- : contentContainerStyle;
101
- // The per-axis intrinsics, base style, and content style come from the shared selector
102
- // (@symbiote-native/components): on Android horizontal resolves to its own ViewManager, on iOS both
103
- // map back to RCTScrollView; here we only pass the axis.
104
- const { scrollViewIntrinsic, contentIntrinsic, scrollViewBaseStyle, contentStyle, } = selectScrollIntrinsics(isHorizontal, resolvedContentContainerStyle);
105
- const outerProps = { ...outer };
106
- // className is pulled out above (unlike the rest of `...outer`) so `layoutSplitStyle` below can
107
- // resolve it eagerly; re-added here so the simple single-node paths (iOS, Android with no
108
- // refreshControl) keep forwarding it raw, exactly as it did when it flowed through `...outer`.
109
- if (className !== undefined)
110
- outerProps.className = className;
111
- // RN defaults nested scrolling ON (ScrollView.js:1862 `nestedScrollEnabled ?? true`).
112
- // Android needs the flag to scroll a scrollable nested inside another scroll view
113
- // independently; without it the inner one stays put. iOS handles nesting natively, so
114
- // it is a no-op there. Default to true so nested lists scroll out of the box, like RN.
115
- outerProps.nestedScrollEnabled = props.nestedScrollEnabled ?? true;
116
- // iOS needs `horizontal` to flip RCTScrollView's axis; Android's dedicated horizontal
117
- // manager ignores it. Harmless on Android, load-bearing on iOS, so always forward it.
118
- if (horizontal !== undefined)
119
- outerProps.horizontal = horizontal;
120
- if (decelerationRate !== undefined) {
121
- outerProps.decelerationRate = resolveDecelerationRate(decelerationRate);
122
- }
123
- // The scroll-forwarding DECISIONS (which onScroll path, the 1/16 throttle defaults, whether to
124
- // capture the viewport height for inverted sticky, whether to keep cells un-flattened) are folded
125
- // out to the shared resolveScrollForwarding; here React only EXECUTES them with its own primitives.
126
- const nativeStickyAvailable = hasStickyHeaders && isNativeAnimatedAvailable();
127
- const forwarding = resolveScrollForwarding({
128
- hasStickyHeaders,
129
- nativeStickyAvailable,
130
- invertStickyHeaders,
131
- scrollEventThrottle,
132
- maintainVisibleContentPosition: props.maintainVisibleContentPosition,
133
- snapToAlignment: props.snapToAlignment,
134
- });
135
- // onScroll: the JS-fallback path wraps the user's handler in Animated.event so the offset drives
136
- // the AnimatedValue each frame (RN _scrollAnimatedValueAttachment); the native + plain paths
137
- // forward the user's handler as-is (the native driver attaches the value on the UI thread).
138
- if (forwarding.mode === 'sticky-js') {
139
- outerProps.onScroll = animatedEvent([{ nativeEvent: { contentOffset: { y: scrollAnimatedValue } } }], onScroll === undefined
140
- ? undefined
141
- : { listener: (...args) => forwardScrollEvent(onScroll, args) });
142
- }
143
- else if (onScroll !== undefined) {
144
- outerProps.onScroll = onScroll;
145
- }
146
- if (forwarding.scrollEventThrottle !== undefined) {
147
- outerProps.scrollEventThrottle = forwarding.scrollEventThrottle;
148
- }
149
- // onLayout on the scroll-view node: capture the viewport height for inverted sticky headers
150
- // (RN _handleLayout), then call the user's handler. Pass through unchanged otherwise.
151
- if (forwarding.capturesViewportHeight) {
152
- outerProps.onLayout = (layoutEvent) => {
153
- const height = readLayoutDimension(layoutEvent, 'height');
154
- if (height !== undefined)
155
- setViewportHeight(height);
156
- onLayout?.(layoutEvent);
157
- };
158
- }
159
- else if (onLayout !== undefined) {
160
- outerProps.onLayout = onLayout;
161
- }
162
- dlog(`ScrollView -> ${scrollViewIntrinsic} (horizontal=${isHorizontal} sticky=${hasStickyHeaders})`);
163
- // onContentSizeChange is synthesized from the content view's own onLayout (RN
164
- // _handleContentOnLayout): read width/height off nativeEvent.layout and fire only when the
165
- // size actually changed (dedupe via a ref, like RN). Composed with any content onLayout.
166
- const lastContentSizeRef = useRef(null);
167
- const contentProps = {
168
- style: contentStyle,
169
- collapsable: false,
170
- };
171
- // maintainVisibleContentPosition (and Android snapToAlignment) anchor against the metrics
172
- // of MOUNTED cell views. Android Fabric view-flattens layout-only cells away, so the native
173
- // MaintainVisibleScrollPositionHelper has nothing to anchor to and the list jumps on prepend.
174
- // RN keeps the cells as real views via collapsableChildren={false} on the content container
175
- // (ScrollView.js:1731-1748 `preserveChildren`). iOS never flattens, so it is a no-op there.
176
- if (forwarding.collapsableChildren) {
177
- contentProps.collapsableChildren = false;
178
- }
179
- if (onContentSizeChange !== undefined) {
180
- contentProps.onLayout = (layoutEvent) => {
181
- const width = readLayoutDimension(layoutEvent, 'width');
182
- const height = readLayoutDimension(layoutEvent, 'height');
183
- if (width === undefined || height === undefined)
184
- return;
185
- const last = lastContentSizeRef.current;
186
- if (!didContentSizeChange(last, { width, height }))
187
- return;
188
- lastContentSizeRef.current = { width, height };
189
- dlog(`ScrollView onContentSizeChange ${width}x${height}`);
190
- onContentSizeChange(width, height);
191
- };
192
- }
193
- // Wrap the flagged children so they pin to the scroll offset (see the sticky-headers note
194
- // above); no-op when none are flagged.
195
- const contentChildren = hasStickyHeaders
196
- ? wrapStickyHeaders(children, stickyHeaderIndices, scrollAnimatedValue, invertStickyHeaders, viewportHeight, StickyHeaderComponent, { headerLayoutYs, onHeaderLayoutY, onHeaderRef })
197
- : children;
198
- // `collapsable: false` is load-bearing on Android. The content container is a
199
- // layout-only View, which Android Fabric view-flattens away, hoisting the cells
200
- // up as DIRECT children of the scroll view, which strictly hosts exactly one
201
- // child ("ScrollView can host only one direct child" → addViewAt crash). RN pins
202
- // its own NativeScrollContentView the same way (ScrollView.js, collapsable={false};
203
- // ReactScrollView.java: "the 'content' View … non-collapsable so it will never be
204
- // View-flattened away"). iOS doesn't flatten, so this is a no-op there.
205
- const content = createElement(contentIntrinsic, contentProps, contentChildren);
206
- // resolveClassName(undefined) is a cheap {} no-op, so this is safe with no className too.
207
- const resolvedClassName = isClassNameValue(className) ? className : undefined;
208
- const layoutSplitStyle = [
209
- resolveClassName(resolvedClassName),
210
- style,
211
- ];
212
- return {
213
- scrollViewIntrinsic,
214
- scrollViewBaseStyle,
215
- outerProps,
216
- style,
217
- layoutSplitStyle,
218
- content,
219
- refreshControl,
220
- scrollAnimatedValue,
221
- nativeStickyAvailable,
222
- };
223
- }
224
- // Attach the scroll event to the scroll-offset value on the NATIVE driver, RN's
225
- // _updateAnimatedNodeAttachment / AnimatedImplementation.attachNativeEvent (ScrollView.js:1087).
226
- // Called by each platform ScrollView with its committed scroll-node ref; the value then tracks
227
- // scroll on the UI thread and the sticky-header interpolations ride it natively (no JS jitter).
228
- // No-op when native sticky is unavailable or the node hasn't committed. Detaches on unmount.
229
- // The attach/detach itself lives in @symbiote-native/components (attachStickyScroll); this is the React
230
- // effect that drives it.
231
- export function useNativeStickyScrollAttach(scrollNodeRef, scrollAnimatedValue, enabled) {
232
- useEffect(() => {
233
- if (!enabled)
234
- return;
235
- const node = scrollNodeRef.current;
236
- if (node === null)
237
- return;
238
- return attachStickyScroll(node, scrollAnimatedValue);
239
- }, [scrollNodeRef, scrollAnimatedValue, enabled]);
240
- }
@@ -1,18 +0,0 @@
1
- import { type ComponentType, type ReactNode, type Ref } from 'react';
2
- import { AnimatedValue } from '@symbiote-native/engine';
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
- };
12
- export type IStickyHeaderComponentProps = IStickyHeaderProps & {
13
- children?: ReactNode;
14
- ref?: Ref<IStickyHeaderHandle>;
15
- };
16
- export type IStickyHeaderComponentType = ComponentType<IStickyHeaderComponentProps>;
17
- export declare const ScrollViewStickyHeader: IStickyHeaderComponentType;
18
- export declare function wrapStickyHeaders(children: ReactNode, stickyHeaderIndices: number[] | undefined, scrollAnimatedValue: AnimatedValue, invertStickyHeaders: boolean | undefined, scrollViewHeight: number | undefined, StickyHeaderComponent: IStickyHeaderComponentType | undefined, crossTalk: IStickyCrossTalk): ReactNode;
@@ -1,227 +0,0 @@
1
- // Sticky headers: the JS layer RN implements in ScrollView.js / ScrollViewStickyHeader.js.
2
- //
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, useImperativeHandle, useReducer, useRef, useState, } from 'react';
11
- import { AnimatedInterpolation, AnimatedValue, Platform, dlog, } from '@symbiote-native/engine';
12
- import { createInitialStickyState, nextStickyHeaderY, readLayoutNumber, reduceSticky, STICKY_HEADER_Z_INDEX, } from '@symbiote-native/components';
13
- import { Animated } from '../../modules/animated/index.js';
14
- function readChildOnLayout(child) {
15
- const childProps = child.props;
16
- if (typeof childProps !== 'object' || childProps === null)
17
- return undefined;
18
- const handler = Reflect.get(childProps, 'onLayout');
19
- return typeof handler === 'function' ? handler : undefined;
20
- }
21
- function firstChild(children) {
22
- const first = Children.toArray(children)[0];
23
- return isValidElement(first) ? first : undefined;
24
- }
25
- // One sticky header. Measures its own y/height via onLayout, interpolates the shared scroll
26
- // offset into a translateY that keeps it pinned to the top (or bottom, inverted) until the next
27
- // header collides with it, and drives that translate through the native driver when available so
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
- export const ScrollViewStickyHeader = 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
- }
51
- // The one folded state cell (RN's scattered useState/useRef collapsed into IStickyHeaderState),
52
- // mutated in place by reduceSticky. Lazily created once.
53
- const stateRef = useRef(null);
54
- const state = (stateRef.current ??= createInitialStickyState());
55
- const [, forceRender] = useReducer((tick) => tick + 1, 0);
56
- // The animated node that drives the transform (RN's animatedTranslateY), rebuilt by the
57
- // rebuild-interpolation effect. When the scroll value is native, this interpolation runs on the
58
- // UI thread: the smooth pin.
59
- const [animatedTranslateY, setAnimatedTranslateY] = useState(() => scrollAnimatedValue.interpolate({
60
- inputRange: [-1, 0],
61
- outputRange: [0, 0],
62
- }));
63
- const debounceTimer = useRef(null);
64
- // The current interpolation node + its settled-value listener id, held so the next rebuild can
65
- // detach the old listener (engine calls the reducer does NOT own) and unmount can clean up.
66
- const interpolationRef = useRef(null);
67
- const listenerIdRef = useRef(null);
68
- const inputsRef = useRef({
69
- os: Platform.OS,
70
- inverted,
71
- scrollViewHeight,
72
- nextHeaderLayoutY: nextHeaderY,
73
- });
74
- inputsRef.current = {
75
- os: Platform.OS,
76
- inverted,
77
- scrollViewHeight,
78
- nextHeaderLayoutY: nextHeaderY,
79
- };
80
- // dispatch reaches through a ref because the effect executors dispatch follow-up actions
81
- // (the listener -> animated-tick, the debounce timer -> debounce-fired).
82
- const dispatchRef = useRef(() => { });
83
- const runEffects = useCallback((effects) => {
84
- for (const effect of effects) {
85
- switch (effect.kind) {
86
- case 'rebuild-interpolation': {
87
- // Detach the old listener, build a fresh interpolation onto the shared scroll value, and
88
- // wire the settled-value listener (symbiote is always Fabric; RN attaches it only there).
89
- const previous = interpolationRef.current;
90
- if (previous !== null && listenerIdRef.current !== null) {
91
- previous.removeListener(listenerIdRef.current);
92
- listenerIdRef.current = null;
93
- }
94
- const next = scrollAnimatedValue.interpolate({
95
- inputRange: effect.inputRange,
96
- outputRange: effect.outputRange,
97
- });
98
- listenerIdRef.current = next.addListener(({ value }) => {
99
- if (typeof value === 'number')
100
- dispatchRef.current({ kind: 'animated-tick', value });
101
- });
102
- interpolationRef.current = next;
103
- setAnimatedTranslateY(next);
104
- break;
105
- }
106
- case 'schedule-debounce': {
107
- // The animated value updates several times per frame; debounce the settled value into the
108
- // committed transform so hit detection stays current (a Fabric issue, worse on Android).
109
- if (debounceTimer.current !== null)
110
- clearTimeout(debounceTimer.current);
111
- debounceTimer.current = setTimeout(() => {
112
- debounceTimer.current = null;
113
- dispatchRef.current({
114
- kind: 'debounce-fired',
115
- value: effect.value,
116
- });
117
- }, effect.delay);
118
- break;
119
- }
120
- case 'apply-passthrough':
121
- forceRender();
122
- break;
123
- case 'record-header-y':
124
- // React records through the wrapper's onLayout closure (props.onLayout, below), which
125
- // honors the public IStickyHeaderProps contract; the reducer emits no index for it.
126
- break;
127
- }
128
- }
129
- }, [scrollAnimatedValue]);
130
- const dispatch = useCallback((action) => {
131
- const current = stateRef.current;
132
- if (current === null)
133
- return;
134
- runEffects(reduceSticky(current, action, inputsRef.current).effects);
135
- }, [runEffects]);
136
- dispatchRef.current = dispatch;
137
- // Rebuild when the collision/viewport inputs change (RN effect deps minus the layout state, which
138
- // dispatches 'layout' itself); also does the initial identity build on mount.
139
- useEffect(() => {
140
- dispatchRef.current({ kind: 'inputs-changed' });
141
- }, [inverted, scrollViewHeight, nextHeaderY, scrollAnimatedValue]);
142
- // Detach the listener + clear the debounce on unmount.
143
- useEffect(() => () => {
144
- const previous = interpolationRef.current;
145
- if (previous !== null && listenerIdRef.current !== null)
146
- previous.removeListener(listenerIdRef.current);
147
- if (debounceTimer.current !== null)
148
- clearTimeout(debounceTimer.current);
149
- }, []);
150
- const onLayout = (event) => {
151
- const y = readLayoutNumber(event, 'y');
152
- const height = readLayoutNumber(event, 'height');
153
- // Keep the previous value when a field is absent (RN sets state only on a defined read).
154
- dispatch({
155
- kind: 'layout',
156
- y: y ?? state.layoutY,
157
- height: height ?? state.layoutHeight,
158
- });
159
- props.onLayout(event);
160
- const child = firstChild(children);
161
- const childOnLayout = child === undefined ? undefined : readChildOnLayout(child);
162
- childOnLayout?.(event);
163
- };
164
- // The EXPLICIT debounced translateY overrides the committed transform for hit-testing, while
165
- // `animatedTranslateY` does the smooth (native-driven) pin, per RN ScrollViewStickyHeader.js.
166
- const passthroughAnimatedPropExplicitValues = state.translateY !== null
167
- ? { style: { transform: [{ translateY: state.translateY }] } }
168
- : null;
169
- // collapsable:false keeps the wrapper a real Yoga node; zIndex makes the pinned header paint
170
- // OVER the rows scrolling under it. `style` is `unknown` on Animated.View, so the interpolation
171
- // transform passes with no cast.
172
- return createElement(Animated.View, {
173
- style: {
174
- transform: [{ translateY: animatedTranslateY }],
175
- zIndex: STICKY_HEADER_Z_INDEX,
176
- },
177
- onLayout,
178
- collapsable: false,
179
- passthroughAnimatedPropExplicitValues,
180
- }, children);
181
- };
182
- ScrollViewStickyHeader.displayName = 'ScrollViewStickyHeader';
183
- // Wrap each child flagged by `stickyHeaderIndices` in the sticky header component, fed by the
184
- // shared scroll AnimatedValue. Mirrors ScrollView.js's render-time children.map (~line 1690).
185
- // Returns the children unchanged when no indices are flagged.
186
- //
187
- // Cross-talk plumbing (RN's _headerLayoutYs + _onStickyHeaderLayout, ScrollView.js:1115-1143):
188
- // `headerLayoutYs` is a child-index→measured-y map the parent keeps; each header reports its own
189
- // y through `onHeaderLayoutY` as it measures, and we feed every header the y of the NEXT flagged
190
- // header (the collision point past which it scrolls off) by looking up its successor's index in
191
- // `stickyHeaderIndices`. The LAST flagged header has no successor, so its `nextHeaderLayoutY`
192
- // stays undefined and it sticks indefinitely (correct).
193
- export function wrapStickyHeaders(children, stickyHeaderIndices, scrollAnimatedValue, invertStickyHeaders, scrollViewHeight, StickyHeaderComponent, crossTalk) {
194
- const { headerLayoutYs, onHeaderLayoutY, onHeaderRef } = crossTalk;
195
- if (stickyHeaderIndices === undefined || stickyHeaderIndices.length === 0)
196
- return children;
197
- const Wrapper = StickyHeaderComponent ?? ScrollViewStickyHeader;
198
- return Children.toArray(children).map((child, index) => {
199
- const indexOfIndex = stickyHeaderIndices.indexOf(index);
200
- if (indexOfIndex === -1 || !isValidElement(child))
201
- return child;
202
- // The next flagged header's measured y, by index order in stickyHeaderIndices (RN
203
- // ScrollView.js:1695 nextIndex). undefined until that header has measured (or for the last).
204
- const nextIndex = stickyHeaderIndices[indexOfIndex + 1];
205
- const nextHeaderLayoutY = nextStickyHeaderY(stickyHeaderIndices, indexOfIndex, headerLayoutYs);
206
- dlog(`ScrollView sticky-header wrap index=${index} next=${nextIndex} nextY=${nextHeaderLayoutY}`);
207
- return createElement(Wrapper, {
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),
213
- nextHeaderLayoutY,
214
- // RN _onStickyHeaderLayout: record this header's own y, then push it to the previous
215
- // header as its nextHeaderLayoutY. We record into the parent map; the lookup above feeds
216
- // it forward on the resulting re-render.
217
- onLayout: (event) => {
218
- const y = readLayoutNumber(event, 'y');
219
- if (y !== undefined)
220
- onHeaderLayoutY(index, y);
221
- },
222
- scrollAnimatedValue,
223
- inverted: invertStickyHeaders,
224
- scrollViewHeight,
225
- }, child);
226
- });
227
- }
@@ -1,3 +0,0 @@
1
- import type { ISwitchProps } from './shared';
2
- export type { ISwitchProps, ISwitchTrackColor } from './shared';
3
- export declare function Switch(rawProps: ISwitchProps): import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
@@ -1,18 +0,0 @@
1
- // Switch, Android host binding. AndroidSwitch takes trackColorForTrue / trackColorForFalse
2
- // plus trackTintColor (the color for the CURRENT value, which RN computes as value ? true :
3
- // false), and snaps native back via the `setNativeValue` command (Switch.js:221-225).
4
- import { descriptorToReact } from '../../descriptor-to-react/index.js';
5
- import { useSwitchLogic } from './shared.js';
6
- const PLATFORM = {
7
- snapBackCommand: 'setNativeValue',
8
- trackColorProps: (value, trackColor) => ({
9
- trackColorForFalse: trackColor?.false,
10
- trackColorForTrue: trackColor?.true,
11
- trackTintColor: value ? trackColor?.true : trackColor?.false,
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 +0,0 @@
1
- export * from './index.ios';
@@ -1,3 +0,0 @@
1
- import type { ISwitchProps } from './shared';
2
- export type { ISwitchProps, ISwitchTrackColor } from './shared';
3
- export declare function Switch(rawProps: ISwitchProps): import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
@@ -1,16 +0,0 @@
1
- // Switch, iOS host binding. iOS's native Switch takes onTintColor (ON-track) / tintColor
2
- // (OFF-track), and snaps native back via the `setValue` command (Switch.js:221-225).
3
- import { descriptorToReact } from '../../descriptor-to-react/index.js';
4
- import { useSwitchLogic } from './shared.js';
5
- const PLATFORM = {
6
- snapBackCommand: 'setValue',
7
- trackColorProps: (_value, trackColor) => ({
8
- onTintColor: trackColor?.true,
9
- tintColor: trackColor?.false,
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,6 +0,0 @@
1
- // Base / default Switch: re-exports the iOS build. Metro overrides this with switch.ios.ts /
2
- // switch.android.ts on a real host; under tsx / tsc / web the host config resolves here.
3
- // Filename is the selector, no Platform.OS read. The logic lives in
4
- // @symbiote-native/components/state, the render in @symbiote-native/components/view, and the
5
- // hook in switch-shared.
6
- export * from './index.ios.js';
@@ -1,9 +0,0 @@
1
- import type { ISwitchPlatform, ISwitchProps as ISwitchBaseProps } from '@symbiote-native/components';
2
- export type { ISwitchTrackColor } from '@symbiote-native/components';
3
- export type ISwitchProps = ISwitchBaseProps & {
4
- className?: string;
5
- };
6
- export type ISwitchHostPlatform = ISwitchPlatform & {
7
- snapBackCommand: string;
8
- };
9
- export declare function useSwitchLogic(rawProps: ISwitchProps, platform: ISwitchHostPlatform): import("@symbiote-native/components").IDescriptor;
@@ -1,60 +0,0 @@
1
- // Switch is the React lifecycle half. The logic (the lastNativeReport reducer, valueFromChange,
2
- // the snap-back decision) lives in @symbiote-native/components/state, the render (value fold, track
3
- // colors, ios_backgroundColor) in @symbiote-native/components/view; here the adapter only owns the
4
- // hook: useReducer over the shared reducer, a ref for the imperative command, and the
5
- // useLayoutEffect that snaps native back when the parent rejects a toggle.
6
- //
7
- // Switch is controlled exactly like RN's: the parent's onValueChange MUST update `value` for
8
- // the toggle to stick. If the handler is a no-op, native has already flipped its own grip, so
9
- // JS commands the old value back down (a plain prop re-push cannot cover the no-op case: the
10
- // prop never changes, so the retained tree never diverges). The command name is platform-
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.
17
- import { useCallback, useLayoutEffect, useReducer, useRef } from 'react';
18
- import { renderSwitch, switchReducer, createInitialSwitchState, shouldSnapBack, valueFromChange, } from '@symbiote-native/components';
19
- import { dispatchViewCommand, dlog, } from '@symbiote-native/engine';
20
- import { resolveAccessibilityProps } from '@symbiote-native/components';
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);
60
- }