@symbiote-native/solid 0.0.1 → 0.1.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 (105) hide show
  1. package/build/bootstrap.d.ts +7 -0
  2. package/build/bootstrap.js +18 -0
  3. package/build/components/activity-indicator/index.android.d.ts +2 -0
  4. package/build/components/activity-indicator/index.android.js +11 -0
  5. package/build/components/activity-indicator/index.d.ts +1 -0
  6. package/build/components/activity-indicator/index.ios.d.ts +2 -0
  7. package/build/components/activity-indicator/index.ios.js +10 -0
  8. package/build/components/activity-indicator/index.js +4 -0
  9. package/build/components/activity-indicator/shared.d.ts +8 -0
  10. package/build/components/activity-indicator/shared.js +51 -0
  11. package/build/components/button.d.ts +7 -0
  12. package/build/components/button.jsx +45 -0
  13. package/build/components/flat-list/index.d.ts +10 -0
  14. package/build/components/flat-list/index.jsx +118 -0
  15. package/build/components/image-background.d.ts +10 -0
  16. package/build/components/image-background.jsx +74 -0
  17. package/build/components/image.d.ts +9 -0
  18. package/build/components/image.js +75 -0
  19. package/build/components/input-accessory-view.d.ts +11 -0
  20. package/build/components/input-accessory-view.jsx +39 -0
  21. package/build/components/keyboard-avoiding-view.d.ts +15 -0
  22. package/build/components/keyboard-avoiding-view.jsx +152 -0
  23. package/build/components/modal.d.ts +24 -0
  24. package/build/components/modal.jsx +125 -0
  25. package/build/components/pressable.d.ts +30 -0
  26. package/build/components/pressable.jsx +185 -0
  27. package/build/components/refresh-control.d.ts +18 -0
  28. package/build/components/refresh-control.jsx +36 -0
  29. package/build/components/safe-area-view.d.ts +10 -0
  30. package/build/components/safe-area-view.jsx +24 -0
  31. package/build/components/scroll-view/index.android.d.ts +2 -0
  32. package/build/components/scroll-view/index.android.js +11 -0
  33. package/build/components/scroll-view/index.d.ts +2 -0
  34. package/build/components/scroll-view/index.ios.d.ts +2 -0
  35. package/build/components/scroll-view/index.ios.js +8 -0
  36. package/build/components/scroll-view/index.js +9 -0
  37. package/build/components/scroll-view/shared.d.ts +85 -0
  38. package/build/components/scroll-view/shared.jsx +334 -0
  39. package/build/components/scroll-view/sticky-header.d.ts +18 -0
  40. package/build/components/scroll-view/sticky-header.jsx +182 -0
  41. package/build/components/section-list/index.d.ts +6 -0
  42. package/build/components/section-list/index.jsx +8 -0
  43. package/build/components/switch/index.android.d.ts +2 -0
  44. package/build/components/switch/index.android.js +12 -0
  45. package/build/components/switch/index.d.ts +1 -0
  46. package/build/components/switch/index.ios.d.ts +2 -0
  47. package/build/components/switch/index.ios.js +11 -0
  48. package/build/components/switch/index.js +4 -0
  49. package/build/components/switch/shared.d.ts +11 -0
  50. package/build/components/switch/shared.js +87 -0
  51. package/build/components/text-input.d.ts +10 -0
  52. package/build/components/text-input.js +268 -0
  53. package/build/components/text.d.ts +26 -0
  54. package/build/components/text.jsx +27 -0
  55. package/build/components/touchable/index.d.ts +22 -0
  56. package/build/components/touchable/index.jsx +247 -0
  57. package/build/components/touchable-native-feedback/index.d.ts +16 -0
  58. package/build/components/touchable-native-feedback/index.jsx +68 -0
  59. package/build/components/view.d.ts +32 -0
  60. package/build/components/view.jsx +49 -0
  61. package/build/components/virtualized-list/index.android.d.ts +2 -0
  62. package/build/components/virtualized-list/index.android.js +9 -0
  63. package/build/components/virtualized-list/index.d.ts +1 -0
  64. package/build/components/virtualized-list/index.ios.d.ts +2 -0
  65. package/build/components/virtualized-list/index.ios.js +9 -0
  66. package/build/components/virtualized-list/index.js +4 -0
  67. package/build/components/virtualized-list/shared.d.ts +75 -0
  68. package/build/components/virtualized-list/shared.jsx +782 -0
  69. package/build/components/virtualized-section-list/index.d.ts +62 -0
  70. package/build/components/virtualized-section-list/index.jsx +145 -0
  71. package/build/components.d.ts +43 -0
  72. package/build/components.js +54 -0
  73. package/build/create-portal/index.d.ts +11 -0
  74. package/build/create-portal/index.jsx +88 -0
  75. package/build/create-tunnel/index.d.ts +13 -0
  76. package/build/create-tunnel/index.jsx +53 -0
  77. package/build/descriptor-to-solid.d.ts +3 -0
  78. package/build/descriptor-to-solid.js +91 -0
  79. package/build/host-instance.d.ts +2 -0
  80. package/build/host-instance.js +36 -0
  81. package/build/index.d.ts +30 -0
  82. package/build/index.js +96 -0
  83. package/build/jsx-runtime.d.ts +26 -0
  84. package/build/jsx-runtime.js +46 -0
  85. package/build/modules/animated/create-animated-component.d.ts +10 -0
  86. package/build/modules/animated/create-animated-component.jsx +105 -0
  87. package/build/modules/animated/index.d.ts +54 -0
  88. package/build/modules/animated/index.js +78 -0
  89. package/build/modules/app-registry/index.d.ts +7 -0
  90. package/build/modules/app-registry/index.js +37 -0
  91. package/build/modules/status-bar.d.ts +7 -0
  92. package/build/modules/status-bar.js +45 -0
  93. package/build/primitives/create-color-scheme.d.ts +3 -0
  94. package/build/primitives/create-color-scheme.js +33 -0
  95. package/build/primitives/create-window-dimensions.d.ts +3 -0
  96. package/build/primitives/create-window-dimensions.js +34 -0
  97. package/build/render.d.ts +7 -0
  98. package/build/render.js +91 -0
  99. package/build/renderer.d.ts +7 -0
  100. package/build/renderer.js +195 -0
  101. package/build/utils/host-ref.d.ts +3 -0
  102. package/build/utils/host-ref.js +22 -0
  103. package/build/utils/stable-keys.d.ts +1 -0
  104. package/build/utils/stable-keys.js +31 -0
  105. package/package.json +5 -5
@@ -0,0 +1,8 @@
1
+ // ScrollView on iOS. The RefreshControl (PullToRefreshView) is a CHILD of the scroll view, rendered
2
+ // as a childless SIBLING BEFORE the content container — RN ScrollView.js's
3
+ // `{refreshControl}{contentContainer}`, the same fact adapters/react's, adapters/vue's and
4
+ // adapters/svelte's iOS bindings encode. Metro picks this file on an iOS host; no Platform.OS read.
5
+ import { createScrollView } from './shared.jsx';
6
+ export const ScrollView = createScrollView({
7
+ refreshControlMode: 'sibling',
8
+ });
@@ -0,0 +1,9 @@
1
+ // Base / default ScrollView: re-exports the iOS build. Metro overrides this with index.ios.ts /
2
+ // index.android.ts on a real host; under vitest / tsc the resolution lands here. Filename is the
3
+ // selector, no Platform.OS read.
4
+ //
5
+ // ScrollViewStickyHeader is exported from here rather than from the platform files: it has no
6
+ // platform branch of its own, and an app may compose it directly around a section instead of going
7
+ // through `stickyHeaderIndices`.
8
+ export * from './index.ios.js';
9
+ export { ScrollViewStickyHeader } from './sticky-header.jsx';
@@ -0,0 +1,85 @@
1
+ import { type Ref } from 'solid-js';
2
+ import type { JSX } from '../../jsx-runtime';
3
+ import { type IAccessibilityProps, type IAriaProps, type IScrollViewHandle } from '@symbiote-native/components';
4
+ import { type IClassNameValue, type IStyleProp, type ISymbioteEvent, type IViewStyle } from '@symbiote-native/engine';
5
+ import { type IStickyHeaderComponentType } from './sticky-header';
6
+ export type { IScrollViewHandle } from '@symbiote-native/components';
7
+ export type { IStickyHeaderComponentType, IStickyHeaderComponentProps, } from './sticky-header';
8
+ type IScrollHandler = (event: ISymbioteEvent) => void;
9
+ export type IScrollViewHostPlatform = {
10
+ refreshControlMode: 'sibling' | 'wrap';
11
+ };
12
+ export interface IScrollViewProps extends IAccessibilityProps, IAriaProps {
13
+ style?: IStyleProp<IViewStyle>;
14
+ class?: IClassNameValue;
15
+ contentContainerStyle?: IStyleProp<IViewStyle> | string;
16
+ horizontal?: boolean;
17
+ scrollEnabled?: boolean;
18
+ showsVerticalScrollIndicator?: boolean;
19
+ showsHorizontalScrollIndicator?: boolean;
20
+ pagingEnabled?: boolean;
21
+ bounces?: boolean;
22
+ decelerationRate?: 'normal' | 'fast' | number;
23
+ scrollEventThrottle?: number;
24
+ contentInset?: {
25
+ top?: number;
26
+ left?: number;
27
+ bottom?: number;
28
+ right?: number;
29
+ };
30
+ contentOffset?: {
31
+ x: number;
32
+ y: number;
33
+ };
34
+ refreshControl?: JSX.Element;
35
+ removeClippedSubviews?: boolean;
36
+ onContentSizeChange?: (width: number, height: number) => void;
37
+ snapToInterval?: number;
38
+ snapToOffsets?: number[];
39
+ snapToAlignment?: 'start' | 'center' | 'end';
40
+ snapToStart?: boolean;
41
+ snapToEnd?: boolean;
42
+ disableIntervalMomentum?: boolean;
43
+ stickyHeaderIndices?: number[];
44
+ invertStickyHeaders?: boolean;
45
+ StickyHeaderComponent?: IStickyHeaderComponentType;
46
+ keyboardDismissMode?: 'none' | 'on-drag' | 'interactive';
47
+ keyboardShouldPersistTaps?: boolean | 'always' | 'never' | 'handled';
48
+ maintainVisibleContentPosition?: {
49
+ minIndexForVisible: number;
50
+ autoscrollToTopThreshold?: number;
51
+ };
52
+ alwaysBounceHorizontal?: boolean;
53
+ alwaysBounceVertical?: boolean;
54
+ centerContent?: boolean;
55
+ scrollIndicatorInsets?: {
56
+ top?: number;
57
+ left?: number;
58
+ bottom?: number;
59
+ right?: number;
60
+ };
61
+ indicatorStyle?: 'default' | 'black' | 'white';
62
+ directionalLockEnabled?: boolean;
63
+ automaticallyAdjustKeyboardInsets?: boolean;
64
+ contentInsetAdjustmentBehavior?: 'automatic' | 'scrollableAxes' | 'never' | 'always';
65
+ minimumZoomScale?: number;
66
+ maximumZoomScale?: number;
67
+ zoomScale?: number;
68
+ bouncesZoom?: boolean;
69
+ pinchGestureEnabled?: boolean;
70
+ nestedScrollEnabled?: boolean;
71
+ overScrollMode?: 'auto' | 'always' | 'never';
72
+ fadingEdgeLength?: number;
73
+ persistentScrollbar?: boolean;
74
+ endFillColor?: string;
75
+ onLayout?: IScrollHandler;
76
+ onScroll?: IScrollHandler;
77
+ onScrollBeginDrag?: IScrollHandler;
78
+ onScrollEndDrag?: IScrollHandler;
79
+ onMomentumScrollBegin?: IScrollHandler;
80
+ onMomentumScrollEnd?: IScrollHandler;
81
+ onScrollToTop?: IScrollHandler;
82
+ ref?: Ref<IScrollViewHandle>;
83
+ children?: JSX.Element;
84
+ }
85
+ export declare function createScrollView(platform: IScrollViewHostPlatform): (props: IScrollViewProps) => JSX.Element;
@@ -0,0 +1,334 @@
1
+ // ScrollView — the Solid lifecycle half. The Fabric tree is nested: a scroll view wraps a content
2
+ // view that holds the children (RN's own ScrollView.js shape). Everything platform- and
3
+ // framework-invariant already exists in @symbiote-native/components and is CALLED, never re-derived:
4
+ // `selectScrollIntrinsics` (per-axis tags + base styles), `resolveDecelerationRate`,
5
+ // `resolveScrollForwarding` (which onScroll path, the 1/16 throttle defaults, the inverted viewport
6
+ // capture, collapsableChildren), `didContentSizeChange`, `buildScrollViewHandle`,
7
+ // `splitLayoutProps`, `attachStickyScroll`, `forwardScrollEvent`, `resolveAccessibilityProps`.
8
+ // Solid supplies signals, effects and the element assembly.
9
+ //
10
+ // THREE THINGS ARE SOLID-SPECIFIC AND NONE OF THEM IS COSMETIC.
11
+ //
12
+ // 1. There is no reconciler between what this file returns and the host nodes. React diffs fibers,
13
+ // Vue diffs vnodes, Svelte patches in place; Solid's `insert` REPLACES. So the tree is built ONCE
14
+ // and every prop rides a `spread` render effect on the SAME node — the identity
15
+ // dispatchViewCommand, the commit mirror and native-owned scroll offset all key on. The only
16
+ // rebuild is when the host TAG itself has to change (see `treeShape` below).
17
+ // 2. A JSX element prop is a GETTER that CREATES the element on read, so `refreshControl` is read
18
+ // exactly once. A second read would build a second refresh-control node.
19
+ // 3. The commit is microtask-coalesced (`requestCommit`), so at mount time the scroll node has no
20
+ // Fabric tag. Anything native wired then — here, the sticky scroll attach — goes through
21
+ // `whenCommitted` or it silently no-ops with no retry. The imperative handle is safe without it
22
+ // for a different reason: `buildScrollViewHandle` takes a LAZY node getter and every command
23
+ // re-reads it, so a handle handed out at setup keeps working once the node commits.
24
+ //
25
+ // NOTHING here destructures `props`. Solid props are getters and a component body runs ONCE;
26
+ // `splitProps` is the idiomatic split that keeps the rest reactive, and every read below sits inside
27
+ // an accessor, a memo, or an event handler.
28
+ import { children, createEffect, createMemo, createRenderEffect, createSignal, on, onCleanup, splitProps, untrack, } from 'solid-js';
29
+ import { attachStickyScroll, buildScrollViewHandle, didContentSizeChange, forwardScrollEvent, nextStickyHeaderY, readLayoutDimension, resolveAccessibilityProps, resolveDecelerationRate, resolveScrollForwarding, selectScrollIntrinsics, splitLayoutProps, } from '@symbiote-native/components';
30
+ import { AnimatedValue, dlog, event as animatedEvent, isNativeAnimatedAvailable, isSymbioteNode, resolveClassName, whenCommitted, } from '@symbiote-native/engine';
31
+ import { createElement, insert, insertNode, setProp, spread, } from '../../renderer.js';
32
+ import { withStableKeys } from '../../utils/stable-keys.js';
33
+ import { wrapStickyHeaders, } from './sticky-header.jsx';
34
+ // Consumed by the lifecycle itself; everything else forwards onto the scroll-view host node.
35
+ // onContentSizeChange is synthesized from the content view's onLayout and must never reach Fabric —
36
+ // a function prop crashes Android's folly::dynamic serializer. Same for the sticky props, which
37
+ // native ignores, and `refreshControl`, which is an element.
38
+ const HANDLED_PROPS = [
39
+ 'style',
40
+ 'class',
41
+ 'contentContainerStyle',
42
+ 'horizontal',
43
+ 'decelerationRate',
44
+ 'refreshControl',
45
+ 'stickyHeaderIndices',
46
+ 'invertStickyHeaders',
47
+ 'StickyHeaderComponent',
48
+ 'onContentSizeChange',
49
+ 'onLayout',
50
+ 'onScroll',
51
+ 'scrollEventThrottle',
52
+ 'children',
53
+ 'ref',
54
+ ];
55
+ // `maintainVisibleContentPosition`, `snapToAlignment` and `nestedScrollEnabled` are deliberately NOT
56
+ // in that list: the lifecycle READS them (for resolveScrollForwarding and RN's nested-scroll
57
+ // default) while native also needs them, so they forward through `rest` untouched.
58
+ function hostElement(tag) {
59
+ const node = createElement(tag);
60
+ // Narrowing, not defensive: the renderer types createElement over its IHostNode union (which
61
+ // includes the surface), while everything below needs a real host node.
62
+ if (!isSymbioteNode(node))
63
+ throw new Error(`ScrollView: ${tag} did not create a host node`);
64
+ return node;
65
+ }
66
+ export function createScrollView(platform) {
67
+ return function ScrollView(props) {
68
+ const [local, rest] = splitProps(props, HANDLED_PROPS);
69
+ // The scroll-view host node, held by IDENTITY in a plain variable. A store or any proxy wrapper
70
+ // would become a different key than the one the engine's commit mirror holds, and every
71
+ // imperative command would silently no-op (symbiote-engine-core §3).
72
+ let scrollNode = null;
73
+ // A LAZY getter, not the node captured once: it is null until buildTree runs, and null again
74
+ // under it if the tree is ever rebuilt.
75
+ const handle = buildScrollViewHandle(() => scrollNode);
76
+ // Read ONCE and untracked — see the module header, point 2. Nothing reactive is lost: a
77
+ // RefreshControl's live state is `refreshing`, which is a prop of the element itself.
78
+ const refreshControlElement = untrack(() => local.refreshControl);
79
+ const refreshControlNode = isSymbioteNode(refreshControlElement)
80
+ ? refreshControlElement
81
+ : undefined;
82
+ const isWrappingRefreshControl = platform.refreshControlMode === 'wrap' &&
83
+ refreshControlNode !== undefined;
84
+ // Drives every sticky header's translateY (RN's _scrollAnimatedValue). Allocated
85
+ // unconditionally, exactly like React's unconditional hook, and held by identity.
86
+ const scrollAnimatedValue = new AnimatedValue(0);
87
+ // Inverted sticky headers stick to the BOTTOM, so they need the viewport height (RN
88
+ // _handleLayout). Captured off the scroll view's own onLayout.
89
+ const [viewportHeight, setViewportHeight] = createSignal(undefined);
90
+ // Sticky-header cross-talk (RN's _headerLayoutYs): a child-index -> measured-y map so each
91
+ // header learns where the NEXT one starts — its push-off collision point. Mutated imperatively
92
+ // from each header's onLayout; the version bump is what lets the PREVIOUS header re-read it.
93
+ const headerLayoutYs = new Map();
94
+ const [headerLayoutVersion, setHeaderLayoutVersion] = createSignal(0);
95
+ // RN fires the content onLayout on every layout pass; only a real size change emits.
96
+ let lastContentSize = null;
97
+ const isHorizontal = () => local.horizontal === true;
98
+ const stickyHeaderIndices = createMemo(() => local.stickyHeaderIndices);
99
+ const hasStickyHeaders = () => {
100
+ const indices = stickyHeaderIndices();
101
+ return indices !== undefined && indices.length > 0;
102
+ };
103
+ const nativeStickyAvailable = () => hasStickyHeaders() && isNativeAnimatedAvailable();
104
+ // A class-name string resolves through the shared registry before it reaches the intrinsic
105
+ // selector, which only understands style objects/arrays.
106
+ const resolvedContentContainerStyle = () => {
107
+ const style = local.contentContainerStyle;
108
+ return typeof style === 'string' ? resolveClassName(style) : style;
109
+ };
110
+ const intrinsics = createMemo(() => selectScrollIntrinsics(isHorizontal(), resolvedContentContainerStyle()));
111
+ // `class` is normally forwarded raw and resolved per-node at commit time, but the Android
112
+ // RefreshControl wrap has to splitLayoutProps() BEFORE that, so a class-only layout prop
113
+ // (flex, height, gap, …) would otherwise never reach the wrapper and it would collapse to
114
+ // nothing.
115
+ const layoutSplitStyle = () => [
116
+ resolveClassName(local.class),
117
+ local.style,
118
+ ];
119
+ const splitStyles = createMemo(() => splitLayoutProps(layoutSplitStyle()));
120
+ const forwarding = createMemo(() => resolveScrollForwarding({
121
+ hasStickyHeaders: hasStickyHeaders(),
122
+ nativeStickyAvailable: nativeStickyAvailable(),
123
+ invertStickyHeaders: local.invertStickyHeaders,
124
+ scrollEventThrottle: local.scrollEventThrottle,
125
+ maintainVisibleContentPosition: props.maintainVisibleContentPosition,
126
+ snapToAlignment: props.snapToAlignment,
127
+ }));
128
+ // onScroll: the JS-fallback path wraps the user's handler in Animated.event so the offset drives
129
+ // the AnimatedValue each frame (RN's _scrollAnimatedValueAttachment); the native and plain paths
130
+ // forward it as-is, because the native driver attaches the value on the UI thread instead.
131
+ const scrollHandler = createMemo(() => {
132
+ const userOnScroll = local.onScroll;
133
+ if (forwarding().mode !== 'sticky-js')
134
+ return userOnScroll;
135
+ return animatedEvent([{ nativeEvent: { contentOffset: { y: scrollAnimatedValue } } }], userOnScroll === undefined
136
+ ? undefined
137
+ : {
138
+ listener: (...args) => forwardScrollEvent(userOnScroll, args),
139
+ });
140
+ });
141
+ // onLayout on the scroll-view node: capture the viewport height for inverted sticky headers
142
+ // (RN _handleLayout), then call the user's handler. Passed through unchanged otherwise, so a
143
+ // ScrollView with neither never raises the onLayout flag prop and native never measures it.
144
+ const scrollLayoutHandler = createMemo(() => {
145
+ const userOnLayout = local.onLayout;
146
+ if (!forwarding().capturesViewportHeight)
147
+ return userOnLayout;
148
+ return (event) => {
149
+ const height = readLayoutDimension(event, 'height');
150
+ if (height !== undefined)
151
+ setViewportHeight(height);
152
+ userOnLayout?.(event);
153
+ };
154
+ });
155
+ const handleContentLayout = (event) => {
156
+ const width = readLayoutDimension(event, 'width');
157
+ const height = readLayoutDimension(event, 'height');
158
+ if (width === undefined || height === undefined)
159
+ return;
160
+ if (!didContentSizeChange(lastContentSize, { width, height }))
161
+ return;
162
+ lastContentSize = { width, height };
163
+ dlog(`Solid ScrollView contentSizeChange ${width}x${height}`);
164
+ local.onContentSizeChange?.(width, height);
165
+ };
166
+ const onHeaderLayoutY = (index, y) => {
167
+ if (headerLayoutYs.get(index) === y)
168
+ return;
169
+ headerLayoutYs.set(index, y);
170
+ dlog(`Solid ScrollView sticky-header layoutY index=${index} y=${y}`);
171
+ setHeaderLayoutVersion(tick => tick + 1);
172
+ };
173
+ const readNextHeaderLayoutY = (indexOfIndex) => {
174
+ // Read the bump FIRST so this stays a dependency of whichever header calls it — that is what
175
+ // turns "a later header measured" into "the previous header rebuilds its collision range".
176
+ headerLayoutVersion();
177
+ const indices = stickyHeaderIndices();
178
+ if (indices === undefined)
179
+ return undefined;
180
+ return nextStickyHeaderY(indices, indexOfIndex, headerLayoutYs);
181
+ };
182
+ // withStableKeys on BOTH bags: resolveAccessibilityProps has two branches with different key
183
+ // sets, and several keys below are conditional. Solid's `spread` walks only the CURRENT keys and
184
+ // has no removal pass, so a vanished key would keep its last value on the native view forever
185
+ // (.claude/rules/solid-descriptor-bridge.md §1).
186
+ const outerBag = withStableKeys(() => {
187
+ const bag = {
188
+ ...resolveAccessibilityProps(rest),
189
+ };
190
+ // RN defaults nested scrolling ON (ScrollView.js `nestedScrollEnabled ?? true`): Android needs
191
+ // it for a scrollable nested inside another to scroll independently; iOS handles nesting
192
+ // natively. The Android RefreshControl wrap forces it, so the inner scroll view takes the
193
+ // gesture before the refresh parent.
194
+ bag.nestedScrollEnabled = isWrappingRefreshControl
195
+ ? true
196
+ : (props.nestedScrollEnabled ?? true);
197
+ // Load-bearing on iOS (it flips RCTScrollView's axis), ignored by Android's dedicated
198
+ // horizontal manager — so forward it whenever it is defined.
199
+ if (local.horizontal !== undefined)
200
+ bag.horizontal = local.horizontal;
201
+ const rate = local.decelerationRate;
202
+ if (rate !== undefined)
203
+ bag.decelerationRate = resolveDecelerationRate(rate);
204
+ // Base style UNDER the user style, so an explicit height / flexDirection still wins. Under the
205
+ // Android wrap only the VISUAL half stays here; the LAYOUT half moved to the wrapper.
206
+ bag.style = [
207
+ intrinsics().scrollViewBaseStyle,
208
+ isWrappingRefreshControl ? splitStyles().inner : local.style,
209
+ ];
210
+ // Stripped under the wrap: layoutSplitStyle already folded the resolved class into
211
+ // outer/inner, so forwarding it raw too would re-apply its LAYOUT half a second time.
212
+ if (!isWrappingRefreshControl)
213
+ bag.class = local.class;
214
+ bag.onScroll = scrollHandler();
215
+ bag.onLayout = scrollLayoutHandler();
216
+ bag.scrollEventThrottle = forwarding().scrollEventThrottle;
217
+ return bag;
218
+ });
219
+ // `collapsable: false` is load-bearing on Android: the content container is a layout-only view
220
+ // that Fabric would otherwise flatten away, hoisting the cells up as DIRECT children of the
221
+ // scroll view — which hosts exactly one ("addViewAt" crash). iOS never flattens.
222
+ const contentBag = withStableKeys(() => {
223
+ const bag = {
224
+ style: intrinsics().contentStyle,
225
+ collapsable: false,
226
+ };
227
+ // maintainVisibleContentPosition (and Android snapToAlignment) anchor against the metrics of
228
+ // MOUNTED cell views, so RN keeps them un-flattened too (ScrollView.js preserveChildren).
229
+ if (forwarding().collapsableChildren)
230
+ bag.collapsableChildren = false;
231
+ if (local.onContentSizeChange !== undefined)
232
+ bag.onLayout = handleContentLayout;
233
+ return bag;
234
+ });
235
+ function mountChildren(content) {
236
+ if (!hasStickyHeaders()) {
237
+ // The plain path hands `insert` the raw children accessor, so its own nested-effect
238
+ // machinery keeps updates fine-grained — a <For> inside re-runs only its own insert.
239
+ insert(content, () => local.children);
240
+ return;
241
+ }
242
+ // Sticky wrapping needs an INDEXABLE list, which is what solid's `children()` helper resolves
243
+ // to. It costs a coarser update (any child change re-runs the whole wrap), which is why it is
244
+ // created ONLY here and not for every ScrollView — and it must not coexist with the accessor
245
+ // above, since reading `local.children` twice would build the subtree twice.
246
+ const resolved = children(() => local.children);
247
+ insert(content, () => wrapStickyHeaders(resolved.toArray(), {
248
+ stickyHeaderIndices: stickyHeaderIndices() ?? [],
249
+ scrollAnimatedValue,
250
+ readInverted: () => local.invertStickyHeaders,
251
+ readScrollViewHeight: viewportHeight,
252
+ readNextHeaderLayoutY,
253
+ StickyHeaderComponent: local.StickyHeaderComponent,
254
+ onHeaderLayoutY,
255
+ }));
256
+ }
257
+ function buildTree() {
258
+ const { scrollViewIntrinsic, contentIntrinsic } = intrinsics();
259
+ dlog(`Solid ScrollView -> ${scrollViewIntrinsic} (horizontal=${String(isHorizontal())} sticky=${String(hasStickyHeaders())})`);
260
+ const content = hostElement(contentIntrinsic);
261
+ spread(content, contentBag, true);
262
+ mountChildren(content);
263
+ const scroll = hostElement(scrollViewIntrinsic);
264
+ scrollNode = scroll;
265
+ spread(scroll, outerBag, true);
266
+ // iOS: the RefreshControl is a childless SIBLING placed BEFORE the content container (RN
267
+ // ScrollView.js: {refreshControl}{contentContainer}).
268
+ if (refreshControlNode !== undefined && !isWrappingRefreshControl) {
269
+ insertNode(scroll, refreshControlNode);
270
+ }
271
+ insertNode(scroll, content);
272
+ if (refreshControlNode === undefined || !isWrappingRefreshControl)
273
+ return scroll;
274
+ // Android: the RefreshControl WRAPS the scroll view. React does this with cloneElement and Vue
275
+ // by re-invoking the VNode's type; Solid has neither, because the element arrived already
276
+ // built — so the scroll view is nested into the existing node and the outer style is written
277
+ // onto it. A render effect, not a one-shot set, so a later style/class change still moves.
278
+ //
279
+ // This OVERRIDES a `style` the app put on the RefreshControl itself, exactly as React's
280
+ // cloneElement({style}) does. Unlike React's, the two writers are independent render effects,
281
+ // so an app that styles BOTH boxes on Android has no defined winner — pass the ScrollView's
282
+ // layout through `style`/`class` there and leave the control's own `style` alone.
283
+ createRenderEffect(() => {
284
+ setProp(refreshControlNode, 'style', splitStyles().outer);
285
+ });
286
+ insertNode(refreshControlNode, scroll);
287
+ return refreshControlNode;
288
+ }
289
+ // The scroll AXIS picks a different host TAG (horizontal is a separate ViewManager on Android)
290
+ // and sticky headers pick a different children pipeline. Solid cannot swap a tag under a live
291
+ // node, so either flip REBUILDS — which is exactly what React does when an element type changes.
292
+ // `on()` runs buildTree untracked, so every other read inside it (style, class, handlers,
293
+ // contentContainerStyle) re-props the SAME nodes through `spread` instead of rebuilding them.
294
+ const treeShape = createMemo(() => `${String(isHorizontal())}:${String(hasStickyHeaders())}`);
295
+ const tree = createMemo(on(treeShape, () => buildTree()));
296
+ // Solid's `ref` is a COMPILE-TIME construct: by the time a component body reads `props.ref`, a
297
+ // `ref={scroller}` call site has already been rewritten into a callback. The full rationale (and
298
+ // why the declared type still has to be the `Ref<T>` union) is in utils/host-ref.ts, whose
299
+ // helper is typed for a host node — this hands back the imperative handle instead.
300
+ //
301
+ // Called AFTER `tree` has built at least once, not right after `handle` is constructed.
302
+ // `createAnimatedComponent`'s captureRef (resolveHostNode) reads handle.getScrollNode()
303
+ // EAGERLY, once, at ref-call time - it never re-reads the lazy getter later. React's
304
+ // useImperativeHandle and Vue's expose() fire only after the child's own mount work, so they
305
+ // never saw a null handle; Solid's `ref` is called by the component itself and has to be
306
+ // sequenced by hand. Calling it before `buildTree()` gave captureRef a permanently-null node,
307
+ // so Animated.ScrollView's onScroll never native-attached - a scroll-linked header fade froze
308
+ // at its initial value on this adapter only.
309
+ if (typeof local.ref === 'function')
310
+ local.ref(handle);
311
+ // Drive the sticky scroll value on the native UI thread (RN's attachNativeEvent /
312
+ // _updateAnimatedNodeAttachment), so the header interpolations ride scroll natively with no JS
313
+ // jitter. `tree()` is read for its dependency: a rebuild means a NEW scroll node to attach to.
314
+ createEffect(() => {
315
+ tree();
316
+ if (!nativeStickyAvailable())
317
+ return;
318
+ const node = scrollNode;
319
+ if (node === null)
320
+ return;
321
+ let detach;
322
+ // The engine commits on a microtask, so this node has no Fabric tag yet on the first run and
323
+ // the attach would no-op with no retry. whenCommitted is that retry.
324
+ const cancel = whenCommitted(node, () => {
325
+ detach = attachStickyScroll(node, scrollAnimatedValue);
326
+ });
327
+ onCleanup(() => {
328
+ cancel();
329
+ detach?.();
330
+ });
331
+ });
332
+ return tree;
333
+ };
334
+ }
@@ -0,0 +1,18 @@
1
+ import type { JSX } from '../../jsx-runtime';
2
+ import { type IStickyHeaderProps } from '@symbiote-native/components';
3
+ import { type AnimatedValue } from '@symbiote-native/engine';
4
+ export type IStickyHeaderComponentProps = IStickyHeaderProps & {
5
+ children?: JSX.Element;
6
+ };
7
+ export type IStickyHeaderComponentType = (props: IStickyHeaderComponentProps) => JSX.Element;
8
+ export declare function ScrollViewStickyHeader(props: IStickyHeaderComponentProps): JSX.Element;
9
+ export interface IStickyWrapInputs {
10
+ stickyHeaderIndices: number[];
11
+ scrollAnimatedValue: AnimatedValue;
12
+ readInverted: () => boolean | undefined;
13
+ readScrollViewHeight: () => number | undefined;
14
+ readNextHeaderLayoutY: (indexOfIndex: number) => number | undefined;
15
+ StickyHeaderComponent: IStickyHeaderComponentType | undefined;
16
+ onHeaderLayoutY: (index: number, y: number) => void;
17
+ }
18
+ export declare function wrapStickyHeaders(children: readonly JSX.Element[], inputs: IStickyWrapInputs): JSX.Element[];
@@ -0,0 +1,182 @@
1
+ // Sticky headers — the Solid lifecycle half of the JS layer RN implements in ScrollView.js /
2
+ // ScrollViewStickyHeader.js.
3
+ //
4
+ // RN does stickiness PURELY IN JS: ScrollView wraps each flagged child in a ScrollViewStickyHeader
5
+ // fed by ONE scroll AnimatedValue, and the native scroll view ignores `stickyHeaderIndices`
6
+ // entirely — forwarding that array to Fabric is a silent no-op, so we replicate the JS layer
7
+ // instead. The math (top and inverted input/output ranges) is `computeStickyInterpolation` and the
8
+ // DECISIONS (the zero-swallow gate, the debounce window, when to rebuild) are `reduceSticky`, both
9
+ // in @symbiote-native/components and both shared verbatim with React, Vue and Angular. This file
10
+ // contributes only Solid's reactivity: one signal for the interpolation node, one version counter,
11
+ // the listener/debounce wiring, and the child-wrapping.
12
+ //
13
+ // WHY THE WRAPPER TAKES ITS REACTIVE INPUTS AS PROPS AND NOT AS RESOLVED VALUES. Every prop below
14
+ // that changes over time — `nextHeaderLayoutY` (cross-talk as a later header measures),
15
+ // `scrollViewHeight` (the inverted viewport capture), `inverted` — is written into the JSX as a
16
+ // CALL, so the Solid compiler emits it as a getter and the header reads it inside its OWN effect.
17
+ // That is not a style choice: the wrap runs inside the content view's `insert` render effect, and
18
+ // `insert` REPLACES a subtree rather than diffing one, so a value read there would put those
19
+ // signals in the insert effect's dependency set and every header layout would tear down and rebuild
20
+ // every header. React/Vue/Angular can pass plain values because each has a node-reusing layer
21
+ // underneath; Solid does not (.claude/rules/solid-descriptor-bridge.md §4).
22
+ import { createEffect, createMemo, createSignal, onCleanup } from 'solid-js';
23
+ import { createInitialStickyState, readLayoutNumber, reduceSticky, STICKY_HEADER_Z_INDEX, } from '@symbiote-native/components';
24
+ import { AnimatedInterpolation, Platform, dlog, isSymbioteNode, } from '@symbiote-native/engine';
25
+ import { Animated } from '../../modules/animated/index.js';
26
+ // One sticky header. Measures its own y/height through onLayout, interpolates the shared scroll
27
+ // offset into a translateY that pins it to the top (or the bottom, inverted) until the next header
28
+ // collides with it, and drives that translate through the native driver when available so the pin
29
+ // tracks scroll on the UI thread with no JS per frame.
30
+ export function ScrollViewStickyHeader(props) {
31
+ // The ONE folded state cell (RN's scattered useState/useRef, collapsed into IStickyHeaderState),
32
+ // mutated in place by reduceSticky. A plain object, never a signal: nothing reads it reactively —
33
+ // the two signals below are what render depends on.
34
+ const state = createInitialStickyState();
35
+ // Bumped on every committed translateY so the passthrough memo re-reads state.translateY (RN's
36
+ // forceRender()).
37
+ const [version, setVersion] = createSignal(0);
38
+ // Read ONCE and held by identity: the value is stable for the header's lifetime by contract (the
39
+ // parent allocates exactly one per ScrollView).
40
+ const scrollAnimatedValue = props.scrollAnimatedValue;
41
+ // The interpolation node currently bound into style.transform — the un-measured identity stub
42
+ // until the first rebuild-interpolation effect replaces it. A signal, not a plain variable:
43
+ // render has to repaint when it is swapped.
44
+ const [animatedTranslateY, setAnimatedTranslateY] = createSignal(scrollAnimatedValue.interpolate({
45
+ inputRange: [-1, 0],
46
+ outputRange: [0, 0],
47
+ }));
48
+ // Held so the next rebuild can detach the previous settled-value listener (an engine call the
49
+ // reducer does not own) and cleanup can drop both.
50
+ let interpolation;
51
+ let listenerId;
52
+ let debounceTimer;
53
+ const inputs = () => ({
54
+ os: Platform.OS,
55
+ inverted: props.inverted,
56
+ scrollViewHeight: props.scrollViewHeight,
57
+ nextHeaderLayoutY: props.nextHeaderLayoutY,
58
+ });
59
+ function runEffects(effects) {
60
+ for (const effect of effects) {
61
+ switch (effect.kind) {
62
+ case 'rebuild-interpolation': {
63
+ dlog(`Solid ScrollViewStickyHeader[y=${state.layoutY}] rebuild-interpolation ` +
64
+ `inputRange=${JSON.stringify(effect.inputRange)} outputRange=${JSON.stringify(effect.outputRange)}`);
65
+ if (interpolation !== undefined && listenerId !== undefined) {
66
+ interpolation.removeListener(listenerId);
67
+ listenerId = undefined;
68
+ }
69
+ const next = scrollAnimatedValue.interpolate({
70
+ inputRange: effect.inputRange,
71
+ outputRange: effect.outputRange,
72
+ });
73
+ // Feeds ONLY the debounced committed transform below (RN adds this listener solely on
74
+ // Fabric, for hit-testing); the visible pin is the native AnimatedProps connection.
75
+ listenerId = next.addListener(({ value }) => {
76
+ if (typeof value === 'number')
77
+ dispatch({ kind: 'animated-tick', value });
78
+ });
79
+ interpolation = 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 !== undefined)
87
+ clearTimeout(debounceTimer);
88
+ debounceTimer = setTimeout(() => {
89
+ debounceTimer = undefined;
90
+ dispatch({ kind: 'debounce-fired', value: effect.value });
91
+ }, effect.delay);
92
+ break;
93
+ case 'apply-passthrough':
94
+ dlog(`Solid ScrollViewStickyHeader[y=${state.layoutY}] apply-passthrough translateY=${effect.translateY}`);
95
+ setVersion(tick => tick + 1);
96
+ break;
97
+ case 'record-header-y':
98
+ // Solid records through the wrapper's own onLayout closure (the public
99
+ // IStickyHeaderProps.onLayout contract, same as React/Vue), so the reducer never emits an
100
+ // index for it.
101
+ break;
102
+ }
103
+ }
104
+ }
105
+ function dispatch(action) {
106
+ runEffects(reduceSticky(state, action, inputs()).effects);
107
+ }
108
+ // Rebuild whenever a collision/viewport input changes (RN's [inverted, scrollViewHeight,
109
+ // nextHeaderLayoutY] dep array); also does the initial identity build on mount. All three are read
110
+ // through inputs() INSIDE the effect, which is what makes them dependencies — a guarded or
111
+ // deferred read would silently drop one.
112
+ createEffect(() => {
113
+ runEffects(reduceSticky(state, { kind: 'inputs-changed' }, inputs()).effects);
114
+ });
115
+ onCleanup(() => {
116
+ if (interpolation !== undefined && listenerId !== undefined) {
117
+ interpolation.removeListener(listenerId);
118
+ }
119
+ if (debounceTimer !== undefined)
120
+ clearTimeout(debounceTimer);
121
+ });
122
+ // RN's ScrollViewStickyHeader._onLayout additionally re-invokes the wrapped child's OWN onLayout.
123
+ // Deliberately not replicated: by the time a Solid component sees `children` they are already
124
+ // real engine nodes, and a node's listeners live in the engine's event map, not as a readable JS
125
+ // prop — there is nothing to re-invoke. Nothing is lost, because the child node is committed in
126
+ // its own right and Fabric fires its onLayout at it directly; React/Vue's forwarding actually
127
+ // makes a child handler run twice.
128
+ const onLayout = (event) => {
129
+ const y = readLayoutNumber(event, 'y');
130
+ const height = readLayoutNumber(event, 'height');
131
+ // Keep the previous value when a field is absent (RN sets state only on a defined read).
132
+ dispatch({
133
+ kind: 'layout',
134
+ y: y ?? state.layoutY,
135
+ height: height ?? state.layoutHeight,
136
+ });
137
+ props.onLayout(event);
138
+ };
139
+ // The EXPLICIT debounced translateY overrides the committed transform for hit-testing, while
140
+ // animatedTranslateY does the smooth (native-driven) pin.
141
+ const passthrough = createMemo(() => {
142
+ // Read the bump so a committed translateY repaints.
143
+ version();
144
+ if (state.translateY === null)
145
+ return null;
146
+ return { style: { transform: [{ translateY: state.translateY }] } };
147
+ });
148
+ // collapsable:false keeps the wrapper a real Yoga node; zIndex makes the pinned header paint OVER
149
+ // the rows scrolling up under it.
150
+ return (<Animated.View style={{
151
+ transform: [{ translateY: animatedTranslateY() }],
152
+ zIndex: STICKY_HEADER_Z_INDEX,
153
+ }} onLayout={onLayout} collapsable={false} passthroughAnimatedPropExplicitValues={passthrough()}>
154
+ {props.children}
155
+ </Animated.View>);
156
+ }
157
+ // Wrap each child flagged by `stickyHeaderIndices` in the sticky header component, fed by the shared
158
+ // scroll AnimatedValue (RN ScrollView.js's render-time children.map). Unflagged children pass
159
+ // through untouched.
160
+ //
161
+ // Cross-talk (RN's _headerLayoutYs + _onStickyHeaderLayout): each header reports its own y through
162
+ // `onHeaderLayoutY`, and every header is fed the y of the NEXT flagged one — the point past which it
163
+ // gets pushed off. The LAST flagged header has no successor, so its nextHeaderLayoutY stays
164
+ // undefined and it sticks indefinitely, which is correct.
165
+ export function wrapStickyHeaders(children, inputs) {
166
+ const Wrapper = inputs.StickyHeaderComponent ?? ScrollViewStickyHeader;
167
+ return children.map((child, index) => {
168
+ const indexOfIndex = inputs.stickyHeaderIndices.indexOf(index);
169
+ // A non-node child (a bare string or number in a scroll view) has no frame to pin and cannot be
170
+ // wrapped meaningfully, so it passes through like an unflagged one.
171
+ if (indexOfIndex === -1 || !isSymbioteNode(child))
172
+ return child;
173
+ dlog(`Solid ScrollView sticky-header wrap index=${index}`);
174
+ return (<Wrapper nextHeaderLayoutY={inputs.readNextHeaderLayoutY(indexOfIndex)} onLayout={(event) => {
175
+ const y = readLayoutNumber(event, 'y');
176
+ if (y !== undefined)
177
+ inputs.onHeaderLayoutY(index, y);
178
+ }} scrollAnimatedValue={inputs.scrollAnimatedValue} inverted={inputs.readInverted()} scrollViewHeight={inputs.readScrollViewHeight()}>
179
+ {child}
180
+ </Wrapper>);
181
+ });
182
+ }
@@ -0,0 +1,6 @@
1
+ import type { JSX } from '../../jsx-runtime';
2
+ import { type IVirtualizedSectionListHandle, type IVirtualizedSectionListProps } from '../virtualized-section-list';
3
+ export type { ISection } from '../virtualized-section-list';
4
+ export type ISectionListProps<ItemT> = IVirtualizedSectionListProps<ItemT>;
5
+ export type ISectionListHandle = IVirtualizedSectionListHandle;
6
+ export declare function SectionList<ItemT>(props: ISectionListProps<ItemT>): JSX.Element;