@symbiote-native/components 1.0.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -13
- package/build/accessibility-props.d.ts +1 -1
- package/build/accessibility-props.js +2 -2
- package/build/behaviors/activity-indicator/index.android.d.ts +1 -0
- package/build/behaviors/activity-indicator/index.android.js +16 -0
- package/build/behaviors/activity-indicator/index.d.ts +3 -0
- package/build/behaviors/activity-indicator/index.ios.d.ts +1 -0
- package/build/behaviors/activity-indicator/index.ios.js +14 -0
- package/build/behaviors/activity-indicator/index.js +5 -0
- package/build/behaviors/activity-indicator/shared.d.ts +18 -0
- package/build/behaviors/activity-indicator/shared.js +120 -0
- package/build/behaviors/button.d.ts +13 -0
- package/build/behaviors/button.js +340 -0
- package/build/behaviors/image-background.d.ts +3 -0
- package/build/behaviors/image-background.js +155 -0
- package/build/behaviors/image.d.ts +1 -2
- package/build/behaviors/image.js +25 -106
- package/build/behaviors/input-accessory-view.d.ts +1 -2
- package/build/behaviors/input-accessory-view.js +49 -55
- package/build/behaviors/pressable.d.ts +59 -1
- package/build/behaviors/pressable.js +142 -96
- package/build/behaviors/refresh-control.d.ts +2 -0
- package/build/behaviors/refresh-control.js +96 -0
- package/build/behaviors/scroll-view/index.android.d.ts +1 -0
- package/build/behaviors/scroll-view/index.android.js +40 -0
- package/build/behaviors/scroll-view/index.d.ts +3 -0
- package/build/behaviors/scroll-view/index.ios.d.ts +1 -0
- package/build/behaviors/scroll-view/index.ios.js +10 -0
- package/build/behaviors/scroll-view/index.js +8 -0
- package/build/behaviors/scroll-view/responder.d.ts +4 -0
- package/build/behaviors/scroll-view/responder.js +202 -0
- package/build/behaviors/scroll-view/shared.d.ts +9 -0
- package/build/behaviors/scroll-view/shared.js +291 -0
- package/build/behaviors/scroll-view/sticky.d.ts +18 -0
- package/build/behaviors/scroll-view/sticky.js +581 -0
- package/build/behaviors/switch.d.ts +1 -1
- package/build/behaviors/switch.js +49 -88
- package/build/behaviors/text-input.d.ts +2 -2
- package/build/behaviors/text-input.js +244 -104
- package/build/behaviors/touchable-highlight.d.ts +9 -0
- package/build/behaviors/touchable-highlight.js +205 -0
- package/build/behaviors/touchable-native-feedback.d.ts +20 -0
- package/build/behaviors/touchable-native-feedback.js +254 -0
- package/build/behaviors/touchable-opacity.d.ts +12 -0
- package/build/behaviors/touchable-opacity.js +239 -0
- package/build/behaviors/touchable-without-feedback.d.ts +2 -0
- package/build/behaviors/touchable-without-feedback.js +231 -0
- package/build/component-names/index.android.js +31 -25
- package/build/component-names/index.ios.js +28 -23
- package/build/component-names/shared.d.ts +2 -1
- package/build/component-names/shared.js +16 -6
- package/build/descriptor.js +4 -4
- package/build/index.d.ts +26 -26
- package/build/index.js +56 -27
- package/build/register.d.ts +1 -0
- package/build/register.js +55 -0
- package/build/resolve-intrinsic.js +3 -9
- package/build/scroll-view-commands.d.ts +1 -5
- package/build/scroll-view-commands.js +23 -85
- package/build/state/flat-list.d.ts +2 -2
- package/build/state/flat-list.js +10 -2
- package/build/state/pressable.d.ts +6 -1
- package/build/state/pressable.js +63 -28
- package/build/state/section-list.d.ts +2 -0
- package/build/state/section-list.js +14 -7
- package/build/state/text-input.d.ts +10 -40
- package/build/state/text-input.js +17 -186
- package/build/state/touchable.d.ts +1 -0
- package/build/state/touchable.js +11 -8
- package/build/state/virtualized-list-reducer.d.ts +2 -2
- package/build/state/virtualized-list.d.ts +6 -6
- package/build/state/virtualized-list.js +71 -37
- package/build/text-props.d.ts +0 -8
- package/build/text-props.js +14 -25
- package/build/view/render-button.d.ts +11 -4
- package/build/view/render-button.js +74 -22
- package/build/view/render-image/index.d.ts +14 -1
- package/build/view/render-image/index.js +22 -147
- package/build/view/render-input-accessory-view.d.ts +1 -5
- package/build/view/render-input-accessory-view.js +26 -48
- package/build/view/render-keyboard-avoiding-view.d.ts +7 -1
- package/build/view/render-keyboard-avoiding-view.js +40 -1
- package/build/view/render-modal.d.ts +1 -1
- package/build/view/render-modal.js +18 -8
- package/build/view/render-pressable/index.d.ts +3 -0
- package/build/view/render-pressable/index.js +28 -0
- package/build/view/render-scroll-view.d.ts +1 -4
- package/build/view/render-scroll-view.js +17 -54
- package/build/view/render-switch.d.ts +4 -15
- package/build/view/render-switch.js +4 -42
- package/build/view/render-touchable-highlight.d.ts +1 -0
- package/build/view/render-touchable-native-feedback.d.ts +19 -1
- package/build/view/render-touchable-native-feedback.js +34 -9
- package/host-primitives.cjs +178 -280
- package/host-primitives.d.cts +0 -3
- package/package.json +8 -21
- package/build/fold-host-bag.d.ts +0 -15
- package/build/fold-host-bag.js +0 -99
- package/build/state-style.d.ts +0 -15
- package/build/state-style.js +0 -47
- package/build/view/render-activity-indicator.d.ts +0 -25
- package/build/view/render-activity-indicator.js +0 -88
- package/build/view/render-image-background.d.ts +0 -9
- package/build/view/render-image-background.js +0 -48
- package/build/view/render-text-input.d.ts +0 -11
- package/build/view/render-text-input.js +0 -39
- package/lowering-fixtures.cjs +0 -259
- package/lowering-fixtures.d.cts +0 -17
- package/specialize-state-style.cjs +0 -219
- package/specialize-state-style.d.cts +0 -15
package/README.md
CHANGED
|
@@ -30,8 +30,7 @@ supplies only the third:
|
|
|
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
|
|
34
|
-
canonical render-only reference (no state machine needed).
|
|
33
|
+
`Switch` is the canonical reference for a full three-layer component.
|
|
35
34
|
|
|
36
35
|
### Install
|
|
37
36
|
|
|
@@ -86,13 +85,13 @@ host node untouched — the render function never names a framework type.
|
|
|
86
85
|
canonical `accessibility*` transform, shared so every adapter folds identically.
|
|
87
86
|
- **Components with a full state + render split** — `Switch`, `Modal` (its reducer gates the iOS
|
|
88
87
|
keep-alive frame).
|
|
89
|
-
- **Render-only components** (no state machine) — `
|
|
90
|
-
`
|
|
88
|
+
- **Render-only components** (no state machine) — `Image`, `ImageBackground`,
|
|
89
|
+
`InputAccessoryView`.
|
|
91
90
|
- **Pure logic/plumbing without a full `Descriptor`** — `Pressable`'s press state machine
|
|
92
91
|
(`createPressHandlers` / `createPressRuntime` in `state/pressable`) plus its render-decision
|
|
93
92
|
helpers (`buildPressableListeners`, `resolveDisabledAccessibilityState`, `shouldClaimResponder`,
|
|
94
93
|
… in `view/render-pressable`), the `Touchable*` timing constants, `Button`'s shared text-style
|
|
95
|
-
fold, `TextInput`'s controlled-value/event-count handshake (`
|
|
94
|
+
fold, `TextInput`'s controlled-value/event-count handshake (`foldText`, `shouldCommandText`,
|
|
96
95
|
`eventCountFromChange`, …), `KeyboardAvoidingView`'s inset math, `ScrollView`'s
|
|
97
96
|
intrinsics/sticky-header math (no full 3-layer split — the adapter owns the element assembly).
|
|
98
97
|
- **Two more shared reducers, driven by every adapter's reactive glue**: the sticky-header effect
|
|
@@ -105,14 +104,13 @@ host node untouched — the render function never names a framework type.
|
|
|
105
104
|
row/section folding helpers. Lists have no `view/render-*.ts` (a cell's content is the
|
|
106
105
|
framework's own children) — the shared layer here is pure state/logic, reused verbatim by every
|
|
107
106
|
adapter.
|
|
108
|
-
- **Host behaviors (`src/behaviors/*.ts`)** —
|
|
109
|
-
|
|
110
|
-
`
|
|
111
|
-
|
|
112
|
-
`
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
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.
|
|
116
114
|
|
|
117
115
|
## What it does NOT do
|
|
118
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
|
|
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
|
|
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
|
|
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
|
*
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function registerActivityIndicatorBehavior(): void;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// ActivityIndicator's behavior on Android, where the spinner is `AndroidProgressBar` and needs two
|
|
2
|
+
// props RN's iOS branch never sends (`ActivityIndicator.js:106`, spread only at `:118`):
|
|
3
|
+
//
|
|
4
|
+
// styleAttr drives ProgressBar.setStyle(); without it the view throws "setStyle() not called"
|
|
5
|
+
// indeterminate the spinner has no determinate mode here
|
|
6
|
+
//
|
|
7
|
+
// and where the default colour is the THEME — expressed as null so the fold omits the key entirely
|
|
8
|
+
// rather than handing Fabric's colour parser a null it rejects.
|
|
9
|
+
import { registerActivityIndicatorBehaviors } from './shared.js';
|
|
10
|
+
const ANDROID_STYLE_ATTR = 'Normal';
|
|
11
|
+
export function registerActivityIndicatorBehavior() {
|
|
12
|
+
registerActivityIndicatorBehaviors({
|
|
13
|
+
defaultColor: null,
|
|
14
|
+
nativeExtras: { styleAttr: ANDROID_STYLE_ATTR, indeterminate: true },
|
|
15
|
+
});
|
|
16
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function registerActivityIndicatorBehavior(): void;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// ActivityIndicator's behavior on iOS: `ActivityIndicatorView` takes the size enum and a GRAY
|
|
2
|
+
// default colour, and needs no extra native props. That is the entire platform half.
|
|
3
|
+
//
|
|
4
|
+
// This file is also the base the folder's `index.ts` re-exports for headless, matching every other
|
|
5
|
+
// platform-split module in this tree.
|
|
6
|
+
import { registerActivityIndicatorBehaviors } from './shared.js';
|
|
7
|
+
// RN's iOS default spinner colour (`ActivityIndicator.js:25`, GRAY).
|
|
8
|
+
const IOS_DEFAULT_COLOR = '#999999';
|
|
9
|
+
export function registerActivityIndicatorBehavior() {
|
|
10
|
+
registerActivityIndicatorBehaviors({
|
|
11
|
+
defaultColor: IOS_DEFAULT_COLOR,
|
|
12
|
+
nativeExtras: {},
|
|
13
|
+
});
|
|
14
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// The base of the folder-as-module group: Metro picks `index.ios` / `index.android` per platform,
|
|
2
|
+
// and everything else (tsx, vitest, headless) lands here. iOS is the default, matching every other
|
|
3
|
+
// platform-split module in this tree.
|
|
4
|
+
export { registerActivityIndicatorBehavior } from './index.ios.js';
|
|
5
|
+
export { ACTIVITY_INDICATOR_SPINNER_TAG, ACTIVITY_INDICATOR_TAG, } from './shared.js';
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type IStyleProp, type ISymbioteEvent, type IViewStyle } from '@symbiote-native/engine';
|
|
2
|
+
import type { IAccessibilityProps, IAriaProps } from '../../accessibility-props';
|
|
3
|
+
export declare const ACTIVITY_INDICATOR_TAG = "activity-indicator";
|
|
4
|
+
export declare const ACTIVITY_INDICATOR_SPINNER_TAG = "activity-indicator-spinner";
|
|
5
|
+
export type IActivityIndicatorSize = 'small' | 'large' | number;
|
|
6
|
+
export interface IActivityIndicatorProps extends IAccessibilityProps, IAriaProps {
|
|
7
|
+
animating?: boolean;
|
|
8
|
+
color?: string;
|
|
9
|
+
size?: IActivityIndicatorSize;
|
|
10
|
+
hidesWhenStopped?: boolean;
|
|
11
|
+
style?: IStyleProp<IViewStyle>;
|
|
12
|
+
onLayout?: (event: ISymbioteEvent) => void;
|
|
13
|
+
}
|
|
14
|
+
export type IActivityIndicatorPlatform = {
|
|
15
|
+
defaultColor: string | null;
|
|
16
|
+
nativeExtras: Readonly<Record<string, unknown>>;
|
|
17
|
+
};
|
|
18
|
+
export declare function registerActivityIndicatorBehaviors(platform: IActivityIndicatorPlatform): void;
|
|
@@ -0,0 +1,120 @@
|
|
|
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.
|
|
29
|
+
import { appendChild, createElement, registerHostBehavior, setProp, } from '@symbiote-native/engine';
|
|
30
|
+
import { descriptorFor } from '../../component-names';
|
|
31
|
+
export const ACTIVITY_INDICATOR_TAG = 'activity-indicator';
|
|
32
|
+
// The NATIVE spinner — `ActivityIndicatorView` on iOS, `AndroidProgressBar` on Android. Built by
|
|
33
|
+
// `buildStructure` below and by nothing else; no app writes it.
|
|
34
|
+
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.
|
|
40
|
+
// The props that stay on the centering host instead of travelling to the spinner: RN's own two
|
|
41
|
+
// (`ActivityIndicator.js:113`) plus the spellings the ENGINE resolves against a node's own style.
|
|
42
|
+
const ACTIVITY_INDICATOR_HOST_PROPS = [
|
|
43
|
+
// A layout callback measures the box the spinner is centred IN, which is this node.
|
|
44
|
+
'onLayout',
|
|
45
|
+
// The composed `StyleSheet.compose(styles.container, style)` array (`:114`).
|
|
46
|
+
'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.
|
|
50
|
+
'class',
|
|
51
|
+
'className',
|
|
52
|
+
];
|
|
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.
|
|
74
|
+
function buildSpinner(platform) {
|
|
75
|
+
return (node) => {
|
|
76
|
+
const descriptor = descriptorFor(ACTIVITY_INDICATOR_SPINNER_TAG);
|
|
77
|
+
const spinner = createElement(descriptor.component, descriptor.isText, ACTIVITY_INDICATOR_SPINNER_TAG);
|
|
78
|
+
// Constants of the platform, never a function of a prop, so they are seeded at build time the
|
|
79
|
+
// way ScrollView seeds `collapsable: false` — empty on iOS, AndroidProgressBar's two
|
|
80
|
+
// requirements on Android.
|
|
81
|
+
for (const [key, value] of Object.entries(platform.nativeExtras))
|
|
82
|
+
setProp(spinner, key, value);
|
|
83
|
+
appendChild(node, spinner);
|
|
84
|
+
return spinner;
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function activityIndicatorBehavior(platform) {
|
|
88
|
+
return {
|
|
89
|
+
slotPropsExcept: ACTIVITY_INDICATOR_HOST_PROPS,
|
|
90
|
+
slotTakesNoChildren: true,
|
|
91
|
+
buildStructure: buildSpinner(platform),
|
|
92
|
+
// Required by the interface and deliberately empty: this primitive owns no timer, no listener
|
|
93
|
+
// and no native handshake. Written out rather than shared with a `noop` so the emptiness reads
|
|
94
|
+
// as a decision.
|
|
95
|
+
attach() { },
|
|
96
|
+
detach() { },
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
// Called by the platform files; nothing else should.
|
|
100
|
+
export function registerActivityIndicatorBehaviors(platform) {
|
|
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
|
+
});
|
|
120
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
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";
|
|
13
|
+
export declare function registerButtonBehavior(): void;
|
|
@@ -0,0 +1,340 @@
|
|
|
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` — foldButtonLabel 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';
|
|
115
|
+
import { descriptorFor } from '../component-names';
|
|
116
|
+
import { resolveButtonDisabled } from '../view/render-button.js';
|
|
117
|
+
import { booleanOr, createPressBehavior, } from './pressable.js';
|
|
118
|
+
import { nativeFeedbackRefinement } from './touchable-native-feedback.js';
|
|
119
|
+
import { createTouchableOpacityBehavior } from './touchable-opacity.js';
|
|
120
|
+
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`.
|
|
125
|
+
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.
|
|
130
|
+
const SLOT_DERIVED = [
|
|
131
|
+
'color',
|
|
132
|
+
'disabled',
|
|
133
|
+
'aria-disabled',
|
|
134
|
+
'accessibilityState',
|
|
135
|
+
];
|
|
136
|
+
// Button.js:386 renders `<Text>{title}</Text>`; the raw text is where that string lives.
|
|
137
|
+
const SLOT_PROPS = { title: 'text' };
|
|
138
|
+
function stringOr(value) {
|
|
139
|
+
return typeof value === 'string' ? value : undefined;
|
|
140
|
+
}
|
|
141
|
+
// `accessibilityState` arrives as `unknown` off `node.props`, and only `disabled` decides anything
|
|
142
|
+
// here. Narrowed field by field rather than cast, the idiom `./pressable` uses for the same bag.
|
|
143
|
+
function accessibilityDisabled(value) {
|
|
144
|
+
if (typeof value !== 'object' || value === null)
|
|
145
|
+
return {};
|
|
146
|
+
const disabled = Reflect.get(value, 'disabled');
|
|
147
|
+
return typeof disabled === 'boolean' ? { disabled } : {};
|
|
148
|
+
}
|
|
149
|
+
// Takes the PROP BAG rather than the node, so the owner's own fold — which is handed a bag and not
|
|
150
|
+
// a node — resolves the same projection its derived children do.
|
|
151
|
+
function projectionOf(props) {
|
|
152
|
+
return {
|
|
153
|
+
color: stringOr(props.color),
|
|
154
|
+
// Button.js:337 — `disabled` may be decided by `aria-disabled` or by an authored
|
|
155
|
+
// `accessibilityState.disabled`. The engine folds both into the COMMITTED accessibilityState
|
|
156
|
+
// already; this is the half a payload fold cannot do, which is greying the label.
|
|
157
|
+
disabled: resolveButtonDisabled(booleanOr(props.disabled), booleanOr(props['aria-disabled']), accessibilityDisabled(props.accessibilityState)),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
// What the press machine reads instead of the raw prop — see KNOWN DIVERGENCES 2. Pure: the
|
|
161
|
+
// projection is derived per call and nothing is written back.
|
|
162
|
+
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.
|
|
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';
|
|
205
|
+
// ---- the Android touchable -------------------------------------------------------------------
|
|
206
|
+
// Button.js:281-284. Not two variants of one component: see the tree diagram at the top for what
|
|
207
|
+
// wrapping instead of cloning costs. The Android arm composes the bare press machine, so no
|
|
208
|
+
// opacity value is opened and no fade runs — the ripple IS the feedback there.
|
|
209
|
+
//
|
|
210
|
+
// The refinement is TNF's own and now lives with TNF (`./touchable-native-feedback`). This file
|
|
211
|
+
// held a private copy while the `touchable-native-feedback` TAG did not exist and its wrappers
|
|
212
|
+
// still wrapped where RN clones; the tag landed, the responder node was already a parameter, and
|
|
213
|
+
// one caller became two.
|
|
214
|
+
const touchable = IS_ANDROID
|
|
215
|
+
? createPressBehavior(nativeFeedbackRefinement, buttonDisabled)
|
|
216
|
+
: createTouchableOpacityBehavior(buttonDisabled);
|
|
217
|
+
// ---- the owner's own payload -------------------------------------------------------------------
|
|
218
|
+
/**
|
|
219
|
+
* The wrapper-body folds, over the touchable's own. What is deliberately NOT here:
|
|
220
|
+
*
|
|
221
|
+
* accessible the touchable's fold already applies `accessible !== false`, which is
|
|
222
|
+
* exactly RN's split — Button forwards the caller's value RAW (Button.js:365)
|
|
223
|
+
* and the touchable one level down defaults it (TouchableOpacity.js:303).
|
|
224
|
+
* accessibilityState the engine's aria fold gives `ariaDisabled ?? state.disabled` and the press
|
|
225
|
+
* fold then merges `props.disabled` over it, which composes to RN's
|
|
226
|
+
* `props.disabled ?? aria ?? state.disabled` — the same value, with
|
|
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.
|
|
240
|
+
*/
|
|
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.
|
|
259
|
+
/**
|
|
260
|
+
* Builds the whole subtree, once, at `attachHostBehavior`.
|
|
261
|
+
*
|
|
262
|
+
* RETURNS THE RAW TEXT. RN's Button declares no `children` prop and renders none, so the slot is
|
|
263
|
+
* not where the app's children go — it is where its `title` goes, which is what `SLOT_PROPS`
|
|
264
|
+
* redirects onto it. `childHost` is also the GATE on both engine seams this behavior uses:
|
|
265
|
+
* `slotDerived`'s mark and the prop redirect are both skipped unless it is set (node.ts), so
|
|
266
|
+
* returning `undefined` would leave the whole subtree frozen at its mount values.
|
|
267
|
+
*/
|
|
268
|
+
function buildStructure(node) {
|
|
269
|
+
const textDescriptor = descriptorFor('text');
|
|
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
|
+
//
|
|
278
|
+
// Empty until the redirected `title` arrives. The commit walk drops an empty raw text
|
|
279
|
+
// (`isEmptyRawText`, node.ts), so no Fabric node exists for it until it has a label — and that
|
|
280
|
+
// check reads `props.text`, which the redirect writes, not the fold's uppercased output.
|
|
281
|
+
const label = createRawText('', BUTTON_LABEL_TAG);
|
|
282
|
+
// The hop `slotDerived` alone does not make: it marks the slot (the label), and this is past it.
|
|
283
|
+
addDerivedNode(node, text);
|
|
284
|
+
appendChild(text, label);
|
|
285
|
+
if (IS_ANDROID) {
|
|
286
|
+
// No fourth node: TNF clones onto the styled view, so the host IS it and the label's parent
|
|
287
|
+
// hangs straight off it.
|
|
288
|
+
appendChild(node, text);
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
const viewDescriptor = descriptorFor('view');
|
|
292
|
+
const view = createElement(viewDescriptor.component, viewDescriptor.isText, 'view');
|
|
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.
|
|
295
|
+
addDerivedNode(node, view);
|
|
296
|
+
appendChild(view, text);
|
|
297
|
+
// Lands on the owner, because `node.childHost` is still undefined here — the engine assigns it
|
|
298
|
+
// from what this returns. That ordering is why `buildStructure` RETURNS the slot instead of
|
|
299
|
+
// setting the field itself.
|
|
300
|
+
appendChild(node, view);
|
|
301
|
+
}
|
|
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`).
|
|
306
|
+
return label;
|
|
307
|
+
}
|
|
308
|
+
// `focusable` is a function of a LISTENER, and a listener flip changes no payload by itself — so
|
|
309
|
+
// the commit after it is a no-op and no fold re-runs (`IHostBehavior.onOwnedListenerChange`).
|
|
310
|
+
function onOwnedListenerChange(node, name) {
|
|
311
|
+
if (name !== 'press')
|
|
312
|
+
return;
|
|
313
|
+
markPropsDirty(node);
|
|
314
|
+
requestCommitFor(node);
|
|
315
|
+
}
|
|
316
|
+
// Idempotent: an adapter entry may be imported more than once in a bundle.
|
|
317
|
+
export function registerButtonBehavior() {
|
|
318
|
+
// `attach`/`detach` come from the touchable unwrapped: the internal nodes are ordinary children
|
|
319
|
+
// that leave with the sweep, and each carries only a pure fold, so this behavior owns no per-node
|
|
320
|
+
// runtime of its own to release.
|
|
321
|
+
//
|
|
322
|
+
const behavior = {
|
|
323
|
+
...touchable,
|
|
324
|
+
buildStructure,
|
|
325
|
+
onOwnedListenerChange,
|
|
326
|
+
slotProps: SLOT_PROPS,
|
|
327
|
+
slotDerived: SLOT_DERIVED,
|
|
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);
|
|
339
|
+
registerHostBehavior(BUTTON_TAG, behavior);
|
|
340
|
+
}
|