@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.
- package/CHANGELOG.md +51 -5
- package/README.md +206 -11
- package/dist/{chunk-5BMRKYVY.js → chunk-2UZTAWUQ.js} +20 -2
- package/dist/chunk-BNSFDNLA.js +75 -0
- package/dist/chunk-DXXGWG4Q.js +136 -0
- package/dist/{chunk-PMR25UCT.js → chunk-HGBCIL6X.js} +1 -1
- package/dist/chunk-HI5PHDJY.js +94 -0
- package/dist/{chunk-IG5RXCYR.js → chunk-IH7SQ5X6.js} +11 -15
- package/dist/chunk-MWCIEVTA.js +199 -0
- package/dist/{chunk-F4RHM4ZK.js → chunk-NYDDZD4G.js} +58 -1
- package/dist/chunk-PGOQSKEJ.js +144 -0
- package/dist/{chunk-FR242SUF.js → chunk-TGOGIZDH.js} +13 -7
- package/dist/chunk-VEPUHGPN.js +12 -0
- package/dist/chunk-YZHAQ4XK.js +136 -0
- package/dist/compose/index.d.ts +1 -1
- package/dist/double-tap/index.d.ts +184 -0
- package/dist/double-tap/index.js +5 -0
- package/dist/drag/index.d.ts +18 -13
- package/dist/drag/index.js +3 -3
- package/dist/index.d.ts +10 -3
- package/dist/index.js +12 -5
- package/dist/long-press/index.d.ts +219 -0
- package/dist/long-press/index.js +4 -0
- package/dist/pan/index.d.ts +221 -0
- package/dist/pan/index.js +4 -0
- package/dist/pinch/index.d.ts +239 -0
- package/dist/pinch/index.js +4 -0
- package/dist/raw/index.d.ts +3 -3
- package/dist/raw/index.js +2 -2
- package/dist/rotate/index.d.ts +263 -0
- package/dist/rotate/index.js +4 -0
- package/dist/swipe/index.d.ts +254 -0
- package/dist/swipe/index.js +4 -0
- package/dist/tap/index.d.ts +34 -29
- package/dist/tap/index.js +4 -3
- package/dist/tapEvent-KSSojt_l.d.ts +28 -0
- package/dist/{types-Ch2HM3aP.d.ts → types-ChGKY28a.d.ts} +27 -1
- package/dist/{useGestureMemo-Ccv8rB0C.d.ts → useGestureMemo-BRW1EKcJ.d.ts} +1 -1
- package/jest-setup.cjs +32 -1
- package/llms.txt +155 -0
- package/package.json +35 -2
- package/src/index.ts +50 -4
- package/src/intents/double-tap/index.ts +6 -0
- package/src/intents/long-press/index.ts +6 -0
- package/src/intents/pan/index.ts +2 -0
- package/src/intents/pinch/index.ts +2 -0
- package/src/intents/rotate/index.ts +6 -0
- package/src/intents/swipe/index.ts +8 -0
- package/src/intents/tapEvent.ts +50 -0
- package/src/intents/useDoubleTap.ts +321 -0
- package/src/intents/useDrag.ts +49 -28
- package/src/intents/useLongPress.ts +391 -0
- package/src/intents/usePan.ts +444 -0
- package/src/intents/usePinch.ts +483 -0
- package/src/intents/useRotate.ts +507 -0
- package/src/intents/useSwipe.ts +585 -0
- package/src/intents/useTap.ts +51 -60
- package/src/internal/intentResult.ts +67 -0
- package/src/internal/phaseCallbacks.ts +51 -0
- package/src/internal/useGestureMemo.ts +32 -4
- package/src/internal/useLatestCallback.ts +2 -2
- package/src/raw/useRawGesture.ts +1 -1
- package/src/relations/index.ts +94 -0
- package/src/types.ts +27 -0
package/src/intents/useTap.ts
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
import { useMemo } from 'react'
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
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 {
|
|
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.**
|
|
36
|
-
*
|
|
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
|
|
98
|
-
* `
|
|
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
|
|
102
|
-
*
|
|
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 `
|
|
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.
|
|
155
|
+
* rejected on the other. The device sweep did not test either default.
|
|
184
156
|
*
|
|
185
|
-
* **
|
|
186
|
-
* composition, not an option —
|
|
187
|
-
*
|
|
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
|
-
|
|
245
|
-
|
|
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(
|
|
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 {
|
|
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
|
|
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` / `
|
|
32
|
-
* worklets, `onTap` / `
|
|
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
|
package/src/raw/useRawGesture.ts
CHANGED
|
@@ -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 `
|
|
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
|
*
|
package/src/relations/index.ts
CHANGED
|
@@ -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
|
+
}
|