@symbiote-native/engine 1.3.0 → 1.4.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.
Files changed (79) hide show
  1. package/build/accessibility-props.d.ts +0 -11
  2. package/build/accessibility-props.js +30 -68
  3. package/build/animated/graph.js +1 -1
  4. package/build/animated/leaf-lifecycle.js +2 -2
  5. package/build/asset-source-resolver.d.ts +2 -0
  6. package/build/asset-source-resolver.js +13 -0
  7. package/build/back-handler/index.d.ts +1 -5
  8. package/build/back-handler/index.js +0 -6
  9. package/build/debug.js +8 -22
  10. package/build/dispatch.js +3 -9
  11. package/build/events/delivery.d.ts +9 -0
  12. package/build/events/delivery.js +143 -0
  13. package/build/events/index.js +199 -660
  14. package/build/events/names.d.ts +24 -0
  15. package/build/events/names.js +80 -0
  16. package/build/events/press.d.ts +20 -0
  17. package/build/events/press.js +89 -0
  18. package/build/events/responder.d.ts +6 -0
  19. package/build/events/responder.js +124 -0
  20. package/build/fabric-props.js +74 -179
  21. package/build/fabric.d.ts +0 -13
  22. package/build/fabric.js +18 -38
  23. package/build/host-access.d.ts +1 -128
  24. package/build/host-access.js +96 -205
  25. package/build/host-behavior.d.ts +0 -100
  26. package/build/host-behavior.js +125 -311
  27. package/build/image-loader.js +10 -23
  28. package/build/image-source-resolver.js +3 -7
  29. package/build/image-source-write.d.ts +0 -11
  30. package/build/image-source-write.js +14 -34
  31. package/build/imperative.d.ts +2 -28
  32. package/build/imperative.js +60 -93
  33. package/build/index.d.ts +6 -2
  34. package/build/index.js +29 -39
  35. package/build/mutation-buffer.d.ts +3 -177
  36. package/build/mutation-buffer.js +162 -316
  37. package/build/native-engine.d.ts +6 -102
  38. package/build/native-engine.js +60 -141
  39. package/build/native-events.js +9 -18
  40. package/build/native-tree-host.d.ts +0 -21
  41. package/build/native-tree-host.js +15 -31
  42. package/build/node-events.d.ts +11 -0
  43. package/build/node-events.js +145 -0
  44. package/build/node-instance.d.ts +8 -0
  45. package/build/node-instance.js +168 -0
  46. package/build/node-props.d.ts +13 -0
  47. package/build/node-props.js +131 -0
  48. package/build/node-route.d.ts +2 -0
  49. package/build/node-route.js +151 -0
  50. package/build/node-style.d.ts +15 -0
  51. package/build/node-style.js +214 -0
  52. package/build/node-tree.d.ts +6 -0
  53. package/build/node-tree.js +159 -0
  54. package/build/node-types.d.ts +70 -0
  55. package/build/node-types.js +36 -0
  56. package/build/node.d.ts +7 -309
  57. package/build/node.js +9 -1564
  58. package/build/post-commit.js +3 -8
  59. package/build/process-aspect-ratio.js +3 -7
  60. package/build/process-background-longhands.js +10 -19
  61. package/build/process-filter.js +11 -19
  62. package/build/process-font-variant.js +3 -7
  63. package/build/registry.d.ts +0 -33
  64. package/build/registry.js +22 -57
  65. package/build/report-error.js +4 -18
  66. package/build/structured-style.d.ts +0 -9
  67. package/build/structured-style.js +16 -31
  68. package/build/styles.js +3 -6
  69. package/build/surface.d.ts +0 -26
  70. package/build/surface.js +29 -76
  71. package/build/text-input-state.js +4 -8
  72. package/build/touch-history.js +5 -11
  73. package/build/tree-host.d.ts +7 -270
  74. package/build/tree-host.js +63 -153
  75. package/build/view-config.js +17 -37
  76. package/cpp/SymbioteEngineBindings.cpp +19 -18
  77. package/cpp/SymbioteTree.cpp +81 -156
  78. package/cpp/SymbioteTree.h +6 -0
  79. package/package.json +2 -2
@@ -1,374 +1,217 @@
1
- // Event normalization. Fabric delivers raw touch primitives to a single global
2
- // handler, with the instanceHandle (our SymbioteNode) as the target. There is no
3
- // raw `press` event; a tap is synthesized from a touch sequence: the start and
4
- // end targets are correlated so a press fires only when the touch ends on the
5
- // node it started on (or a descendant). Bubbling events walk target -> root,
6
- // invoking each ancestor's listener until one calls stopPropagation. Layout is a
7
- // direct event in RN and is delivered only to its own target.
1
+ // Event normalization. Fabric delivers raw touch primitives to a single global handler with the
2
+ // `instanceHandle` (our `ISymbioteNode`) as the target, and there is no raw `press`: a tap is
3
+ // synthesized here from a touch sequence
4
+ // The pieces live in siblings, kept apart т.к. they answer different questions: `./names` what an
5
+ // event is called, `./delivery` who receives it, `./press` what a tap is made of, `./responder`
6
+ // who owns the gesture
8
7
  import { dlog, isDebug } from '../debug.js';
9
8
  import { runWrapped } from '../dispatch.js';
10
9
  import { getSlot } from '../fabric.js';
11
- import { isAnchor, isSymbioteNode, } from '../node.js';
10
+ import { isSymbioteNode } from '../node.js';
12
11
  import { registeredNativeEvent } from '../registry.js';
13
- import { ancestorsOf, parentOf } from '../host-access.js';
14
- import { setIsJSResponder } from '../imperative.js';
15
12
  import { attachTouchHistory, recordTouchTrack, resetTouchHistory, touchHistory, } from '../touch-history.js';
16
- import { isRecord } from '../type-guards.js';
17
- // Raw Fabric event -> listener name, split by dispatch phase. Press is synthesized
18
- // from a touch sequence and layout is direct, so neither lives in this table.
19
- // Bubbling events walk target -> root; direct events fire only on the target.
20
- const BUBBLING_EVENTS = {
21
- topFocus: 'focus',
22
- topBlur: 'blur',
23
- topChange: 'change',
24
- topEndEditing: 'endEditing',
25
- topSubmitEditing: 'submitEditing',
26
- topKeyPress: 'keyPress',
27
- };
28
- const DIRECT_EVENTS = {
29
- topLayout: 'layout',
30
- topScroll: 'scroll',
31
- topScrollBeginDrag: 'scrollBeginDrag',
32
- topScrollEndDrag: 'scrollEndDrag',
33
- topMomentumScrollBegin: 'momentumScrollBegin',
34
- topMomentumScrollEnd: 'momentumScrollEnd',
35
- topSelectionChange: 'selectionChange',
36
- topContentSizeChange: 'contentSizeChange',
37
- topLoadStart: 'loadStart',
38
- topLoad: 'load',
39
- topLoadEnd: 'loadEnd',
40
- topError: 'error',
41
- topProgress: 'progress',
42
- topPartialLoad: 'partialLoad',
43
- topRefresh: 'refresh',
44
- topShow: 'show',
45
- topRequestClose: 'requestClose',
46
- topDismiss: 'dismiss',
47
- topOrientationChange: 'orientationChange',
48
- // Text glyph layout (onTextLayout) and the iOS status-bar-tap scroll-to-top.
49
- topTextLayout: 'textLayout',
50
- topScrollToTop: 'scrollToTop',
51
- // Accessibility events from RN's base ViewConfig; any view can emit them.
52
- // accessibilityAction fires on iOS + Android; the iOS-only three (accessibilityTap,
53
- // magicTap, accessibilityEscape) have no Android producer, so they are inert there.
54
- topAccessibilityAction: 'accessibilityAction',
55
- topAccessibilityTap: 'accessibilityTap',
56
- topMagicTap: 'magicTap',
57
- topAccessibilityEscape: 'accessibilityEscape',
58
- };
59
- const TOUCH_START = 'topTouchStart';
60
- const TOUCH_MOVE = 'topTouchMove';
61
- const TOUCH_END = 'topTouchEnd';
62
- const TOUCH_CANCEL = 'topTouchCancel';
63
- const PRESS = 'press';
64
- // Responder protocol (PanResponder / Touchable). RN's two-phase negotiation:
65
- // every should-set is asked CAPTURE (root -> target) then BUBBLE (target -> root),
66
- // and the first node returning true wins, on a touch START *and* on every MOVE,
67
- // so a node can claim the responder mid-gesture. If someone already holds it, the
68
- // incumbent is asked onResponderTerminationRequest; a true answer (or no listener)
69
- // hands it over (terminate + grant), a false answer rejects the taker. Lifecycle
70
- // events are direct (grant/start/move/end/release/terminate/reject). Listener names
71
- // are post-`on` (onResponderMove -> 'responderMove').
72
- const START_SHOULD_SET = 'startShouldSetResponder';
73
- const START_SHOULD_SET_CAPTURE = 'startShouldSetResponderCapture';
74
- const MOVE_SHOULD_SET = 'moveShouldSetResponder';
75
- const MOVE_SHOULD_SET_CAPTURE = 'moveShouldSetResponderCapture';
76
- const RESPONDER_GRANT = 'responderGrant';
77
- const RESPONDER_REJECT = 'responderReject';
78
- const RESPONDER_START = 'responderStart';
79
- const RESPONDER_MOVE = 'responderMove';
80
- const RESPONDER_END = 'responderEnd';
81
- const RESPONDER_RELEASE = 'responderRelease';
82
- const RESPONDER_TERMINATE = 'responderTerminate';
83
- const RESPONDER_TERMINATION_REQUEST = 'responderTerminationRequest';
84
- // Synthesized alongside press so Pressable can show pressed-state feedback: both
85
- // fire on the node the touch STARTED on (the responder), pressOut on end/cancel.
86
- const PRESS_IN = 'pressIn';
87
- const PRESS_OUT = 'pressOut';
88
- // Synthesized from a sustained hold so bare Text/View onLongPress fires without a
89
- // native event (Pressable runs the same timer in JS). Default delay matches RN's
90
- // Touchable (500ms); a fired long press suppresses the tap on release.
91
- const LONG_PRESS = 'longPress';
92
- const DEFAULT_LONG_PRESS_MS = 500;
93
- // Pressability cancels the pending long press when the touch drifts past this many
94
- // points from where it started (Pressability.DEFAULT_LONG_PRESS_DEACTIVATION_DISTANCE).
95
- const LONG_PRESS_DEACTIVATION_DISTANCE = 10;
13
+ import { bubble, callOwnListener, deliverDirect, endsWithin, hasListenerInPath, } from './delivery.js';
14
+ import { BUBBLING_EVENTS, DEFAULT_LONG_PRESS_MS, DIRECT_EVENTS, LONG_PRESS, LONG_PRESS_DEACTIVATION_DISTANCE, PRESS, PRESS_IN, PRESS_OUT, RESPONDER_END, RESPONDER_MOVE, RESPONDER_RELEASE, RESPONDER_START, RESPONDER_TERMINATE, TOUCH_CANCEL, TOUCH_END, TOUCH_MOVE, TOUCH_START, } from './names.js';
15
+ import { activePresses, clearLongPress, findActivePress, hasRemainingTouchWithin, readTouchPoint, takeAllPresses, takeEndedPresses, } from './press.js';
16
+ import { clearResponder, heldResponder, negotiateResponder, releaseResponderToNative, } from './responder.js';
17
+ // Fabric calls one handler for every surface, so the registration happens once per runtime
96
18
  let installed = false;
97
- // Each unrelated target owns an independent press. Additional fingers under the same owner join
98
- // that press, so one target still receives exactly one pressIn/press/pressOut lifecycle.
99
- const activePresses = new Set();
100
- // The node that claimed the responder for the in-flight touch (PanResponder), or
101
- // undefined when nobody claimed it. Receives move and release/terminate.
102
- let currentResponder;
103
- function clearLongPress(press) {
104
- if (press.longPressTimer !== undefined) {
105
- clearTimeout(press.longPressTimer);
19
+ function armLongPress(press, nativeEvent) {
20
+ press.longPressTimer = setTimeout(() => {
21
+ if (!activePresses.has(press))
22
+ return;
106
23
  press.longPressTimer = undefined;
107
- }
108
- }
109
- function takeAllPresses() {
110
- const presses = [...activePresses];
111
- activePresses.clear();
112
- for (const press of presses)
113
- clearLongPress(press);
114
- return presses;
24
+ press.longPressFired = true;
25
+ dlog('synthesized longPress -> dispatch');
26
+ runWrapped(() => bubble(press.owner, LONG_PRESS, nativeEvent));
27
+ }, DEFAULT_LONG_PRESS_MS);
115
28
  }
116
- function findActivePress(target) {
117
- for (const press of activePresses) {
118
- if (endsWithin(target, press.owner))
119
- return press;
120
- }
121
- return undefined;
122
- }
123
- // Read the changed finger's page coordinate from a raw native touch event, falling back to the
124
- // active-touch list and then undefined. Callers skip coordinate-dependent logic rather than guess.
125
- function readTouchPoint(nativeEvent) {
126
- const fromPair = (source) => {
127
- if (!source)
128
- return undefined;
129
- const { pageX, pageY } = source;
130
- if (typeof pageX === 'number' && typeof pageY === 'number')
131
- return { x: pageX, y: pageY };
132
- return undefined;
133
- };
134
- const direct = fromPair(nativeEvent);
135
- if (direct)
136
- return direct;
137
- // `changedTouches` identifies the finger for this frame. Read it before the full active-touch
138
- // list, whose first entry may belong to another simultaneously pressed target.
139
- for (const key of ['changedTouches', 'touches']) {
140
- const touches = nativeEvent[key];
141
- if (!Array.isArray(touches))
142
- continue;
143
- for (const touch of touches) {
144
- if (!isRecord(touch))
145
- continue;
146
- const point = fromPair(touch);
147
- if (point)
148
- return point;
149
- }
150
- }
151
- return undefined;
152
- }
153
- // Whether any touch still down started inside an owner (its target IS the owner or a descendant).
154
- // RN's noResponderTouches walks nativeEvent.touches and returns false the moment one is found; a
155
- // responder release and our synthesized press completion both wait until none remain. The headless
156
- // smokes fire with an empty `{}` event -> no remaining touch -> preserve single-touch behavior.
157
- function hasRemainingTouchWithin(owner, nativeEvent) {
158
- const touches = nativeEvent.touches;
159
- if (!Array.isArray(touches))
160
- return false;
161
- for (const touch of touches) {
162
- if (!isRecord(touch))
163
- continue;
164
- const target = touch.target;
165
- if (isSymbioteNode(target) && endsWithin(target, owner))
166
- return true;
167
- }
168
- return false;
169
- }
170
- // Whether any node from `target` up to the root listens for `listenerName`, used to
171
- // arm the long-press timer only when a handler would actually receive it.
172
- function hasListenerInPath(target, listenerName) {
173
- for (let node = target; node; node = parentOf(node)) {
174
- if (node.listeners?.has(listenerName) === true)
175
- return true;
176
- }
177
- return false;
178
- }
179
- // Invoke one node's own listener (no bubbling) and hand back its return value, so
180
- // the responder negotiation can read the boolean from onStartShouldSetResponder.
181
- function callOwnListener(node, listenerName, nativeEvent) {
182
- const listener = node.listeners?.get(listenerName);
183
- if (!listener)
184
- return undefined;
185
- return listener({
186
- type: listenerName,
187
- target: node,
188
- currentTarget: node,
189
- nativeEvent,
190
- stopPropagation: () => { },
29
+ function onTouchStart(target, nativeEvent) {
30
+ if (isDebug()) {
31
+ dlog(`event ${TOUCH_START} on ${target.component}`);
32
+ // A responder surviving into a one-touch start lost its end or cancel. If it is an ancestor of
33
+ // the target, this whole tap goes to it and the release finally clears it
34
+ if (heldResponder() !== undefined)
35
+ dlog(`touchStart while ${heldResponder()?.component} still holds it`);
36
+ }
37
+ // Update the touch bank, then attach it so responder handlers read each touch's own
38
+ // previous-to-current delta. RN records before dispatch too
39
+ recordTouchTrack('start', nativeEvent);
40
+ attachTouchHistory(nativeEvent);
41
+ const canJoinExistingPress = Array.isArray(nativeEvent.touches) && nativeEvent.touches.length > 1;
42
+ const joinedPress = canJoinExistingPress
43
+ ? findActivePress(target)
44
+ : undefined;
45
+ // A one-touch or identifier-less frame starts a new physical gesture, so any surviving press is
46
+ // stale (its end or cancel was lost) and gets released instead of suppressing this start
47
+ const stalePresses = canJoinExistingPress ? [] : takeAllPresses();
48
+ let startedPress;
49
+ if (joinedPress === undefined) {
50
+ startedPress = {
51
+ owner: target,
52
+ longPressTimer: undefined,
53
+ longPressFired: false,
54
+ longPressStart: readTouchPoint(nativeEvent),
55
+ };
56
+ activePresses.add(startedPress);
57
+ if (hasListenerInPath(target, LONG_PRESS))
58
+ armLongPress(startedPress, nativeEvent);
59
+ }
60
+ runWrapped(() => {
61
+ for (const stale of stalePresses)
62
+ bubble(stale.owner, PRESS_OUT, nativeEvent);
63
+ if (startedPress)
64
+ bubble(startedPress.owner, PRESS_IN, nativeEvent);
65
+ else
66
+ dlog('pressIn retained (another touch joined the active press)');
67
+ // A View can be both a Pressable and a PanResponder target, so the negotiation runs alongside
68
+ // press synthesis rather than instead of it
69
+ negotiateResponder(target, 'start', nativeEvent);
70
+ const responder = heldResponder();
71
+ if (responder)
72
+ callOwnListener(responder, RESPONDER_START, nativeEvent);
191
73
  });
192
74
  }
193
- // The node chain from `from` up to the root, deepest first. The single allocation
194
- // the two-phase walk indexes both ways (capture reads it reversed).
195
- //
196
- // ONE CROSSING, not one per level. This used to climb `parentOf`, which crosses the host boundary
197
- // each step — 18 questions per event on a depth-8 chain, and again on every frame of every drag for
198
- // the responder's scope. The host holds the tree, so the chain is one answer it can give: the same
199
- // argument `parentsOf` / `subtreesOf` won for the teardown sweep (F-2) and `nextSiblingOf` for Vue's
200
- // quadratic (F-19).
201
- function pathToRoot(from) {
202
- return [...ancestorsOf(from)];
203
- }
204
- // Depth of a node below the root (root = 0). Aligns two nodes before the lockstep
205
- // climb to their lowest common ancestor.
206
- function depthOf(node) {
207
- let depth = 0;
208
- for (let n = parentOf(node); n; n = parentOf(n))
209
- depth++;
210
- return depth;
211
- }
212
- // RN's getLowestCommonAncestor over our parent pointers: lift the deeper node to the
213
- // shallower one's depth, then climb both in lockstep until they meet (ResponderEvent-
214
- // Plugin.getLowestCommonAncestor). Used to scope the move re-negotiation.
215
- function lowestCommonAncestor(a, b) {
216
- let da = depthOf(a);
217
- let db = depthOf(b);
218
- let na = a;
219
- let nb = b;
220
- while (na && da > db) {
221
- na = parentOf(na);
222
- da--;
223
- }
224
- while (nb && db > da) {
225
- nb = parentOf(nb);
226
- db--;
227
- }
228
- while (na && nb) {
229
- if (na === nb)
230
- return na;
231
- na = parentOf(na);
232
- nb = parentOf(nb);
233
- }
234
- return undefined;
75
+ // Only the press owning this move may lose its long-press timer: movement on an unrelated
76
+ // simultaneously held Pressable must not disturb another target's clock
77
+ function cancelLongPressOnDrift(target, nativeEvent) {
78
+ const movedPress = findActivePress(target);
79
+ if (movedPress?.longPressTimer === undefined || !movedPress.longPressStart)
80
+ return;
81
+ const here = readTouchPoint(nativeEvent);
82
+ if (!here)
83
+ return;
84
+ const dx = here.x - movedPress.longPressStart.x;
85
+ const dy = here.y - movedPress.longPressStart.y;
86
+ if (Math.hypot(dx, dy) > LONG_PRESS_DEACTIVATION_DISTANCE) {
87
+ dlog('longPress cancelled (moved past deactivation distance)');
88
+ clearLongPress(movedPress);
89
+ }
90
+ }
91
+ function onTouchMove(target, nativeEvent) {
92
+ recordTouchTrack('move', nativeEvent);
93
+ attachTouchHistory(nativeEvent);
94
+ cancelLongPressOnDrift(target, nativeEvent);
95
+ runWrapped(() => {
96
+ // Re-negotiate first: a node can claim the responder mid-gesture through
97
+ // `onMoveShouldSetResponder`, and the responder itself is skipped
98
+ negotiateResponder(target, 'move', nativeEvent);
99
+ // The only consumer of a move is the responder; without one RN drops it too
100
+ const responder = heldResponder();
101
+ if (responder)
102
+ callOwnListener(responder, RESPONDER_MOVE, nativeEvent);
103
+ });
235
104
  }
236
- // RN's two-phase should-set walk: CAPTURE root -> deepest, then BUBBLE deepest -> root;
237
- // the first node returning true wins. `skip` is excluded from both passes; RN skips
238
- // the deepest node when it IS the current responder (you don't ask the holder to
239
- // re-claim), so its should-set callback never consumes the gesture frame out from under
240
- // its own onResponderMove (PanResponder folds geometry in the should-set-capture
241
- // handler, so asking the responder again would zero its move).
242
- function findWantsResponder(path, captureName, bubbleName, nativeEvent, skip) {
243
- for (let i = path.length - 1; i >= 0; i--) {
244
- if (path[i] !== skip &&
245
- callOwnListener(path[i], captureName, nativeEvent) === true) {
246
- return path[i];
105
+ // Each target completes independently. An honest tap ends inside that target, while `pressOut`
106
+ // always releases the pressed state, a drag-away end included
107
+ function completePress(press, target, nativeEvent) {
108
+ if (endsWithin(target, press.owner)) {
109
+ if (press.longPressFired)
110
+ dlog('press suppressed (longPress already fired)');
111
+ else {
112
+ dlog('event press -> dispatch');
113
+ bubble(press.owner, PRESS, nativeEvent);
247
114
  }
248
115
  }
249
- for (const node of path) {
250
- if (node !== skip &&
251
- callOwnListener(node, bubbleName, nativeEvent) === true)
252
- return node;
253
- }
254
- return undefined;
116
+ bubble(press.owner, PRESS_OUT, nativeEvent);
255
117
  }
256
- // Tell native which node owns the gesture, RN's `injectGlobalResponderHandler`
257
- // (ReactFabric-dev.js:18862) — the OLD owner first, then the new one, both in one call so no
258
- // site can do half of it.
259
- //
260
- // WITHOUT THIS A JS RESPONDER LOSES TO ANY SCROLL VIEW ABOVE IT, and it is invisible from JS:
261
- // `onStartShouldSetResponder` returns true, the native UIScrollView never learns the gesture was
262
- // claimed, and every subsequent move arrives as `topScroll` instead of `topTouchMove` — so the
263
- // negotiation never even gets a move to grant on. Device-diagnosed 2026-09-08 on a PanResponder
264
- // drag box inside the canary's ScrollView: `startShouldSet -> true` followed by
265
- // `topScrollBeginDrag` and twenty `topScroll`, with no grant and no move.
266
- //
267
- // `blockNativeResponder` is the taker's own `onResponderGrant` return, exactly as RN reads it.
268
- function handOverNativeResponder(from, to, blockNativeResponder) {
269
- if (isDebug()) {
270
- dlog(`setIsJSResponder from=${from === undefined ? 'none' : 'yes'} ` +
271
- `to=${to === undefined ? 'none' : 'yes'} block=${blockNativeResponder}`);
272
- }
273
- if (from !== undefined)
274
- setIsJSResponder(from, false, blockNativeResponder);
275
- if (to !== undefined)
276
- setIsJSResponder(to, true, blockNativeResponder);
277
- }
278
- // Whether the taker asked native to stand down. RN reads this off the grant dispatch's return;
279
- // an absent listener means no claim, which is RN's `blockNativeResponder || false`.
280
- function blocksNative(result) {
281
- return result === true;
282
- }
283
- // Negotiate (or re-negotiate) the responder for a touch start/move. If nobody holds
284
- // it, the winner is granted. If someone does, the incumbent is asked to relinquish
285
- // via onResponderTerminationRequest (absent listener = implicit yes); on yes it is
286
- // terminated and the taker granted, on no the taker is rejected.
287
- function negotiateResponder(target, phase, nativeEvent) {
288
- // With no responder, ask the full path from the touch target. With one, RN scopes
289
- // the walk to the lowest common ancestor of responder+target upward (never below
290
- // the responder) and skips the deepest node when it IS the responder (Responder-
291
- // EventPlugin.setResponderAndExtractTransfer). At touch start currentResponder is
292
- // cleared, so this collapses to the plain target->root start walk.
293
- //
294
- // ONE WALK, and the common case never needs a second. `pathToRoot` crosses the host boundary per
295
- // level, and the previous shape paid `lowestCommonAncestor` — `depthOf(a)` plus `depthOf(b)` plus
296
- // a lockstep climb — BEFORE walking again from the answer. Measured on a depth-8 chain: 19
297
- // crossings per move, of which 18 were the ancestor search, on a path that runs 60 times a second
298
- // for the whole of a drag (`__tests__/touch-crossing-cost.test.ts`).
299
- //
300
- // The scope RN computes is the lowest common ancestor of responder and target, and while a finger
301
- // stays inside the view that claimed it — which is what a drag IS — the responder is an ANCESTOR
302
- // of the target, so the LCA is the responder itself and it is already in the target's own path.
303
- // An index lookup answers it with nothing crossed, and the scoped path is that path's suffix.
304
- // The genuine cross-subtree case (the finger left the responder's subtree) still falls back to
305
- // the full search, and it is a rare frame rather than every frame.
306
- const targetPath = pathToRoot(target);
307
- const heldAt = currentResponder === undefined ? -1 : targetPath.indexOf(currentResponder);
308
- const from = currentResponder === undefined
309
- ? target
310
- : heldAt >= 0
311
- ? currentResponder
312
- : lowestCommonAncestor(currentResponder, target);
313
- if (!from)
118
+ // `responderEnd` fires on every finger leaving; the FINAL name (release on an end, terminate on a
119
+ // cancel) only once no touch remains inside the responder
120
+ function finishResponder(responder, finalName, isFinal, nativeEvent) {
121
+ callOwnListener(responder, RESPONDER_END, nativeEvent);
122
+ if (!isFinal)
314
123
  return;
315
- const path = from === target
316
- ? targetPath
317
- : heldAt >= 0
318
- ? targetPath.slice(heldAt)
319
- : pathToRoot(from);
320
- const skip = from === currentResponder ? from : undefined;
321
- const wants = phase === 'start'
322
- ? findWantsResponder(path, START_SHOULD_SET_CAPTURE, START_SHOULD_SET, nativeEvent, skip)
323
- : findWantsResponder(path, MOVE_SHOULD_SET_CAPTURE, MOVE_SHOULD_SET, nativeEvent, skip);
324
- // Every exit is logged: a negotiation that declines is indistinguishable from one that never
325
- // ran, and the two have opposite causes.
326
- if (!wants) {
327
- if (isDebug()) {
328
- dlog(`responder ${phase}: nobody wants it (path=${path.length}${skip === undefined ? '' : ', one skipped'})`);
124
+ callOwnListener(responder, finalName, nativeEvent);
125
+ releaseResponderToNative(responder);
126
+ }
127
+ function onTouchEnd(target, nativeEvent) {
128
+ recordTouchTrack('end', nativeEvent);
129
+ attachTouchHistory(nativeEvent);
130
+ const hadActivePress = activePresses.size > 0;
131
+ const completedPresses = takeEndedPresses(nativeEvent);
132
+ const responder = heldResponder();
133
+ // RN releases the responder only when no remaining touch still down started inside it, so
134
+ // lifting ONE finger of a multi-touch gesture must not release. `onResponderEnd` still fires
135
+ const releases = responder !== undefined && !hasRemainingTouchWithin(responder, nativeEvent);
136
+ if (releases)
137
+ clearResponder();
138
+ runWrapped(() => {
139
+ for (const press of completedPresses)
140
+ completePress(press, target, nativeEvent);
141
+ if (!hadActivePress) {
142
+ if (isDebug())
143
+ dlog(`event ${TOUCH_END} ignored (no matching start)`);
329
144
  }
330
- return;
331
- }
332
- if (wants === currentResponder) {
333
- if (isDebug())
334
- dlog(`responder ${phase}: ${wants.component} already holds it`);
335
- return;
336
- }
337
- if (currentResponder === undefined) {
338
- currentResponder = wants;
145
+ else if (completedPresses.length === 0)
146
+ dlog('press retained (another touch remains inside its owner)');
147
+ if (!responder)
148
+ return;
149
+ finishResponder(responder, RESPONDER_RELEASE, releases, nativeEvent);
150
+ if (!releases)
151
+ dlog('responderEnd without release (touches remain inside responder)');
152
+ });
153
+ // Once no touch is down, clear the bank so the next gesture starts clean
154
+ if (touchHistory.numberActiveTouches === 0)
155
+ resetTouchHistory();
156
+ }
157
+ // Android: a ScrollView whose scroller has not finished (fling tail, spring-back) intercepts the
158
+ // next down natively, and the tap reaches JS as start plus cancel with no press
159
+ function onTouchCancel(target, nativeEvent) {
160
+ if (isDebug())
161
+ dlog(`event ${TOUCH_CANCEL} on ${target.component}`);
162
+ recordTouchTrack('end', nativeEvent);
163
+ attachTouchHistory(nativeEvent);
164
+ // A cancel is scoped to the fingers removed from `touches`, just like an end, so an unrelated
165
+ // Pressable, or another finger under the same owner, keeps its press and long-press state
166
+ const cancelledPresses = takeEndedPresses(nativeEvent);
167
+ const responder = heldResponder();
168
+ const terminates = responder !== undefined && !hasRemainingTouchWithin(responder, nativeEvent);
169
+ if (terminates)
170
+ clearResponder();
171
+ runWrapped(() => {
172
+ for (const press of cancelledPresses)
173
+ bubble(press.owner, PRESS_OUT, nativeEvent);
174
+ if (!responder)
175
+ return;
176
+ finishResponder(responder, RESPONDER_TERMINATE, terminates, nativeEvent);
177
+ });
178
+ if (touchHistory.numberActiveTouches === 0)
179
+ resetTouchHistory();
180
+ }
181
+ const TOUCH_HANDLERS = {
182
+ [TOUCH_START]: onTouchStart,
183
+ [TOUCH_MOVE]: onTouchMove,
184
+ [TOUCH_END]: onTouchEnd,
185
+ [TOUCH_CANCEL]: onTouchCancel,
186
+ };
187
+ // A third-party Fabric view declares its own events, which the built-in tables do not know, so the
188
+ // registry answers keyed by the node's own component
189
+ function deliverNonTouch(target, topLevelType, nativeEvent) {
190
+ const direct = DIRECT_EVENTS[topLevelType];
191
+ if (direct !== undefined) {
339
192
  if (isDebug())
340
- dlog(`responder granted to ${wants.component}`);
341
- const granted = callOwnListener(wants, RESPONDER_GRANT, nativeEvent);
342
- handOverNativeResponder(undefined, wants, blocksNative(granted));
343
- return;
193
+ dlog(`event ${topLevelType} -> ${direct} (direct)`);
194
+ runWrapped(() => deliverDirect(target, direct, nativeEvent));
195
+ return true;
344
196
  }
345
- const incumbent = currentResponder;
346
- // A missing termination-request listener means implicit consent (RN default true);
347
- // only an explicit non-true answer keeps the incumbent and rejects the taker.
348
- const guarded = incumbent.listeners?.has(RESPONDER_TERMINATION_REQUEST) === true;
349
- const allowed = !guarded ||
350
- callOwnListener(incumbent, RESPONDER_TERMINATION_REQUEST, nativeEvent) ===
351
- true;
352
- if (allowed) {
353
- // RN's transfer order (setResponderAndExtractTransfer): grant the TAKER first, then
354
- // terminate the incumbent. RN dispatches grant ahead of the terminationRequest too,
355
- // purely to read the taker's block-native return; we have no native surface to
356
- // block, so on the REJECT path firing a grant the taker never keeps would be a
357
- // visible no-op event with no behavioral counterpart. We therefore fire grant
358
- // before terminate on the consent path (matching RN's grant<terminate ordering) and
359
- // omit it on reject; the consent OUTCOME is unchanged either way.
197
+ const bubbling = BUBBLING_EVENTS[topLevelType];
198
+ if (bubbling !== undefined) {
360
199
  if (isDebug())
361
- dlog(`responder transferred ${incumbent.component} -> ${wants.component}`);
362
- const granted = callOwnListener(wants, RESPONDER_GRANT, nativeEvent);
363
- callOwnListener(incumbent, RESPONDER_TERMINATE, nativeEvent);
364
- currentResponder = wants;
365
- handOverNativeResponder(incumbent, wants, blocksNative(granted));
200
+ dlog(`event ${topLevelType} -> ${bubbling} (bubble)`);
201
+ runWrapped(() => bubble(target, bubbling, nativeEvent));
202
+ return true;
366
203
  }
367
- else {
368
- if (isDebug())
369
- dlog(`responder takeover of ${incumbent.component} rejected`);
370
- callOwnListener(wants, RESPONDER_REJECT, nativeEvent);
204
+ const registered = registeredNativeEvent(target.component, topLevelType);
205
+ if (registered === undefined)
206
+ return false;
207
+ if (isDebug()) {
208
+ const phase = registered.direct ? 'direct' : 'bubble';
209
+ dlog(`event ${topLevelType} -> ${registered.listener} (${phase}, registered)`);
371
210
  }
211
+ runWrapped(() => registered.direct
212
+ ? deliverDirect(target, registered.listener, nativeEvent)
213
+ : bubble(target, registered.listener, nativeEvent));
214
+ return true;
372
215
  }
373
216
  export function installEventHandler() {
374
217
  if (installed)
@@ -377,321 +220,17 @@ export function installEventHandler() {
377
220
  getSlot().registerEventHandler((instanceHandle, topLevelType, nativeEvent) => {
378
221
  if (!isSymbioteNode(instanceHandle))
379
222
  return;
380
- if (topLevelType === TOUCH_START) {
381
- // The ternary is gone with the gate: `isSymbioteNode(instanceHandle)` was re-asked here
382
- // three lines after the early return above already proved it.
383
- if (isDebug()) {
384
- dlog(`event ${TOUCH_START} on ${instanceHandle.component}`);
385
- // A responder surviving into a one-touch start lost its end/cancel. If it is an ancestor
386
- // of the target, this whole tap goes to it and the release finally clears it.
387
- if (currentResponder !== undefined)
388
- dlog(`touchStart while ${currentResponder.component} still holds the responder`);
389
- }
390
- // Update the touch bank, then attach it so responder handlers (PanResponder)
391
- // read each touch's own previous->current delta; RN records before dispatch.
392
- recordTouchTrack('start', nativeEvent);
393
- attachTouchHistory(nativeEvent);
394
- const canJoinExistingPress = Array.isArray(nativeEvent.touches) && nativeEvent.touches.length > 1;
395
- const joinedPress = canJoinExistingPress
396
- ? findActivePress(instanceHandle)
397
- : undefined;
398
- // A one-touch or identifier-less frame starts a new physical gesture. Any surviving press
399
- // is stale (its end/cancel was lost), so release it instead of letting it suppress this start.
400
- const stalePresses = canJoinExistingPress ? [] : takeAllPresses();
401
- let startedPress;
402
- if (joinedPress === undefined) {
403
- startedPress = {
404
- owner: instanceHandle,
405
- longPressTimer: undefined,
406
- longPressFired: false,
407
- longPressStart: readTouchPoint(nativeEvent),
408
- };
409
- activePresses.add(startedPress);
410
- if (hasListenerInPath(instanceHandle, LONG_PRESS)) {
411
- const press = startedPress;
412
- press.longPressTimer = setTimeout(() => {
413
- if (!activePresses.has(press))
414
- return;
415
- press.longPressTimer = undefined;
416
- press.longPressFired = true;
417
- dlog('synthesized longPress -> dispatch');
418
- runWrapped(() => bubble(press.owner, LONG_PRESS, nativeEvent));
419
- }, DEFAULT_LONG_PRESS_MS);
420
- }
421
- }
422
- runWrapped(() => {
423
- for (const stale of stalePresses)
424
- bubble(stale.owner, PRESS_OUT, nativeEvent);
425
- if (startedPress)
426
- bubble(startedPress.owner, PRESS_IN, nativeEvent);
427
- else
428
- dlog('pressIn retained (another touch joined the active press)');
429
- // Responder negotiation runs alongside press synthesis: a View can be both
430
- // a Pressable (press) and a PanResponder target (responder).
431
- negotiateResponder(instanceHandle, 'start', nativeEvent);
432
- // onResponderStart is a direct event to whoever now holds the responder.
433
- if (currentResponder)
434
- callOwnListener(currentResponder, RESPONDER_START, nativeEvent);
435
- });
436
- return;
437
- }
438
- if (topLevelType === TOUCH_MOVE) {
439
- recordTouchTrack('move', nativeEvent);
440
- attachTouchHistory(nativeEvent);
441
- // Only the press owning this move can lose its long-press timer; movement on an unrelated
442
- // simultaneously held Pressable must not disturb another target's clock.
443
- const movedPress = findActivePress(instanceHandle);
444
- if (movedPress?.longPressTimer !== undefined &&
445
- movedPress.longPressStart) {
446
- const here = readTouchPoint(nativeEvent);
447
- if (here) {
448
- const dx = here.x - movedPress.longPressStart.x;
449
- const dy = here.y - movedPress.longPressStart.y;
450
- if (Math.hypot(dx, dy) > LONG_PRESS_DEACTIVATION_DISTANCE) {
451
- dlog('longPress cancelled (moved past deactivation distance)');
452
- clearLongPress(movedPress);
453
- }
454
- }
455
- }
456
- runWrapped(() => {
457
- // Re-negotiate first: a node can claim the responder mid-gesture via
458
- // onMoveShouldSetResponder (the responder itself is skipped, see negotiate).
459
- negotiateResponder(instanceHandle, 'move', nativeEvent);
460
- // The only consumer of a move is the responder; without one, RN drops it too.
461
- if (currentResponder)
462
- callOwnListener(currentResponder, RESPONDER_MOVE, nativeEvent);
463
- });
464
- return;
465
- }
466
- if (topLevelType === TOUCH_END) {
467
- recordTouchTrack('end', nativeEvent);
468
- attachTouchHistory(nativeEvent);
469
- const hadActivePress = activePresses.size > 0;
470
- const completedPresses = [];
471
- for (const press of activePresses) {
472
- if (hasRemainingTouchWithin(press.owner, nativeEvent))
473
- continue;
474
- activePresses.delete(press);
475
- clearLongPress(press);
476
- completedPresses.push(press);
477
- }
478
- const responder = currentResponder;
479
- // RN releases (and clears) the responder only when no remaining touch still down
480
- // started inside it; lifting ONE finger in a multi-touch gesture must NOT release.
481
- // onResponderEnd still fires on every finger-up. (ResponderEventPlugin: responderEnd
482
- // is unconditional, responderRelease is gated on noResponderTouches.)
483
- const releases = responder !== undefined &&
484
- !hasRemainingTouchWithin(responder, nativeEvent);
485
- if (releases)
486
- currentResponder = undefined;
487
- runWrapped(() => {
488
- for (const press of completedPresses) {
489
- // Each target completes independently. An honest tap ends inside that target; pressOut
490
- // always releases its pressed state, including a drag-away end.
491
- if (endsWithin(instanceHandle, press.owner)) {
492
- if (press.longPressFired) {
493
- dlog('press suppressed (longPress already fired)');
494
- }
495
- else {
496
- dlog('event press -> dispatch');
497
- bubble(press.owner, PRESS, nativeEvent);
498
- }
499
- }
500
- bubble(press.owner, PRESS_OUT, nativeEvent);
501
- }
502
- if (!hadActivePress) {
503
- if (isDebug())
504
- dlog(`event ${TOUCH_END} ignored (no matching start)`);
505
- }
506
- else if (completedPresses.length === 0) {
507
- dlog('press retained (another touch remains inside its owner)');
508
- }
509
- // onResponderEnd fires on every finger-up; onResponderRelease (the final
510
- // release) only when the last responder touch lifted.
511
- if (responder) {
512
- callOwnListener(responder, RESPONDER_END, nativeEvent);
513
- if (releases) {
514
- callOwnListener(responder, RESPONDER_RELEASE, nativeEvent);
515
- handOverNativeResponder(responder, undefined, false);
516
- }
517
- else
518
- dlog('responderEnd without release (touches remain inside responder)');
519
- }
520
- });
521
- // Once no touch is down, clear the bank so the next gesture starts clean.
522
- if (touchHistory.numberActiveTouches === 0)
523
- resetTouchHistory();
524
- return;
525
- }
526
- if (topLevelType === TOUCH_CANCEL) {
527
- // Android: a ScrollView whose scroller has not finished (fling tail, spring-back) intercepts
528
- // the next down natively, and the tap reaches JS as start + cancel with no press.
529
- if (isDebug())
530
- dlog(`event ${TOUCH_CANCEL} on ${instanceHandle.component}`);
531
- recordTouchTrack('end', nativeEvent);
532
- attachTouchHistory(nativeEvent);
533
- // A cancel is scoped to the finger(s) removed from `touches`, just like an end. An unrelated
534
- // Pressable—or another finger under the same owner—must keep its press and long-press state.
535
- const cancelledPresses = [];
536
- for (const press of activePresses) {
537
- if (hasRemainingTouchWithin(press.owner, nativeEvent))
538
- continue;
539
- activePresses.delete(press);
540
- clearLongPress(press);
541
- cancelledPresses.push(press);
542
- }
543
- const responder = currentResponder;
544
- const terminatesResponder = responder !== undefined &&
545
- !hasRemainingTouchWithin(responder, nativeEvent);
546
- if (terminatesResponder)
547
- currentResponder = undefined;
548
- runWrapped(() => {
549
- for (const press of cancelledPresses)
550
- bubble(press.owner, PRESS_OUT, nativeEvent);
551
- if (responder) {
552
- // Like touch-end, every finger leaving emits responderEnd. Termination is final only
553
- // when no touch remains inside the responder.
554
- callOwnListener(responder, RESPONDER_END, nativeEvent);
555
- if (terminatesResponder) {
556
- callOwnListener(responder, RESPONDER_TERMINATE, nativeEvent);
557
- handOverNativeResponder(responder, undefined, false);
558
- }
559
- }
560
- });
561
- if (touchHistory.numberActiveTouches === 0)
562
- resetTouchHistory();
563
- return;
564
- }
565
- const direct = DIRECT_EVENTS[topLevelType];
566
- if (direct !== undefined) {
567
- if (isDebug()) {
568
- dlog(`event ${topLevelType} -> ${direct} (direct)`);
569
- }
570
- runWrapped(() => deliverDirect(instanceHandle, direct, nativeEvent));
571
- return;
572
- }
573
- const bubbling = BUBBLING_EVENTS[topLevelType];
574
- if (bubbling !== undefined) {
575
- if (isDebug()) {
576
- dlog(`event ${topLevelType} -> ${bubbling} (bubble)`);
577
- }
578
- runWrapped(() => bubble(instanceHandle, bubbling, nativeEvent));
223
+ const touch = TOUCH_HANDLERS[topLevelType];
224
+ if (touch !== undefined) {
225
+ touch(instanceHandle, nativeEvent);
579
226
  return;
580
227
  }
581
- // Third-party Fabric views (registerComponent) declare their own events; the
582
- // built-in tables above don't know them, so fall back to the registry, keyed by
583
- // the node's own component. `direct` events fire only on their target, the rest
584
- // bubble: same split as the built-ins.
585
- const registered = registeredNativeEvent(instanceHandle.component, topLevelType);
586
- if (registered !== undefined) {
587
- if (isDebug()) {
588
- const phase = registered.direct ? 'direct' : 'bubble';
589
- dlog(`event ${topLevelType} -> ${registered.listener} (${phase}, registered)`);
590
- }
591
- runWrapped(() => registered.direct
592
- ? deliverDirect(instanceHandle, registered.listener, nativeEvent)
593
- : bubble(instanceHandle, registered.listener, nativeEvent));
228
+ if (deliverNonTouch(instanceHandle, topLevelType, nativeEvent))
594
229
  return;
595
- }
596
- // Nothing claimed this event: neither a built-in table nor the view's derived
597
- // config. A permanent diagnostic seam: if a native view fires something we drop
598
- // on the floor (an event the ViewConfig didn't surface, or a name mismatch),
599
- // this is where it shows up. Keeps "the handler silently did nothing" debuggable.
230
+ // Nothing claimed this event, neither a built-in table nor the view's derived config. A
231
+ // permanent seam: a native view firing something we drop on the floor shows up here
600
232
  if (isDebug()) {
601
233
  dlog(`event ${topLevelType} UNMATCHED on ${instanceHandle.component} (dropped)`);
602
234
  }
603
235
  });
604
236
  }
605
- // A press is honest only if the touch ends on the node it started on, or a
606
- // descendant of it: walk parent pointers up from the end target looking for the
607
- // start target. The start node may have been unmounted mid-touch (parent pointer
608
- // cleared); the walk simply runs out and returns false, no throw.
609
- function endsWithin(endTarget, start) {
610
- let node = endTarget;
611
- while (node) {
612
- if (node === start)
613
- return true;
614
- node = parentOf(node);
615
- }
616
- return false;
617
- }
618
- // Two-phase delivery, mirroring RN's accumulateTwoPhaseDispatches (legacy-events/
619
- // EventPropagators): CAPTURE root -> target first, invoking each node's
620
- // `<EventName>Capture` listener, then BUBBLE target -> root invoking the plain
621
- // listener. The same event object semantics apply to both passes; a stopPropagation
622
- // in capture halts before bubble ever runs. `target` stays the original node;
623
- // `currentTarget` tracks whose listener runs.
624
- function bubble(target, listenerName, nativeEvent) {
625
- let stopped = false;
626
- const stopPropagation = () => {
627
- stopped = true;
628
- };
629
- // Capture phase: root -> target. RN gathers captured listeners first (the
630
- // `<EventName>Capture` registration), so on*Capture handlers fire ahead of the
631
- // bubble pass. The path is built target -> root, then walked in reverse to get
632
- // root -> target without a second allocation.
633
- const captureName = `${listenerName}Capture`;
634
- const path = pathToRoot(target);
635
- for (let i = path.length - 1; i >= 0; i--) {
636
- const node = path[i];
637
- // Anchors (Angular's #anchor component hosts) never paint and have no native view. A
638
- // listener registered on one only exists because a framework's own output-binding
639
- // machinery (already delivered directly, e.g. Angular's EventEmitter.subscribe) also
640
- // registered it through Renderer2.listen. Bubbling into it would refire that same
641
- // callback a second time, so anchors are transparent to listener lookup, not just paint.
642
- const listener = isAnchor(node)
643
- ? undefined
644
- : node.listeners?.get(captureName);
645
- if (listener) {
646
- if (isDebug())
647
- dlog(`event ${listenerName} capture on ${node.component}`);
648
- listener({
649
- type: listenerName,
650
- target,
651
- currentTarget: node,
652
- nativeEvent,
653
- stopPropagation,
654
- });
655
- if (stopped)
656
- return;
657
- }
658
- }
659
- // Bubble phase: target -> root, invoking each ancestor's plain listener. Anchors are
660
- // transparent here too, same reason (see the capture-phase comment above).
661
- //
662
- // THE SAME ARRAY, read forward. This used to re-walk `parentOf` from the target, which is a
663
- // CROSSING of the host boundary per level — so one event asked the host for its ancestors twice,
664
- // measured at 18 questions on a depth-8 chain where 9 is the floor. The comment above already
665
- // says the path is built once "without a second allocation"; the second WALK is the expensive
666
- // half, and it was the one left in. Every touch and every frame of every drag pays it.
667
- for (const node of path) {
668
- const listener = isAnchor(node)
669
- ? undefined
670
- : node.listeners?.get(listenerName);
671
- if (listener) {
672
- const event = {
673
- type: listenerName,
674
- target,
675
- currentTarget: node,
676
- nativeEvent,
677
- stopPropagation,
678
- };
679
- listener(event);
680
- if (stopped)
681
- return;
682
- }
683
- }
684
- }
685
- // Direct (non-bubbling) delivery: only the target's own listener fires.
686
- function deliverDirect(target, listenerName, nativeEvent) {
687
- const listener = target.listeners?.get(listenerName);
688
- if (!listener)
689
- return;
690
- listener({
691
- type: listenerName,
692
- target,
693
- currentTarget: target,
694
- nativeEvent,
695
- stopPropagation: () => { },
696
- });
697
- }