@rootnative/impulse 0.0.0-alpha.0 → 0.0.0-alpha.1

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 (64) hide show
  1. package/CHANGELOG.md +51 -5
  2. package/README.md +206 -11
  3. package/dist/{chunk-5BMRKYVY.js → chunk-2UZTAWUQ.js} +20 -2
  4. package/dist/chunk-BNSFDNLA.js +75 -0
  5. package/dist/chunk-DXXGWG4Q.js +136 -0
  6. package/dist/{chunk-PMR25UCT.js → chunk-HGBCIL6X.js} +1 -1
  7. package/dist/chunk-HI5PHDJY.js +94 -0
  8. package/dist/{chunk-IG5RXCYR.js → chunk-IH7SQ5X6.js} +11 -15
  9. package/dist/chunk-MWCIEVTA.js +199 -0
  10. package/dist/{chunk-F4RHM4ZK.js → chunk-NYDDZD4G.js} +58 -1
  11. package/dist/chunk-PGOQSKEJ.js +144 -0
  12. package/dist/{chunk-FR242SUF.js → chunk-TGOGIZDH.js} +13 -7
  13. package/dist/chunk-VEPUHGPN.js +12 -0
  14. package/dist/chunk-YZHAQ4XK.js +136 -0
  15. package/dist/compose/index.d.ts +1 -1
  16. package/dist/double-tap/index.d.ts +184 -0
  17. package/dist/double-tap/index.js +5 -0
  18. package/dist/drag/index.d.ts +18 -13
  19. package/dist/drag/index.js +3 -3
  20. package/dist/index.d.ts +10 -3
  21. package/dist/index.js +12 -5
  22. package/dist/long-press/index.d.ts +219 -0
  23. package/dist/long-press/index.js +4 -0
  24. package/dist/pan/index.d.ts +221 -0
  25. package/dist/pan/index.js +4 -0
  26. package/dist/pinch/index.d.ts +239 -0
  27. package/dist/pinch/index.js +4 -0
  28. package/dist/raw/index.d.ts +3 -3
  29. package/dist/raw/index.js +2 -2
  30. package/dist/rotate/index.d.ts +263 -0
  31. package/dist/rotate/index.js +4 -0
  32. package/dist/swipe/index.d.ts +254 -0
  33. package/dist/swipe/index.js +4 -0
  34. package/dist/tap/index.d.ts +34 -29
  35. package/dist/tap/index.js +4 -3
  36. package/dist/tapEvent-KSSojt_l.d.ts +28 -0
  37. package/dist/{types-Ch2HM3aP.d.ts → types-ChGKY28a.d.ts} +27 -1
  38. package/dist/{useGestureMemo-Ccv8rB0C.d.ts → useGestureMemo-BRW1EKcJ.d.ts} +1 -1
  39. package/jest-setup.cjs +32 -1
  40. package/llms.txt +155 -0
  41. package/package.json +35 -2
  42. package/src/index.ts +50 -4
  43. package/src/intents/double-tap/index.ts +6 -0
  44. package/src/intents/long-press/index.ts +6 -0
  45. package/src/intents/pan/index.ts +2 -0
  46. package/src/intents/pinch/index.ts +2 -0
  47. package/src/intents/rotate/index.ts +6 -0
  48. package/src/intents/swipe/index.ts +8 -0
  49. package/src/intents/tapEvent.ts +50 -0
  50. package/src/intents/useDoubleTap.ts +321 -0
  51. package/src/intents/useDrag.ts +49 -28
  52. package/src/intents/useLongPress.ts +391 -0
  53. package/src/intents/usePan.ts +444 -0
  54. package/src/intents/usePinch.ts +483 -0
  55. package/src/intents/useRotate.ts +507 -0
  56. package/src/intents/useSwipe.ts +585 -0
  57. package/src/intents/useTap.ts +51 -60
  58. package/src/internal/intentResult.ts +67 -0
  59. package/src/internal/phaseCallbacks.ts +51 -0
  60. package/src/internal/useGestureMemo.ts +32 -4
  61. package/src/internal/useLatestCallback.ts +2 -2
  62. package/src/raw/useRawGesture.ts +1 -1
  63. package/src/relations/index.ts +94 -0
  64. package/src/types.ts +27 -0
@@ -0,0 +1,321 @@
1
+ import { useMemo } from 'react'
2
+ import { Gesture, type TapGesture } from 'react-native-gesture-handler'
3
+ import { useSharedValue } from 'react-native-reanimated'
4
+ import { scheduleOnRN } from 'react-native-worklets'
5
+ import {
6
+ useGestureMemo,
7
+ type GestureMemoOptions,
8
+ } from '../internal/useGestureMemo'
9
+ import { buildIntentResult } from '../internal/intentResult'
10
+ import { useLatestCallback } from '../internal/useLatestCallback'
11
+ import { useStableRecord } from '../internal/useStableRecord'
12
+ import { toTapEvent, type TapEvent } from './tapEvent'
13
+ import { type HitSlop, type IntentEndInfo, type IntentResult } from '../types'
14
+
15
+ export type { TapEvent } from './tapEvent'
16
+
17
+ /**
18
+ * How long each tap may hold the finger down, in milliseconds.
19
+ *
20
+ * RNGH's own default, restated for the same reason `useTap` restates it: the
21
+ * number is part of the documented behaviour, and inheriting it means an RNGH
22
+ * release can move it underneath Impulse without a word.
23
+ */
24
+ const DEFAULT_MAX_DURATION = 500
25
+
26
+ /**
27
+ * How long the gap between the two taps may be, in milliseconds.
28
+ *
29
+ * RNGH's own default, restated. It is also the number that decides how much
30
+ * latency a single tap pays when the two are composed — see `maxDelay`.
31
+ */
32
+ const DEFAULT_MAX_DELAY = 500
33
+
34
+ /**
35
+ * How far the finger may travel and still count as a tap, in points.
36
+ *
37
+ * Impulse's number rather than RNGH's, exactly as in `useTap`: RNGH leaves
38
+ * the slop to the platform, so the same tap is accepted on one operating
39
+ * system and rejected on the other. The two hooks share the value on purpose
40
+ * — a double tap that is fussier than a single tap on the same view is a
41
+ * difference the consumer never asked for.
42
+ *
43
+ * **This number is a design intention, not a measurement.** The device sweep
44
+ * of 2026-09-19 did not test it. See Known gaps in docs/docs/roadmap.md.
45
+ */
46
+ const DEFAULT_MAX_DISTANCE = 10
47
+
48
+ /** Options for {@link useDoubleTap}. */
49
+ export interface UseDoubleTapOptions extends GestureMemoOptions {
50
+ /**
51
+ * How many fingers must be down. Default `1`.
52
+ *
53
+ * The count applies to both taps — a two-finger double tap is two taps of
54
+ * two fingers, not a tap of two followed by a tap of one.
55
+ */
56
+ pointers?: number
57
+ /**
58
+ * How long each tap may hold the finger down, in milliseconds. Default
59
+ * `500`.
60
+ *
61
+ * This is per tap, not for the pair. A finger that stays down past it fails
62
+ * the gesture rather than counting as the second tap.
63
+ */
64
+ maxDuration?: number
65
+ /**
66
+ * How long the gap between the two taps may be, in milliseconds. Default
67
+ * `500`.
68
+ *
69
+ * **Lowering it is how a composed single tap stops feeling slow.** When a
70
+ * `useTap` is composed `exclusive` behind this hook, the single tap cannot
71
+ * report until this window has passed without a second tap, so every
72
+ * ordinary tap on that view waits this long. 500ms is RNGH's number and is
73
+ * generous; 250 to 300 is closer to what the platforms themselves use.
74
+ *
75
+ * The device sweep of 2026-09-19 found that a deliberate double tap still
76
+ * registers at 250, on an Android emulator and an iOS simulator. Whether
77
+ * the single-tap latency feels acceptable is unmeasured. See Known gaps in
78
+ * docs/docs/roadmap.md.
79
+ */
80
+ maxDelay?: number
81
+ /**
82
+ * How far the finger may travel within a tap, in points. Default `10`.
83
+ *
84
+ * This does not limit how far the second tap may land from the first — RNGH
85
+ * measures each tap on its own.
86
+ */
87
+ maxDistance?: number
88
+ /**
89
+ * Extra touchable area around the view, in points.
90
+ *
91
+ * Written inline as an object is fine — the gesture is not rebuilt when the
92
+ * contents are unchanged.
93
+ */
94
+ hitSlop?: HitSlop
95
+ /**
96
+ * Whether the gesture is recognized at all. Default `true`.
97
+ *
98
+ * Prefer this over unmounting the `<GestureDetector>`: a disabled gesture
99
+ * keeps its identity and its relations, so re-enabling it does not
100
+ * re-attach anything.
101
+ */
102
+ enabled?: boolean
103
+ /**
104
+ * The double tap ended. **Runs on the JS thread** — Impulse owns the
105
+ * `scheduleOnRN` boundary, so this is an ordinary function and may touch React
106
+ * state.
107
+ *
108
+ * The payload describes the second tap, which is the one the consumer means
109
+ * when they ask where the double tap was.
110
+ *
111
+ * It fires only for a double tap the recognizer accepted, and `cancelled`
112
+ * says what happened after that. `false` is the ordinary double tap. `true`
113
+ * means the system took the recognized double tap away — a competing
114
+ * gesture in a relation won it, or the app went to the background.
115
+ *
116
+ * **Check `cancelled` before you act on it.** A handler that zooms or
117
+ * navigates should do nothing when it is `true`. The path is rare: a single
118
+ * tap that was never followed by a second reaches `onFinalize` with
119
+ * `success: false` and never gets here.
120
+ */
121
+ onDoubleTap?: (event: TapEvent, info: IntentEndInfo) => void
122
+ /**
123
+ * The first finger went down and the gesture is now a candidate. **This is
124
+ * a worklet** — mark it with the `'worklet'` directive, and do not touch
125
+ * React state from it.
126
+ *
127
+ * It fires once for the pair, on the first touch, not once per tap. Most of
128
+ * the touches that reach it are ordinary single taps that will fail this
129
+ * gesture, so it is a poor place to show anything the user would read as
130
+ * commitment.
131
+ */
132
+ onBegin?: (event: TapEvent) => void
133
+ /**
134
+ * The gesture is over, whether both taps happened or not. **This is a
135
+ * worklet.**
136
+ *
137
+ * `success` is `true` when the double tap was recognized. This is the right
138
+ * place to clear anything `onBegin` set, because it runs on both paths.
139
+ */
140
+ onFinalize?: (event: TapEvent, success: boolean) => void
141
+ }
142
+
143
+ /**
144
+ * What {@link useDoubleTap} returns.
145
+ *
146
+ * Its own name rather than an alias of `UseTapResult`, so the two can diverge
147
+ * without a breaking rename. A double tap produces no continuous value, so
148
+ * the gesture, the ref, and `isActive` are the whole result.
149
+ */
150
+ export type UseDoubleTapResult = IntentResult<TapGesture>
151
+
152
+ /**
153
+ * Recognize two taps in quick succession.
154
+ *
155
+ * ```tsx
156
+ * const double = useDoubleTap({ onDoubleTap: () => zoomIn() })
157
+ *
158
+ * return (
159
+ * <GestureDetector gesture={double.gesture}>
160
+ * <View />
161
+ * </GestureDetector>
162
+ * )
163
+ * ```
164
+ *
165
+ * `onDoubleTap` runs on the JS thread and may set React state directly.
166
+ * `onBegin` and `onFinalize` are worklets and run on the UI thread — the name
167
+ * states the thread, so there is nothing to configure and no `scheduleOnRN` to
168
+ * write.
169
+ *
170
+ * `isActive` is a shared value that is `true` from the first finger down
171
+ * until the pair resolves. It is **not** a useful pressed state on its own:
172
+ * it is `true` for every ordinary single tap on the view as well, and those
173
+ * mostly end in failure. Drive a pressed state from a `useTap` composed with
174
+ * this one, and use `isActive` here only for something that should show while
175
+ * a double tap is genuinely in progress.
176
+ *
177
+ * **Pairing with a single tap.** This is the composition that makes both
178
+ * work, and the mode matters:
179
+ *
180
+ * ```tsx
181
+ * const double = useDoubleTap({ maxDelay: 250, onDoubleTap: zoomIn })
182
+ * const tap = useTap({ onTap: select })
183
+ * const { gesture } = useGestures([double, tap], { mode: 'exclusive' })
184
+ * ```
185
+ *
186
+ * `exclusive` tries the members in order and lets a later one activate only
187
+ * after every earlier one has failed, so the single tap waits to learn
188
+ * whether a second tap is coming. `race` does not work: a single tap
189
+ * recognizes on the first release and would win every time. The cost is
190
+ * latency — the single tap cannot report for `maxDelay` milliseconds — which
191
+ * is why lowering `maxDelay` is the first thing to reach for when a composed
192
+ * single tap feels slow.
193
+ *
194
+ * **A view that only needs a double tap does not need the composition.** Use
195
+ * this hook alone; there is nothing for it to wait on.
196
+ *
197
+ * **Three taps and up are not modelled.** A triple tap is rare enough that a
198
+ * named intent would be dead API. Build it with `useRawGesture` and
199
+ * `Gesture.Tap().numberOfTaps(3)`.
200
+ *
201
+ * **Activation criteria.** `maxDuration` and `maxDelay` default to 500ms, and
202
+ * `maxDistance` to 10 points. The distance default is Impulse's, not RNGH's,
203
+ * and matches `useTap` so the two agree about what counts as a tap on one
204
+ * view. The device sweep found that `maxDelay` works at 500ms and at 250ms.
205
+ * It did not test `maxDuration` or `maxDistance`, and it measured no feel.
206
+ *
207
+ * **Web.** RNGH's web implementation recognizes tap from pointer events, so a
208
+ * double click behaves as a double tap. The browser's own double-click
209
+ * handling — text selection, zoom — is not suppressed by this hook, and
210
+ * `pointers` above 1 is unreliable there because a mouse reports one pointer
211
+ * and touch emulation varies by browser.
212
+ *
213
+ * **Accessibility.** A double tap is invisible to a screen reader and
214
+ * unreachable from a keyboard, and it is worse than a single tap on both
215
+ * counts: VoiceOver and TalkBack both consume a double tap as their own
216
+ * activation gesture, so a screen-reader user cannot reach this at all.
217
+ * Whatever it does must be reachable another way — an explicit control, or
218
+ * `accessibilityActions` with `onAccessibilityAction` on the view the gesture
219
+ * is attached to. A double-tap-only affordance is a bug, not a trade-off.
220
+ *
221
+ * @param options - Activation criteria, callbacks, and the `alongside` /
222
+ * `blocks` / `deferTo` coexistence options every Impulse hook accepts.
223
+ */
224
+ export function useDoubleTap(
225
+ options: UseDoubleTapOptions = {},
226
+ ): UseDoubleTapResult {
227
+ const {
228
+ pointers = 1,
229
+ maxDuration = DEFAULT_MAX_DURATION,
230
+ maxDelay = DEFAULT_MAX_DELAY,
231
+ maxDistance = DEFAULT_MAX_DISTANCE,
232
+ enabled,
233
+ onDoubleTap,
234
+ onBegin,
235
+ onFinalize,
236
+ } = options
237
+
238
+ const isActive = useSharedValue(false)
239
+ // `hitSlop` is the one option a consumer writes as an object literal, so it
240
+ // is the one that would rebuild the gesture every render if taken as-is.
241
+ const hitSlop = useStableRecord(options.hitSlop)
242
+ // JS-thread callback: reached through a stable identity so it is never a
243
+ // gesture dependency. The worklet callbacks below stay direct dependencies,
244
+ // because a worklet is captured as written.
245
+ const handleDoubleTap = useLatestCallback(onDoubleTap)
246
+ // Attaching a handler is not the same as calling it: RNGH decides which
247
+ // thread a gesture's callbacks run on by inspecting the ones it was given,
248
+ // so the gesture does have to change when `onDoubleTap` appears or
249
+ // disappears. This is a boolean, so it changes only when that is actually
250
+ // true.
251
+ const hasDoubleTapHandler = onDoubleTap !== undefined
252
+
253
+ const built = useGestureMemo(
254
+ 'useDoubleTap',
255
+ () => {
256
+ const tap = Gesture.Tap()
257
+ .numberOfTaps(2)
258
+ .minPointers(pointers)
259
+ .maxDuration(maxDuration)
260
+ .maxDelay(maxDelay)
261
+ .maxDistance(maxDistance)
262
+ .onBegin((event) => {
263
+ 'worklet'
264
+ isActive.value = true
265
+ onBegin?.(toTapEvent(event))
266
+ })
267
+ .onEnd((event, success) => {
268
+ 'worklet'
269
+ // Not guarded on `success`: RNGH calls `onEnd` only when the old
270
+ // state was ACTIVE, so reaching here at all means the double tap
271
+ // was recognized. `cancelled` then separates the one the user
272
+ // completed from the one the system took away. Without it the
273
+ // cancel is reportable only from `onFinalize`, which is a worklet.
274
+ if (hasDoubleTapHandler) {
275
+ scheduleOnRN(handleDoubleTap, toTapEvent(event), {
276
+ cancelled: !success,
277
+ })
278
+ }
279
+ })
280
+ .onFinalize((event, success) => {
281
+ 'worklet'
282
+ isActive.value = false
283
+ onFinalize?.(toTapEvent(event), success)
284
+ })
285
+
286
+ // Applied conditionally rather than with a default, so an option the
287
+ // consumer did not set leaves RNGH's own default in place instead of
288
+ // Impulse overwriting it with a guess.
289
+ if (hitSlop !== undefined) {
290
+ tap.hitSlop(hitSlop)
291
+ }
292
+ if (enabled !== undefined) {
293
+ tap.enabled(enabled)
294
+ }
295
+ return tap
296
+ },
297
+ [
298
+ pointers,
299
+ maxDuration,
300
+ maxDelay,
301
+ maxDistance,
302
+ hitSlop,
303
+ enabled,
304
+ hasDoubleTapHandler,
305
+ handleDoubleTap,
306
+ isActive,
307
+ onBegin,
308
+ onFinalize,
309
+ ],
310
+ options,
311
+ )
312
+
313
+ // Memoised for the same reason `useGestureMemo` memoises its own result: a
314
+ // consumer may put the whole hook result in a dependency list, and a fresh
315
+ // object every render would make that dependency useless. `isActive` is
316
+ // stable for the life of the hook, so `built` is the only real input.
317
+ return useMemo(
318
+ () => buildIntentResult(built, { isActive }),
319
+ [built, isActive],
320
+ )
321
+ }
@@ -6,18 +6,21 @@ import {
6
6
  type PanGesture,
7
7
  type PanGestureHandlerEventPayload,
8
8
  } from 'react-native-gesture-handler'
9
- import {
10
- runOnJS,
11
- useSharedValue,
12
- type SharedValue,
13
- } from 'react-native-reanimated'
9
+ import { useSharedValue, type SharedValue } from 'react-native-reanimated'
10
+ import { scheduleOnRN } from 'react-native-worklets'
14
11
  import {
15
12
  useGestureMemo,
16
13
  type GestureMemoOptions,
17
14
  } from '../internal/useGestureMemo'
15
+ import { buildIntentResult } from '../internal/intentResult'
18
16
  import { useLatestCallback } from '../internal/useLatestCallback'
19
17
  import { useStableRecord } from '../internal/useStableRecord'
20
- import { type HitSlop, type IntentResult, type Point } from '../types'
18
+ import {
19
+ type HitSlop,
20
+ type IntentEndInfo,
21
+ type IntentResult,
22
+ type Point,
23
+ } from '../types'
21
24
 
22
25
  /**
23
26
  * How far the finger must travel before the drag takes the touch, in points.
@@ -28,8 +31,10 @@ import { type HitSlop, type IntentResult, type Point } from '../types'
28
31
  * can share a view, so it is set here rather than left to the consumer to
29
32
  * discover.
30
33
  *
31
- * **This number is a design intention, not a measurement.** No hardware pass
32
- * has happened. See Known gaps in CLAUDE.md.
34
+ * **This number is a design intention, not a measurement.** The device sweep
35
+ * of 2026-09-19 found that it works mechanically on an Android emulator and
36
+ * an iOS simulator. Whether it feels right is unmeasured. See Known gaps in
37
+ * docs/docs/roadmap.md.
33
38
  */
34
39
  const DEFAULT_THRESHOLD = 10
35
40
 
@@ -179,7 +184,7 @@ export interface UseDragOptions extends GestureMemoOptions {
179
184
  enabled?: boolean
180
185
  /**
181
186
  * The drag passed `threshold` and now owns the touch. **Runs on the JS
182
- * thread** — Impulse owns the `runOnJS` boundary, so this is an ordinary
187
+ * thread** — Impulse owns the `scheduleOnRN` boundary, so this is an ordinary
183
188
  * function and may touch React state.
184
189
  *
185
190
  * This is the first moment the drag has definitely won. `onBegin` fires
@@ -187,17 +192,21 @@ export interface UseDragOptions extends GestureMemoOptions {
187
192
  */
188
193
  onDragStart?: (event: DragEvent) => void
189
194
  /**
190
- * The finger lifted and the drag is over. **Runs on the JS thread.**
195
+ * The drag is over. **Runs on the JS thread.**
191
196
  *
192
- * Fires only for a drag that activated and then released. A drag the system
193
- * took away — a competing gesture won, or the app went to the background —
194
- * reaches `onFinalize` with `success: false` and never gets here, because
195
- * there was no release and so no velocity worth seeding a spring with.
197
+ * Fires only for a drag that activated, so a touch that never passed the
198
+ * threshold never reaches here on either path.
196
199
  *
197
- * Read `velocity` to seed that release animation, and `settled` for where
198
- * an elastic overshoot should return to.
200
+ * It fires for both endings, and `cancelled` says which. `false` is the
201
+ * finger lifting, and `velocity` then seeds the release animation. `true`
202
+ * is the system taking the drag away — a competing gesture won, or the app
203
+ * went to the background. **There was no release on that path, so the
204
+ * velocity describes the last movement rather than a throw.** Return the
205
+ * view to `settled` instead of springing it.
206
+ *
207
+ * Read `settled` on both paths for where an elastic overshoot belongs.
199
208
  */
200
- onDragEnd?: (event: DragEvent) => void
209
+ onDragEnd?: (event: DragEvent, info: IntentEndInfo) => void
201
210
  /**
202
211
  * The finger went down and the gesture is now a candidate. **This is a
203
212
  * worklet** — mark it with the `'worklet'` directive, and do not touch
@@ -212,7 +221,7 @@ export interface UseDragOptions extends GestureMemoOptions {
212
221
  * The drag moved. **This is a worklet**, and it runs on every frame the
213
222
  * finger moves.
214
223
  *
215
- * There is no JS-thread counterpart on purpose. A per-frame `runOnJS` is a
224
+ * There is no JS-thread counterpart on purpose. A per-frame `scheduleOnRN` is a
216
225
  * scheduling cost paid sixty times a second for a value that is already on
217
226
  * the thread that needs it — read `x` and `y` from a `useAnimatedStyle`
218
227
  * instead, and let this callback handle what the style cannot.
@@ -324,7 +333,8 @@ function clamp(
324
333
  * vertical `ScrollView` leaves the scroll alone until the finger commits
325
334
  * sideways; with `'both'` it is a radial distance. Set `failOffset` as well
326
335
  * when a mostly-diagonal move should go to the other gesture rather than to
327
- * this one. The default has not been measured on hardware yet.
336
+ * this one. The default works mechanically in the device sweep, and its feel
337
+ * is unmeasured.
328
338
  *
329
339
  * **Coexistence.** A threshold decides who moves first; it does not decide
330
340
  * who wins a contested touch. For a drag inside a scroll view, say which one
@@ -452,7 +462,7 @@ export function useDrag(options: UseDragOptions = {}): UseDragResult {
452
462
  startY.value = y.value - event.translationY
453
463
  isActive.value = true
454
464
  if (hasDragStart) {
455
- runOnJS(handleDragStart)(toDragEvent(event))
465
+ scheduleOnRN(handleDragStart, toDragEvent(event))
456
466
  }
457
467
  })
458
468
  .onUpdate((event) => {
@@ -477,13 +487,21 @@ export function useDrag(options: UseDragOptions = {}): UseDragResult {
477
487
  })
478
488
  .onEnd((event, success) => {
479
489
  'worklet'
480
- // Guarded on `success`, because RNGH calls this for a cancelled
481
- // drag too. A cancelled drag had its touch taken away — by a
482
- // competing gesture winning, or the app going to the background —
483
- // and there was no release, so there is no velocity worth seeding a
484
- // spring with. That path reaches `onFinalize` instead.
485
- if (success && hasDragEnd) {
486
- runOnJS(handleDragEnd)(toDragEvent(event))
490
+ // Not guarded on `success`: RNGH calls this for a cancelled drag
491
+ // too, and `cancelled` is what separates the two. The guard used to
492
+ // be here, which left the cancel reportable only from `onFinalize`
493
+ // — a worklet — so a consumer holding phase in React state had to
494
+ // cross the thread boundary by hand. A drag that is taken away
495
+ // still has to put its view somewhere, and that decision belongs on
496
+ // the JS thread as much as the release does.
497
+ //
498
+ // Reporting the cancel is safe because RNGH calls `onEnd` only when
499
+ // the old state was ACTIVE. A touch that never passed the threshold
500
+ // reaches `onFinalize` and never gets here.
501
+ if (hasDragEnd) {
502
+ scheduleOnRN(handleDragEnd, toDragEvent(event), {
503
+ cancelled: !success,
504
+ })
487
505
  }
488
506
  })
489
507
  .onFinalize((event, success) => {
@@ -559,5 +577,8 @@ export function useDrag(options: UseDragOptions = {}): UseDragResult {
559
577
  // consumer may put the whole hook result in a dependency list, and a fresh
560
578
  // object every render would make that dependency useless. The shared values
561
579
  // are stable for the life of the hook, so `built` is the only real input.
562
- return useMemo(() => ({ ...built, x, y, isActive }), [built, x, y, isActive])
580
+ return useMemo(
581
+ () => buildIntentResult(built, { x, y, isActive }),
582
+ [built, x, y, isActive],
583
+ )
563
584
  }