@rootnative/inertia-gestures 0.0.9 → 0.0.10

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 CHANGED
@@ -6,6 +6,24 @@ This package ships in lockstep with `@rootnative/inertia` — version numbers tr
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.0.10] - 2026-09-06
10
+
11
+ ### Fixed
12
+
13
+ - **`useDrag` and `useSwipe` no longer rebuild their gesture when an inline callback changes identity.** `onDragStart` / `onDragEnd` (`useDrag`) and `onSwipe` / `onSwipeEnd` (`useSwipe`) were dependencies of the gesture memo, so a callback written inline in the options rebuilt the `Pan` gesture on every render, and `<GestureDetector>` re-attached it. The JS-thread callbacks are now reached through stable wrappers (`useLatestCallback`) that always call the latest function; the worklet callbacks (`onRelease`, `onCommit`) stay direct dependencies because a worklet must capture them as-is. Found by the `1.0.0` readiness audit (2026-09-05), Phase 3.
14
+
15
+ - **`useSwipe`'s decay `releaseTransition` warning fires once per name, not once per render.** Found by the `1.0.0` readiness audit (2026-09-05), Phase 3.
16
+
17
+ ### Changed
18
+
19
+ - **The `@rootnative/inertia` peer range has an upper bound: `>=0.0.9 <0.1.0`.** Pre-`1.0.0`, a minor bump of core is where a breaking change lands, so this adapter must not accept it. Enforced by `check:versions`. Found by the `1.0.0` readiness audit (2026-09-05), Phase 3.
20
+
21
+ - `useDrag`, `usePan`, and `useSwipe` return the `animatedStyle` from core's new `useTranslateStyle`, and `useDrag` applies its constraints through core's exported `applyBounds`. Same behaviour, one implementation. Both come from `@rootnative/inertia`, so this adapter needs the core release that ships them; the lockstep release carries both.
22
+
23
+ ### Added
24
+
25
+ - Tests for `useDrag`'s `onDragStart` and decay release branch, `usePan`'s `withDecay` config (velocity, deceleration, clamp widening), and gesture identity across inline callback changes.
26
+
9
27
  ## [0.0.9] - 2026-08-22
10
28
 
11
29
  **Lockstep version bump** alongside `@rootnative/inertia@0.0.9` (`useInterpolatedStyle` types its return against the map it was given, so a `style` array no longer needs a cast). No runtime changes in this adapter; the `@rootnative/inertia` peer range moves to `>=0.0.9`.
@@ -71,7 +89,8 @@ Initial alpha publish alongside `@rootnative/inertia@0.0.0-alpha.0`. Optional ad
71
89
  - `useDrag({ onRelease })` — release worklet returns per-axis Inertia transitions (snap-to-tick spring, decay with bounds, etc.). Velocity stays on the UI thread; no JS round-trip.
72
90
  - `useSwipe`, `usePan` hooks composable with any `Motion.*` primitive via `<GestureDetector>`.
73
91
 
74
- [unreleased]: https://github.com/rootnative/inertia/compare/core+gestures+gradients+svg@0.0.9...HEAD
92
+ [unreleased]: https://github.com/rootnative/inertia/compare/core+gestures+gradients+svg@0.0.10...HEAD
93
+ [0.0.10]: https://github.com/rootnative/inertia/releases/tag/core+gestures+gradients+svg@0.0.10
75
94
  [0.0.9]: https://github.com/rootnative/inertia/releases/tag/core+gestures+gradients+svg@0.0.9
76
95
  [0.0.8]: https://github.com/rootnative/inertia/releases/tag/core+gestures+gradients+svg@0.0.8
77
96
  [0.0.7]: https://github.com/rootnative/inertia/releases/tag/core+gestures+gradients+svg@0.0.7
package/README.md CHANGED
@@ -36,12 +36,12 @@ import { useDrag } from '@rootnative/inertia-gestures'
36
36
  function DraggableBox() {
37
37
  const drag = useDrag({
38
38
  axis: 'both',
39
- bounds: { left: -120, right: 120, top: -120, bottom: 120 },
39
+ constraints: { left: -120, right: 120, top: -120, bottom: 120 },
40
40
  })
41
41
 
42
42
  return (
43
43
  <GestureDetector gesture={drag.gesture}>
44
- <Motion.View style={drag.style} />
44
+ <Motion.View style={drag.animatedStyle} />
45
45
  </GestureDetector>
46
46
  )
47
47
  }
package/dist/index.js CHANGED
@@ -5,6 +5,15 @@ var reactNativeGestureHandler = require('react-native-gesture-handler');
5
5
  var reactNativeReanimated = require('react-native-reanimated');
6
6
  var inertia = require('@rootnative/inertia');
7
7
 
8
+ // src/useDrag.ts
9
+ function useLatestCallback(fn) {
10
+ const ref = react.useRef(fn);
11
+ ref.current = fn;
12
+ return react.useCallback((...args) => {
13
+ ref.current?.(...args);
14
+ }, []);
15
+ }
16
+
8
17
  // src/useDrag.ts
9
18
  function useDrag(options = {}) {
10
19
  const {
@@ -27,17 +36,21 @@ function useDrag(options = {}) {
27
36
  const top = constraints?.top;
28
37
  const bottom = constraints?.bottom;
29
38
  const elasticCoef = elastic;
39
+ const hasDragStart = onDragStart !== void 0;
40
+ const hasDragEnd = onDragEnd !== void 0;
41
+ const fireDragStart = useLatestCallback(onDragStart);
42
+ const fireDragEnd = useLatestCallback(onDragEnd);
30
43
  const gesture = react.useMemo(() => {
31
44
  const pan = reactNativeGestureHandler.Gesture.Pan().onStart(() => {
32
45
  "worklet";
33
46
  startX.value = dragX.value;
34
47
  startY.value = dragY.value;
35
48
  isDragging.value = true;
36
- if (onDragStart) reactNativeReanimated.runOnJS(onDragStart)();
49
+ if (hasDragStart) reactNativeReanimated.runOnJS(fireDragStart)();
37
50
  }).onUpdate((e) => {
38
51
  "worklet";
39
52
  if (lockX) {
40
- dragX.value = applyBounds(
53
+ dragX.value = inertia.applyBounds(
41
54
  startX.value + e.translationX,
42
55
  left,
43
56
  right,
@@ -45,7 +58,7 @@ function useDrag(options = {}) {
45
58
  );
46
59
  }
47
60
  if (lockY) {
48
- dragY.value = applyBounds(
61
+ dragY.value = inertia.applyBounds(
49
62
  startY.value + e.translationY,
50
63
  top,
51
64
  bottom,
@@ -78,8 +91,8 @@ function useDrag(options = {}) {
78
91
  }
79
92
  }
80
93
  }
81
- if (onDragEnd) {
82
- reactNativeReanimated.runOnJS(onDragEnd)({ x, y, velocity: { x: vx, y: vy } });
94
+ if (hasDragEnd) {
95
+ reactNativeReanimated.runOnJS(fireDragEnd)({ x, y, velocity: { x: vx, y: vy } });
83
96
  }
84
97
  });
85
98
  return pan;
@@ -91,8 +104,10 @@ function useDrag(options = {}) {
91
104
  top,
92
105
  bottom,
93
106
  elasticCoef,
94
- onDragStart,
95
- onDragEnd,
107
+ hasDragStart,
108
+ hasDragEnd,
109
+ fireDragStart,
110
+ fireDragEnd,
96
111
  onRelease,
97
112
  dragX,
98
113
  dragY,
@@ -100,21 +115,10 @@ function useDrag(options = {}) {
100
115
  startY,
101
116
  isDragging
102
117
  ]);
103
- const animatedStyle = reactNativeReanimated.useAnimatedStyle(() => ({
104
- transform: [{ translateX: dragX.value }, { translateY: dragY.value }]
105
- }));
118
+ const animatedStyle = inertia.useTranslateStyle(dragX, dragY);
106
119
  return { gesture, animatedStyle, dragX, dragY, isDragging };
107
120
  }
108
- function applyBounds(value, min, max, elastic) {
109
- "worklet";
110
- if (min !== void 0 && value < min) {
111
- return elastic > 0 ? min + (value - min) * elastic : min;
112
- }
113
- if (max !== void 0 && value > max) {
114
- return elastic > 0 ? max + (value - max) * elastic : max;
115
- }
116
- return value;
117
- }
121
+ var warnedDecayNames = /* @__PURE__ */ new Set();
118
122
  var DEFAULT_DIRECTIONS = ["left", "right", "up", "down"];
119
123
  var DEFAULT_SNAP_BACK = { type: "spring" };
120
124
  function useSwipe(options = {}) {
@@ -138,7 +142,9 @@ function useSwipe(options = {}) {
138
142
  const snapBack = react.useMemo(() => {
139
143
  const cfg = inertia.resolveNamedTransition(releaseTransition, registry) ?? DEFAULT_SNAP_BACK;
140
144
  if (cfg.type === "decay") {
141
- if (__DEV__) {
145
+ const key = typeof releaseTransition === "string" ? releaseTransition : "<inline>";
146
+ if (__DEV__ && !warnedDecayNames.has(key)) {
147
+ warnedDecayNames.add(key);
142
148
  console.warn(
143
149
  "[inertia] useSwipe `releaseTransition` resolved to a decay transition, which has no target and cannot snap back to zero \u2014 falling back to the default spring."
144
150
  );
@@ -147,6 +153,10 @@ function useSwipe(options = {}) {
147
153
  }
148
154
  return cfg;
149
155
  }, [releaseTransition, registry]);
156
+ const hasSwipe = onSwipe !== void 0;
157
+ const hasSwipeEnd = onSwipeEnd !== void 0;
158
+ const fireSwipe = useLatestCallback(onSwipe);
159
+ const fireSwipeEnd = useLatestCallback(onSwipeEnd);
150
160
  const gesture = react.useMemo(() => {
151
161
  const pan = reactNativeGestureHandler.Gesture.Pan().onStart(() => {
152
162
  "worklet";
@@ -170,11 +180,11 @@ function useSwipe(options = {}) {
170
180
  allowUp,
171
181
  allowDown
172
182
  );
173
- if (direction !== null && onSwipe) {
183
+ if (direction !== null && hasSwipe) {
174
184
  const isHoriz = direction === "left" || direction === "right";
175
185
  const distance = isHoriz ? Math.abs(e.translationX) : Math.abs(e.translationY);
176
186
  const velocity = isHoriz ? Math.abs(e.velocityX) : Math.abs(e.velocityY);
177
- reactNativeReanimated.runOnJS(onSwipe)(direction, { distance, velocity });
187
+ reactNativeReanimated.runOnJS(fireSwipe)(direction, { distance, velocity });
178
188
  }
179
189
  let exit = void 0;
180
190
  if (direction !== null && onCommit) {
@@ -185,11 +195,10 @@ function useSwipe(options = {}) {
185
195
  });
186
196
  }
187
197
  let settle;
188
- if (direction !== null && onSwipeEnd) {
198
+ if (direction !== null && hasSwipeEnd) {
189
199
  const dir = direction;
190
- const end = onSwipeEnd;
191
200
  settle = (finished) => {
192
- reactNativeReanimated.runOnJS(end)(dir, { finished: finished === true });
201
+ reactNativeReanimated.runOnJS(fireSwipeEnd)(dir, { finished: finished === true });
193
202
  };
194
203
  }
195
204
  const isHorizontalCommit = direction === "left" || direction === "right";
@@ -236,16 +245,16 @@ function useSwipe(options = {}) {
236
245
  allowUp,
237
246
  allowDown,
238
247
  snapBack,
239
- onSwipe,
248
+ hasSwipe,
249
+ hasSwipeEnd,
250
+ fireSwipe,
251
+ fireSwipeEnd,
240
252
  onCommit,
241
- onSwipeEnd,
242
253
  swipeX,
243
254
  swipeY,
244
255
  isActive
245
256
  ]);
246
- const animatedStyle = reactNativeReanimated.useAnimatedStyle(() => ({
247
- transform: [{ translateX: swipeX.value }, { translateY: swipeY.value }]
248
- }));
257
+ const animatedStyle = inertia.useTranslateStyle(swipeX, swipeY);
249
258
  const reset = react.useCallback(() => {
250
259
  swipeX.value = 0;
251
260
  swipeY.value = 0;
@@ -323,9 +332,7 @@ function usePan(options = {}) {
323
332
  startY,
324
333
  isPanning
325
334
  ]);
326
- const animatedStyle = reactNativeReanimated.useAnimatedStyle(() => ({
327
- transform: [{ translateX: panX.value }, { translateY: panY.value }]
328
- }));
335
+ const animatedStyle = inertia.useTranslateStyle(panX, panY);
329
336
  return { gesture, animatedStyle, panX, panY, isPanning };
330
337
  }
331
338
  function clamp(value, min, max) {
package/dist/index.mjs CHANGED
@@ -1,7 +1,16 @@
1
- import { useMemo, useCallback } from 'react';
1
+ import { useMemo, useCallback, useRef } from 'react';
2
2
  import { Gesture } from 'react-native-gesture-handler';
3
- import { useSharedValue, runOnJS, useAnimatedStyle, withDecay } from 'react-native-reanimated';
4
- import { buildReleaseAnimation, useNamedTransitions, resolveNamedTransition } from '@rootnative/inertia';
3
+ import { useSharedValue, runOnJS, withDecay } from 'react-native-reanimated';
4
+ import { applyBounds, buildReleaseAnimation, useTranslateStyle, useNamedTransitions, resolveNamedTransition } from '@rootnative/inertia';
5
+
6
+ // src/useDrag.ts
7
+ function useLatestCallback(fn) {
8
+ const ref = useRef(fn);
9
+ ref.current = fn;
10
+ return useCallback((...args) => {
11
+ ref.current?.(...args);
12
+ }, []);
13
+ }
5
14
 
6
15
  // src/useDrag.ts
7
16
  function useDrag(options = {}) {
@@ -25,13 +34,17 @@ function useDrag(options = {}) {
25
34
  const top = constraints?.top;
26
35
  const bottom = constraints?.bottom;
27
36
  const elasticCoef = elastic;
37
+ const hasDragStart = onDragStart !== void 0;
38
+ const hasDragEnd = onDragEnd !== void 0;
39
+ const fireDragStart = useLatestCallback(onDragStart);
40
+ const fireDragEnd = useLatestCallback(onDragEnd);
28
41
  const gesture = useMemo(() => {
29
42
  const pan = Gesture.Pan().onStart(() => {
30
43
  "worklet";
31
44
  startX.value = dragX.value;
32
45
  startY.value = dragY.value;
33
46
  isDragging.value = true;
34
- if (onDragStart) runOnJS(onDragStart)();
47
+ if (hasDragStart) runOnJS(fireDragStart)();
35
48
  }).onUpdate((e) => {
36
49
  "worklet";
37
50
  if (lockX) {
@@ -76,8 +89,8 @@ function useDrag(options = {}) {
76
89
  }
77
90
  }
78
91
  }
79
- if (onDragEnd) {
80
- runOnJS(onDragEnd)({ x, y, velocity: { x: vx, y: vy } });
92
+ if (hasDragEnd) {
93
+ runOnJS(fireDragEnd)({ x, y, velocity: { x: vx, y: vy } });
81
94
  }
82
95
  });
83
96
  return pan;
@@ -89,8 +102,10 @@ function useDrag(options = {}) {
89
102
  top,
90
103
  bottom,
91
104
  elasticCoef,
92
- onDragStart,
93
- onDragEnd,
105
+ hasDragStart,
106
+ hasDragEnd,
107
+ fireDragStart,
108
+ fireDragEnd,
94
109
  onRelease,
95
110
  dragX,
96
111
  dragY,
@@ -98,21 +113,10 @@ function useDrag(options = {}) {
98
113
  startY,
99
114
  isDragging
100
115
  ]);
101
- const animatedStyle = useAnimatedStyle(() => ({
102
- transform: [{ translateX: dragX.value }, { translateY: dragY.value }]
103
- }));
116
+ const animatedStyle = useTranslateStyle(dragX, dragY);
104
117
  return { gesture, animatedStyle, dragX, dragY, isDragging };
105
118
  }
106
- function applyBounds(value, min, max, elastic) {
107
- "worklet";
108
- if (min !== void 0 && value < min) {
109
- return elastic > 0 ? min + (value - min) * elastic : min;
110
- }
111
- if (max !== void 0 && value > max) {
112
- return elastic > 0 ? max + (value - max) * elastic : max;
113
- }
114
- return value;
115
- }
119
+ var warnedDecayNames = /* @__PURE__ */ new Set();
116
120
  var DEFAULT_DIRECTIONS = ["left", "right", "up", "down"];
117
121
  var DEFAULT_SNAP_BACK = { type: "spring" };
118
122
  function useSwipe(options = {}) {
@@ -136,7 +140,9 @@ function useSwipe(options = {}) {
136
140
  const snapBack = useMemo(() => {
137
141
  const cfg = resolveNamedTransition(releaseTransition, registry) ?? DEFAULT_SNAP_BACK;
138
142
  if (cfg.type === "decay") {
139
- if (__DEV__) {
143
+ const key = typeof releaseTransition === "string" ? releaseTransition : "<inline>";
144
+ if (__DEV__ && !warnedDecayNames.has(key)) {
145
+ warnedDecayNames.add(key);
140
146
  console.warn(
141
147
  "[inertia] useSwipe `releaseTransition` resolved to a decay transition, which has no target and cannot snap back to zero \u2014 falling back to the default spring."
142
148
  );
@@ -145,6 +151,10 @@ function useSwipe(options = {}) {
145
151
  }
146
152
  return cfg;
147
153
  }, [releaseTransition, registry]);
154
+ const hasSwipe = onSwipe !== void 0;
155
+ const hasSwipeEnd = onSwipeEnd !== void 0;
156
+ const fireSwipe = useLatestCallback(onSwipe);
157
+ const fireSwipeEnd = useLatestCallback(onSwipeEnd);
148
158
  const gesture = useMemo(() => {
149
159
  const pan = Gesture.Pan().onStart(() => {
150
160
  "worklet";
@@ -168,11 +178,11 @@ function useSwipe(options = {}) {
168
178
  allowUp,
169
179
  allowDown
170
180
  );
171
- if (direction !== null && onSwipe) {
181
+ if (direction !== null && hasSwipe) {
172
182
  const isHoriz = direction === "left" || direction === "right";
173
183
  const distance = isHoriz ? Math.abs(e.translationX) : Math.abs(e.translationY);
174
184
  const velocity = isHoriz ? Math.abs(e.velocityX) : Math.abs(e.velocityY);
175
- runOnJS(onSwipe)(direction, { distance, velocity });
185
+ runOnJS(fireSwipe)(direction, { distance, velocity });
176
186
  }
177
187
  let exit = void 0;
178
188
  if (direction !== null && onCommit) {
@@ -183,11 +193,10 @@ function useSwipe(options = {}) {
183
193
  });
184
194
  }
185
195
  let settle;
186
- if (direction !== null && onSwipeEnd) {
196
+ if (direction !== null && hasSwipeEnd) {
187
197
  const dir = direction;
188
- const end = onSwipeEnd;
189
198
  settle = (finished) => {
190
- runOnJS(end)(dir, { finished: finished === true });
199
+ runOnJS(fireSwipeEnd)(dir, { finished: finished === true });
191
200
  };
192
201
  }
193
202
  const isHorizontalCommit = direction === "left" || direction === "right";
@@ -234,16 +243,16 @@ function useSwipe(options = {}) {
234
243
  allowUp,
235
244
  allowDown,
236
245
  snapBack,
237
- onSwipe,
246
+ hasSwipe,
247
+ hasSwipeEnd,
248
+ fireSwipe,
249
+ fireSwipeEnd,
238
250
  onCommit,
239
- onSwipeEnd,
240
251
  swipeX,
241
252
  swipeY,
242
253
  isActive
243
254
  ]);
244
- const animatedStyle = useAnimatedStyle(() => ({
245
- transform: [{ translateX: swipeX.value }, { translateY: swipeY.value }]
246
- }));
255
+ const animatedStyle = useTranslateStyle(swipeX, swipeY);
247
256
  const reset = useCallback(() => {
248
257
  swipeX.value = 0;
249
258
  swipeY.value = 0;
@@ -321,9 +330,7 @@ function usePan(options = {}) {
321
330
  startY,
322
331
  isPanning
323
332
  ]);
324
- const animatedStyle = useAnimatedStyle(() => ({
325
- transform: [{ translateX: panX.value }, { translateY: panY.value }]
326
- }));
333
+ const animatedStyle = useTranslateStyle(panX, panY);
327
334
  return { gesture, animatedStyle, panX, panY, isPanning };
328
335
  }
329
336
  function clamp(value, min, max) {
package/llms.txt CHANGED
@@ -258,10 +258,6 @@ The full ladder:
258
258
 
259
259
  Both `useTouchDrag` and the gesture-handler `useDrag` accept the same `onRelease` shape and route through `buildReleaseAnimation`, so swapping between them only touches the wiring, not the release animation config.
260
260
 
261
- ## Try it
262
-
263
-
264
-
265
261
  ## Why a separate package?
266
262
 
267
263
  The core library's `gesture` prop covers `pressed` / `focused` / `focusVisible` / `hovered` — boolean state-overlay gestures with no continuous translation. Drag, swipe, and pan are **value-driven**: the gesture publishes a continuous position that the consumer composes into the view's style. They don't fit the boolean-state model, and they need `react-native-gesture-handler` (which we don't want as a required peer of core).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rootnative/inertia-gestures",
3
- "version": "0.0.9",
3
+ "version": "0.0.10",
4
4
  "description": "Gesture-handler-driven drag / pan / swipe adapters for @rootnative/inertia.",
5
5
  "license": "MIT",
6
6
  "author": "RootNative",
@@ -50,7 +50,7 @@
50
50
  "!**/*.test.*"
51
51
  ],
52
52
  "peerDependencies": {
53
- "@rootnative/inertia": ">=0.0.9",
53
+ "@rootnative/inertia": ">=0.0.10 <0.1.0",
54
54
  "react": ">=19.0.0",
55
55
  "react-native": ">=0.81.0",
56
56
  "react-native-gesture-handler": ">=2.0.0",
@@ -69,7 +69,7 @@
69
69
  "react-test-renderer": "19.1.0",
70
70
  "tsup": "^8.3.5",
71
71
  "typescript": "^5.7.3",
72
- "@rootnative/inertia": "0.0.9"
72
+ "@rootnative/inertia": "0.0.10"
73
73
  },
74
74
  "publishConfig": {
75
75
  "access": "public"
package/src/useDrag.ts CHANGED
@@ -2,11 +2,16 @@ import { useMemo } from 'react'
2
2
  import { Gesture, type PanGesture } from 'react-native-gesture-handler'
3
3
  import {
4
4
  runOnJS,
5
- useAnimatedStyle,
6
5
  useSharedValue,
7
6
  type SharedValue,
8
7
  } from 'react-native-reanimated'
9
- import { buildReleaseAnimation } from '@rootnative/inertia'
8
+ import type { useAnimatedStyle } from 'react-native-reanimated'
9
+ import {
10
+ applyBounds,
11
+ buildReleaseAnimation,
12
+ useTranslateStyle,
13
+ } from '@rootnative/inertia'
14
+ import { useLatestCallback } from './useLatestCallback'
10
15
  import type { DragConstraints, DragOptions } from './types'
11
16
 
12
17
  export interface UseDragResult {
@@ -77,6 +82,15 @@ export function useDrag(options: DragOptions = {}): UseDragResult {
77
82
  const bottom = constraints?.bottom
78
83
  const elasticCoef = elastic
79
84
 
85
+ // JS-thread callbacks are read through stable wrappers so an inline arrow
86
+ // in `options` does not rebuild the gesture every render. `onRelease` is a
87
+ // worklet and stays a direct dependency — it must be captured by the
88
+ // gesture worklet as-is, since there is no ref on the UI thread.
89
+ const hasDragStart = onDragStart !== undefined
90
+ const hasDragEnd = onDragEnd !== undefined
91
+ const fireDragStart = useLatestCallback(onDragStart)
92
+ const fireDragEnd = useLatestCallback(onDragEnd)
93
+
80
94
  const gesture = useMemo(() => {
81
95
  const pan = Gesture.Pan()
82
96
  .onStart(() => {
@@ -84,7 +98,7 @@ export function useDrag(options: DragOptions = {}): UseDragResult {
84
98
  startX.value = dragX.value
85
99
  startY.value = dragY.value
86
100
  isDragging.value = true
87
- if (onDragStart) runOnJS(onDragStart)()
101
+ if (hasDragStart) runOnJS(fireDragStart)()
88
102
  })
89
103
  .onUpdate((e) => {
90
104
  'worklet'
@@ -134,8 +148,8 @@ export function useDrag(options: DragOptions = {}): UseDragResult {
134
148
  }
135
149
  }
136
150
  }
137
- if (onDragEnd) {
138
- runOnJS(onDragEnd)({ x, y, velocity: { x: vx, y: vy } })
151
+ if (hasDragEnd) {
152
+ runOnJS(fireDragEnd)({ x, y, velocity: { x: vx, y: vy } })
139
153
  }
140
154
  })
141
155
  return pan
@@ -147,8 +161,10 @@ export function useDrag(options: DragOptions = {}): UseDragResult {
147
161
  top,
148
162
  bottom,
149
163
  elasticCoef,
150
- onDragStart,
151
- onDragEnd,
164
+ hasDragStart,
165
+ hasDragEnd,
166
+ fireDragStart,
167
+ fireDragEnd,
152
168
  onRelease,
153
169
  dragX,
154
170
  dragY,
@@ -157,34 +173,9 @@ export function useDrag(options: DragOptions = {}): UseDragResult {
157
173
  isDragging,
158
174
  ])
159
175
 
160
- const animatedStyle = useAnimatedStyle(() => ({
161
- transform: [{ translateX: dragX.value }, { translateY: dragY.value }],
162
- }))
176
+ const animatedStyle = useTranslateStyle(dragX, dragY)
163
177
 
164
178
  return { gesture, animatedStyle, dragX, dragY, isDragging }
165
179
  }
166
180
 
167
- /**
168
- * Clamp `value` to `[min, max]`. When `elastic > 0` the overshoot beyond a
169
- * bound is scaled by `elastic` instead of hard-clamped, giving a rubber-band
170
- * feel. `min` / `max` may be `undefined` to leave that side unbounded.
171
- *
172
- * Worklet — runs on the UI thread inside the pan handler.
173
- */
174
- function applyBounds(
175
- value: number,
176
- min: number | undefined,
177
- max: number | undefined,
178
- elastic: number,
179
- ): number {
180
- 'worklet'
181
- if (min !== undefined && value < min) {
182
- return elastic > 0 ? min + (value - min) * elastic : min
183
- }
184
- if (max !== undefined && value > max) {
185
- return elastic > 0 ? max + (value - max) * elastic : max
186
- }
187
- return value
188
- }
189
-
190
181
  export type { DragConstraints, DragOptions }
@@ -0,0 +1,18 @@
1
+ import { useCallback, useRef } from 'react'
2
+
3
+ /**
4
+ * Return a function with a stable identity that always calls the latest
5
+ * `fn`. The gesture hooks hand the returned function to `runOnJS` inside a
6
+ * memoised gesture, so an inline callback in the options does not rebuild the
7
+ * gesture on every render. Calling the result when `fn` is `undefined` is a
8
+ * no-op.
9
+ */
10
+ export function useLatestCallback<A extends unknown[]>(
11
+ fn: ((...args: A) => void) | undefined,
12
+ ): (...args: A) => void {
13
+ const ref = useRef(fn)
14
+ ref.current = fn
15
+ return useCallback((...args: A) => {
16
+ ref.current?.(...args)
17
+ }, [])
18
+ }
package/src/usePan.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  import { useMemo } from 'react'
2
2
  import { Gesture, type PanGesture } from 'react-native-gesture-handler'
3
3
  import {
4
- useAnimatedStyle,
5
4
  useSharedValue,
6
5
  withDecay,
7
6
  type SharedValue,
8
7
  } from 'react-native-reanimated'
8
+ import type { useAnimatedStyle } from 'react-native-reanimated'
9
+ import { useTranslateStyle } from '@rootnative/inertia'
9
10
  import type { DragConstraints } from './types'
10
11
 
11
12
  export interface PanOptions {
@@ -114,9 +115,7 @@ export function usePan(options: PanOptions = {}): UsePanResult {
114
115
  isPanning,
115
116
  ])
116
117
 
117
- const animatedStyle = useAnimatedStyle(() => ({
118
- transform: [{ translateX: panX.value }, { translateY: panY.value }],
119
- }))
118
+ const animatedStyle = useTranslateStyle(panX, panY)
120
119
 
121
120
  return { gesture, animatedStyle, panX, panY, isPanning }
122
121
  }
package/src/useSwipe.ts CHANGED
@@ -2,21 +2,27 @@ import { useCallback, useMemo } from 'react'
2
2
  import { Gesture, type PanGesture } from 'react-native-gesture-handler'
3
3
  import {
4
4
  runOnJS,
5
- useAnimatedStyle,
6
5
  useSharedValue,
7
6
  type SharedValue,
8
7
  } from 'react-native-reanimated'
8
+ import type { useAnimatedStyle } from 'react-native-reanimated'
9
9
  import {
10
10
  buildReleaseAnimation,
11
11
  resolveNamedTransition,
12
12
  useNamedTransitions,
13
+ useTranslateStyle,
13
14
  type TransitionConfig,
14
15
  type TransitionName,
15
16
  } from '@rootnative/inertia'
17
+ import { useLatestCallback } from './useLatestCallback'
16
18
  import type { ReleaseInfo, ReleaseResult, SnapBackTransition } from './types'
17
19
 
18
20
  declare const __DEV__: boolean
19
21
 
22
+ // Dev warning for a decay `releaseTransition`, keyed per name so it fires once
23
+ // per misconfiguration instead of once per render.
24
+ const warnedDecayNames = new Set<string>()
25
+
20
26
  export type SwipeDirection = 'left' | 'right' | 'up' | 'down'
21
27
 
22
28
  export interface SwipeOptions {
@@ -201,7 +207,10 @@ export function useSwipe(options: SwipeOptions = {}): UseSwipeResult {
201
207
  const cfg =
202
208
  resolveNamedTransition(releaseTransition, registry) ?? DEFAULT_SNAP_BACK
203
209
  if (cfg.type === 'decay') {
204
- if (__DEV__) {
210
+ const key =
211
+ typeof releaseTransition === 'string' ? releaseTransition : '<inline>'
212
+ if (__DEV__ && !warnedDecayNames.has(key)) {
213
+ warnedDecayNames.add(key)
205
214
  console.warn(
206
215
  '[inertia] useSwipe `releaseTransition` resolved to a decay ' +
207
216
  'transition, which has no target and cannot snap back to zero — ' +
@@ -213,6 +222,14 @@ export function useSwipe(options: SwipeOptions = {}): UseSwipeResult {
213
222
  return cfg
214
223
  }, [releaseTransition, registry])
215
224
 
225
+ // JS-thread callbacks are read through stable wrappers so an inline arrow
226
+ // in `options` does not rebuild the gesture every render. `onCommit` is a
227
+ // worklet and stays a direct dependency.
228
+ const hasSwipe = onSwipe !== undefined
229
+ const hasSwipeEnd = onSwipeEnd !== undefined
230
+ const fireSwipe = useLatestCallback(onSwipe)
231
+ const fireSwipeEnd = useLatestCallback(onSwipeEnd)
232
+
216
233
  const gesture = useMemo(() => {
217
234
  const pan = Gesture.Pan()
218
235
  .onStart(() => {
@@ -239,7 +256,7 @@ export function useSwipe(options: SwipeOptions = {}): UseSwipeResult {
239
256
  allowUp,
240
257
  allowDown,
241
258
  )
242
- if (direction !== null && onSwipe) {
259
+ if (direction !== null && hasSwipe) {
243
260
  const isHoriz = direction === 'left' || direction === 'right'
244
261
  const distance = isHoriz
245
262
  ? Math.abs(e.translationX)
@@ -247,7 +264,7 @@ export function useSwipe(options: SwipeOptions = {}): UseSwipeResult {
247
264
  const velocity = isHoriz
248
265
  ? Math.abs(e.velocityX)
249
266
  : Math.abs(e.velocityY)
250
- runOnJS(onSwipe)(direction, { distance, velocity })
267
+ runOnJS(fireSwipe)(direction, { distance, velocity })
251
268
  }
252
269
 
253
270
  // A commit may return per-axis exit transitions to run instead of
@@ -264,11 +281,10 @@ export function useSwipe(options: SwipeOptions = {}): UseSwipeResult {
264
281
  // `onSwipeEnd` rides the settle callback of the swipe axis — the one
265
282
  // animation whose end means "the card has arrived".
266
283
  let settle: ((finished?: boolean) => void) | undefined
267
- if (direction !== null && onSwipeEnd) {
284
+ if (direction !== null && hasSwipeEnd) {
268
285
  const dir = direction
269
- const end = onSwipeEnd
270
286
  settle = (finished?: boolean) => {
271
- runOnJS(end)(dir, { finished: finished === true })
287
+ runOnJS(fireSwipeEnd)(dir, { finished: finished === true })
272
288
  }
273
289
  }
274
290
  const isHorizontalCommit = direction === 'left' || direction === 'right'
@@ -320,17 +336,17 @@ export function useSwipe(options: SwipeOptions = {}): UseSwipeResult {
320
336
  allowUp,
321
337
  allowDown,
322
338
  snapBack,
323
- onSwipe,
339
+ hasSwipe,
340
+ hasSwipeEnd,
341
+ fireSwipe,
342
+ fireSwipeEnd,
324
343
  onCommit,
325
- onSwipeEnd,
326
344
  swipeX,
327
345
  swipeY,
328
346
  isActive,
329
347
  ])
330
348
 
331
- const animatedStyle = useAnimatedStyle(() => ({
332
- transform: [{ translateX: swipeX.value }, { translateY: swipeY.value }],
333
- }))
349
+ const animatedStyle = useTranslateStyle(swipeX, swipeY)
334
350
 
335
351
  const reset = useCallback(() => {
336
352
  swipeX.value = 0