@symbiote-native/components 2.0.0 → 3.0.1
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.
- package/README.md +8 -9
- package/build/accessibility-props.d.ts +1 -1
- package/build/accessibility-props.js +2 -2
- package/build/behaviors/activity-indicator/shared.js +41 -70
- package/build/behaviors/button.d.ts +11 -0
- package/build/behaviors/button.js +101 -89
- package/build/behaviors/image-background.d.ts +1 -0
- package/build/behaviors/image-background.js +97 -81
- package/build/behaviors/image.d.ts +0 -1
- package/build/behaviors/image.js +24 -105
- package/build/behaviors/input-accessory-view.d.ts +0 -1
- package/build/behaviors/input-accessory-view.js +47 -53
- package/build/behaviors/pressable.d.ts +1 -1
- package/build/behaviors/pressable.js +89 -100
- package/build/behaviors/refresh-control.js +15 -2
- package/build/behaviors/scroll-view/index.android.js +25 -37
- package/build/behaviors/scroll-view/index.d.ts +1 -0
- package/build/behaviors/scroll-view/index.js +3 -0
- package/build/behaviors/scroll-view/responder.d.ts +4 -0
- package/build/behaviors/scroll-view/responder.js +202 -0
- package/build/behaviors/scroll-view/shared.d.ts +1 -3
- package/build/behaviors/scroll-view/shared.js +95 -95
- package/build/behaviors/scroll-view/sticky.d.ts +1 -0
- package/build/behaviors/scroll-view/sticky.js +62 -49
- package/build/behaviors/switch.js +43 -86
- package/build/behaviors/text-input.js +219 -107
- package/build/behaviors/touchable-highlight.js +74 -61
- package/build/behaviors/touchable-native-feedback.js +43 -122
- package/build/behaviors/touchable-opacity.js +71 -59
- package/build/behaviors/touchable-without-feedback.js +35 -100
- package/build/component-names/index.android.js +7 -11
- package/build/component-names/index.ios.js +0 -7
- package/build/component-names/shared.d.ts +1 -1
- package/build/index.d.ts +15 -21
- package/build/index.js +21 -28
- package/build/resolve-intrinsic.js +3 -9
- package/build/scroll-view-commands.d.ts +1 -9
- package/build/scroll-view-commands.js +13 -74
- package/build/state/flat-list.d.ts +2 -2
- package/build/state/flat-list.js +10 -2
- package/build/state/pressable.d.ts +6 -1
- package/build/state/pressable.js +63 -28
- package/build/state/section-list.d.ts +2 -0
- package/build/state/section-list.js +14 -7
- package/build/state/text-input.d.ts +7 -40
- package/build/state/text-input.js +17 -186
- package/build/state/touchable.d.ts +1 -0
- package/build/state/touchable.js +11 -8
- package/build/state/virtualized-list-reducer.d.ts +2 -2
- package/build/state/virtualized-list.d.ts +6 -6
- package/build/state/virtualized-list.js +71 -37
- package/build/text-props.d.ts +0 -8
- package/build/text-props.js +14 -25
- package/build/view/render-button.d.ts +1 -29
- package/build/view/render-button.js +44 -81
- package/build/view/render-image/index.d.ts +14 -1
- package/build/view/render-image/index.js +22 -147
- package/build/view/render-input-accessory-view.d.ts +1 -5
- package/build/view/render-input-accessory-view.js +26 -48
- package/build/view/render-keyboard-avoiding-view.d.ts +7 -1
- package/build/view/render-keyboard-avoiding-view.js +40 -1
- package/build/view/render-modal.d.ts +1 -1
- package/build/view/render-modal.js +15 -5
- package/build/view/render-pressable/index.d.ts +1 -0
- package/build/view/render-pressable/index.js +4 -0
- package/build/view/render-scroll-view.d.ts +0 -4
- package/build/view/render-scroll-view.js +3 -49
- package/build/view/render-switch.d.ts +0 -14
- package/build/view/render-switch.js +4 -42
- package/build/view/render-touchable-highlight.d.ts +1 -0
- package/build/view/render-touchable-native-feedback.d.ts +0 -1
- package/build/view/render-touchable-native-feedback.js +15 -9
- package/host-primitives.cjs +49 -203
- package/host-primitives.d.cts +0 -1
- package/package.json +3 -7
- package/build/fold-host-bag.d.ts +0 -15
- package/build/fold-host-bag.js +0 -99
- package/build/view/render-text-input.d.ts +0 -11
- package/build/view/render-text-input.js +0 -39
|
@@ -15,14 +15,31 @@
|
|
|
15
15
|
// `./sticky`, because a `<StickyHeader>` is a CHILD and the three props above are functions of
|
|
16
16
|
// whether one registered.
|
|
17
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
|
+
//
|
|
18
36
|
// WHAT A COMPOSED PRIMITIVE COSTS TODAY. Every adapter's ScrollView wrapper builds the same two
|
|
19
37
|
// nodes: `selectScrollIntrinsics` picks a scroll intrinsic and a content intrinsic, and the
|
|
20
38
|
// wrapper's body nests `<content>{children}</content>` inside `<scroll>`. That body is a framework
|
|
21
39
|
// component instance per ScrollView — a Vue instance, a Solid props Proxy, Svelte anchors, an
|
|
22
|
-
// Angular LView — which is precisely the currency
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
// lowered at all no matter what its props did. This is that half.
|
|
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.
|
|
26
43
|
//
|
|
27
44
|
// WHY THE TAG CARRIES THE AXIS. `buildStructure` runs at `createElement`, before a single prop is
|
|
28
45
|
// routed, so it cannot read `horizontal`. It does not need to: horizontal scroll is already a
|
|
@@ -39,15 +56,12 @@
|
|
|
39
56
|
// content node: `RCTScrollView > RCTScrollContentView > RCTScrollContentView`. So the precondition
|
|
40
57
|
// per adapter is that nothing else builds the content node.
|
|
41
58
|
//
|
|
42
|
-
// A SECOND TAG
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
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`.
|
|
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`.
|
|
51
65
|
//
|
|
52
66
|
// STYLE, on both nodes, and the precedence is the part that is easy to get silently wrong. The
|
|
53
67
|
// wrapper composes exactly two arrays, and this reproduces both:
|
|
@@ -67,9 +81,10 @@
|
|
|
67
81
|
// The slot's fold is assigned to the node inside `buildStructure`, not declared on the behavior:
|
|
68
82
|
// `IHostBehavior.foldPayload` is the OWNER's, wired by `attachHostBehavior`, and a behavior that
|
|
69
83
|
// builds a node owns what that node carries.
|
|
70
|
-
import { appendChild, appListenerFor, createElement, dlog, registerHostBehavior, setBehaviorListener, setEventListener, } from '@symbiote-native/engine';
|
|
84
|
+
import { appendChild, appListenerFor, createElement, dlog, registerHostBehavior, setBehaviorListener, setEventListener, setProp, } from '@symbiote-native/engine';
|
|
71
85
|
import { descriptorFor } from '../../component-names';
|
|
72
|
-
import { didContentSizeChange,
|
|
86
|
+
import { didContentSizeChange, readLayoutDimension, } from '../../view/render-scroll-view.js';
|
|
87
|
+
import { installResponderPredicates, RESPONDER_OWNED_LISTENERS, } from './responder.js';
|
|
73
88
|
import { handleOwnerScroll, markScrollOwner, reconcileStickyIndices, releaseStickyOwner, stickyHeaderBehavior, STICKY_HEADER_TAG, syncOwnerLayout, } from './sticky.js';
|
|
74
89
|
export const SCROLL_VIEW_TAG = 'scroll-view';
|
|
75
90
|
export const HORIZONTAL_SCROLL_VIEW_TAG = 'horizontal-scroll-view';
|
|
@@ -88,8 +103,8 @@ const SLOT_DERIVED = ['maintainVisibleContentPosition', 'snapToAlignment'];
|
|
|
88
103
|
export const REFRESH_CONTROL = descriptorFor('refresh-control').component;
|
|
89
104
|
// The OWNER's fold: the per-axis base style UNDER the app's (so an explicit `flexDirection` still
|
|
90
105
|
// wins), `decelerationRate` resolved from RN's two words to the platform's friction constant, and
|
|
91
|
-
// the two props a
|
|
92
|
-
//
|
|
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.
|
|
93
108
|
//
|
|
94
109
|
// `horizontal` is a real C++ prop (`BaseScrollViewProps.h:56`) and the separate ViewManager is
|
|
95
110
|
// ANDROID's — on iOS both tags resolve to RCTScrollView, so the PROP is what turns the axis there
|
|
@@ -100,78 +115,50 @@ export const REFRESH_CONTROL = descriptorFor('refresh-control').component;
|
|
|
100
115
|
//
|
|
101
116
|
// `nestedScrollEnabled` defaults ON because every wrapper writes it on every ScrollView, both
|
|
102
117
|
// platforms. RN itself only defaults it on the Android RefreshControl WRAP path
|
|
103
|
-
// (`ScrollView.js:1862`) — parity here is with the wrapper
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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:
|
|
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:
|
|
144
139
|
//
|
|
145
140
|
// rowStyle a CONSTANT, horizontal only, composed OVER the app's contentContainerStyle
|
|
146
|
-
//
|
|
147
|
-
// collapsableChildren DERIVED from
|
|
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
|
|
148
144
|
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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) {
|
|
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) {
|
|
167
154
|
return (node) => {
|
|
168
155
|
const descriptor = descriptorFor(contentIntrinsic);
|
|
169
156
|
const content = createElement(descriptor.component, descriptor.isText, contentIntrinsic);
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
content.
|
|
174
|
-
|
|
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).
|
|
175
162
|
// Lands directly on the owner, because `node.childHost` is still undefined here: the engine
|
|
176
163
|
// assigns it from what this returns. That ordering is why `buildStructure` RETURNS the slot
|
|
177
164
|
// instead of setting the field itself — a behavior that set it first would redirect its own
|
|
@@ -186,8 +173,8 @@ function buildContent(contentIntrinsic, rowStyle) {
|
|
|
186
173
|
const lastContentSize = new WeakMap();
|
|
187
174
|
// RN synthesizes onContentSizeChange from the CONTENT view's own onLayout — there is no native
|
|
188
175
|
// content-size event (ScrollView.js:1675 `contentSizeChangeProps`). The wrapper wired that by
|
|
189
|
-
// rendering an `onLayout` onto its inner node; a
|
|
190
|
-
//
|
|
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.
|
|
191
178
|
//
|
|
192
179
|
// The app's callback takes `(width, height)`, not an event, which is why `contentSizeChange` is an
|
|
193
180
|
// OWNED listener: `setEventListener` wraps an ordinary listener as `(event) => handler(event)` and
|
|
@@ -213,9 +200,9 @@ function contentSizeListener(owner) {
|
|
|
213
200
|
}
|
|
214
201
|
// RN installs the content `onLayout` only when the app passed `onContentSizeChange`, and so does
|
|
215
202
|
// every wrapper — `onLayout` is a gated event, so wiring it unconditionally would put `onLayout:
|
|
216
|
-
// true` in the payload of every
|
|
217
|
-
//
|
|
218
|
-
//
|
|
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.
|
|
219
206
|
//
|
|
220
207
|
// It follows the LISTENER rather than a commit, which is what `onOwnedListenerChange` is for: a
|
|
221
208
|
// listener flip changes no payload by itself, so the commit after it is a no-op and a post-commit
|
|
@@ -242,7 +229,7 @@ function syncOwnedListener(owner, name, wired) {
|
|
|
242
229
|
else if (name === 'layout')
|
|
243
230
|
syncOwnerLayout(owner);
|
|
244
231
|
}
|
|
245
|
-
function scrollBehavior(contentIntrinsic,
|
|
232
|
+
function scrollBehavior(contentIntrinsic, rowStyle, platform) {
|
|
246
233
|
// The row style is the horizontal tag's constant and nothing else carries it, so it IS the axis —
|
|
247
234
|
// deriving keeps the two from ever disagreeing about which behavior this is.
|
|
248
235
|
const horizontal = rowStyle !== undefined;
|
|
@@ -252,13 +239,16 @@ function scrollBehavior(contentIntrinsic, base, rowStyle, platform) {
|
|
|
252
239
|
// view unconditionally and calls the app's own handler from inside them, and `node.listeners`
|
|
253
240
|
// is single-slot — so a behavior that installed either without owning it would silently evict
|
|
254
241
|
// the app's.
|
|
255
|
-
ownedListeners: [
|
|
242
|
+
ownedListeners: [
|
|
243
|
+
'contentSizeChange',
|
|
244
|
+
'scroll',
|
|
245
|
+
'layout',
|
|
246
|
+
...RESPONDER_OWNED_LISTENERS,
|
|
247
|
+
],
|
|
256
248
|
slotProps: SLOT_PROPS,
|
|
257
249
|
slotDerived: [...SLOT_DERIVED, ...(platform.slotDerived ?? [])],
|
|
258
250
|
claimedChildren: { [REFRESH_CONTROL]: platform.claimMode },
|
|
259
|
-
|
|
260
|
-
buildStructure: buildContent(contentIntrinsic, rowStyle),
|
|
261
|
-
foldPayload: ownerFold(base, horizontal),
|
|
251
|
+
buildStructure: buildContent(contentIntrinsic),
|
|
262
252
|
// The scroll dispatcher is installed here and never conditionally: it is what drives the
|
|
263
253
|
// sticky AnimatedValue, and a header can register long after this node was created. It costs a
|
|
264
254
|
// forward per scroll event on a ScrollView with no sticky child, which is what RN pays too.
|
|
@@ -266,6 +256,7 @@ function scrollBehavior(contentIntrinsic, base, rowStyle, platform) {
|
|
|
266
256
|
attach(node) {
|
|
267
257
|
markScrollOwner(node);
|
|
268
258
|
setBehaviorListener(node, 'scroll', event => handleOwnerScroll(node, event));
|
|
259
|
+
installResponderPredicates(node);
|
|
269
260
|
},
|
|
270
261
|
onOwnedListenerChange: syncOwnedListener,
|
|
271
262
|
// The one beat at which the app's children are all present — `stickyHeaderIndices` addresses
|
|
@@ -282,8 +273,17 @@ function scrollBehavior(contentIntrinsic, base, rowStyle, platform) {
|
|
|
282
273
|
// Both axes, given the platform's answer to the RefreshControl question. The platform files call
|
|
283
274
|
// this; nothing else should.
|
|
284
275
|
export function registerScrollViewBehaviors(platform) {
|
|
285
|
-
registerHostBehavior(SCROLL_VIEW_TAG, scrollBehavior('scroll-content',
|
|
286
|
-
registerHostBehavior(HORIZONTAL_SCROLL_VIEW_TAG, scrollBehavior('horizontal-scroll-content',
|
|
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
287
|
// With the scroll views, never on its own: a sticky header is meaningless without an owner to
|
|
288
288
|
// find, and registering the pair together is what makes "did the registration run" one question
|
|
289
289
|
// rather than two.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type IHostBehavior, type ISymbioteEvent, type ISymbioteNode } from '@symbiote-native/engine';
|
|
2
2
|
export declare const STICKY_HEADER_TAG = "sticky-header";
|
|
3
|
+
export declare const STICKY_TRANSLATE_PROP = "stickyTranslateY";
|
|
3
4
|
export declare function markScrollOwner(node: ISymbioteNode): void;
|
|
4
5
|
export declare function hasStickyHeaders(owner: ISymbioteNode): boolean;
|
|
5
6
|
export declare function syncOwnerLayout(owner: ISymbioteNode): void;
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
// Sticky headers
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// Sticky headers. BOTH forms live here — the CHILD form (`<sticky-header>`, the path our own lists
|
|
2
|
+
// use) and the INDEX form (`stickyHeaderIndices`, RN's public API) — plus the owner-side half that
|
|
3
|
+
// feeds them.
|
|
4
4
|
//
|
|
5
5
|
// WHY A CHILD AT ALL. `stickyHeaderIndices` is an index list because JSX has no way to MARK an
|
|
6
|
-
// element — RN walks its own children array and wraps the flagged ones. `<
|
|
7
|
-
// same thing in the one place
|
|
8
|
-
//
|
|
6
|
+
// element — RN walks its own children array and wraps the flagged ones. `<sticky-header>` says the
|
|
7
|
+
// same thing in the one place the engine can read without an index: the tag of a node that is
|
|
8
|
+
// already in the tree.
|
|
9
9
|
//
|
|
10
10
|
// THE INDEX FORM IS BUILT (2026-09-07), and this header said it was impossible until then. Both
|
|
11
11
|
// halves of that claim were false, measured against Angular's projection controller, which already
|
|
@@ -44,13 +44,19 @@
|
|
|
44
44
|
// `scrollEventThrottle` (RN raises it so the offset reaches the AnimatedValue at all), the scroll
|
|
45
45
|
// listener that drives that value, and — inverted only — the viewport height the pin math needs.
|
|
46
46
|
// A separate module would have to export a registry back and forth.
|
|
47
|
-
import { AnimatedProps, AnimatedValue, appendChild, appListenerFor, createElement, dlog, insertBefore, isAnchor, isNativeAnimatedAvailable,
|
|
47
|
+
import { AnimatedProps, AnimatedValue, appendChild, appListenerFor, createElement, dlog, insertBefore, isAnchor, isNativeAnimatedAvailable, Platform, removeChild, requestCommitFor, setBehaviorListener, setProp, whenCommitted, propOf, childrenOf, parentOf, } from '@symbiote-native/engine';
|
|
48
48
|
import { descriptorFor } from '../../component-names';
|
|
49
49
|
import { attachStickyScroll } from '../../scroll-view-commands.js';
|
|
50
|
+
import { markScrollObserved } from './responder.js';
|
|
50
51
|
import { createInitialStickyState, reduceSticky, } from '../../state/sticky-header-reducer.js';
|
|
51
52
|
import { readLayoutNumber, STICKY_HEADER_Z_INDEX, } from '../../view/render-scroll-sticky.js';
|
|
52
53
|
import { resolveScrollForwarding } from '../../view/render-scroll-view.js';
|
|
53
54
|
export const STICKY_HEADER_TAG = 'sticky-header';
|
|
55
|
+
// The machine's one channel to its tag rule, and the only prop it ever writes. RN's twin is
|
|
56
|
+
// `passthroughAnimatedPropExplicitValues` (`ScrollViewStickyHeader.js:282-304`), a whole style
|
|
57
|
+
// object; ours carries the one number that object ever holds, so it does not borrow the name.
|
|
58
|
+
// `foldStickyHeaderProps` composes it into the style and strips the key — no ViewConfig declares it.
|
|
59
|
+
export const STICKY_TRANSLATE_PROP = 'stickyTranslateY';
|
|
54
60
|
// The scroll views that could own a header, so a header can find its own by walking up. The tag is
|
|
55
61
|
// not on the node (`createElement` looks the behavior up and stores nothing), and the parent chain
|
|
56
62
|
// is the only route — a header may sit any depth below the content view.
|
|
@@ -69,7 +75,7 @@ export function hasStickyHeaders(owner) {
|
|
|
69
75
|
// otherwise), which is exactly when RN wraps the scroll view's own onLayout — so the gate flag
|
|
70
76
|
// lands on the same ScrollViews the wrapper puts it on and on no others.
|
|
71
77
|
function needsViewportHeight(owner) {
|
|
72
|
-
return hasStickyHeaders(owner) && owner
|
|
78
|
+
return (hasStickyHeaders(owner) && propOf(owner, 'invertStickyHeaders') === true);
|
|
73
79
|
}
|
|
74
80
|
function ownerSticky(owner) {
|
|
75
81
|
const existing = stickyOwners.get(owner);
|
|
@@ -113,7 +119,7 @@ function syncNativeScroll(owner, sticky) {
|
|
|
113
119
|
// Depth-first over the content subtree, which IS document order — the same order RN's children
|
|
114
120
|
// walk produces, arrived at from the tree instead of from an index array.
|
|
115
121
|
function collectHeaders(node, members, out) {
|
|
116
|
-
for (const child of node
|
|
122
|
+
for (const child of childrenOf(node)) {
|
|
117
123
|
if (members.has(child))
|
|
118
124
|
out.push(child);
|
|
119
125
|
collectHeaders(child, members, out);
|
|
@@ -138,7 +144,7 @@ function orderedHeaders(owner, sticky) {
|
|
|
138
144
|
// The attach happens after the header has registered, which is the same ordering React's
|
|
139
145
|
// `useEffect` gives it.
|
|
140
146
|
function syncThrottle(owner, sticky) {
|
|
141
|
-
const current = owner
|
|
147
|
+
const current = propOf(owner, 'scrollEventThrottle');
|
|
142
148
|
// Whatever stands in the key is the APP's unless it is byte-for-byte the value written here —
|
|
143
149
|
// which is what makes the take-back on the last unregister safe.
|
|
144
150
|
const ours = sticky.writtenThrottle !== undefined && current === sticky.writtenThrottle;
|
|
@@ -194,6 +200,10 @@ function readContentOffsetY(event) {
|
|
|
194
200
|
// the app's own `onScroll` is an OWNED name and would otherwise evict this one from the single
|
|
195
201
|
// listener slot.
|
|
196
202
|
export function handleOwnerScroll(owner, event) {
|
|
203
|
+
// RN's `_handleScroll` (`ScrollView.js:1145-1147`) sets this unconditionally too — nothing reads
|
|
204
|
+
// it unless this node actually holds the responder, so an unconditional set on every scroll is
|
|
205
|
+
// exactly as safe here as it is there. See `./responder.ts`.
|
|
206
|
+
markScrollObserved(owner);
|
|
197
207
|
const sticky = stickyOwners.get(owner);
|
|
198
208
|
// Skipped while the offset rides the UI thread: the native attach already drives the value every
|
|
199
209
|
// frame, so writing it again from a JS event is a redundant graph update at a WORSE rate.
|
|
@@ -226,7 +236,7 @@ export function releaseStickyOwner(owner) {
|
|
|
226
236
|
stickyOwners.delete(owner);
|
|
227
237
|
}
|
|
228
238
|
// ---------------------------------------------------------------- the index form
|
|
229
|
-
// `stickyHeaderIndices
|
|
239
|
+
// `stickyHeaderIndices`, and it is deliberately NOT a second machine: a flagged
|
|
230
240
|
// child is MOVED into a synthesized `sticky-header` node, so ordering, cross-talk, the raised
|
|
231
241
|
// throttle, the pin and the teardown are the child form's, unchanged. Indices decide only WHICH
|
|
232
242
|
// children get one.
|
|
@@ -275,9 +285,19 @@ function wrapForIndex(slot, child) {
|
|
|
275
285
|
// so the wrapper takes the position the child vacates and nothing has to be removed.
|
|
276
286
|
insertBefore(slot, wrapper, child);
|
|
277
287
|
appendChild(wrapper, child);
|
|
288
|
+
// AFTER both, or the anchor above would resolve to the wrapper itself. `wrapper` is the engine's
|
|
289
|
+
// own "what stands in this node's place" indirection (`ISymbioteNode.wrapper`), and a sticky
|
|
290
|
+
// wrapper is exactly that: the framework keeps naming the ScrollView and the row, while the tree
|
|
291
|
+
// holds the wrapper in the row's place — so a later `removeChild(owner, row)` takes the wrapper
|
|
292
|
+
// out with it instead of being refused for naming a parent the row no longer has.
|
|
293
|
+
child.wrapper = wrapper;
|
|
278
294
|
}
|
|
279
295
|
function unwrapIndex(slot, wrapper) {
|
|
280
|
-
const child = wrapper
|
|
296
|
+
const child = childrenOf(wrapper)[0];
|
|
297
|
+
// Before the move, for the same reason it is set after one: `insertBefore` would otherwise put
|
|
298
|
+
// the wrapper back in the child's place.
|
|
299
|
+
if (child !== undefined)
|
|
300
|
+
child.wrapper = undefined;
|
|
281
301
|
if (child !== undefined)
|
|
282
302
|
insertBefore(slot, child, wrapper);
|
|
283
303
|
removeChild(slot, wrapper);
|
|
@@ -294,7 +314,7 @@ export function reconcileStickyIndices(owner) {
|
|
|
294
314
|
const slot = owner.childHost;
|
|
295
315
|
if (slot === undefined)
|
|
296
316
|
return;
|
|
297
|
-
const wanted = stickyIndexSet(owner
|
|
317
|
+
const wanted = stickyIndexSet(propOf(owner, 'stickyHeaderIndices'));
|
|
298
318
|
if (wanted === undefined && !ownersWithIndexWrappers.has(owner))
|
|
299
319
|
return;
|
|
300
320
|
let paintIndex = 0;
|
|
@@ -304,13 +324,14 @@ export function reconcileStickyIndices(owner) {
|
|
|
304
324
|
//
|
|
305
325
|
// A claimed `<RefreshControl>` needs no filter here, unlike Angular's walk — `hostFor` keeps a
|
|
306
326
|
// claimed child on the OWNER, so it never reaches the slot at all.
|
|
307
|
-
for (const child of [...slot
|
|
327
|
+
for (const child of [...childrenOf(slot)]) {
|
|
308
328
|
const wrapper = indexWrappers.has(child) ? child : undefined;
|
|
309
329
|
if (wrapper !== undefined) {
|
|
310
330
|
// The framework removes a child from the SLOT, because that is where it appended it — so the
|
|
311
331
|
// engine's `removeChild` finds nothing to splice and only clears `child.parent`, leaving a
|
|
312
332
|
// committed wrapper around a node nobody owns. This walk is the only thing that can see it.
|
|
313
|
-
|
|
333
|
+
const held = childrenOf(wrapper)[0];
|
|
334
|
+
if (held === undefined || parentOf(held) !== wrapper) {
|
|
314
335
|
removeChild(slot, wrapper);
|
|
315
336
|
changed = true;
|
|
316
337
|
continue;
|
|
@@ -351,11 +372,11 @@ export function reconcileStickyIndices(owner) {
|
|
|
351
372
|
}
|
|
352
373
|
// ---------------------------------------------------------------- the header half
|
|
353
374
|
function findScrollOwner(node) {
|
|
354
|
-
let current = node
|
|
375
|
+
let current = parentOf(node);
|
|
355
376
|
while (current !== undefined) {
|
|
356
377
|
if (scrollOwners.has(current))
|
|
357
378
|
return current;
|
|
358
|
-
current = current
|
|
379
|
+
current = parentOf(current);
|
|
359
380
|
}
|
|
360
381
|
return undefined;
|
|
361
382
|
}
|
|
@@ -370,28 +391,16 @@ function nextHeaderY(runtime, node) {
|
|
|
370
391
|
const next = order[order.indexOf(node) + 1];
|
|
371
392
|
return next === undefined ? undefined : sticky.layoutYs.get(next);
|
|
372
393
|
}
|
|
373
|
-
//
|
|
374
|
-
// hit-testing (`passthroughAnimatedPropExplicitValues`), plus the two constants the wrapper always
|
|
375
|
-
// carries. The SMOOTH half rides the AnimatedProps leaf below and never passes through here.
|
|
394
|
+
// STICKY FOLD LEFT THIS FILE ON 2026-09-18, and it was the last `payloadFold` in the codebase.
|
|
376
395
|
//
|
|
377
|
-
//
|
|
378
|
-
//
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
...props,
|
|
386
|
-
// Over the app's, never under: the pin is the whole point of the element, and a header whose
|
|
387
|
-
// own style set a transform would otherwise cancel it.
|
|
388
|
-
style: [props.style, pin],
|
|
389
|
-
// Yoga may flatten a view that only groups children, and a flattened header has no transform
|
|
390
|
-
// to animate. RN's sticky wrapper sets it for the same reason.
|
|
391
|
-
collapsable: false,
|
|
392
|
-
};
|
|
393
|
-
};
|
|
394
|
-
}
|
|
396
|
+
// It wrote three things and they had two different origins. `zIndex: 10` and `collapsable: false`
|
|
397
|
+
// are constants of the wrapper — the platform's in any app — and the debounced translate is the
|
|
398
|
+
// machine's. Splitting them that way is what let the whole rule move: `foldStickyHeaderProps` in
|
|
399
|
+
// `SymbioteFabricProps.cpp` owns the composition now, and the one live number crosses as an
|
|
400
|
+
// ordinary prop (`STICKY_TRANSLATE_PROP`), which is how RN spells it too.
|
|
401
|
+
//
|
|
402
|
+
// Contract: `core/engine/cpp/tests/js/sticky-header-payload.itest.ts`. There is no JS twin — a
|
|
403
|
+
// payload rule asserted against a second copy of itself is asserted against nothing.
|
|
395
404
|
function dispatch(node, action) {
|
|
396
405
|
const runtime = headerRuntimes.get(node);
|
|
397
406
|
if (runtime === undefined || runtime.owner === undefined)
|
|
@@ -399,7 +408,7 @@ function dispatch(node, action) {
|
|
|
399
408
|
const sticky = stickyOwners.get(runtime.owner);
|
|
400
409
|
const result = reduceSticky(runtime.state, action, {
|
|
401
410
|
os: Platform.OS,
|
|
402
|
-
inverted: runtime.owner
|
|
411
|
+
inverted: propOf(runtime.owner, 'invertStickyHeaders') === true,
|
|
403
412
|
scrollViewHeight: sticky?.viewportHeight,
|
|
404
413
|
nextHeaderLayoutY: nextHeaderY(runtime, node),
|
|
405
414
|
});
|
|
@@ -420,16 +429,21 @@ function runEffects(node, runtime, effects) {
|
|
|
420
429
|
}, effect.delay);
|
|
421
430
|
break;
|
|
422
431
|
case 'apply-passthrough':
|
|
423
|
-
// The
|
|
424
|
-
//
|
|
432
|
+
// The machine's one channel to its tag rule. A prop rather than runtime state the fold
|
|
433
|
+
// reaches back for, because RN spells this the same way
|
|
434
|
+
// (`ScrollViewStickyHeader.js:302`, `passthroughAnimatedPropExplicitValues`) and because a
|
|
435
|
+
// prop write is what the rule in `SymbioteFabricProps.cpp` can read at all.
|
|
436
|
+
//
|
|
437
|
+
// The write marks the node itself, so only the commit request is still owed — dirtying is
|
|
438
|
+
// not publishing.
|
|
425
439
|
//
|
|
426
|
-
//
|
|
427
|
-
//
|
|
428
|
-
//
|
|
429
|
-
//
|
|
430
|
-
//
|
|
431
|
-
//
|
|
432
|
-
|
|
440
|
+
// WHY A COMMIT IS REQUESTED AT ALL, and it is NOT witnessed by a headless test: while the
|
|
441
|
+
// pin is JS-driven the animated leaf's own `setNativeProps` has already written the same
|
|
442
|
+
// transform and marked the node, so removing this reddens nothing here. It is the
|
|
443
|
+
// NATIVE-driver path it exists for — there the leaf stops writing JS-side and the committed
|
|
444
|
+
// transform is all hit-testing has, which is why RN keeps that explicit value beside the
|
|
445
|
+
// animated one.
|
|
446
|
+
setProp(node, STICKY_TRANSLATE_PROP, effect.translateY);
|
|
433
447
|
requestCommitFor(node);
|
|
434
448
|
break;
|
|
435
449
|
case 'record-header-y':
|
|
@@ -509,7 +523,6 @@ function attach(node) {
|
|
|
509
523
|
cancelBind: undefined,
|
|
510
524
|
};
|
|
511
525
|
headerRuntimes.set(node, runtime);
|
|
512
|
-
node.payloadFold = stickyFold(runtime);
|
|
513
526
|
setBehaviorListener(node, 'layout', event => handleHeaderLayout(node, event));
|
|
514
527
|
}
|
|
515
528
|
// The registration waits for a committed tag rather than happening in `attach`, and both halves of
|