@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.
Files changed (44) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/LICENSE +21 -0
  3. package/README.md +174 -0
  4. package/dist/chunk-5BMRKYVY.js +39 -0
  5. package/dist/chunk-F4RHM4ZK.js +77 -0
  6. package/dist/chunk-FR242SUF.js +174 -0
  7. package/dist/chunk-IG5RXCYR.js +74 -0
  8. package/dist/chunk-LM645QQT.js +37 -0
  9. package/dist/chunk-PMR25UCT.js +8 -0
  10. package/dist/chunk-ZX7WNICB.js +39 -0
  11. package/dist/compose/index.d.ts +80 -0
  12. package/dist/compose/index.js +2 -0
  13. package/dist/drag/index.d.ts +277 -0
  14. package/dist/drag/index.js +4 -0
  15. package/dist/gesture-handler/index.d.ts +1 -0
  16. package/dist/gesture-handler/index.js +1 -0
  17. package/dist/index.d.ts +9 -0
  18. package/dist/index.js +8 -0
  19. package/dist/raw/index.d.ts +63 -0
  20. package/dist/raw/index.js +3 -0
  21. package/dist/tap/index.d.ts +150 -0
  22. package/dist/tap/index.js +4 -0
  23. package/dist/types-Ch2HM3aP.d.ts +142 -0
  24. package/dist/useGestureMemo-Ccv8rB0C.d.ts +38 -0
  25. package/jest-preset.cjs +60 -0
  26. package/jest-setup.cjs +56 -0
  27. package/package.json +115 -0
  28. package/src/compose/index.ts +7 -0
  29. package/src/compose/useGestures.ts +136 -0
  30. package/src/gesture-handler/index.ts +18 -0
  31. package/src/index.ts +53 -0
  32. package/src/intents/drag/index.ts +8 -0
  33. package/src/intents/tap/index.ts +2 -0
  34. package/src/intents/useDrag.ts +563 -0
  35. package/src/intents/useTap.ts +285 -0
  36. package/src/internal/useGestureMemo.ts +110 -0
  37. package/src/internal/useLatestCallback.ts +64 -0
  38. package/src/internal/useStableList.ts +62 -0
  39. package/src/internal/useStableRecord.ts +72 -0
  40. package/src/internal/warnOnce.ts +60 -0
  41. package/src/raw/index.ts +2 -0
  42. package/src/raw/useRawGesture.ts +71 -0
  43. package/src/relations/index.ts +120 -0
  44. 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
+ }
@@ -0,0 +1,2 @@
1
+ export { useRawGesture } from './useRawGesture'
2
+ export type { RawGestureResult, UseRawGestureOptions } from './useRawGesture'