@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
|
@@ -14,10 +14,10 @@
|
|
|
14
14
|
// `registerPressableBehavior()`, and its entry does a bare `import './register';` that the barrel
|
|
15
15
|
// does NOT re-export. A bare `import './register';` sitting NEXT TO such a re-export does not work
|
|
16
16
|
// either: Babel merges the two imports of one specifier and the merged dependency stays lazy.
|
|
17
|
-
import { appListenerFor, dlog, registerHostBehavior, requestCommitFor, setBehaviorListener, setNodePressed, } from '@symbiote-native/engine';
|
|
18
|
-
import { createPressHandlers, createPressRuntime, disposePressRuntime, DEFAULT_DELAY_LONG_PRESS_MS,
|
|
19
|
-
import { buildPressableListeners
|
|
20
|
-
export const PRESSABLE_TAG = '
|
|
17
|
+
import { appListenerFor, dlog, propOf, registerHostBehavior, requestCommitFor, setBehaviorListener, setNodePressed, propsOf, } from '@symbiote-native/engine';
|
|
18
|
+
import { createPressHandlers, createPressRuntime, disposePressRuntime, DEFAULT_DELAY_LONG_PRESS_MS, DEFAULT_MIN_PRESS_DURATION_MS, } from '../state/pressable.js';
|
|
19
|
+
import { buildPressableListeners } from '../view/render-pressable/index.js';
|
|
20
|
+
export const PRESSABLE_TAG = 'pressable';
|
|
21
21
|
const states = new WeakMap();
|
|
22
22
|
function isRecord(value) {
|
|
23
23
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
@@ -25,6 +25,12 @@ function isRecord(value) {
|
|
|
25
25
|
function numberOr(value, fallback) {
|
|
26
26
|
return typeof value === 'number' ? value : fallback;
|
|
27
27
|
}
|
|
28
|
+
// The bag arrives as `unknown` off `node.props`; every RN default below is written against
|
|
29
|
+
// `boolean | undefined`. Exported to the sibling behaviors folding the same bag, and deliberately
|
|
30
|
+
// NOT to the shared barrel — same reasoning as `asAccessibilityState`.
|
|
31
|
+
export function booleanOr(value) {
|
|
32
|
+
return typeof value === 'boolean' ? value : undefined;
|
|
33
|
+
}
|
|
28
34
|
// A scalar offset or the per-edge object; anything else reads as "no offset", which the machine
|
|
29
35
|
// turns into RN's defaults. Same narrowing the component path does — kept here rather than shared
|
|
30
36
|
// because the component's version narrows Vue attrs, and this one narrows engine props.
|
|
@@ -50,27 +56,11 @@ function asRectOffset(value) {
|
|
|
50
56
|
function isPressHandler(value) {
|
|
51
57
|
return typeof value === 'function';
|
|
52
58
|
}
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
// Consumed below and replaced by the resolved `nativeBackgroundAndroid` /
|
|
59
|
-
// `nativeForegroundAndroid`; the raw config is not a native prop.
|
|
60
|
-
'android_ripple',
|
|
61
|
-
'disabled',
|
|
62
|
-
'cancelable',
|
|
63
|
-
'delayLongPress',
|
|
64
|
-
'unstable_pressDelay',
|
|
65
|
-
'pressRetentionOffset',
|
|
66
|
-
'delayHoverIn',
|
|
67
|
-
'delayHoverOut',
|
|
68
|
-
];
|
|
69
|
-
// Narrowed field by field rather than cast: the bag arrives as `unknown` off `node.props`. A local
|
|
70
|
-
// twin of the guard each adapter keeps for its own attrs (Vue's `asAccessibilityState`) — not
|
|
71
|
-
// hoisted to the shared barrel, because every adapter re-exports that barrel wholesale and a
|
|
72
|
-
// narrowing helper is not API anyone should be able to import.
|
|
73
|
-
function asAccessibilityState(value) {
|
|
59
|
+
// Narrowed field by field rather than cast: the bag arrives as `unknown` off `propOf`. A local
|
|
60
|
+
// twin of the guard each adapter keeps for its own attrs (Vue's `asAccessibilityState`) — exported
|
|
61
|
+
// to the sibling behaviors that fold the same bag, and deliberately NOT to the shared barrel, which
|
|
62
|
+
// every adapter re-exports wholesale: a narrowing helper is not API anyone should be able to import.
|
|
63
|
+
export function asAccessibilityState(value) {
|
|
74
64
|
if (!isRecord(value))
|
|
75
65
|
return undefined;
|
|
76
66
|
const state = {};
|
|
@@ -86,99 +76,118 @@ function asAccessibilityState(value) {
|
|
|
86
76
|
state.expanded = value.expanded;
|
|
87
77
|
return state;
|
|
88
78
|
}
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
if (typeof value.foreground === 'boolean')
|
|
102
|
-
config.foreground = value.foreground;
|
|
103
|
-
return config;
|
|
104
|
-
}
|
|
105
|
-
// `disabled` reaches a screen reader ONLY as `accessibilityState.disabled` — it is not a native
|
|
106
|
-
// View prop, so the wrapper folds it (`resolveDisabledAccessibilityState`, called by all five) and
|
|
107
|
-
// forwards the composite. Lowering dropped that fold: press suppression still worked, because the
|
|
108
|
-
// machine reads `node.props.disabled` directly, so the button behaved correctly and announced
|
|
109
|
-
// itself as enabled. An accessibility regression with no visual tell and no failing test.
|
|
79
|
+
// THE PAYLOAD FOLD MOVED TO THE ENGINE — `foldPressableProps` in `SymbioteFabricProps.cpp`, and
|
|
80
|
+
// its contract is `core/engine/cpp/tests/js/pressable-payload.itest.ts`. It is not re-implemented
|
|
81
|
+
// here in any form, which is the point: `disabled` -> `accessibilityState`, `accessible`/`focusable`
|
|
82
|
+
// defaulting on, the Android ripple config, and the nine machine-only keys being kept out of the
|
|
83
|
+
// payload are all functions of the TAG alone. That is user-agent behavior — RN does it for every
|
|
84
|
+
// Pressable in every app — and it belongs beside the tree, like a browser's `<button>`.
|
|
85
|
+
//
|
|
86
|
+
// It cost a trip: a `payloadFold` marshals the whole bag out and the whole bag back, ~17 us per
|
|
87
|
+
// pressable per commit, and a benchmark row carries two.
|
|
88
|
+
//
|
|
89
|
+
// What is still here is the MACHINE, which is where a browser keeps it too: timers, the responder
|
|
90
|
+
// claim, hit-slop retention, and the callbacks into app code.
|
|
110
91
|
//
|
|
111
|
-
//
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
// STILL MISSING ON BOTH PATHS, and lowering did not cause it: RN also dispatches
|
|
123
|
-
// `Commands.hotspotUpdate(x, y)` on pressIn/pressMove and `Commands.setPressed` on
|
|
124
|
-
// pressIn/pressOut, which is what makes the ripple originate at the touch point. Neither our
|
|
125
|
-
// wrapper nor this behavior sends them — grep for `hotspotUpdate` returns nothing in the tree.
|
|
126
|
-
const rippleConfig = asRippleConfig(props.android_ripple);
|
|
127
|
-
const ripple = rippleConfig !== undefined ? rippleProps(rippleConfig) : undefined;
|
|
128
|
-
const out = { ...props };
|
|
129
|
-
for (const key of MACHINE_ONLY_KEYS)
|
|
130
|
-
delete out[key];
|
|
131
|
-
if (ripple !== undefined)
|
|
132
|
-
Object.assign(out, ripple);
|
|
133
|
-
// Written only when the fold produced something: an unconditional assignment would put an
|
|
134
|
-
// `accessibilityState: undefined` key on every lowered Pressable in the tree, and `fabricProps`
|
|
135
|
-
// skipping undefined is a coincidence to lean on, not a contract to rely on here.
|
|
136
|
-
if (resolved !== undefined)
|
|
137
|
-
out.accessibilityState = resolved;
|
|
138
|
-
return out;
|
|
92
|
+
// The one thing that did NOT move with it is `hitSlop`, and that is deliberate — it is a real
|
|
93
|
+
// native View prop, so it never was part of the fold.
|
|
94
|
+
// RN makes every pressable accessible unless the app opts OUT — `Pressable.js:252`
|
|
95
|
+
// (`accessible: accessible !== false`), and the whole Touchable family repeats it verbatim
|
|
96
|
+
// (`TouchableOpacity.js:303`, `TouchableHighlight.js:337`). `!== false` rather than `?? true`, so
|
|
97
|
+
// only a literal `false` opts out and an explicit `undefined` still reads as accessible.
|
|
98
|
+
//
|
|
99
|
+
// Nothing in this repo did it until 2026-09-09, so a Pressable reached a screen reader as a plain
|
|
100
|
+
// view unless the app wrote the prop. Exported so anything composing this tag can say it too.
|
|
101
|
+
export function accessibleUnlessOptedOut(props) {
|
|
102
|
+
return props.accessible !== false;
|
|
139
103
|
}
|
|
140
|
-
// From the STASH, not from
|
|
104
|
+
// From the STASH, not from the props. Every name below is in `ownedListeners`, so `routeProp`
|
|
141
105
|
// diverts the app's `onPress` away from `node.listeners` (where it would evict the behavior's own
|
|
142
106
|
// dispatcher) and into the stash — which makes the stash the only place it exists. Reading
|
|
143
|
-
// `
|
|
107
|
+
// `propOf` here returns undefined for every callback and every press silently does nothing:
|
|
144
108
|
// the behavior runs, the machine runs, and it calls nobody.
|
|
145
109
|
function callbackAt(node, event) {
|
|
146
110
|
const value = appListenerFor(node, event);
|
|
147
111
|
return isPressHandler(value) ? value : undefined;
|
|
148
112
|
}
|
|
149
|
-
// Callbacks come from `callbackAt` (the stash), scalars from `
|
|
113
|
+
// Callbacks come from `callbackAt` (the stash), scalars from `propOf`. The split is not
|
|
150
114
|
// cosmetic: `delayLongPress` and `hitSlop` are ordinary props that `fabricProps` drops as unknown
|
|
151
|
-
// keys, while `onPress` and friends are OWNED event names that never reach
|
|
115
|
+
// keys, while `onPress` and friends are OWNED event names that never reach the props at all.
|
|
152
116
|
function configFor(node) {
|
|
117
|
+
const unstablePressDelay = numberOr(propOf(node, 'unstable_pressDelay'), 0);
|
|
153
118
|
return {
|
|
154
119
|
onPress: callbackAt(node, 'press'),
|
|
155
120
|
onPressIn: callbackAt(node, 'pressIn'),
|
|
156
121
|
onPressOut: callbackAt(node, 'pressOut'),
|
|
157
122
|
onPressMove: callbackAt(node, 'pressMove'),
|
|
158
123
|
onLongPress: callbackAt(node, 'longPress'),
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
124
|
+
// Pressability.js:471-474 — `normalizeDelay(authored, 10, DEFAULT_LONG_PRESS_DELAY_MS -
|
|
125
|
+
// delayPressIn)`. The subtraction applies only to the FALLBACK, never to an authored value —
|
|
126
|
+
// it exists so the long-press threshold, timed from the grant that also arms
|
|
127
|
+
// `unstable_pressDelay`, lands at a constant 500ms from touch-down by default, not
|
|
128
|
+
// 500ms + unstable_pressDelay. See `createPressHandlers`'s `handlePressIn` for the other half
|
|
129
|
+
// (the timer must be ARMED at grant, not after the pressDelay fires, or this compensation
|
|
130
|
+
// does nothing).
|
|
131
|
+
delayLongPress: Math.max(10, numberOr(propOf(node, 'delayLongPress'), DEFAULT_DELAY_LONG_PRESS_MS - unstablePressDelay)),
|
|
132
|
+
unstable_pressDelay: unstablePressDelay,
|
|
133
|
+
// RN's Touchables own the deactivation floor in their OWN machine and hand Pressability
|
|
134
|
+
// `minPressDuration: 0` (TouchableOpacity.js:195). While they were wrappers they passed it as
|
|
135
|
+
// an internal input; on the tag there is nowhere else to say it, so the floor has to be a
|
|
136
|
+
// readable prop or every Touchable holds its fade for the machine's 130 ms default.
|
|
137
|
+
minPressDuration: numberOr(propOf(node, 'minPressDuration'), DEFAULT_MIN_PRESS_DURATION_MS),
|
|
138
|
+
hitSlop: asRectOffset(propOf(node, 'hitSlop')),
|
|
139
|
+
pressRetentionOffset: asRectOffset(propOf(node, 'pressRetentionOffset')),
|
|
140
|
+
// Pressable.js's own name is `android_disableSound`; every composed touchable (Highlight,
|
|
141
|
+
// NativeFeedback, WithoutFeedback, Button) instead forwards vendor's `touchSoundDisabled` to
|
|
142
|
+
// this same config field (`TouchableHighlight.js:205`, `TouchableWithoutFeedback.js:199`,
|
|
143
|
+
// `TouchableNativeFeedback.js:228`). `node` here is whichever tag AUTHORS the prop — itself for
|
|
144
|
+
// a plain pressable/highlight/button, the owner for a clone-onto-child touchable, since
|
|
145
|
+
// `configFor` is always called with that source — so reading both names off it resolves
|
|
146
|
+
// correctly for every composition without a per-tag override.
|
|
147
|
+
android_disableSound: booleanOr(propOf(node, 'android_disableSound')) ??
|
|
148
|
+
booleanOr(propOf(node, 'touchSoundDisabled')),
|
|
163
149
|
};
|
|
164
150
|
}
|
|
165
151
|
// Rebuilding at GESTURE START is the whole reason for the dispatcher indirection, and skipping it
|
|
166
152
|
// is a bug that looks like working code. `attach` runs inside `createElement`, before a single
|
|
167
|
-
// prop has been routed —
|
|
153
|
+
// prop has been routed — the node holds nothing at all there — so a machine built at attach would
|
|
168
154
|
// capture no `onPress` at all and every press would silently do nothing. `createPressHandlers`
|
|
169
155
|
// destructures its config eagerly, so it cannot be handed a live view either; it has to be re-made
|
|
170
156
|
// once the props exist. A gesture is one interaction, so a handful of closures per press is
|
|
171
157
|
// invisible — unlike doing it per prop write, which is the cost this whole tier exists to remove.
|
|
172
158
|
function rebuild(node, state) {
|
|
173
|
-
|
|
159
|
+
// `state.source` for everything READ, `node` for the refinement, which acts on the responder
|
|
160
|
+
// (dispatching a view command needs the committed node, not the one holding the props).
|
|
161
|
+
const source = state.source;
|
|
162
|
+
const base = configFor(source);
|
|
163
|
+
const handlers = createPressHandlers(state.refine === undefined ? base : state.refine(node, base), state.runtime, state.host);
|
|
174
164
|
state.isBuilt = true;
|
|
165
|
+
// Re-read every gesture, so a tag whose resolver looks past `disabled` — `./button`, at
|
|
166
|
+
// `aria-disabled` — re-enables on the next touch instead of latching at its first answer.
|
|
167
|
+
const sourceProps = propsOf(source);
|
|
168
|
+
const disabled = state.disabledOf === undefined
|
|
169
|
+
? sourceProps.disabled
|
|
170
|
+
: state.disabledOf(sourceProps);
|
|
175
171
|
state.listeners = buildPressableListeners(handlers, {
|
|
176
|
-
disabled:
|
|
177
|
-
cancelable:
|
|
178
|
-
|
|
179
|
-
: undefined,
|
|
172
|
+
disabled: disabled === true ? true : undefined,
|
|
173
|
+
cancelable: resolveCancelable(source),
|
|
174
|
+
blockNativeResponder: propOf(source, 'blockNativeResponder') === true,
|
|
180
175
|
});
|
|
181
176
|
}
|
|
177
|
+
// Pressable is the only member of the family with a `cancelable` prop of its own (`Pressable.js:41`).
|
|
178
|
+
// TouchableOpacity/TouchableHighlight/TouchableNativeFeedback instead expose `rejectResponderTermination`
|
|
179
|
+
// and derive `cancelable: !this.props.rejectResponderTermination` internally
|
|
180
|
+
// (`TouchableOpacity.js:186`, `TouchableHighlight.js:194`, `TouchableNativeFeedback.js:217`) — every
|
|
181
|
+
// composed touchable shares this `rebuild()`, so without this the authored name never reached the
|
|
182
|
+
// machine and every Touchable silently kept the RN native default (yield the responder) regardless of
|
|
183
|
+
// what the app asked for. An explicit `cancelable` still wins, matching Pressable's own precedence.
|
|
184
|
+
function resolveCancelable(source) {
|
|
185
|
+
const cancelable = propOf(source, 'cancelable');
|
|
186
|
+
if (typeof cancelable === 'boolean')
|
|
187
|
+
return cancelable;
|
|
188
|
+
const reject = propOf(source, 'rejectResponderTermination');
|
|
189
|
+
return typeof reject === 'boolean' ? !reject : undefined;
|
|
190
|
+
}
|
|
182
191
|
// WHICHEVER EVENT OPENS THE GESTURE REBUILDS, and pinning that to one name was a real bug.
|
|
183
192
|
// `onStartShouldSetResponder` looks like the opener and is not: `core/engine/src/events/index.ts`
|
|
184
193
|
// bubbles PRESS_IN and only THEN calls `negotiateResponder`, so `pressIn` arrives FIRST on every
|
|
@@ -222,13 +231,31 @@ const KEY_BY_EVENT = new Map([
|
|
|
222
231
|
['startShouldSetResponder', 'onStartShouldSetResponder'],
|
|
223
232
|
['responderMove', 'onResponderMove'],
|
|
224
233
|
['responderTerminationRequest', 'onResponderTerminationRequest'],
|
|
234
|
+
['responderGrant', 'onResponderGrant'],
|
|
225
235
|
]);
|
|
226
236
|
function installListeners(node, state) {
|
|
227
237
|
for (const [event, key] of KEY_BY_EVENT) {
|
|
228
238
|
setBehaviorListener(node, event, symbioteEvent => dispatch(node, state, key, [symbioteEvent]));
|
|
229
239
|
}
|
|
230
240
|
}
|
|
231
|
-
function
|
|
241
|
+
function attachWith(refine, disabledOf) {
|
|
242
|
+
return node => attach(node, { refine, disabledOf });
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* The machine on `node`, reading its props and the app's callbacks off `options.source` when that
|
|
246
|
+
* is a different node.
|
|
247
|
+
*
|
|
248
|
+
* Exported for a behavior whose responder is not its own node — `./touchable-native-feedback`,
|
|
249
|
+
* whose tag commits nothing and adopts the app's single child as the responder. Every other caller
|
|
250
|
+
* goes through `createPressBehavior`, where source and node are the same.
|
|
251
|
+
*
|
|
252
|
+
* Re-callable on the same node: a second call replaces the state and the dispatchers, which is what
|
|
253
|
+
* a re-arm after `detachPressMachine` needs.
|
|
254
|
+
*/
|
|
255
|
+
export function attachPressMachine(node, options = {}) {
|
|
256
|
+
attach(node, options);
|
|
257
|
+
}
|
|
258
|
+
function attach(node, options) {
|
|
232
259
|
const timers = new Set();
|
|
233
260
|
const runtime = createPressRuntime();
|
|
234
261
|
const host = {
|
|
@@ -264,6 +291,9 @@ function attach(node) {
|
|
|
264
291
|
const state = {
|
|
265
292
|
runtime,
|
|
266
293
|
host,
|
|
294
|
+
refine: options.refine,
|
|
295
|
+
disabledOf: options.disabledOf,
|
|
296
|
+
source: options.source ?? node,
|
|
267
297
|
timers,
|
|
268
298
|
listeners: {},
|
|
269
299
|
isBuilt: false,
|
|
@@ -271,6 +301,10 @@ function attach(node) {
|
|
|
271
301
|
states.set(node, state);
|
|
272
302
|
installListeners(node, state);
|
|
273
303
|
}
|
|
304
|
+
/** See `attachPressMachine`: the same teardown `createPressBehavior` registers as its `detach`. */
|
|
305
|
+
export function detachPressMachine(node) {
|
|
306
|
+
detach(node);
|
|
307
|
+
}
|
|
274
308
|
function detach(node) {
|
|
275
309
|
const state = states.get(node);
|
|
276
310
|
if (state === undefined)
|
|
@@ -286,13 +320,19 @@ function detach(node) {
|
|
|
286
320
|
states.delete(node);
|
|
287
321
|
dlog('pressable behavior detached');
|
|
288
322
|
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
323
|
+
/**
|
|
324
|
+
* The press machine as behavior parts, so a tag that is a pressable PLUS something can compose it
|
|
325
|
+
* instead of re-implementing it.
|
|
326
|
+
*
|
|
327
|
+
* Spread into the caller's own behavior and wrap `attach`/`detach` around these — the touchable
|
|
328
|
+
* family needs a per-node Animated value opened before the machine and closed after it. The
|
|
329
|
+
* WeakMap holding the machine's own state is keyed by node, so one node may hold exactly one of
|
|
330
|
+
* these; a tag composing it therefore must not also register the plain `pressable` behavior.
|
|
331
|
+
*/
|
|
332
|
+
export function createPressBehavior(refine, disabledOf) {
|
|
333
|
+
return {
|
|
334
|
+
attach: attachWith(refine, disabledOf),
|
|
294
335
|
detach,
|
|
295
|
-
foldPayload,
|
|
296
336
|
// Every name the machine needs as an INPUT. The responder pair is not optional — it is how a
|
|
297
337
|
// gesture starts at all, and `RESPONDER_EVENTS` makes those listeners on any node regardless
|
|
298
338
|
// of ViewConfig, so they collide exactly like `press` does.
|
|
@@ -305,6 +345,12 @@ export function registerPressableBehavior() {
|
|
|
305
345
|
'startShouldSetResponder',
|
|
306
346
|
'responderMove',
|
|
307
347
|
'responderTerminationRequest',
|
|
348
|
+
'responderGrant',
|
|
308
349
|
],
|
|
309
|
-
}
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
// Idempotent: an adapter entry may be imported more than once in a bundle, and re-registering the
|
|
353
|
+
// same tag with an equivalent behavior must not double-install anything.
|
|
354
|
+
export function registerPressableBehavior() {
|
|
355
|
+
registerHostBehavior(PRESSABLE_TAG, createPressBehavior());
|
|
310
356
|
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// RefreshControl's machine, on the engine node instead of inside a framework component.
|
|
2
|
+
//
|
|
3
|
+
// WHAT THE FIVE WRAPPERS ACTUALLY DO, counted before writing this — the audit rule's instruction to
|
|
4
|
+
// grep the fold's OUTPUT rather than trust one wrapper. Four of the five (react, vue, solid,
|
|
5
|
+
// svelte) fold exactly `resolveAccessibilityProps` and forward, and that fold ALREADY runs in the
|
|
6
|
+
// engine at `fabricProps` on every path (the aria fold `SafeAreaView`'s spec entry cites). So there
|
|
7
|
+
// is nothing left for a `foldPayload` here to do, and this behavior deliberately declares none.
|
|
8
|
+
//
|
|
9
|
+
// The fifth is Angular, and it is the whole reason this file exists: it alone reproduces RN's
|
|
10
|
+
// CONTROLLED handshake (`RefreshControl.js:145-166`) — mirror what native last reported, and when
|
|
11
|
+
// the app's `refreshing` disagrees, command native back with `setNativeRefreshing`. React, Vue,
|
|
12
|
+
// Solid and Svelte have never had it, so a pull whose handler leaves `refreshing` false spins
|
|
13
|
+
// forever on four of five adapters. Moving it here closes that as a P0 parity gap rather than
|
|
14
|
+
// porting it four more times.
|
|
15
|
+
//
|
|
16
|
+
// ONE COMMAND NAME ON BOTH PLATFORMS — no `Platform.OS` branch, unlike Switch's snap-back
|
|
17
|
+
// (`setValue` / `setNativeValue`). RN sends `setNativeRefreshing` through both
|
|
18
|
+
// `PullToRefreshCommands` and `AndroidSwipeRefreshLayoutCommands` (`RefreshControl.js:152,157`).
|
|
19
|
+
//
|
|
20
|
+
// PLACEMENT IS NOT THIS FILE'S PROBLEM, though the tier audit once filed the primitive as
|
|
21
|
+
// impossible over it. iOS puts the control BESIDE the scroll view's content and Android makes it
|
|
22
|
+
// the scroll view's PARENT, and that decision belongs to the ScrollView, which now states it as
|
|
23
|
+
// data — `claimedChildren: { [REFRESH_CONTROL]: platform.claimMode }` in
|
|
24
|
+
// `behaviors/scroll-view/shared.ts`, honoured by the engine's `appendChild`. A claim is keyed on
|
|
25
|
+
// the child's FABRIC name and needs nothing from the child's own behavior, so the two are
|
|
26
|
+
// independent; `refresh-control-placement.test.ts` pins that both ways round.
|
|
27
|
+
//
|
|
28
|
+
// WHY THE DIVERGENCE CHECK IS DEFERRED A MICROTASK, and why `afterCommit` alone is not enough:
|
|
29
|
+
// `behaviors/switch.ts`'s module header, verbatim. Same shape, same two triggers, same reasons —
|
|
30
|
+
// an ACCEPTING app's state reaches `node.props` only after its own reconciliation, and a REJECTING
|
|
31
|
+
// app writes no prop at all, so the commit that `afterCommit` waits for never comes.
|
|
32
|
+
import { appListenerFor, dispatchViewCommand, dlog, registerHostBehavior, setBehaviorListener, propOf, } from '@symbiote-native/engine';
|
|
33
|
+
export const REFRESH_CONTROL_TAG = 'refresh-control';
|
|
34
|
+
// RN's own name for the command, sent to whichever of the two native views the platform resolved.
|
|
35
|
+
const SET_NATIVE_REFRESHING = 'setNativeRefreshing';
|
|
36
|
+
// What native LAST reported, absent until it has reported at all. Absent is not `false`: native is
|
|
37
|
+
// optimistic — it spins on the gesture before JS approves — so only a report can make the mirror
|
|
38
|
+
// authoritative, and an app that drives `refreshing` on its own initiative must never be corrected
|
|
39
|
+
// against a value native never claimed.
|
|
40
|
+
const reported = new WeakMap();
|
|
41
|
+
// TODO(rn-parity, low priority, efficiency not correctness): vendor's `componentDidUpdate`
|
|
42
|
+
// (`RefreshControl.js:139-158`) sends `setNativeRefreshing` only when `refreshing` did NOT change
|
|
43
|
+
// between renders, trusting the ordinary prop diff to carry a real change to native. Ours has no
|
|
44
|
+
// such guard — `evaluateSnapBack` fires the command on any disagreement with `reported`, even when
|
|
45
|
+
// this same commit already carries the authored `refreshing` change (accept-then-finish: app agrees
|
|
46
|
+
// on the gesture, then sets `refreshing: false` to end it — vendor sends zero commands, we send one
|
|
47
|
+
// extra). Both settle on the same native value, so this is a harmless redundant call, not a bug —
|
|
48
|
+
// and NOT a candidate for the same fix as the "repairs a snap-back the app contradicts on a later
|
|
49
|
+
// commit" test below, which relies on this exact unconditional re-check for a genuine cross-
|
|
50
|
+
// framework microtask race. A real fix needs tracking the last-seen authored value alongside
|
|
51
|
+
// `reported`, so a same-commit authored change can be told apart from native drifting on its own —
|
|
52
|
+
// not attempted here.
|
|
53
|
+
//
|
|
54
|
+
// Shared by both triggers — see the module header for why there are two.
|
|
55
|
+
function evaluateSnapBack(node) {
|
|
56
|
+
const lastNativeReport = reported.get(node);
|
|
57
|
+
if (lastNativeReport === undefined)
|
|
58
|
+
return; // no report yet, nothing to disagree with
|
|
59
|
+
const refreshing = propOf(node, 'refreshing') === true;
|
|
60
|
+
if (lastNativeReport === refreshing) {
|
|
61
|
+
dlog(`RefreshControl behavior snap-back no-op refreshing=${refreshing}`);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
dlog(`RefreshControl behavior ${SET_NATIVE_REFRESHING} reported=${lastNativeReport} refreshing=${refreshing}`);
|
|
65
|
+
dispatchViewCommand(node, SET_NATIVE_REFRESHING, [refreshing]);
|
|
66
|
+
reported.set(node, refreshing);
|
|
67
|
+
}
|
|
68
|
+
function onRefresh(node, event) {
|
|
69
|
+
// Native has already started spinning by the time this arrives (RefreshControl.js:180), so the
|
|
70
|
+
// mirror moves BEFORE the app's handler runs — a handler that flips `refreshing` to true then
|
|
71
|
+
// agrees with it, and one that does nothing is what the deferred check corrects.
|
|
72
|
+
reported.set(node, true);
|
|
73
|
+
const listener = appListenerFor(node, 'refresh');
|
|
74
|
+
if (typeof listener === 'function')
|
|
75
|
+
listener(event);
|
|
76
|
+
queueMicrotask(() => evaluateSnapBack(node));
|
|
77
|
+
}
|
|
78
|
+
function attach(node) {
|
|
79
|
+
setBehaviorListener(node, 'refresh', event => onRefresh(node, event));
|
|
80
|
+
}
|
|
81
|
+
function detach(node) {
|
|
82
|
+
reported.delete(node);
|
|
83
|
+
}
|
|
84
|
+
// Idempotent: an adapter entry may be imported more than once in a bundle, and re-registering the
|
|
85
|
+
// same tag with an equivalent behavior must not double-install anything.
|
|
86
|
+
export function registerRefreshControlBehavior() {
|
|
87
|
+
registerHostBehavior(REFRESH_CONTROL_TAG, {
|
|
88
|
+
attach,
|
|
89
|
+
// Closes the case a microtask scheduled from `onRefresh` cannot: the app moves `refreshing` on
|
|
90
|
+
// its own while a past report is still unresolved. Costs nothing extra — it fires only on a
|
|
91
|
+
// commit that already changed something.
|
|
92
|
+
afterCommit: evaluateSnapBack,
|
|
93
|
+
detach,
|
|
94
|
+
ownedListeners: ['refresh'],
|
|
95
|
+
});
|
|
96
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function registerScrollViewBehavior(): void;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// ScrollView's behavior on Android, where a RefreshControl is not a child at all.
|
|
2
|
+
//
|
|
3
|
+
// An Android ScrollView holds exactly ONE child, so a sibling refresh control is an `addViewAt`
|
|
4
|
+
// crash rather than a layout mistake. RN inverts the tree instead: `AndroidSwipeRefreshLayout`
|
|
5
|
+
// WRAPS the scroll view, and the scroll view's style is split across the two boxes — layout on the
|
|
6
|
+
// wrapper's frame, visual on the scroller (`ScrollView.js:1856`). `nestedScrollEnabled` goes on the
|
|
7
|
+
// inner view so it consumes the gesture before the refresh parent sees it.
|
|
8
|
+
//
|
|
9
|
+
// THIS FILE IS A CLAIM MODE AND NOTHING ELSE NOW (2026-09-18). It carried two `payloadFold`s until
|
|
10
|
+
// then — the last two folds in ScrollView and the last structural blocker in the behavior
|
|
11
|
+
// migration — and both are `SymbioteFabricProps.cpp` now: `foldScrollViewProps` takes the visual
|
|
12
|
+
// half when its parent is a refresh control, and `foldRefreshWrapperProps` takes the layout half of
|
|
13
|
+
// the child it wraps.
|
|
14
|
+
//
|
|
15
|
+
// WHY THEY RESISTED THREE ITERATIONS, and what changed. Every seam the engine had read UP —
|
|
16
|
+
// `ownerProps`, `IOwner.tagName`, `IAncestorLookup` — and the wrapper is the scroll view's PARENT
|
|
17
|
+
// asking for the scroll view's style, the one direction none of them go. `IFirstChild` is that
|
|
18
|
+
// direction, and it is the same argument `ownerProps` already made rather than a new one: the tree
|
|
19
|
+
// lives in C++, so reading another node is a pointer hop, and RN itself builds this parent FROM its
|
|
20
|
+
// child (`cloneElement(refreshControl, {style: outer}, scrollView)`).
|
|
21
|
+
//
|
|
22
|
+
// `slotDerived: ['style']` STAYS AND IS NOW LOAD-BEARING FOR THE ENGINE'S RULE. A rule re-runs when
|
|
23
|
+
// ITS node is dirty; the wrapper derives from a node that is not itself, so an owner style write
|
|
24
|
+
// must mark it (`routeProp`'s `node.wrapper` branch). Without this entry the wrapper freezes at its
|
|
25
|
+
// mount frame while the scroller visibly restyles inside it —
|
|
26
|
+
// `core/engine/cpp/tests/js/scroll-view-wrap-payload.itest.ts` is the case that says so.
|
|
27
|
+
//
|
|
28
|
+
// AND NOTHING HERE IS `#ifdef ANDROID` OR `Platform.OS`, on either side: the engine's rules are
|
|
29
|
+
// gated on TOPOLOGY. iOS claims the refresh control BESIDE the content, so a scroll view is never
|
|
30
|
+
// one's child there and neither branch can fire. That is strictly better than a compile-time split
|
|
31
|
+
// for the reason `Switch`/`AndroidSwitch` already showed — and it is why the wrap's payload fixture
|
|
32
|
+
// runs on the ORDINARY test host rather than needing the Android arm.
|
|
33
|
+
import { registerScrollViewBehaviors } from './shared.js';
|
|
34
|
+
const android = {
|
|
35
|
+
claimMode: 'wrap',
|
|
36
|
+
slotDerived: ['style'],
|
|
37
|
+
};
|
|
38
|
+
export function registerScrollViewBehavior() {
|
|
39
|
+
registerScrollViewBehaviors(android);
|
|
40
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function registerScrollViewBehavior(): void;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// ScrollView's behavior on iOS: a RefreshControl is a SIBLING of the content view, rendered before
|
|
2
|
+
// it (`ScrollView.js:1844`). That is the whole platform half — a claim in `beside` mode, and the
|
|
3
|
+
// engine's placement rule already puts a claimed child before the slot.
|
|
4
|
+
//
|
|
5
|
+
// This file is also the base the folder's `index.ts` re-exports for headless, matching
|
|
6
|
+
// `render-scroll-view`'s own choice to make iOS the default for anything platform-branched.
|
|
7
|
+
import { registerScrollViewBehaviors } from './shared.js';
|
|
8
|
+
export function registerScrollViewBehavior() {
|
|
9
|
+
registerScrollViewBehaviors({ claimMode: 'beside' });
|
|
10
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
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 for the same reason
|
|
3
|
+
// `render-scroll-view`'s own `Platform.select` defaults to it.
|
|
4
|
+
export { registerScrollViewBehavior } from './index.ios.js';
|
|
5
|
+
export { HORIZONTAL_SCROLL_VIEW_TAG, REFRESH_CONTROL, SCROLL_VIEW_TAG, } from './shared.js';
|
|
6
|
+
// The tag, so a test can locate a committed sticky wrapper by what the engine was TOLD rather than
|
|
7
|
+
// by a key its tag rule writes — see `ILiveNode.tagName`.
|
|
8
|
+
export { STICKY_HEADER_TAG } from './sticky.js';
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { type ISymbioteNode } from '@symbiote-native/engine';
|
|
2
|
+
export declare function markScrollObserved(owner: ISymbioteNode): void;
|
|
3
|
+
export declare const RESPONDER_OWNED_LISTENERS: readonly string[];
|
|
4
|
+
export declare function installResponderPredicates(owner: ISymbioteNode): void;
|