@symbiote-native/components 0.5.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 (88) hide show
  1. package/README.md +23 -10
  2. package/build/accessibility-props.d.ts +11 -0
  3. package/build/accessibility-props.js +30 -118
  4. package/build/behaviors/activity-indicator/index.android.d.ts +1 -0
  5. package/build/behaviors/activity-indicator/index.android.js +16 -0
  6. package/build/behaviors/activity-indicator/index.d.ts +3 -0
  7. package/build/behaviors/activity-indicator/index.ios.d.ts +1 -0
  8. package/build/behaviors/activity-indicator/index.ios.js +14 -0
  9. package/build/behaviors/activity-indicator/index.js +5 -0
  10. package/build/behaviors/activity-indicator/shared.d.ts +18 -0
  11. package/build/behaviors/activity-indicator/shared.js +149 -0
  12. package/build/behaviors/button.d.ts +2 -0
  13. package/build/behaviors/button.js +328 -0
  14. package/build/behaviors/image-background.d.ts +2 -0
  15. package/build/behaviors/image-background.js +139 -0
  16. package/build/behaviors/image.d.ts +3 -0
  17. package/build/behaviors/image.js +123 -0
  18. package/build/behaviors/input-accessory-view.d.ts +3 -0
  19. package/build/behaviors/input-accessory-view.js +70 -0
  20. package/build/behaviors/pressable.d.ts +60 -0
  21. package/build/behaviors/pressable.js +385 -0
  22. package/build/behaviors/refresh-control.d.ts +2 -0
  23. package/build/behaviors/refresh-control.js +83 -0
  24. package/build/behaviors/scroll-view/index.android.d.ts +1 -0
  25. package/build/behaviors/scroll-view/index.android.js +52 -0
  26. package/build/behaviors/scroll-view/index.d.ts +2 -0
  27. package/build/behaviors/scroll-view/index.ios.d.ts +1 -0
  28. package/build/behaviors/scroll-view/index.ios.js +10 -0
  29. package/build/behaviors/scroll-view/index.js +5 -0
  30. package/build/behaviors/scroll-view/shared.d.ts +11 -0
  31. package/build/behaviors/scroll-view/shared.js +291 -0
  32. package/build/behaviors/scroll-view/sticky.d.ts +17 -0
  33. package/build/behaviors/scroll-view/sticky.js +568 -0
  34. package/build/behaviors/switch.d.ts +2 -0
  35. package/build/behaviors/switch.js +186 -0
  36. package/build/behaviors/text-input.d.ts +14 -0
  37. package/build/behaviors/text-input.js +319 -0
  38. package/build/behaviors/touchable-highlight.d.ts +9 -0
  39. package/build/behaviors/touchable-highlight.js +192 -0
  40. package/build/behaviors/touchable-native-feedback.d.ts +20 -0
  41. package/build/behaviors/touchable-native-feedback.js +333 -0
  42. package/build/behaviors/touchable-opacity.d.ts +12 -0
  43. package/build/behaviors/touchable-opacity.js +227 -0
  44. package/build/behaviors/touchable-without-feedback.d.ts +2 -0
  45. package/build/behaviors/touchable-without-feedback.js +296 -0
  46. package/build/component-names/index.android.js +47 -15
  47. package/build/component-names/index.ios.js +35 -15
  48. package/build/component-names/shared.d.ts +2 -1
  49. package/build/component-names/shared.js +49 -3
  50. package/build/descriptor.js +4 -4
  51. package/build/fold-host-bag.d.ts +15 -0
  52. package/build/fold-host-bag.js +99 -0
  53. package/build/index.d.ts +23 -12
  54. package/build/index.js +50 -9
  55. package/build/register.d.ts +1 -0
  56. package/build/register.js +55 -0
  57. package/build/resolve-intrinsic.d.ts +7 -0
  58. package/build/resolve-intrinsic.js +49 -0
  59. package/build/scroll-view-commands.d.ts +4 -0
  60. package/build/scroll-view-commands.js +30 -31
  61. package/build/state/pressable.d.ts +9 -0
  62. package/build/state/pressable.js +120 -34
  63. package/build/state/text-input.d.ts +10 -2
  64. package/build/state/text-input.js +11 -0
  65. package/build/text-props.js +2 -1
  66. package/build/view/render-button.d.ts +36 -1
  67. package/build/view/render-button.js +101 -12
  68. package/build/view/render-image/index.d.ts +2 -0
  69. package/build/view/render-image/index.js +29 -2
  70. package/build/view/render-input-accessory-view.d.ts +2 -0
  71. package/build/view/render-input-accessory-view.js +37 -5
  72. package/build/view/render-modal.js +3 -3
  73. package/build/view/render-pressable/index.d.ts +2 -0
  74. package/build/view/render-pressable/index.js +24 -0
  75. package/build/view/render-scroll-view.d.ts +1 -0
  76. package/build/view/render-scroll-view.js +18 -9
  77. package/build/view/render-switch.d.ts +4 -1
  78. package/build/view/render-switch.js +8 -2
  79. package/build/view/render-text-input.js +6 -2
  80. package/build/view/render-touchable-native-feedback.d.ts +19 -0
  81. package/build/view/render-touchable-native-feedback.js +19 -0
  82. package/host-primitives.cjs +432 -0
  83. package/host-primitives.d.cts +33 -0
  84. package/package.json +33 -5
  85. package/build/view/render-activity-indicator.d.ts +0 -25
  86. package/build/view/render-activity-indicator.js +0 -62
  87. package/build/view/render-image-background.d.ts +0 -9
  88. package/build/view/render-image-background.js +0 -48
@@ -0,0 +1,2 @@
1
+ export { registerScrollViewBehavior } from './index.ios';
2
+ export { HORIZONTAL_SCROLL_VIEW_TAG, REFRESH_CONTROL, SCROLL_VIEW_TAG, } from './shared';
@@ -0,0 +1 @@
1
+ export declare function registerScrollViewBehavior(): void;
@@ -0,0 +1,10 @@
1
+ // ScrollView's behavior on iOS: a RefreshControl is a SIBLING of the content view, rendered before
2
+ // it (`ScrollView.js:1844`). That is the whole platform half — a claim in `beside` mode, and the
3
+ // engine's placement rule already puts a claimed child before the slot.
4
+ //
5
+ // This file is also the base the folder's `index.ts` re-exports for headless, matching
6
+ // `render-scroll-view`'s own choice to make iOS the default for anything platform-branched.
7
+ import { registerScrollViewBehaviors } from './shared.js';
8
+ export function registerScrollViewBehavior() {
9
+ registerScrollViewBehaviors({ claimMode: 'beside' });
10
+ }
@@ -0,0 +1,5 @@
1
+ // The base of the folder-as-module group: Metro picks `index.ios` / `index.android` per platform,
2
+ // and everything else (tsx, vitest, headless) lands here. iOS is the default for the same reason
3
+ // `render-scroll-view`'s own `Platform.select` defaults to it.
4
+ export { registerScrollViewBehavior } from './index.ios.js';
5
+ export { HORIZONTAL_SCROLL_VIEW_TAG, REFRESH_CONTROL, SCROLL_VIEW_TAG, } from './shared.js';
@@ -0,0 +1,11 @@
1
+ import { type IClaimMode, type IHostBehavior, type IPayloadFold, type IViewStyle } from '@symbiote-native/engine';
2
+ export declare const SCROLL_VIEW_TAG = "scroll-view";
3
+ export declare const HORIZONTAL_SCROLL_VIEW_TAG = "horizontal-scroll-view";
4
+ export declare const REFRESH_CONTROL: string;
5
+ export declare function ownerFold(base: IViewStyle, horizontal: boolean): IPayloadFold;
6
+ export interface IScrollPlatform {
7
+ claimMode: IClaimMode;
8
+ onWrapChange?: (base: IViewStyle, horizontal: boolean) => IHostBehavior['onWrapChange'];
9
+ slotDerived?: readonly string[];
10
+ }
11
+ export declare function registerScrollViewBehaviors(platform: IScrollPlatform): void;
@@ -0,0 +1,291 @@
1
+ // ScrollView's host behavior, the platform-invariant half — and the pilot for four of the engine's
2
+ // composed-primitive seams: `buildStructure` + `childHost`, `slotProps`, `slotDerived`, and
3
+ // `claimedChildren`.
4
+ //
5
+ // THE PLATFORM HALF IS THE REFRESHCONTROL, and only that. `index.ios` claims it `beside` the
6
+ // content view; `index.android` claims it as a `wrap`, because an Android ScrollView holds exactly
7
+ // one child. Everything else here is shared, including the tags, the folds and the content-size
8
+ // synthesis.
9
+ //
10
+ // WHAT IS WIRED. Structure, the style compositions, `decelerationRate` resolution,
11
+ // `collapsableChildren`, the synthesized `onContentSizeChange`, the RefreshControl on both
12
+ // platforms — and, since the sticky half landed, the raised `scrollEventThrottle`, the scroll
13
+ // value that drives the pins, the owner layout an inverted pin needs, and the per-commit walk that
14
+ // turns `stickyHeaderIndices` into those same headers. The sticky machinery itself lives in
15
+ // `./sticky`, because a `<StickyHeader>` is a CHILD and the three props above are functions of
16
+ // whether one registered.
17
+ //
18
+ // WHAT A COMPOSED PRIMITIVE COSTS TODAY. Every adapter's ScrollView wrapper builds the same two
19
+ // nodes: `selectScrollIntrinsics` picks a scroll intrinsic and a content intrinsic, and the
20
+ // wrapper's body nests `<content>{children}</content>` inside `<scroll>`. That body is a framework
21
+ // component instance per ScrollView — a Vue instance, a Solid props Proxy, Svelte anchors, an
22
+ // Angular LView — which is precisely the currency host-primitive lowering exists to delete
23
+ // (`.claude/rules/host-primitive-tier.md`). `foldPayload` gave a lowered primitive its wrapper's
24
+ // PROP MAPPING; nothing gave it the wrapper's COMPOSITION, so a composed primitive could not be
25
+ // lowered at all no matter what its props did. This is that half.
26
+ //
27
+ // WHY THE TAG CARRIES THE AXIS. `buildStructure` runs at `createElement`, before a single prop is
28
+ // routed, so it cannot read `horizontal`. It does not need to: horizontal scroll is already a
29
+ // SEPARATE intrinsic (`horizontal-scroll-view` — a different native ViewManager on
30
+ // Android, not RCTScrollView with a flag), so the decision the behavior needs is in the tag it was
31
+ // looked up by. One behavior per tag, each knowing its own content intrinsic. That is the same
32
+ // shape `intrinsicWhen` gives TextInput's `multiline`, arrived at from the other side.
33
+ //
34
+ // REGISTERED BY SVELTE SINCE 2026-09-07 (`adapters/svelte/src/register.ts`), and by no other
35
+ // adapter — this paragraph read "NOT REGISTERED BY ANY ADAPTER" for three days after that stopped
36
+ // being true. The hazard still holds for the four that have not registered: `scroll-view` is the
37
+ // tag their WRAPPERS emit, and a wrapper builds its own content node from `selectScrollIntrinsics`,
38
+ // as does `VirtualizedList`. Registering while either stands silently gives those trees a SECOND
39
+ // content node: `RCTScrollView > RCTScrollContentView > RCTScrollContentView`. So the precondition
40
+ // per adapter is that nothing else builds the content node.
41
+ //
42
+ // A SECOND TAG IS NOT THE ANSWER, and this reverses what this header said until 2026-09-07. The
43
+ // `text-input` / `text-input-managed` split is debt with a deletion date, not a technique
44
+ // (`.claude/rules/fold-only-primitive-recipe.md` §4): each pair exists only to keep two owners
45
+ // apart while a wrapper and a lowered element both emit a tag, and minting one here would buy a
46
+ // rename across every call site now and a second rename when the wrapper dies. The owner's decision
47
+ // is that the ENGINE becomes the single owner of the content node — every adapter's list and
48
+ // wrapper stops building one — so registration waits on that cut rather than on a new spelling.
49
+ // That cut LANDED 2026-09-11: no adapter's wrapper or list builds a content node any more, and all
50
+ // five register this behavior through `@symbiote-native/components/register`.
51
+ //
52
+ // STYLE, on both nodes, and the precedence is the part that is easy to get silently wrong. The
53
+ // wrapper composes exactly two arrays, and this reproduces both:
54
+ //
55
+ // owner [scrollViewBaseStyle, style] base UNDER the app's, so an explicit
56
+ // flexDirection still wins
57
+ // slot [contentContainerStyle, {flexDirection:'row'}] row OVER the app's, on horizontal only
58
+ //
59
+ // Which is why the two halves use different seams rather than one. `contentContainerStyle` is
60
+ // written by the app on the OWNER and belongs to the slot, so it travels through `slotProps` — a
61
+ // pure RENAME (`contentContainerStyle` -> the slot's `style`) that goes through the slot's own
62
+ // `routeProp` and inherits style merging, class merging and the already-published guard. The
63
+ // CONSTANT half is a `payloadFold`, because a fold is where precedence can be expressed: the
64
+ // owner's puts the base first, the slot's puts the row direction last. A redirect that also tried
65
+ // to compose would have to pick one order for both.
66
+ //
67
+ // The slot's fold is assigned to the node inside `buildStructure`, not declared on the behavior:
68
+ // `IHostBehavior.foldPayload` is the OWNER's, wired by `attachHostBehavior`, and a behavior that
69
+ // builds a node owns what that node carries.
70
+ import { appendChild, appListenerFor, createElement, dlog, registerHostBehavior, setBehaviorListener, setEventListener, } from '@symbiote-native/engine';
71
+ import { descriptorFor } from '../../component-names';
72
+ import { didContentSizeChange, preservesContentChildren, readLayoutDimension, resolveDecelerationRate, SCROLL_VIEW_BASE_HORIZONTAL, SCROLL_VIEW_BASE_VERTICAL, } from '../../view/render-scroll-view.js';
73
+ import { handleOwnerScroll, markScrollOwner, reconcileStickyIndices, releaseStickyOwner, stickyHeaderBehavior, STICKY_HEADER_TAG, syncOwnerLayout, } from './sticky.js';
74
+ export const SCROLL_VIEW_TAG = 'scroll-view';
75
+ export const HORIZONTAL_SCROLL_VIEW_TAG = 'horizontal-scroll-view';
76
+ // The app writes it on the ScrollView; it styles the content view. One entry, and it is the whole
77
+ // reason `slotProps` exists.
78
+ const SLOT_PROPS = {
79
+ contentContainerStyle: 'style',
80
+ };
81
+ // Owner props the SLOT's payload reads. Declared so a write to one dirties the slot — see
82
+ // `IHostBehavior.slotDerived` for why nothing else makes that happen.
83
+ const SLOT_DERIVED = ['maintainVisibleContentPosition', 'snapToAlignment'];
84
+ // A `<RefreshControl>` written among the app's children is claimed, and WHAT the owner does with
85
+ // it is the one thing that genuinely differs per platform — see the platform files. Resolved
86
+ // through `descriptorFor`, so this is `PullToRefreshView` on iOS and `AndroidSwipeRefreshLayout`
87
+ // on Android without either name appearing here.
88
+ export const REFRESH_CONTROL = descriptorFor('refresh-control').component;
89
+ // The OWNER's fold: the per-axis base style UNDER the app's (so an explicit `flexDirection` still
90
+ // wins), `decelerationRate` resolved from RN's two words to the platform's friction constant, and
91
+ // the two props a lowered element has no wrapper to write for it. The resolution has to happen here
92
+ // because 'normal'/'fast' reach Fabric as strings it cannot read.
93
+ //
94
+ // `horizontal` is a real C++ prop (`BaseScrollViewProps.h:56`) and the separate ViewManager is
95
+ // ANDROID's — on iOS both tags resolve to RCTScrollView, so the PROP is what turns the axis there
96
+ // and a bare `<horizontal-scroll-view>` would otherwise scroll vertically. Written from the tag
97
+ // rather than read off props, which is the same source `buildStructure` picked the content
98
+ // intrinsic from; an app that also writes `horizontal` on the vertical tag is contradicting the
99
+ // element it chose, and the tag wins.
100
+ //
101
+ // `nestedScrollEnabled` defaults ON because every wrapper writes it on every ScrollView, both
102
+ // platforms. RN itself only defaults it on the Android RefreshControl WRAP path
103
+ // (`ScrollView.js:1862`) — parity here is with the wrapper, which is what the lowered path replaces.
104
+ export function ownerFold(base, horizontal) {
105
+ return props => {
106
+ const next = {
107
+ ...props,
108
+ style: [base, props.style],
109
+ nestedScrollEnabled: props.nestedScrollEnabled ?? true,
110
+ };
111
+ // The tag is the ONLY axis input, which is what keeps the three halves of the axis from
112
+ // disagreeing. RN derives all three from one prop, so a mismatch is unrepresentable there, and
113
+ // on iOS both tags really are RCTScrollView — a stray `horizontal` would turn a vertical
114
+ // scroller over a content node with no row style, a shape RN cannot produce.
115
+ if (props.horizontal !== undefined && props.horizontal !== horizontal) {
116
+ dlog(`ScrollView: horizontal=${String(props.horizontal)} ignored — the axis comes from the ` +
117
+ `tag; write <${horizontal ? SCROLL_VIEW_TAG : HORIZONTAL_SCROLL_VIEW_TAG}> instead`);
118
+ }
119
+ delete next.horizontal;
120
+ if (horizontal)
121
+ next.horizontal = true;
122
+ // The bounce pair is the axis's other consequence (`ScrollView.js:1753-1761`), ASYMMETRIC
123
+ // because RN falls back to `this.props.horizontal` — unset on a vertical view, so the
124
+ // horizontal key resolves to undefined and never reaches the payload. Both names are declared
125
+ // in every adapter's prop type and computed in none: vertical never bounced by default.
126
+ if (props.alwaysBounceHorizontal === undefined && horizontal)
127
+ next.alwaysBounceHorizontal = true;
128
+ if (props.alwaysBounceVertical === undefined)
129
+ next.alwaysBounceVertical = !horizontal;
130
+ // Consumed by the behavior and declared by no ViewConfig — neither name appears anywhere under
131
+ // `ReactCommon/react/renderer/components/scrollview`. `stickyHeaderIndices` decides which
132
+ // children get a `sticky-header`, `invertStickyHeaders` feeds the pin. Every wrapper strips both
133
+ // (Vue's `HANDLED_ATTRS` is the reference list), and a key Fabric does not know throws nothing,
134
+ // logs nothing and paints nothing — so the strip has to be here or it is never noticed.
135
+ delete next.stickyHeaderIndices;
136
+ delete next.invertStickyHeaders;
137
+ const rate = props.decelerationRate;
138
+ if (rate === 'normal' || rate === 'fast' || typeof rate === 'number')
139
+ next.decelerationRate = resolveDecelerationRate(rate);
140
+ return next;
141
+ };
142
+ }
143
+ // The SLOT's fold. Two halves with different sources, which is why it takes the owner:
144
+ //
145
+ // rowStyle a CONSTANT, horizontal only, composed OVER the app's contentContainerStyle
146
+ // (the wrapper writes `[contentContainerStyle, {flexDirection:'row'}]`)
147
+ // collapsableChildren DERIVED from props that stay on the OWNER, so it is read back off it
148
+ //
149
+ // Written only when false, matching every wrapper — RN sends `collapsableChildren={!preserveChildren}`
150
+ // and therefore an explicit `true`, which is the native default anyway.
151
+ function contentFold(owner, rowStyle) {
152
+ return props => {
153
+ const preserve = preservesContentChildren(owner.props.maintainVisibleContentPosition, owner.props.snapToAlignment);
154
+ // The identity return IPayloadFold's contract asks for: a vertical content view with neither
155
+ // prop set has nothing to add, which is the common case.
156
+ if (rowStyle === undefined && !preserve)
157
+ return props;
158
+ const next = { ...props };
159
+ if (rowStyle !== undefined)
160
+ next.style = [props.style, rowStyle];
161
+ if (preserve)
162
+ next.collapsableChildren = false;
163
+ return next;
164
+ };
165
+ }
166
+ function buildContent(contentIntrinsic, rowStyle) {
167
+ return (node) => {
168
+ const descriptor = descriptorFor(contentIntrinsic);
169
+ const content = createElement(descriptor.component, descriptor.isText, contentIntrinsic);
170
+ // The wrapper sets it on every content node, both axes (react's `contentProps`). Yoga may
171
+ // collapse a view that only groups children, and a collapsed content node takes the scroll
172
+ // metrics with it.
173
+ content.props = { collapsable: false };
174
+ content.payloadFold = contentFold(node, rowStyle);
175
+ // Lands directly on the owner, because `node.childHost` is still undefined here: the engine
176
+ // assigns it from what this returns. That ordering is why `buildStructure` RETURNS the slot
177
+ // instead of setting the field itself — a behavior that set it first would redirect its own
178
+ // structure into the slot it was building.
179
+ appendChild(node, content);
180
+ return content;
181
+ };
182
+ }
183
+ // The last size each owner reported, so a layout pass that did not change the content size does not
184
+ // fire the app's handler — RN dedupes the same way (`_handleContentOnLayout`). Off the node: this
185
+ // exists only for the ScrollViews an app wired a handler to.
186
+ const lastContentSize = new WeakMap();
187
+ // RN synthesizes onContentSizeChange from the CONTENT view's own onLayout — there is no native
188
+ // content-size event (ScrollView.js:1675 `contentSizeChangeProps`). The wrapper wired that by
189
+ // rendering an `onLayout` onto its inner node; a lowered element has no inner node of its own, so
190
+ // the behavior installs it on the slot it built.
191
+ //
192
+ // The app's callback takes `(width, height)`, not an event, which is why `contentSizeChange` is an
193
+ // OWNED listener: `setEventListener` wraps an ordinary listener as `(event) => handler(event)` and
194
+ // would call a two-number handler with one event. Owned names are stashed raw instead.
195
+ function contentSizeListener(owner) {
196
+ return (event) => {
197
+ const handler = appListenerFor(owner, 'contentSizeChange');
198
+ if (typeof handler !== 'function')
199
+ return;
200
+ const width = readLayoutDimension(event, 'width');
201
+ const height = readLayoutDimension(event, 'height');
202
+ if (width === undefined || height === undefined)
203
+ return;
204
+ if (!didContentSizeChange(lastContentSize.get(owner) ?? null, {
205
+ width,
206
+ height,
207
+ }))
208
+ return;
209
+ lastContentSize.set(owner, { width, height });
210
+ dlog(`ScrollView onContentSizeChange ${width}x${height}`);
211
+ handler(width, height);
212
+ };
213
+ }
214
+ // RN installs the content `onLayout` only when the app passed `onContentSizeChange`, and so does
215
+ // every wrapper — `onLayout` is a gated event, so wiring it unconditionally would put `onLayout:
216
+ // true` in the payload of every lowered ScrollView's content node and buy a native event nobody
217
+ // reads. A lowering that changes the committed surface in EITHER direction is a bug, so the wiring
218
+ // has to follow the prop.
219
+ //
220
+ // It follows the LISTENER rather than a commit, which is what `onOwnedListenerChange` is for: a
221
+ // listener flip changes no payload by itself, so the commit after it is a no-op and a post-commit
222
+ // hook would never fire. Measured on exactly this — the wire worked (mount commits for other
223
+ // reasons) and the UNWIRE silently did not.
224
+ function syncContentSizeWiring(owner, wired) {
225
+ const slot = owner.childHost;
226
+ if (slot === undefined)
227
+ return;
228
+ if (wired) {
229
+ setEventListener(slot, 'layout', contentSizeListener(owner));
230
+ }
231
+ else {
232
+ lastContentSize.delete(owner);
233
+ setEventListener(slot, 'layout', undefined);
234
+ }
235
+ }
236
+ // Two owned names answer to a flip, and they answer on DIFFERENT nodes: `contentSizeChange` wires
237
+ // the SLOT's layout, `layout` wires the owner's own — which an inverted sticky header also wants,
238
+ // so the two claims are resolved in one place (`syncOwnerLayout`) rather than by whoever wrote last.
239
+ function syncOwnedListener(owner, name, wired) {
240
+ if (name === 'contentSizeChange')
241
+ syncContentSizeWiring(owner, wired);
242
+ else if (name === 'layout')
243
+ syncOwnerLayout(owner);
244
+ }
245
+ function scrollBehavior(contentIntrinsic, base, rowStyle, platform) {
246
+ // The row style is the horizontal tag's constant and nothing else carries it, so it IS the axis —
247
+ // deriving keeps the two from ever disagreeing about which behavior this is.
248
+ const horizontal = rowStyle !== undefined;
249
+ return {
250
+ // `scroll` and `layout` are owned for the collision reason rather than because the behavior
251
+ // consumes them: RN's ScrollView installs `_handleScroll` and `_handleLayout` on the native
252
+ // view unconditionally and calls the app's own handler from inside them, and `node.listeners`
253
+ // is single-slot — so a behavior that installed either without owning it would silently evict
254
+ // the app's.
255
+ ownedListeners: ['contentSizeChange', 'scroll', 'layout'],
256
+ slotProps: SLOT_PROPS,
257
+ slotDerived: [...SLOT_DERIVED, ...(platform.slotDerived ?? [])],
258
+ claimedChildren: { [REFRESH_CONTROL]: platform.claimMode },
259
+ onWrapChange: platform.onWrapChange?.(base, horizontal),
260
+ buildStructure: buildContent(contentIntrinsic, rowStyle),
261
+ foldPayload: ownerFold(base, horizontal),
262
+ // The scroll dispatcher is installed here and never conditionally: it is what drives the
263
+ // sticky AnimatedValue, and a header can register long after this node was created. It costs a
264
+ // forward per scroll event on a ScrollView with no sticky child, which is what RN pays too.
265
+ // Nothing else is taken — no timer, and the two conditional listeners are wired on a flip.
266
+ attach(node) {
267
+ markScrollOwner(node);
268
+ setBehaviorListener(node, 'scroll', event => handleOwnerScroll(node, event));
269
+ },
270
+ onOwnedListenerChange: syncOwnedListener,
271
+ // The one beat at which the app's children are all present — `stickyHeaderIndices` addresses
272
+ // them positionally, and no hook reports a children CHANGE. Costs a Set iteration per commit
273
+ // over the ScrollViews alone, and `reconcileStickyIndices` returns on a WeakSet miss for any
274
+ // that never used the prop.
275
+ afterCommit: reconcileStickyIndices,
276
+ detach(node) {
277
+ lastContentSize.delete(node);
278
+ releaseStickyOwner(node);
279
+ },
280
+ };
281
+ }
282
+ // Both axes, given the platform's answer to the RefreshControl question. The platform files call
283
+ // this; nothing else should.
284
+ export function registerScrollViewBehaviors(platform) {
285
+ registerHostBehavior(SCROLL_VIEW_TAG, scrollBehavior('scroll-content', SCROLL_VIEW_BASE_VERTICAL, undefined, platform));
286
+ registerHostBehavior(HORIZONTAL_SCROLL_VIEW_TAG, scrollBehavior('horizontal-scroll-content', SCROLL_VIEW_BASE_HORIZONTAL, { flexDirection: 'row' }, platform));
287
+ // With the scroll views, never on its own: a sticky header is meaningless without an owner to
288
+ // find, and registering the pair together is what makes "did the registration run" one question
289
+ // rather than two.
290
+ registerHostBehavior(STICKY_HEADER_TAG, stickyHeaderBehavior);
291
+ }
@@ -0,0 +1,17 @@
1
+ import { type IHostBehavior, type ISymbioteEvent, type ISymbioteNode } from '@symbiote-native/engine';
2
+ export declare const STICKY_HEADER_TAG = "sticky-header";
3
+ export declare function markScrollOwner(node: ISymbioteNode): void;
4
+ export declare function hasStickyHeaders(owner: ISymbioteNode): boolean;
5
+ export declare function syncOwnerLayout(owner: ISymbioteNode): void;
6
+ export declare function handleOwnerScroll(owner: ISymbioteNode, event: ISymbioteEvent): void;
7
+ export declare function releaseStickyOwner(owner: ISymbioteNode): void;
8
+ /**
9
+ * Bring the synthesized wrappers in line with `stickyHeaderIndices`. Called from the ScrollView
10
+ * behavior's `afterCommit`, the one beat at which the app's children are all present.
11
+ *
12
+ * O(slot children) per commit, once — never per mutation. Angular's controller coalesces to one
13
+ * pass per change detection for exactly this reason: its per-mutation walk was O(M²) and died at
14
+ * 801 children.
15
+ */
16
+ export declare function reconcileStickyIndices(owner: ISymbioteNode): void;
17
+ export declare const stickyHeaderBehavior: IHostBehavior;