@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
package/README.md CHANGED
@@ -91,7 +91,7 @@ host node untouched — the render function never names a framework type.
91
91
  (`createPressHandlers` / `createPressRuntime` in `state/pressable`) plus its render-decision
92
92
  helpers (`buildPressableListeners`, `resolveDisabledAccessibilityState`, `shouldClaimResponder`,
93
93
  … in `view/render-pressable`), the `Touchable*` timing constants, `Button`'s shared text-style
94
- fold, `TextInput`'s controlled-value/event-count handshake (`resolveTextInputProps`, `foldText`,
94
+ fold, `TextInput`'s controlled-value/event-count handshake (`foldText`, `shouldCommandText`,
95
95
  `eventCountFromChange`, …), `KeyboardAvoidingView`'s inset math, `ScrollView`'s
96
96
  intrinsics/sticky-header math (no full 3-layer split — the adapter owns the element assembly).
97
97
  - **Two more shared reducers, driven by every adapter's reactive glue**: the sticky-header effect
@@ -104,14 +104,13 @@ host node untouched — the render function never names a framework type.
104
104
  row/section folding helpers. Lists have no `view/render-*.ts` (a cell's content is the
105
105
  framework's own children) — the shared layer here is pure state/logic, reused verbatim by every
106
106
  adapter.
107
- - **Host behaviors (`src/behaviors/*.ts`)** — tier-2 host-primitive lowering: a primitive's state
108
- machine and prop folds registered directly on the engine node (`registerPressableBehavior`,
109
- `registerSwitchBehavior`, `registerImageBehavior`, `registerTextInputBehavior`,
110
- `registerInputAccessoryViewBehavior`, plus the folds a lowered element still needs —
111
- `foldImagePayload`, `foldInputAccessoryViewPayload`, `buildTextInputHandle`) so a
112
- `Pressable`/`Switch`/`TextInput`/`Image`/`InputAccessoryView` can compile to a bare intrinsic tag
113
- instead of a framework component. Built on `@symbiote-native/engine`'s `registerHostBehavior`
114
- seam; see `.claude/rules/host-primitive-tier.md`.
107
+ - **Host behaviors (`src/behaviors/*.ts`)** — a primitive's state machine and prop folds registered
108
+ directly on the engine node (`registerPressableBehavior`, `registerSwitchBehavior`,
109
+ `registerImageBehavior`, `registerTextInputBehavior`, `registerInputAccessoryViewBehavior`, plus
110
+ the folds a tag still needs — `foldImagePayload`, `foldInputAccessoryViewPayload`,
111
+ `buildTextInputHandle`), so `pressable`/`switch`/`text-input`/`image`/`input-accessory-view` are
112
+ bare intrinsic tags rather than framework components. Built on `@symbiote-native/engine`'s
113
+ `registerHostBehavior` seam.
115
114
 
116
115
  ## What it does NOT do
117
116
 
@@ -64,7 +64,7 @@ export interface IAriaProps {
64
64
  /**
65
65
  * The typed entry point adapters import. The FOLD itself now lives in `@symbiote-native/engine`
66
66
  * (`core/engine/src/accessibility-props.ts`) so it runs at the layer every path goes through —
67
- * including a LOWERED element, which has no component wrapper to run it. This function stays here
67
+ * including a bare tag, which has no component wrapper to run it. This function stays here
68
68
  * because the public types do, and because it keeps the typed gate: `hasAnyAriaKey` is checked
69
69
  * before anything is allocated, so the ~99% of nodes carrying no alias cost the same as before.
70
70
  *
@@ -10,7 +10,7 @@
10
10
  // Vue, and the next) folds aria/role into accessibility* identically.
11
11
  import { foldAriaProps } from '@symbiote-native/engine';
12
12
  // The gate's key list, typed against `IAriaProps` so a new alias added to that interface and
13
- // forgotten here is a type error. The engine carries its own untyped copy for the lowered path;
13
+ // forgotten here is a type error. The engine carries its own untyped copy;
14
14
  // `core/engine/src/accessibility-props.ts` is the single implementation of the FOLD, this is only
15
15
  // the cheap probe that decides whether to call it.
16
16
  const ARIA_KEYS = [
@@ -44,7 +44,7 @@ function hasAnyAriaKey(props) {
44
44
  /**
45
45
  * The typed entry point adapters import. The FOLD itself now lives in `@symbiote-native/engine`
46
46
  * (`core/engine/src/accessibility-props.ts`) so it runs at the layer every path goes through —
47
- * including a LOWERED element, which has no component wrapper to run it. This function stays here
47
+ * including a bare tag, which has no component wrapper to run it. This function stays here
48
48
  * because the public types do, and because it keeps the typed gate: `hasAnyAriaKey` is checked
49
49
  * before anything is allocated, so the ~99% of nodes carrying no alias cost the same as before.
50
50
  *
@@ -26,22 +26,17 @@
26
26
  // Registered by all five adapters since 2026-09-09, in the same commit that deleted the five
27
27
  // wrappers — the registry is keyed by TAG, so registering while a wrapper still painted its own
28
28
  // spinner would have given every indicator two.
29
- import { appendChild, createElement, registerHostBehavior, } from '@symbiote-native/engine';
29
+ import { appendChild, createElement, registerHostBehavior, setProp, } from '@symbiote-native/engine';
30
30
  import { descriptorFor } from '../../component-names';
31
31
  export const ACTIVITY_INDICATOR_TAG = 'activity-indicator';
32
32
  // The NATIVE spinner — `ActivityIndicatorView` on iOS, `AndroidProgressBar` on Android. Built by
33
33
  // `buildStructure` below and by nothing else; no app writes it.
34
34
  export const ACTIVITY_INDICATOR_SPINNER_TAG = 'activity-indicator-spinner';
35
- // Fixed pixel boxes RN gives the two named sizes (styles.sizeSmall/sizeLarge).
36
- const SIZE_SMALL_PX = 20;
37
- const SIZE_LARGE_PX = 36;
38
- // RN's own default when the app writes no `size` (ActivityIndicator.js:72).
39
- const DEFAULT_SIZE = 'small';
40
- // Centering wrapper RN puts around the spinner (styles.container).
41
- const CONTAINER_STYLE = {
42
- alignItems: 'center',
43
- justifyContent: 'center',
44
- };
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.
45
40
  // The props that stay on the centering host instead of travelling to the spinner: RN's own two
46
41
  // (`ActivityIndicator.js:113`) plus the spellings the ENGINE resolves against a node's own style.
47
42
  const ACTIVITY_INDICATOR_HOST_PROPS = [
@@ -55,62 +50,21 @@ const ACTIVITY_INDICATOR_HOST_PROPS = [
55
50
  'class',
56
51
  'className',
57
52
  ];
58
- function resolveSize(size) {
59
- if (size === 'small') {
60
- return {
61
- sizeStyle: { width: SIZE_SMALL_PX, height: SIZE_SMALL_PX },
62
- sizeProp: 'small',
63
- };
64
- }
65
- if (size === 'large') {
66
- return {
67
- sizeStyle: { width: SIZE_LARGE_PX, height: SIZE_LARGE_PX },
68
- sizeProp: 'large',
69
- };
70
- }
71
- return { sizeStyle: { width: size, height: size } };
72
- }
73
- // The HOST's fold: RN's `StyleSheet.compose(styles.container, style)` (ActivityIndicator.js:114).
74
- // Base first, so an app style still wins.
75
- const hostFold = props => ({
76
- ...props,
77
- style: [CONTAINER_STYLE, props.style],
78
- });
79
- function isActivityIndicatorSize(value) {
80
- return value === 'small' || value === 'large' || typeof value === 'number';
81
- }
82
- // The SPINNER's fold — RN's own body (`ActivityIndicator.js:99-118`) applied to the node the app
83
- // never names.
84
- function spinnerFold(platform) {
85
- return props => {
86
- const size = isActivityIndicatorSize(props.size)
87
- ? props.size
88
- : DEFAULT_SIZE;
89
- const { sizeStyle, sizeProp } = resolveSize(size);
90
- const next = {
91
- ...props,
92
- // RN defaults both to true and every wrapper spelled that `!== false`. A tag has no
93
- // destructuring default, so the fold is where the default has to live.
94
- animating: props.animating !== false,
95
- hidesWhenStopped: props.hidesWhenStopped !== false,
96
- style: sizeStyle,
97
- };
98
- // A NUMBER never reaches native: it sizes the spinner through style alone, and the native enum
99
- // takes 'small'/'large' only. So the key has to leave, not merely go unwritten.
100
- if (sizeProp === undefined)
101
- delete next.size;
102
- else
103
- next.size = sizeProp;
104
- const color = typeof props.color === 'string' ? props.color : platform.defaultColor;
105
- // Omitted rather than sent as null — Android's theme default is null and Fabric's colour parser
106
- // rejects one.
107
- if (color === null)
108
- delete next.color;
109
- else
110
- next.color = color;
111
- return next;
112
- };
113
- }
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.
114
68
  // The composition. Returns the spinner as the slot because the prop redirect is gated on
115
69
  // `childHost` being set — the redirect is what this slot is FOR, and NOT where children go: RN's
116
70
  // ActivityIndicator renders only the spinner (`ActivityIndicator.js:112-118`) and takes no children
@@ -124,8 +78,8 @@ function buildSpinner(platform) {
124
78
  // Constants of the platform, never a function of a prop, so they are seeded at build time the
125
79
  // way ScrollView seeds `collapsable: false` — empty on iOS, AndroidProgressBar's two
126
80
  // requirements on Android.
127
- spinner.props = { ...platform.nativeExtras };
128
- spinner.payloadFold = spinnerFold(platform);
81
+ for (const [key, value] of Object.entries(platform.nativeExtras))
82
+ setProp(spinner, key, value);
129
83
  appendChild(node, spinner);
130
84
  return spinner;
131
85
  };
@@ -135,7 +89,6 @@ function activityIndicatorBehavior(platform) {
135
89
  slotPropsExcept: ACTIVITY_INDICATOR_HOST_PROPS,
136
90
  slotTakesNoChildren: true,
137
91
  buildStructure: buildSpinner(platform),
138
- foldPayload: hostFold,
139
92
  // Required by the interface and deliberately empty: this primitive owns no timer, no listener
140
93
  // and no native handshake. Written out rather than shared with a `noop` so the emptiness reads
141
94
  // as a decision.
@@ -146,4 +99,22 @@ function activityIndicatorBehavior(platform) {
146
99
  // Called by the platform files; nothing else should.
147
100
  export function registerActivityIndicatorBehaviors(platform) {
148
101
  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.
116
+ registerHostBehavior(ACTIVITY_INDICATOR_SPINNER_TAG, {
117
+ attach() { },
118
+ detach() { },
119
+ });
149
120
  }
@@ -1,2 +1,13 @@
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
+ export declare const BUTTON_LABEL_TEXT_TAG = "button-label-text";
12
+ export declare const BUTTON_LABEL_TAG = "button-label";
2
13
  export declare function registerButtonBehavior(): void;
@@ -12,17 +12,23 @@
12
12
  // Android Button's own `<View style={buttonStyles}>` IS the responder:
13
13
  //
14
14
  // iOS button RCTView TouchableOpacity's Animated.View — the responder + the fade
15
- // └ view RCTView resolveButtonViewStyle(color, disabled) — `{}` here
16
- // └ text RCTText resolveButtonTextStyle(color, disabled) + RN's Text defaults
17
- // └ raw RCTRawText resolveButtonTitle(title) FOUR nodes
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` — foldButtonLabel FOUR nodes
18
18
  //
19
19
  // Android button RCTView the styled button view, CLONED onto: the responder, the ripple
20
20
  // │ background, the whole a11y fold. No fade, no wrapper.
21
21
  // └ text RCTText
22
22
  // └ raw RCTRawText UPPERCASED (Button.js:352-353) THREE nodes
23
23
  //
24
- // EVERY FOLD IS ALREADY WRITTEN AND TESTED in `../view/render-button`; nothing here re-derives one.
25
- // What is new is only WHERE they run: on engine nodes, instead of in a component body.
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.
26
32
  //
27
33
  // ---------------------------------------------------------------------------------------------
28
34
  // HOW THE PROJECTION REACHES ITS NODES, given that each `payloadFold` MUST be pure:
@@ -105,12 +111,9 @@
105
111
  // since 2026-09-09, in the same commit that deleted the five wrappers, which is what makes it safe:
106
112
  // while a wrapper still built its own view and text under this tag, registering would have given
107
113
  // every Button a second copy of the subtree.
108
- import { addDerivedNode, appendChild, appListenerFor, createElement, createRawText, markPropsDirty, Platform, registerHostBehavior, requestCommitFor, } from '@symbiote-native/engine';
114
+ import { addDerivedNode, appendChild, createElement, createRawText, markPropsDirty, Platform, registerHostBehavior, requestCommitFor, setProp, } from '@symbiote-native/engine';
109
115
  import { descriptorFor } from '../component-names';
110
- import { resolveTextProps } from '../text-props.js';
111
- import { BUTTON_ACCESSIBILITY_ROLE, resolveButtonDisabled, resolveButtonImportantForAccessibility, resolveButtonTextStyle, resolveButtonTitle, resolveButtonViewStyle, } from '../view/render-button.js';
112
- import { backgroundProps, selectableBackground, } from '../view/render-touchable-native-feedback.js';
113
- import { resolveTouchableFocusable } from '../view/render-pressable/index.js';
116
+ import { resolveButtonDisabled } from '../view/render-button.js';
114
117
  import { booleanOr, createPressBehavior, } from './pressable.js';
115
118
  import { nativeFeedbackRefinement } from './touchable-native-feedback.js';
116
119
  import { createTouchableOpacityBehavior } from './touchable-opacity.js';
@@ -166,29 +169,39 @@ const buttonDisabled = props => projectionOf(props).disabled;
166
169
  * (commit.ts) and reuses the committed handle when nothing moved. An equal-but-fresh style is
167
170
  * therefore not a change, and there is nothing to feed back.
168
171
  */
169
- function viewFold(owner) {
170
- return props => {
171
- const { color, disabled } = projectionOf(owner.props);
172
- return { ...props, style: resolveButtonViewStyle(color, disabled) };
173
- };
174
- }
175
- function textFold(owner) {
176
- return props => {
177
- const { color, disabled } = projectionOf(owner.props);
178
- return {
179
- ...props,
180
- style: resolveButtonTextStyle(color, disabled),
181
- // RN puts `disabled` on the Text as well (Button.js:386) — a real RCTText prop read by
182
- // Android's accessibility layer, and not the same thing as the greyed colour above.
183
- disabled,
184
- };
185
- };
186
- }
187
- // Reads its OWN `text`, which `SLOT_PROPS` redirected the app's `title` into — no owner closure, so
188
- // the fold is shared by every button. `fabricProps` reads only `.text` off a raw-text fold.
189
- const labelFold = props => ({
190
- text: resolveButtonTitle(stringOr(props.text) ?? ''),
191
- });
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.
194
+ export const BUTTON_LABEL_TEXT_TAG = 'button-label-text';
195
+ // The label's own tag. A raw text carrying one looks odd and is not: it has no props an app can
196
+ // write, but its CONTENT is the platform's decision here — RN renders a button's title uppercased on
197
+ // Android and verbatim elsewhere (`Button.js:352-353`), which is a user-agent choice about a control
198
+ // rather than anything the app asked for.
199
+ //
200
+ // That is what the fold here used to do, and it is `foldButtonLabel` in `SymbioteFabricProps.cpp`
201
+ // now, reached off this tag. `button-payload.itest.ts` recorded "a raw text carries no tag at all,
202
+ // so there is nothing for a tag-keyed rule to key on" — true of `createRawText`'s old signature, not
203
+ // of raw texts, and it takes a tag now for exactly this.
204
+ export const BUTTON_LABEL_TAG = 'button-label';
192
205
  // ---- the Android touchable -------------------------------------------------------------------
193
206
  // Button.js:281-284. Not two variants of one component: see the tree diagram at the top for what
194
207
  // wrapping instead of cloning costs. The Android arm composes the bare press machine, so no
@@ -212,48 +225,37 @@ const touchable = IS_ANDROID
212
225
  * fold then merges `props.disabled` over it, which composes to RN's
213
226
  * `props.disabled ?? aria ?? state.disabled` — the same value, with
214
227
  * busy/checked/expanded/selected preserved, without a Button-specific fold.
228
+ *
229
+ * THE TOUCHABLE'S HALF IS NO LONGER A FUNCTION ON EITHER PLATFORM, and the absence is the design
230
+ * rather than a gap: neither `createPressBehavior` nor `createTouchableOpacityBehavior` has a
231
+ * `foldPayload` any more, because both rules moved into the engine (`foldPressableProps` and
232
+ * `foldIdAlias`, `SymbioteFabricProps.cpp`), which names `button` among the tags it serves. So the
233
+ * same work happens, one layer down and before this fold runs — the order is unchanged, the trip
234
+ * into JS is gone.
235
+ *
236
+ * The composition used to be spelled `touchable.foldPayload === undefined ? props : ...`, and that
237
+ * shape is deleted rather than left standing at its `undefined` branch: a conditional call through
238
+ * a field nothing assigns any more is a whole rule that vanishes silently the day the field is
239
+ * removed, which is exactly how it would have gone unnoticed here.
215
240
  */
216
- function ownerFold(node) {
217
- return props => {
218
- const next = {
219
- ...(touchable.foldPayload === undefined
220
- ? props
221
- : touchable.foldPayload(props)),
222
- };
223
- next.accessibilityRole = BUTTON_ACCESSIBILITY_ROLE;
224
- // 'no' is the only value the resolver moves (Button.js:356), so checking for it IS the
225
- // narrowing this bag needs — the shared resolver still owns what 'no' becomes.
226
- if (next.importantForAccessibility === 'no')
227
- next.importantForAccessibility =
228
- resolveButtonImportantForAccessibility('no');
229
- // Re-mapped, so the raw name must not also reach Fabric. Where the wrappers put it too — the
230
- // pressable owns sound suppression (Button.js:377 hands `touchSoundDisabled` to the touchable).
231
- if (Object.hasOwn(next, 'touchSoundDisabled')) {
232
- next.android_disableSound = next.touchSoundDisabled;
233
- delete next.touchSoundDisabled;
234
- }
235
- const { color, disabled } = projectionOf(props);
236
- // TouchableOpacity.js:336 and TouchableNativeFeedback.js:369 — the SAME expression, so the tag
237
- // owes it on both platforms. `onPress` is an owned name, so it is in the stash and never in
238
- // `props`; a flip of it dirties nothing by itself, which `onOwnedListenerChange` answers.
239
- next.focusable = resolveTouchableFocusable(booleanOr(props.focusable), appListenerFor(node, 'press') !== undefined, disabled);
240
- if (IS_ANDROID) {
241
- // TNF renders no view, it CLONES onto Button's `<View style={buttonStyles}>`
242
- // (TouchableNativeFeedback.js:339), so this host IS that view. Overwritten rather than merged
243
- // because RN's Button declares no `style` prop at all — there is nothing to compose with.
244
- next.style = resolveButtonViewStyle(color, disabled);
245
- // Button passes no `background` and no `useForeground`, so TNF resolves the theme's
246
- // selectable background onto the background slot (TouchableNativeFeedback.js:343-348,
247
- // :402). The dicts are the shared factories', never restated here.
248
- Object.assign(next, backgroundProps(selectableBackground(), false));
249
- }
250
- // Read by the folds above and declared by no ViewConfig. A key Fabric does not know throws
251
- // nothing, logs nothing and paints nothing, so the strip has to be here or it is never noticed.
252
- // `title` needs none — `SLOT_PROPS` redirects it before it can land on this node.
253
- delete next.color;
254
- return next;
255
- };
256
- }
241
+ // `focusable` LEFT THIS FOLD ON 2026-09-18, and with it the whole fold off Android.
242
+ //
243
+ // It was the last thing here that ran on both platforms, and it stayed because its middle leg is
244
+ // `onPress !== undefined` — an owned listener, stashed in JS. That bit crosses now
245
+ // (`OP_SET_OWNED_LISTENER`), and Button's three-way `disabled` was only ever three PROPS, so
246
+ // `foldButtonProps` resolves the expression itself. It reads the AUTHORED bag rather than the folded
247
+ // one, which is the same Trap A correction this fold carried as `projectionOf(propsOf(node))`.
248
+ //
249
+ // The ANDROID half went the same day, once the test host grew an arm that compiles `#ifdef ANDROID`
250
+ // (`tests/CMakeLists.txt`, `SYMBIOTE_PLATFORM_ANDROID`). It is inside `foldButtonProps` now: the
251
+ // Material view style and the theme's selectable background, which TNF clones onto this very node
252
+ // (`TouchableNativeFeedback.js:339`) because it renders no view of its own.
253
+ //
254
+ // SO BUTTON BINDS NO FOLD ON EITHER PLATFORM, and it is the first primitive to reach that with a
255
+ // subtree — four nodes, four crossings per commit when this migration started.
256
+ //
257
+ // Contract: `core/engine/cpp/tests/js/button-payload.itest.ts` for the platform-invariant half and
258
+ // `android-rules.itest.ts` for the style, the colour override and the disabled greying.
257
259
  /**
258
260
  * Builds the whole subtree, once, at `attachHostBehavior`.
259
261
  *
@@ -265,18 +267,18 @@ function ownerFold(node) {
265
267
  */
266
268
  function buildStructure(node) {
267
269
  const textDescriptor = descriptorFor('text');
268
- const text = createElement(textDescriptor.component, textDescriptor.isText, 'text');
269
- // RN's Text.js applies these to every non-virtual Text on its way to native, and a hand-written
270
- // host tag inherits nothing a `<Text>` component did — Svelte's Button clipped long labels
271
- // mid-word for exactly this reason (`.claude/rules/host-primitive-tier.md`, "The THIRD path").
272
- // Constants, because the app cannot reach this node to override them.
273
- text.props = resolveTextProps({});
270
+ const text = createElement(textDescriptor.component, textDescriptor.isText, BUTTON_LABEL_TEXT_TAG);
271
+ // RN's two Text defaults are NOT written here, and that is deliberate as of 2026-09-18: they are
272
+ // the platform's, applied by the payload builder to every `RCTText` (`foldTextDefaults`), so this
273
+ // node inherits them for being a text rather than for being handed them. Seeding them was two
274
+ // writes per button per commit producing the payload the builder already produces — the shape
275
+ // `seedTextDefaults` had in three adapters. `button-derived-payload.itest.ts` reads them off the
276
+ // committed payload and is what proves the node still gets them.
277
+ //
274
278
  // Empty until the redirected `title` arrives. The commit walk drops an empty raw text
275
279
  // (`isEmptyRawText`, node.ts), so no Fabric node exists for it until it has a label — and that
276
280
  // check reads `props.text`, which the redirect writes, not the fold's uppercased output.
277
- const label = createRawText('');
278
- text.payloadFold = textFold(node);
279
- label.payloadFold = labelFold;
281
+ const label = createRawText('', BUTTON_LABEL_TAG);
280
282
  // The hop `slotDerived` alone does not make: it marks the slot (the label), and this is past it.
281
283
  addDerivedNode(node, text);
282
284
  appendChild(text, label);
@@ -288,7 +290,8 @@ function buildStructure(node) {
288
290
  else {
289
291
  const viewDescriptor = descriptorFor('view');
290
292
  const view = createElement(viewDescriptor.component, viewDescriptor.isText, 'view');
291
- view.payloadFold = viewFold(node);
293
+ // No fold: see the note where `viewFold` was. Off Android this node's style was `{}` and this
294
+ // branch is the only one that builds it, so the fold was a crossing bought for an empty object.
292
295
  addDerivedNode(node, view);
293
296
  appendChild(view, text);
294
297
  // Lands on the owner, because `node.childHost` is still undefined here — the engine assigns it
@@ -296,9 +299,10 @@ function buildStructure(node) {
296
299
  // setting the field itself.
297
300
  appendChild(node, view);
298
301
  }
299
- // See the header: the owner's fold needs its own node, and this runs after
300
- // `attachHostBehavior` has already written `behavior.foldPayload` into the field.
301
- node.payloadFold = ownerFold(node);
302
+ // ANDROID ONLY since 2026-09-18. See the header: the owner's fold needs its own node, and this
303
+ // NOTHING IS BOUND HERE ON EITHER PLATFORM as of 2026-09-18, which is the point — a fold with an
304
+ // empty body still costs a full JSI round trip per commit, so leaving one that returns its input
305
+ // is the worst value available (`input-accessory-view`, and Button's own `viewFold`).
302
306
  return label;
303
307
  }
304
308
  // `focusable` is a function of a LISTENER, and a listener flip changes no payload by itself — so
@@ -315,14 +319,22 @@ export function registerButtonBehavior() {
315
319
  // that leave with the sweep, and each carries only a pure fold, so this behavior owns no per-node
316
320
  // runtime of its own to release.
317
321
  //
318
- // No `foldPayload` here on purpose — `buildStructure` binds the owner's fold to its node.
319
322
  const behavior = {
320
323
  ...touchable,
321
- foldPayload: undefined,
322
324
  buildStructure,
323
325
  onOwnedListenerChange,
324
326
  slotProps: SLOT_PROPS,
325
327
  slotDerived: SLOT_DERIVED,
326
328
  };
329
+ // The two DERIVED nodes' tags, registered with no runtime at all. A tag reaches C++ only through
330
+ // `recordSetTag`, which `attachHostBehavior` emits, so a tag nobody registered carries an empty
331
+ // `tagName` in the host and no rule fires for it — however the rule is written. Same shape as the
332
+ // ActivityIndicator spinner's and ImageBackground's inner image.
333
+ //
334
+ // A registration is how this codebase declares a tag HAS platform semantics, which is exactly the
335
+ // claim: the label's style is RN's, not the app's.
336
+ const derived = { attach() { }, detach() { } };
337
+ registerHostBehavior(BUTTON_LABEL_TEXT_TAG, derived);
338
+ registerHostBehavior(BUTTON_LABEL_TAG, derived);
327
339
  registerHostBehavior(BUTTON_TAG, behavior);
328
340
  }
@@ -1,2 +1,3 @@
1
1
  export declare const IMAGE_BACKGROUND_TAG = "image-background";
2
+ export declare const IMAGE_BACKGROUND_IMAGE_TAG = "image-background-image";
2
3
  export declare function registerImageBackgroundBehavior(): void;