@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.
Files changed (79) hide show
  1. package/README.md +8 -9
  2. package/build/accessibility-props.d.ts +1 -1
  3. package/build/accessibility-props.js +2 -2
  4. package/build/behaviors/activity-indicator/shared.js +41 -70
  5. package/build/behaviors/button.d.ts +11 -0
  6. package/build/behaviors/button.js +101 -89
  7. package/build/behaviors/image-background.d.ts +1 -0
  8. package/build/behaviors/image-background.js +97 -81
  9. package/build/behaviors/image.d.ts +0 -1
  10. package/build/behaviors/image.js +24 -105
  11. package/build/behaviors/input-accessory-view.d.ts +0 -1
  12. package/build/behaviors/input-accessory-view.js +47 -53
  13. package/build/behaviors/pressable.d.ts +1 -1
  14. package/build/behaviors/pressable.js +89 -100
  15. package/build/behaviors/refresh-control.js +15 -2
  16. package/build/behaviors/scroll-view/index.android.js +25 -37
  17. package/build/behaviors/scroll-view/index.d.ts +1 -0
  18. package/build/behaviors/scroll-view/index.js +3 -0
  19. package/build/behaviors/scroll-view/responder.d.ts +4 -0
  20. package/build/behaviors/scroll-view/responder.js +202 -0
  21. package/build/behaviors/scroll-view/shared.d.ts +1 -3
  22. package/build/behaviors/scroll-view/shared.js +95 -95
  23. package/build/behaviors/scroll-view/sticky.d.ts +1 -0
  24. package/build/behaviors/scroll-view/sticky.js +62 -49
  25. package/build/behaviors/switch.js +43 -86
  26. package/build/behaviors/text-input.js +219 -107
  27. package/build/behaviors/touchable-highlight.js +74 -61
  28. package/build/behaviors/touchable-native-feedback.js +43 -122
  29. package/build/behaviors/touchable-opacity.js +71 -59
  30. package/build/behaviors/touchable-without-feedback.js +35 -100
  31. package/build/component-names/index.android.js +7 -11
  32. package/build/component-names/index.ios.js +0 -7
  33. package/build/component-names/shared.d.ts +1 -1
  34. package/build/index.d.ts +15 -21
  35. package/build/index.js +21 -28
  36. package/build/resolve-intrinsic.js +3 -9
  37. package/build/scroll-view-commands.d.ts +1 -9
  38. package/build/scroll-view-commands.js +13 -74
  39. package/build/state/flat-list.d.ts +2 -2
  40. package/build/state/flat-list.js +10 -2
  41. package/build/state/pressable.d.ts +6 -1
  42. package/build/state/pressable.js +63 -28
  43. package/build/state/section-list.d.ts +2 -0
  44. package/build/state/section-list.js +14 -7
  45. package/build/state/text-input.d.ts +7 -40
  46. package/build/state/text-input.js +17 -186
  47. package/build/state/touchable.d.ts +1 -0
  48. package/build/state/touchable.js +11 -8
  49. package/build/state/virtualized-list-reducer.d.ts +2 -2
  50. package/build/state/virtualized-list.d.ts +6 -6
  51. package/build/state/virtualized-list.js +71 -37
  52. package/build/text-props.d.ts +0 -8
  53. package/build/text-props.js +14 -25
  54. package/build/view/render-button.d.ts +1 -29
  55. package/build/view/render-button.js +44 -81
  56. package/build/view/render-image/index.d.ts +14 -1
  57. package/build/view/render-image/index.js +22 -147
  58. package/build/view/render-input-accessory-view.d.ts +1 -5
  59. package/build/view/render-input-accessory-view.js +26 -48
  60. package/build/view/render-keyboard-avoiding-view.d.ts +7 -1
  61. package/build/view/render-keyboard-avoiding-view.js +40 -1
  62. package/build/view/render-modal.d.ts +1 -1
  63. package/build/view/render-modal.js +15 -5
  64. package/build/view/render-pressable/index.d.ts +1 -0
  65. package/build/view/render-pressable/index.js +4 -0
  66. package/build/view/render-scroll-view.d.ts +0 -4
  67. package/build/view/render-scroll-view.js +3 -49
  68. package/build/view/render-switch.d.ts +0 -14
  69. package/build/view/render-switch.js +4 -42
  70. package/build/view/render-touchable-highlight.d.ts +1 -0
  71. package/build/view/render-touchable-native-feedback.d.ts +0 -1
  72. package/build/view/render-touchable-native-feedback.js +15 -9
  73. package/host-primitives.cjs +49 -203
  74. package/host-primitives.d.cts +0 -1
  75. package/package.json +3 -7
  76. package/build/fold-host-bag.d.ts +0 -15
  77. package/build/fold-host-bag.js +0 -99
  78. package/build/view/render-text-input.d.ts +0 -11
  79. 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 host-primitive lowering exists to delete
23
- // (`.claude/rules/host-primitive-tier.md`). `foldPayload` gave a lowered primitive its wrapper's
24
- // PROP MAPPING; nothing gave it the wrapper's COMPOSITION, so a composed primitive could not be
25
- // lowered at all no matter what its props did. This is that half.
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 IS NOT THE ANSWER, and this reverses what this header said until 2026-09-07. The
43
- // `text-input` / `text-input-managed` split is debt with a deletion date, not a technique
44
- // (`.claude/rules/fold-only-primitive-recipe.md` §4): each pair exists only to keep two owners
45
- // apart while a wrapper and a lowered element both emit a tag, and minting one here would buy a
46
- // rename across every call site now and a second rename when the wrapper dies. The owner's decision
47
- // is that the ENGINE becomes the single owner of the content node — every adapter's list and
48
- // wrapper stops building one — so registration waits on that cut rather than on a new spelling.
49
- // That cut LANDED 2026-09-11: no adapter's wrapper or list builds a content node any more, and all
50
- // five register this behavior through `@symbiote-native/components/register`.
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, preservesContentChildren, readLayoutDimension, resolveDecelerationRate, SCROLL_VIEW_BASE_HORIZONTAL, SCROLL_VIEW_BASE_VERTICAL, } from '../../view/render-scroll-view.js';
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 lowered element has no wrapper to write for it. The resolution has to happen here
92
- // because 'normal'/'fast' reach Fabric as strings it cannot read.
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, which is what the lowered path replaces.
104
- export function ownerFold(base, horizontal) {
105
- return props => {
106
- const next = {
107
- ...props,
108
- style: [base, props.style],
109
- nestedScrollEnabled: props.nestedScrollEnabled ?? true,
110
- };
111
- // The tag is the ONLY axis input, which is what keeps the three halves of the axis from
112
- // disagreeing. RN derives all three from one prop, so a mismatch is unrepresentable there, and
113
- // on iOS both tags really are RCTScrollView — a stray `horizontal` would turn a vertical
114
- // scroller over a content node with no row style, a shape RN cannot produce.
115
- if (props.horizontal !== undefined && props.horizontal !== horizontal) {
116
- dlog(`ScrollView: horizontal=${String(props.horizontal)} ignored — the axis comes from the ` +
117
- `tag; write <${horizontal ? SCROLL_VIEW_TAG : HORIZONTAL_SCROLL_VIEW_TAG}> instead`);
118
- }
119
- delete next.horizontal;
120
- if (horizontal)
121
- next.horizontal = true;
122
- // The bounce pair is the axis's other consequence (`ScrollView.js:1753-1761`), ASYMMETRIC
123
- // because RN falls back to `this.props.horizontal` — unset on a vertical view, so the
124
- // horizontal key resolves to undefined and never reaches the payload. Both names are declared
125
- // in every adapter's prop type and computed in none: vertical never bounced by default.
126
- if (props.alwaysBounceHorizontal === undefined && horizontal)
127
- next.alwaysBounceHorizontal = true;
128
- if (props.alwaysBounceVertical === undefined)
129
- next.alwaysBounceVertical = !horizontal;
130
- // Consumed by the behavior and declared by no ViewConfig — neither name appears anywhere under
131
- // `ReactCommon/react/renderer/components/scrollview`. `stickyHeaderIndices` decides which
132
- // children get a `sticky-header`, `invertStickyHeaders` feeds the pin. Every wrapper strips both
133
- // (Vue's `HANDLED_ATTRS` is the reference list), and a key Fabric does not know throws nothing,
134
- // logs nothing and paints nothing — so the strip has to be here or it is never noticed.
135
- delete next.stickyHeaderIndices;
136
- delete next.invertStickyHeaders;
137
- const rate = props.decelerationRate;
138
- if (rate === 'normal' || rate === 'fast' || typeof rate === 'number')
139
- next.decelerationRate = resolveDecelerationRate(rate);
140
- return next;
141
- };
142
- }
143
- // The SLOT's fold. Two halves with different sources, which is why it takes the owner:
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
- // (the wrapper writes `[contentContainerStyle, {flexDirection:'row'}]`)
147
- // collapsableChildren DERIVED from props that stay on the OWNER, so it is read back off it
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
- // Written only when false, matching every wrapper — RN sends `collapsableChildren={!preserveChildren}`
150
- // and therefore an explicit `true`, which is the native default anyway.
151
- function contentFold(owner, rowStyle) {
152
- return props => {
153
- const preserve = preservesContentChildren(owner.props.maintainVisibleContentPosition, owner.props.snapToAlignment);
154
- // The identity return IPayloadFold's contract asks for: a vertical content view with neither
155
- // prop set has nothing to add, which is the common case.
156
- if (rowStyle === undefined && !preserve)
157
- return props;
158
- const next = { ...props };
159
- if (rowStyle !== undefined)
160
- next.style = [props.style, rowStyle];
161
- if (preserve)
162
- next.collapsableChildren = false;
163
- return next;
164
- };
165
- }
166
- function buildContent(contentIntrinsic, rowStyle) {
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
- // The wrapper sets it on every content node, both axes (react's `contentProps`). Yoga may
171
- // collapse a view that only groups children, and a collapsed content node takes the scroll
172
- // metrics with it.
173
- content.props = { collapsable: false };
174
- content.payloadFold = contentFold(node, rowStyle);
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 lowered element has no inner node of its own, so
190
- // the behavior installs it on the slot it built.
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 lowered ScrollView's content node and buy a native event nobody
217
- // reads. A lowering that changes the committed surface in EITHER direction is a bug, so the wiring
218
- // has to follow the prop.
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, base, rowStyle, platform) {
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: ['contentSizeChange', 'scroll', 'layout'],
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
- onWrapChange: platform.onWrapChange?.(base, horizontal),
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', SCROLL_VIEW_BASE_VERTICAL, undefined, platform));
286
- registerHostBehavior(HORIZONTAL_SCROLL_VIEW_TAG, scrollBehavior('horizontal-scroll-content', SCROLL_VIEW_BASE_HORIZONTAL, { flexDirection: 'row' }, 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
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 on the lowered path. BOTH forms live here — the CHILD form (`<sticky-header>`, the
2
- // path our own lists use) and the INDEX form (`stickyHeaderIndices`, RN's public API) — plus the
3
- // owner-side half that feeds them.
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. `<StickyHeader>` says the
7
- // same thing in the one place a lowered element can read without an index: the tag of a node that
8
- // is already in the tree.
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, markPropsDirty, Platform, removeChild, requestCommitFor, setBehaviorListener, setProp, whenCommitted, } from '@symbiote-native/engine';
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.props.invertStickyHeaders === true;
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.children) {
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.props.scrollEventThrottle;
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` on the lowered path, and it is deliberately NOT a second machine: a flagged
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.children[0];
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.props.stickyHeaderIndices);
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.children]) {
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
- if (wrapper.children[0]?.parent !== wrapper) {
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.parent;
375
+ let current = parentOf(node);
355
376
  while (current !== undefined) {
356
377
  if (scrollOwners.has(current))
357
378
  return current;
358
- current = current.parent;
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
- // The committed half of the pin: the debounced translateY RN pushes into the transform for
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
- // A per-node fold, assigned in `attach`, because what it reads is per-node runtime state rather
378
- // than a prop — the behavior-level `foldPayload` gets props and nothing else.
379
- function stickyFold(runtime) {
380
- return props => {
381
- const pin = { zIndex: STICKY_HEADER_Z_INDEX };
382
- if (runtime.state.translateY !== null)
383
- pin.transform = [{ translateY: runtime.state.translateY }];
384
- return {
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.props.invertStickyHeaders === true,
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 fold reads `runtime.state`, which no prop write touched, so nothing has marked the
424
- // node — and dirtying is not publishing, hence both calls.
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
- // NOT witnessed by a headless test, and the reason is worth knowing before deleting it:
427
- // while the pin is JS-driven the animated leaf's own `setNativeProps` has already written
428
- // the same transform and marked the node, so removing this line reddens nothing here. It
429
- // is the NATIVE-driver path this exists for — there the leaf stops writing JS-side and the
430
- // committed transform is all hit-testing has, which is exactly why RN keeps
431
- // `passthroughAnimatedPropExplicitValues` beside the animated one.
432
- markPropsDirty(node);
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