@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
@@ -2,7 +2,11 @@
2
2
  // dep); Reanimated's own re-export is deprecated.
3
3
  import { isWorkletFunction } from 'react-native-worklets'
4
4
  import { warnNonWorkletOnce } from '../internal/nonWorkletWarning'
5
- import { type EasingInput } from '../types'
5
+ import {
6
+ type EasingFunction,
7
+ type EasingFunctionFactory,
8
+ type EasingInput,
9
+ } from '../types'
6
10
 
7
11
  /**
8
12
  * Reanimated 3.9+ validates that easing functions used in nested-transition
@@ -33,7 +37,7 @@ export function ensureWorkletEasing(
33
37
  // Reanimated 4 `EasingFunctionFactory` — unwrap via `.factory()` before
34
38
  // checking worklet status, so the wrapped fn (not the factory wrapper)
35
39
  // ends up in the transition config.
36
- const fn = isEasingFactory(easing) ? easing.factory() : easing
40
+ const fn = unwrapEasingFactory(easing)
37
41
  if (isWorkletFunction(fn)) return fn
38
42
  warnNonWorkletOnce(
39
43
  'timing-easing',
@@ -46,13 +50,21 @@ export function ensureWorkletEasing(
46
50
  return wrapped
47
51
  }
48
52
 
49
- function isEasingFactory(
50
- value: EasingInput,
51
- ): value is { factory: () => (t: number) => number } {
52
- return (
53
- typeof value === 'object' &&
54
- value !== null &&
55
- 'factory' in value &&
56
- typeof (value as { factory: unknown }).factory === 'function'
57
- )
53
+ /**
54
+ * Return the easing function behind an `EasingInput`: a Reanimated 4
55
+ * `EasingFunctionFactory` is unwrapped through `.factory()`, a plain function
56
+ * passes through. Worklet, so `buildReleaseAnimation` can call it from a
57
+ * gesture `onEnd` handler on the UI thread.
58
+ */
59
+ export function unwrapEasingFactory(easing: EasingInput): EasingFunction {
60
+ 'worklet'
61
+ if (
62
+ typeof easing === 'object' &&
63
+ easing !== null &&
64
+ 'factory' in easing &&
65
+ typeof (easing as { factory: unknown }).factory === 'function'
66
+ ) {
67
+ return (easing as EasingFunctionFactory).factory()
68
+ }
69
+ return easing as EasingFunction
58
70
  }
@@ -1,9 +1,14 @@
1
1
  export {
2
2
  applyDelay,
3
+ applyRepeat,
4
+ normalizeRepeat,
5
+ repeatIterationsOf,
3
6
  resolveTransition,
4
7
  type AnimationCallback,
5
8
  type CallbackFactory,
9
+ type NormalizedRepeat,
6
10
  } from './resolve'
11
+ export { DEFAULT_LAYOUT_DURATION, DEFAULT_TIMING_DURATION } from './constants'
7
12
  export { resolveAnimatableValue } from './resolveSequence'
8
13
  export { cubicBezier } from './cubicBezier'
9
14
  export { ensureWorkletEasing } from './easing'
@@ -14,8 +14,6 @@ export const TRANSITION_CONFIG_KEYS = new Set([
14
14
  'friction',
15
15
  'mass',
16
16
  'velocity',
17
- 'restSpeedThreshold',
18
- 'restDisplacementThreshold',
19
17
  'duration',
20
18
  'easing',
21
19
  'delay',
@@ -6,8 +6,10 @@ import {
6
6
  withSpring,
7
7
  withTiming,
8
8
  } from 'react-native-reanimated'
9
+ import { DEFAULT_TIMING_DURATION } from './constants'
9
10
  import { ensureWorkletEasing } from './easing'
10
11
  import { springToReanimated } from './spring'
12
+ import { warnOnce } from '../internal/warnOnce'
11
13
  import {
12
14
  type DecayTransition,
13
15
  type RepeatConfig,
@@ -37,8 +39,6 @@ export type CallbackFactory = (
37
39
  step: number | undefined,
38
40
  ) => AnimationCallback | undefined
39
41
 
40
- const DEFAULT_TIMING_DURATION = 250
41
-
42
42
  function buildSpring(
43
43
  cfg: SpringTransition,
44
44
  toValue: number | string,
@@ -94,26 +94,101 @@ function buildOne(
94
94
  return buildSpring(cfg as SpringTransition, toValue, cb)
95
95
  }
96
96
 
97
+ /**
98
+ * Normalised repeat: `count` is the total number of iterations
99
+ * (`Number.POSITIVE_INFINITY` for `'infinite'`), `alternate` is the resolved
100
+ * flag, `explicitAlternate` says whether the caller set `alternate` by hand.
101
+ */
102
+ export interface NormalizedRepeat {
103
+ count: number
104
+ alternate: boolean
105
+ explicitAlternate: boolean
106
+ }
107
+
108
+ /**
109
+ * Reduce the three public `repeat` shapes to one record, or `undefined` when
110
+ * the animation runs once. A count below `1` is treated as "run once" and
111
+ * warns in dev: Reanimated's `withRepeat` reads `0` and negative counts as
112
+ * endless, so forwarding them would turn `repeat: 0` into an infinite loop.
113
+ */
114
+ export function normalizeRepeat(
115
+ repeat: RepeatConfig | undefined,
116
+ ): NormalizedRepeat | undefined {
117
+ if (repeat === undefined) return undefined
118
+ if (repeat === 'infinite') {
119
+ return {
120
+ count: Number.POSITIVE_INFINITY,
121
+ alternate: true,
122
+ explicitAlternate: false,
123
+ }
124
+ }
125
+ const rawCount = typeof repeat === 'number' ? repeat : repeat.count
126
+ const alternate = typeof repeat === 'number' ? true : repeat.alternate
127
+ if (rawCount === 'infinite') {
128
+ return {
129
+ count: Number.POSITIVE_INFINITY,
130
+ alternate: alternate ?? true,
131
+ explicitAlternate: alternate !== undefined,
132
+ }
133
+ }
134
+ if (!(rawCount >= 1)) {
135
+ warnOnce(
136
+ `repeat-count:${String(rawCount)}`,
137
+ `[inertia] repeat count ${String(rawCount)} is below 1 — the ` +
138
+ `animation runs once. Use \`repeat: 2\` or more to repeat, or ` +
139
+ `\`repeat: 'infinite'\`. (Reanimated reads a count of 0 as endless, ` +
140
+ `so it is not forwarded.)`,
141
+ )
142
+ return undefined
143
+ }
144
+ return {
145
+ count: rawCount,
146
+ alternate: alternate ?? true,
147
+ explicitAlternate: alternate !== undefined,
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Total number of iterations an animation built from `repeat` runs, including
153
+ * the first pass. `1` when there is no repeat or the count is below 1;
154
+ * `Number.POSITIVE_INFINITY` for `'infinite'`.
155
+ */
156
+ export function repeatIterationsOf(repeat: RepeatConfig | undefined): number {
157
+ return normalizeRepeat(repeat)?.count ?? 1
158
+ }
159
+
97
160
  /**
98
161
  * Wrap an animation in `withRepeat` per the unified `repeat` shape:
99
162
  * - `number` → finite count, alternating direction
100
163
  * - `'infinite'` → endless, alternating direction
101
164
  * - `{ count, alternate }`→ explicit; `alternate` defaults to `true`
165
+ *
166
+ * Pass `{ sequence: true }` when `animation` is a `withSequence` result.
167
+ * Reanimated's `reverse` flag only swaps the wrapped animation's `toValue`,
168
+ * which a sequence ignores — it restarts at step 0 on every pass — so the
169
+ * flag is not forwarded for sequences. An explicit `alternate: true` on a
170
+ * sequence warns in dev; write the reverse steps into the sequence instead.
102
171
  */
103
172
  export function applyRepeat(
104
173
  animation: unknown,
105
174
  repeat: RepeatConfig | undefined,
175
+ options?: { sequence?: boolean },
106
176
  ) {
107
- if (repeat === undefined) return animation
108
- if (repeat === 'infinite') {
109
- return withRepeat(animation as never, -1, true)
110
- }
111
- if (typeof repeat === 'number') {
112
- return withRepeat(animation as never, repeat, true)
177
+ const r = normalizeRepeat(repeat)
178
+ if (r === undefined) return animation
179
+ const count = Number.isFinite(r.count) ? r.count : -1
180
+ if (options?.sequence) {
181
+ if (r.explicitAlternate && r.alternate) {
182
+ warnOnce(
183
+ 'repeat-sequence-alternate',
184
+ '[inertia] repeat.alternate has no effect on a sequence — Reanimated ' +
185
+ 'restarts a sequence at its first step on every pass. Append the ' +
186
+ 'reverse steps to the sequence to alternate.',
187
+ )
188
+ }
189
+ return withRepeat(animation as never, count, false)
113
190
  }
114
- const count = repeat.count === 'infinite' ? -1 : repeat.count
115
- const alternate = repeat.alternate ?? true
116
- return withRepeat(animation as never, count, alternate)
191
+ return withRepeat(animation as never, count, r.alternate)
117
192
  }
118
193
 
119
194
  /**
@@ -50,7 +50,9 @@ export function resolveAnimatableValue<V extends number | string>(
50
50
  resolveStep(step, stepBase, factory?.('step', i)),
51
51
  )
52
52
  const seq = withSequence(...(animations as never[]))
53
- return applyRepeat(seq, base ? repeatOf(base) : undefined)
53
+ return applyRepeat(seq, base ? repeatOf(base) : undefined, {
54
+ sequence: true,
55
+ })
54
56
  }
55
57
  const step = value as SequenceStep<V>
56
58
  const cb = factory?.('animation', undefined)
@@ -4,12 +4,12 @@ import {
4
4
  withSpring,
5
5
  withTiming,
6
6
  } from 'react-native-reanimated'
7
+ import { DEFAULT_TIMING_DURATION } from './constants'
8
+ import { unwrapEasingFactory } from './easing'
7
9
  import { springToReanimated } from './spring'
8
10
  import { type AnimationCallback } from './resolve'
9
11
  import { type TransitionConfig } from '../types'
10
12
 
11
- const DEFAULT_TIMING_DURATION = 250
12
-
13
13
  /**
14
14
  * Worklet-safe single-step animation builder. Mirrors a subset of
15
15
  * `resolveTransition` for the UI-thread path where the transition config is
@@ -58,13 +58,10 @@ export function buildReleaseAnimation(
58
58
  }
59
59
  if (transition.type === 'timing') {
60
60
  // Reanimated 4's `Easing.bezier(...)` returns an `EasingFunctionFactory`
61
- // rather than the function itself. Unwrap inline so consumers calling
61
+ // rather than the function itself. Unwrap so consumers calling
62
62
  // `buildReleaseAnimation` from a gesture worklet don't have to.
63
63
  const e = transition.easing
64
- const easingFn =
65
- e && typeof e === 'object' && 'factory' in e
66
- ? e.factory()
67
- : (e ?? Easing.inOut(Easing.ease))
64
+ const easingFn = e ? unwrapEasingFactory(e) : Easing.inOut(Easing.ease)
68
65
  return withTiming(
69
66
  toValue,
70
67
  {
@@ -28,6 +28,13 @@ export const DEFAULT_SPRING: Required<
28
28
  * names differ but the underlying physics constants are the same. We don't
29
29
  * surface the raw names publicly because the react-spring vocabulary is
30
30
  * what designers and prior-art consumers expect.
31
+ *
32
+ * Settle control is deliberately absent: Reanimated 4 settles springs on an
33
+ * internal `energyThreshold` and ignores Reanimated 3's `restSpeedThreshold`
34
+ * / `restDisplacementThreshold`. Inertia surfaced those two names through
35
+ * `0.0.9`, where they were silently inert — do not re-add them. If a settle
36
+ * knob is ever needed, expose Reanimated 4's `overshootClamping` or
37
+ * `energyThreshold`, which the runtime actually reads.
31
38
  */
32
39
  export function springToReanimated(t: SpringTransition) {
33
40
  'worklet'
@@ -36,7 +43,5 @@ export function springToReanimated(t: SpringTransition) {
36
43
  damping: t.friction ?? DEFAULT_SPRING.friction,
37
44
  mass: t.mass ?? DEFAULT_SPRING.mass,
38
45
  velocity: t.velocity,
39
- restSpeedThreshold: t.restSpeedThreshold,
40
- restDisplacementThreshold: t.restDisplacementThreshold,
41
46
  }
42
47
  }
package/src/types.ts CHANGED
@@ -31,8 +31,6 @@ export interface SpringTransition {
31
31
  friction?: number
32
32
  mass?: number
33
33
  velocity?: number
34
- restSpeedThreshold?: number
35
- restDisplacementThreshold?: number
36
34
  delay?: number
37
35
  repeat?: RepeatConfig
38
36
  }
@@ -130,6 +128,15 @@ export type TransitionInput = TransitionConfig | TransitionName
130
128
 
131
129
  /**
132
130
  * Repeat config — one shape, not three flags. Default `alternate: true`.
131
+ *
132
+ * `count` is the total number of iterations, including the first pass, so
133
+ * `repeat: 2` plays the animation twice. A count below `1` runs the animation
134
+ * once and warns in dev.
135
+ *
136
+ * `alternate` reverses direction on every other pass for a single-value
137
+ * animation. It has no effect on a sequence (an array target): Reanimated
138
+ * restarts a sequence at its first step on every pass. Append the reverse
139
+ * steps to the sequence to alternate.
133
140
  */
134
141
  export type RepeatConfig =
135
142
  | number
@@ -0,0 +1,21 @@
1
+ import { Extrapolation } from 'react-native-reanimated'
2
+
3
+ /**
4
+ * Extrapolation behavior at the edges of the input range. Mirrors
5
+ * Reanimated's enum so consumers don't need a separate import.
6
+ *
7
+ * - `'clamp'` (default) — output stays pinned at the first/last value
8
+ * outside the input range. Matches Framer Motion's default.
9
+ * - `'identity'` — return the input unchanged outside the range.
10
+ * - `'extend'` — continue the linear slope beyond the range.
11
+ */
12
+ export type ExtrapolationMode = 'clamp' | 'identity' | 'extend'
13
+
14
+ /** Map the public mode name to Reanimated's `Extrapolation` enum. */
15
+ export function mapExtrapolation(
16
+ mode: ExtrapolationMode | undefined,
17
+ ): Extrapolation {
18
+ if (mode === 'identity') return Extrapolation.IDENTITY
19
+ if (mode === 'extend') return Extrapolation.EXTEND
20
+ return Extrapolation.CLAMP
21
+ }
@@ -18,6 +18,7 @@ export {
18
18
  } from './useGesture'
19
19
  export {
20
20
  useInterpolatedStyle,
21
+ type InterpolatedStyle,
21
22
  type InterpolatedStyleMap,
22
23
  type NumericStyleKey,
23
24
  type TransformKey,
@@ -37,4 +38,5 @@ export {
37
38
  type ShadowConfig,
38
39
  type UseShadowOptions,
39
40
  } from './useShadow'
41
+ export { useTranslateStyle } from './useTranslateStyle'
40
42
  export { useVariants } from './useVariants'
@@ -1,11 +1,11 @@
1
- import { useCallback, useMemo } from 'react'
1
+ import { useCallback, useEffect, useMemo } from 'react'
2
2
  import { useSharedValue, type SharedValue } from 'react-native-reanimated'
3
3
  import {
4
4
  resolveNamedTransitionProp,
5
5
  useNamedTransitions,
6
6
  useShouldReduceMotion,
7
7
  } from '../config'
8
- import { isFocusVisible } from '../gestures'
8
+ import { installFocusVisibility, isFocusVisible } from '../gestures'
9
9
  import { isTopLevelTransition, resolveTransition } from '../transitions'
10
10
  import {
11
11
  type GestureLayerTransitions,
@@ -110,6 +110,13 @@ export function useGesture(
110
110
  // the callbacks below only ever see concrete configs.
111
111
  const resolved = resolveNamedTransitionProp(transition, useNamedTransitions())
112
112
 
113
+ // The web modality listeners behind `focusVisible` attach on mount — see
114
+ // `focusVisibility.ts` for why mount time is early enough and import time
115
+ // is not an option.
116
+ useEffect(() => {
117
+ installFocusVisibility()
118
+ }, [])
119
+
113
120
  const setLayer = useCallback(
114
121
  (sv: SharedValue<number>, layer: LayerName, target: 0 | 1) => {
115
122
  const cfg = shouldReduceMotion
@@ -1,13 +1,14 @@
1
1
  import { useMemo } from 'react'
2
+ import type { ImageStyle, TextStyle, ViewStyle } from 'react-native'
2
3
  import {
3
- Extrapolation,
4
4
  interpolate,
5
5
  interpolateColor,
6
6
  useAnimatedStyle,
7
7
  type SharedValue,
8
8
  } from 'react-native-reanimated'
9
+ import { warnOnce } from '../internal/warnOnce'
10
+ import { mapExtrapolation, type ExtrapolationMode } from './extrapolation'
9
11
  import type { ColorStyleKey } from './useColorTransition'
10
- import type { ExtrapolationMode } from './useTransform'
11
12
 
12
13
  /**
13
14
  * Numeric style keys `useInterpolatedStyle` can emit directly (not lifted into
@@ -83,6 +84,29 @@ export type InterpolatedStyleMap = {
83
84
  [K in ColorStyleKey]?: readonly string[]
84
85
  }
85
86
 
87
+ /**
88
+ * The style shape a map of `K` produces. Transform keys collapse into the
89
+ * single `transform` array the worklet emits; every other key survives under
90
+ * its own name, typed from the RN style family it belongs to.
91
+ *
92
+ * This is what lets the return narrow to `ViewStyle` at a call site whose map
93
+ * holds only view keys. Reanimated's own `useAnimatedStyle` resolves to
94
+ * `DefaultStyle` (`ViewStyle | ImageStyle | TextStyle`), and that union is
95
+ * rejected inside a `StyleProp<ViewStyle>` array — `TextStyle` fails on
96
+ * `cursor` — so a consumer had to cast.
97
+ */
98
+ export type InterpolatedStyle<K extends keyof InterpolatedStyleMap> = {
99
+ [P in Exclude<K, TransformKey>]: P extends keyof TextStyle
100
+ ? TextStyle[P]
101
+ : P extends keyof ImageStyle
102
+ ? ImageStyle[P]
103
+ : P extends keyof ViewStyle
104
+ ? ViewStyle[P]
105
+ : never
106
+ } & (Extract<K, TransformKey> extends never
107
+ ? unknown
108
+ : { transform: NonNullable<ViewStyle['transform']> })
109
+
86
110
  export interface UseInterpolatedStyleOptions {
87
111
  /**
88
112
  * Input range mapped onto every key's output range. Defaults to `[0, 1]`
@@ -134,12 +158,6 @@ function evenlySpaced(count: number): number[] {
134
158
  return out
135
159
  }
136
160
 
137
- function mapExtrapolation(mode: ExtrapolationMode | undefined): Extrapolation {
138
- if (mode === 'identity') return Extrapolation.IDENTITY
139
- if (mode === 'extend') return Extrapolation.EXTEND
140
- return Extrapolation.CLAMP
141
- }
142
-
143
161
  /**
144
162
  * Order-preserving structural signature of the map + options. Unlike
145
163
  * `stableSig` (which sorts keys), this walks `map` in insertion order because
@@ -178,8 +196,9 @@ function buildEntries(
178
196
  ? [0, 1]
179
197
  : evenlySpaced(output.length)
180
198
 
181
- if (__DEV__ && explicitInput && explicitInput.length !== output.length) {
182
- console.warn(
199
+ if (explicitInput && explicitInput.length !== output.length) {
200
+ warnOnce(
201
+ `interpolated-style-range:${String(key)}:${explicitInput.length}:${output.length}`,
183
202
  `[inertia] useInterpolatedStyle: inputRange has ${explicitInput.length} stops but the "${String(
184
203
  key,
185
204
  )}" output has ${output.length}. They must match — interpolation results are undefined otherwise.`,
@@ -262,17 +281,17 @@ function buildEntries(
262
281
  * hook stays fully declarative and hashable so unchanged maps produce zero
263
282
  * new UI-thread closures.
264
283
  */
265
- export function useInterpolatedStyle(
284
+ export function useInterpolatedStyle<K extends keyof InterpolatedStyleMap>(
266
285
  progress: SharedValue<number>,
267
- map: InterpolatedStyleMap,
286
+ map: Pick<InterpolatedStyleMap, K>,
268
287
  options?: UseInterpolatedStyleOptions,
269
- ): ReturnType<typeof useAnimatedStyle> {
288
+ ): InterpolatedStyle<K> {
270
289
  const extrapolate = mapExtrapolation(options?.extrapolate)
271
290
 
272
291
  // Order-preserving signature: the map's key order is load-bearing (transform
273
292
  // lifting emits axes in author order), so `stableSig` (which sorts keys) is
274
293
  // wrong here — sign the ordered key/output pairs plus the options directly.
275
- const sig = buildSignature(map, options)
294
+ const sig = buildSignature(map as InterpolatedStyleMap, options)
276
295
 
277
296
  // Resolve every key's plan once on the JS thread so the worklet body only
278
297
  // consumes flat arrays — consistent with the JS-thread resolver principle
@@ -280,9 +299,15 @@ export function useInterpolatedStyle(
280
299
  // principle 8). Memoized on `sig` so a fresh-but-equal map literal each
281
300
  // render yields the same `entries` reference — Reanimated then sees an
282
301
  // unchanged closure dependency and does not rebuild the UI-thread worklet.
283
- // eslint-disable-next-line react-hooks/exhaustive-deps
284
- const entries = useMemo<Entry[]>(() => buildEntries(map, options), [sig])
302
+ const entries = useMemo<Entry[]>(
303
+ () => buildEntries(map as InterpolatedStyleMap, options),
304
+ // eslint-disable-next-line react-hooks/exhaustive-deps
305
+ [sig],
306
+ )
285
307
 
308
+ // The worklet builds a `Record<string, unknown>` by design — the emitted keys
309
+ // are only known from `entries` at run time. `InterpolatedStyle<K>` is the
310
+ // static statement of that same shape, so the cast is where the two meet.
286
311
  return useAnimatedStyle(() => {
287
312
  'worklet'
288
313
  const out: Record<string, unknown> = {}
@@ -313,7 +338,5 @@ export function useInterpolatedStyle(
313
338
  }
314
339
  if (transform.length > 0) out.transform = transform
315
340
  return out
316
- })
341
+ }) as InterpolatedStyle<K>
317
342
  }
318
-
319
- declare const __DEV__: boolean
@@ -1,3 +1,4 @@
1
+ import { useMemo } from 'react'
1
2
  import {
2
3
  interpolate,
3
4
  interpolateColor,
@@ -9,6 +10,7 @@ import {
9
10
  resolveBoxShadowInput,
10
11
  type BoxShadowLayer,
11
12
  } from '../internal/boxShadow'
13
+ import { stableSig } from '../transitions/sig'
12
14
 
13
15
  export type { BoxShadowLayer }
14
16
 
@@ -113,13 +115,22 @@ export function useShadow({
113
115
  // boxShadow layers: parse/pair once on the JS thread into flat records so
114
116
  // the worklet only interpolates numbers/colors and concatenates — no
115
117
  // frame-time parsing. `[]` when neither side provides the key.
116
- const boxShadowPairs =
117
- from.boxShadow !== undefined || to.boxShadow !== undefined
118
- ? pairBoxShadowLayers(
119
- resolveBoxShadowInput(from.boxShadow),
120
- resolveBoxShadowInput(to.boxShadow),
121
- )
122
- : []
118
+ //
119
+ // Memoised on the inputs' structure: the pairs array is the one non-scalar
120
+ // the worklet captures, and `from` / `to` are usually inline literals, so
121
+ // without the memo a fresh array each render would rebuild the worklet.
122
+ const boxShadowSig = stableSig([from.boxShadow, to.boxShadow])
123
+ const boxShadowPairs = useMemo(
124
+ () =>
125
+ from.boxShadow !== undefined || to.boxShadow !== undefined
126
+ ? pairBoxShadowLayers(
127
+ resolveBoxShadowInput(from.boxShadow),
128
+ resolveBoxShadowInput(to.boxShadow),
129
+ )
130
+ : [],
131
+ // eslint-disable-next-line react-hooks/exhaustive-deps
132
+ [boxShadowSig],
133
+ )
123
134
 
124
135
  const opacityFrom = from.shadowOpacity ?? 0
125
136
  const opacityTo = to.shadowOpacity ?? 0
@@ -6,7 +6,12 @@ import {
6
6
  withSpring,
7
7
  type SharedValue,
8
8
  } from 'react-native-reanimated'
9
- import { lookupNamedTransition, useNamedTransitions } from '../config'
9
+ import {
10
+ lookupNamedTransition,
11
+ useNamedTransitions,
12
+ useShouldReduceMotion,
13
+ } from '../config'
14
+ import { warnOnce } from '../internal/warnOnce'
10
15
  import { springToReanimated } from '../transitions/spring'
11
16
  import {
12
17
  type NamedTransitions,
@@ -14,8 +19,6 @@ import {
14
19
  type TransitionName,
15
20
  } from '../types'
16
21
 
17
- declare const __DEV__: boolean
18
-
19
22
  /**
20
23
  * Animate a shared value toward `target` with spring physics, using the
21
24
  * library's react-spring vocabulary (`tension` / `friction` / `mass`).
@@ -35,12 +38,16 @@ declare const __DEV__: boolean
35
38
  * must resolve to a spring config — a name registered as timing / decay /
36
39
  * no-animation warns in dev and falls back to the default spring (reach for
37
40
  * `useAnimation` when the named transition's type should be honored).
41
+ *
42
+ * Reduced motion (via `<MotionConfig reducedMotion>`) is honoured: the output
43
+ * is assigned the target directly, with no spring, on both paths.
38
44
  */
39
45
  export function useSpring(
40
46
  target: number | SharedValue<number>,
41
47
  config?: SpringTransition | TransitionName,
42
48
  ): SharedValue<number> {
43
49
  const spring = resolveSpringInput(config, useNamedTransitions())
50
+ const shouldReduceMotion = useShouldReduceMotion()
44
51
  // Reanimated config is rebuilt only when the public config object changes
45
52
  // shape. The worklet path reads this from JS-thread closure capture, which
46
53
  // is fine: it's the resolved config that's invariant across UI-thread
@@ -52,14 +59,7 @@ export function useSpring(
52
59
  // depending on `config` itself would rebuild the Reanimated config on
53
60
  // every render and defeat the memo.
54
61
  // eslint-disable-next-line react-hooks/exhaustive-deps
55
- [
56
- spring?.tension,
57
- spring?.friction,
58
- spring?.mass,
59
- spring?.velocity,
60
- spring?.restSpeedThreshold,
61
- spring?.restDisplacementThreshold,
62
- ],
62
+ [spring?.tension, spring?.friction, spring?.mass, spring?.velocity],
63
63
  )
64
64
 
65
65
  const isSharedTarget = isSharedValue(target)
@@ -72,28 +72,38 @@ export function useSpring(
72
72
  // a stale closure.
73
73
  useEffect(() => {
74
74
  if (isSharedTarget) return
75
- output.value = withSpring(target as number, reanimConfig)
75
+ output.value = shouldReduceMotion
76
+ ? (target as number)
77
+ : withSpring(target as number, reanimConfig)
76
78
  // `output` is identity-stable per hook instance (Reanimated guarantee).
77
79
  // eslint-disable-next-line react-hooks/exhaustive-deps
78
- }, [isSharedTarget, target, reanimConfig])
80
+ }, [isSharedTarget, target, reanimConfig, shouldReduceMotion])
79
81
 
80
82
  // SharedValue path. `useAnimatedReaction` runs the prepare worklet whenever
81
83
  // its returned value changes; we read `.value` off the target SV and pipe
82
84
  // it through `withSpring` on the UI thread. When the target is a plain
83
85
  // number we never declare a source so the reaction is inert (returns
84
86
  // `null`, never fires `react`).
87
+ //
88
+ // `source` is in the dependency list on purpose. An explicit list replaces
89
+ // Reanimated's closure-derived one, so a caller that swaps which shared
90
+ // value drives the spring would otherwise keep the reaction wired to the
91
+ // old one — the same stale-closure class `useColorCascade` fixed in 0.0.4.
92
+ // A plain-number target maps to `null`, so a changing number does not
93
+ // re-register the inert reaction.
94
+ const source = isSharedTarget ? target : null
85
95
  useAnimatedReaction(
86
96
  () => {
87
97
  'worklet'
88
- if (!isSharedTarget) return null
89
- return (target as SharedValue<number>).value
98
+ if (source === null) return null
99
+ return source.value
90
100
  },
91
101
  (next, prev) => {
92
102
  'worklet'
93
103
  if (next === null || next === prev) return
94
- output.value = withSpring(next, reanimConfig)
104
+ output.value = shouldReduceMotion ? next : withSpring(next, reanimConfig)
95
105
  },
96
- [isSharedTarget, reanimConfig],
106
+ [source, reanimConfig, shouldReduceMotion],
97
107
  )
98
108
 
99
109
  // Stop the in-flight spring when the owning component unmounts so its
@@ -122,14 +132,13 @@ function resolveSpringInput(
122
132
  if (typeof config !== 'string') return config
123
133
  const cfg = lookupNamedTransition(config, registry)
124
134
  if (cfg.type === undefined || cfg.type === 'spring') return cfg
125
- if (__DEV__) {
126
- console.warn(
127
- `[inertia] Named transition "${config}" resolves to type ` +
128
- `'${cfg.type}', but useSpring / useBooleanSpring are spring-only — ` +
129
- `falling back to the default spring. Use useAnimation to honor ` +
130
- `non-spring named transitions.`,
131
- )
132
- }
135
+ warnOnce(
136
+ `spring-only:${config}`,
137
+ `[inertia] Named transition "${config}" resolves to type ` +
138
+ `'${cfg.type}', but useSpring / useBooleanSpring are spring-only — ` +
139
+ `falling back to the default spring. Use useAnimation to honor ` +
140
+ `non-spring named transitions.`,
141
+ )
133
142
  return undefined
134
143
  }
135
144