@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.
- package/README.md +11 -13
- package/build/accessibility-props.d.ts +1 -1
- package/build/accessibility-props.js +2 -2
- package/build/behaviors/activity-indicator/index.android.d.ts +1 -0
- package/build/behaviors/activity-indicator/index.android.js +16 -0
- package/build/behaviors/activity-indicator/index.d.ts +3 -0
- package/build/behaviors/activity-indicator/index.ios.d.ts +1 -0
- package/build/behaviors/activity-indicator/index.ios.js +14 -0
- package/build/behaviors/activity-indicator/index.js +5 -0
- package/build/behaviors/activity-indicator/shared.d.ts +18 -0
- package/build/behaviors/activity-indicator/shared.js +120 -0
- package/build/behaviors/button.d.ts +13 -0
- package/build/behaviors/button.js +340 -0
- package/build/behaviors/image-background.d.ts +3 -0
- package/build/behaviors/image-background.js +155 -0
- package/build/behaviors/image.d.ts +1 -2
- package/build/behaviors/image.js +25 -106
- package/build/behaviors/input-accessory-view.d.ts +1 -2
- package/build/behaviors/input-accessory-view.js +49 -55
- package/build/behaviors/pressable.d.ts +59 -1
- package/build/behaviors/pressable.js +142 -96
- package/build/behaviors/refresh-control.d.ts +2 -0
- package/build/behaviors/refresh-control.js +96 -0
- package/build/behaviors/scroll-view/index.android.d.ts +1 -0
- package/build/behaviors/scroll-view/index.android.js +40 -0
- package/build/behaviors/scroll-view/index.d.ts +3 -0
- package/build/behaviors/scroll-view/index.ios.d.ts +1 -0
- package/build/behaviors/scroll-view/index.ios.js +10 -0
- package/build/behaviors/scroll-view/index.js +8 -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 +9 -0
- package/build/behaviors/scroll-view/shared.js +291 -0
- package/build/behaviors/scroll-view/sticky.d.ts +18 -0
- package/build/behaviors/scroll-view/sticky.js +581 -0
- package/build/behaviors/switch.d.ts +1 -1
- package/build/behaviors/switch.js +49 -88
- package/build/behaviors/text-input.d.ts +2 -2
- package/build/behaviors/text-input.js +244 -104
- package/build/behaviors/touchable-highlight.d.ts +9 -0
- package/build/behaviors/touchable-highlight.js +205 -0
- package/build/behaviors/touchable-native-feedback.d.ts +20 -0
- package/build/behaviors/touchable-native-feedback.js +254 -0
- package/build/behaviors/touchable-opacity.d.ts +12 -0
- package/build/behaviors/touchable-opacity.js +239 -0
- package/build/behaviors/touchable-without-feedback.d.ts +2 -0
- package/build/behaviors/touchable-without-feedback.js +231 -0
- package/build/component-names/index.android.js +31 -25
- package/build/component-names/index.ios.js +28 -23
- package/build/component-names/shared.d.ts +2 -1
- package/build/component-names/shared.js +16 -6
- package/build/descriptor.js +4 -4
- package/build/index.d.ts +26 -26
- package/build/index.js +56 -27
- package/build/register.d.ts +1 -0
- package/build/register.js +55 -0
- package/build/resolve-intrinsic.js +3 -9
- package/build/scroll-view-commands.d.ts +1 -5
- package/build/scroll-view-commands.js +23 -85
- 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 +10 -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 +11 -4
- package/build/view/render-button.js +74 -22
- 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 +18 -8
- package/build/view/render-pressable/index.d.ts +3 -0
- package/build/view/render-pressable/index.js +28 -0
- package/build/view/render-scroll-view.d.ts +1 -4
- package/build/view/render-scroll-view.js +17 -54
- package/build/view/render-switch.d.ts +4 -15
- 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 +19 -1
- package/build/view/render-touchable-native-feedback.js +34 -9
- package/host-primitives.cjs +178 -280
- package/host-primitives.d.cts +0 -3
- package/package.json +8 -21
- package/build/fold-host-bag.d.ts +0 -15
- package/build/fold-host-bag.js +0 -99
- package/build/state-style.d.ts +0 -15
- package/build/state-style.js +0 -47
- package/build/view/render-activity-indicator.d.ts +0 -25
- package/build/view/render-activity-indicator.js +0 -88
- package/build/view/render-image-background.d.ts +0 -9
- package/build/view/render-image-background.js +0 -48
- package/build/view/render-text-input.d.ts +0 -11
- package/build/view/render-text-input.js +0 -39
- package/lowering-fixtures.cjs +0 -259
- package/lowering-fixtures.d.cts +0 -17
- package/specialize-state-style.cjs +0 -219
- package/specialize-state-style.d.cts +0 -15
|
@@ -0,0 +1,581 @@
|
|
|
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
|
+
//
|
|
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. `<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
|
+
//
|
|
10
|
+
// THE INDEX FORM IS BUILT (2026-09-07), and this header said it was impossible until then. Both
|
|
11
|
+
// halves of that claim were false, measured against Angular's projection controller, which already
|
|
12
|
+
// resolves the same indices on engine nodes:
|
|
13
|
+
//
|
|
14
|
+
// "no children array to walk" `afterCommit` sees `owner.childHost.children` complete, on
|
|
15
|
+
// every commit that made a native call — and a child add or
|
|
16
|
+
// remove always does. The index basis is recoverable the way
|
|
17
|
+
// `reconcileStickyRecords` recovers it: a paint index that skips
|
|
18
|
+
// anchors.
|
|
19
|
+
// "no render to wrap anything in" a behavior builds nodes with the ordinary mutation API.
|
|
20
|
+
// `wrapForIndex` creates a node, inserts it at the slot and
|
|
21
|
+
// `appendChild`s the child into it — the engine's appendChild
|
|
22
|
+
// detaches from the old parent, so no removal is needed.
|
|
23
|
+
//
|
|
24
|
+
// The wrap is what must NOT be skipped. Writing the pin straight onto the flagged child is the
|
|
25
|
+
// tempting shortcut and it destroys the child's own `transform`: `fabricProps.addStyle` hoists
|
|
26
|
+
// style keys into one payload and later entries WIN, so a pin composed over an app's
|
|
27
|
+
// `transform: [{scale}]` replaces it rather than composing. RN's two nested views compose.
|
|
28
|
+
//
|
|
29
|
+
// WHAT RUNS WHERE. The DECISIONS are `reduceSticky` (`../../state/sticky-header-reducer`), shared
|
|
30
|
+
// with every adapter's own sticky component and untouched here. This module is one more EFFECT
|
|
31
|
+
// RUNNER for it — the fourth, after React/Vue/Svelte's components and Angular's projection
|
|
32
|
+
// controller — and the only one that runs on an engine node with no framework above it. The
|
|
33
|
+
// runner's shape is Angular's (`adapters/angular/src/components/scroll-view/projection.ts`),
|
|
34
|
+
// because that one already drives engine nodes; what changes is where the cross-talk comes from.
|
|
35
|
+
//
|
|
36
|
+
// CROSS-TALK WITHOUT INDICES. Each header is fed the y of the NEXT sticky header, which is the
|
|
37
|
+
// collision point it gets pushed off at. Every existing runner reads that out of an index map
|
|
38
|
+
// (`nextStickyHeaderY(childIndex)`), because indices are what it has. Here the owner keeps its
|
|
39
|
+
// headers in DOCUMENT order and the next one is the next entry — no index, and nothing to renumber
|
|
40
|
+
// when a list windows.
|
|
41
|
+
//
|
|
42
|
+
// THE OWNER HALF, and it is why this file holds both. Three of the scroll view's own props are
|
|
43
|
+
// functions of "does this ScrollView have sticky headers", which only a registration can answer:
|
|
44
|
+
// `scrollEventThrottle` (RN raises it so the offset reaches the AnimatedValue at all), the scroll
|
|
45
|
+
// listener that drives that value, and — inverted only — the viewport height the pin math needs.
|
|
46
|
+
// A separate module would have to export a registry back and forth.
|
|
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
|
+
import { descriptorFor } from '../../component-names';
|
|
49
|
+
import { attachStickyScroll } from '../../scroll-view-commands.js';
|
|
50
|
+
import { markScrollObserved } from './responder.js';
|
|
51
|
+
import { createInitialStickyState, reduceSticky, } from '../../state/sticky-header-reducer.js';
|
|
52
|
+
import { readLayoutNumber, STICKY_HEADER_Z_INDEX, } from '../../view/render-scroll-sticky.js';
|
|
53
|
+
import { resolveScrollForwarding } from '../../view/render-scroll-view.js';
|
|
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';
|
|
60
|
+
// The scroll views that could own a header, so a header can find its own by walking up. The tag is
|
|
61
|
+
// not on the node (`createElement` looks the behavior up and stores nothing), and the parent chain
|
|
62
|
+
// is the only route — a header may sit any depth below the content view.
|
|
63
|
+
const scrollOwners = new WeakSet();
|
|
64
|
+
const stickyOwners = new WeakMap();
|
|
65
|
+
const headerRuntimes = new WeakMap();
|
|
66
|
+
// ---------------------------------------------------------------- the owner half
|
|
67
|
+
export function markScrollOwner(node) {
|
|
68
|
+
scrollOwners.add(node);
|
|
69
|
+
}
|
|
70
|
+
export function hasStickyHeaders(owner) {
|
|
71
|
+
const sticky = stickyOwners.get(owner);
|
|
72
|
+
return sticky !== undefined && sticky.members.size > 0;
|
|
73
|
+
}
|
|
74
|
+
// Only the INVERTED pin reads the viewport height (`computeStickyInterpolation` ignores it
|
|
75
|
+
// otherwise), which is exactly when RN wraps the scroll view's own onLayout — so the gate flag
|
|
76
|
+
// lands on the same ScrollViews the wrapper puts it on and on no others.
|
|
77
|
+
function needsViewportHeight(owner) {
|
|
78
|
+
return (hasStickyHeaders(owner) && propOf(owner, 'invertStickyHeaders') === true);
|
|
79
|
+
}
|
|
80
|
+
function ownerSticky(owner) {
|
|
81
|
+
const existing = stickyOwners.get(owner);
|
|
82
|
+
if (existing !== undefined)
|
|
83
|
+
return existing;
|
|
84
|
+
const created = {
|
|
85
|
+
scrollValue: new AnimatedValue(0),
|
|
86
|
+
members: new Set(),
|
|
87
|
+
ordered: undefined,
|
|
88
|
+
layoutYs: new Map(),
|
|
89
|
+
viewportHeight: undefined,
|
|
90
|
+
writtenThrottle: undefined,
|
|
91
|
+
detachNativeScroll: undefined,
|
|
92
|
+
};
|
|
93
|
+
stickyOwners.set(owner, created);
|
|
94
|
+
return created;
|
|
95
|
+
}
|
|
96
|
+
// Put the scroll offset on the UI THREAD, which is what every wrapper's ScrollView already does
|
|
97
|
+
// (`useNativeStickyScrollAttach` -> `attachStickyScroll`) and what this runner was missing.
|
|
98
|
+
//
|
|
99
|
+
// Without it the offset only ever reaches `scrollValue` from `handleOwnerScroll`, i.e. once per
|
|
100
|
+
// delivered JS scroll event — so the pin moves at whatever rate the JS thread can be interrupted
|
|
101
|
+
// at. Device-reported 2026-09-08: during a flick the header was not painted at all and snapped
|
|
102
|
+
// into place only once the scroll stopped, which is the JS thread catching up.
|
|
103
|
+
//
|
|
104
|
+
// The interpolation listeners survive this. A tick is not the MOVEMENT — the AnimatedProps leaf
|
|
105
|
+
// owns that — it is the settled value the reducer debounces into the committed transform, RN's
|
|
106
|
+
// `passthroughAnimatedPropExplicitValues`, and a native value still streams to JS while a listener
|
|
107
|
+
// is registered (`AnimatedValue.__makeNative`).
|
|
108
|
+
function syncNativeScroll(owner, sticky) {
|
|
109
|
+
const wanted = sticky.members.size > 0 && isNativeAnimatedAvailable();
|
|
110
|
+
if (wanted === (sticky.detachNativeScroll !== undefined))
|
|
111
|
+
return;
|
|
112
|
+
if (!wanted) {
|
|
113
|
+
sticky.detachNativeScroll?.();
|
|
114
|
+
sticky.detachNativeScroll = undefined;
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
sticky.detachNativeScroll = attachStickyScroll(owner, sticky.scrollValue);
|
|
118
|
+
}
|
|
119
|
+
// Depth-first over the content subtree, which IS document order — the same order RN's children
|
|
120
|
+
// walk produces, arrived at from the tree instead of from an index array.
|
|
121
|
+
function collectHeaders(node, members, out) {
|
|
122
|
+
for (const child of childrenOf(node)) {
|
|
123
|
+
if (members.has(child))
|
|
124
|
+
out.push(child);
|
|
125
|
+
collectHeaders(child, members, out);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function orderedHeaders(owner, sticky) {
|
|
129
|
+
if (sticky.ordered !== undefined)
|
|
130
|
+
return sticky.ordered;
|
|
131
|
+
const out = [];
|
|
132
|
+
collectHeaders(owner.childHost ?? owner, sticky.members, out);
|
|
133
|
+
sticky.ordered = out;
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
// RN raises the scroll event rate for sticky headers so the offset actually reaches the
|
|
137
|
+
// AnimatedValue (`ScrollView.js:1798`). An app value always wins, which is why this reads
|
|
138
|
+
// `resolveScrollForwarding` rather than the constant — the 1/16 split lives there.
|
|
139
|
+
//
|
|
140
|
+
// This used to force `nativeStickyAvailable: false`, on the reasoning that a scroll value made
|
|
141
|
+
// native up front would cut the child listener cascade before a single tick arrived. It was wrong
|
|
142
|
+
// on both halves and it cost a visibly broken pin: no adapter forces that false — every wrapper
|
|
143
|
+
// calls `attachStickyScroll` — and a tick is not what MOVES the header anyway (`syncNativeScroll`).
|
|
144
|
+
// The attach happens after the header has registered, which is the same ordering React's
|
|
145
|
+
// `useEffect` gives it.
|
|
146
|
+
function syncThrottle(owner, sticky) {
|
|
147
|
+
const current = propOf(owner, 'scrollEventThrottle');
|
|
148
|
+
// Whatever stands in the key is the APP's unless it is byte-for-byte the value written here —
|
|
149
|
+
// which is what makes the take-back on the last unregister safe.
|
|
150
|
+
const ours = sticky.writtenThrottle !== undefined && current === sticky.writtenThrottle;
|
|
151
|
+
const appThrottle = !ours && typeof current === 'number' ? current : undefined;
|
|
152
|
+
const wanted = resolveScrollForwarding({
|
|
153
|
+
hasStickyHeaders: sticky.members.size > 0,
|
|
154
|
+
// Reads the same probe `syncNativeScroll` decides on, so the two cannot disagree: RN lowers
|
|
155
|
+
// the forced scroll rate once the offset is on the UI thread, since the JS event is then only
|
|
156
|
+
// the settled-value feed and no longer the animation itself.
|
|
157
|
+
nativeStickyAvailable: isNativeAnimatedAvailable(),
|
|
158
|
+
invertStickyHeaders: undefined,
|
|
159
|
+
scrollEventThrottle: appThrottle,
|
|
160
|
+
maintainVisibleContentPosition: undefined,
|
|
161
|
+
snapToAlignment: undefined,
|
|
162
|
+
}).scrollEventThrottle;
|
|
163
|
+
if (wanted === current)
|
|
164
|
+
return;
|
|
165
|
+
// `wanted` IS `appThrottle` whenever the app set one, so nothing is claimed in that case.
|
|
166
|
+
sticky.writtenThrottle = appThrottle === undefined ? wanted : undefined;
|
|
167
|
+
setProp(owner, 'scrollEventThrottle', wanted);
|
|
168
|
+
requestCommitFor(owner);
|
|
169
|
+
}
|
|
170
|
+
// The owner's own layout, wanted by an inverted sticky pin and by the app, and installed while
|
|
171
|
+
// EITHER wants it. Both halves route through here so neither can uninstall the other's.
|
|
172
|
+
export function syncOwnerLayout(owner) {
|
|
173
|
+
const wanted = needsViewportHeight(owner) || appListenerFor(owner, 'layout') !== undefined;
|
|
174
|
+
setBehaviorListener(owner, 'layout', wanted ? event => handleOwnerLayout(owner, event) : undefined);
|
|
175
|
+
}
|
|
176
|
+
function handleOwnerLayout(owner, event) {
|
|
177
|
+
const sticky = stickyOwners.get(owner);
|
|
178
|
+
const height = readLayoutNumber(event, 'height');
|
|
179
|
+
if (sticky !== undefined && height !== undefined) {
|
|
180
|
+
sticky.viewportHeight = height;
|
|
181
|
+
for (const header of sticky.members)
|
|
182
|
+
dispatch(header, { kind: 'inputs-changed' });
|
|
183
|
+
}
|
|
184
|
+
const app = appListenerFor(owner, 'layout');
|
|
185
|
+
if (typeof app === 'function')
|
|
186
|
+
app(event);
|
|
187
|
+
}
|
|
188
|
+
function readContentOffsetY(event) {
|
|
189
|
+
const native = event.nativeEvent;
|
|
190
|
+
if (typeof native !== 'object' || native === null)
|
|
191
|
+
return undefined;
|
|
192
|
+
const offset = Reflect.get(native, 'contentOffset');
|
|
193
|
+
if (typeof offset !== 'object' || offset === null)
|
|
194
|
+
return undefined;
|
|
195
|
+
const y = Reflect.get(offset, 'y');
|
|
196
|
+
return typeof y === 'number' ? y : undefined;
|
|
197
|
+
}
|
|
198
|
+
// The owner's scroll dispatcher: drive the shared value, then hand the app its event. Installed
|
|
199
|
+
// unconditionally, exactly as RN installs `_handleScroll` unconditionally (`ScrollView.js:1145`) —
|
|
200
|
+
// the app's own `onScroll` is an OWNED name and would otherwise evict this one from the single
|
|
201
|
+
// listener slot.
|
|
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);
|
|
207
|
+
const sticky = stickyOwners.get(owner);
|
|
208
|
+
// Skipped while the offset rides the UI thread: the native attach already drives the value every
|
|
209
|
+
// frame, so writing it again from a JS event is a redundant graph update at a WORSE rate.
|
|
210
|
+
if (sticky !== undefined &&
|
|
211
|
+
sticky.members.size > 0 &&
|
|
212
|
+
sticky.detachNativeScroll === undefined) {
|
|
213
|
+
const y = readContentOffsetY(event);
|
|
214
|
+
if (y !== undefined)
|
|
215
|
+
sticky.scrollValue.setValue(y);
|
|
216
|
+
}
|
|
217
|
+
const app = appListenerFor(owner, 'scroll');
|
|
218
|
+
if (typeof app === 'function')
|
|
219
|
+
app(event);
|
|
220
|
+
}
|
|
221
|
+
// The ScrollView's own teardown. Every header's runtime is released by its own `detach`, so the
|
|
222
|
+
// only thing owed here is the owner state — and cutting each header's back-reference with it, so a
|
|
223
|
+
// header still in flight cannot dispatch into a registry that is gone.
|
|
224
|
+
export function releaseStickyOwner(owner) {
|
|
225
|
+
ownersWithIndexWrappers.delete(owner);
|
|
226
|
+
const sticky = stickyOwners.get(owner);
|
|
227
|
+
if (sticky === undefined)
|
|
228
|
+
return;
|
|
229
|
+
sticky.detachNativeScroll?.();
|
|
230
|
+
sticky.detachNativeScroll = undefined;
|
|
231
|
+
for (const header of sticky.members) {
|
|
232
|
+
const runtime = headerRuntimes.get(header);
|
|
233
|
+
if (runtime !== undefined)
|
|
234
|
+
runtime.owner = undefined;
|
|
235
|
+
}
|
|
236
|
+
stickyOwners.delete(owner);
|
|
237
|
+
}
|
|
238
|
+
// ---------------------------------------------------------------- the index form
|
|
239
|
+
// `stickyHeaderIndices`, and it is deliberately NOT a second machine: a flagged
|
|
240
|
+
// child is MOVED into a synthesized `sticky-header` node, so ordering, cross-talk, the raised
|
|
241
|
+
// throttle, the pin and the teardown are the child form's, unchanged. Indices decide only WHICH
|
|
242
|
+
// children get one.
|
|
243
|
+
//
|
|
244
|
+
// TWO COSTS, both accepted rather than engineered away.
|
|
245
|
+
//
|
|
246
|
+
// `StickyHeaderComponent` — RN's prop naming a custom wrapper component — is NOT honoured: a
|
|
247
|
+
// behavior cannot instantiate a framework component, and Angular's automatic path already made
|
|
248
|
+
// that trade (`projection.ts`, `wrapRecord`). It IS honoured on React/Vue/Solid/Svelte's own
|
|
249
|
+
// component paths today, so this is a real narrowing for them; an app that needs one composes it
|
|
250
|
+
// explicitly around a `<sticky-header>` instead.
|
|
251
|
+
//
|
|
252
|
+
// THE WRAP LANDS ONE COMMIT LATE. `afterCommit` is the only hook that sees `owner.childHost.children`
|
|
253
|
+
// complete, and it runs past `completeRoot` — so a flagged child paints unwrapped for the frame it
|
|
254
|
+
// mounts in and pins from the next commit. Angular escapes this only through a synchronous flush at
|
|
255
|
+
// `RendererFactory2.end()`, a seam a behavior does not have. Pinned by `sticky-indices.test.ts` so
|
|
256
|
+
// it is learned from a green assertion rather than rediscovered on a device.
|
|
257
|
+
//
|
|
258
|
+
// UNSORTED INDICES RESOLVE BY DOCUMENT ORDER, which is what `orderedHeaders` already gives — and it
|
|
259
|
+
// is a choice, because the two existing runners disagree: React reads the next header out of the
|
|
260
|
+
// ARRAY (`ScrollView.js:1695`, `indexOf(index) + 1`), Angular out of the sorted list
|
|
261
|
+
// (`find(entry > index)`). Document order is the one that stays correct, because the value is a
|
|
262
|
+
// COLLISION POINT — the y of the header that pushes this one off — so it has to be the header BELOW
|
|
263
|
+
// on screen; `[2, 0]` under React's rule feeds header 2 the y of a header above it. It is also the
|
|
264
|
+
// only ordering under which the two forms can share a scroll view, since both produce members of
|
|
265
|
+
// one set ordered by where they sit.
|
|
266
|
+
// The nodes this module synthesized, so a later walk can tell its own wrapper from an app's child.
|
|
267
|
+
const indexWrappers = new WeakSet();
|
|
268
|
+
// Owners currently holding one. The gate: a ScrollView that never used the prop pays one WeakSet
|
|
269
|
+
// miss per commit and walks nothing.
|
|
270
|
+
const ownersWithIndexWrappers = new WeakSet();
|
|
271
|
+
function stickyIndexSet(value) {
|
|
272
|
+
if (!Array.isArray(value))
|
|
273
|
+
return undefined;
|
|
274
|
+
const out = new Set();
|
|
275
|
+
for (const entry of value)
|
|
276
|
+
if (typeof entry === 'number')
|
|
277
|
+
out.add(entry);
|
|
278
|
+
return out.size === 0 ? undefined : out;
|
|
279
|
+
}
|
|
280
|
+
function wrapForIndex(slot, child) {
|
|
281
|
+
const descriptor = descriptorFor(STICKY_HEADER_TAG);
|
|
282
|
+
const wrapper = createElement(descriptor.component, descriptor.isText, STICKY_HEADER_TAG);
|
|
283
|
+
indexWrappers.add(wrapper);
|
|
284
|
+
// The slot FIRST, then the child into it: the engine's appendChild detaches from the old parent,
|
|
285
|
+
// so the wrapper takes the position the child vacates and nothing has to be removed.
|
|
286
|
+
insertBefore(slot, wrapper, child);
|
|
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;
|
|
294
|
+
}
|
|
295
|
+
function unwrapIndex(slot, 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;
|
|
301
|
+
if (child !== undefined)
|
|
302
|
+
insertBefore(slot, child, wrapper);
|
|
303
|
+
removeChild(slot, wrapper);
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Bring the synthesized wrappers in line with `stickyHeaderIndices`. Called from the ScrollView
|
|
307
|
+
* behavior's `afterCommit`, the one beat at which the app's children are all present.
|
|
308
|
+
*
|
|
309
|
+
* O(slot children) per commit, once — never per mutation. Angular's controller coalesces to one
|
|
310
|
+
* pass per change detection for exactly this reason: its per-mutation walk was O(M²) and died at
|
|
311
|
+
* 801 children.
|
|
312
|
+
*/
|
|
313
|
+
export function reconcileStickyIndices(owner) {
|
|
314
|
+
const slot = owner.childHost;
|
|
315
|
+
if (slot === undefined)
|
|
316
|
+
return;
|
|
317
|
+
const wanted = stickyIndexSet(propOf(owner, 'stickyHeaderIndices'));
|
|
318
|
+
if (wanted === undefined && !ownersWithIndexWrappers.has(owner))
|
|
319
|
+
return;
|
|
320
|
+
let paintIndex = 0;
|
|
321
|
+
let wrapped = 0;
|
|
322
|
+
let changed = false;
|
|
323
|
+
// Snapshot: wrapping and unwrapping both splice the list being walked.
|
|
324
|
+
//
|
|
325
|
+
// A claimed `<RefreshControl>` needs no filter here, unlike Angular's walk — `hostFor` keeps a
|
|
326
|
+
// claimed child on the OWNER, so it never reaches the slot at all.
|
|
327
|
+
for (const child of [...childrenOf(slot)]) {
|
|
328
|
+
const wrapper = indexWrappers.has(child) ? child : undefined;
|
|
329
|
+
if (wrapper !== undefined) {
|
|
330
|
+
// The framework removes a child from the SLOT, because that is where it appended it — so the
|
|
331
|
+
// engine's `removeChild` finds nothing to splice and only clears `child.parent`, leaving a
|
|
332
|
+
// committed wrapper around a node nobody owns. This walk is the only thing that can see it.
|
|
333
|
+
const held = childrenOf(wrapper)[0];
|
|
334
|
+
if (held === undefined || parentOf(held) !== wrapper) {
|
|
335
|
+
removeChild(slot, wrapper);
|
|
336
|
+
changed = true;
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
else if (isAnchor(child)) {
|
|
341
|
+
// An anchor paints nothing, so RN's own children walk never numbered one. Without this every
|
|
342
|
+
// index below an anchor addresses the wrong child, and a windowed list inserts them freely.
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
const index = paintIndex;
|
|
346
|
+
paintIndex += 1;
|
|
347
|
+
// A `<sticky-header>` the app wrote is a child like any other and counts — it just must not be
|
|
348
|
+
// wrapped in a second one.
|
|
349
|
+
if (wrapper === undefined && headerRuntimes.has(child))
|
|
350
|
+
continue;
|
|
351
|
+
const shouldWrap = wanted !== undefined && wanted.has(index);
|
|
352
|
+
if (shouldWrap && wrapper === undefined) {
|
|
353
|
+
wrapForIndex(slot, child);
|
|
354
|
+
changed = true;
|
|
355
|
+
wrapped += 1;
|
|
356
|
+
}
|
|
357
|
+
else if (!shouldWrap && wrapper !== undefined) {
|
|
358
|
+
unwrapIndex(slot, wrapper);
|
|
359
|
+
changed = true;
|
|
360
|
+
}
|
|
361
|
+
else if (wrapper !== undefined)
|
|
362
|
+
wrapped += 1;
|
|
363
|
+
}
|
|
364
|
+
if (wrapped > 0)
|
|
365
|
+
ownersWithIndexWrappers.add(owner);
|
|
366
|
+
else
|
|
367
|
+
ownersWithIndexWrappers.delete(owner);
|
|
368
|
+
if (changed) {
|
|
369
|
+
dlog(`sticky indices reconciled (${wrapped} wrapped)`);
|
|
370
|
+
requestCommitFor(owner);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
// ---------------------------------------------------------------- the header half
|
|
374
|
+
function findScrollOwner(node) {
|
|
375
|
+
let current = parentOf(node);
|
|
376
|
+
while (current !== undefined) {
|
|
377
|
+
if (scrollOwners.has(current))
|
|
378
|
+
return current;
|
|
379
|
+
current = parentOf(current);
|
|
380
|
+
}
|
|
381
|
+
return undefined;
|
|
382
|
+
}
|
|
383
|
+
function nextHeaderY(runtime, node) {
|
|
384
|
+
const owner = runtime.owner;
|
|
385
|
+
if (owner === undefined)
|
|
386
|
+
return undefined;
|
|
387
|
+
const sticky = stickyOwners.get(owner);
|
|
388
|
+
if (sticky === undefined)
|
|
389
|
+
return undefined;
|
|
390
|
+
const order = orderedHeaders(owner, sticky);
|
|
391
|
+
const next = order[order.indexOf(node) + 1];
|
|
392
|
+
return next === undefined ? undefined : sticky.layoutYs.get(next);
|
|
393
|
+
}
|
|
394
|
+
// STICKY FOLD LEFT THIS FILE ON 2026-09-18, and it was the last `payloadFold` in the codebase.
|
|
395
|
+
//
|
|
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.
|
|
404
|
+
function dispatch(node, action) {
|
|
405
|
+
const runtime = headerRuntimes.get(node);
|
|
406
|
+
if (runtime === undefined || runtime.owner === undefined)
|
|
407
|
+
return;
|
|
408
|
+
const sticky = stickyOwners.get(runtime.owner);
|
|
409
|
+
const result = reduceSticky(runtime.state, action, {
|
|
410
|
+
os: Platform.OS,
|
|
411
|
+
inverted: propOf(runtime.owner, 'invertStickyHeaders') === true,
|
|
412
|
+
scrollViewHeight: sticky?.viewportHeight,
|
|
413
|
+
nextHeaderLayoutY: nextHeaderY(runtime, node),
|
|
414
|
+
});
|
|
415
|
+
runEffects(node, runtime, result.effects);
|
|
416
|
+
}
|
|
417
|
+
function runEffects(node, runtime, effects) {
|
|
418
|
+
for (const effect of effects) {
|
|
419
|
+
switch (effect.kind) {
|
|
420
|
+
case 'rebuild-interpolation':
|
|
421
|
+
rebuildInterpolation(node, runtime, effect.inputRange, effect.outputRange);
|
|
422
|
+
break;
|
|
423
|
+
case 'schedule-debounce':
|
|
424
|
+
if (runtime.debounceTimer !== undefined)
|
|
425
|
+
clearTimeout(runtime.debounceTimer);
|
|
426
|
+
runtime.debounceTimer = setTimeout(() => {
|
|
427
|
+
runtime.debounceTimer = undefined;
|
|
428
|
+
dispatch(node, { kind: 'debounce-fired', value: effect.value });
|
|
429
|
+
}, effect.delay);
|
|
430
|
+
break;
|
|
431
|
+
case 'apply-passthrough':
|
|
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.
|
|
439
|
+
//
|
|
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);
|
|
447
|
+
requestCommitFor(node);
|
|
448
|
+
break;
|
|
449
|
+
case 'record-header-y':
|
|
450
|
+
// Recorded from the layout handler instead: the reducer emits this only for a runner that
|
|
451
|
+
// owns a child INDEX, and the owner's document order is what replaces indices here.
|
|
452
|
+
break;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
function rebuildInterpolation(node, runtime, inputRange, outputRange) {
|
|
457
|
+
const owner = runtime.owner;
|
|
458
|
+
if (owner === undefined)
|
|
459
|
+
return;
|
|
460
|
+
const sticky = stickyOwners.get(owner);
|
|
461
|
+
if (sticky === undefined)
|
|
462
|
+
return;
|
|
463
|
+
if (runtime.interpolation !== undefined && runtime.listenerId !== undefined)
|
|
464
|
+
runtime.interpolation.removeListener(runtime.listenerId);
|
|
465
|
+
const next = sticky.scrollValue.interpolate({
|
|
466
|
+
inputRange: [...inputRange],
|
|
467
|
+
outputRange: [...outputRange],
|
|
468
|
+
});
|
|
469
|
+
runtime.listenerId = next.addListener(({ value }) => {
|
|
470
|
+
if (typeof value === 'number')
|
|
471
|
+
dispatch(node, { kind: 'animated-tick', value });
|
|
472
|
+
});
|
|
473
|
+
runtime.interpolation = next;
|
|
474
|
+
// A fresh leaf per rebuild, as every other runner does: `AnimatedProps` binds the props map it
|
|
475
|
+
// was constructed with, so a new interpolation node needs a new leaf. No `__makeNative()` — the
|
|
476
|
+
// leaf joins the graph under the scroll value and promotes when that value does.
|
|
477
|
+
const leaf = new AnimatedProps({
|
|
478
|
+
style: { transform: [{ translateY: next }], zIndex: STICKY_HEADER_Z_INDEX },
|
|
479
|
+
});
|
|
480
|
+
leaf.__attach();
|
|
481
|
+
runtime.leaf?.__detach();
|
|
482
|
+
runtime.leaf = leaf;
|
|
483
|
+
runtime.cancelBind?.();
|
|
484
|
+
runtime.cancelBind = whenCommitted(node, () => leaf.setNativeView(node));
|
|
485
|
+
}
|
|
486
|
+
function handleHeaderLayout(node, event) {
|
|
487
|
+
const runtime = headerRuntimes.get(node);
|
|
488
|
+
if (runtime === undefined)
|
|
489
|
+
return;
|
|
490
|
+
const y = readLayoutNumber(event, 'y');
|
|
491
|
+
const height = readLayoutNumber(event, 'height');
|
|
492
|
+
if (runtime.owner !== undefined && y !== undefined) {
|
|
493
|
+
const sticky = stickyOwners.get(runtime.owner);
|
|
494
|
+
if (sticky !== undefined && sticky.layoutYs.get(node) !== y) {
|
|
495
|
+
sticky.layoutYs.set(node, y);
|
|
496
|
+
// The cross-talk: this header's y is the PREVIOUS one's collision point, and nothing else
|
|
497
|
+
// tells that header its input moved.
|
|
498
|
+
const order = orderedHeaders(runtime.owner, sticky);
|
|
499
|
+
const previous = order[order.indexOf(node) - 1];
|
|
500
|
+
if (previous !== undefined)
|
|
501
|
+
dispatch(previous, { kind: 'inputs-changed' });
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
// Keep the previous value when a field is absent, as every runner does — RN sets state only on a
|
|
505
|
+
// defined read.
|
|
506
|
+
dispatch(node, {
|
|
507
|
+
kind: 'layout',
|
|
508
|
+
y: y ?? runtime.state.layoutY,
|
|
509
|
+
height: height ?? runtime.state.layoutHeight,
|
|
510
|
+
});
|
|
511
|
+
const app = appListenerFor(node, 'layout');
|
|
512
|
+
if (typeof app === 'function')
|
|
513
|
+
app(event);
|
|
514
|
+
}
|
|
515
|
+
function attach(node) {
|
|
516
|
+
const runtime = {
|
|
517
|
+
state: createInitialStickyState(),
|
|
518
|
+
owner: undefined,
|
|
519
|
+
interpolation: undefined,
|
|
520
|
+
listenerId: undefined,
|
|
521
|
+
debounceTimer: undefined,
|
|
522
|
+
leaf: undefined,
|
|
523
|
+
cancelBind: undefined,
|
|
524
|
+
};
|
|
525
|
+
headerRuntimes.set(node, runtime);
|
|
526
|
+
setBehaviorListener(node, 'layout', event => handleHeaderLayout(node, event));
|
|
527
|
+
}
|
|
528
|
+
// The registration waits for a committed tag rather than happening in `attach`, and both halves of
|
|
529
|
+
// that are load-bearing: at `attach` the node has no parent, so there is no owner to find, and the
|
|
530
|
+
// AnimatedProps leaf needs a tag to bind to.
|
|
531
|
+
function attachAfterCommit(node) {
|
|
532
|
+
const runtime = headerRuntimes.get(node);
|
|
533
|
+
if (runtime === undefined || runtime.owner !== undefined)
|
|
534
|
+
return;
|
|
535
|
+
const owner = findScrollOwner(node);
|
|
536
|
+
if (owner === undefined) {
|
|
537
|
+
dlog('sticky header committed outside a ScrollView — the pin is a no-op');
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
runtime.owner = owner;
|
|
541
|
+
const sticky = ownerSticky(owner);
|
|
542
|
+
sticky.members.add(node);
|
|
543
|
+
sticky.ordered = undefined;
|
|
544
|
+
syncThrottle(owner, sticky);
|
|
545
|
+
syncNativeScroll(owner, sticky);
|
|
546
|
+
syncOwnerLayout(owner);
|
|
547
|
+
dlog(`sticky header registered (${sticky.members.size} on this ScrollView)`);
|
|
548
|
+
dispatch(node, { kind: 'inputs-changed' });
|
|
549
|
+
}
|
|
550
|
+
function detach(node) {
|
|
551
|
+
const runtime = headerRuntimes.get(node);
|
|
552
|
+
if (runtime === undefined)
|
|
553
|
+
return;
|
|
554
|
+
runtime.cancelBind?.();
|
|
555
|
+
if (runtime.interpolation !== undefined && runtime.listenerId !== undefined)
|
|
556
|
+
runtime.interpolation.removeListener(runtime.listenerId);
|
|
557
|
+
if (runtime.debounceTimer !== undefined)
|
|
558
|
+
clearTimeout(runtime.debounceTimer);
|
|
559
|
+
runtime.leaf?.__detach();
|
|
560
|
+
const owner = runtime.owner;
|
|
561
|
+
headerRuntimes.delete(node);
|
|
562
|
+
if (owner === undefined)
|
|
563
|
+
return;
|
|
564
|
+
const sticky = stickyOwners.get(owner);
|
|
565
|
+
if (sticky === undefined)
|
|
566
|
+
return;
|
|
567
|
+
sticky.members.delete(node);
|
|
568
|
+
sticky.layoutYs.delete(node);
|
|
569
|
+
sticky.ordered = undefined;
|
|
570
|
+
syncThrottle(owner, sticky);
|
|
571
|
+
syncNativeScroll(owner, sticky);
|
|
572
|
+
syncOwnerLayout(owner);
|
|
573
|
+
}
|
|
574
|
+
export const stickyHeaderBehavior = {
|
|
575
|
+
// The app's own `onLayout` on a sticky header is forwarded, not replaced: the header's measured
|
|
576
|
+
// y is what the whole machine runs on, so the behavior cannot give the slot up.
|
|
577
|
+
ownedListeners: ['layout'],
|
|
578
|
+
attach,
|
|
579
|
+
attachAfterCommit,
|
|
580
|
+
detach,
|
|
581
|
+
};
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const SWITCH_TAG = "
|
|
1
|
+
export declare const SWITCH_TAG = "switch";
|
|
2
2
|
export declare function registerSwitchBehavior(): void;
|