@symbiote-native/components 3.1.0 → 3.1.2

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 (38) hide show
  1. package/README.md +17 -18
  2. package/build/behaviors/activity-indicator/shared.js +29 -71
  3. package/build/behaviors/button.d.ts +0 -9
  4. package/build/behaviors/button.js +48 -234
  5. package/build/behaviors/image-background.js +21 -81
  6. package/build/behaviors/image.js +3 -10
  7. package/build/behaviors/input-accessory-view.js +10 -51
  8. package/build/behaviors/pressable.d.ts +0 -48
  9. package/build/behaviors/pressable.js +59 -149
  10. package/build/behaviors/scroll-view/index.android.js +10 -30
  11. package/build/behaviors/scroll-view/shared.js +55 -186
  12. package/build/behaviors/scroll-view/sticky.d.ts +0 -8
  13. package/build/behaviors/scroll-view/sticky.js +54 -142
  14. package/build/behaviors/text-input.d.ts +0 -8
  15. package/build/behaviors/text-input.js +57 -156
  16. package/build/behaviors/touchable-highlight.js +14 -54
  17. package/build/behaviors/touchable-native-feedback.js +9 -32
  18. package/build/behaviors/touchable-opacity.js +3 -18
  19. package/build/behaviors/touchable-without-feedback.js +6 -24
  20. package/build/bootstrap/index.d.ts +1 -0
  21. package/build/bootstrap/index.js +2 -1
  22. package/build/component-names/index.android.js +6 -8
  23. package/build/component-names/shared.js +12 -42
  24. package/build/index.js +13 -19
  25. package/build/scroll-view-commands.js +9 -17
  26. package/build/state/pressable.js +18 -43
  27. package/build/state/sticky-header-reducer.js +103 -149
  28. package/build/state/touchable.js +3 -5
  29. package/build/state/virtualized-list-reducer.js +21 -48
  30. package/build/state/virtualized-list.js +63 -148
  31. package/build/text-props.js +3 -13
  32. package/build/view/render-button.js +13 -53
  33. package/build/view/render-input-accessory-view.js +8 -24
  34. package/build/view/render-pressable/index.js +3 -4
  35. package/build/view/render-scroll-view.js +13 -23
  36. package/build/view/render-touchable-native-feedback.js +5 -14
  37. package/host-primitives.cjs +33 -207
  38. package/package.json +3 -3
@@ -1,49 +1,19 @@
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.
1
+ // Sticky headers, both forms: the CHILD form (`<sticky-header>`) and the INDEX form
2
+ // (`stickyHeaderIndices`, RN's public API), plus the owner-side half feeding them.
3
+ // `<sticky-header>` marks an element the way an index list would in JSX-less code — reading the
4
+ // tag needs no index at all.
5
+ // The index form resolves via `afterCommit` seeing `owner.childHost.children` complete on every
6
+ // commit with a native call. `wrapForIndex` builds a WRAPPING node so the pin composes onto it
7
+ // instead of overwriting the child's own `transform` (`fabricProps.addStyle`: later entries win).
8
+ // DECISIONS are reduceSticky (../../state/sticky-header-reducer), shared with every adapter's own
9
+ // sticky component; this module is one more EFFECT RUNNER for it, running on an engine node with
10
+ // no framework above it.
11
+ // Cross-talk without indices: each header is fed the y of the NEXT sticky header, the collision
12
+ // point it gets pushed off at. The owner keeps its headers in DOCUMENT order, so the next one is
13
+ // just the next entry — no index, nothing to renumber when a list windows.
14
+ // THE OWNER HALF, why this file holds both: three of the scroll view's own props are functions of
15
+ // "does this ScrollView have sticky headers", which only a registration can answer — a separate
16
+ // module would have to export a registry back and forth.
47
17
  import { AnimatedProps, AnimatedValue, appendChild, appListenerFor, createElement, dlog, insertBefore, isAnchor, isNativeAnimatedAvailable, Platform, removeChild, requestCommitFor, setBehaviorListener, setProp, whenCommitted, propOf, childrenOf, parentOf, } from '@symbiote-native/engine';
48
18
  import { descriptorFor } from '../../component-names';
49
19
  import { attachStickyScroll } from '../../scroll-view-commands.js';
@@ -52,10 +22,8 @@ import { createInitialStickyState, reduceSticky, } from '../../state/sticky-head
52
22
  import { readLayoutNumber, STICKY_HEADER_Z_INDEX, } from '../../view/render-scroll-sticky.js';
53
23
  import { resolveScrollForwarding } from '../../view/render-scroll-view.js';
54
24
  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.
25
+ // The machine's one channel to its tag rule, and the only prop it ever writes. RN's twin is a
26
+ // whole style object; ours carries the one number that object ever holds.
59
27
  export const STICKY_TRANSLATE_PROP = 'stickyTranslateY';
60
28
  // The scroll views that could own a header, so a header can find its own by walking up. The tag is
61
29
  // not on the node (`createElement` looks the behavior up and stores nothing), and the parent chain
@@ -93,18 +61,12 @@ function ownerSticky(owner) {
93
61
  stickyOwners.set(owner, created);
94
62
  return created;
95
63
  }
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`).
64
+ // Put the scroll offset on the UI THREAD, what every wrapper's ScrollView already does. Without
65
+ // it the offset only reaches scrollValue once per delivered JS scroll event, so during a flick
66
+ // the header snaps into place only once the JS thread catches up, instead of tracking it live.
67
+ // The interpolation listeners survive this: a tick is not the MOVEMENT (the AnimatedProps leaf
68
+ // owns that), it's the settled value the reducer debounces, and a native value still streams to
69
+ // JS while a listener is registered.
108
70
  function syncNativeScroll(owner, sticky) {
109
71
  const wanted = sticky.members.size > 0 && isNativeAnimatedAvailable();
110
72
  if (wanted === (sticky.detachNativeScroll !== undefined))
@@ -134,15 +96,8 @@ function orderedHeaders(owner, sticky) {
134
96
  return out;
135
97
  }
136
98
  // 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.
99
+ // AnimatedValue. An app value always wins, which is why this reads resolveScrollForwarding rather
100
+ // than a constant.
146
101
  function syncThrottle(owner, sticky) {
147
102
  const current = propOf(owner, 'scrollEventThrottle');
148
103
  // Whatever stands in the key is the APP's unless it is byte-for-byte the value written here —
@@ -196,9 +151,8 @@ function readContentOffsetY(event) {
196
151
  return typeof y === 'number' ? y : undefined;
197
152
  }
198
153
  // 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.
154
+ // unconditionally — the app's own `onScroll` is an OWNED name and would otherwise evict this one
155
+ // from the single listener slot.
202
156
  export function handleOwnerScroll(owner, event) {
203
157
  // RN's `_handleScroll` (`ScrollView.js:1145-1147`) sets this unconditionally too — nothing reads
204
158
  // it unless this node actually holds the responder, so an unconditional set on every scroll is
@@ -236,33 +190,16 @@ export function releaseStickyOwner(owner) {
236
190
  stickyOwners.delete(owner);
237
191
  }
238
192
  // ---------------------------------------------------------------- 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.
193
+ // `stickyHeaderIndices` is deliberately NOT a second machine: a flagged child is MOVED into a
194
+ // synthesized `sticky-header` node, so ordering, cross-talk, throttle, pin and teardown are all
195
+ // the child form's, unchanged. Indices decide only WHICH children get one.
196
+ // `StickyHeaderComponent` (RN's custom wrapper prop) is NOT honoured — a behavior can't instantiate
197
+ // a framework component; an app that needs one composes it explicitly around `<sticky-header>`.
198
+ // The wrap lands one commit late: afterCommit is the only hook seeing childHost.children complete,
199
+ // and it runs past completeRoot, so a flagged child paints unwrapped for one frame.
200
+ // Unsorted indices resolve by DOCUMENT ORDER, which orderedHeaders already gives: the value is a
201
+ // COLLISION POINT (the y of the header that pushes this one off), so it must be the header BELOW
202
+ // on screen — document order is also the only one under which both forms can share a scroll view.
266
203
  // The nodes this module synthesized, so a later walk can tell its own wrapper from an app's child.
267
204
  const indexWrappers = new WeakSet();
268
205
  // Owners currently holding one. The gate: a ScrollView that never used the prop pays one WeakSet
@@ -285,11 +222,9 @@ function wrapForIndex(slot, child) {
285
222
  // so the wrapper takes the position the child vacates and nothing has to be removed.
286
223
  insertBefore(slot, wrapper, child);
287
224
  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.
225
+ // AFTER both, or the anchor above would resolve to the wrapper itself: the framework keeps
226
+ // naming the row, while the tree holds the wrapper in its place, so a later removeChild(owner,
227
+ // row) takes the wrapper out with it.
293
228
  child.wrapper = wrapper;
294
229
  }
295
230
  function unwrapIndex(slot, wrapper) {
@@ -302,14 +237,9 @@ function unwrapIndex(slot, wrapper) {
302
237
  insertBefore(slot, child, wrapper);
303
238
  removeChild(slot, wrapper);
304
239
  }
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
- */
240
+ // Bring the synthesized wrappers in line with `stickyHeaderIndices`. Called from afterCommit, the
241
+ // one beat where the app's children are all present. O(slot children) per commit, once, never
242
+ // per mutation.
313
243
  export function reconcileStickyIndices(owner) {
314
244
  const slot = owner.childHost;
315
245
  if (slot === undefined)
@@ -320,10 +250,8 @@ export function reconcileStickyIndices(owner) {
320
250
  let paintIndex = 0;
321
251
  let wrapped = 0;
322
252
  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.
253
+ // Snapshot: wrapping and unwrapping both splice the list being walked. A claimed
254
+ // `<RefreshControl>` needs no filter here — hostFor keeps it on the OWNER, never the slot.
327
255
  for (const child of [...childrenOf(slot)]) {
328
256
  const wrapper = indexWrappers.has(child) ? child : undefined;
329
257
  if (wrapper !== undefined) {
@@ -391,16 +319,9 @@ function nextHeaderY(runtime, node) {
391
319
  const next = order[order.indexOf(node) + 1];
392
320
  return next === undefined ? undefined : sticky.layoutYs.get(next);
393
321
  }
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.
322
+ // foldStickyHeaderProps in C++ owns the wrapper's constants; the debounced translate crosses as
323
+ // an ordinary prop. No JS twin — a payload rule asserted against a second copy of itself proves
324
+ // nothing, so sticky-header-payload.itest.ts is the contract.
404
325
  function dispatch(node, action) {
405
326
  const runtime = headerRuntimes.get(node);
406
327
  if (runtime === undefined || runtime.owner === undefined)
@@ -429,20 +350,11 @@ function runEffects(node, runtime, effects) {
429
350
  }, effect.delay);
430
351
  break;
431
352
  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.
353
+ // The machine's one channel to its tag rule: a prop write is what the C++ rule can read.
354
+ // The write marks the node, so only the commit request is still owed.
355
+ // Not witnessed by a headless test: while the pin is JS-driven the animated leaf's own
356
+ // setNativeProps already wrote the same transform. It's the NATIVE-driver path this exists
357
+ // for, where the committed transform is all hit-testing has.
446
358
  setProp(node, STICKY_TRANSLATE_PROP, effect.translateY);
447
359
  requestCommitFor(node);
448
360
  break;
@@ -2,13 +2,5 @@ import { type ISymbioteNode } from '@symbiote-native/engine';
2
2
  import { type ITextInputHandle } from '../state/text-input';
3
3
  export declare const TEXT_INPUT_TAG = "text-input";
4
4
  export declare const TEXT_INPUT_MULTILINE_TAG = "text-input-multiline";
5
- /**
6
- * The imperative API RN exposes on a TextInput ref, built over the engine node. Reached through
7
- * each adapter's own `host-instance` accessor — the capability, not a shape
8
- * (`.claude/rules/adapter-parity-audit.md`).
9
- *
10
- * `focus`/`blur` are native view commands; `clear` and `setSelection` reuse `setTextAndSelection`,
11
- * the same stale-safe path a controlled write takes, so they cannot race a keystroke either.
12
- */
13
5
  export declare function buildTextInputHandle(node: ISymbioteNode): ITextInputHandle;
14
6
  export declare function registerTextInputBehavior(): void;