@symbiote-native/components 1.0.0 → 3.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 (110) hide show
  1. package/README.md +11 -13
  2. package/build/accessibility-props.d.ts +1 -1
  3. package/build/accessibility-props.js +2 -2
  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 +120 -0
  12. package/build/behaviors/button.d.ts +13 -0
  13. package/build/behaviors/button.js +340 -0
  14. package/build/behaviors/image-background.d.ts +3 -0
  15. package/build/behaviors/image-background.js +155 -0
  16. package/build/behaviors/image.d.ts +1 -2
  17. package/build/behaviors/image.js +25 -106
  18. package/build/behaviors/input-accessory-view.d.ts +1 -2
  19. package/build/behaviors/input-accessory-view.js +49 -55
  20. package/build/behaviors/pressable.d.ts +59 -1
  21. package/build/behaviors/pressable.js +142 -96
  22. package/build/behaviors/refresh-control.d.ts +2 -0
  23. package/build/behaviors/refresh-control.js +96 -0
  24. package/build/behaviors/scroll-view/index.android.d.ts +1 -0
  25. package/build/behaviors/scroll-view/index.android.js +40 -0
  26. package/build/behaviors/scroll-view/index.d.ts +3 -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 +8 -0
  30. package/build/behaviors/scroll-view/responder.d.ts +4 -0
  31. package/build/behaviors/scroll-view/responder.js +202 -0
  32. package/build/behaviors/scroll-view/shared.d.ts +9 -0
  33. package/build/behaviors/scroll-view/shared.js +291 -0
  34. package/build/behaviors/scroll-view/sticky.d.ts +18 -0
  35. package/build/behaviors/scroll-view/sticky.js +581 -0
  36. package/build/behaviors/switch.d.ts +1 -1
  37. package/build/behaviors/switch.js +49 -88
  38. package/build/behaviors/text-input.d.ts +2 -2
  39. package/build/behaviors/text-input.js +244 -104
  40. package/build/behaviors/touchable-highlight.d.ts +9 -0
  41. package/build/behaviors/touchable-highlight.js +205 -0
  42. package/build/behaviors/touchable-native-feedback.d.ts +20 -0
  43. package/build/behaviors/touchable-native-feedback.js +254 -0
  44. package/build/behaviors/touchable-opacity.d.ts +12 -0
  45. package/build/behaviors/touchable-opacity.js +239 -0
  46. package/build/behaviors/touchable-without-feedback.d.ts +2 -0
  47. package/build/behaviors/touchable-without-feedback.js +231 -0
  48. package/build/component-names/index.android.js +31 -25
  49. package/build/component-names/index.ios.js +28 -23
  50. package/build/component-names/shared.d.ts +2 -1
  51. package/build/component-names/shared.js +16 -6
  52. package/build/descriptor.js +4 -4
  53. package/build/index.d.ts +26 -26
  54. package/build/index.js +56 -27
  55. package/build/register.d.ts +1 -0
  56. package/build/register.js +55 -0
  57. package/build/resolve-intrinsic.js +3 -9
  58. package/build/scroll-view-commands.d.ts +1 -5
  59. package/build/scroll-view-commands.js +23 -85
  60. package/build/state/flat-list.d.ts +2 -2
  61. package/build/state/flat-list.js +10 -2
  62. package/build/state/pressable.d.ts +6 -1
  63. package/build/state/pressable.js +63 -28
  64. package/build/state/section-list.d.ts +2 -0
  65. package/build/state/section-list.js +14 -7
  66. package/build/state/text-input.d.ts +10 -40
  67. package/build/state/text-input.js +17 -186
  68. package/build/state/touchable.d.ts +1 -0
  69. package/build/state/touchable.js +11 -8
  70. package/build/state/virtualized-list-reducer.d.ts +2 -2
  71. package/build/state/virtualized-list.d.ts +6 -6
  72. package/build/state/virtualized-list.js +71 -37
  73. package/build/text-props.d.ts +0 -8
  74. package/build/text-props.js +14 -25
  75. package/build/view/render-button.d.ts +11 -4
  76. package/build/view/render-button.js +74 -22
  77. package/build/view/render-image/index.d.ts +14 -1
  78. package/build/view/render-image/index.js +22 -147
  79. package/build/view/render-input-accessory-view.d.ts +1 -5
  80. package/build/view/render-input-accessory-view.js +26 -48
  81. package/build/view/render-keyboard-avoiding-view.d.ts +7 -1
  82. package/build/view/render-keyboard-avoiding-view.js +40 -1
  83. package/build/view/render-modal.d.ts +1 -1
  84. package/build/view/render-modal.js +18 -8
  85. package/build/view/render-pressable/index.d.ts +3 -0
  86. package/build/view/render-pressable/index.js +28 -0
  87. package/build/view/render-scroll-view.d.ts +1 -4
  88. package/build/view/render-scroll-view.js +17 -54
  89. package/build/view/render-switch.d.ts +4 -15
  90. package/build/view/render-switch.js +4 -42
  91. package/build/view/render-touchable-highlight.d.ts +1 -0
  92. package/build/view/render-touchable-native-feedback.d.ts +19 -1
  93. package/build/view/render-touchable-native-feedback.js +34 -9
  94. package/host-primitives.cjs +178 -280
  95. package/host-primitives.d.cts +0 -3
  96. package/package.json +8 -21
  97. package/build/fold-host-bag.d.ts +0 -15
  98. package/build/fold-host-bag.js +0 -99
  99. package/build/state-style.d.ts +0 -15
  100. package/build/state-style.js +0 -47
  101. package/build/view/render-activity-indicator.d.ts +0 -25
  102. package/build/view/render-activity-indicator.js +0 -88
  103. package/build/view/render-image-background.d.ts +0 -9
  104. package/build/view/render-image-background.js +0 -48
  105. package/build/view/render-text-input.d.ts +0 -11
  106. package/build/view/render-text-input.js +0 -39
  107. package/lowering-fixtures.cjs +0 -259
  108. package/lowering-fixtures.d.cts +0 -17
  109. package/specialize-state-style.cjs +0 -219
  110. package/specialize-state-style.d.cts +0 -15
@@ -0,0 +1,202 @@
1
+ // ScrollView's own participation in the responder negotiation
2
+ // (`core/engine/src/events`) — RN's `ScrollView.js` four predicates
3
+ // (`_handleStartShouldSetResponderCapture`/`_handleStartShouldSetResponder`/
4
+ // `_handleResponderTerminationRequest`) plus the grant/release/momentum bookkeeping they read.
5
+ // Ported line-for-line from `ScrollView.js:1263-1546` — the negotiation engine itself and the
6
+ // `IResponderProps` prop surface already exist and needed no change; this file is ScrollView opting
7
+ // into the same mechanism Pressable already uses (`../pressable.ts:329-384`).
8
+ import { appListenerFor, currentlyFocusedInput, blurTextInput, isRecord, isSymbioteNode, Keyboard, propOf, setBehaviorListener, } from '@symbiote-native/engine';
9
+ import { descriptorFor } from '../../component-names';
10
+ import { TEXT_INPUT_MULTILINE_TAG, TEXT_INPUT_TAG } from '../text-input.js';
11
+ // RN's `IS_ANIMATING_TOUCH_START_THRESHOLD_MS` (ScrollView.js:695) — a momentum end inside this
12
+ // window still counts as animating, so a touch that lands one frame after the list stops still
13
+ // goes to the ScrollView rather than a child it is still settling under.
14
+ const IS_ANIMATING_TOUCH_START_THRESHOLD_MS = 16;
15
+ const stateByOwner = new WeakMap();
16
+ function stateFor(owner) {
17
+ let state = stateByOwner.get(owner);
18
+ if (state === undefined) {
19
+ state = {
20
+ observedScrollSinceBecomingResponder: false,
21
+ becameResponderWhileAnimating: false,
22
+ lastMomentumScrollBeginTime: 0,
23
+ lastMomentumScrollEndTime: 0,
24
+ };
25
+ stateByOwner.set(owner, state);
26
+ }
27
+ return state;
28
+ }
29
+ // RN's `_isAnimating()` (ScrollView.js:1321-1330), unchanged: a momentum scroll in flight
30
+ // (begin > end) or one that just ended within the threshold.
31
+ function isAnimating(state) {
32
+ const sinceEnd = performance.now() - state.lastMomentumScrollEndTime;
33
+ return (sinceEnd < IS_ANIMATING_TOUCH_START_THRESHOLD_MS ||
34
+ state.lastMomentumScrollEndTime < state.lastMomentumScrollBeginTime);
35
+ }
36
+ // The one thing this repo already has that RN builds from native `Keyboard` events:
37
+ // `Keyboard.metrics()` reads the last-known metrics synchronously (`core/engine/src/keyboard`),
38
+ // the same value RN caches on `_keyboardMetrics`. Reused as-is, no new tracking.
39
+ // `node.component` is the resolved Fabric VIEW NAME (`RCTSinglelineTextInputView` on iOS, its own
40
+ // name on Android), not the tag — the tag is consumed at `createElement` and not retained
41
+ // (`core/engine/src/node.ts:466-470`'s own comment). `descriptorFor` resolves the same tag through
42
+ // the same per-platform table TextInput's own registration used, so the two stay in lockstep by
43
+ // construction rather than by a second hardcoded name list here.
44
+ function isTextInputNode(node) {
45
+ return (node.component === descriptorFor(TEXT_INPUT_TAG).component ||
46
+ node.component === descriptorFor(TEXT_INPUT_MULTILINE_TAG).component);
47
+ }
48
+ // RN's `_keyboardIsDismissible()` (ScrollView.js:1527-1541). RN also has `_keyboardEventsAreUnreliable()`
49
+ // — a pre-API-30 Android layout-polling fallback — deliberately not ported: modern targets only,
50
+ // and the omission only ever makes this return `false` more readily (never dismisses when RN
51
+ // would not have been sure either), never a false claim.
52
+ function keyboardIsDismissible() {
53
+ const focused = currentlyFocusedInput();
54
+ const hasFocusedTextInput = focused !== null && isTextInputNode(focused);
55
+ return hasFocusedTextInput && Keyboard.metrics() !== undefined;
56
+ }
57
+ // RN's `_softKeyboardIsDetached()` (ScrollView.js:1543-1546).
58
+ function softKeyboardIsDetached() {
59
+ const metrics = Keyboard.metrics();
60
+ return metrics !== undefined && metrics.height === 0;
61
+ }
62
+ // The should-set listeners run on the SCROLL VIEW's own node (`callOwnListener` sets `event.target`
63
+ // to the node being asked, not the node the touch landed on), so RN's `e.target` has to be read
64
+ // back out of the raw touch payload instead — the same place `core/engine/src/touch-history.ts`
65
+ // and `hasRemainingTouchWithin` (`events/index.ts`) already read a touch's `target` from.
66
+ function touchTargetOf(nativeEvent) {
67
+ for (const key of ['changedTouches', 'touches']) {
68
+ const touches = nativeEvent[key];
69
+ if (!Array.isArray(touches) || touches.length === 0)
70
+ continue;
71
+ const first = touches[0];
72
+ if (!isRecord(first))
73
+ continue;
74
+ const target = first.target;
75
+ if (isSymbioteNode(target))
76
+ return target;
77
+ }
78
+ return undefined;
79
+ }
80
+ function keyboardNeverPersistsTaps(persist) {
81
+ return (persist === undefined ||
82
+ persist === null ||
83
+ persist === false ||
84
+ persist === 'never');
85
+ }
86
+ // `ScrollView.js:1474-1522`. Claims the tap in the CAPTURE phase — before any child sees it —
87
+ // while animating, or (the keyboard-dismiss case) while the default is in force, a dismissible
88
+ // keyboard is up, and the touch did not land on the focused input itself.
89
+ function startShouldSetResponderCapture(owner, event) {
90
+ const state = stateFor(owner);
91
+ if (isAnimating(state))
92
+ return true;
93
+ if (propOf(owner, 'disableScrollViewPanResponder') === true)
94
+ return false;
95
+ if (softKeyboardIsDetached())
96
+ return false;
97
+ if (keyboardNeverPersistsTaps(propOf(owner, 'keyboardShouldPersistTaps')) &&
98
+ keyboardIsDismissible()) {
99
+ const target = touchTargetOf(event.nativeEvent);
100
+ if (target !== undefined && !isTextInputNode(target))
101
+ return true;
102
+ }
103
+ return false;
104
+ }
105
+ // `ScrollView.js:1444-1461`. The BUBBLE-phase counterpart — only reachable when capture declined
106
+ // — for `keyboardShouldPersistTaps: 'handled'`: claim only if the tap didn't land on the focused
107
+ // input (letting a child handle it first is the whole point of "handled").
108
+ function startShouldSetResponder(owner, event) {
109
+ if (propOf(owner, 'disableScrollViewPanResponder') === true)
110
+ return false;
111
+ if (propOf(owner, 'keyboardShouldPersistTaps') === 'handled' &&
112
+ keyboardIsDismissible()) {
113
+ const target = touchTargetOf(event.nativeEvent);
114
+ if (target !== currentlyFocusedInput())
115
+ return true;
116
+ }
117
+ return false;
118
+ }
119
+ // `ScrollView.js:1404-1406`. Once real scroll motion has been observed, refuse to give the
120
+ // responder up — matches every other adapter's "don't drop a gesture already committed" rule.
121
+ function responderTerminationRequest(owner) {
122
+ return !stateFor(owner).observedScrollSinceBecomingResponder;
123
+ }
124
+ // `ScrollView.js:1334-1341`. `onResponderGrant` is an ordinary `IResponderProps` field an app may
125
+ // also set, so — same reason `scroll`/`layout`/`contentSizeChange` are owned — it is composed
126
+ // through the stash rather than claimed outright.
127
+ function handleResponderGrant(owner, event) {
128
+ const state = stateFor(owner);
129
+ state.observedScrollSinceBecomingResponder = false;
130
+ const app = appListenerFor(owner, 'responderGrant');
131
+ if (typeof app === 'function')
132
+ app(event);
133
+ state.becameResponderWhileAnimating = isAnimating(state);
134
+ }
135
+ // `ScrollView.js:1357-1387`. On release, dismiss the keyboard iff: something is focused,
136
+ // `keyboardShouldPersistTaps` isn't `true`/`'always'`, the keyboard is dismissible, the release
137
+ // didn't land on the focused input, and nothing since becoming responder was a real scroll or an
138
+ // animation-time grant (both mean the touch was already "used" for something else).
139
+ function handleResponderRelease(owner, event) {
140
+ const state = stateFor(owner);
141
+ const app = appListenerFor(owner, 'responderRelease');
142
+ if (typeof app === 'function')
143
+ app(event);
144
+ const focused = currentlyFocusedInput();
145
+ const persist = propOf(owner, 'keyboardShouldPersistTaps');
146
+ if (focused === null ||
147
+ persist === true ||
148
+ persist === 'always' ||
149
+ !keyboardIsDismissible() ||
150
+ state.observedScrollSinceBecomingResponder ||
151
+ state.becameResponderWhileAnimating)
152
+ return;
153
+ if (touchTargetOf(event.nativeEvent) === focused)
154
+ return;
155
+ blurTextInput(focused);
156
+ }
157
+ // `ScrollView.js:1146` — unconditional, exactly as RN sets it from `_handleScroll`: nothing reads
158
+ // the flag unless this node actually holds the responder, so setting it on every scroll is safe.
159
+ export function markScrollObserved(owner) {
160
+ stateFor(owner).observedScrollSinceBecomingResponder = true;
161
+ }
162
+ // `ScrollView.js:1263-1274`. Both are ordinary `on*` props apps use for pagination, so both are
163
+ // composed through the stash exactly like `handleOwnerScroll` composes `onScroll` (`./sticky.ts`).
164
+ function handleMomentumScrollBegin(owner, event) {
165
+ stateFor(owner).lastMomentumScrollBeginTime = performance.now();
166
+ const app = appListenerFor(owner, 'momentumScrollBegin');
167
+ if (typeof app === 'function')
168
+ app(event);
169
+ }
170
+ function handleMomentumScrollEnd(owner, event) {
171
+ stateFor(owner).lastMomentumScrollEndTime = performance.now();
172
+ const app = appListenerFor(owner, 'momentumScrollEnd');
173
+ if (typeof app === 'function')
174
+ app(event);
175
+ }
176
+ export const RESPONDER_OWNED_LISTENERS = [
177
+ 'startShouldSetResponderCapture',
178
+ 'startShouldSetResponder',
179
+ 'responderTerminationRequest',
180
+ 'responderGrant',
181
+ 'responderRelease',
182
+ 'momentumScrollBegin',
183
+ 'momentumScrollEnd',
184
+ ];
185
+ // Installed once per owner, in `attach` — same beat Pressable wires its own responder pair.
186
+ export function installResponderPredicates(owner) {
187
+ setBehaviorListener(owner, 'startShouldSetResponderCapture', event => startShouldSetResponderCapture(owner, event));
188
+ setBehaviorListener(owner, 'startShouldSetResponder', event => startShouldSetResponder(owner, event));
189
+ setBehaviorListener(owner, 'responderTerminationRequest', () => responderTerminationRequest(owner));
190
+ setBehaviorListener(owner, 'responderGrant', event => {
191
+ handleResponderGrant(owner, event);
192
+ });
193
+ setBehaviorListener(owner, 'responderRelease', event => {
194
+ handleResponderRelease(owner, event);
195
+ });
196
+ setBehaviorListener(owner, 'momentumScrollBegin', event => {
197
+ handleMomentumScrollBegin(owner, event);
198
+ });
199
+ setBehaviorListener(owner, 'momentumScrollEnd', event => {
200
+ handleMomentumScrollEnd(owner, event);
201
+ });
202
+ }
@@ -0,0 +1,9 @@
1
+ import { type IClaimMode } 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 interface IScrollPlatform {
6
+ claimMode: IClaimMode;
7
+ slotDerived?: readonly string[];
8
+ }
9
+ 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
+ // TODO(rn-parity): `keyboardShouldPersistTaps` is a type-only prop everywhere — no capture-phase
19
+ // responder negotiation exists to eat a tap-elsewhere and dismiss the keyboard
20
+ // (`ScrollView.js:1360-1590`). Needs a `TextInputState.isTextInput`-equivalent, capture-phase
21
+ // `startShouldSetResponder`/`responderTerminationRequest` wiring on this tag (the same seam
22
+ // `Switch`/`Pressable` already use), and an `_isAnimating()`/momentum signal from the scroll
23
+ // machine. Full scope and the reusable infra already in place: audit skill, "Found, NOT fixed:
24
+ // ScrollView's keyboardShouldPersistTaps".
25
+ //
26
+ // TODO(rn-parity): `stickyHeaderHiddenOnScroll` is completely absent — no prop surface, no state,
27
+ // no adapter wiring (`grep -rn "stickyHeaderHiddenOnScroll\|hiddenOnScroll"` returns zero hits).
28
+ // Vendor composes an `Animated.diffClamp` over the scroll delta and adds it to the ordinary sticky
29
+ // translateY (`ScrollViewStickyHeader.js:39,84-103`). Needs a new running clamped-offset state
30
+ // ADDED to `STICKY_TRANSLATE_PROP` (our sticky pin is a discrete debounced number, not a live
31
+ // `Animated` composition), and the fix touches `reduceSticky`
32
+ // (`state/sticky-header-reducer.ts`) — the shared decision function every adapter's own sticky
33
+ // component plus Angular's projection controller also run — not just this file. Audit skill,
34
+ // "Found, NOT fixed: ScrollView's stickyHeaderHiddenOnScroll".
35
+ //
36
+ // WHAT A COMPOSED PRIMITIVE COSTS TODAY. Every adapter's ScrollView wrapper builds the same two
37
+ // nodes: `selectScrollIntrinsics` picks a scroll intrinsic and a content intrinsic, and the
38
+ // wrapper's body nests `<content>{children}</content>` inside `<scroll>`. That body is a framework
39
+ // component instance per ScrollView — a Vue instance, a Solid props Proxy, Svelte anchors, an
40
+ // Angular LView — which is precisely the currency a tag exists to delete. `foldPayload` gives a tag
41
+ // its wrapper's PROP MAPPING; nothing gave it the wrapper's COMPOSITION, so a composed primitive
42
+ // could not become a tag at all no matter what its props did. This is that half.
43
+ //
44
+ // WHY THE TAG CARRIES THE AXIS. `buildStructure` runs at `createElement`, before a single prop is
45
+ // routed, so it cannot read `horizontal`. It does not need to: horizontal scroll is already a
46
+ // SEPARATE intrinsic (`horizontal-scroll-view` — a different native ViewManager on
47
+ // Android, not RCTScrollView with a flag), so the decision the behavior needs is in the tag it was
48
+ // looked up by. One behavior per tag, each knowing its own content intrinsic. That is the same
49
+ // shape `intrinsicWhen` gives TextInput's `multiline`, arrived at from the other side.
50
+ //
51
+ // REGISTERED BY SVELTE SINCE 2026-09-07 (`adapters/svelte/src/register.ts`), and by no other
52
+ // adapter — this paragraph read "NOT REGISTERED BY ANY ADAPTER" for three days after that stopped
53
+ // being true. The hazard still holds for the four that have not registered: `scroll-view` is the
54
+ // tag their WRAPPERS emit, and a wrapper builds its own content node from `selectScrollIntrinsics`,
55
+ // as does `VirtualizedList`. Registering while either stands silently gives those trees a SECOND
56
+ // content node: `RCTScrollView > RCTScrollContentView > RCTScrollContentView`. So the precondition
57
+ // per adapter is that nothing else builds the content node.
58
+ //
59
+ // A SECOND TAG WAS NOT THE ANSWER, and this reverses what this header said until 2026-09-07: a
60
+ // second spelling only keeps two owners apart while two paths exist, and it buys a rename across
61
+ // every call site now plus another when one path dies. The decision was that the ENGINE becomes the
62
+ // single owner of the content node. That cut LANDED 2026-09-11: nothing else builds a content node
63
+ // any more, and all five adapters register this behavior through
64
+ // `@symbiote-native/components/register`.
65
+ //
66
+ // STYLE, on both nodes, and the precedence is the part that is easy to get silently wrong. The
67
+ // wrapper composes exactly two arrays, and this reproduces both:
68
+ //
69
+ // owner [scrollViewBaseStyle, style] base UNDER the app's, so an explicit
70
+ // flexDirection still wins
71
+ // slot [contentContainerStyle, {flexDirection:'row'}] row OVER the app's, on horizontal only
72
+ //
73
+ // Which is why the two halves use different seams rather than one. `contentContainerStyle` is
74
+ // written by the app on the OWNER and belongs to the slot, so it travels through `slotProps` — a
75
+ // pure RENAME (`contentContainerStyle` -> the slot's `style`) that goes through the slot's own
76
+ // `routeProp` and inherits style merging, class merging and the already-published guard. The
77
+ // CONSTANT half is a `payloadFold`, because a fold is where precedence can be expressed: the
78
+ // owner's puts the base first, the slot's puts the row direction last. A redirect that also tried
79
+ // to compose would have to pick one order for both.
80
+ //
81
+ // The slot's fold is assigned to the node inside `buildStructure`, not declared on the behavior:
82
+ // `IHostBehavior.foldPayload` is the OWNER's, wired by `attachHostBehavior`, and a behavior that
83
+ // builds a node owns what that node carries.
84
+ import { appendChild, appListenerFor, createElement, dlog, registerHostBehavior, setBehaviorListener, setEventListener, setProp, } from '@symbiote-native/engine';
85
+ import { descriptorFor } from '../../component-names';
86
+ import { didContentSizeChange, readLayoutDimension, } from '../../view/render-scroll-view.js';
87
+ import { installResponderPredicates, RESPONDER_OWNED_LISTENERS, } from './responder.js';
88
+ import { handleOwnerScroll, markScrollOwner, reconcileStickyIndices, releaseStickyOwner, stickyHeaderBehavior, STICKY_HEADER_TAG, syncOwnerLayout, } from './sticky.js';
89
+ export const SCROLL_VIEW_TAG = 'scroll-view';
90
+ export const HORIZONTAL_SCROLL_VIEW_TAG = 'horizontal-scroll-view';
91
+ // The app writes it on the ScrollView; it styles the content view. One entry, and it is the whole
92
+ // reason `slotProps` exists.
93
+ const SLOT_PROPS = {
94
+ contentContainerStyle: 'style',
95
+ };
96
+ // Owner props the SLOT's payload reads. Declared so a write to one dirties the slot — see
97
+ // `IHostBehavior.slotDerived` for why nothing else makes that happen.
98
+ const SLOT_DERIVED = ['maintainVisibleContentPosition', 'snapToAlignment'];
99
+ // A `<RefreshControl>` written among the app's children is claimed, and WHAT the owner does with
100
+ // it is the one thing that genuinely differs per platform — see the platform files. Resolved
101
+ // through `descriptorFor`, so this is `PullToRefreshView` on iOS and `AndroidSwipeRefreshLayout`
102
+ // on Android without either name appearing here.
103
+ export const REFRESH_CONTROL = descriptorFor('refresh-control').component;
104
+ // The OWNER's fold: the per-axis base style UNDER the app's (so an explicit `flexDirection` still
105
+ // wins), `decelerationRate` resolved from RN's two words to the platform's friction constant, and
106
+ // the two props a tag has no wrapper to write for it. The resolution has to happen here because
107
+ // 'normal'/'fast' reach Fabric as strings it cannot read.
108
+ //
109
+ // `horizontal` is a real C++ prop (`BaseScrollViewProps.h:56`) and the separate ViewManager is
110
+ // ANDROID's — on iOS both tags resolve to RCTScrollView, so the PROP is what turns the axis there
111
+ // and a bare `<horizontal-scroll-view>` would otherwise scroll vertically. Written from the tag
112
+ // rather than read off props, which is the same source `buildStructure` picked the content
113
+ // intrinsic from; an app that also writes `horizontal` on the vertical tag is contradicting the
114
+ // element it chose, and the tag wins.
115
+ //
116
+ // `nestedScrollEnabled` defaults ON because every wrapper writes it on every ScrollView, both
117
+ // platforms. RN itself only defaults it on the Android RefreshControl WRAP path
118
+ // (`ScrollView.js:1862`) — parity here is with the wrapper this behavior replaced.
119
+ // THE OWNER'S FOLD IS GONE (2026-09-18) — `foldScrollViewProps` in `SymbioteFabricProps.cpp`. Every
120
+ // input it had was the node's own bag plus the AXIS, and the axis is the tag (`scroll-view` vs
121
+ // `horizontal-scroll-view`), so it was a tag rule by every criterion: the base style composition,
122
+ // `nestedScrollEnabled`, the `horizontal` strip, the asymmetric bounce pair, the two ViewConfig-less
123
+ // strips, and `decelerationRate`. Contract:
124
+ // `core/engine/cpp/tests/js/scroll-view-payload.itest.ts`.
125
+ //
126
+ // IT WAS BLOCKED ON A MISSING LOG, not on anything about the rule. An app writing `horizontal` on
127
+ // the vertical tag has it IGNORED, and this fold `dlog`'d where to write it instead — while
128
+ // `core/engine/cpp` had no logging facility at all, only `throw jsi::JSError`. Moving the rule as
129
+ // written would have deleted a diagnostic, which `<keep_logs_gate_behind_DEBUG>` forbids. So
130
+ // `SymbioteDebug.h` was built first and this is its first caller
131
+ // (`core/engine/cpp/tests/js/native-debug-log.itest.ts`).
132
+ //
133
+ // `decelerationRate`'s two constants went with it and are `#ifdef ANDROID` there: on iOS BOTH tags
134
+ // resolve to `RCTScrollView`, so a component name cannot tell iOS-vertical from Android-vertical the
135
+ // way `foldSwitchProps` can. That leaves the Android half outside headless reach — the same gap
136
+ // already recorded for `android_ripple`, and the only part of this rule a test here cannot see.
137
+ // THE SLOT'S FOLD IS GONE (2026-09-18) — `foldScrollContentProps` in `SymbioteFabricProps.cpp`.
138
+ // Its two halves came from different places and the second is why it took until now:
139
+ //
140
+ // rowStyle a CONSTANT, horizontal only, composed OVER the app's contentContainerStyle
141
+ // — a function of the content node's OWN tag, portable from the start
142
+ // collapsableChildren DERIVED from `maintainVisibleContentPosition` / `snapToAlignment`, which
143
+ // stay on the OWNER
144
+ //
145
+ // "A per-node rule cannot reach another node" is what this file used to say, and it was a fact about
146
+ // the JS FOLD rather than about the engine: the tree lives in C++, so a node knows its parent and
147
+ // `fabricProps` now takes `ownerProps` from it. Contract:
148
+ // `core/engine/cpp/tests/js/scroll-content-payload.itest.ts`.
149
+ // `rowStyle` USED TO BE A PARAMETER HERE and is not one any more: the content node's row direction
150
+ // is decided in the engine from that node's OWN tag (`horizontal-scroll-content`), so this builder
151
+ // no longer needs to know the axis to build it. `scrollBehavior` still derives `horizontal` from the
152
+ // row style for the things that DO still need it in JS.
153
+ function buildContent(contentIntrinsic) {
154
+ return (node) => {
155
+ const descriptor = descriptorFor(contentIntrinsic);
156
+ const content = createElement(descriptor.component, descriptor.isText, contentIntrinsic);
157
+ // `collapsable: false` WAS SEEDED HERE AND IS NOT ANY MORE (2026-09-18). It is unconditional on
158
+ // every content node in both axes (`ScrollView.js:1747`), which makes it a constant of the TAG
159
+ // rather than of this builder — `foldScrollContentProps` writes it, and the contract is
160
+ // `scroll-content-payload.itest.ts` ("from the rule and not a seed", which asserts the authored
161
+ // prop is ABSENT because that is the only thing distinguishing the two routes).
162
+ // Lands directly on the owner, because `node.childHost` is still undefined here: the engine
163
+ // assigns it from what this returns. That ordering is why `buildStructure` RETURNS the slot
164
+ // instead of setting the field itself — a behavior that set it first would redirect its own
165
+ // structure into the slot it was building.
166
+ appendChild(node, content);
167
+ return content;
168
+ };
169
+ }
170
+ // The last size each owner reported, so a layout pass that did not change the content size does not
171
+ // fire the app's handler — RN dedupes the same way (`_handleContentOnLayout`). Off the node: this
172
+ // exists only for the ScrollViews an app wired a handler to.
173
+ const lastContentSize = new WeakMap();
174
+ // RN synthesizes onContentSizeChange from the CONTENT view's own onLayout — there is no native
175
+ // content-size event (ScrollView.js:1675 `contentSizeChangeProps`). The wrapper wired that by
176
+ // rendering an `onLayout` onto its inner node; a tag has no inner node of its own, so the behavior
177
+ // installs it on the slot it built.
178
+ //
179
+ // The app's callback takes `(width, height)`, not an event, which is why `contentSizeChange` is an
180
+ // OWNED listener: `setEventListener` wraps an ordinary listener as `(event) => handler(event)` and
181
+ // would call a two-number handler with one event. Owned names are stashed raw instead.
182
+ function contentSizeListener(owner) {
183
+ return (event) => {
184
+ const handler = appListenerFor(owner, 'contentSizeChange');
185
+ if (typeof handler !== 'function')
186
+ return;
187
+ const width = readLayoutDimension(event, 'width');
188
+ const height = readLayoutDimension(event, 'height');
189
+ if (width === undefined || height === undefined)
190
+ return;
191
+ if (!didContentSizeChange(lastContentSize.get(owner) ?? null, {
192
+ width,
193
+ height,
194
+ }))
195
+ return;
196
+ lastContentSize.set(owner, { width, height });
197
+ dlog(`ScrollView onContentSizeChange ${width}x${height}`);
198
+ handler(width, height);
199
+ };
200
+ }
201
+ // RN installs the content `onLayout` only when the app passed `onContentSizeChange`, and so does
202
+ // every wrapper — `onLayout` is a gated event, so wiring it unconditionally would put `onLayout:
203
+ // true` in the payload of every ScrollView's content node and buy a native event nobody reads. A
204
+ // change to the committed surface in EITHER direction is a bug, so the wiring has to follow the
205
+ // prop.
206
+ //
207
+ // It follows the LISTENER rather than a commit, which is what `onOwnedListenerChange` is for: a
208
+ // listener flip changes no payload by itself, so the commit after it is a no-op and a post-commit
209
+ // hook would never fire. Measured on exactly this — the wire worked (mount commits for other
210
+ // reasons) and the UNWIRE silently did not.
211
+ function syncContentSizeWiring(owner, wired) {
212
+ const slot = owner.childHost;
213
+ if (slot === undefined)
214
+ return;
215
+ if (wired) {
216
+ setEventListener(slot, 'layout', contentSizeListener(owner));
217
+ }
218
+ else {
219
+ lastContentSize.delete(owner);
220
+ setEventListener(slot, 'layout', undefined);
221
+ }
222
+ }
223
+ // Two owned names answer to a flip, and they answer on DIFFERENT nodes: `contentSizeChange` wires
224
+ // the SLOT's layout, `layout` wires the owner's own — which an inverted sticky header also wants,
225
+ // so the two claims are resolved in one place (`syncOwnerLayout`) rather than by whoever wrote last.
226
+ function syncOwnedListener(owner, name, wired) {
227
+ if (name === 'contentSizeChange')
228
+ syncContentSizeWiring(owner, wired);
229
+ else if (name === 'layout')
230
+ syncOwnerLayout(owner);
231
+ }
232
+ function scrollBehavior(contentIntrinsic, rowStyle, platform) {
233
+ // The row style is the horizontal tag's constant and nothing else carries it, so it IS the axis —
234
+ // deriving keeps the two from ever disagreeing about which behavior this is.
235
+ const horizontal = rowStyle !== undefined;
236
+ return {
237
+ // `scroll` and `layout` are owned for the collision reason rather than because the behavior
238
+ // consumes them: RN's ScrollView installs `_handleScroll` and `_handleLayout` on the native
239
+ // view unconditionally and calls the app's own handler from inside them, and `node.listeners`
240
+ // is single-slot — so a behavior that installed either without owning it would silently evict
241
+ // the app's.
242
+ ownedListeners: [
243
+ 'contentSizeChange',
244
+ 'scroll',
245
+ 'layout',
246
+ ...RESPONDER_OWNED_LISTENERS,
247
+ ],
248
+ slotProps: SLOT_PROPS,
249
+ slotDerived: [...SLOT_DERIVED, ...(platform.slotDerived ?? [])],
250
+ claimedChildren: { [REFRESH_CONTROL]: platform.claimMode },
251
+ buildStructure: buildContent(contentIntrinsic),
252
+ // The scroll dispatcher is installed here and never conditionally: it is what drives the
253
+ // sticky AnimatedValue, and a header can register long after this node was created. It costs a
254
+ // forward per scroll event on a ScrollView with no sticky child, which is what RN pays too.
255
+ // Nothing else is taken — no timer, and the two conditional listeners are wired on a flip.
256
+ attach(node) {
257
+ markScrollOwner(node);
258
+ setBehaviorListener(node, 'scroll', event => handleOwnerScroll(node, event));
259
+ installResponderPredicates(node);
260
+ },
261
+ onOwnedListenerChange: syncOwnedListener,
262
+ // The one beat at which the app's children are all present — `stickyHeaderIndices` addresses
263
+ // them positionally, and no hook reports a children CHANGE. Costs a Set iteration per commit
264
+ // over the ScrollViews alone, and `reconcileStickyIndices` returns on a WeakSet miss for any
265
+ // that never used the prop.
266
+ afterCommit: reconcileStickyIndices,
267
+ detach(node) {
268
+ lastContentSize.delete(node);
269
+ releaseStickyOwner(node);
270
+ },
271
+ };
272
+ }
273
+ // Both axes, given the platform's answer to the RefreshControl question. The platform files call
274
+ // this; nothing else should.
275
+ export function registerScrollViewBehaviors(platform) {
276
+ registerHostBehavior(SCROLL_VIEW_TAG, scrollBehavior('scroll-content', undefined, platform));
277
+ registerHostBehavior(HORIZONTAL_SCROLL_VIEW_TAG, scrollBehavior('horizontal-scroll-content', { flexDirection: 'row' }, platform));
278
+ // REGISTRATIONS WITH NO RUNTIME, and they are what hand the content tags to the host. A tag
279
+ // crosses only through `recordSetTag`, which `attachHostBehavior` emits, so a tag with no behavior
280
+ // registered carries an EMPTY `tagName` in C++ and no rule can fire for it. These two nodes are
281
+ // built by `buildStructure` and named by no app, which is exactly the shape that trap has.
282
+ //
283
+ // Same reasoning as `activity-indicator-spinner`'s stub: a registration is how this codebase says
284
+ // a tag HAS platform semantics, which is the claim being made.
285
+ for (const contentTag of ['scroll-content', 'horizontal-scroll-content'])
286
+ registerHostBehavior(contentTag, { attach() { }, detach() { } });
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,18 @@
1
+ import { type IHostBehavior, type ISymbioteEvent, type ISymbioteNode } from '@symbiote-native/engine';
2
+ export declare const STICKY_HEADER_TAG = "sticky-header";
3
+ export declare const STICKY_TRANSLATE_PROP = "stickyTranslateY";
4
+ export declare function markScrollOwner(node: ISymbioteNode): void;
5
+ export declare function hasStickyHeaders(owner: ISymbioteNode): boolean;
6
+ export declare function syncOwnerLayout(owner: ISymbioteNode): void;
7
+ export declare function handleOwnerScroll(owner: ISymbioteNode, event: ISymbioteEvent): void;
8
+ export declare function releaseStickyOwner(owner: ISymbioteNode): void;
9
+ /**
10
+ * Bring the synthesized wrappers in line with `stickyHeaderIndices`. Called from the ScrollView
11
+ * behavior's `afterCommit`, the one beat at which the app's children are all present.
12
+ *
13
+ * O(slot children) per commit, once — never per mutation. Angular's controller coalesces to one
14
+ * pass per change detection for exactly this reason: its per-mutation walk was O(M²) and died at
15
+ * 801 children.
16
+ */
17
+ export declare function reconcileStickyIndices(owner: ISymbioteNode): void;
18
+ export declare const stickyHeaderBehavior: IHostBehavior;