@rootnative/inertia 0.0.8 → 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.
Files changed (101) hide show
  1. package/CHANGELOG.md +81 -1
  2. package/README.md +7 -7
  3. package/dist/applyBounds-BYGiHrYO.d.mts +18 -0
  4. package/dist/applyBounds-BYGiHrYO.d.ts +18 -0
  5. package/dist/{chunk-E3ALFSH2.js → chunk-2UGWAB6N.js} +78 -70
  6. package/dist/{chunk-ZBHQPVWE.js → chunk-3ZNHPT5Z.js} +2 -2
  7. package/dist/{chunk-IHVMNOS4.js → chunk-46766TBM.js} +2 -2
  8. package/dist/{chunk-MUT6BTZS.js → chunk-4EZ5DBL3.js} +10 -7
  9. package/dist/{chunk-MFAFB4K7.mjs → chunk-6FCATUMY.mjs} +8 -10
  10. package/dist/{chunk-6YIUETBF.mjs → chunk-7BH3CAO5.mjs} +1 -1
  11. package/dist/{chunk-JBK5ZLMH.mjs → chunk-CUHOCONB.mjs} +1 -1
  12. package/dist/{chunk-M4766VUV.mjs → chunk-DRA2K53O.mjs} +1 -1
  13. package/dist/{chunk-L4JFECXU.mjs → chunk-F4HMCXAX.mjs} +6 -3
  14. package/dist/{chunk-W7NTRSPD.mjs → chunk-HEAWMZEM.mjs} +105 -66
  15. package/dist/{chunk-IX6SEOSK.mjs → chunk-JENYDTXH.mjs} +52 -44
  16. package/dist/chunk-KFHZMFAX.js +23 -0
  17. package/dist/{chunk-6AN5X3YE.js → chunk-KXU6VH2Y.js} +10 -11
  18. package/dist/{chunk-WNVHPMBI.js → chunk-N33GSDNQ.js} +106 -65
  19. package/dist/chunk-OH46TGEA.mjs +20 -0
  20. package/dist/{chunk-QNLSCOR5.mjs → chunk-OLOSU2BD.mjs} +1 -1
  21. package/dist/chunk-PLKXCJIB.js +8 -0
  22. package/dist/{chunk-GSUC4HWP.mjs → chunk-QXKPIENP.mjs} +1 -1
  23. package/dist/chunk-UBHQJFUW.js +8 -0
  24. package/dist/{chunk-6NOCY764.js → chunk-VKXSL3ZL.js} +2 -2
  25. package/dist/chunk-XKNEQDAT.js +8 -0
  26. package/dist/{chunk-EN4PN7X3.mjs → chunk-Z23AMUSW.mjs} +1 -1
  27. package/dist/gestureLayer/index.d.mts +3 -3
  28. package/dist/gestureLayer/index.d.ts +3 -3
  29. package/dist/gestureLayer/index.js +12 -18
  30. package/dist/gestureLayer/index.mjs +5 -11
  31. package/dist/index.d.mts +105 -47
  32. package/dist/index.d.ts +105 -47
  33. package/dist/index.js +119 -92
  34. package/dist/index.mjs +71 -60
  35. package/dist/motion/FlatList.d.mts +1 -1
  36. package/dist/motion/FlatList.d.ts +1 -1
  37. package/dist/motion/FlatList.js +5 -5
  38. package/dist/motion/FlatList.mjs +4 -4
  39. package/dist/motion/Image.d.mts +1 -1
  40. package/dist/motion/Image.d.ts +1 -1
  41. package/dist/motion/Image.js +5 -5
  42. package/dist/motion/Image.mjs +4 -4
  43. package/dist/motion/Pressable.d.mts +1 -1
  44. package/dist/motion/Pressable.d.ts +1 -1
  45. package/dist/motion/Pressable.js +5 -5
  46. package/dist/motion/Pressable.mjs +4 -4
  47. package/dist/motion/ScrollView.d.mts +1 -1
  48. package/dist/motion/ScrollView.d.ts +1 -1
  49. package/dist/motion/ScrollView.js +5 -5
  50. package/dist/motion/ScrollView.mjs +4 -4
  51. package/dist/motion/Text.d.mts +1 -1
  52. package/dist/motion/Text.d.ts +1 -1
  53. package/dist/motion/Text.js +5 -5
  54. package/dist/motion/Text.mjs +4 -4
  55. package/dist/motion/View.d.mts +1 -1
  56. package/dist/motion/View.d.ts +1 -1
  57. package/dist/motion/View.js +5 -5
  58. package/dist/motion/View.mjs +4 -4
  59. package/dist/touch/index.d.mts +2 -1
  60. package/dist/touch/index.d.ts +2 -1
  61. package/dist/touch/index.js +16 -31
  62. package/dist/touch/index.mjs +11 -29
  63. package/dist/{types-DyJpG64F.d.mts → types-08mG8fNk.d.mts} +10 -3
  64. package/dist/{types-DyJpG64F.d.ts → types-08mG8fNk.d.ts} +10 -3
  65. package/dist/{useGesture-DbH46EBp.d.ts → useGesture-D1lHeQQ6.d.ts} +1 -1
  66. package/dist/{useGesture-CnZQnYHH.d.mts → useGesture-DjszaHWz.d.mts} +1 -1
  67. package/llms.txt +1 -1
  68. package/package.json +4 -2
  69. package/src/config/namedTransitions.ts +7 -9
  70. package/src/gestureLayer/index.ts +3 -0
  71. package/src/gestureLayer/useGestureLayer.ts +6 -9
  72. package/src/gestures/focusVisibility.ts +19 -17
  73. package/src/gestures/index.ts +1 -1
  74. package/src/index.ts +10 -0
  75. package/src/layout/resolveLayout.ts +4 -1
  76. package/src/layout/useSharedLayout.ts +2 -1
  77. package/src/motion/createMotionComponent.tsx +92 -44
  78. package/src/presence/Presence.tsx +5 -7
  79. package/src/touch/applyBounds.ts +30 -0
  80. package/src/touch/index.ts +1 -0
  81. package/src/touch/useTouchDrag.ts +15 -45
  82. package/src/transitions/constants.ts +14 -0
  83. package/src/transitions/easing.ts +23 -11
  84. package/src/transitions/index.ts +5 -0
  85. package/src/transitions/keys.ts +0 -2
  86. package/src/transitions/resolve.ts +86 -11
  87. package/src/transitions/resolveSequence.ts +3 -1
  88. package/src/transitions/runtime.ts +4 -7
  89. package/src/transitions/spring.ts +7 -2
  90. package/src/types.ts +9 -2
  91. package/src/values/extrapolation.ts +21 -0
  92. package/src/values/index.ts +2 -0
  93. package/src/values/useGesture.ts +9 -2
  94. package/src/values/useInterpolatedStyle.ts +42 -19
  95. package/src/values/useShadow.ts +18 -7
  96. package/src/values/useSpring.ts +34 -25
  97. package/src/values/useTransform.ts +24 -20
  98. package/src/values/useTranslateStyle.ts +31 -0
  99. package/dist/chunk-24NUL23E.js +0 -8
  100. package/dist/chunk-4XBX7KOD.js +0 -8
  101. package/dist/chunk-Z5USXWTE.js +0 -8
@@ -20,3 +20,6 @@ export type {
20
20
  UseGestureLayerOptions,
21
21
  UseGestureLayerResult,
22
22
  } from './useGestureLayer'
23
+ // The per-layer form of `UseGestureLayerOptions.transition`. Re-exported here
24
+ // so a consumer of this subpath can name it without also importing the root.
25
+ export type { GestureLayerTransitions } from '../types'
@@ -210,7 +210,10 @@ export function useGestureLayer(
210
210
  // JS-thread precompute: union of keys across all layers, per-key type
211
211
  // (number vs color), and a rest-fallback table. The worklet body reads
212
212
  // from `meta` instead of probing each layer per frame — the type check
213
- // only runs when layer identities change.
213
+ // only runs when the layers change structurally. Keyed on a signature, not
214
+ // on object identity: `states` is usually an inline literal, and a fresh
215
+ // `meta` each render would rebuild the animated style's worklet.
216
+ const statesSig = stableSig(states)
214
217
  const meta = useMemo(() => {
215
218
  const layers = {
216
219
  rest: states.rest,
@@ -251,14 +254,8 @@ export function useGestureLayer(
251
254
  restRaw !== undefined ? restRaw : isColor ? 'transparent' : 0
252
255
  }
253
256
  return { layers, keys, types, restValues }
254
- }, [
255
- states.rest,
256
- states.hovered,
257
- states.focused,
258
- states.focusVisible,
259
- states.pressed,
260
- states.disabled,
261
- ])
257
+ // eslint-disable-next-line react-hooks/exhaustive-deps
258
+ }, [statesSig])
262
259
 
263
260
  const style = useAnimatedStyle(() => {
264
261
  const { layers, keys, types, restValues } = meta
@@ -12,15 +12,16 @@ import { Platform } from 'react-native'
12
12
  * arrives via D-pad, screen reader, or hardware keyboard, all of which are
13
13
  * keyboard-equivalent — so `isFocusVisible()` is unconditionally `true`.
14
14
  *
15
- * The web listeners attach eagerly at module import (capture phase, so they
16
- * run before the focus event reaches the focused element) and stay installed
17
- * for the lifetime of the document. Eager installation matters: the very
18
- * first interaction with a page can be the mouse click that focuses a
19
- * gesture-wired element, and if the listeners only attached during that
20
- * focus dispatch the mousedown would already have passed unobserved
21
- * leaving the default `'keyboard'` modality and drawing a focus ring for a
22
- * pointer interaction. They are passive and idle-cheap; the cost is one
23
- * boolean read per `onFocus` dispatch.
15
+ * The web listeners attach when the first component that tracks
16
+ * `focusVisible` mounts (`installFocusVisibility`, called from a mount
17
+ * effect), in the capture phase so they run before the focus event reaches
18
+ * the focused element, and they stay installed for the lifetime of the
19
+ * document. Mount-time installation is early enough: the click that focuses
20
+ * a gesture-wired element can only land on an element that is already
21
+ * mounted, so its `mousedown` is observed. The listeners are not attached at
22
+ * import time `@rootnative/inertia` declares `sideEffects: false`, and an
23
+ * import-time listener would make that declaration false. They are passive
24
+ * and idle-cheap; the cost is one boolean read per `onFocus` dispatch.
24
25
  */
25
26
 
26
27
  type InputModality = 'keyboard' | 'pointer'
@@ -38,7 +39,14 @@ function setPointer() {
38
39
  modality = 'pointer'
39
40
  }
40
41
 
41
- function ensureInstalled(): void {
42
+ /**
43
+ * Attach the document listeners that track input modality on web. Idempotent
44
+ * and a no-op on native or without a `document`. Call it from a mount effect
45
+ * of any component that reads `isFocusVisible()`, so the pointer event that
46
+ * precedes the first focus is observed (see module doc above).
47
+ * `isFocusVisible` also calls it as a safety net.
48
+ */
49
+ export function installFocusVisibility(): void {
42
50
  if (installed) return
43
51
  if (Platform.OS !== 'web') return
44
52
  if (typeof document === 'undefined') return
@@ -49,12 +57,6 @@ function ensureInstalled(): void {
49
57
  installed = true
50
58
  }
51
59
 
52
- // Install at import time so the pointer event that precedes the first focus
53
- // is observed (see module doc above). `ensureInstalled` stays in
54
- // `isFocusVisible` as a safety net for environments where `document` appears
55
- // after import.
56
- ensureInstalled()
57
-
58
60
  /**
59
61
  * `true` if the next `onFocus` should be treated as "focus-visible" (keyboard
60
62
  * focus). On native, always `true`. On web, reflects the most recent user
@@ -62,7 +64,7 @@ ensureInstalled()
62
64
  */
63
65
  export function isFocusVisible(): boolean {
64
66
  if (Platform.OS !== 'web') return true
65
- ensureInstalled()
67
+ installFocusVisibility()
66
68
  return modality === 'keyboard'
67
69
  }
68
70
 
@@ -1 +1 @@
1
- export { isFocusVisible } from './focusVisibility'
1
+ export { installFocusVisibility, isFocusVisible } from './focusVisibility'
package/src/index.ts CHANGED
@@ -42,12 +42,18 @@ export {
42
42
  resolveTransition,
43
43
  resolveAnimatableValue,
44
44
  ensureWorkletEasing,
45
+ isTopLevelTransition,
46
+ stableSig,
45
47
  type AnimationCallback,
48
+ type CallbackFactory,
46
49
  } from './transitions'
47
50
  // Seed value for any color shared value a custom animated component drives
48
51
  // through `resolveTransition`. Exported because the obvious choice —
49
52
  // `'transparent'` — cannot be animated away from; see the symbol's own docs.
50
53
  export { TRANSPARENT } from './internal/color'
54
+ // Worklet-safe clamp shared by `useTouchDrag` and the gesture adapters'
55
+ // `useDrag`; exported so a custom drag can apply the same rubber-band rule.
56
+ export { applyBounds } from './touch/applyBounds'
51
57
  export {
52
58
  useAnimation,
53
59
  useAnimator,
@@ -61,6 +67,7 @@ export {
61
67
  useShadow,
62
68
  useSpring,
63
69
  useTransform,
70
+ useTranslateStyle,
64
71
  useVariants,
65
72
  } from './values'
66
73
  // The value-layer hooks above all return Reanimated `SharedValue`s — export
@@ -74,6 +81,7 @@ export type {
74
81
  ColorCascadeLayer,
75
82
  ColorStyleKey,
76
83
  ExtrapolationMode,
84
+ InterpolatedStyle,
77
85
  InterpolatedStyleMap,
78
86
  NumericStyleKey,
79
87
  ShadowConfig,
@@ -96,8 +104,10 @@ export type {
96
104
  EasingFunction,
97
105
  EasingFunctionFactory,
98
106
  EasingInput,
107
+ GestureLayerTransitions,
99
108
  GestureSubStates,
100
109
  MotionComponent,
110
+ MotionComponentProps,
101
111
  MotionProps,
102
112
  NamedTransitions,
103
113
  NoAnimationTransition,
@@ -1,4 +1,5 @@
1
1
  import { LinearTransition } from 'react-native-reanimated'
2
+ import { DEFAULT_LAYOUT_DURATION } from '../transitions/constants'
2
3
  import { ensureWorkletEasing } from '../transitions/easing'
3
4
  import { DEFAULT_SPRING, springToReanimated } from '../transitions/spring'
4
5
  import { type TransitionConfig } from '../types'
@@ -34,7 +35,9 @@ export function resolveLayoutTransition(
34
35
  if (cfg.type === 'no-animation') return undefined
35
36
 
36
37
  if (cfg.type === 'timing') {
37
- let builder = LinearTransition.duration(cfg.duration ?? 300)
38
+ let builder = LinearTransition.duration(
39
+ cfg.duration ?? DEFAULT_LAYOUT_DURATION,
40
+ )
38
41
  const easing = ensureWorkletEasing(cfg.easing)
39
42
  if (easing) builder = builder.easing(easing)
40
43
  if (cfg.delay) builder = builder.delay(cfg.delay)
@@ -15,6 +15,7 @@ import {
15
15
  withSpring,
16
16
  withTiming,
17
17
  } from 'react-native-reanimated'
18
+ import { DEFAULT_LAYOUT_DURATION } from '../transitions/constants'
18
19
  import { DEFAULT_SPRING, springToReanimated } from '../transitions/spring'
19
20
  import { type SpringTransition, type TransitionConfig } from '../types'
20
21
  import { measureWindowRect } from './measureWindow'
@@ -334,7 +335,7 @@ function legBuilder(
334
335
  transition: TransitionConfig | undefined,
335
336
  ): (from: number, to: number) => number {
336
337
  if (transition?.type === 'timing') {
337
- const duration = transition.duration ?? 300
338
+ const duration = transition.duration ?? DEFAULT_LAYOUT_DURATION
338
339
  return (from, to) =>
339
340
  withSequence(
340
341
  withTiming(from, { duration: 0 }),
@@ -21,7 +21,7 @@ import {
21
21
  useNamedTransitions,
22
22
  useShouldReduceMotion,
23
23
  } from '../config'
24
- import { isFocusVisible } from '../gestures'
24
+ import { installFocusVisibility, isFocusVisible } from '../gestures'
25
25
  import {
26
26
  layersToPayload,
27
27
  normalizeBoxShadow,
@@ -47,6 +47,7 @@ import { useStaggerDelay } from '../stagger'
47
47
  import {
48
48
  applyDelay,
49
49
  isTopLevelTransition,
50
+ repeatIterationsOf,
50
51
  resolveAnimatableValue,
51
52
  resolveTransition,
52
53
  stableSig,
@@ -271,6 +272,18 @@ const SHARED_STYLE_KEY_SET = new Set<AnimatableKey>(SHARED_STYLE_KEYS)
271
272
  */
272
273
  type AnimatableSlotValue = number | string | BoxShadowPayload
273
274
 
275
+ /**
276
+ * The keys one instance animates plus the three flags the worklet branches
277
+ * on. Grown monotonically on the JS thread and mirrored to the UI thread
278
+ * through a shared value — see `activeSetSV` in the factory.
279
+ */
280
+ type ActiveKeySet = {
281
+ keys: readonly AnimatableKey[]
282
+ hasTransform: boolean
283
+ hasShadowOffset: boolean
284
+ hasBoxShadow: boolean
285
+ }
286
+
274
287
  /**
275
288
  * Resting `boxShadow` — no shadow at all. Frozen and hoisted so every
276
289
  * primitive shares one reference and an untouched slot never allocates.
@@ -603,10 +616,10 @@ export function createMotionComponent<C extends ComponentType<any>>(
603
616
  // render silently dropped the new key (its SV updated, but the
604
617
  // worklet — which iterates this set — never read it).
605
618
  //
606
- // Growing-only keeps the worklet stable: the `activeKeysRef.current` array
607
- // identity only changes on the renders that actually add a key, so the
608
- // `useAnimatedStyle` worklet (which reads `.current` each frame) sees the
609
- // expansion without churning frame-to-frame.
619
+ // Growing-only keeps the worklet stable: the set object only changes on
620
+ // the renders that actually add a key. The worklet reads it through a
621
+ // shared value (`activeSetSV`, below), so an expansion crosses to the UI
622
+ // thread once per growth and nothing churns frame-to-frame.
610
623
  const hasLayoutId = layoutId !== undefined
611
624
 
612
625
  // The static `style`, flattened at most once per render and only when
@@ -668,14 +681,11 @@ export function createMotionComponent<C extends ComponentType<any>>(
668
681
  }
669
682
  }
670
683
 
671
- const activeKeysRef = useRef<readonly AnimatableKey[] | null>(null)
672
- const hasTransformRef = useRef<boolean>(false)
673
- const hasShadowOffsetRef = useRef<boolean>(false)
674
- const hasBoxShadowRef = useRef<boolean>(false)
684
+ const activeSetRef = useRef<ActiveKeySet | null>(null)
675
685
  // Expand the active set only when this render touched a key we haven't
676
- // recorded yet. When nothing new appears we keep the existing array
677
- // identity so the worklet's captured ref doesn't see a fresh value.
678
- const prevActive = activeKeysRef.current
686
+ // recorded yet. When nothing new appears we keep the existing object
687
+ // identity so the JS-side readers below see a stable value.
688
+ const prevActive = activeSetRef.current?.keys ?? null
679
689
  let grew = prevActive === null
680
690
  if (!grew && prevActive) {
681
691
  for (const k of touched) {
@@ -688,14 +698,13 @@ export function createMotionComponent<C extends ComponentType<any>>(
688
698
  if (grew) {
689
699
  const merged = new Set<AnimatableKey>(prevActive ?? [])
690
700
  for (const k of touched) merged.add(k)
691
- activeKeysRef.current = ALL_KEYS.filter((k) => merged.has(k))
692
- hasTransformRef.current = activeKeysRef.current.some((k) =>
693
- TRANSFORM_KEY_SET.has(k),
694
- )
695
- hasShadowOffsetRef.current = activeKeysRef.current.some((k) =>
696
- SHADOW_OFFSET_KEY_SET.has(k),
697
- )
698
- hasBoxShadowRef.current = activeKeysRef.current.includes('boxShadow')
701
+ const keys = ALL_KEYS.filter((k) => merged.has(k))
702
+ activeSetRef.current = {
703
+ keys,
704
+ hasTransform: keys.some((k) => TRANSFORM_KEY_SET.has(k)),
705
+ hasShadowOffset: keys.some((k) => SHADOW_OFFSET_KEY_SET.has(k)),
706
+ hasBoxShadow: keys.includes('boxShadow'),
707
+ }
699
708
  // Animating `boxShadow` alongside the native `shadow*` keys puts two
700
709
  // shadow systems on one element; whichever the underlying view resolves
701
710
  // last wins, and the result reads as a bug rather than a choice.
@@ -703,10 +712,8 @@ export function createMotionComponent<C extends ComponentType<any>>(
703
712
  // decides it, and this block only runs when that set grows.
704
713
  if (
705
714
  __DEV__ &&
706
- hasBoxShadowRef.current &&
707
- activeKeysRef.current.some(
708
- (k) => k !== 'boxShadow' && k.startsWith('shadow'),
709
- )
715
+ activeSetRef.current.hasBoxShadow &&
716
+ keys.some((k) => k !== 'boxShadow' && k.startsWith('shadow'))
710
717
  ) {
711
718
  warnOnce(
712
719
  'boxShadow-with-native-shadow',
@@ -717,6 +724,25 @@ export function createMotionComponent<C extends ComponentType<any>>(
717
724
  )
718
725
  }
719
726
  }
727
+ const activeKeys = activeSetRef.current!.keys
728
+
729
+ // The worklet's copy of the active set. It must be a shared value, not
730
+ // the ref above: react-native-worklets clones a captured plain object
731
+ // once, when the worklet is first serialised, and in dev it freezes the
732
+ // original so later writes to `.current` are dropped with a warning. A
733
+ // ref read inside the worklet therefore only ever sees the mount-time
734
+ // set, and a key added after mount drives its shared value but never
735
+ // reaches the screen (Worklet rule 2). The Jest mock runs the worklet on
736
+ // the JS thread, so only a device shows the difference. The write lives
737
+ // in an effect because Reanimated's strict mode warns on a shared-value
738
+ // write during render; it is declared before the value-driving effects
739
+ // below, so the grown set reaches the UI thread in the same commit as
740
+ // the first animation of the new key.
741
+ const activeSetSV = useSharedValue<ActiveKeySet>(activeSetRef.current!)
742
+ const activeSet = activeSetRef.current!
743
+ useEffect(() => {
744
+ if (activeSetSV.value !== activeSet) activeSetSV.value = activeSet
745
+ }, [activeSetSV, activeSet])
720
746
 
721
747
  // Which keys a record actually drives *right now*. Everything else in
722
748
  // the active set is there only because a gesture sub-state, an `exit`
@@ -741,7 +767,6 @@ export function createMotionComponent<C extends ComponentType<any>>(
741
767
  // Resting values pulled off the static `style`, for active keys nothing
742
768
  // has driven. Gated so the common `animate`-only instance never flattens
743
769
  // a style it has no use for.
744
- const activeKeys = activeKeysRef.current!
745
770
  const everDriven = everDrivenRef.current
746
771
  const styleResting: Partial<Record<AnimatableKey, AnimatableSlotValue>> =
747
772
  {}
@@ -857,7 +882,7 @@ export function createMotionComponent<C extends ComponentType<any>>(
857
882
 
858
883
  const styleRestingSig = stableSig(styleResting)
859
884
  useEffect(() => {
860
- for (const key of activeKeysRef.current!) {
885
+ for (const key of activeKeys) {
861
886
  if (everDrivenRef.current.has(key)) continue
862
887
  sharedValues[key].value = (styleResting[key] ??
863
888
  DEFAULT_RESTING[key]) as never
@@ -926,6 +951,11 @@ export function createMotionComponent<C extends ComponentType<any>>(
926
951
  (isExiting ? '|exit' : '') +
927
952
  (shouldReduceMotion ? '|rm' : '')
928
953
  const transitionSig = stableSig(transition)
954
+ // The callback factory skips callback installation when there is no
955
+ // `onAnimationEnd` and nothing to settle, so a handler that appears
956
+ // after mount needs the effect to run again. Keyed on presence, not
957
+ // identity: a changing handler is read through `onAnimationEndRef`.
958
+ const hasAnimationEnd = onAnimationEnd !== undefined
929
959
 
930
960
  // Stable ref to the live `safeToRemove` so the effect's settle-counter
931
961
  // closure can reach the latest <Presence> binding without retriggering.
@@ -943,6 +973,10 @@ export function createMotionComponent<C extends ComponentType<any>>(
943
973
  return
944
974
  }
945
975
 
976
+ // `done` doubles as the run token. When this effect re-runs, the
977
+ // cleanup below sets it, so the callbacks of the superseded animations
978
+ // — which Reanimated fires with `finished: false` as they are replaced
979
+ // — cannot drain this run's counter and release <Presence> early.
946
980
  let pending = 0
947
981
  let done = false
948
982
  const onSettle = () => {
@@ -1095,8 +1129,11 @@ export function createMotionComponent<C extends ComponentType<any>>(
1095
1129
  if (isExiting && pending === 0) {
1096
1130
  safeToRemoveRef.current?.()
1097
1131
  }
1132
+ return () => {
1133
+ done = true
1134
+ }
1098
1135
  // eslint-disable-next-line react-hooks/exhaustive-deps
1099
- }, [baseSig, transitionSig])
1136
+ }, [baseSig, transitionSig, hasAnimationEnd])
1100
1137
 
1101
1138
  // Per-layer progress: when a sub-state activation flips, animate its
1102
1139
  // progress SV 0↔1 with the layer's own transition (or the parent
@@ -1162,7 +1199,7 @@ export function createMotionComponent<C extends ComponentType<any>>(
1162
1199
  if (!hasLayoutId) return undefined
1163
1200
  const out: SharedStyleSnapshot = {}
1164
1201
  let any = false
1165
- for (const key of activeKeysRef.current!) {
1202
+ for (const key of activeKeys) {
1166
1203
  if (!SHARED_STYLE_KEY_SET.has(key)) continue
1167
1204
  const v = sharedValues[key].value
1168
1205
  // Structured slots can't be carried, and a key nothing ever gave a
@@ -1186,10 +1223,11 @@ export function createMotionComponent<C extends ComponentType<any>>(
1186
1223
  const carry = sharedLayout.carry
1187
1224
 
1188
1225
  const animatedStyle = useAnimatedStyle(() => {
1189
- const activeKeys = activeKeysRef.current!
1190
- const hasTransform = hasTransformRef.current
1191
- const hasShadowOffset = hasShadowOffsetRef.current
1192
- const hasBoxShadow = hasBoxShadowRef.current
1226
+ const activeSet = activeSetSV.value
1227
+ const activeKeys = activeSet.keys
1228
+ const hasTransform = activeSet.hasTransform
1229
+ const hasShadowOffset = activeSet.hasShadowOffset
1230
+ const hasBoxShadow = activeSet.hasBoxShadow
1193
1231
  const out: Record<string, unknown> = {}
1194
1232
  const transform: Array<Record<string, unknown>> = []
1195
1233
  // shadow-offset reassembly buffers. The two synthetic axis SVs feed in
@@ -1448,7 +1486,7 @@ type SharedValueMap = Record<AnimatableKey, SharedValue<AnimatableSlotValue>>
1448
1486
  * The ESLint exhaustive-deps rule can't see through the loop, hence the
1449
1487
  * disable — the invariant it would check for us is asserted above instead.
1450
1488
  *
1451
- * Unused shared values are cheap; the worklet skips them via `activeKeysRef`.
1489
+ * Unused shared values are cheap; the worklet skips them via `activeSetSV`.
1452
1490
  * Color keys are seeded with the initial color string so Reanimated's value
1453
1491
  * setter recognizes the slot as a color from the first `withSpring` /
1454
1492
  * `withTiming` call.
@@ -1805,12 +1843,9 @@ function stepCountOf(v: AnimatableValue<number | string> | undefined): number {
1805
1843
  */
1806
1844
  function totalIterationsOf(cfg: TransitionConfig | undefined): number {
1807
1845
  if (!cfg || cfg.type === 'no-animation' || cfg.type === 'decay') return 1
1808
- const r = cfg.repeat
1809
- if (r === undefined) return 1
1810
- if (r === 'infinite') return Number.POSITIVE_INFINITY
1811
- if (typeof r === 'number') return r
1812
- if (r.count === 'infinite') return Number.POSITIVE_INFINITY
1813
- return r.count
1846
+ // Shares the resolver's count rule, so a `repeat: 0` that the resolver
1847
+ // treats as "run once" is also counted as one iteration here.
1848
+ return repeatIterationsOf(cfg.repeat)
1814
1849
  }
1815
1850
 
1816
1851
  /**
@@ -1880,16 +1915,23 @@ function resolveAnimateInput(
1880
1915
  variants: VariantsMap<unknown> | undefined,
1881
1916
  controllerKey: string | undefined,
1882
1917
  ): AnimateStyle<unknown> | undefined {
1918
+ if (controllerKey !== undefined && animate !== undefined) {
1919
+ warnOnce(
1920
+ 'controller-and-animate',
1921
+ '[inertia] Both `controller` and `animate` are set on the same ' +
1922
+ 'primitive. The controller drives the animation and `animate` is ' +
1923
+ 'ignored — remove one of the two.',
1924
+ )
1925
+ }
1883
1926
  if (controllerKey !== undefined && variants && controllerKey in variants) {
1884
1927
  return variants[controllerKey]
1885
1928
  }
1886
1929
  if (typeof animate === 'string') {
1887
1930
  if (variants && animate in variants) return variants[animate]
1888
- if (__DEV__) {
1889
- console.warn(
1890
- `[inertia] animate="${animate}" but no matching variant. Did you forget to pass \`variants\`?`,
1891
- )
1892
- }
1931
+ warnOnce(
1932
+ `missing-variant:${animate}`,
1933
+ `[inertia] animate="${animate}" but no matching variant. Did you forget to pass \`variants\`?`,
1934
+ )
1893
1935
  return undefined
1894
1936
  }
1895
1937
  return animate as AnimateStyle<unknown> | undefined
@@ -2047,6 +2089,12 @@ function useGestureHandlers(
2047
2089
  const hasFocused = gesture?.focused ? 1 : 0
2048
2090
  const hasFocusVisible = gesture?.focusVisible ? 1 : 0
2049
2091
  const hasHovered = gesture?.hovered ? 1 : 0
2092
+ // The web modality listeners attach on the first mount that tracks
2093
+ // `focusVisible` — see `focusVisibility.ts` for why mount time is early
2094
+ // enough and why import time is not an option.
2095
+ useEffect(() => {
2096
+ if (hasFocusVisible) installFocusVisibility()
2097
+ }, [hasFocusVisible])
2050
2098
  return useMemo(() => {
2051
2099
  if (!gesture) return {}
2052
2100
  const handlers: GestureHandlers = {}
@@ -9,6 +9,7 @@ import {
9
9
  useRef,
10
10
  useState,
11
11
  } from 'react'
12
+ import { warnOnce } from '../internal/warnOnce'
12
13
  import { PresenceContext, type PresenceContextValue } from './PresenceContext'
13
14
 
14
15
  interface RenderEntry {
@@ -38,11 +39,10 @@ export function Presence({ children }: { children: ReactNode }) {
38
39
  Children.forEach(children, (child) => {
39
40
  if (!isValidElement(child)) return
40
41
  if (child.key === null) {
41
- if (__DEV__) {
42
- console.warn(
43
- '[inertia] <Presence> children must have a `key`. Skipping a keyless child.',
44
- )
45
- }
42
+ warnOnce(
43
+ 'presence-keyless-child',
44
+ '[inertia] <Presence> children must have a `key`. Skipping a keyless child.',
45
+ )
46
46
  return
47
47
  }
48
48
  out.push(child)
@@ -224,5 +224,3 @@ function PresenceItem({
224
224
  </PresenceContext.Provider>
225
225
  )
226
226
  }
227
-
228
- declare const __DEV__: boolean
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Clamp `value` to `[min, max]`. When `elastic > 0` the overshoot past a
3
+ * bound is scaled by `elastic` instead of hard-clamped, giving a rubber-band
4
+ * feel. `min` / `max` may be `undefined` to leave that side unbounded.
5
+ *
6
+ * Worklet, so it runs both inside a gesture-handler pan handler on the UI
7
+ * thread (`useDrag`) and from a JS-thread `PanResponder` callback
8
+ * (`useTouchDrag`).
9
+ *
10
+ * ```ts
11
+ * applyBounds(120, -100, 100, 0) // 100
12
+ * applyBounds(120, -100, 100, 0.5) // 110
13
+ * applyBounds(50, undefined, 100, 0) // 50
14
+ * ```
15
+ */
16
+ export function applyBounds(
17
+ value: number,
18
+ min: number | undefined,
19
+ max: number | undefined,
20
+ elastic: number,
21
+ ): number {
22
+ 'worklet'
23
+ if (min !== undefined && value < min) {
24
+ return elastic > 0 ? min + (value - min) * elastic : min
25
+ }
26
+ if (max !== undefined && value > max) {
27
+ return elastic > 0 ? max + (value - max) * elastic : max
28
+ }
29
+ return value
30
+ }
@@ -8,6 +8,7 @@
8
8
  * prefer `useDrag` from `@rootnative/inertia-gestures` — its UI-thread
9
9
  * release path is more precise.
10
10
  */
11
+ export { applyBounds } from './applyBounds'
11
12
  export { useTouchDrag } from './useTouchDrag'
12
13
  export type {
13
14
  TouchReleaseInfo,
@@ -1,15 +1,14 @@
1
- import { useMemo } from 'react'
1
+ import { useMemo, useRef } from 'react'
2
2
  import {
3
3
  PanResponder,
4
4
  type PanResponderGestureState,
5
5
  type PanResponderInstance,
6
6
  } from 'react-native'
7
- import {
8
- useAnimatedStyle,
9
- useSharedValue,
10
- type SharedValue,
11
- } from 'react-native-reanimated'
7
+ import { useSharedValue, type SharedValue } from 'react-native-reanimated'
8
+ import type { useAnimatedStyle } from 'react-native-reanimated'
12
9
  import { buildReleaseAnimation } from '../transitions'
10
+ import { useTranslateStyle } from '../values/useTranslateStyle'
11
+ import { applyBounds } from './applyBounds'
13
12
  import type { TransitionConfig } from '../types'
14
13
 
15
14
  /**
@@ -166,23 +165,17 @@ export function useTouchDrag(
166
165
  const top = constraints?.top
167
166
  const bottom = constraints?.bottom
168
167
  const elasticCoef = elastic
169
- const { onDragStart, onDragEnd, onRelease } = options
168
+
169
+ // Callbacks are read through a ref at call time, so an inline arrow in
170
+ // `options` does not rebuild the PanResponder every render. PanResponder
171
+ // callbacks run on the JS thread, so a ref read is safe here.
172
+ const callbacksRef = useRef(options)
173
+ callbacksRef.current = options
170
174
 
171
175
  const responder = useMemo(
172
176
  () => buildResponder(),
173
177
  // eslint-disable-next-line react-hooks/exhaustive-deps
174
- [
175
- lockX,
176
- lockY,
177
- left,
178
- right,
179
- top,
180
- bottom,
181
- elasticCoef,
182
- onDragStart,
183
- onDragEnd,
184
- onRelease,
185
- ],
178
+ [lockX, lockY, left, right, top, bottom, elasticCoef],
186
179
  )
187
180
 
188
181
  // Hoisted out of the inline `useMemo` factory to keep the dep list readable
@@ -196,6 +189,7 @@ export function useTouchDrag(
196
189
  // `@rootnative/inertia-gestures` API (px/sec from gesture-handler).
197
190
  const vx = g.vx * 1000
198
191
  const vy = g.vy * 1000
192
+ const { onRelease, onDragEnd } = callbacksRef.current
199
193
  if (onRelease) {
200
194
  const result = onRelease({ x, y, velocity: { x: vx, y: vy } })
201
195
  if (result) {
@@ -228,7 +222,7 @@ export function useTouchDrag(
228
222
  startX.value = dragX.value
229
223
  startY.value = dragY.value
230
224
  isDragging.value = true
231
- if (onDragStart) onDragStart()
225
+ callbacksRef.current.onDragStart?.()
232
226
  },
233
227
  onPanResponderMove: (_e, g) => {
234
228
  if (lockX) {
@@ -253,9 +247,7 @@ export function useTouchDrag(
253
247
  })
254
248
  }
255
249
 
256
- const animatedStyle = useAnimatedStyle(() => ({
257
- transform: [{ translateX: dragX.value }, { translateY: dragY.value }],
258
- }))
250
+ const animatedStyle = useTranslateStyle(dragX, dragY)
259
251
 
260
252
  return {
261
253
  panHandlers: responder.panHandlers,
@@ -265,25 +257,3 @@ export function useTouchDrag(
265
257
  isDragging,
266
258
  }
267
259
  }
268
-
269
- /**
270
- * Clamp `value` to `[min, max]`. When `elastic > 0` the overshoot past a
271
- * bound is scaled by `elastic`, giving a rubber-band feel. `min` / `max`
272
- * may be `undefined` to leave that side unbounded.
273
- *
274
- * JS-thread (PanResponder callbacks are JS, not worklets).
275
- */
276
- function applyBounds(
277
- value: number,
278
- min: number | undefined,
279
- max: number | undefined,
280
- elastic: number,
281
- ): number {
282
- if (min !== undefined && value < min) {
283
- return elastic > 0 ? min + (value - min) * elastic : min
284
- }
285
- if (max !== undefined && value > max) {
286
- return elastic > 0 ? max + (value - max) * elastic : max
287
- }
288
- return value
289
- }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Default `duration` for a `timing` transition that does not set one, in ms.
3
+ * Shared by the JS-thread resolver (`resolve.ts`) and the UI-thread builder
4
+ * (`runtime.ts`) so the two paths cannot drift apart.
5
+ */
6
+ export const DEFAULT_TIMING_DURATION = 250
7
+
8
+ /**
9
+ * Default `duration` for a `timing` layout transition (`layout` prop and
10
+ * `layoutId` shared-element legs), in ms. Layout moves cover more distance
11
+ * than a property tween, so the default is longer than
12
+ * `DEFAULT_TIMING_DURATION`.
13
+ */
14
+ export const DEFAULT_LAYOUT_DURATION = 300