@rootnative/impulse 0.0.0-alpha.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.
- package/CHANGELOG.md +48 -0
- package/LICENSE +21 -0
- package/README.md +174 -0
- package/dist/chunk-5BMRKYVY.js +39 -0
- package/dist/chunk-F4RHM4ZK.js +77 -0
- package/dist/chunk-FR242SUF.js +174 -0
- package/dist/chunk-IG5RXCYR.js +74 -0
- package/dist/chunk-LM645QQT.js +37 -0
- package/dist/chunk-PMR25UCT.js +8 -0
- package/dist/chunk-ZX7WNICB.js +39 -0
- package/dist/compose/index.d.ts +80 -0
- package/dist/compose/index.js +2 -0
- package/dist/drag/index.d.ts +277 -0
- package/dist/drag/index.js +4 -0
- package/dist/gesture-handler/index.d.ts +1 -0
- package/dist/gesture-handler/index.js +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +8 -0
- package/dist/raw/index.d.ts +63 -0
- package/dist/raw/index.js +3 -0
- package/dist/tap/index.d.ts +150 -0
- package/dist/tap/index.js +4 -0
- package/dist/types-Ch2HM3aP.d.ts +142 -0
- package/dist/useGestureMemo-Ccv8rB0C.d.ts +38 -0
- package/jest-preset.cjs +60 -0
- package/jest-setup.cjs +56 -0
- package/package.json +115 -0
- package/src/compose/index.ts +7 -0
- package/src/compose/useGestures.ts +136 -0
- package/src/gesture-handler/index.ts +18 -0
- package/src/index.ts +53 -0
- package/src/intents/drag/index.ts +8 -0
- package/src/intents/tap/index.ts +2 -0
- package/src/intents/useDrag.ts +563 -0
- package/src/intents/useTap.ts +285 -0
- package/src/internal/useGestureMemo.ts +110 -0
- package/src/internal/useLatestCallback.ts +64 -0
- package/src/internal/useStableList.ts +62 -0
- package/src/internal/useStableRecord.ts +72 -0
- package/src/internal/warnOnce.ts +60 -0
- package/src/raw/index.ts +2 -0
- package/src/raw/useRawGesture.ts +71 -0
- package/src/relations/index.ts +120 -0
- package/src/types.ts +187 -0
|
@@ -0,0 +1,285 @@
|
|
|
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'
|
|
9
|
+
import {
|
|
10
|
+
useGestureMemo,
|
|
11
|
+
type GestureMemoOptions,
|
|
12
|
+
} from '../internal/useGestureMemo'
|
|
13
|
+
import { useLatestCallback } from '../internal/useLatestCallback'
|
|
14
|
+
import { useStableRecord } from '../internal/useStableRecord'
|
|
15
|
+
import { type HitSlop, type IntentResult, type Point } from '../types'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Maximum time the finger may stay down and still count as a tap, in
|
|
19
|
+
* milliseconds.
|
|
20
|
+
*
|
|
21
|
+
* This is RNGH's own default, restated rather than inherited so the value is
|
|
22
|
+
* visible at the call site's documentation and cannot move underneath Impulse
|
|
23
|
+
* in an RNGH release.
|
|
24
|
+
*/
|
|
25
|
+
const DEFAULT_MAX_DURATION = 500
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Maximum distance the finger may travel and still count as a tap, in points.
|
|
29
|
+
*
|
|
30
|
+
* Set explicitly, and this one is **not** RNGH's default: RNGH leaves the
|
|
31
|
+
* slop to the platform, so the same tap is accepted on one operating system
|
|
32
|
+
* and rejected on the other. A fixed number is the behaviour a consumer can
|
|
33
|
+
* reason about. 10 points is roughly a finger's own jitter while pressing.
|
|
34
|
+
*
|
|
35
|
+
* **This number is a design intention, not a measurement.** No hardware pass
|
|
36
|
+
* has happened. See Known gaps in CLAUDE.md.
|
|
37
|
+
*/
|
|
38
|
+
const DEFAULT_MAX_DISTANCE = 10
|
|
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
|
+
/** Options for {@link useTap}. */
|
|
58
|
+
export interface UseTapOptions extends GestureMemoOptions {
|
|
59
|
+
/**
|
|
60
|
+
* How many fingers must be down. Default `1`.
|
|
61
|
+
*
|
|
62
|
+
* A two-finger tap is a common "undo" or "zoom out" affordance, and it is
|
|
63
|
+
* the same intent with a different pointer count rather than a separate
|
|
64
|
+
* one.
|
|
65
|
+
*/
|
|
66
|
+
pointers?: number
|
|
67
|
+
/**
|
|
68
|
+
* How long the finger may stay down, in milliseconds. Default `500`.
|
|
69
|
+
*
|
|
70
|
+
* Past this the gesture fails rather than firing, which is what leaves the
|
|
71
|
+
* touch available to a `useLongPress` racing against it.
|
|
72
|
+
*/
|
|
73
|
+
maxDuration?: number
|
|
74
|
+
/**
|
|
75
|
+
* How far the finger may travel, in points. Default `10`.
|
|
76
|
+
*
|
|
77
|
+
* Raising it makes the tap more forgiving and makes it harder for a drag in
|
|
78
|
+
* the same view to win the touch.
|
|
79
|
+
*/
|
|
80
|
+
maxDistance?: number
|
|
81
|
+
/**
|
|
82
|
+
* Extra touchable area around the view, in points.
|
|
83
|
+
*
|
|
84
|
+
* Written inline as an object is fine — the gesture is not rebuilt when the
|
|
85
|
+
* contents are unchanged.
|
|
86
|
+
*/
|
|
87
|
+
hitSlop?: HitSlop
|
|
88
|
+
/**
|
|
89
|
+
* Whether the gesture is recognized at all. Default `true`.
|
|
90
|
+
*
|
|
91
|
+
* Prefer this over unmounting the `<GestureDetector>`: a disabled gesture
|
|
92
|
+
* keeps its identity and its relations, so re-enabling it does not
|
|
93
|
+
* re-attach anything.
|
|
94
|
+
*/
|
|
95
|
+
enabled?: boolean
|
|
96
|
+
/**
|
|
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
|
|
99
|
+
* state.
|
|
100
|
+
*
|
|
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.
|
|
103
|
+
*/
|
|
104
|
+
onTap?: (event: TapEvent) => void
|
|
105
|
+
/**
|
|
106
|
+
* The finger went down and the gesture is now a candidate. **This is a
|
|
107
|
+
* worklet** — mark it with the `'worklet'` directive, and do not touch
|
|
108
|
+
* React state from it.
|
|
109
|
+
*
|
|
110
|
+
* Being a candidate is not the same as winning: in a race with a
|
|
111
|
+
* long press or a drag, this fires and the gesture may still fail. Use it
|
|
112
|
+
* to show a pressed state, and undo that state in `onFinalize`.
|
|
113
|
+
*/
|
|
114
|
+
onBegin?: (event: TapEvent) => void
|
|
115
|
+
/**
|
|
116
|
+
* The gesture is over, whether it was recognized or not. **This is a
|
|
117
|
+
* worklet.**
|
|
118
|
+
*
|
|
119
|
+
* `success` is `true` when the tap was recognized. This is the right place
|
|
120
|
+
* to clear anything `onBegin` set, because it runs on both paths.
|
|
121
|
+
*/
|
|
122
|
+
onFinalize?: (event: TapEvent, success: boolean) => void
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* What {@link useTap} returns.
|
|
127
|
+
*
|
|
128
|
+
* An alias rather than an extending interface, because a tap produces no
|
|
129
|
+
* continuous value of its own: the gesture, the ref, and `isActive` are the
|
|
130
|
+
* whole result. An intent that does produce one — a drag's `x`, a pinch's
|
|
131
|
+
* `scale` — extends {@link IntentResult} instead.
|
|
132
|
+
*/
|
|
133
|
+
export type UseTapResult = IntentResult<TapGesture>
|
|
134
|
+
|
|
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
|
+
/**
|
|
156
|
+
* Recognize a single tap.
|
|
157
|
+
*
|
|
158
|
+
* ```tsx
|
|
159
|
+
* const tap = useTap({ onTap: () => select(item.id) })
|
|
160
|
+
*
|
|
161
|
+
* return (
|
|
162
|
+
* <GestureDetector gesture={tap.gesture}>
|
|
163
|
+
* <View />
|
|
164
|
+
* </GestureDetector>
|
|
165
|
+
* )
|
|
166
|
+
* ```
|
|
167
|
+
*
|
|
168
|
+
* `onTap` runs on the JS thread and may set React state directly. `onBegin`
|
|
169
|
+
* 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.
|
|
171
|
+
*
|
|
172
|
+
* `isActive` is a shared value that is `true` while the finger is down. Drive
|
|
173
|
+
* a pressed state from it without a re-render:
|
|
174
|
+
*
|
|
175
|
+
* ```tsx
|
|
176
|
+
* const tap = useTap({ onTap: select })
|
|
177
|
+
* const style = useAnimatedStyle(() => ({ opacity: tap.isActive.value ? 0.6 : 1 }))
|
|
178
|
+
* ```
|
|
179
|
+
*
|
|
180
|
+
* **Activation criteria.** `maxDuration` defaults to 500ms and `maxDistance`
|
|
181
|
+
* to 10 points. The distance default is Impulse's, not RNGH's: RNGH defers to
|
|
182
|
+
* 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.
|
|
184
|
+
*
|
|
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.
|
|
188
|
+
*
|
|
189
|
+
* **Web.** RNGH's web implementation recognizes tap from pointer events, and
|
|
190
|
+
* `pointers` above 1 is unreliable there because a mouse reports one pointer
|
|
191
|
+
* and touch emulation varies by browser. A single-finger tap behaves the same
|
|
192
|
+
* as on native.
|
|
193
|
+
*
|
|
194
|
+
* **Accessibility.** A tap gesture is invisible to a screen reader and
|
|
195
|
+
* unreachable from a keyboard. This hook does not fix that, and it cannot.
|
|
196
|
+
* Whatever the tap does must also be reachable another way: put the same
|
|
197
|
+
* action on a `<Pressable>`, or declare it with `accessibilityActions` and
|
|
198
|
+
* `onAccessibilityAction` on the view the gesture is attached to. A tap-only
|
|
199
|
+
* affordance is a bug, not a trade-off.
|
|
200
|
+
*
|
|
201
|
+
* @param options - Activation criteria, callbacks, and the `alongside` /
|
|
202
|
+
* `blocks` / `deferTo` coexistence options every Impulse hook accepts.
|
|
203
|
+
*/
|
|
204
|
+
export function useTap(options: UseTapOptions = {}): UseTapResult {
|
|
205
|
+
const {
|
|
206
|
+
pointers = 1,
|
|
207
|
+
maxDuration = DEFAULT_MAX_DURATION,
|
|
208
|
+
maxDistance = DEFAULT_MAX_DISTANCE,
|
|
209
|
+
enabled,
|
|
210
|
+
onTap,
|
|
211
|
+
onBegin,
|
|
212
|
+
onFinalize,
|
|
213
|
+
} = options
|
|
214
|
+
|
|
215
|
+
const isActive = useSharedValue(false)
|
|
216
|
+
// `hitSlop` is the one option a consumer writes as an object literal, so it
|
|
217
|
+
// is the one that would rebuild the gesture every render if taken as-is.
|
|
218
|
+
const hitSlop = useStableRecord(options.hitSlop)
|
|
219
|
+
// JS-thread callback: reached through a stable identity so it is never a
|
|
220
|
+
// gesture dependency. The worklet callbacks below stay direct dependencies,
|
|
221
|
+
// because a worklet is captured as written.
|
|
222
|
+
const handleTap = useLatestCallback(onTap)
|
|
223
|
+
// Attaching a handler is not the same as calling it: RNGH decides which
|
|
224
|
+
// thread a gesture's callbacks run on by inspecting the ones it was given,
|
|
225
|
+
// so the gesture does have to change when `onTap` appears or disappears.
|
|
226
|
+
// This is a boolean, so it changes only when that is actually true.
|
|
227
|
+
const hasTapHandler = onTap !== undefined
|
|
228
|
+
|
|
229
|
+
const built = useGestureMemo(
|
|
230
|
+
'useTap',
|
|
231
|
+
() => {
|
|
232
|
+
const tap = Gesture.Tap()
|
|
233
|
+
.numberOfTaps(1)
|
|
234
|
+
.minPointers(pointers)
|
|
235
|
+
.maxDuration(maxDuration)
|
|
236
|
+
.maxDistance(maxDistance)
|
|
237
|
+
.onBegin((event) => {
|
|
238
|
+
'worklet'
|
|
239
|
+
isActive.value = true
|
|
240
|
+
onBegin?.(toTapEvent(event))
|
|
241
|
+
})
|
|
242
|
+
.onEnd((event, success) => {
|
|
243
|
+
'worklet'
|
|
244
|
+
if (success && hasTapHandler) {
|
|
245
|
+
runOnJS(handleTap)(toTapEvent(event))
|
|
246
|
+
}
|
|
247
|
+
})
|
|
248
|
+
.onFinalize((event, success) => {
|
|
249
|
+
'worklet'
|
|
250
|
+
isActive.value = false
|
|
251
|
+
onFinalize?.(toTapEvent(event), success)
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
// Applied conditionally rather than with a default, so an option the
|
|
255
|
+
// consumer did not set leaves RNGH's own default in place instead of
|
|
256
|
+
// Impulse overwriting it with a guess.
|
|
257
|
+
if (hitSlop !== undefined) {
|
|
258
|
+
tap.hitSlop(hitSlop)
|
|
259
|
+
}
|
|
260
|
+
if (enabled !== undefined) {
|
|
261
|
+
tap.enabled(enabled)
|
|
262
|
+
}
|
|
263
|
+
return tap
|
|
264
|
+
},
|
|
265
|
+
[
|
|
266
|
+
pointers,
|
|
267
|
+
maxDuration,
|
|
268
|
+
maxDistance,
|
|
269
|
+
hitSlop,
|
|
270
|
+
enabled,
|
|
271
|
+
hasTapHandler,
|
|
272
|
+
handleTap,
|
|
273
|
+
isActive,
|
|
274
|
+
onBegin,
|
|
275
|
+
onFinalize,
|
|
276
|
+
],
|
|
277
|
+
options,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
// Memoised for the same reason `useGestureMemo` memoises its own result: a
|
|
281
|
+
// consumer may put the whole hook result in a dependency list, and a fresh
|
|
282
|
+
// object every render would make that dependency useless. `isActive` is
|
|
283
|
+
// stable for the life of the hook, so `built` is the only real input.
|
|
284
|
+
return useMemo(() => ({ ...built, isActive }), [built, isActive])
|
|
285
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { useMemo, useRef, type DependencyList, type RefObject } from 'react'
|
|
2
|
+
import { type GestureType } from 'react-native-gesture-handler'
|
|
3
|
+
import { applyRelations } from '../relations'
|
|
4
|
+
import { type CoexistenceOptions } from '../types'
|
|
5
|
+
import { useStableList } from './useStableList'
|
|
6
|
+
|
|
7
|
+
/** What every hook built on this helper accepts on top of its own options. */
|
|
8
|
+
export interface GestureMemoOptions extends CoexistenceOptions {
|
|
9
|
+
/**
|
|
10
|
+
* A test id for RNGH's `getByGestureTestId`, forwarded to `withTestId`.
|
|
11
|
+
*
|
|
12
|
+
* Impulse's own tests mostly inspect the gesture object directly, which is
|
|
13
|
+
* more precise. This is here for consumers driving their gestures through
|
|
14
|
+
* `fireGestureHandler`, which needs a way to find them.
|
|
15
|
+
*/
|
|
16
|
+
testId?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The two members every Impulse gesture hook returns, whatever else it adds. */
|
|
20
|
+
export interface BuiltGesture<G extends GestureType> {
|
|
21
|
+
/** The configured gesture. Hand it to `<GestureDetector>`. */
|
|
22
|
+
readonly gesture: G
|
|
23
|
+
/**
|
|
24
|
+
* A handle on this gesture for another hook's `alongside` / `blocks` /
|
|
25
|
+
* `deferTo`.
|
|
26
|
+
*
|
|
27
|
+
* Passing `other.gesture` to a relation also works — RNGH accepts a gesture
|
|
28
|
+
* object — but it captures *that* object, and a gesture is replaced when
|
|
29
|
+
* its own dependencies change. The ref is created once and never replaced,
|
|
30
|
+
* and RNGH reads it when it resolves relations rather than when the
|
|
31
|
+
* relation is declared, so a relation written against `other.ref` keeps
|
|
32
|
+
* pointing at the live gesture. Prefer it.
|
|
33
|
+
*
|
|
34
|
+
* The ref is populated when `<GestureDetector>` mounts the gesture, not
|
|
35
|
+
* when the hook runs. Reading `.current` during render gives `undefined`
|
|
36
|
+
* on the first pass.
|
|
37
|
+
*/
|
|
38
|
+
readonly ref: RefObject<GestureType | undefined>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Build a gesture once, configure it, and keep it until its dependencies
|
|
43
|
+
* actually change.
|
|
44
|
+
*
|
|
45
|
+
* Every Impulse hook goes through here rather than hand-rolling `useMemo`
|
|
46
|
+
* plus relation wiring. Two reasons, both about defects that are invisible
|
|
47
|
+
* at the call site:
|
|
48
|
+
*
|
|
49
|
+
* 1. **Gesture identity is a correctness property, not an optimization.** A
|
|
50
|
+
* gesture whose shape changed is re-attached by `<GestureDetector>`, and
|
|
51
|
+
* a re-attach in the middle of a drag drops the drag. Concentrating the
|
|
52
|
+
* memoisation here means a new hook cannot forget it.
|
|
53
|
+
* 2. **Relations must be applied exactly once per gesture object.** RNGH's
|
|
54
|
+
* relation methods append to the gesture's config, so a second call adds
|
|
55
|
+
* the same reference again. Applying them inside the same `useMemo` that
|
|
56
|
+
* builds the gesture ties "applied once" to "built once".
|
|
57
|
+
*
|
|
58
|
+
* @param hookName - The public hook this is building for, used in dev
|
|
59
|
+
* warnings so the message names something the consumer wrote.
|
|
60
|
+
* @param build - Constructs the bare gesture. Called only when `deps` change.
|
|
61
|
+
* Do not apply relations here; this helper owns them.
|
|
62
|
+
* @param deps - What the built gesture depends on. Worklet callbacks belong
|
|
63
|
+
* here, because a worklet is captured as written. JS-thread callbacks do
|
|
64
|
+
* not — route those through `useLatestCallback` first.
|
|
65
|
+
* @param options - Coexistence options and `testId`.
|
|
66
|
+
*/
|
|
67
|
+
export function useGestureMemo<G extends GestureType>(
|
|
68
|
+
hookName: string,
|
|
69
|
+
build: () => G,
|
|
70
|
+
deps: DependencyList,
|
|
71
|
+
options?: GestureMemoOptions,
|
|
72
|
+
): BuiltGesture<G> {
|
|
73
|
+
const alongside = useStableList(options?.alongside)
|
|
74
|
+
const blocks = useStableList(options?.blocks)
|
|
75
|
+
const deferTo = useStableList(options?.deferTo)
|
|
76
|
+
const testId = options?.testId
|
|
77
|
+
const ref = useRef<GestureType | undefined>(undefined)
|
|
78
|
+
|
|
79
|
+
const gesture = useMemo(
|
|
80
|
+
() => {
|
|
81
|
+
const built = build()
|
|
82
|
+
// `built` stays typed as `G` so the caller keeps its concrete gesture
|
|
83
|
+
// type; the configuration below only needs the base surface, and
|
|
84
|
+
// calling these through the narrowed type avoids resolving a method on
|
|
85
|
+
// a union of `this`-returning signatures.
|
|
86
|
+
const base: GestureType = built
|
|
87
|
+
base.withRef(ref)
|
|
88
|
+
if (testId !== undefined) {
|
|
89
|
+
base.withTestId(testId)
|
|
90
|
+
}
|
|
91
|
+
applyRelations(base, { alongside, blocks, deferTo }, hookName)
|
|
92
|
+
return built
|
|
93
|
+
},
|
|
94
|
+
// `build` is deliberately absent: it is written inline at every call
|
|
95
|
+
// site, so its identity changes every render and including it would
|
|
96
|
+
// rebuild the gesture every render — the exact defect this helper
|
|
97
|
+
// exists to prevent. `deps` is what the caller declares instead, which
|
|
98
|
+
// is the same contract `useMemo` itself has. The lint rule cannot see
|
|
99
|
+
// through a forwarded dependency list, and the spread is what makes the
|
|
100
|
+
// array one flat list rather than a nested one; its length is fixed per
|
|
101
|
+
// call site, which is what React requires.
|
|
102
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
103
|
+
[...deps, alongside, blocks, deferTo, testId, hookName],
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
// The result object is memoised too, so a consumer can put the whole hook
|
|
107
|
+
// result in a dependency list — `useGestures` does exactly that with its
|
|
108
|
+
// members.
|
|
109
|
+
return useMemo(() => ({ gesture, ref }), [gesture])
|
|
110
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { useCallback, useInsertionEffect, useRef } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Give a JS-thread callback a stable identity that never changes, while
|
|
5
|
+
* always calling the newest version passed in.
|
|
6
|
+
*
|
|
7
|
+
* This is the mechanism behind Impulse's "stable by construction" guarantee.
|
|
8
|
+
* A gesture object must not be rebuilt during a render, because
|
|
9
|
+
* `<GestureDetector>` re-attaches a gesture whose shape changed — and a
|
|
10
|
+
* re-attach mid-drag drops the drag. An inline callback is enough to trigger
|
|
11
|
+
* that:
|
|
12
|
+
*
|
|
13
|
+
* ```tsx
|
|
14
|
+
* // Without this hook, `onTap` is a new function every render, so the
|
|
15
|
+
* // gesture's `useMemo` invalidates every render, so the gesture re-attaches
|
|
16
|
+
* // every render.
|
|
17
|
+
* useTap({ onTap: () => select(item.id) })
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* Routing the callback through here makes the gesture independent of it: the
|
|
21
|
+
* identity the gesture captures is created once and never replaced, and the
|
|
22
|
+
* body it forwards to is swapped underneath. `@rootnative/inertia` shipped
|
|
23
|
+
* this exact defect in `-gestures` and fixed it in `0.0.10`; here it is the
|
|
24
|
+
* architecture rather than a fix, and a test pins gesture identity across an
|
|
25
|
+
* inline-callback re-render.
|
|
26
|
+
*
|
|
27
|
+
* **This is for JS-thread callbacks only.** A worklet must stay a direct
|
|
28
|
+
* dependency of the gesture's `useMemo`, because a worklet is captured as
|
|
29
|
+
* written: swapping its body through a ref would leave the UI thread running
|
|
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.
|
|
33
|
+
*
|
|
34
|
+
* The returned function is stable, so it is never a useful dependency. A
|
|
35
|
+
* caller that needs the gesture to change when the callback *appears or
|
|
36
|
+
* disappears* — attaching a handler is not the same as calling it — should
|
|
37
|
+
* put `options.onTap !== undefined` in its dependency list, which is a
|
|
38
|
+
* boolean and changes only when that is actually true.
|
|
39
|
+
*
|
|
40
|
+
* @param callback - The callback to forward to, or `undefined` when the
|
|
41
|
+
* consumer passed none.
|
|
42
|
+
* @returns A function whose identity never changes. Calling it invokes the
|
|
43
|
+
* callback from the most recent commit, or does nothing and returns
|
|
44
|
+
* `undefined` when there is none.
|
|
45
|
+
*/
|
|
46
|
+
export function useLatestCallback<Args extends readonly unknown[], Result>(
|
|
47
|
+
callback: ((...args: Args) => Result) | undefined,
|
|
48
|
+
): (...args: Args) => Result | undefined {
|
|
49
|
+
const latest = useRef(callback)
|
|
50
|
+
|
|
51
|
+
// `useInsertionEffect` rather than an assignment during render or a layout
|
|
52
|
+
// effect. Assigning during render publishes a callback from a render React
|
|
53
|
+
// may abandon, which under a concurrent re-render means a gesture calling a
|
|
54
|
+
// version of the callback that never committed. This effect runs at commit,
|
|
55
|
+
// before every layout effect, so the swap is complete before anything the
|
|
56
|
+
// consumer could have wired up can fire — and a gesture cannot fire earlier
|
|
57
|
+
// than that, because it is driven by native touches on a mounted view.
|
|
58
|
+
useInsertionEffect(() => {
|
|
59
|
+
latest.current = callback
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
// Empty dependency list on purpose: this identity is the whole point.
|
|
63
|
+
return useCallback((...args: Args) => latest.current?.(...args), [])
|
|
64
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { useRef } from 'react'
|
|
2
|
+
|
|
3
|
+
const EMPTY: readonly never[] = []
|
|
4
|
+
|
|
5
|
+
/** Element-wise identity comparison. `Object.is` so `NaN` is not a surprise. */
|
|
6
|
+
function sameContents<T>(a: readonly T[], b: readonly T[]): boolean {
|
|
7
|
+
if (a === b) {
|
|
8
|
+
return true
|
|
9
|
+
}
|
|
10
|
+
if (a.length !== b.length) {
|
|
11
|
+
return false
|
|
12
|
+
}
|
|
13
|
+
for (let index = 0; index < a.length; index += 1) {
|
|
14
|
+
if (!Object.is(a[index], b[index])) {
|
|
15
|
+
return false
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return true
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Normalize "one value or several" to an array whose identity changes only
|
|
23
|
+
* when its contents do.
|
|
24
|
+
*
|
|
25
|
+
* Every coexistence option takes a reference or an array of them, and the
|
|
26
|
+
* array is almost always written inline:
|
|
27
|
+
*
|
|
28
|
+
* ```tsx
|
|
29
|
+
* useDrag({ axis: 'y', deferTo: [scrollRef, pagerRef] })
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* That literal is a new array on every render. Used directly as a gesture
|
|
33
|
+
* dependency it would rebuild the gesture every render — the exact defect
|
|
34
|
+
* `useLatestCallback` exists to prevent for callbacks. Comparing the contents
|
|
35
|
+
* instead makes the dependency track what the consumer meant rather than how
|
|
36
|
+
* they spelled it.
|
|
37
|
+
*
|
|
38
|
+
* The cache is a ref written during render. That is safe here because the
|
|
39
|
+
* value is derived purely from the argument: a render React abandons can
|
|
40
|
+
* leave a stale list in the ref, and the next render compares against it by
|
|
41
|
+
* content and reaches the same answer either way.
|
|
42
|
+
*
|
|
43
|
+
* @param value - One item, several, or nothing.
|
|
44
|
+
* @returns The items as an array. The same array instance is returned on
|
|
45
|
+
* every subsequent render whose contents match element for element.
|
|
46
|
+
*/
|
|
47
|
+
export function useStableList<T>(
|
|
48
|
+
value: T | readonly T[] | undefined,
|
|
49
|
+
): readonly T[] {
|
|
50
|
+
const next: readonly T[] =
|
|
51
|
+
value === undefined
|
|
52
|
+
? EMPTY
|
|
53
|
+
: Array.isArray(value)
|
|
54
|
+
? (value as readonly T[])
|
|
55
|
+
: [value as T]
|
|
56
|
+
|
|
57
|
+
const held = useRef<readonly T[]>(EMPTY)
|
|
58
|
+
if (!sameContents(held.current, next)) {
|
|
59
|
+
held.current = next
|
|
60
|
+
}
|
|
61
|
+
return held.current
|
|
62
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { useRef } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shallow comparison for a plain options object. `Object.is` so `NaN` is not
|
|
5
|
+
* a surprise, and so a value swapped for an identical primitive compares
|
|
6
|
+
* equal.
|
|
7
|
+
*/
|
|
8
|
+
function sameEntries(a: unknown, b: unknown): boolean {
|
|
9
|
+
if (Object.is(a, b)) {
|
|
10
|
+
return true
|
|
11
|
+
}
|
|
12
|
+
if (
|
|
13
|
+
typeof a !== 'object' ||
|
|
14
|
+
typeof b !== 'object' ||
|
|
15
|
+
a === null ||
|
|
16
|
+
b === null
|
|
17
|
+
) {
|
|
18
|
+
return false
|
|
19
|
+
}
|
|
20
|
+
const left = a as Record<string, unknown>
|
|
21
|
+
const right = b as Record<string, unknown>
|
|
22
|
+
const leftKeys = Object.keys(left)
|
|
23
|
+
if (leftKeys.length !== Object.keys(right).length) {
|
|
24
|
+
return false
|
|
25
|
+
}
|
|
26
|
+
for (const key of leftKeys) {
|
|
27
|
+
if (!Object.is(left[key], right[key])) {
|
|
28
|
+
return false
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return true
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Hold an options object's identity steady while its contents are unchanged.
|
|
36
|
+
*
|
|
37
|
+
* The array counterpart of {@link useStableList}, for the options that are
|
|
38
|
+
* records rather than lists — `hitSlop` is the first, and every activation
|
|
39
|
+
* criterion shaped like it will be the next. The problem is identical: the
|
|
40
|
+
* value is written inline at the call site,
|
|
41
|
+
*
|
|
42
|
+
* ```tsx
|
|
43
|
+
* useTap({ hitSlop: { horizontal: 12 }, onTap: select })
|
|
44
|
+
* ```
|
|
45
|
+
*
|
|
46
|
+
* so it is a new object on every render. Used directly as a gesture
|
|
47
|
+
* dependency it rebuilds the gesture every render, `<GestureDetector>`
|
|
48
|
+
* re-attaches it, and a re-attach mid-press drops the press. Comparing the
|
|
49
|
+
* contents makes the dependency track what the consumer meant rather than how
|
|
50
|
+
* they spelled it.
|
|
51
|
+
*
|
|
52
|
+
* A shallow comparison is enough because every option this is used for is one
|
|
53
|
+
* level deep. It is not a general deep-equal, and it must not become one: a
|
|
54
|
+
* deep walk on every render of every gesture is a cost paid on the render path
|
|
55
|
+
* to save a cost paid only when a gesture is rebuilt.
|
|
56
|
+
*
|
|
57
|
+
* The cache is a ref written during render, which is safe for the same reason
|
|
58
|
+
* it is in `useStableList`: the value is derived purely from the argument, so
|
|
59
|
+
* a render React abandons leaves a stale entry that the next render compares
|
|
60
|
+
* against by content and reaches the same answer either way.
|
|
61
|
+
*
|
|
62
|
+
* @param value - The options object, a primitive, or nothing.
|
|
63
|
+
* @returns The same instance on every render whose contents match key for
|
|
64
|
+
* key.
|
|
65
|
+
*/
|
|
66
|
+
export function useStableRecord<T>(value: T): T {
|
|
67
|
+
const held = useRef<T>(value)
|
|
68
|
+
if (!sameEntries(held.current, value)) {
|
|
69
|
+
held.current = value
|
|
70
|
+
}
|
|
71
|
+
return held.current
|
|
72
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev-only warnings, emitted at most once per key.
|
|
3
|
+
*
|
|
4
|
+
* A gesture that misbehaves gives the consumer almost nothing to go on — the
|
|
5
|
+
* failure is "nothing happened", on a device, with no stack. Impulse's answer
|
|
6
|
+
* is to say the specific thing that is wrong at the moment it can still be
|
|
7
|
+
* detected. The warning has to be cheap enough to leave in every hook, hence
|
|
8
|
+
* the key: a hook that re-renders sixty times a second must not print sixty
|
|
9
|
+
* times a second.
|
|
10
|
+
*
|
|
11
|
+
* The key is the deduplication unit, so it names the *condition*, not the
|
|
12
|
+
* call site. Two components making the same mistake warn once between them —
|
|
13
|
+
* deliberately: the message names the fix, and repeating it per instance
|
|
14
|
+
* buries it.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// `__DEV__` is defined by Metro on every platform that can run RNGH, and both
|
|
18
|
+
// RNGH and Reanimated read it bare. It is not a language global though, so a
|
|
19
|
+
// bundler that does not define it would throw a `ReferenceError` on a bare
|
|
20
|
+
// read; `typeof` is safe against that. The declaration lives here because the
|
|
21
|
+
// package's tsconfig sets `types: []`, so no ambient React Native types are
|
|
22
|
+
// in scope.
|
|
23
|
+
declare const __DEV__: boolean
|
|
24
|
+
|
|
25
|
+
const warned = new Set<string>()
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Whether this is a development build. `false` when `__DEV__` is undefined,
|
|
29
|
+
* so a bundler that does not define it gets silence rather than a crash.
|
|
30
|
+
*/
|
|
31
|
+
export function isDevBuild(): boolean {
|
|
32
|
+
return typeof __DEV__ !== 'undefined' && __DEV__
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Warn once for `key`, in development builds only.
|
|
37
|
+
*
|
|
38
|
+
* @param key - The condition being reported. Reused keys warn once in total.
|
|
39
|
+
* @param message - What is wrong and what to do about it. Write the fix into
|
|
40
|
+
* the message; a warning the reader has to interpret is a warning they
|
|
41
|
+
* ignore.
|
|
42
|
+
*/
|
|
43
|
+
export function warnOnce(key: string, message: string): void {
|
|
44
|
+
if (!isDevBuild() || warned.has(key)) {
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
warned.add(key)
|
|
48
|
+
console.warn(`[impulse] ${message}`)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Forget every key already warned about.
|
|
53
|
+
*
|
|
54
|
+
* For tests only. Deduplication is process-wide, so without this the second
|
|
55
|
+
* test asserting a given warning would see nothing and pass for the wrong
|
|
56
|
+
* reason.
|
|
57
|
+
*/
|
|
58
|
+
export function resetWarnings(): void {
|
|
59
|
+
warned.clear()
|
|
60
|
+
}
|
package/src/raw/index.ts
ADDED