@symbiote-native/components 3.1.2 → 3.1.4

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.
@@ -1,4 +1,4 @@
1
- import { type IHostBehavior, type ISymbioteNode } from '@symbiote-native/engine';
1
+ import { type IEventDispatch, type IHostBehavior, type ISymbioteNode } from '@symbiote-native/engine';
2
2
  import { type IPressMachineConfig } from '../state/pressable';
3
3
  import type { IAccessibilityStateValue } from '../accessibility-props';
4
4
  export declare const PRESSABLE_TAG = "pressable";
@@ -9,12 +9,14 @@ export declare function booleanOr(value: unknown): boolean | undefined;
9
9
  export declare function asAccessibilityState(value: unknown): IAccessibilityStateValue | undefined;
10
10
  export declare function accessibleUnlessOptedOut(props: Readonly<Record<string, unknown>>): boolean;
11
11
  export declare function withNativeFeedbackCommands(node: ISymbioteNode, config: IPressMachineConfig): IPressMachineConfig;
12
- export declare function attachPressMachine(node: ISymbioteNode, options?: {
12
+ export declare const PRESS_DISPATCH: IEventDispatch;
13
+ export type IAttachOptions = {
13
14
  readonly refine?: IPressConfigRefinement;
14
15
  readonly disabledOf?: IDisabledResolver;
15
16
  readonly cancelableOf?: ICancelableResolver;
16
17
  readonly source?: ISymbioteNode;
17
- }): void;
18
+ };
19
+ export declare function attachPressMachine(node: ISymbioteNode, options?: IAttachOptions): void;
18
20
  /** See `attachPressMachine`: the same teardown `createPressBehavior` registers as its `detach`. */
19
21
  export declare function detachPressMachine(node: ISymbioteNode): void;
20
22
  export declare function createPressBehavior(refine?: IPressConfigRefinement, disabledOf?: IDisabledResolver): Pick<IHostBehavior, 'attach' | 'detach' | 'ownedListeners'>;
@@ -4,7 +4,7 @@
4
4
  // Registration, not the machine, is the hazard: Metro's inlineRequires makes a barrel re-export of
5
5
  // a side-effect-only module never run in a release build. Each adapter keeps its own
6
6
  // `src/register.ts` calling registerPressableBehavior(), reached only by a bare side-effect import.
7
- import { appListenerFor, dispatchViewCommand, dlog, Platform, propOf, registerHostBehavior, requestCommitFor, setBehaviorListener, setNodePressed, propsOf, } from '@symbiote-native/engine';
7
+ import { appListenerFor, dispatchViewCommand, dlog, Platform, propOf, registerHostBehavior, requestCommitFor, setNodeDispatch, setNodePressed, propsOf, } from '@symbiote-native/engine';
8
8
  import { createPressHandlers, createPressRuntime, disposePressRuntime, DEFAULT_DELAY_LONG_PRESS_MS, DEFAULT_MIN_PRESS_DURATION_MS, } from '../state/pressable.js';
9
9
  import { buildPressableListeners } from '../view/render-pressable/index.js';
10
10
  export const PRESSABLE_TAG = 'pressable';
@@ -196,7 +196,8 @@ const GESTURE_END_KEYS = new Set([
196
196
  'onPressOut',
197
197
  'onResponderTerminationRequest',
198
198
  ]);
199
- function dispatch(node, state, key, args) {
199
+ function dispatch(node, options, key, args) {
200
+ const state = stateOf(node, options);
200
201
  if (!state.isBuilt)
201
202
  rebuild(node, state);
202
203
  const listener = state.listeners[key];
@@ -209,15 +210,10 @@ function dispatch(node, state, key, args) {
209
210
  state.isBuilt = false;
210
211
  return result;
211
212
  }
212
- // Installed straight into the listener slot, not through routeProp: the behavior OWNS these
213
- // names, and setEventListener diverts an owned name into the app stash — routing the dispatcher
214
- // through there would stash it and leave the slot empty.
215
- // Engine event name -> the app-facing callback key its dispatcher routes to. An array of pairs
216
- // rather than a Map: installListeners runs once per node carrying a press machine, and a `for…of`
217
- // over a Map allocates a fresh pair per entry where these tuples already exist.
218
- // NOT the same list as createPressBehavior's ownedListeners — that's every name the machine takes
219
- // as an INPUT, this is only the names it installs a dispatcher for.
220
- const EVENT_KEY_PAIRS = [
213
+ // Engine event name -> the app-facing callback key it routes to. NOT the same list as
214
+ // `createPressBehavior`'s `ownedListeners`, which is every name the machine takes as an INPUT;
215
+ // this is only what it answers when Fabric fires
216
+ const DISPATCH_KEYS = new Map([
221
217
  ['press', 'onPress'],
222
218
  ['pressIn', 'onPressIn'],
223
219
  ['pressOut', 'onPressOut'],
@@ -225,24 +221,55 @@ const EVENT_KEY_PAIRS = [
225
221
  ['responderMove', 'onResponderMove'],
226
222
  ['responderTerminationRequest', 'onResponderTerminationRequest'],
227
223
  ['responderGrant', 'onResponderGrant'],
228
- ];
229
- function installListeners(node, state) {
230
- for (const [event, key] of EVENT_KEY_PAIRS) {
231
- setBehaviorListener(node, event, symbioteEvent => dispatch(node, state, key, [symbioteEvent]));
232
- }
233
- }
224
+ ]);
225
+ // The per-node half of what the seven dispatcher closures used to capture. A behavior that
226
+ // registered no refinement leaves its nodes out of here entirely
227
+ const attached = new WeakMap();
228
+ // ONE object for every pressable in the app, where seven closures plus the `Map` holding them used
229
+ // to sit per node. `attach` runs inside `createElement`, so what it allocates every list item pays
230
+ // Exported for a tag that is a pressable PLUS something with names of its own (`./text-input`): it
231
+ // composes a union and delegates here, since a node holds exactly one dispatch
232
+ export const PRESS_DISPATCH = {
233
+ names: new Set(DISPATCH_KEYS.keys()),
234
+ deliver(node, name, event) {
235
+ const key = DISPATCH_KEYS.get(name);
236
+ if (key === undefined)
237
+ return undefined;
238
+ return dispatch(node, attached.get(node) ?? NO_OPTIONS, key, [event]);
239
+ },
240
+ };
234
241
  function attachWith(refine, disabledOf) {
235
- return node => attach(node, { refine, disabledOf });
242
+ // One bag per BEHAVIOR, not per node: `refine` and `disabledOf` are fixed at registration, and a
243
+ // behavior with neither shares the empty one so a plain `<pressable>` stores nothing
244
+ const options = refine === undefined && disabledOf === undefined
245
+ ? NO_OPTIONS
246
+ : { refine, disabledOf };
247
+ return node => attach(node, options);
236
248
  }
249
+ const NO_OPTIONS = {};
237
250
  // The machine on `node`, reading props and callbacks off `options.source` when different.
238
251
  // Exported for touchable-native-feedback, whose tag commits nothing and adopts the app's single
239
252
  // child as the responder; every other caller goes through createPressBehavior where they're equal.
240
253
  // Re-callable on the same node: a second call replaces the state and dispatchers, which is what
241
254
  // re-arming after detachPressMachine needs.
242
- export function attachPressMachine(node, options = {}) {
255
+ export function attachPressMachine(node, options = NO_OPTIONS) {
243
256
  attach(node, options);
244
257
  }
258
+ // One pointer, plus one map slot for a refined behavior: everything below is read from `dispatch`
259
+ // alone, so a pressable nobody touches never builds a gesture runtime
260
+ // A re-attach drops the old state so the new options take effect on the next gesture
245
261
  function attach(node, options) {
262
+ states.delete(node);
263
+ if (options === NO_OPTIONS)
264
+ attached.delete(node);
265
+ else
266
+ attached.set(node, options);
267
+ setNodeDispatch(node, PRESS_DISPATCH);
268
+ }
269
+ function stateOf(node, options) {
270
+ const existing = states.get(node);
271
+ if (existing !== undefined)
272
+ return existing;
246
273
  const timers = new Set();
247
274
  const runtime = createPressRuntime();
248
275
  const host = {
@@ -285,14 +312,17 @@ function attach(node, options) {
285
312
  isBuilt: false,
286
313
  };
287
314
  states.set(node, state);
288
- installListeners(node, state);
315
+ return state;
289
316
  }
290
317
  /** See `attachPressMachine`: the same teardown `createPressBehavior` registers as its `detach`. */
291
318
  export function detachPressMachine(node) {
292
319
  detach(node);
293
320
  }
294
321
  function detach(node) {
322
+ setNodeDispatch(node, undefined);
323
+ attached.delete(node);
295
324
  const state = states.get(node);
325
+ // A pressable nobody touched has no state at all, and the two releases above are all it owes
296
326
  if (state === undefined)
297
327
  return;
298
328
  // The machine's own teardown. Not load-bearing here: host.schedule puts every timer the machine
@@ -29,7 +29,7 @@
29
29
  // `behaviors/switch.ts`'s module header, verbatim. Same shape, same two triggers, same reasons —
30
30
  // an ACCEPTING app's state reaches `node.props` only after its own reconciliation, and a REJECTING
31
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';
32
+ import { appListenerFor, dispatchViewCommand, dlog, registerHostBehavior, setNodeDispatch, propOf, } from '@symbiote-native/engine';
33
33
  export const REFRESH_CONTROL_TAG = 'refresh-control';
34
34
  // RN's own name for the command, sent to whichever of the two native views the platform resolved.
35
35
  const SET_NATIVE_REFRESHING = 'setNativeRefreshing';
@@ -75,10 +75,16 @@ function onRefresh(node, event) {
75
75
  listener(event);
76
76
  queueMicrotask(() => evaluateSnapBack(node));
77
77
  }
78
+ // ONE object for every RefreshControl in the app, where a closure per node used to be
79
+ const REFRESH_DISPATCH = {
80
+ names: new Set(['refresh']),
81
+ deliver: (node, _name, event) => onRefresh(node, event),
82
+ };
78
83
  function attach(node) {
79
- setBehaviorListener(node, 'refresh', event => onRefresh(node, event));
84
+ setNodeDispatch(node, REFRESH_DISPATCH);
80
85
  }
81
86
  function detach(node) {
87
+ setNodeDispatch(node, undefined);
82
88
  reported.delete(node);
83
89
  }
84
90
  // Idempotent: an adapter entry may be imported more than once in a bundle, and re-registering the
@@ -1,4 +1,5 @@
1
- import { type ISymbioteNode } from '@symbiote-native/engine';
1
+ import { type ISymbioteEvent, type ISymbioteNode } from '@symbiote-native/engine';
2
2
  export declare function markScrollObserved(owner: ISymbioteNode): void;
3
3
  export declare const RESPONDER_OWNED_LISTENERS: readonly string[];
4
- export declare function installResponderPredicates(owner: ISymbioteNode): void;
4
+ export declare const RESPONDER_DISPATCHED_NAMES: ReadonlySet<string>;
5
+ export declare function deliverResponderEvent(owner: ISymbioteNode, name: string, event: ISymbioteEvent): unknown;
@@ -5,7 +5,7 @@
5
5
  // Ported line-for-line from `ScrollView.js:1263-1546` — the negotiation engine itself and the
6
6
  // `IResponderProps` prop surface already exist and needed no change; this file is ScrollView opting
7
7
  // into the same mechanism Pressable already uses (`../pressable.ts:329-384`).
8
- import { appListenerFor, currentlyFocusedInput, blurTextInput, dlog, isDebug, isRecord, isSymbioteNode, Keyboard, Platform, propOf, setBehaviorListener, } from '@symbiote-native/engine';
8
+ import { appListenerFor, currentlyFocusedInput, blurTextInput, dlog, isDebug, isRecord, isSymbioteNode, Keyboard, Platform, propOf, } from '@symbiote-native/engine';
9
9
  import { descriptorFor } from '../../component-names';
10
10
  import { TEXT_INPUT_MULTILINE_TAG, TEXT_INPUT_TAG } from '../text-input.js';
11
11
  // RN's `IS_ANIMATING_TOUCH_START_THRESHOLD_MS` (ScrollView.js:695) — a momentum end inside this
@@ -208,24 +208,22 @@ export const RESPONDER_OWNED_LISTENERS = [
208
208
  'momentumScrollBegin',
209
209
  'momentumScrollEnd',
210
210
  ];
211
- // Installed once per owner, in `attach` — same beat Pressable wires its own responder pair.
212
- export function installResponderPredicates(owner) {
213
- setBehaviorListener(owner, 'startShouldSetResponderCapture', event => startShouldSetResponderCapture(owner, event));
214
- setBehaviorListener(owner, 'startShouldSetResponder', event => startShouldSetResponder(owner, event));
215
- setBehaviorListener(owner, 'responderTerminationRequest', () => responderTerminationRequest(owner));
216
- setBehaviorListener(owner, 'responderGrant', event => {
217
- handleResponderGrant(owner, event);
218
- });
219
- setBehaviorListener(owner, 'responderRelease', event => {
220
- handleResponderRelease(owner, event);
221
- });
222
- setBehaviorListener(owner, 'momentumScrollBegin', event => {
223
- handleMomentumScrollBegin(owner, event);
224
- });
225
- setBehaviorListener(owner, 'scrollBeginDrag', event => {
226
- handleScrollBeginDrag(owner, event);
227
- });
228
- setBehaviorListener(owner, 'momentumScrollEnd', event => {
229
- handleMomentumScrollEnd(owner, event);
230
- });
211
+ // The eight names an owner answers, as ONE module-level table where eight closures per node used to
212
+ // be. `./shared` composes it with its own `scroll` into the owner's `IEventDispatch`
213
+ const HANDLERS = new Map([
214
+ ['startShouldSetResponderCapture', startShouldSetResponderCapture],
215
+ ['startShouldSetResponder', startShouldSetResponder],
216
+ [
217
+ 'responderTerminationRequest',
218
+ (owner) => responderTerminationRequest(owner),
219
+ ],
220
+ ['responderGrant', handleResponderGrant],
221
+ ['responderRelease', handleResponderRelease],
222
+ ['momentumScrollBegin', handleMomentumScrollBegin],
223
+ ['scrollBeginDrag', handleScrollBeginDrag],
224
+ ['momentumScrollEnd', handleMomentumScrollEnd],
225
+ ]);
226
+ export const RESPONDER_DISPATCHED_NAMES = new Set(HANDLERS.keys());
227
+ export function deliverResponderEvent(owner, name, event) {
228
+ return HANDLERS.get(name)?.(owner, event);
231
229
  }
@@ -20,10 +20,10 @@
20
20
  // `contentContainerStyle` travels through `slotProps` as a pure RENAME onto the slot's `style`;
21
21
  // the row-direction CONSTANT is a `payloadFold` instead, assigned to the slot node inside
22
22
  // `buildStructure` since `IHostBehavior.foldPayload` is only ever the OWNER's.
23
- import { appendChild, appListenerFor, createElement, dlog, registerHostBehavior, setBehaviorListener, setEventListener, } from '@symbiote-native/engine';
23
+ import { appendChild, appListenerFor, createElement, dlog, registerHostBehavior, setNodeDispatch, setEventListener, } from '@symbiote-native/engine';
24
24
  import { descriptorFor } from '../../component-names';
25
25
  import { didContentSizeChange, readLayoutDimension, } from '../../view/render-scroll-view.js';
26
- import { installResponderPredicates, RESPONDER_OWNED_LISTENERS, } from './responder.js';
26
+ import { deliverResponderEvent, RESPONDER_DISPATCHED_NAMES, RESPONDER_OWNED_LISTENERS, } from './responder.js';
27
27
  import { handleOwnerScroll, markScrollOwner, reconcileStickyIndices, releaseStickyOwner, stickyHeaderBehavior, STICKY_HEADER_TAG, syncOwnerLayout, } from './sticky.js';
28
28
  export const SCROLL_VIEW_TAG = 'scroll-view';
29
29
  export const HORIZONTAL_SCROLL_VIEW_TAG = 'horizontal-scroll-view';
@@ -105,6 +105,17 @@ function syncContentSizeWiring(owner, wired) {
105
105
  setEventListener(slot, 'layout', undefined);
106
106
  }
107
107
  }
108
+ // The nine names an owner answers, as ONE object for every ScrollView in the app. `scroll` is in
109
+ // here unconditionally т.к. it drives the sticky `AnimatedValue`, and a header may register long
110
+ // after the node was created
111
+ const OWNER_DISPATCH = {
112
+ names: new Set([...RESPONDER_DISPATCHED_NAMES, 'scroll']),
113
+ deliver(node, name, event) {
114
+ if (name === 'scroll')
115
+ return handleOwnerScroll(node, event);
116
+ return deliverResponderEvent(node, name, event);
117
+ },
118
+ };
108
119
  // Two owned names answer to a flip, and they answer on DIFFERENT nodes: `contentSizeChange` wires
109
120
  // the SLOT's layout, `layout` wires the owner's own — which an inverted sticky header also wants,
110
121
  // so the two claims are resolved in one place (`syncOwnerLayout`) rather than by whoever wrote last.
@@ -133,14 +144,14 @@ function scrollBehavior(contentIntrinsic, rowStyle, platform) {
133
144
  // a header can register long after this node was created.
134
145
  attach(node) {
135
146
  markScrollOwner(node);
136
- setBehaviorListener(node, 'scroll', event => handleOwnerScroll(node, event));
137
- installResponderPredicates(node);
147
+ setNodeDispatch(node, OWNER_DISPATCH);
138
148
  },
139
149
  onOwnedListenerChange: syncOwnedListener,
140
150
  // The one beat where the app's children are all present — no hook reports a children CHANGE,
141
151
  // and reconcileStickyIndices returns on a WeakSet miss for any that never used the prop.
142
152
  afterCommit: reconcileStickyIndices,
143
153
  detach(node) {
154
+ setNodeDispatch(node, undefined);
144
155
  lastContentSize.delete(node);
145
156
  releaseStickyOwner(node);
146
157
  },
@@ -36,7 +36,7 @@
36
36
  // unresolved. `afterCommit` costs nothing extra (it fires only on a commit that already changed
37
37
  // something) and closes that one case; the microtask path is what the no-op-handler case actually
38
38
  // needs.
39
- import { appListenerFor, dispatchViewCommand, dlog, Platform, propOf, registerHostBehavior, setBehaviorListener, } from '@symbiote-native/engine';
39
+ import { appListenerFor, dispatchViewCommand, dlog, Platform, propOf, registerHostBehavior, setNodeDispatch, } from '@symbiote-native/engine';
40
40
  import { createInitialSwitchState, shouldSnapBack, switchReducer, valueFromChange, } from '../state/switch.js';
41
41
  export const SWITCH_TAG = 'switch';
42
42
  const states = new WeakMap();
@@ -120,13 +120,27 @@ function alwaysClaimsResponder() {
120
120
  function neverYieldsResponder() {
121
121
  return false;
122
122
  }
123
+ // ONE object for every Switch in the app, where three closures per node used to be
124
+ const SWITCH_DISPATCH = {
125
+ names: new Set([
126
+ 'change',
127
+ 'startShouldSetResponder',
128
+ 'responderTerminationRequest',
129
+ ]),
130
+ deliver(node, name, event) {
131
+ if (name === 'change')
132
+ return onChange(node, event);
133
+ return name === 'startShouldSetResponder'
134
+ ? alwaysClaimsResponder()
135
+ : neverYieldsResponder();
136
+ },
137
+ };
123
138
  function attach(node) {
124
139
  states.set(node, createInitialSwitchState());
125
- setBehaviorListener(node, 'change', event => onChange(node, event));
126
- setBehaviorListener(node, 'startShouldSetResponder', alwaysClaimsResponder);
127
- setBehaviorListener(node, 'responderTerminationRequest', neverYieldsResponder);
140
+ setNodeDispatch(node, SWITCH_DISPATCH);
128
141
  }
129
142
  function detach(node) {
143
+ setNodeDispatch(node, undefined);
130
144
  states.delete(node);
131
145
  }
132
146
  // Idempotent: an adapter entry may be imported more than once in a bundle, and re-registering the
@@ -6,8 +6,8 @@
6
6
  // The order of the two commit hooks is load-bearing, pinned by a test: attachAfterCommit seeds
7
7
  // lastNativeText from mount-time props, afterCommit compares against that seed. Reversed, the
8
8
  // first beat would see an empty mirror and command a redundant write on every input.
9
- import { appListenerFor, blurTextInput, dispatchViewCommand, dlog, focusTextInput, Platform, propOf, propsOf, registerHostBehavior, requestCommitFor, setBehaviorListener, setInputBlurred, setInputFocused, setProp, } from '@symbiote-native/engine';
10
- import { attachPressMachine, detachPressMachine, } from './pressable.js';
9
+ import { appListenerFor, blurTextInput, dispatchViewCommand, dlog, focusTextInput, Platform, propOf, propsOf, registerHostBehavior, requestCommitFor, setInputBlurred, setInputFocused, setNodeDispatch, setProp, } from '@symbiote-native/engine';
10
+ import { attachPressMachine, detachPressMachine, PRESS_DISPATCH, } from './pressable.js';
11
11
  import { eventCountFromChange, foldText, INITIAL_EVENT_COUNT, SELECTION_NONE, shouldCommandText, textFromChange, } from '../state/text-input.js';
12
12
  // Both spellings, because `multiline` picks between two Fabric views and the TAG is what decides.
13
13
  // The behavior is registered for both so it does not care which one the app wrote.
@@ -186,15 +186,29 @@ function attach(node) {
186
186
  // No `requestCommitFor` here: at create the renderer commits anyway, and on a re-attach the key
187
187
  // is already at this value, so `setProp`'s identity guard makes the write a no-op.
188
188
  setProp(node, 'mostRecentEventCount', INITIAL_EVENT_COUNT);
189
- setBehaviorListener(node, 'change', event => onChange(node, event));
190
- setBehaviorListener(node, 'focus', event => onFocus(node, event));
191
- setBehaviorListener(node, 'blur', event => onBlur(node, event));
192
- setBehaviorListener(node, 'selectionChange', event => onSelectionChange(node, event));
193
189
  attachPressMachine(node, {
194
190
  refine: focusOnPress,
195
191
  cancelableOf: textInputCancelable,
196
192
  });
193
+ // AFTER the machine, which points the node at its own: a node holds exactly one dispatch, and
194
+ // `TEXT_INPUT_DISPATCH` is the union that delegates the machine's seven back to it
195
+ setNodeDispatch(node, TEXT_INPUT_DISPATCH);
197
196
  }
197
+ const OWN_HANDLERS = new Map([
198
+ ['change', onChange],
199
+ ['focus', onFocus],
200
+ ['blur', onBlur],
201
+ ['selectionChange', onSelectionChange],
202
+ ]);
203
+ const TEXT_INPUT_DISPATCH = {
204
+ names: new Set([...OWN_HANDLERS.keys(), ...PRESS_DISPATCH.names]),
205
+ deliver(node, name, event) {
206
+ const own = OWN_HANDLERS.get(name);
207
+ if (own !== undefined)
208
+ return own(node, event);
209
+ return PRESS_DISPATCH.deliver(node, name, event);
210
+ },
211
+ };
198
212
  // TextInput.js:597 with its `rejectResponderTermination = true` default (:905): iOS keeps the
199
213
  // gesture unless the app opts in; Android hands Pressability `null`, i.e. its own default (yield).
200
214
  function textInputCancelable(source) {
@@ -1,12 +1,5 @@
1
1
  import { type IHostBehavior } from '@symbiote-native/engine';
2
2
  import { type IDisabledResolver } from './pressable';
3
3
  export declare const TOUCHABLE_OPACITY_TAG = "touchable-opacity";
4
- /**
5
- * The behavior as PARTS, so a tag that is a TouchableOpacity plus something — `button`, which RN
6
- * builds as exactly that (Button.js:283) — composes the fade instead of re-implementing it.
7
- *
8
- * Safe to hand to two tags: every piece of runtime is keyed by NODE (`states`), and `press` is
9
- * itself already shared that way.
10
- */
11
4
  export declare function createTouchableOpacityBehavior(disabledOf?: IDisabledResolver): IHostBehavior;
12
5
  export declare function registerTouchableOpacityBehavior(): void;
@@ -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, RESTING_OPACITY, restingOpacityFromStyle, TOUCHABLE_MIN_PRESS_DURATION_MS, } from '../state/touchable.js';
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
- function fadeTo(state, toValue, ms) {
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 = states.get(node);
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: our
90
- // engine's own 'pressIn' event (an ordinary grant, or its delayed replay) is the 0ms leg;
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
- * The behavior as PARTS, so a tag that is a TouchableOpacity plus something — `button`, which RN
120
- * builds as exactly that (Button.js:283) — composes the fade instead of re-implementing it.
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,72 +151,50 @@ export function createTouchableOpacityBehavior(disabledOf) {
130
151
  return {
131
152
  ...machine,
132
153
  attach(node) {
133
- const state = {
134
- opacity: new AnimatedValue(RESTING_OPACITY),
135
- runtime: createTouchableFeedbackRuntime(),
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 Button `disabled` is
154
- // the three-way answer of `props.disabled ?? aria-disabled ?? accessibilityState.disabled`
155
- // (Button.js:331,337), so reading the prop here left an aria-only flip un-settled — the view
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 = state.settled;
161
- state.settled = { disabled, resting };
162
- if (previous === undefined) {
163
- // NOT vendor parity, despite this comment's old claim: `TouchableOpacity-itest.js` ("does
164
- // not render explicit opacity when using default") proves a resting, untouched
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(state, resting, OPACITY_INACTIVE_DURATION_MS);
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
- if (state === undefined)
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
- setAnimatedBehaviorStyle(node, undefined);
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
  },
@@ -71,15 +71,22 @@ function numberOr(value, fallback) {
71
71
  // The clone is `foldCloneOntoChild` in C++, keyed on the PARENT'S tag; TWF's passthrough half
72
72
  // copies only when SET, unlike TNF's unconditional list. One upstream quirk it reproduces:
73
73
  // `nativeID` wins over `id` here (`node.nativeIdWinsOverId`, `clone-onto-child-payload.itest.ts`).
74
- /**
75
- * `delayPressIn` / `delayPressOut` (:186-188) plus RN's unconditional `minPressDuration: 0` (:190).
76
- *
77
- * The scheduler is `../state/touchable`'s — the one `./touchable-opacity` drives its fade with,
78
- * here with `activate` / `deactivate` forwarding and painting nothing. It reads the OWNER, so the
79
- * owner is closed over rather than taken from the refinement's argument, which is the CHILD.
80
- */
81
- function refinementFor(owner, state) {
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) {
82
88
  return (_node, config) => {
89
+ const state = stateOf(owner);
83
90
  const handlers = createTouchableFeedbackHandlers({
84
91
  delayPressIn: numberOr(propOf(owner, 'delayPressIn'), 0),
85
92
  delayPressOut: numberOr(propOf(owner, 'delayPressOut'), 0),
@@ -127,18 +134,13 @@ function trampolineFor(owner, name) {
127
134
  function forwardListener(owner, child, name, wired) {
128
135
  setBehaviorListener(child, name, wired ? trampolineFor(owner, name) : undefined);
129
136
  }
130
- // The machine plus the forwarded listeners. Split from adoption because a parked subtree comes back
131
- // with its child and its fold intact but its machine torn down — `attach` re-runs this, and must
132
- // 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
133
140
  function arm(owner, child) {
134
- let state = states.get(owner);
135
- if (state === undefined) {
136
- state = { runtime: createTouchableFeedbackRuntime(), timers: new Set() };
137
- states.set(owner, state);
138
- }
139
141
  attachPressMachine(child, {
140
142
  source: owner,
141
- refine: refinementFor(owner, state),
143
+ refine: refinementFor(owner),
142
144
  });
143
145
  for (const name of FORWARDED_LISTENERS) {
144
146
  forwardListener(owner, child, name, appListenerFor(owner, name) !== undefined);
package/build/index.d.ts CHANGED
@@ -47,6 +47,7 @@ export type { ITextInputProps, ITextInputHandle, ITextInputSelection, ITextInput
47
47
  export { DEFAULT_WINDOW_SIZE, DEFAULT_INITIAL_NUM_TO_RENDER, DEFAULT_MAX_TO_RENDER_PER_BATCH, DEFAULT_UPDATE_CELLS_BATCHING_PERIOD, DEFAULT_VIEW_AREA_COVERAGE_PERCENT_THRESHOLD, FIRST_INDEX, EMPTY_OFFSET, NO_INDEX, NO_CONTENT_LENGTH_SENT, INVERTED_Y_STYLE, INVERTED_X_STYLE, readScrollOffset, readLayoutLength, readLayoutOffset, buildOffsets, computeWindow, throttleWindow, isCellViewable, offsetForIndex, averageMeasuredLength, averageMeasuredStride, highestMeasuredIndex, computeEndReached, computeStartReached, buildViewabilityPairs, computeViewableSet, diffViewable, maxMinimumViewTime, buildListPlan, computeMvcpAdjustment, resolveItemKey, indexOfItem, offsetForEnd, isSeparatorGapInRange, decideEdgeReached, resolveStickySectionHeaders, wrapFixedLayout, resolveAverageLength, } from './state/virtualized-list';
48
48
  export type { IMvcpAction, IMvcpAdjustmentParams, IMvcpAdjustmentResult, ICellLayout, ISeparators, ISeparatorProps, IViewToken, IViewableItemsChangedInfo, IViewabilityConfig, IViewabilityConfigCallbackPair, IVirtualizedListHandle, IViewableSetParams, IListCellPlan, IListPlan, IListPlanParams, } from './state/virtualized-list';
49
49
  export { reduceList, createInitialListState, listEffectSignature, } from './state/virtualized-list-reducer';
50
+ export { LIST_ACTION_KIND, LIST_EFFECT_KIND } from './state/list-kinds';
50
51
  export type { IListState, IListMetrics, IListReducerInputs, IListAction, IListEffect, IListReduceResult, } from './state/virtualized-list-reducer';
51
52
  export { subscribeListDiagnostics } from './state/virtualized-list-diagnostics';
52
53
  export type { IListDiagnosticFrame, IListDiagnosticMove, } from './state/virtualized-list-diagnostics';
package/build/index.js CHANGED
@@ -71,6 +71,7 @@ export { DEFAULT_WINDOW_SIZE, DEFAULT_INITIAL_NUM_TO_RENDER, DEFAULT_MAX_TO_REND
71
71
  // after-commit effect skeleton (window recompute -> edge -> viewability -> initial-scroll -> MVCP)
72
72
  // out of each adapter's reactive glue. The adapter maps events to actions and runs the effects.
73
73
  export { reduceList, createInitialListState, listEffectSignature, } from './state/virtualized-list-reducer.js';
74
+ export { LIST_ACTION_KIND, LIST_EFFECT_KIND } from './state/list-kinds.js';
74
75
  export { subscribeListDiagnostics } from './state/virtualized-list-diagnostics.js';
75
76
  export { SINGLE_COLUMN, chunkIntoRows, rowKeyExtractor, expandRowToken, expandRowViewability, lastItemOfRow, firstItemOfRow, removeClippedSubviewsOrDefault, } from './state/flat-list.js';
76
77
  export { flattenSections, unwrapEntryItem, sectionEntryKey, scrollLocationToFlatIndex, } from './state/section-list.js';
@@ -0,0 +1,22 @@
1
+ export declare const LIST_ACTION_KIND: {
2
+ readonly scroll: "scroll";
3
+ readonly layout: "layout";
4
+ readonly measure: "measure";
5
+ readonly refreshMetrics: "refresh-metrics";
6
+ readonly batchTick: "batch-tick";
7
+ readonly recordInteraction: "record-interaction";
8
+ readonly viewableFired: "viewable-fired";
9
+ readonly commit: "commit";
10
+ readonly scrollToOffset: "scroll-to-offset";
11
+ readonly scrollToIndex: "scroll-to-index";
12
+ readonly scrollToItem: "scroll-to-item";
13
+ readonly scrollToEnd: "scroll-to-end";
14
+ };
15
+ export declare const LIST_EFFECT_KIND: {
16
+ readonly scrollTo: "scroll-to";
17
+ readonly fireEndReached: "fire-end-reached";
18
+ readonly fireStartReached: "fire-start-reached";
19
+ readonly fireViewable: "fire-viewable";
20
+ readonly scheduleRefill: "schedule-refill";
21
+ readonly fireScrollToIndexFailed: "fire-scroll-to-index-failed";
22
+ };
@@ -0,0 +1,25 @@
1
+ // Names for the `kind` discriminants of the list reducer's actions and effects, so an adapter
2
+ // compares against a constant instead of repeating the string. `satisfies` keeps each value a
3
+ // real member of the union, a renamed kind stops compiling here
4
+ export const LIST_ACTION_KIND = {
5
+ scroll: 'scroll',
6
+ layout: 'layout',
7
+ measure: 'measure',
8
+ refreshMetrics: 'refresh-metrics',
9
+ batchTick: 'batch-tick',
10
+ recordInteraction: 'record-interaction',
11
+ viewableFired: 'viewable-fired',
12
+ commit: 'commit',
13
+ scrollToOffset: 'scroll-to-offset',
14
+ scrollToIndex: 'scroll-to-index',
15
+ scrollToItem: 'scroll-to-item',
16
+ scrollToEnd: 'scroll-to-end',
17
+ };
18
+ export const LIST_EFFECT_KIND = {
19
+ scrollTo: 'scroll-to',
20
+ fireEndReached: 'fire-end-reached',
21
+ fireStartReached: 'fire-start-reached',
22
+ fireViewable: 'fire-viewable',
23
+ scheduleRefill: 'schedule-refill',
24
+ fireScrollToIndexFailed: 'fire-scroll-to-index-failed',
25
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@symbiote-native/components",
3
- "version": "3.1.2",
3
+ "version": "3.1.4",
4
4
  "description": "Framework-agnostic component logic (state machines + render functions) for SymbioteNative — written once, inherited by every adapter (React, Vue, Angular, ...).",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -59,11 +59,11 @@
59
59
  },
60
60
  "peerDependencies": {
61
61
  "react-native": ">=0.86",
62
- "@symbiote-native/engine": "^1.3.1"
62
+ "@symbiote-native/engine": "^1.5.0"
63
63
  },
64
64
  "devDependencies": {
65
65
  "@types/node": "^26.0.0",
66
- "@symbiote-native/engine": "1.3.1"
66
+ "@symbiote-native/engine": "1.5.0"
67
67
  },
68
68
  "scripts": {
69
69
  "typecheck": "tsc --build",