@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
@@ -1,18 +1,18 @@
1
1
  import { useMemo } from 'react'
2
- import {
3
- Gesture,
4
- type GestureStateChangeEvent,
5
- type TapGesture,
6
- type TapGestureHandlerEventPayload,
7
- } from 'react-native-gesture-handler'
8
- import { runOnJS, useSharedValue } from 'react-native-reanimated'
2
+ import { Gesture, type TapGesture } from 'react-native-gesture-handler'
3
+ import { useSharedValue } from 'react-native-reanimated'
4
+ import { scheduleOnRN } from 'react-native-worklets'
9
5
  import {
10
6
  useGestureMemo,
11
7
  type GestureMemoOptions,
12
8
  } from '../internal/useGestureMemo'
9
+ import { buildIntentResult } from '../internal/intentResult'
13
10
  import { useLatestCallback } from '../internal/useLatestCallback'
14
11
  import { useStableRecord } from '../internal/useStableRecord'
15
- import { type HitSlop, type IntentResult, type Point } from '../types'
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
16
 
17
17
  /**
18
18
  * Maximum time the finger may stay down and still count as a tap, in
@@ -32,28 +32,11 @@ const DEFAULT_MAX_DURATION = 500
32
32
  * and rejected on the other. A fixed number is the behaviour a consumer can
33
33
  * reason about. 10 points is roughly a finger's own jitter while pressing.
34
34
  *
35
- * **This number is a design intention, not a measurement.** No hardware pass
36
- * has happened. See Known gaps in CLAUDE.md.
35
+ * **This number is a design intention, not a measurement.** The device sweep
36
+ * of 2026-09-19 did not test it. See Known gaps in docs/docs/roadmap.md.
37
37
  */
38
38
  const DEFAULT_MAX_DISTANCE = 10
39
39
 
40
- /** The intent-shaped payload a {@link useTap} callback receives. */
41
- export interface TapEvent {
42
- /** X of the tap, in points, relative to the view the gesture is attached to. */
43
- readonly x: number
44
- /** Y of the tap, in points, relative to the view the gesture is attached to. */
45
- readonly y: number
46
- /**
47
- * The same point relative to the window.
48
- *
49
- * Prefer it over `x` / `y` when the view itself is being transformed by the
50
- * gesture — a tap on a view that is mid-animation reports a moving `x`.
51
- */
52
- readonly absolute: Point
53
- /** How many fingers were down when the tap was recognized. */
54
- readonly pointers: number
55
- }
56
-
57
40
  /** Options for {@link useTap}. */
58
41
  export interface UseTapOptions extends GestureMemoOptions {
59
42
  /**
@@ -94,14 +77,23 @@ export interface UseTapOptions extends GestureMemoOptions {
94
77
  */
95
78
  enabled?: boolean
96
79
  /**
97
- * The tap happened. **Runs on the JS thread** — Impulse owns the
98
- * `runOnJS` boundary, so this is an ordinary function and may touch React
80
+ * The tap ended. **Runs on the JS thread** — Impulse owns the
81
+ * `scheduleOnRN` boundary, so this is an ordinary function and may touch React
99
82
  * state.
100
83
  *
101
- * It fires only for a successful tap. A touch that moved too far or stayed
102
- * down too long reaches `onFinalize` with `success: false` instead.
84
+ * It fires only for a tap the recognizer accepted, and `cancelled` says
85
+ * what happened after that. `false` is the ordinary tap. `true` means the
86
+ * system took the recognized tap away before it could be acted on — a
87
+ * competing gesture in a relation won it, or the app went to the
88
+ * background.
89
+ *
90
+ * **Check `cancelled` before you act on the tap.** A handler that navigates
91
+ * or submits should do nothing when it is `true`. The path is rare: a touch
92
+ * that moved past `maxDistance` or stayed down past `maxDuration` was never
93
+ * a tap at all, so it reaches `onFinalize` with `success: false` and never
94
+ * gets here.
103
95
  */
104
- onTap?: (event: TapEvent) => void
96
+ onTap?: (event: TapEvent, info: IntentEndInfo) => void
105
97
  /**
106
98
  * The finger went down and the gesture is now a candidate. **This is a
107
99
  * worklet** — mark it with the `'worklet'` directive, and do not touch
@@ -132,26 +124,6 @@ export interface UseTapOptions extends GestureMemoOptions {
132
124
  */
133
125
  export type UseTapResult = IntentResult<TapGesture>
134
126
 
135
- /**
136
- * Shape RNGH's flat state-change event into the tap payload.
137
- *
138
- * A worklet, because every caller is one. Keeping the normalizer out of the
139
- * gesture callbacks means the four call sites cannot disagree about which
140
- * RNGH field means what — which is the defect the intent payload exists to
141
- * remove.
142
- */
143
- function toTapEvent(
144
- event: GestureStateChangeEvent<TapGestureHandlerEventPayload>,
145
- ): TapEvent {
146
- 'worklet'
147
- return {
148
- x: event.x,
149
- y: event.y,
150
- absolute: { x: event.absoluteX, y: event.absoluteY },
151
- pointers: event.numberOfPointers,
152
- }
153
- }
154
-
155
127
  /**
156
128
  * Recognize a single tap.
157
129
  *
@@ -167,7 +139,7 @@ function toTapEvent(
167
139
  *
168
140
  * `onTap` runs on the JS thread and may set React state directly. `onBegin`
169
141
  * and `onFinalize` are worklets and run on the UI thread — the name states
170
- * the thread, so there is nothing to configure and no `runOnJS` to write.
142
+ * the thread, so there is nothing to configure and no `scheduleOnRN` to write.
171
143
  *
172
144
  * `isActive` is a shared value that is `true` while the finger is down. Drive
173
145
  * a pressed state from it without a re-render:
@@ -180,11 +152,22 @@ function toTapEvent(
180
152
  * **Activation criteria.** `maxDuration` defaults to 500ms and `maxDistance`
181
153
  * to 10 points. The distance default is Impulse's, not RNGH's: RNGH defers to
182
154
  * the platform there, so the same tap is accepted on one operating system and
183
- * rejected on the other. Neither default has been measured on hardware yet.
155
+ * rejected on the other. The device sweep did not test either default.
184
156
  *
185
- * **Racing a double tap.** A single tap and a double tap on one view is a
186
- * composition, not an option — `useGestures([tap, double], { mode: 'race' })`.
187
- * Do not reach for `maxDelay` to build it by hand.
157
+ * **Pairing with a double tap.** A single tap and a double tap on one view is
158
+ * a composition, not an option — and the mode is `exclusive`, with the double
159
+ * tap named first:
160
+ *
161
+ * ```tsx
162
+ * useGestures([double, tap], { mode: 'exclusive' })
163
+ * ```
164
+ *
165
+ * `race` is the wrong mode here and fails quietly. A single tap recognizes on
166
+ * the first release, so it wins the race every time and the double tap never
167
+ * fires. `exclusive` is what makes the single tap wait to learn whether a
168
+ * second tap is coming — at the cost of `useDoubleTap`'s `maxDelay` in
169
+ * latency on every single tap. Do not reach for `maxDelay` to build the pair
170
+ * by hand.
188
171
  *
189
172
  * **Web.** RNGH's web implementation recognizes tap from pointer events, and
190
173
  * `pointers` above 1 is unreliable there because a mouse reports one pointer
@@ -241,8 +224,13 @@ export function useTap(options: UseTapOptions = {}): UseTapResult {
241
224
  })
242
225
  .onEnd((event, success) => {
243
226
  'worklet'
244
- if (success && hasTapHandler) {
245
- runOnJS(handleTap)(toTapEvent(event))
227
+ // Not guarded on `success`: RNGH calls `onEnd` only when the old
228
+ // state was ACTIVE, so reaching here at all means the tap was
229
+ // recognized. `cancelled` then separates the tap the user completed
230
+ // from the one the system took away. Without it the cancel is
231
+ // reportable only from `onFinalize`, which is a worklet.
232
+ if (hasTapHandler) {
233
+ scheduleOnRN(handleTap, toTapEvent(event), { cancelled: !success })
246
234
  }
247
235
  })
248
236
  .onFinalize((event, success) => {
@@ -281,5 +269,8 @@ export function useTap(options: UseTapOptions = {}): UseTapResult {
281
269
  // consumer may put the whole hook result in a dependency list, and a fresh
282
270
  // object every render would make that dependency useless. `isActive` is
283
271
  // stable for the life of the hook, so `built` is the only real input.
284
- return useMemo(() => ({ ...built, isActive }), [built, isActive])
272
+ return useMemo(
273
+ () => buildIntentResult(built, { isActive }),
274
+ [built, isActive],
275
+ )
285
276
  }
@@ -0,0 +1,67 @@
1
+ import { type GestureType } from 'react-native-gesture-handler'
2
+ import { type BuiltGesture } from './useGestureMemo'
3
+
4
+ /**
5
+ * Assemble a hook result whose `gesture` and `ref` are **not enumerable**.
6
+ *
7
+ * This is a correctness fix, not a tidiness one, and it is the single most
8
+ * load-bearing line in the library.
9
+ *
10
+ * A Reanimated worklet captures the **root identifier** it reads through, so
11
+ * a consumer writing the obvious thing —
12
+ *
13
+ * ```tsx
14
+ * const style = useAnimatedStyle(() => ({
15
+ * transform: [{ scale: pinch.scale.value }],
16
+ * }))
17
+ * ```
18
+ *
19
+ * — captures `pinch`, the whole hook result, and Worklets then tries to copy
20
+ * it to the UI thread. `gesture` is an RNGH class instance, which Worklets
21
+ * cannot serialize, so the component throws at render with
22
+ * `[Worklets] Cannot copy value of type 'PinchGesture'`. Every intent had
23
+ * this defect, and every example screen died of it the first time one was run
24
+ * on a device.
25
+ *
26
+ * Worklets copies an object through `cloneObjectProperties`, which iterates
27
+ * `Object.entries` — own **enumerable** keys only. Hiding `gesture` and `ref`
28
+ * from enumeration therefore removes them from the copy, and what crosses to
29
+ * the UI thread is the shared values alone, which is all a worklet ever
30
+ * wanted. Reading `pinch.gesture` still works: non-enumerable is not private.
31
+ *
32
+ * The alternative was to document "destructure before the worklet, or your
33
+ * app crashes". That is a new sharp edge, and absorbing sharp edges is the
34
+ * reason this package exists.
35
+ *
36
+ * **What this costs.** `gesture` and `ref` do not appear in `Object.keys`, a
37
+ * spread of the result, `JSON.stringify`, or a `console.log` of the object.
38
+ * Nothing in Impulse relies on any of those — `useGestures` reads `.gesture`
39
+ * by property access, which is unaffected — and a consumer who spreads a hook
40
+ * result to build another object is doing something the `ref` exists to
41
+ * prevent.
42
+ *
43
+ * @param built - The gesture and its ref, from `useGestureMemo`.
44
+ * @param values - The shared values this intent reports. These stay
45
+ * enumerable: a shared value is exactly what a worklet should capture.
46
+ */
47
+ export function buildIntentResult<G extends GestureType, V extends object>(
48
+ built: BuiltGesture<G>,
49
+ values: V,
50
+ ): V & BuiltGesture<G> {
51
+ const result = { ...values } as V & BuiltGesture<G>
52
+ Object.defineProperty(result, 'gesture', {
53
+ value: built.gesture,
54
+ enumerable: false,
55
+ // Configurable so the object stays describable and a future field can
56
+ // replace it. Not writable: the result is read-only by type, and a
57
+ // consumer swapping the gesture would defeat the memoisation that keeps
58
+ // gesture identity stable.
59
+ configurable: true,
60
+ })
61
+ Object.defineProperty(result, 'ref', {
62
+ value: built.ref,
63
+ enumerable: false,
64
+ configurable: true,
65
+ })
66
+ return result
67
+ }
@@ -0,0 +1,51 @@
1
+ import { isWorkletFunction } from 'react-native-worklets'
2
+ import { isDevBuild, warnOnce } from './warnOnce'
3
+
4
+ /** The callbacks every intent runs on the UI thread. */
5
+ export const PHASE_CALLBACKS = ['onBegin', 'onUpdate', 'onFinalize'] as const
6
+
7
+ /** The phase callbacks of any intent's options, read without their types. */
8
+ export type PhaseCallbacks = Partial<
9
+ Record<(typeof PHASE_CALLBACKS)[number], unknown>
10
+ >
11
+
12
+ /**
13
+ * Warn once for each phase callback that is a plain function, not a worklet.
14
+ *
15
+ * A plain function reaches the UI thread as a remote function, and the first
16
+ * time the gesture enters that phase it throws `Tried to synchronously call a
17
+ * Remote Function. Called "anonymous"`. That error names neither the hook nor
18
+ * the option, and it arrives on a touch rather than at the call site.
19
+ *
20
+ * `isWorkletFunction` reads `__workletHash`, which only the Worklets Babel
21
+ * plugin writes. A test runner without the plugin marks nothing, so the
22
+ * shipped Jest setup reports every function as a worklet: its mock has no UI
23
+ * thread, and a plain function is correct there.
24
+ */
25
+ export function warnOnPlainPhaseCallbacks(
26
+ callbacks: PhaseCallbacks,
27
+ hookName: string,
28
+ ): void {
29
+ // A consumer's own worklets mock can omit this export. A missing check must
30
+ // not break their tests.
31
+ if (!isDevBuild() || typeof isWorkletFunction !== 'function') {
32
+ return
33
+ }
34
+
35
+ for (const name of PHASE_CALLBACKS) {
36
+ const callback = callbacks[name]
37
+ if (typeof callback !== 'function' || isWorkletFunction(callback)) {
38
+ continue
39
+ }
40
+ warnOnce(
41
+ `worklets:plain:${hookName}:${name}`,
42
+ `${hookName} received an \`${name}\` that is not a worklet, and ` +
43
+ `\`${name}\` runs on the UI thread. Add the 'worklet' directive as ` +
44
+ 'the first statement of the function. Without it, iOS and Android ' +
45
+ 'throw "Tried to synchronously call a Remote Function" when the ' +
46
+ 'gesture reaches that phase. For code that must run on the JS ' +
47
+ "thread, such as a React state update, use the hook's intent " +
48
+ 'callback instead.',
49
+ )
50
+ }
51
+ }
@@ -1,7 +1,17 @@
1
- import { useMemo, useRef, type DependencyList, type RefObject } from 'react'
1
+ import {
2
+ useEffect,
3
+ useMemo,
4
+ useRef,
5
+ type DependencyList,
6
+ type RefObject,
7
+ } from 'react'
2
8
  import { type GestureType } from 'react-native-gesture-handler'
3
- import { applyRelations } from '../relations'
9
+ import { applyRelations, warnOnUnresolvableReferences } from '../relations'
4
10
  import { type CoexistenceOptions } from '../types'
11
+ import {
12
+ warnOnPlainPhaseCallbacks,
13
+ type PhaseCallbacks,
14
+ } from './phaseCallbacks'
5
15
  import { useStableList } from './useStableList'
6
16
 
7
17
  /** What every hook built on this helper accepts on top of its own options. */
@@ -62,18 +72,22 @@ export interface BuiltGesture<G extends GestureType> {
62
72
  * @param deps - What the built gesture depends on. Worklet callbacks belong
63
73
  * here, because a worklet is captured as written. JS-thread callbacks do
64
74
  * not — route those through `useLatestCallback` first.
65
- * @param options - Coexistence options and `testId`.
75
+ * @param options - Coexistence options, `testId`, and the hook's phase
76
+ * callbacks, which are checked for the `'worklet'` directive.
66
77
  */
67
78
  export function useGestureMemo<G extends GestureType>(
68
79
  hookName: string,
69
80
  build: () => G,
70
81
  deps: DependencyList,
71
- options?: GestureMemoOptions,
82
+ options?: GestureMemoOptions & PhaseCallbacks,
72
83
  ): BuiltGesture<G> {
73
84
  const alongside = useStableList(options?.alongside)
74
85
  const blocks = useStableList(options?.blocks)
75
86
  const deferTo = useStableList(options?.deferTo)
76
87
  const testId = options?.testId
88
+ const onBegin = options?.onBegin
89
+ const onUpdate = options?.onUpdate
90
+ const onFinalize = options?.onFinalize
77
91
  const ref = useRef<GestureType | undefined>(undefined)
78
92
 
79
93
  const gesture = useMemo(
@@ -103,6 +117,20 @@ export function useGestureMemo<G extends GestureType>(
103
117
  [...deps, alongside, blocks, deferTo, testId, hookName],
104
118
  )
105
119
 
120
+ // A relation to a component that owns no gesture is dropped by RNGH without
121
+ // a word, and this is the only place with both the references and a moment
122
+ // late enough to read them. It has to be an effect: refs are empty while
123
+ // the memo above runs, so the same check there would fire for every correct
124
+ // relation. Dev-only, and `warnOnce` keyed, so a hook that re-renders at
125
+ // frame rate does not print at frame rate.
126
+ useEffect(() => {
127
+ warnOnUnresolvableReferences({ alongside, blocks, deferTo }, hookName)
128
+ }, [alongside, blocks, deferTo, hookName])
129
+
130
+ useEffect(() => {
131
+ warnOnPlainPhaseCallbacks({ onBegin, onUpdate, onFinalize }, hookName)
132
+ }, [onBegin, onUpdate, onFinalize, hookName])
133
+
106
134
  // The result object is memoised too, so a consumer can put the whole hook
107
135
  // result in a dependency list — `useGestures` does exactly that with its
108
136
  // members.
@@ -28,8 +28,8 @@ import { useCallback, useInsertionEffect, useRef } from 'react'
28
28
  * dependency of the gesture's `useMemo`, because a worklet is captured as
29
29
  * written: swapping its body through a ref would leave the UI thread running
30
30
  * the version it was serialized with, silently. That asymmetry is why
31
- * Impulse splits callbacks by name — `onBegin` / `onUpdate` / `onEnd` are
32
- * worklets, `onTap` / `onSwipe` / `onLongPress` are not.
31
+ * Impulse splits callbacks by name — `onBegin` / `onUpdate` / `onFinalize`
32
+ * are worklets, `onTap` / `onDragEnd` / `onLongPress` are not.
33
33
  *
34
34
  * The returned function is stable, so it is never a useful dependency. A
35
35
  * caller that needs the gesture to change when the callback *appears or
@@ -39,7 +39,7 @@ export type RawGestureResult<G extends GestureType> = BuiltGesture<G>
39
39
  * put it through `useLatestCallback` and depend on the stable result.
40
40
  * - **The thread.** RNGH decides per callback, by whether it carries the
41
41
  * `'worklet'` directive, and warns in development when a gesture mixes the
42
- * two. Impulse does not insert a `runOnJS` boundary for you here; that is
42
+ * two. Impulse does not insert a `scheduleOnRN` boundary for you here; that is
43
43
  * something the intent hooks do because they know what each callback means.
44
44
  * - **The payload.** You get RNGH's flat event, not an intent-shaped one.
45
45
  *
@@ -70,6 +70,100 @@ export function applyRelations(
70
70
  }
71
71
  }
72
72
 
73
+ /**
74
+ * RNGH's test for a usable handler tag, mirrored rather than inferred.
75
+ * `extractValidHandlerTags` keeps `tag > 0` and drops everything else, so a
76
+ * reference this returns `false` for is a reference RNGH silently discards.
77
+ */
78
+ function hasHandlerTag(candidate: object): boolean {
79
+ const tag = (candidate as { handlerTag?: unknown }).handlerTag
80
+ return typeof tag === 'number' && tag > 0
81
+ }
82
+
83
+ /**
84
+ * Whether this reference names something RNGH will drop.
85
+ *
86
+ * Three states, and only the third is a defect:
87
+ *
88
+ * 1. **A gesture object.** It carries its own `handlerTag`. Nothing to check.
89
+ * 2. **A ref with no `.current`.** The target has not mounted yet, or never
90
+ * will. Say nothing — see the timing note on `warnOnUnresolvableReferences`.
91
+ * 3. **A ref whose `.current` carries no handler tag.** The component is
92
+ * mounted and owns no gesture. RNGH resolves it to `-1` and filters it out.
93
+ */
94
+ function isUnresolvable(reference: GestureReference): boolean {
95
+ if (typeof reference !== 'object' || reference === null) {
96
+ return false
97
+ }
98
+ if (!('current' in reference)) {
99
+ return false
100
+ }
101
+ const current = reference.current
102
+ if (current === null || current === undefined) {
103
+ return false
104
+ }
105
+ return !hasHandlerTag(current)
106
+ }
107
+
108
+ /**
109
+ * Warn when a relation names a mounted component that owns no gesture.
110
+ *
111
+ * This is the silent failure the library exists to remove. RNGH resolves
112
+ * every relation reference through `convertToHandlerTag`, which reads
113
+ * `ref.current?.handlerTag ?? -1` and then keeps only tags above zero. A ref
114
+ * to React Native's own `ScrollView` has no tag, so the relation is dropped —
115
+ * with no warning, no error, and no way to tell the result apart from a
116
+ * relation that was never written. The gesture keeps working; it just never
117
+ * coexists with the scroll view, which is the whole reason the option was
118
+ * passed.
119
+ *
120
+ * **Call this from an effect, never during render.** A ref is empty while the
121
+ * component that owns it renders and is filled during the commit, so a check
122
+ * at `applyRelations` time reads `undefined` for a correct relation and a
123
+ * wrong one alike.
124
+ *
125
+ * **A populated ref is a finished ref, which is what makes state 3 above safe
126
+ * to report.** Both places RNGH fills one do it together with the tag:
127
+ * `BaseGesture.initialize` assigns `handlerTag` and sets `config.ref.current`
128
+ * in the same function, and `createNativeWrapper`'s `useImperativeHandle`
129
+ * copies the tag onto the instance before returning it, and returns `null`
130
+ * when it cannot. Neither leaves a window where `.current` is set and the tag
131
+ * is still coming, so a populated ref with no tag is never a timing artifact.
132
+ *
133
+ * What this deliberately does not catch: a target that mounts in a *later*
134
+ * commit than the gesture. Its ref is empty when this runs and nothing
135
+ * re-checks, so the case stays silent. That is the right trade — RNGH itself
136
+ * re-resolves relations when a handler mounts late, through `MountRegistry`,
137
+ * so the relation is installed anyway and a warning here would be wrong.
138
+ *
139
+ * The key names the hook and the option rather than the reference, because a
140
+ * ref has no stable string form and the fix is the same for every instance.
141
+ */
142
+ export function warnOnUnresolvableReferences(
143
+ relations: ResolvedCoexistence,
144
+ hookName: string,
145
+ ): void {
146
+ if (!isDevBuild()) {
147
+ return
148
+ }
149
+
150
+ for (const [option] of RELATIONS) {
151
+ if (!relations[option].some(isUnresolvable)) {
152
+ continue
153
+ }
154
+ warnOnce(
155
+ `relations:untagged:${hookName}:${option}`,
156
+ `${hookName} received a \`${option}\` reference to a component that ` +
157
+ 'owns no gesture, so gesture-handler dropped the relation. The ' +
158
+ 'gesture still works and the two still conflict — nothing reports ' +
159
+ "it at runtime. React Native's own `ScrollView` and `FlatList` are " +
160
+ 'the usual cause: they carry no handler tag. Import `ScrollView` or ' +
161
+ '`FlatList` from `@rootnative/impulse/gesture-handler` and put the ' +
162
+ 'ref on that component instead.',
163
+ )
164
+ }
165
+ }
166
+
73
167
  /**
74
168
  * Warn when one gesture is named by more than one coexistence option.
75
169
  *
package/src/types.ts CHANGED
@@ -185,3 +185,30 @@ export interface IntentResult<G extends GestureType> {
185
185
  */
186
186
  readonly isActive: SharedValue<boolean>
187
187
  }
188
+
189
+ /**
190
+ * How a gesture ended, handed to every intent's end callback as its second
191
+ * argument.
192
+ *
193
+ * The end callbacks — `onTap`, `onDoubleTap`, `onLongPressEnd`, `onDragEnd` —
194
+ * fire on both paths: a gesture the user completed, and one the system took
195
+ * away. This says which. Without it, a cancel is reported only by
196
+ * `onFinalize`, which is a worklet, so a consumer holding phase in React
197
+ * state has to write `'worklet'` plus `scheduleOnRN` by hand.
198
+ *
199
+ * It is an object rather than a bare boolean so a later field — a reason for
200
+ * the cancel, say — does not break the signature a second time.
201
+ */
202
+ export interface IntentEndInfo {
203
+ /**
204
+ * `true` when the system took the gesture away instead of the user
205
+ * completing it: a competing gesture won, the app went to the background,
206
+ * or a relation handed the touch to another recognizer.
207
+ *
208
+ * A gesture that never activated at all does not reach an end callback on
209
+ * either path, so this is never `true` for a touch that was never the
210
+ * intent. It separates "this ended, but not by the user" from "the user
211
+ * did it".
212
+ */
213
+ readonly cancelled: boolean
214
+ }