@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
package/README.md CHANGED
@@ -20,17 +20,19 @@ supplies only the third:
20
20
 
21
21
  1. **Logic — `src/state/*.ts`.** A pure reducer `(state, action) => state`, a
22
22
  `createInitial*State` factory, and pure predicates. Zero framework, zero render —
23
- `switchReducer` / `shouldSnapBack` / `valueFromChange` for `Switch`, `modalReducer` for `Modal`,
24
- the `virtualized-list` windowing math for the list family.
23
+ `modalReducer` for `Modal`, `shouldSnapBack` / `valueFromChange` for `Pressable`'s press
24
+ machine, the `virtualized-list` windowing math for the list family.
25
25
  2. **View — `src/view/render-*.ts`.** A pure function `render*(viewState, platform) => Descriptor`.
26
26
  State and visuals enter **only through arguments**; out comes a tree of `Descriptor` nodes
27
27
  (`{ type, props, children, key }`, built with `el()` / `txt()`) over the intrinsic primitives
28
- (`symbiote-view`, `symbiote-switch`, …). No framework, no state, no events.
28
+ (`symbiote-view`, `symbiote-scroll-view`, …). No framework, no state, no events.
29
29
  3. **Lifecycle — the adapter.** React wires the reducer through `useReducer`/`useLayoutEffect` and
30
30
  bridges the `Descriptor` to `React.createElement`; Vue wires it through `ref`/`watch` and
31
31
  bridges to `h()`. This is the ONLY part a new adapter has to write.
32
32
 
33
- `Switch` is the canonical reference for a full three-layer component.
33
+ `Modal` is the canonical reference for a full three-layer component. `Switch` used to be — its
34
+ `render*` function is gone now that the painting moved to the `switch` tag's own host behavior
35
+ (see "Host behaviors" below), so only its reducer and prop type still live at this layer.
34
36
 
35
37
  ### Install
36
38
 
@@ -45,28 +47,25 @@ npm install @symbiote-native/components
45
47
  ## Usage
46
48
 
47
49
  Nearly every consumer reaches this package **through an adapter**, not directly — an app imports
48
- `Switch` from `@symbiote-native/react` (or `@symbiote-native/vue`), and that adapter re-exports the prop
50
+ `Modal` from `@symbiote-native/react` (or `@symbiote-native/vue`), and that adapter re-exports the prop
49
51
  types and wires the reducer/render pair from here. Calling the render function directly is what an
50
52
  adapter itself does, to build its lifecycle wrapper:
51
53
 
52
54
  ```ts
53
55
  import {
54
- renderSwitch,
55
- createInitialSwitchState,
56
- switchReducer,
56
+ renderModal,
57
+ createInitialModalState,
58
+ modalReducer,
57
59
  } from '@symbiote-native/components';
58
60
 
59
61
  // inside an adapter's own hook/composable:
60
- const state = createInitialSwitchState();
61
- const next = switchReducer(state, { type: 'native-reported', value: true });
62
- const descriptor = renderSwitch(
63
- { value: true, disabled: false, passthrough: { onChange, ref } },
64
- {
65
- trackColorProps: (value, trackColor) => ({
66
- /* platform-specific prop names */
67
- }),
68
- },
69
- );
62
+ const state = createInitialModalState(visible);
63
+ const next = modalReducer(state, { type: visible ? 'show' : 'hide' });
64
+ const descriptor = renderModal({
65
+ visible,
66
+ transparent: false,
67
+ passthrough: { onDismiss, onRequestClose, ref },
68
+ });
70
69
  // descriptor is then handed to the adapter's own descriptorToReact / descriptorToVue bridge
71
70
  ```
72
71
 
@@ -1,42 +1,25 @@
1
- // ActivityIndicator's host behavior: the composition and the prop fold, below the framework, so the
2
- // primitive is a bare `activity-indicator` tag and not five wrapper components.
3
- //
4
- // THE TWO-NODE SHAPE IS RN'S, not ours to collapse. `ActivityIndicator.js:112` opens a centering
5
- // `<View>` around the native spinner, so the tag is that View and `buildStructure` builds
6
- // `activity-indicator-spinner` under it. The container cannot be folded INTO the spinner either: it
7
- // carries `alignItems`/`justifyContent`, which centre the spinner inside the space it was given,
8
- // and moved onto the spinner they would centre its children, of which it has none.
9
- //
10
- // THE PLATFORM HALF IS THE SPINNER'S DEFAULTS AND NOTHING ELSE. iOS defaults the colour to RN's GRAY
11
- // and needs no extra native props; Android's default is the theme, which means OMITTING the key
12
- // rather than sending null (Fabric's colour parser rejects a null), plus `styleAttr` and
13
- // `indeterminate` — without the first, AndroidProgressBar throws "setStyle() not called".
14
- // `index.ios` / `index.android` supply them, the same file split `behaviors/scroll-view` uses.
15
- //
16
- // WHERE THE APP'S PROPS GO. `slotPropsExcept` is the COMPLEMENT of a rename map: everything an app
17
- // writes on the tag routes to the spinner under its own name except `ACTIVITY_INDICATOR_HOST_PROPS`,
18
- // which is RN's own split (`ActivityIndicator.js:99` spreads `...restProps` onto the spinner; `:113`
19
- // keeps `onLayout` and `style` on the View). The set that moves is OPEN — every aria alias, every
20
- // accessibility prop, whatever an app writes next — so a name map cannot express it.
21
- //
22
- // THE SIZE TRANSLATION IS PLATFORM-INVARIANT and lives here beside the fold that applies it: RN maps
23
- // 'small'/'large' to a native size enum AND a fixed box style, while a NUMBER never reaches native
24
- // at all (it sizes the spinner through style alone).
25
- //
26
- // Registered by all five adapters since 2026-09-09, in the same commit that deleted the five
27
- // wrappers — the registry is keyed by TAG, so registering while a wrapper still painted its own
28
- // spinner would have given every indicator two.
1
+ // ActivityIndicator's host behavior: the composition and the prop fold, below the framework, so
2
+ // the primitive is a bare `activity-indicator` tag and not five wrapper components.
3
+ // The two-node shape is RN's, not ours to collapse: `ActivityIndicator.js:112` opens a centering
4
+ // View around the native spinner. The container can't fold into the spinner either — it carries
5
+ // `alignItems`/`justifyContent`, which would centre the spinner's own (nonexistent) children.
6
+ // The platform half is only the spinner's defaults: iOS defaults colour to GRAY with no extra
7
+ // props; Android's default is the theme (omit the key, Fabric rejects null) plus `styleAttr`/
8
+ // `indeterminate` (AndroidProgressBar throws without the first). Split across index.ios/.android.
9
+ // `slotPropsExcept` is the COMPLEMENT of a rename map: everything an app writes routes to the
10
+ // spinner except `ACTIVITY_INDICATOR_HOST_PROPS` (RN's own split, `ActivityIndicator.js:99,113`).
11
+ // The moved set is OPEN — every accessibility prop an app writes — a name map can't express it.
12
+ // The size translation is platform-invariant: RN maps 'small'/'large' to a native size enum plus
13
+ // a fixed box style, while a number sizes the spinner through style alone.
29
14
  import { appendChild, createElement, registerHostBehavior, setProp, } from '@symbiote-native/engine';
30
15
  import { descriptorFor } from '../../component-names';
31
16
  export const ACTIVITY_INDICATOR_TAG = 'activity-indicator';
32
17
  // The NATIVE spinner — `ActivityIndicatorView` on iOS, `AndroidProgressBar` on Android. Built by
33
18
  // `buildStructure` below and by nothing else; no app writes it.
34
19
  export const ACTIVITY_INDICATOR_SPINNER_TAG = 'activity-indicator-spinner';
35
- // The two size boxes, the default size and the centering style are NOT here any more: they are
36
- // literals inside `foldActivityIndicatorProps` / `foldActivityIndicatorSpinnerProps`
37
- // (`SymbioteFabricProps.cpp`). Keeping a JS copy of a value only C++ reads is the mirror this port
38
- // exists to remove — it would compile, export and test cleanly while nothing on a device consulted
39
- // it.
20
+ // The size boxes, default size, and centering style live only in `foldActivityIndicatorProps`/
21
+ // `foldActivityIndicatorSpinnerProps` (SymbioteFabricProps.cpp) — no JS copy of a value only C++
22
+ // reads.
40
23
  // The props that stay on the centering host instead of travelling to the spinner: RN's own two
41
24
  // (`ActivityIndicator.js:113`) plus the spellings the ENGINE resolves against a node's own style.
42
25
  const ACTIVITY_INDICATOR_HOST_PROPS = [
@@ -44,33 +27,19 @@ const ACTIVITY_INDICATOR_HOST_PROPS = [
44
27
  'onLayout',
45
28
  // The composed `StyleSheet.compose(styles.container, style)` array (`:114`).
46
29
  'style',
47
- // A class NAME resolves to a style (`routeProp`'s class branch), so a class written on the tag has
48
- // to reach the centering view — the node `style` lands on — or the app's rule paints a spinner it
49
- // was never written for.
30
+ // A class resolves to a style (`routeProp`'s class branch), so it must reach the centering view
31
+ // — the node `style` lands on — or the app's rule paints a spinner it was never written for.
50
32
  'class',
51
33
  'className',
52
34
  ];
53
- // `activeStyle` is deliberately NOT here. It looked like it belonged — it is slot 1 of the same
54
- // `pushClassStyle` merge `style` and `class` feed — but this primitive has no pressed state, so
55
- // `routeProp` consumes the key on whichever node it lands on and it never reaches Fabric either
56
- // way. An entry no test can make fail is an entry that was never wired in.
57
- // BOTH FOLDS LEFT THIS FILE on 2026-09-18 and neither was replaced by anything here: they are
58
- // `foldActivityIndicatorProps` and `foldActivityIndicatorSpinnerProps` in `SymbioteFabricProps.cpp`.
59
- // Every input either read was the node's own bag — no owner, no listener, no live state — which is
60
- // what made them tag rules rather than composition, and it is why both nodes now cost ZERO trips
61
- // into JS instead of one each. Contract:
62
- // `core/engine/cpp/tests/js/activity-indicator-payload.itest.ts`.
63
- //
64
- // The size constants, the container style and the default colour went WITH them rather than staying
65
- // as a second copy for the tests to assert. `platform.defaultColor` survives as the last field of
66
- // `IActivityIndicatorPlatform` only because the rule's Android half is chosen by COMPONENT NAME in
67
- // C++, so the JS value would have no reader — see the type's own note.
68
- // The composition. Returns the spinner as the slot because the prop redirect is gated on
69
- // `childHost` being set — the redirect is what this slot is FOR, and NOT where children go: RN's
70
- // ActivityIndicator renders only the spinner (`ActivityIndicator.js:112-118`) and takes no children
71
- // at all. Hence `slotTakesNoChildren` below; without it a stray child would mount INSIDE the native
72
- // spinner, which on Android is a `ProgressBar` and not a `ViewGroup` — the `addView` crash
73
- // `IHostBehavior.slotTakesNoChildren` records for ImageBackground's Image.
35
+ // `activeStyle` is deliberately NOT here: this primitive has no pressed state, so it never
36
+ // reaches Fabric — an entry no test can fail is one that was never wired in.
37
+ // Both prop folds are C++ now (`SymbioteFabricProps.cpp`), asserted in
38
+ // `core/engine/cpp/tests/js/activity-indicator-payload.itest.ts`. `platform.defaultColor` stays
39
+ // because the Android half is chosen by COMPONENT NAME in C++.
40
+ // Returns the spinner as the slot because the prop redirect gates on `childHost` being set — not
41
+ // because children go there: RN's ActivityIndicator takes no children at all, hence
42
+ // `slotTakesNoChildren` below (Android's ProgressBar isn't a ViewGroup and would crash on addView).
74
43
  function buildSpinner(platform) {
75
44
  return (node) => {
76
45
  const descriptor = descriptorFor(ACTIVITY_INDICATOR_SPINNER_TAG);
@@ -99,20 +68,9 @@ function activityIndicatorBehavior(platform) {
99
68
  // Called by the platform files; nothing else should.
100
69
  export function registerActivityIndicatorBehaviors(platform) {
101
70
  registerHostBehavior(ACTIVITY_INDICATOR_TAG, activityIndicatorBehavior(platform));
102
- // A REGISTRATION WITH NO RUNTIME, and it is what hands the spinner's tag to the host.
103
- //
104
- // A tag crosses only through `recordSetTag`, which `attachHostBehavior` emits and nothing else
105
- // does — so a tag with no behavior registered carries an EMPTY `tagName` in C++ and no rule can
106
- // fire for it. The spinner is built by `buildStructure` and no app ever names it, so it had a tag,
107
- // had platform semantics, and the host could not see either. Its rule lives in
108
- // `SymbioteFabricProps.cpp` now (the size translation, RN's two `!== false` defaults, the
109
- // platform's default colour), which is why this registration has to exist even though there is no
110
- // JS left to run.
111
- //
112
- // Not a workaround for the seam: a registration is how this codebase declares that a tag HAS
113
- // platform semantics, which is exactly the claim. Emitting the tag from `createElement` for every
114
- // node was the alternative and is rejected where `attachHostBehavior` explains itself — an app's
115
- // own `<div>`-equivalent would pay an intern and an op to name something the host has no rule for.
71
+ // A registration with no runtime: a tag with no behavior registered carries an empty `tagName`
72
+ // in C++ and no rule can fire for it, so this has to exist even though there's no JS left to run
73
+ // — a registration is how this codebase declares a tag HAS platform semantics.
116
74
  registerHostBehavior(ACTIVITY_INDICATOR_SPINNER_TAG, {
117
75
  attach() { },
118
76
  detach() { },
@@ -1,13 +1,4 @@
1
1
  export declare const BUTTON_TAG = "button";
2
- /**
3
- * NO MEMO, and the earlier version's memo is deliberately gone. It guarded `setProp`'s `Object.is`,
4
- * which a FRESH style object per call can never satisfy — so pushing unconditionally would have
5
- * dirtied a node on every commit and re-committed forever
6
- * (`.claude/rules/list-geometry-feedback-loop.md`). A payload fold does not go through `setProp`:
7
- * its result reaches `reconcile`, which compares against the mirror with a recursive `propsEqual`
8
- * (commit.ts) and reuses the committed handle when nothing moved. An equal-but-fresh style is
9
- * therefore not a change, and there is nothing to feed back.
10
- */
11
2
  export declare const BUTTON_LABEL_TEXT_TAG = "button-label-text";
12
3
  export declare const BUTTON_LABEL_TAG = "button-label";
13
4
  export declare function registerButtonBehavior(): void;
@@ -1,132 +1,29 @@
1
- // Button as an ENGINE-NODE behavior, so RN's one batteries-included control can be an intrinsic
2
- // tag instead of five framework components (`.claude/rules/host-primitive-tier.md`, tier 2).
3
- //
4
- // THE WHOLE PRIMITIVE IS COMPOSITION. RN's Button is a touchable wrapping a View wrapping a Text
5
- // (Button.js:363-388) and takes NO children — `title` is a string prop. So `buildStructure` owns
6
- // the entire subtree, and the nodes below it are a PROJECTION of three owner props.
7
- //
8
- // AND THE TOUCHABLE IS NOT THE SAME ONE ON BOTH PLATFORMS (Button.js:281-284), which is why the
9
- // two trees have different HEIGHTS. TouchableOpacity WRAPS — it renders its own `<Animated.View>`
10
- // and puts the child inside it (TouchableOpacity.js:302,344). TouchableNativeFeedback RENDERS
11
- // NOTHING and clones its props onto the child instead (TouchableNativeFeedback.js:339), so on
12
- // Android Button's own `<View style={buttonStyles}>` IS the responder:
13
- //
14
- // iOS button RCTView TouchableOpacity's Animated.View — the responder + the fade
15
- // └ view RCTView no style at all, and no fold
16
- // └ text RCTText `button-label-text` — foldButtonLabelStyle + RN's Text defaults
17
- // └ raw RCTRawText `button-label` — uppercaseTitle (Android) FOUR nodes
18
- //
19
- // Android button RCTView the styled button view, CLONED onto: the responder, the ripple
20
- // │ background, the whole a11y fold. No fade, no wrapper.
21
- // └ text RCTText
22
- // └ raw RCTRawText UPPERCASED (Button.js:352-353) THREE nodes
23
- //
24
- // EVERY RULE IS IN THE ENGINE AS OF 2026-09-18, and off Android this primitive binds no
25
- // `payloadFold` on any of its four nodes — it costs ZERO trips into JS, down from five. The one that
26
- // survives is the OWNER's on Android, for the view style and the ripple background.
27
- //
28
- // The last to move was the label text's, and it needed a seam none of the others did: its style is a
29
- // function of the BUTTON's `color` and `disabled`, and the button is its GRANDPARENT here and its
30
- // parent on Android. `IAncestorLookup` asks for the nearest ancestor carrying a tag — a CSS ancestor
31
- // selector — so one rule is correct on both trees.
32
- //
33
- // ---------------------------------------------------------------------------------------------
34
- // HOW THE PROJECTION REACHES ITS NODES, given that each `payloadFold` MUST be pure:
35
- //
36
- // title a REDIRECT. The raw text is the slot, and `slotProps` renames `title` -> `text` on it,
37
- // so the app's write lands on the label through the label's own `routeProp` and marks
38
- // it. `resolveButtonTitle`'s uppercase is then the label's own fold over its OWN props —
39
- // no owner to read, and `isEmptyRawText` still sees the real title, so an empty one is
40
- // dropped by the commit walk exactly as it was before.
41
- // color a per-node FOLD over the owner, on the text and — on iOS — on the view (the shape
42
- // disabled `behaviors/scroll-view/shared.ts` uses). `slotDerived` marks the slot, and
43
- // `addDerivedNode` extends that mark to the nodes past it. On Android the second
44
- // consumer is the OWNER itself, which `setProp` already dirties.
45
- //
46
- // So no node writes to another and no follow-up commit is needed. Two seams that do NOT work here,
47
- // measured against the real commit path, so neither is tried again:
48
- //
49
- // slotDerived alone marks `node.childHost` and nothing else — ONE node, where a colour change
50
- // moves two. `addDerivedNode` is the hop past it.
51
- // afterCommit UNREACHABLE for exactly the props that matter. `title` and `color` never
52
- // reach the host payload, so a write to either produces a byte-identical
53
- // payload and `commitContainer` returns on a no-op ABOVE
54
- // `runDeferredAttaches`. Recorded at `IHostBehavior.afterCommit`.
55
- //
56
- // WHY THE OWNER'S FOLD IS BOUND IN `buildStructure` rather than declared as `behavior.foldPayload`.
57
- // It needs two things that are not in the bag it is handed: `onPress` lives in the listener STASH
58
- // (`ownedListeners` diverts it, so `props.onPress` is always undefined), and `focusable` is a
59
- // function of it. `scroll-view/index.android.ts` assigns `owner.payloadFold` from `onWrapChange`
60
- // for the same reason; `attachHostBehavior` sets the field one line BEFORE it calls
61
- // `buildStructure`, so the binding here is what stands.
62
- // ---------------------------------------------------------------------------------------------
63
- // KNOWN DIVERGENCES, stated rather than left to be discovered on a device:
64
- //
65
- // 1. CLOSED 2026-09-09, and it closed by DELETION rather than by a fix. The gap was that all five
66
- // wrappers rendered TouchableOpacity unconditionally where RN swaps in TouchableNativeFeedback
67
- // (Button.js:280-283), so a Button faded on Android and committed four nodes where RN ripples
68
- // and commits three. There is no wrapper left to diverge: `button` is a tag, registered by all
69
- // five adapters, and the swap above is the only implementation.
70
- //
71
- // THE ORDER MATTERED AND IS THE REUSABLE HALF. The registry is keyed by TAG, so registering
72
- // while a wrapper still built its own view and text would have given every Button a SECOND copy
73
- // of the subtree — the hazard `behaviors/scroll-view/shared.ts` records. Entry, registration and
74
- // the five deletions are one change, which is also what `touchable-native-feedback` did hours
75
- // earlier and for the same reason.
76
- //
77
- // WHAT AN APP SEES, stated because it is a behaviour change and not a refactor: on Android a
78
- // Button now ripples instead of fading and commits three nodes instead of four. That is the RN
79
- // parity this whole line of work was for.
80
- //
81
- // 2. CLOSED 2026-09-09, kept for the seam rather than the gap. `aria-disabled` — and an authored
82
- // `accessibilityState.disabled` — now suppress the press, not just grey the label.
83
- //
84
- // THE RESOLUTION IS BUTTON'S, NOT THE MACHINE'S, and that asymmetry is the finding. RN hands
85
- // Pressability the RAW prop (Pressable.js:266), so on a bare `pressable` `aria-disabled` changes
86
- // only what is ANNOUNCED and the press still fires; resolving it down there would be a new
87
- // divergence pointing the other way. Button is the outlier (Button.js:337), so it hands the
88
- // touchable an `IDisabledResolver` (`./pressable`) and `rebuild` calls it at every gesture start.
89
- //
90
- // A RESOLVER RATHER THAN A WRITE, because writing the answer into `node.props.disabled` LATCHES:
91
- // `resolveButtonDisabled` short-circuits on `disabled !== undefined`, so the injected value would
92
- // answer the next resolution as the app's own and the button could never re-enable. Reading per
93
- // gesture also means a flip needs no commit to reach the machine.
94
- //
95
- // AND THE PRESS WAS ONLY HALF OF IT. `./touchable-opacity`'s `afterCommit` re-settles the fade
96
- // when `disabled` moves, and it read the RAW prop — so for an hour after the press half closed, a
97
- // Button disabled by `aria-disabled` mid-press stayed at its ACTIVE opacity while already
98
- // refusing the press. It reads through the same resolver now. The general shape: one prop
99
- // resolved in two places, and closing the first makes the second look done.
100
- //
101
- // 3. CLOSED 2026-09-09, repo-wide, and kept here for the finding rather than the gap. `focusable`
102
- // was emitted by NOTHING in `core/components` — not a wrapper, not the press behavior — so a
103
- // keyboard or TV host could focus a disabled button, on every adapter and both paths. RN carries
104
- // two formulas (`Pressable.js:258` vs the four `Touchable*`, which also require a press handler
105
- // and a non-disabled state); both now live in `../view/render-pressable` and every behavior and
106
- // surviving wrapper calls them.
107
- // ---------------------------------------------------------------------------------------------
108
- //
109
- // REGISTRATION IS THE HAZARD, not the machine — see `./pressable` for why each adapter entry does
110
- // a bare `import './register';` that the barrel does not re-export. Registered by ALL FIVE adapters
111
- // since 2026-09-09, in the same commit that deleted the five wrappers, which is what makes it safe:
112
- // while a wrapper still built its own view and text under this tag, registering would have given
113
- // every Button a second copy of the subtree.
114
- import { addDerivedNode, appendChild, createElement, createRawText, markPropsDirty, Platform, registerHostBehavior, requestCommitFor, setProp, } from '@symbiote-native/engine';
1
+ // Button as an ENGINE-NODE behavior: RN's Button is a touchable wrapping a View wrapping a Text
2
+ // (Button.js:363-388) with no children, so `buildStructure` owns the whole subtree as a
3
+ // PROJECTION of `title`/`color`/`disabled`.
4
+ // The touchable differs by platform (Button.js:281-284): iOS's TouchableOpacity WRAPS (an extra
5
+ // Animated.View, FOUR nodes total); Android's TouchableNativeFeedback clones onto the child, so
6
+ // Button's own view IS the responder (THREE nodes — ripple + a11y cloned onto it, no fade).
7
+ // `title` is a rename via `slotProps`; `color`/`disabled` are a per-node fold over the owner via
8
+ // `slotDerived`/`addDerivedNode`. Neither needs a follow-up commit: neither prop ever reaches the
9
+ // host payload, so an `afterCommit` write on either would be a byte-identical no-op.
10
+ // The owner's fold binds in `buildStructure`, not `behavior.foldPayload`: it needs `onPress` from
11
+ // the listener stash (`props.onPress` is always undefined) and `focusable`, derived from it —
12
+ // same reason `scroll-view/index.android.ts` assigns `owner.payloadFold` from `onWrapChange`.
13
+ // Registered by all five adapters — see `./pressable` for why via a bare `import './register'`.
14
+ import { addDerivedNode, appendChild, createElement, createRawText, markPropsDirty, Platform, registerHostBehavior, requestCommitFor, } from '@symbiote-native/engine';
115
15
  import { descriptorFor } from '../component-names';
116
16
  import { resolveButtonDisabled } from '../view/render-button.js';
117
17
  import { booleanOr, createPressBehavior, } from './pressable.js';
118
18
  import { nativeFeedbackRefinement } from './touchable-native-feedback.js';
119
19
  import { createTouchableOpacityBehavior } from './touchable-opacity.js';
120
20
  export const BUTTON_TAG = 'button';
121
- // Read once, like `render-button`'s own module-level `buttonViewStyle`: the platform cannot change
122
- // under a running app, and every test that needs the other branch already has to mock `Platform`
123
- // for `render-button` regardless — which is why this is a branch rather than a `button/` folder
124
- // split. A file split would move the behavior and leave its style half still reading `Platform`.
21
+ // Read once: the platform cannot change under a running app. A branch, not a `button/` folder
22
+ // split, since a split would move the behavior but leave its style half still reading Platform.
125
23
  const IS_ANDROID = Platform.OS === 'android';
126
- // The owner props the derived nodes' styles are derived from. A name missing here is a node frozen
127
- // at its mount value, which is the whole failure mode this list has. `title` is NOT one of them: it
128
- // is redirected by `SLOT_PROPS` and never reaches `setProp` on the owner, so listing it would be
129
- // dead.
24
+ // The owner props the derived nodes' styles are derived from. A name missing here is a node
25
+ // frozen at its mount value. `title` is NOT one of them — SLOT_PROPS redirects it, so it never
26
+ // reaches setProp on the owner.
130
27
  const SLOT_DERIVED = [
131
28
  'color',
132
29
  'disabled',
@@ -160,117 +57,42 @@ function projectionOf(props) {
160
57
  // What the press machine reads instead of the raw prop — see KNOWN DIVERGENCES 2. Pure: the
161
58
  // projection is derived per call and nothing is written back.
162
59
  const buttonDisabled = props => projectionOf(props).disabled;
163
- /**
164
- * NO MEMO, and the earlier version's memo is deliberately gone. It guarded `setProp`'s `Object.is`,
165
- * which a FRESH style object per call can never satisfy — so pushing unconditionally would have
166
- * dirtied a node on every commit and re-committed forever
167
- * (`.claude/rules/list-geometry-feedback-loop.md`). A payload fold does not go through `setProp`:
168
- * its result reaches `reconcile`, which compares against the mirror with a recursive `propsEqual`
169
- * (commit.ts) and reuses the committed handle when nothing moved. An equal-but-fresh style is
170
- * therefore not a change, and there is nothing to feed back.
171
- */
172
- // THE VIEW'S FOLD IS GONE AND WAS NOT PORTED — it was doing nothing, on the only platform where it
173
- // ran. It wrote `style: resolveButtonViewStyle(color, disabled)`, and that function returns the
174
- // constant `buttonViewStyle` on every platform but Android while the view node is built ONLY in the
175
- // non-Android branch of `buildStructure`. `buttonViewStyle` off Android is `{}`. So it read two
176
- // props off its owner, discarded both, and spent a JSI round trip per button per commit to write an
177
- // empty style.
178
- //
179
- // This is the `input-accessory-view` shape again, and the second time this migration has found one:
180
- // a fold's price is the TRIP, not the body, so a fold that does nothing is the worst value in the
181
- // file and deleting it is worth as much as porting one that does a lot.
182
- //
183
- // Proven not to move the payload rather than argued: `button-derived-payload.itest.ts` pins the
184
- // view's committed keys, including with an app-set `color` — which lands on the LABEL here and must
185
- // not reach this node.
186
- // THE LABEL TEXT'S TAG. Its style is a function of the BUTTON's `color` and `disabled`, and the
187
- // button is this node's grandparent on iOS (`button -> view -> text`) and its parent on Android —
188
- // so the rule asks for the NEAREST BUTTON ancestor rather than for a fixed number of hops, which is
189
- // the same question a CSS ancestor selector asks and is true on both trees.
190
- //
191
- // `foldButtonLabelStyle` in `SymbioteFabricProps.cpp`, reached through `IAncestorLookup`. That seam
192
- // was the thing this fold was waiting for: `ownerProps` answers "my parent" and this node's parent
193
- // is the wrapping view, which knows none of it.
60
+ // NO MEMO: a payload fold doesn't go through setProp, so its result reaches reconcile, which
61
+ // compares against the mirror with a recursive propsEqual and reuses the committed handle when
62
+ // nothing moved. An equal-but-fresh style is not a change, so there's nothing to feed back.
63
+ // The view's fold is gone, not ported: it wrote a style that's the constant `buttonViewStyle` on
64
+ // every platform but Android, where the view node isn't even built — so it read two props,
65
+ // discarded both, and paid a JSI round trip per button per commit for an empty style.
66
+ // Proven, not argued: button-derived-payload.itest.ts pins the view's committed keys, including
67
+ // with an app-set `color`, which lands on the LABEL and must not reach this node.
68
+ // THE LABEL TEXT'S TAG. Its style is a function of the button's `color`/`disabled`, and the button
69
+ // is this node's grandparent on iOS but its parent on Android — so foldButtonLabelStyle asks for
70
+ // the NEAREST BUTTON ancestor rather than a fixed hop count, true on both trees.
194
71
  export const BUTTON_LABEL_TEXT_TAG = 'button-label-text';
195
72
  // The label's own tag, on the raw text that holds the title. Its Android uppercase (`Button.js:352`)
196
73
  // is `uppercaseTitle` below, in JS: the C++ tag rule it briefly was could only do ASCII.
197
74
  export const BUTTON_LABEL_TAG = 'button-label';
198
75
  // ---- the Android touchable -------------------------------------------------------------------
199
- // Button.js:281-284. Not two variants of one component: see the tree diagram at the top for what
200
- // wrapping instead of cloning costs. The Android arm composes the bare press machine, so no
201
- // opacity value is opened and no fade runs — the ripple IS the feedback there.
202
- //
203
- // The refinement is TNF's own and now lives with TNF (`./touchable-native-feedback`). This file
204
- // held a private copy while the `touchable-native-feedback` TAG did not exist and its wrappers
205
- // still wrapped where RN clones; the tag landed, the responder node was already a parameter, and
206
- // one caller became two.
76
+ // Not two variants of one component: see the tree diagram at the top for what wrapping instead
77
+ // of cloning costs. The Android arm composes the bare press machine, so no opacity value opens
78
+ // and no fade runs — the ripple IS the feedback there. The refinement lives with TNF.
207
79
  const touchable = IS_ANDROID
208
80
  ? createPressBehavior(nativeFeedbackRefinement, buttonDisabled)
209
81
  : createTouchableOpacityBehavior(buttonDisabled);
210
82
  // ---- the owner's own payload -------------------------------------------------------------------
211
- /**
212
- * The wrapper-body folds, over the touchable's own. What is deliberately NOT here:
213
- *
214
- * accessible the touchable's fold already applies `accessible !== false`, which is
215
- * exactly RN's split — Button forwards the caller's value RAW (Button.js:365)
216
- * and the touchable one level down defaults it (TouchableOpacity.js:303).
217
- * accessibilityState the engine's aria fold gives `ariaDisabled ?? state.disabled` and the press
218
- * fold then merges `props.disabled` over it, which composes to RN's
219
- * `props.disabled ?? aria ?? state.disabled` — the same value, with
220
- * busy/checked/expanded/selected preserved, without a Button-specific fold.
221
- *
222
- * THE TOUCHABLE'S HALF IS NO LONGER A FUNCTION ON EITHER PLATFORM, and the absence is the design
223
- * rather than a gap: neither `createPressBehavior` nor `createTouchableOpacityBehavior` has a
224
- * `foldPayload` any more, because both rules moved into the engine (`foldPressableProps` and
225
- * `foldIdAlias`, `SymbioteFabricProps.cpp`), which names `button` among the tags it serves. So the
226
- * same work happens, one layer down and before this fold runs — the order is unchanged, the trip
227
- * into JS is gone.
228
- *
229
- * The composition used to be spelled `touchable.foldPayload === undefined ? props : ...`, and that
230
- * shape is deleted rather than left standing at its `undefined` branch: a conditional call through
231
- * a field nothing assigns any more is a whole rule that vanishes silently the day the field is
232
- * removed, which is exactly how it would have gone unnoticed here.
233
- */
234
- // `focusable` LEFT THIS FOLD ON 2026-09-18, and with it the whole fold off Android.
235
- //
236
- // It was the last thing here that ran on both platforms, and it stayed because its middle leg is
237
- // `onPress !== undefined` — an owned listener, stashed in JS. That bit crosses now
238
- // (`OP_SET_OWNED_LISTENER`), and Button's three-way `disabled` was only ever three PROPS, so
239
- // `foldButtonProps` resolves the expression itself. It reads the AUTHORED bag rather than the folded
240
- // one, which is the same Trap A correction this fold carried as `projectionOf(propsOf(node))`.
241
- //
242
- // The ANDROID half went the same day, once the test host grew an arm that compiles `#ifdef ANDROID`
243
- // (`tests/CMakeLists.txt`, `SYMBIOTE_PLATFORM_ANDROID`). It is inside `foldButtonProps` now: the
244
- // Material view style and the theme's selectable background, which TNF clones onto this very node
245
- // (`TouchableNativeFeedback.js:339`) because it renders no view of its own.
246
- //
247
- // SO BUTTON BINDS NO FOLD ON EITHER PLATFORM, and it is the first primitive to reach that with a
248
- // subtree — four nodes, four crossings per commit when this migration started.
249
- //
250
- // Contract: `core/engine/cpp/tests/js/button-payload.itest.ts` for the platform-invariant half and
251
- // `android-rules.itest.ts` for the style, the colour override and the disabled greying.
252
- /**
253
- * Builds the whole subtree, once, at `attachHostBehavior`.
254
- *
255
- * RETURNS THE RAW TEXT. RN's Button declares no `children` prop and renders none, so the slot is
256
- * not where the app's children go — it is where its `title` goes, which is what `SLOT_PROPS`
257
- * redirects onto it. `childHost` is also the GATE on both engine seams this behavior uses:
258
- * `slotDerived`'s mark and the prop redirect are both skipped unless it is set (node.ts), so
259
- * returning `undefined` would leave the whole subtree frozen at its mount values.
260
- */
83
+ // Button binds NO `payloadFold` on either platform: the touchable's half is in the engine
84
+ // (`foldPressableProps`/`foldIdAlias`), the Android style/ripple/focusable half is
85
+ // `foldButtonProps` in C++. Asserted in `button-payload.itest.ts` / `android-rules.itest.ts`.
86
+ // Builds the whole subtree, once, at attachHostBehavior. RETURNS THE RAW TEXT: RN's Button
87
+ // renders no children, so the slot is where `title` goes. `childHost` also gates both engine
88
+ // seams this behavior uses, so returning undefined would freeze the whole subtree at mount values.
261
89
  function buildStructure(node) {
262
90
  const textDescriptor = descriptorFor('text');
263
91
  const text = createElement(textDescriptor.component, textDescriptor.isText, BUTTON_LABEL_TEXT_TAG);
264
- // RN's two Text defaults are NOT written here, and that is deliberate as of 2026-09-18: they are
265
- // the platform's, applied by the payload builder to every `RCTText` (`foldTextDefaults`), so this
266
- // node inherits them for being a text rather than for being handed them. Seeding them was two
267
- // writes per button per commit producing the payload the builder already produces — the shape
268
- // `seedTextDefaults` had in three adapters. `button-derived-payload.itest.ts` reads them off the
269
- // committed payload and is what proves the node still gets them.
270
- //
271
- // Empty until the redirected `title` arrives. The commit walk drops an empty raw text
272
- // (`isEmptyRawText`, node.ts), so no Fabric node exists for it until it has a label — and that
273
- // check reads `props.text`, which the redirect writes, not the fold's uppercased output.
92
+ // RN's two Text defaults are NOT written here: they're the platform's, applied to every RCTText,
93
+ // so this node inherits them for being text, not for being handed them.
94
+ // Empty until the redirected `title` arrives: the commit walk drops an empty raw text, so no
95
+ // Fabric node exists for it until it has a label.
274
96
  const label = createRawText('', BUTTON_LABEL_TAG);
275
97
  // The hop `slotDerived` alone does not make: it marks the slot (the label), and this is past it.
276
98
  addDerivedNode(node, text);
@@ -292,10 +114,8 @@ function buildStructure(node) {
292
114
  // setting the field itself.
293
115
  appendChild(node, view);
294
116
  }
295
- // ANDROID ONLY since 2026-09-18. See the header: the owner's fold needs its own node, and this
296
- // NOTHING IS BOUND HERE ON EITHER PLATFORM as of 2026-09-18, which is the point — a fold with an
297
- // empty body still costs a full JSI round trip per commit, so leaving one that returns its input
298
- // is the worst value available (`input-accessory-view`, and Button's own `viewFold`).
117
+ // No fold bound here on either platform: an empty-body fold still costs a full JSI round trip
118
+ // per commit, worse than not having one at all.
299
119
  return label;
300
120
  }
301
121
  // `focusable` is a function of a LISTENER, and a listener flip changes no payload by itself — so
@@ -317,9 +137,7 @@ function uppercaseTitle(slotKey, value) {
317
137
  // Idempotent: an adapter entry may be imported more than once in a bundle.
318
138
  export function registerButtonBehavior() {
319
139
  // `attach`/`detach` come from the touchable unwrapped: the internal nodes are ordinary children
320
- // that leave with the sweep, and each carries only a pure fold, so this behavior owns no per-node
321
- // runtime of its own to release.
322
- //
140
+ // that leave with the sweep, so this behavior owns no per-node runtime of its own to release.
323
141
  const behavior = {
324
142
  ...touchable,
325
143
  buildStructure,
@@ -329,12 +147,8 @@ export function registerButtonBehavior() {
329
147
  ...(IS_ANDROID ? { slotValueFor: uppercaseTitle } : {}),
330
148
  };
331
149
  // The two DERIVED nodes' tags, registered with no runtime at all. A tag reaches C++ only through
332
- // `recordSetTag`, which `attachHostBehavior` emits, so a tag nobody registered carries an empty
333
- // `tagName` in the host and no rule fires for it — however the rule is written. Same shape as the
334
- // ActivityIndicator spinner's and ImageBackground's inner image.
335
- //
336
- // A registration is how this codebase declares a tag HAS platform semantics, which is exactly the
337
- // claim: the label's style is RN's, not the app's.
150
+ // recordSetTag, which attachHostBehavior emits — a tag nobody registered fires no rule at all.
151
+ // A registration is how this codebase declares a tag HAS platform semantics.
338
152
  const derived = { attach() { }, detach() { } };
339
153
  registerHostBehavior(BUTTON_LABEL_TEXT_TAG, derived);
340
154
  registerHostBehavior(BUTTON_LABEL_TAG, derived);