@symbiote-native/components 3.1.1 → 3.1.3
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/build/behaviors/activity-indicator/shared.js +29 -71
- package/build/behaviors/button.d.ts +0 -9
- package/build/behaviors/button.js +47 -233
- package/build/behaviors/image-background.js +21 -81
- package/build/behaviors/image.js +3 -10
- package/build/behaviors/input-accessory-view.js +10 -51
- package/build/behaviors/pressable.d.ts +5 -51
- package/build/behaviors/pressable.js +101 -161
- package/build/behaviors/refresh-control.js +8 -2
- package/build/behaviors/scroll-view/index.android.js +10 -30
- package/build/behaviors/scroll-view/responder.d.ts +3 -2
- package/build/behaviors/scroll-view/responder.js +19 -21
- package/build/behaviors/scroll-view/shared.js +69 -185
- package/build/behaviors/scroll-view/sticky.d.ts +0 -8
- package/build/behaviors/scroll-view/sticky.js +54 -142
- package/build/behaviors/switch.js +18 -4
- package/build/behaviors/text-input.d.ts +0 -8
- package/build/behaviors/text-input.js +77 -162
- package/build/behaviors/touchable-highlight.js +14 -54
- package/build/behaviors/touchable-native-feedback.js +9 -32
- package/build/behaviors/touchable-opacity.d.ts +0 -7
- package/build/behaviors/touchable-opacity.js +65 -81
- package/build/behaviors/touchable-without-feedback.js +25 -41
- package/build/component-names/index.android.js +6 -8
- package/build/component-names/shared.js +12 -42
- package/build/index.js +13 -19
- package/build/scroll-view-commands.js +3 -11
- package/build/state/pressable.js +18 -43
- package/build/state/sticky-header-reducer.js +103 -149
- package/build/state/touchable.js +3 -5
- package/build/state/virtualized-list-reducer.js +21 -48
- package/build/state/virtualized-list.js +63 -148
- package/build/text-props.js +3 -13
- package/build/view/render-button.js +13 -44
- package/build/view/render-input-accessory-view.js +8 -24
- package/build/view/render-pressable/index.js +3 -4
- package/build/view/render-scroll-view.js +13 -23
- package/build/view/render-touchable-native-feedback.js +5 -14
- package/host-primitives.cjs +33 -207
- package/package.json +3 -3
|
@@ -25,12 +25,32 @@
|
|
|
25
25
|
// event props. Deliberately not implemented — dead on every device this project targets (no tvOS
|
|
26
26
|
// build, no example app; iOS/Android are the only targets). Audit skill, "Found, NOT fixed: TV
|
|
27
27
|
// (Platform.isTV) focus/blur feedback on TouchableOpacity/Highlight".
|
|
28
|
-
import { AnimatedMock, AnimatedValue, Easing, Platform, dlog, getExplicitStyle, markPropsDirty, registerHostBehavior, requestCommitFor, setAnimatedBehaviorStyle, timing, propOf, propsOf, } from '@symbiote-native/engine';
|
|
28
|
+
import { AnimatedMock, AnimatedValue, Easing, Platform, dlog, getExplicitStyle, markPropsDirty, registerHostBehavior, requestCommitFor, setAnimatedBehaviorStyle, setProp, timing, propOf, propsOf, } from '@symbiote-native/engine';
|
|
29
29
|
import { resolveButtonDisabled } from '../view/render-button.js';
|
|
30
30
|
import { asAccessibilityState, booleanOr, createPressBehavior, } from './pressable.js';
|
|
31
|
-
import { createTouchableFeedbackHandlers, createTouchableFeedbackRuntime, DEFAULT_ACTIVE_OPACITY, OPACITY_ACTIVE_DURATION_MS, OPACITY_ACTIVE_GRANT_DURATION_MS, OPACITY_INACTIVE_DURATION_MS,
|
|
31
|
+
import { createTouchableFeedbackHandlers, createTouchableFeedbackRuntime, DEFAULT_ACTIVE_OPACITY, OPACITY_ACTIVE_DURATION_MS, OPACITY_ACTIVE_GRANT_DURATION_MS, OPACITY_INACTIVE_DURATION_MS, restingOpacityFromStyle, TOUCHABLE_MIN_PRESS_DURATION_MS, } from '../state/touchable.js';
|
|
32
32
|
export const TOUCHABLE_OPACITY_TAG = 'touchable-opacity';
|
|
33
33
|
const states = new WeakMap();
|
|
34
|
+
// Built on the FIRST FADE, not at attach: the three together read 1 343 B per node
|
|
35
|
+
// (`touchable-attach-cost.itest.ts`) and nothing reads them until a finger lands
|
|
36
|
+
function stateOf(node) {
|
|
37
|
+
const existing = states.get(node);
|
|
38
|
+
if (existing !== undefined)
|
|
39
|
+
return existing;
|
|
40
|
+
// Seeded at the CURRENT resting opacity, which the first `afterCommit` used to set
|
|
41
|
+
const state = {
|
|
42
|
+
opacity: new AnimatedValue(restingOpacityOf(node)),
|
|
43
|
+
runtime: createTouchableFeedbackRuntime(),
|
|
44
|
+
timers: new Set(),
|
|
45
|
+
isBound: false,
|
|
46
|
+
};
|
|
47
|
+
states.set(node, state);
|
|
48
|
+
return state;
|
|
49
|
+
}
|
|
50
|
+
// What the last commit settled at, apart from the state above т.к. `afterCommit` runs for every
|
|
51
|
+
// mounted node while that state is owed only by one that actually fades
|
|
52
|
+
// `undefined` is the mount, where RN re-settles nothing (it is `componentDidUpdate` only)
|
|
53
|
+
const settledAt = new WeakMap();
|
|
34
54
|
// `modules/animated`'s barrel swaps the whole driver namespace for the mock when the host reports
|
|
35
55
|
// reduced motion; the one driver used here is swapped on the same flag.
|
|
36
56
|
const startTiming = Platform.isDisableAnimations ? AnimatedMock.timing : timing;
|
|
@@ -46,7 +66,16 @@ function numberOr(value, fallback) {
|
|
|
46
66
|
function restingOpacityOf(node) {
|
|
47
67
|
return restingOpacityFromStyle(getExplicitStyle(node));
|
|
48
68
|
}
|
|
49
|
-
|
|
69
|
+
// Registered on FIRST FADE, not at attach: the leaf costs 9.8 us and 5.9 KB per node
|
|
70
|
+
// (`touchable-attach-cost.itest.ts`) and a list nobody presses pays it for a value that never moves
|
|
71
|
+
function ensureLayer(node, state) {
|
|
72
|
+
if (state.isBound)
|
|
73
|
+
return;
|
|
74
|
+
state.isBound = true;
|
|
75
|
+
setAnimatedBehaviorStyle(node, { opacity: state.opacity });
|
|
76
|
+
}
|
|
77
|
+
function fadeTo(node, state, toValue, ms) {
|
|
78
|
+
ensureLayer(node, state);
|
|
50
79
|
dlog(`touchable-opacity opacity -> ${toValue} over ${ms}ms`);
|
|
51
80
|
// useNativeDriver is RN's own (TouchableOpacity.js:242): opacity is natively drivable, so the
|
|
52
81
|
// fade survives a busy JS thread, which is the whole point of press feedback.
|
|
@@ -61,9 +90,7 @@ function fadeTo(state, toValue, ms) {
|
|
|
61
90
|
// hold when a finger lands, and the handlers it builds are discarded with the gesture. The runtime
|
|
62
91
|
// that must outlive one — an in-flight delayPressIn timer, the activation clock — is on the node.
|
|
63
92
|
const refine = (node, config) => {
|
|
64
|
-
const state =
|
|
65
|
-
if (state === undefined)
|
|
66
|
-
return config;
|
|
93
|
+
const state = stateOf(node);
|
|
67
94
|
const resting = restingOpacityOf(node);
|
|
68
95
|
const activeOpacity = numberOr(propOf(node, 'activeOpacity'), DEFAULT_ACTIVE_OPACITY);
|
|
69
96
|
const handlers = createTouchableFeedbackHandlers({
|
|
@@ -86,18 +113,16 @@ const refine = (node, config) => {
|
|
|
86
113
|
now: Date.now,
|
|
87
114
|
}, state.runtime, {
|
|
88
115
|
activate(event) {
|
|
89
|
-
// TouchableOpacity.js:215-220 picks the duration from WHERE the press-in came from:
|
|
90
|
-
//
|
|
91
|
-
// a drift-out/drift-back-in reactivation arrives as 'responderMove'
|
|
92
|
-
// (`state/pressable.ts`'s `handleResponderMove`) and gets the 150ms ease-back-in instead.
|
|
116
|
+
// TouchableOpacity.js:215-220 picks the duration from WHERE the press-in came from: a
|
|
117
|
+
// plain grant fades instantly, a drift-back-in arrives as `responderMove` and eases
|
|
93
118
|
const duration = event.type === 'responderMove'
|
|
94
119
|
? OPACITY_ACTIVE_DURATION_MS
|
|
95
120
|
: OPACITY_ACTIVE_GRANT_DURATION_MS;
|
|
96
|
-
fadeTo(state, activeOpacity, duration);
|
|
121
|
+
fadeTo(node, state, activeOpacity, duration);
|
|
97
122
|
config.onPressIn?.(event);
|
|
98
123
|
},
|
|
99
124
|
deactivate(event) {
|
|
100
|
-
fadeTo(state, resting, OPACITY_INACTIVE_DURATION_MS);
|
|
125
|
+
fadeTo(node, state, resting, OPACITY_INACTIVE_DURATION_MS);
|
|
101
126
|
config.onPressOut?.(event);
|
|
102
127
|
},
|
|
103
128
|
});
|
|
@@ -115,13 +140,9 @@ const refine = (node, config) => {
|
|
|
115
140
|
// Without it, a caller who sets only `accessibilityState={{disabled: true}}` (no `disabled` prop)
|
|
116
141
|
// gets the greyed-out RN look but a press that still fires here — RN suppresses it.
|
|
117
142
|
const touchableOpacityDisabled = props => resolveButtonDisabled(booleanOr(props.disabled), booleanOr(props['aria-disabled']), asAccessibilityState(props.accessibilityState));
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
*
|
|
122
|
-
* Safe to hand to two tags: every piece of runtime is keyed by NODE (`states`), and `press` is
|
|
123
|
-
* itself already shared that way.
|
|
124
|
-
*/
|
|
143
|
+
// The behavior as PARTS, so a tag that is a TouchableOpacity plus something composes the fade
|
|
144
|
+
// instead of re-implementing it (`button` is exactly that, Button.js:283)
|
|
145
|
+
// Safe to hand to two tags: every piece of runtime is keyed by NODE, `press` included
|
|
125
146
|
export function createTouchableOpacityBehavior(disabledOf) {
|
|
126
147
|
// Its own machine rather than the module-level `press`, so a composing tag can say what
|
|
127
148
|
// `disabled` MEANS to it — the answer feeds the machine, not a payload: the props half of that
|
|
@@ -130,95 +151,58 @@ export function createTouchableOpacityBehavior(disabledOf) {
|
|
|
130
151
|
return {
|
|
131
152
|
...machine,
|
|
132
153
|
attach(node) {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
timers: new Set(),
|
|
137
|
-
settled: undefined,
|
|
138
|
-
};
|
|
139
|
-
states.set(node, state);
|
|
140
|
-
// Bound at ATTACH rather than at the first press, matching RN: the binding is what forces
|
|
141
|
-
// `collapsable: false`, and a view Fabric un-flattens mid-gesture would lose the responder.
|
|
142
|
-
// Nothing is published until a fade runs — the leaf writes only when its value moves.
|
|
143
|
-
setAnimatedBehaviorStyle(node, { opacity: state.opacity });
|
|
154
|
+
// The half of the binding that CANNOT wait for the first press: a view Fabric flattens
|
|
155
|
+
// mid-gesture loses the tag the responder lands on. Everything else follows in `stateOf`
|
|
156
|
+
setProp(node, 'collapsable', false);
|
|
144
157
|
machine.attach(node);
|
|
145
158
|
},
|
|
146
159
|
// RN's componentDidUpdate: a changed `disabled` or a changed style opacity re-settles the view,
|
|
147
160
|
// so a Touchable disabled mid-press does not stay stuck at its active opacity.
|
|
148
161
|
afterCommit(node) {
|
|
149
|
-
const state = states.get(node);
|
|
150
|
-
if (state === undefined)
|
|
151
|
-
return;
|
|
152
162
|
const resting = restingOpacityOf(node);
|
|
153
|
-
// Through the SAME resolver the press machine uses, not the raw prop: on
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
// stayed at its active opacity while the press was already suppressed. RN's Button has no
|
|
157
|
-
// such gap because it passes the resolved value DOWN as its touchable's prop.
|
|
163
|
+
// Through the SAME resolver the press machine uses, not the raw prop: on `button` `disabled`
|
|
164
|
+
// also answers to `aria-disabled` / `accessibilityState` (Button.js:331,337), and reading the
|
|
165
|
+
// prop alone left an aria-only flip un-settled at its active opacity
|
|
158
166
|
const props = propsOf(node);
|
|
159
167
|
const disabled = disabledOf?.(props) ?? props.disabled;
|
|
160
|
-
const previous =
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
// TouchableOpacity commits no `opacity` key at all. Vendor's native-animated leaf is a
|
|
166
|
-
// separate channel from the ordinary style diff, so forcing non-flattening never implies
|
|
167
|
-
// publishing a value. Ours publishes anyway: `setAnimatedBehaviorStyle`/`bindAnimatedValue`
|
|
168
|
-
// (`host-binding.ts`) force `collapsable: false` and resolve+write the leaf's value in the
|
|
169
|
-
// SAME call. Untangling that is a change to shared animation infrastructure, not a one-line
|
|
170
|
-
// fix here — deferred, see the audit skill. Set rather than animate: this is the mount, and
|
|
171
|
-
// RN re-settles on componentDidUpdate only.
|
|
172
|
-
//
|
|
173
|
-
// TODO(rn-parity): skip this initial publish when `resting === RESTING_OPACITY` (1, Fabric's
|
|
174
|
-
// own opacity default) and no authored `style.opacity` needs another route to Fabric, so a
|
|
175
|
-
// resting default TouchableOpacity commits no `opacity` key — matching vendor. Needs
|
|
176
|
-
// `bindAnimatedValue`/`host-binding.ts` to separate "force collapsable:false" from "publish
|
|
177
|
-
// the leaf's value" (shared by every animated-behavior consumer, not just this tag), plus a
|
|
178
|
-
// new test for the untouched-default case (no existing case in touchable-opacity.test.ts
|
|
179
|
-
// covers it — every case authors an explicit style.opacity first).
|
|
180
|
-
state.opacity.setValue(resting);
|
|
168
|
+
const previous = settledAt.get(node);
|
|
169
|
+
settledAt.set(node, { disabled, resting });
|
|
170
|
+
// The mount, where RN re-settles nothing. An untouched node holds no `AnimatedValue` yet, and
|
|
171
|
+
// `stateOf` seeds the one it eventually builds at whatever `resting` is by then
|
|
172
|
+
if (previous === undefined)
|
|
181
173
|
return;
|
|
182
|
-
}
|
|
183
174
|
if (previous.disabled === disabled && previous.resting === resting)
|
|
184
175
|
return;
|
|
185
|
-
fadeTo(
|
|
176
|
+
fadeTo(node, stateOf(node), resting, OPACITY_INACTIVE_DURATION_MS);
|
|
186
177
|
},
|
|
187
178
|
detach(node) {
|
|
188
179
|
machine.detach(node);
|
|
180
|
+
settledAt.delete(node);
|
|
189
181
|
const state = states.get(node);
|
|
190
|
-
|
|
182
|
+
// Never faded, so the flatten guard `attach` wrote is the only thing owed back
|
|
183
|
+
if (state === undefined) {
|
|
184
|
+
setProp(node, 'collapsable', undefined);
|
|
191
185
|
return;
|
|
186
|
+
}
|
|
192
187
|
for (const id of state.timers)
|
|
193
188
|
clearTimeout(id);
|
|
194
189
|
state.timers.clear();
|
|
195
190
|
// RN's componentWillUnmount: stop the animation so a teardown mid-fade leaves no driver
|
|
196
191
|
// running against a node that is gone.
|
|
197
192
|
state.opacity.resetAnimation();
|
|
198
|
-
|
|
193
|
+
// Dropping the layer clears `collapsable` on its way out, so only an unbound node still owes
|
|
194
|
+
if (state.isBound)
|
|
195
|
+
setAnimatedBehaviorStyle(node, undefined);
|
|
196
|
+
else
|
|
197
|
+
setProp(node, 'collapsable', undefined);
|
|
199
198
|
states.delete(node);
|
|
200
199
|
dlog('touchable-opacity behavior detached');
|
|
201
200
|
},
|
|
202
201
|
};
|
|
203
202
|
}
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
// What moved was not the closure but ONE BIT. `setEventListener` already knew the flip (it computes
|
|
209
|
-
// `wasWired !== isHandler` to fire `onOwnedListenerChange`); it now also records
|
|
210
|
-
// `OP_SET_OWNED_LISTENER`, and `foldPressableProps` resolves the whole three-leg expression off the
|
|
211
|
-
// node. The callback itself never left JS and never will — that is the split a browser draws too,
|
|
212
|
-
// where the UA knows which elements carry a click handler and the handler's body stays the page's.
|
|
213
|
-
//
|
|
214
|
-
// So "a rule cannot read an owned listener" was two claims wearing one sentence, and only the one
|
|
215
|
-
// about the FUNCTION was true. Contract:
|
|
216
|
-
// `core/engine/cpp/tests/js/touchable-focusable-payload.itest.ts`.
|
|
217
|
-
//
|
|
218
|
-
// `./button` keeps its own fold and its own resolution, deliberately: it resolves `disabled` three
|
|
219
|
-
// ways through the projection its derived children share, and it folds an Android view style and
|
|
220
|
-
// ripple regardless — so moving only its `focusable` would duplicate that precedence and buy back
|
|
221
|
-
// no crossing.
|
|
203
|
+
// `focusable`'s three-leg check (disabled, aria-disabled/accessibilityState, `onPress !==
|
|
204
|
+
// undefined`) is `foldPressableProps` in C++, reading `OP_SET_OWNED_LISTENER` for the last leg.
|
|
205
|
+
// Contract: `touchable-focusable-payload.itest.ts`. `./button` keeps its own copy regardless.
|
|
222
206
|
// STILL NEEDED, and for a reason the wire did not remove: `markDirty` on the host marks the node
|
|
223
207
|
// whose payload must be rebuilt, but the COMMIT still has to be asked for. A listener flip changes
|
|
224
208
|
// no prop on this side, so nothing else would schedule one and the new answer would sit in the host
|
|
@@ -39,18 +39,9 @@ import { SLOT_DERIVED_ALL, appListenerFor, markPropsDirty, registerHostBehavior,
|
|
|
39
39
|
import { attachPressMachine, detachPressMachine, } from './pressable.js';
|
|
40
40
|
import { createTouchableFeedbackHandlers, createTouchableFeedbackRuntime, TOUCHABLE_MIN_PRESS_DURATION_MS, } from '../state/touchable.js';
|
|
41
41
|
export const TOUCHABLE_WITHOUT_FEEDBACK_TAG = 'touchable-without-feedback';
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
// here until 2026-09-18, feeding `slotDerived` alongside the six computed names and the three
|
|
46
|
-
// scheduler ones. The clone is `foldCloneOntoChild` in `SymbioteFabricProps.cpp` now, which turned
|
|
47
|
-
// the list from the RULE into a MIRROR of `kWithoutFeedbackWhenSetKeys` +
|
|
48
|
-
// `kWithoutFeedbackAlwaysKeys` — two lists that must agree, failing silently when they drift.
|
|
49
|
-
//
|
|
50
|
-
// See `touchable-native-feedback.ts` for the same note and what the wildcard costs. The three
|
|
51
|
-
// scheduler names (`delayPressIn`, `delayPressOut`, `minPressDuration`) were the one entry here that
|
|
52
|
-
// was NEVER about the clone — they configure the press machine, which reads them live at gesture
|
|
53
|
-
// start — and a wildcard covers them for free where a list had to remember them.
|
|
42
|
+
// Dirties EVERY owner prop rather than a list: the clone is `foldCloneOntoChild` in C++ now, so a
|
|
43
|
+
// wildcard covers it for free. The three scheduler names (`delayPressIn`, `delayPressOut`,
|
|
44
|
+
// `minPressDuration`) were never about the clone — they configure the press machine, read live.
|
|
54
45
|
const SLOT_DERIVED = [SLOT_DERIVED_ALL];
|
|
55
46
|
// :148-153. Owned, so the app's callback stashes on the owner and a trampoline on the child reads it
|
|
56
47
|
// at dispatch time. `layout` and `accessibilityAction` are Fabric BOOLEAN-GATED events
|
|
@@ -77,27 +68,25 @@ const states = new WeakMap();
|
|
|
77
68
|
function numberOr(value, fallback) {
|
|
78
69
|
return typeof value === 'number' ? value : fallback;
|
|
79
70
|
}
|
|
80
|
-
// `
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
* owner is closed over rather than taken from the refinement's argument, which is the CHILD.
|
|
98
|
-
*/
|
|
99
|
-
function refinementFor(owner, state) {
|
|
71
|
+
// The clone is `foldCloneOntoChild` in C++, keyed on the PARENT'S tag; TWF's passthrough half
|
|
72
|
+
// copies only when SET, unlike TNF's unconditional list. One upstream quirk it reproduces:
|
|
73
|
+
// `nativeID` wins over `id` here (`node.nativeIdWinsOverId`, `clone-onto-child-payload.itest.ts`).
|
|
74
|
+
// Built on the FIRST GESTURE, not at arm: nothing reads the runtime or the timer set until a
|
|
75
|
+
// finger lands, and a list of touchables nobody presses paid ~500 B per item for them
|
|
76
|
+
function stateOf(owner) {
|
|
77
|
+
const existing = states.get(owner);
|
|
78
|
+
if (existing !== undefined)
|
|
79
|
+
return existing;
|
|
80
|
+
const state = {
|
|
81
|
+
runtime: createTouchableFeedbackRuntime(),
|
|
82
|
+
timers: new Set(),
|
|
83
|
+
};
|
|
84
|
+
states.set(owner, state);
|
|
85
|
+
return state;
|
|
86
|
+
}
|
|
87
|
+
function refinementFor(owner) {
|
|
100
88
|
return (_node, config) => {
|
|
89
|
+
const state = stateOf(owner);
|
|
101
90
|
const handlers = createTouchableFeedbackHandlers({
|
|
102
91
|
delayPressIn: numberOr(propOf(owner, 'delayPressIn'), 0),
|
|
103
92
|
delayPressOut: numberOr(propOf(owner, 'delayPressOut'), 0),
|
|
@@ -145,18 +134,13 @@ function trampolineFor(owner, name) {
|
|
|
145
134
|
function forwardListener(owner, child, name, wired) {
|
|
146
135
|
setBehaviorListener(child, name, wired ? trampolineFor(owner, name) : undefined);
|
|
147
136
|
}
|
|
148
|
-
// The machine plus the forwarded listeners. Split from adoption
|
|
149
|
-
// with its child and its fold intact but its machine torn down
|
|
150
|
-
// NOT re-chain the fold
|
|
137
|
+
// The machine plus the forwarded listeners. Split from adoption т.к. a parked subtree comes back
|
|
138
|
+
// with its child and its fold intact but its machine torn down, so `attach` re-runs this and must
|
|
139
|
+
// NOT re-chain the fold
|
|
151
140
|
function arm(owner, child) {
|
|
152
|
-
let state = states.get(owner);
|
|
153
|
-
if (state === undefined) {
|
|
154
|
-
state = { runtime: createTouchableFeedbackRuntime(), timers: new Set() };
|
|
155
|
-
states.set(owner, state);
|
|
156
|
-
}
|
|
157
141
|
attachPressMachine(child, {
|
|
158
142
|
source: owner,
|
|
159
|
-
refine: refinementFor(owner
|
|
143
|
+
refine: refinementFor(owner),
|
|
160
144
|
});
|
|
161
145
|
for (const name of FORWARDED_LISTENERS) {
|
|
162
146
|
forwardListener(owner, child, name, appListenerFor(owner, name) !== undefined);
|
|
@@ -31,18 +31,16 @@ const ANDROID_NAMES = {
|
|
|
31
31
|
switch: 'AndroidSwitch',
|
|
32
32
|
'activity-indicator': 'RCTView',
|
|
33
33
|
'activity-indicator-spinner': 'AndroidProgressBar',
|
|
34
|
-
// DELIBERATE DIVERGENCE FROM RN
|
|
35
|
-
//
|
|
36
|
-
//
|
|
34
|
+
// DELIBERATE DIVERGENCE FROM RN: `SafeAreaView.js` is a plain View on Android, with no insets.
|
|
35
|
+
// `RCTSafeAreaView` applies the window insets there too (`ReactSafeAreaViewManager`), so one
|
|
36
|
+
// screen is safe on both platforms.
|
|
37
37
|
'safe-area-view': 'RCTSafeAreaView',
|
|
38
38
|
modal: 'RCTModalHostView',
|
|
39
39
|
'refresh-control': 'AndroidSwipeRefreshLayout',
|
|
40
40
|
'sticky-header': 'RCTView',
|
|
41
|
-
// `InputAccessoryView.js` on Android does `console.warn(...); return null` — the WHOLE
|
|
42
|
-
// children included, renders NOTHING.
|
|
43
|
-
//
|
|
44
|
-
// a real divergence from vendor, not a harmless degrade. `VOID_COMPONENT` matches vendor exactly:
|
|
45
|
-
// no node, no children, on this platform only.
|
|
41
|
+
// `InputAccessoryView.js` on Android does `console.warn(...); return null` — the WHOLE
|
|
42
|
+
// component, children included, renders NOTHING. `VOID_COMPONENT` matches vendor exactly: no
|
|
43
|
+
// node, no children, on this platform only.
|
|
46
44
|
'input-accessory-view': VOID_COMPONENT,
|
|
47
45
|
};
|
|
48
46
|
export const COMPONENT_DESCRIPTORS = buildDescriptors(ANDROID_NAMES);
|
|
@@ -1,9 +1,6 @@
|
|
|
1
|
-
// The intrinsic JSX types symbiote's host config maps to Fabric components
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// Android 'AndroidSwitch'), so the name tables live in component-names.ios.ts /
|
|
5
|
-
// .android.ts and the filename selects, no Platform.OS read. The isText flag is
|
|
6
|
-
// platform-invariant, so it lives here once and both tables share it.
|
|
1
|
+
// The intrinsic JSX types symbiote's host config maps to Fabric components. A Fabric name is
|
|
2
|
+
// platform-specific, so name tables live in component-names.ios/.android.ts, selected by
|
|
3
|
+
// filename; `isText` is platform-invariant and lives here once.
|
|
7
4
|
// The only text-laying intrinsic; drives the RCTText / RCTVirtualText nesting choice
|
|
8
5
|
// (a <Text> inside another <Text> becomes a virtual span). Platform-invariant, so it is
|
|
9
6
|
// not part of the per-platform name table.
|
|
@@ -20,32 +17,18 @@ export function buildDescriptors(names) {
|
|
|
20
17
|
}
|
|
21
18
|
return descriptors;
|
|
22
19
|
}
|
|
23
|
-
// Resolve an intrinsic type to its descriptor
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
// COMPONENT_DESCRIPTORS. A `symbiote-*` miss is a typo in our own code; any other string
|
|
27
|
-
// is a raw Fabric view name from a library's codegen component and flows through untouched
|
|
28
|
-
// (the engine derives its events/processors from the view's ViewConfig, no per-library glue).
|
|
20
|
+
// Resolve an intrinsic type to its descriptor against the platform-selected map, so each
|
|
21
|
+
// platform file binds it to its own COMPONENT_DESCRIPTORS. Any string with no descriptor is a
|
|
22
|
+
// raw Fabric view name from a library's codegen component and flows through untouched.
|
|
29
23
|
export function makeDescriptorFor(descriptors) {
|
|
30
24
|
const unrewritten = publicNamesThatAreNotViewNames(descriptors);
|
|
31
25
|
return type => {
|
|
32
26
|
const descriptor = descriptors[type];
|
|
33
27
|
if (descriptor !== undefined)
|
|
34
28
|
return descriptor;
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
// view · scroll-view ours
|
|
40
|
-
// counter-child · app-root an Angular app's OWN component selectors, kebab by convention
|
|
41
|
-
// RCTView · RNCSlider a Fabric view name, resolving through the fallthrough below
|
|
42
|
-
//
|
|
43
|
-
// — and the first attempt at a replacement ("lowercase is ours") threw on the second row, which
|
|
44
|
-
// is most of an Angular app. Measured: 222 tests, the primary failure being `counter-child`.
|
|
45
|
-
//
|
|
46
|
-
// What replaces it is stronger and is not runtime at all: `ISymbioteIntrinsic` is a closed
|
|
47
|
-
// union, so a misspelled tag in our own source is a compile error at every call site that names
|
|
48
|
-
// one. The guard was only ever a backstop for a name built dynamically.
|
|
29
|
+
// No "unknown tag" throw here: this namespace holds three populations no string alone can
|
|
30
|
+
// tell apart — our own tags, an Angular app's own kebab-case selectors, and a Fabric view
|
|
31
|
+
// name. `ISymbioteIntrinsic` being a closed union makes a misspelled tag a compile error.
|
|
49
32
|
if (unrewritten.has(type)) {
|
|
50
33
|
throw new Error(`"${type}" is a primitive's PUBLIC name, not a Fabric view name — a rewrite was missed. ` +
|
|
51
34
|
`Expected the intrinsic tag (e.g. "view"). Falling through would have committed ` +
|
|
@@ -54,22 +37,9 @@ export function makeDescriptorFor(descriptors) {
|
|
|
54
37
|
return { component: type, isText: false };
|
|
55
38
|
};
|
|
56
39
|
}
|
|
57
|
-
// A primitive's public name reaching here means an adapter's rewrite missed a call site
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
// Both halves are DERIVED, because a hand-written list of either would be wrong within a release.
|
|
62
|
-
// The names come from the intrinsic union (kebab -> Pascal); the exclusions come from the platform's
|
|
63
|
-
// own table, and deriving them is not tidiness — two public names ARE real Fabric view names, so a
|
|
64
|
-
// hand-written block list would break an adapter's thin wrapper over a third-party native view,
|
|
65
|
-
// which resolves by view name through this same function.
|
|
66
|
-
//
|
|
67
|
-
// AND THE EXCLUSION IS PER PLATFORM, which is why it must be computed from the table rather than
|
|
68
|
-
// stated. `Switch` and `SafeAreaView` are iOS view names; Android spells them `AndroidSwitch` and
|
|
69
|
-
// `RCTSafeAreaView`, so the same public name is EXCLUDED on iOS and BLOCKED on Android. That
|
|
70
|
-
// asymmetry is correct — nothing legitimate resolves by the bare name on Android — but a reader who
|
|
71
|
-
// takes "these two are real view names" as platform-invariant will conclude the guard is broken on
|
|
72
|
-
// one side or the other.
|
|
40
|
+
// A primitive's public name reaching here means an adapter's rewrite missed a call site —
|
|
41
|
+
// otherwise it commits a view literally called `View`. Both halves are DERIVED, not hand-written:
|
|
42
|
+
// some public names (`Switch` on iOS) ARE real view names, and which is per-platform.
|
|
73
43
|
function publicNamesThatAreNotViewNames(descriptors) {
|
|
74
44
|
const viewNames = new Set(Object.values(descriptors).map(descriptor => descriptor.component));
|
|
75
45
|
const names = new Set();
|
package/build/index.js
CHANGED
|
@@ -53,10 +53,8 @@ export { canUseNativeForeground, selectableBackground, selectableBackgroundBorde
|
|
|
53
53
|
// element is a tag now, and a tag is a string that cannot carry `.Ripple`.
|
|
54
54
|
TouchableNativeFeedback, } from './view/render-touchable-native-feedback.js';
|
|
55
55
|
// Button: `resolveButtonDisabled` alone now — the `props.disabled ?? aria ?? state.disabled`
|
|
56
|
-
// precedence, which the press MACHINE reads and
|
|
57
|
-
//
|
|
58
|
-
// `SymbioteFabricProps.cpp`; the last two, `buttonViewStyle` and `resolveButtonViewStyle`, went with
|
|
59
|
-
// the Android arm of the test host that finally made their branch assertable.
|
|
56
|
+
// precedence, which the press MACHINE reads and stays JS. Every platform fold Button.js performs
|
|
57
|
+
// is `SymbioteFabricProps.cpp`'s.
|
|
60
58
|
export { resolveButtonDisabled } from './view/render-button.js';
|
|
61
59
|
// TextInput: the controlled-value / event-count handshake, and ONLY that now — the machine. The
|
|
62
60
|
// W3C->native prop resolution that used to sit beside it is the engine's (`foldTextInputAliases`,
|
|
@@ -80,25 +78,21 @@ export { registerPressableBehavior, PRESSABLE_TAG, } from './behaviors/pressable
|
|
|
80
78
|
export { registerTouchableOpacityBehavior, TOUCHABLE_OPACITY_TAG, } from './behaviors/touchable-opacity.js';
|
|
81
79
|
export { registerTouchableHighlightBehavior, TOUCHABLE_HIGHLIGHT_TAG, } from './behaviors/touchable-highlight.js';
|
|
82
80
|
export { registerButtonBehavior, BUTTON_TAG } from './behaviors/button.js';
|
|
83
|
-
// Registered by ALL FIVE adapters
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
// TouchableNativeFeedback. The ActivityIndicator and ScrollView blocks below are still in the
|
|
87
|
-
// withheld state this one just left.
|
|
81
|
+
// Registered by ALL FIVE adapters, in the same commit that deleted the five wrappers — safe
|
|
82
|
+
// only because those wrappers no longer render their own Pressable, which would put a second
|
|
83
|
+
// responder on every TouchableNativeFeedback.
|
|
88
84
|
export { registerTouchableNativeFeedbackBehavior, TOUCHABLE_NATIVE_FEEDBACK_TAG, } from './behaviors/touchable-native-feedback.js';
|
|
89
85
|
// The second tag that commits no node, and registered by all five adapters in the same commit that
|
|
90
86
|
// deleted their five wrappers — while those wrappers still rendered a `Pressable` around the app's
|
|
91
87
|
// children, registering would have put a second responder on every TouchableWithoutFeedback.
|
|
92
88
|
export { registerTouchableWithoutFeedbackBehavior, TOUCHABLE_WITHOUT_FEEDBACK_TAG, } from './behaviors/touchable-without-feedback.js';
|
|
93
|
-
// Registered by all five adapters
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
// only render this primitive had was the two nodes the behavior now builds.
|
|
89
|
+
// Registered by all five adapters, in the same commit that deleted the five wrappers — the tag
|
|
90
|
+
// builds its own spinner, so a surviving wrapper would have painted a second one. No
|
|
91
|
+
// `view/render-activity-indicator.ts`: the behavior now builds the only two nodes this had.
|
|
97
92
|
export { ACTIVITY_INDICATOR_SPINNER_TAG, ACTIVITY_INDICATOR_TAG, registerActivityIndicatorBehavior, } from './behaviors/activity-indicator';
|
|
98
93
|
export { IMAGE_TAG, registerImageBehavior } from './behaviors/image.js';
|
|
99
|
-
// Registered by all five adapters
|
|
100
|
-
//
|
|
101
|
-
// committed a second one under it.
|
|
94
|
+
// Registered by all five adapters, in the same commit that deleted the five wrappers — the tag
|
|
95
|
+
// builds the background image itself, so a surviving wrapper would have committed a second one.
|
|
102
96
|
export { IMAGE_BACKGROUND_TAG, registerImageBackgroundBehavior, } from './behaviors/image-background.js';
|
|
103
97
|
export { INPUT_ACCESSORY_VIEW_TAG, registerInputAccessoryViewBehavior, } from './behaviors/input-accessory-view.js';
|
|
104
98
|
export { registerTextInputBehavior, buildTextInputHandle, TEXT_INPUT_TAG, TEXT_INPUT_MULTILINE_TAG, } from './behaviors/text-input.js';
|
|
@@ -109,7 +103,7 @@ export { registerRefreshControlBehavior, REFRESH_CONTROL_TAG, } from './behavior
|
|
|
109
103
|
// caller of `registerScrollViewBehavior()` names it. The shape CLAUDE.md forbids is a module whose
|
|
110
104
|
// evaluation alone registers.
|
|
111
105
|
//
|
|
112
|
-
// EVERY ADAPTER CALLS IT
|
|
113
|
-
//
|
|
114
|
-
//
|
|
106
|
+
// EVERY ADAPTER CALLS IT, through `@symbiote-native/components/register`. Withheld while the
|
|
107
|
+
// wrappers built their own content node — registering then would have double-nested every
|
|
108
|
+
// ScrollView. They are gone; the engine is the single owner.
|
|
115
109
|
export { HORIZONTAL_SCROLL_VIEW_TAG, registerScrollViewBehavior, SCROLL_VIEW_TAG, STICKY_HEADER_TAG, } from './behaviors/scroll-view';
|
|
@@ -4,17 +4,9 @@
|
|
|
4
4
|
// Android RefreshControl wrap's style split moved out — see the note below. The adapter supplies
|
|
5
5
|
// the lifecycle (the node getter, the effect) and re-exports these.
|
|
6
6
|
import { attachNativeEvent, dlog, isSymbioteEvent, } from '@symbiote-native/engine';
|
|
7
|
-
//
|
|
8
|
-
// `
|
|
9
|
-
//
|
|
10
|
-
// from a `payloadFold` per node — see `behaviors/scroll-view/index.android.ts` for what made the
|
|
11
|
-
// wrapper's half possible at all.
|
|
12
|
-
//
|
|
13
|
-
// Nothing replaced them here on purpose: a JS copy kept for a caller that no longer exists is the
|
|
14
|
-
// mirror shape this migration keeps deleting, and the twenty-eight layout keys are exactly the kind
|
|
15
|
-
// of list that drifts in silence. `core/engine/cpp/tests/js/scroll-view-wrap-payload.itest.ts`
|
|
16
|
-
// asserts the split on the payload a commit actually sent, which is the only place it can now be
|
|
17
|
-
// wrong.
|
|
7
|
+
// The Android wrap's style split (`splitScrollViewStyle`, `foldRefreshWrapperProps`) is
|
|
8
|
+
// `SymbioteFabricProps.cpp`'s, reached off the tag and tree rather than a `payloadFold` per node.
|
|
9
|
+
// Contract: `scroll-view-wrap-payload.itest.ts`, asserted on the payload a commit actually sent.
|
|
18
10
|
// Re-exported so the package barrel (index.ts) can still export this guard to
|
|
19
11
|
// '@symbiote-native/components' callers, now that it lives in the engine, next to ISymbioteEvent.
|
|
20
12
|
export { isSymbioteEvent };
|
package/build/state/pressable.js
CHANGED
|
@@ -1,22 +1,14 @@
|
|
|
1
|
-
// Pressable, the logic half (framework-agnostic, zero render
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// Framework-specific, stays in the adapter: the `pressed` state cell (it drives a
|
|
9
|
-
// re-render, so each framework owns its reactive primitive) and the raw frame-measure (the host
|
|
10
|
-
// node plus its measure call). The rest (the timers, the geometry, the suppression flags,
|
|
11
|
-
// the decision of when each callback fires) is here, shared by every adapter.
|
|
1
|
+
// Pressable, the logic half (framework-agnostic, zero render). The press lifecycle RN's
|
|
2
|
+
// Pressability runs in JS lives here as a pure state machine over a mutable runtime plus an
|
|
3
|
+
// adapter-supplied host. Both React and Vue call the SAME handlers, differing only in lifecycle.
|
|
4
|
+
// Framework-specific, stays in the adapter: the `pressed` state cell (drives a re-render, so each
|
|
5
|
+
// framework owns its reactive primitive) and the raw frame-measure. The rest — timers, geometry,
|
|
6
|
+
// suppression flags, when each callback fires — is here, shared by every adapter.
|
|
12
7
|
import { dlog, Platform, SoundManager, } from '@symbiote-native/engine';
|
|
13
8
|
export const DEFAULT_DELAY_LONG_PRESS_MS = 500;
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// not fire mid-scroll. Vendor exposes it only as a rare static override
|
|
18
|
-
// (`Pressability.setLongPressDeactivationDistance`, used by e.g. gesture libraries), never as a
|
|
19
|
-
// per-component prop, so it stays a plain constant here rather than a config field.
|
|
9
|
+
// A SEPARATE, smaller radius than pressRetentionOffset/hitSlop: any move past it cancels a
|
|
10
|
+
// pending long press even while the finger is still well inside the retention rect — real presses
|
|
11
|
+
// jitter a few px, long-press must not fire mid-scroll.
|
|
20
12
|
export const LONG_PRESS_DEACTIVATION_DISTANCE = 10;
|
|
21
13
|
// Pressability's default active-visual floor for a plain Pressable. Touchable* overrides this to 0.
|
|
22
14
|
export const DEFAULT_MIN_PRESS_DURATION_MS = 130;
|
|
@@ -120,10 +112,8 @@ export function disposePressRuntime(runtime) {
|
|
|
120
112
|
runtime.delayElapsed = false;
|
|
121
113
|
}
|
|
122
114
|
// Measure the responder's on-screen frame and cache it as the retention region for the life of
|
|
123
|
-
// the press
|
|
124
|
-
//
|
|
125
|
-
// headless) the region stays undefined and the move test falls back to the radius bound. The
|
|
126
|
-
// try/catch guards that last case: a slot lacking measure throws rather than no-opping.
|
|
115
|
+
// the press. When measure is unavailable the region stays undefined and the move test falls back
|
|
116
|
+
// to the radius bound; the try/catch guards a slot lacking measure throwing rather than no-opping.
|
|
127
117
|
function measureRegion(runtime, measureFn) {
|
|
128
118
|
runtime.region = undefined;
|
|
129
119
|
if (measureFn === undefined)
|
|
@@ -157,18 +147,9 @@ export function createPressHandlers(config, runtime, host) {
|
|
|
157
147
|
const fallbackThreshold = maxEdge(hitSlopRect) + maxEdge(pressRectOffset);
|
|
158
148
|
// True iff the touch still belongs to the active press: against the measured rect when we have
|
|
159
149
|
// one (the RN-faithful path), else the symmetric radius fallback.
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
// measured — vendor treats "no measurement yet" as "do nothing until one arrives." The radius
|
|
164
|
-
// fallback below diverges: it judges drift/jitter on synthetic geometry instead of waiting.
|
|
165
|
-
// Confirmed unreachable on a real device (`Tree::measure` in `SymbioteTree.cpp` is a synchronous
|
|
166
|
-
// JSI call, so the region is always populated before `handleResponderMove` can fire), so the
|
|
167
|
-
// fallback only ever fires in this codebase's own test harness, which wires
|
|
168
|
-
// `getMeasureFn: () => undefined` everywhere (`state/pressable.test.ts:84` and every touchable
|
|
169
|
-
// test built on it). A real fix needs a synchronous mock-measure harness under 11+ existing
|
|
170
|
-
// drift/retention/long-press call sites across `pressable.test.ts`, `touchable-opacity.test.ts`
|
|
171
|
-
// and `render-pressable.test.ts`, not a one-line guard — see the audit skill for the finding.
|
|
150
|
+
// Known divergence: vendor does nothing until the region is measured, this judges drift/jitter
|
|
151
|
+
// on synthetic geometry instead. Unreachable on a real device (measure is a synchronous JSI
|
|
152
|
+
// call), so the fallback only ever fires in this codebase's own test harness.
|
|
172
153
|
function isWithinRetention(point) {
|
|
173
154
|
const region = runtime.region;
|
|
174
155
|
if (region !== undefined) {
|
|
@@ -188,12 +169,8 @@ export function createPressHandlers(config, runtime, host) {
|
|
|
188
169
|
function clearPressOut() {
|
|
189
170
|
cancelRuntimeTimer(runtime, 'pressOutCancel');
|
|
190
171
|
}
|
|
191
|
-
//
|
|
192
|
-
// (
|
|
193
|
-
// rest of the gesture). Splitting it out of `activate()` is what makes that possible: `activate`
|
|
194
|
-
// itself runs again on every drift-back-in, and used to re-arm a fresh full-duration timer each
|
|
195
|
-
// time, which is wrong on two counts — it can resurrect a long press the finger already
|
|
196
|
-
// disqualified, and if it never left, it's simply the same timer restarted for no reason.
|
|
172
|
+
// Arms ONCE, at grant; a drift out/back-in never re-arms it, only cancels. Split out of
|
|
173
|
+
// activate(), which runs again on drift-back-in and would resurrect a disqualified long press.
|
|
197
174
|
function armLongPress(event) {
|
|
198
175
|
if (!onLongPress)
|
|
199
176
|
return;
|
|
@@ -277,10 +254,8 @@ export function createPressHandlers(config, runtime, host) {
|
|
|
277
254
|
runtime.pressOrigin = readPoint(event);
|
|
278
255
|
runtime.driftedOut = false;
|
|
279
256
|
measureRegion(runtime, host.getMeasureFn());
|
|
280
|
-
// Armed at GRANT, unconditionally —
|
|
281
|
-
//
|
|
282
|
-
// its default (`configFor`), so adding it back here is what keeps the long-press threshold at
|
|
283
|
-
// a constant time-from-touch-down regardless of how long the visual is deferred.
|
|
257
|
+
// Armed at GRANT, unconditionally — the unstable_pressDelay branch below only defers the
|
|
258
|
+
// PRESSED VISUAL, keeping the long-press threshold constant from touch-down.
|
|
284
259
|
armLongPress(event);
|
|
285
260
|
if (unstable_pressDelay > 0) {
|
|
286
261
|
dlog(`Pressable pressIn deferred ${unstable_pressDelay}ms`);
|