@onlynative/inertia 0.0.1-alpha.2 → 0.0.1-alpha.4

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 (61) hide show
  1. package/README.md +44 -3
  2. package/dist/index.d.mts +259 -3
  3. package/dist/index.d.ts +259 -3
  4. package/dist/index.js +1866 -161
  5. package/dist/index.js.map +1 -1
  6. package/dist/index.mjs +1864 -165
  7. package/dist/index.mjs.map +1 -1
  8. package/dist/motion/Image.d.mts +1 -1
  9. package/dist/motion/Image.d.ts +1 -1
  10. package/dist/motion/Image.js +1696 -146
  11. package/dist/motion/Image.js.map +1 -1
  12. package/dist/motion/Image.mjs +1698 -148
  13. package/dist/motion/Image.mjs.map +1 -1
  14. package/dist/motion/Pressable.d.mts +1 -1
  15. package/dist/motion/Pressable.d.ts +1 -1
  16. package/dist/motion/Pressable.js +1696 -146
  17. package/dist/motion/Pressable.js.map +1 -1
  18. package/dist/motion/Pressable.mjs +1698 -148
  19. package/dist/motion/Pressable.mjs.map +1 -1
  20. package/dist/motion/ScrollView.d.mts +1 -1
  21. package/dist/motion/ScrollView.d.ts +1 -1
  22. package/dist/motion/ScrollView.js +1696 -146
  23. package/dist/motion/ScrollView.js.map +1 -1
  24. package/dist/motion/ScrollView.mjs +1698 -148
  25. package/dist/motion/ScrollView.mjs.map +1 -1
  26. package/dist/motion/Text.d.mts +1 -1
  27. package/dist/motion/Text.d.ts +1 -1
  28. package/dist/motion/Text.js +1696 -146
  29. package/dist/motion/Text.js.map +1 -1
  30. package/dist/motion/Text.mjs +1698 -148
  31. package/dist/motion/Text.mjs.map +1 -1
  32. package/dist/motion/View.d.mts +1 -1
  33. package/dist/motion/View.d.ts +1 -1
  34. package/dist/motion/View.js +1696 -146
  35. package/dist/motion/View.js.map +1 -1
  36. package/dist/motion/View.mjs +1698 -148
  37. package/dist/motion/View.mjs.map +1 -1
  38. package/dist/{types-DeZZzE_e.d.mts → types-CjztO3RW.d.mts} +89 -20
  39. package/dist/{types-DeZZzE_e.d.ts → types-CjztO3RW.d.ts} +89 -20
  40. package/llms.txt +54 -6
  41. package/package.json +1 -1
  42. package/src/__type-tests__/animate.test-d.tsx +88 -0
  43. package/src/index.ts +16 -1
  44. package/src/layout/index.ts +1 -0
  45. package/src/layout/resolveLayout.ts +54 -0
  46. package/src/motion/createMotionComponent.tsx +292 -153
  47. package/src/motion/installCheck.ts +69 -0
  48. package/src/transitions/easing.ts +3 -1
  49. package/src/transitions/index.ts +3 -0
  50. package/src/transitions/keys.ts +32 -0
  51. package/src/transitions/resolve.ts +1 -24
  52. package/src/transitions/sig.ts +40 -0
  53. package/src/transitions/spring.ts +41 -0
  54. package/src/types.ts +96 -18
  55. package/src/values/index.ts +14 -0
  56. package/src/values/useAnimation.ts +69 -0
  57. package/src/values/useGesture.ts +144 -0
  58. package/src/values/useMotionValue.ts +33 -0
  59. package/src/values/useScroll.ts +72 -0
  60. package/src/values/useSpring.ts +93 -0
  61. package/src/values/useTransform.ts +132 -0
@@ -7,6 +7,7 @@ import {
7
7
  useState,
8
8
  } from 'react'
9
9
  import Animated, {
10
+ interpolateColor,
10
11
  runOnJS,
11
12
  useAnimatedStyle,
12
13
  useSharedValue,
@@ -14,12 +15,20 @@ import Animated, {
14
15
  } from 'react-native-reanimated'
15
16
  import { useShouldReduceMotion } from '../config'
16
17
  import { isFocusVisible } from '../gestures'
18
+ import { resolveLayoutTransition, type LayoutProp } from '../layout'
17
19
  import { usePresence } from '../presence'
18
- import { resolveAnimatableValue } from '../transitions'
20
+ import {
21
+ isTopLevelTransition,
22
+ resolveAnimatableValue,
23
+ resolveTransition,
24
+ stableSig,
25
+ } from '../transitions'
26
+ import { ensureReanimatedInstalled } from './installCheck'
19
27
  import {
20
28
  type AnimatableValue,
21
29
  type AnimateStyle,
22
30
  type AnimationCallbackInfo,
31
+ type GestureLayerTransitions,
23
32
  type GestureSubStates,
24
33
  type MotionComponent,
25
34
  type MotionProps,
@@ -42,8 +51,17 @@ const TRANSFORM_KEYS = [
42
51
  'scaleX',
43
52
  'scaleY',
44
53
  'rotate',
54
+ 'rotateX',
55
+ 'rotateY',
45
56
  ] as const
46
57
 
58
+ // Rotation keys land in the transform array as `{ rotate: '45deg' }` / `{
59
+ // rotateX: '45deg' }` etc. — Reanimated needs the unit-suffixed string form.
60
+ // We hold the underlying shared value as a plain number (degrees) and wrap
61
+ // in the worklet so the resolver pipeline stays uniform with the other
62
+ // numeric transform keys.
63
+ const ROTATION_KEYS = new Set<string>(['rotate', 'rotateX', 'rotateY'])
64
+
47
65
  const NUMERIC_TOP_LEVEL_KEYS = [
48
66
  'opacity',
49
67
  'width',
@@ -84,6 +102,16 @@ type AnimatableKey = (typeof ALL_KEYS)[number]
84
102
  type TransformKey = (typeof TRANSFORM_KEYS)[number]
85
103
 
86
104
  const TRANSFORM_KEY_SET = new Set<AnimatableKey>(TRANSFORM_KEYS)
105
+ const COLOR_KEY_SET = new Set<AnimatableKey>(COLOR_KEYS)
106
+
107
+ const GESTURE_LAYER_NAMES = [
108
+ 'hovered',
109
+ 'focused',
110
+ 'focusVisible',
111
+ 'pressed',
112
+ ] as const
113
+ type GestureLayerName = (typeof GESTURE_LAYER_NAMES)[number]
114
+ const GESTURE_LAYER_NAME_SET = new Set<string>(GESTURE_LAYER_NAMES)
87
115
 
88
116
  // Stable style object applied while a Motion primitive is mid-exit so taps
89
117
  // fall through. Hoisted so every render shares the same reference and
@@ -97,6 +125,8 @@ const DEFAULT_RESTING: Record<AnimatableKey, number | string> = {
97
125
  scaleX: 1,
98
126
  scaleY: 1,
99
127
  rotate: 0,
128
+ rotateX: 0,
129
+ rotateY: 0,
100
130
  opacity: 1,
101
131
  width: 0,
102
132
  height: 0,
@@ -111,38 +141,30 @@ const DEFAULT_RESTING: Record<AnimatableKey, number | string> = {
111
141
  tintColor: 'transparent',
112
142
  }
113
143
 
114
- const TRANSITION_KEYS = new Set([
115
- 'type',
116
- 'tension',
117
- 'friction',
118
- 'mass',
119
- 'velocity',
120
- 'restSpeedThreshold',
121
- 'restDisplacementThreshold',
122
- 'duration',
123
- 'easing',
124
- 'delay',
125
- 'repeat',
126
- 'deceleration',
127
- 'clamp',
128
- ])
129
-
130
- function isTopLevelTransition(t: unknown): t is TransitionConfig {
131
- if (t === null || typeof t !== 'object') return false
132
- const keys = Object.keys(t as object)
133
- if (keys.length === 0) return false
134
- return keys.every((k) => TRANSITION_KEYS.has(k))
135
- }
136
-
137
144
  function transitionFor<S>(
138
145
  prop: keyof S,
139
146
  transition: Transition<S> | undefined,
140
147
  ): TransitionConfig | undefined {
141
148
  if (!transition) return undefined
142
149
  if (isTopLevelTransition(transition)) return transition
150
+ // Gesture-layer keys (`pressed`, `hovered`, …) live on the same map as
151
+ // per-property keys; skip them when looking up a property transition so a
152
+ // user who wires `transition.pressed` doesn't accidentally apply that to a
153
+ // style key named `pressed` (none currently exist, but keep the lookup
154
+ // honest).
155
+ if (GESTURE_LAYER_NAME_SET.has(prop as string)) return undefined
143
156
  return (transition as PerPropertyTransition<S>)[prop]
144
157
  }
145
158
 
159
+ function gestureLayerTransitionFor<S>(
160
+ layer: GestureLayerName,
161
+ transition: Transition<S> | undefined,
162
+ ): TransitionConfig | undefined {
163
+ if (!transition) return undefined
164
+ if (isTopLevelTransition(transition)) return transition
165
+ return (transition as GestureLayerTransitions)[layer]
166
+ }
167
+
146
168
  /**
147
169
  * Factory that wraps a React Native primitive as a `Motion.*` component.
148
170
  *
@@ -154,10 +176,17 @@ function transitionFor<S>(
154
176
  * borderRadius) and color properties (backgroundColor, borderColor, color,
155
177
  * tintColor) applied via Reanimated shared values + `useAnimatedStyle`.
156
178
  */
179
+ // `ComponentType<any>` is React's canonical "accept any component" idiom.
180
+ // `unknown` doesn't work — props need to widen to whatever `C` actually accepts
181
+ // at the call site. The two `any` uses below are deliberate.
182
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
157
183
  export function createMotionComponent<C extends ComponentType<any>>(
158
184
  Component: C,
159
185
  ): MotionComponent<C> {
186
+ ensureReanimatedInstalled()
187
+
160
188
  const AnimatedComponent = Animated.createAnimatedComponent(
189
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
161
190
  Component as ComponentType<any>,
162
191
  )
163
192
 
@@ -172,10 +201,11 @@ export function createMotionComponent<C extends ComponentType<any>>(
172
201
  variants,
173
202
  controller,
174
203
  gesture,
204
+ layout,
175
205
  onAnimationEnd,
176
206
  style,
177
207
  ...rest
178
- } = props as Props & { style?: unknown }
208
+ } = props as Props & { style?: unknown; layout?: LayoutProp }
179
209
 
180
210
  // <Presence> contract: when an ancestor flips `isPresent` to false the
181
211
  // child stays rendered until `safeToRemove` is called, giving the exit
@@ -220,10 +250,10 @@ export function createMotionComponent<C extends ComponentType<any>>(
220
250
  >)
221
251
  : undefined
222
252
 
223
- // Gesture sub-state activation tracked as JS state so changes invalidate
224
- // the merged-target signature and re-run the animation effect. The cost
225
- // is four useState slots regardless of whether `gesture` is set; that's
226
- // tiny and lets us stay rules-of-hooks-clean.
253
+ // Gesture sub-state activation tracked as JS state. Activation flips drive
254
+ // the per-layer progress shared values (0↔1); they intentionally do NOT
255
+ // re-run the value-driving effect gesture sub-state targets live on the
256
+ // worklet's composition chain, not on the base `animate` SV.
227
257
  const [pressed, setPressed] = useState(false)
228
258
  const [focused, setFocused] = useState(false)
229
259
  const [focusVisible, setFocusVisible] = useState(false)
@@ -233,7 +263,8 @@ export function createMotionComponent<C extends ComponentType<any>>(
233
263
  // variants in play the union across all variants is what matters — a key
234
264
  // touched by any variant must be active so the worklet picks it up when
235
265
  // the controller transitions. Gesture sub-states join the same union so
236
- // pressed/focused/hovered targets can drive any key they declare.
266
+ // pressed/focused/focusVisible/hovered targets can drive any key they
267
+ // declare even when the base `animate` doesn't touch it.
237
268
  const activeKeysRef = useRef<readonly AnimatableKey[] | null>(null)
238
269
  if (activeKeysRef.current === null) {
239
270
  const touched = new Set<AnimatableKey>()
@@ -285,24 +316,46 @@ export function createMotionComponent<C extends ComponentType<any>>(
285
316
  )
286
317
  })
287
318
 
288
- // Merge gesture sub-state targets over the base `animate` record. Keys
289
- // touched by any sub-state always appear in the merged record (falling
290
- // back to `animateRecord` or `DEFAULT_RESTING`) so releasing a gesture
291
- // animates back to a defined value rather than getting skipped.
319
+ // One progress SV per gesture layer, allocated unconditionally for hook
320
+ // stability. Each layer's progress animates 0↔1 with its own transition
321
+ // when its activation flips; the worklet reads them when compositing.
322
+ // Initial value is 0 — even if a sub-state is somehow active on mount,
323
+ // the activation effect below will animate it to 1 on the next tick.
324
+ const pressedProgress = useSharedValue(0)
325
+ const focusedProgress = useSharedValue(0)
326
+ const focusVisibleProgress = useSharedValue(0)
327
+ const hoveredProgress = useSharedValue(0)
328
+
329
+ // Mirror gesture targets into a UI-runtime-resident shared value so the
330
+ // animated-style worklet can read the latest layer values without having
331
+ // to capture `gesture` directly (which would re-register the worklet on
332
+ // every render where the consumer passes a fresh literal). The signature
333
+ // dependency means we only push to the SV when targets actually change —
334
+ // the SV ref itself is stable across renders.
292
335
  //
293
- // While exiting, exit values override everything gesture / animate
294
- // targets are inert because the component is on its way out.
295
- const mergedRecord =
336
+ // The resolved value is a layer-keyed map of primitive endpoints (numbers
337
+ // or color strings); sequence/`{ to }` step shapes on a sub-state collapse
338
+ // to their final endpoint via `targetEndValue` because a gesture layer
339
+ // describes a steady target, not a keyframe sequence.
340
+ const gestureSV = useSharedValue<ResolvedGestureLayers | null>(
341
+ resolveGestureLayers(gesture),
342
+ )
343
+ const gestureTargetsSig = stableSig(gesture)
344
+ useEffect(() => {
345
+ gestureSV.value = resolveGestureLayers(gesture)
346
+ // eslint-disable-next-line react-hooks/exhaustive-deps
347
+ }, [gestureTargetsSig])
348
+
349
+ // The base record drives the per-key shared values. Gesture sub-state
350
+ // targets are intentionally NOT merged here — they layer on top in the
351
+ // worklet. Exit values still take precedence over `animate` while exiting
352
+ // because the base SV is what <Presence> waits on to settle.
353
+ const baseRecord =
296
354
  isExiting && exitRecord
297
355
  ? { ...animateRecord, ...exitRecord }
298
- : mergeGestureTargets(animateRecord, gesture, {
299
- pressed,
300
- focused,
301
- focusVisible,
302
- hovered,
303
- })
304
- const mergedSig =
305
- stableSig(mergedRecord) +
356
+ : animateRecord
357
+ const baseSig =
358
+ stableSig(baseRecord) +
306
359
  (isExiting ? '|exit' : '') +
307
360
  (shouldReduceMotion ? '|rm' : '')
308
361
  const transitionSig = stableSig(transition)
@@ -337,7 +390,7 @@ export function createMotionComponent<C extends ComponentType<any>>(
337
390
  // the factory skip the coalescing branch entirely.
338
391
  let transformPending = 0
339
392
  for (const k of ALL_KEYS) {
340
- if (TRANSFORM_KEY_SET.has(k) && mergedRecord[k] !== undefined) {
393
+ if (TRANSFORM_KEY_SET.has(k) && baseRecord[k] !== undefined) {
341
394
  transformPending++
342
395
  }
343
396
  }
@@ -345,7 +398,7 @@ export function createMotionComponent<C extends ComponentType<any>>(
345
398
  transformPending > 0 ? { remaining: transformPending } : undefined
346
399
 
347
400
  for (const key of ALL_KEYS) {
348
- const target = mergedRecord[key]
401
+ const target = baseRecord[key]
349
402
  if (target === undefined) continue
350
403
  // Reduced-motion overrides every per-key transition (and any nested
351
404
  // sequence-step transition) with `no-animation`, which the resolver
@@ -382,18 +435,120 @@ export function createMotionComponent<C extends ComponentType<any>>(
382
435
  safeToRemoveRef.current?.()
383
436
  }
384
437
  // eslint-disable-next-line react-hooks/exhaustive-deps
385
- }, [mergedSig, transitionSig])
438
+ }, [baseSig, transitionSig])
439
+
440
+ // Per-layer progress: when a sub-state activation flips, animate its
441
+ // progress SV 0↔1 with the layer's own transition (or the parent
442
+ // transition / library default, in priority order). On exit we snap every
443
+ // layer to 0 instantly so the unmount-bound base SV isn't fighting a
444
+ // stale layer contribution mid-fade.
445
+ //
446
+ // The `declared` flag short-circuits the effect when the consumer hasn't
447
+ // wired the corresponding sub-state — so a Motion primitive without a
448
+ // `gesture` prop (or with only some sub-states declared) makes zero extra
449
+ // `withSpring` / `withTiming` calls on mount.
450
+ useGestureLayerProgress(
451
+ pressedProgress,
452
+ pressed,
453
+ gesture?.pressed != null,
454
+ 'pressed',
455
+ transition,
456
+ isExiting,
457
+ shouldReduceMotion,
458
+ )
459
+ useGestureLayerProgress(
460
+ focusedProgress,
461
+ focused,
462
+ gesture?.focused != null,
463
+ 'focused',
464
+ transition,
465
+ isExiting,
466
+ shouldReduceMotion,
467
+ )
468
+ useGestureLayerProgress(
469
+ focusVisibleProgress,
470
+ focusVisible,
471
+ gesture?.focusVisible != null,
472
+ 'focusVisible',
473
+ transition,
474
+ isExiting,
475
+ shouldReduceMotion,
476
+ )
477
+ useGestureLayerProgress(
478
+ hoveredProgress,
479
+ hovered,
480
+ gesture?.hovered != null,
481
+ 'hovered',
482
+ transition,
483
+ isExiting,
484
+ shouldReduceMotion,
485
+ )
386
486
 
387
487
  const animatedStyle = useAnimatedStyle(() => {
388
488
  const activeKeys = activeKeysRef.current!
389
489
  const hasTransform = hasTransformRef.current
390
490
  const out: Record<string, unknown> = {}
391
491
  const transform: Array<Record<string, unknown>> = []
492
+
493
+ // Read each progress SV exactly once so the chain below sees a coherent
494
+ // snapshot for this frame. Reading them on the UI thread is cheap.
495
+ const ph = hoveredProgress.value
496
+ const pf = focusedProgress.value
497
+ const pfv = focusVisibleProgress.value
498
+ const pp = pressedProgress.value
499
+
500
+ const layers = gestureSV.value
501
+ // Locals are suffixed `Layer` so they don't shadow the outer `pressed` /
502
+ // `focused` / `focusVisible` / `hovered` JS-state booleans — Reanimated's
503
+ // worklet closure tracker would otherwise pick those up as captured
504
+ // dependencies and re-register the worklet on every activation flip.
505
+ const hoveredLayer = layers ? layers.hovered : null
506
+ const focusedLayer = layers ? layers.focused : null
507
+ const focusVisibleLayer = layers ? layers.focusVisible : null
508
+ const pressedLayer = layers ? layers.pressed : null
509
+
392
510
  for (const key of activeKeys) {
393
- const v = sharedValues[key].value
511
+ let v = sharedValues[key].value
512
+ const isColor = COLOR_KEY_SET.has(key)
513
+
514
+ // Composite gesture layers in priority order (lowest first). Each
515
+ // active layer pulls the value toward its pre-resolved primitive
516
+ // endpoint by `progress`; numeric keys lerp, color keys go through
517
+ // Reanimated's RGBA `interpolateColor`. We skip layers with progress
518
+ // 0 to avoid an `interpolateColor(0, ...)` call that would parse the
519
+ // target color string for no visible effect.
520
+ if (hoveredLayer && ph > 0 && hoveredLayer[key] !== undefined) {
521
+ const t = hoveredLayer[key]
522
+ v = isColor
523
+ ? interpolateColor(ph, [0, 1], [v as string, t as string])
524
+ : (v as number) + ((t as number) - (v as number)) * ph
525
+ }
526
+ if (focusedLayer && pf > 0 && focusedLayer[key] !== undefined) {
527
+ const t = focusedLayer[key]
528
+ v = isColor
529
+ ? interpolateColor(pf, [0, 1], [v as string, t as string])
530
+ : (v as number) + ((t as number) - (v as number)) * pf
531
+ }
532
+ if (
533
+ focusVisibleLayer &&
534
+ pfv > 0 &&
535
+ focusVisibleLayer[key] !== undefined
536
+ ) {
537
+ const t = focusVisibleLayer[key]
538
+ v = isColor
539
+ ? interpolateColor(pfv, [0, 1], [v as string, t as string])
540
+ : (v as number) + ((t as number) - (v as number)) * pfv
541
+ }
542
+ if (pressedLayer && pp > 0 && pressedLayer[key] !== undefined) {
543
+ const t = pressedLayer[key]
544
+ v = isColor
545
+ ? interpolateColor(pp, [0, 1], [v as string, t as string])
546
+ : (v as number) + ((t as number) - (v as number)) * pp
547
+ }
548
+
394
549
  if (TRANSFORM_KEY_SET.has(key)) {
395
550
  transform.push(
396
- key === 'rotate' ? { rotate: `${v}deg` } : { [key]: v },
551
+ ROTATION_KEYS.has(key) ? { [key]: `${v}deg` } : { [key]: v },
397
552
  )
398
553
  } else {
399
554
  out[key] = v
@@ -425,11 +580,24 @@ export function createMotionComponent<C extends ComponentType<any>>(
425
580
  setHovered,
426
581
  )
427
582
 
583
+ // Resolve the `layout` prop into a Reanimated `LinearTransition` builder.
584
+ // Memoized on the value's stable signature so a fresh `layout={true}` or
585
+ // `layout={{ ... }}` literal each render doesn't rebuild the builder. When
586
+ // reduced motion is active we pass `undefined` — see `resolveLayout` for
587
+ // why we don't pass a duration-0 builder instead.
588
+ const layoutSig = stableSig(layout)
589
+ const layoutTransition = useMemo(
590
+ () => (shouldReduceMotion ? undefined : resolveLayoutTransition(layout)),
591
+ // eslint-disable-next-line react-hooks/exhaustive-deps
592
+ [layoutSig, shouldReduceMotion],
593
+ )
594
+
428
595
  return (
429
596
  <AnimatedComponent
430
597
  ref={ref as never}
431
598
  {...(rest as object)}
432
599
  {...gestureHandlers}
600
+ layout={layoutTransition}
433
601
  style={mergedStyle}
434
602
  />
435
603
  )
@@ -469,6 +637,8 @@ function useAnimatableSharedValues(
469
637
  const scaleX = useSharedValue<number | string>(init('scaleX'))
470
638
  const scaleY = useSharedValue<number | string>(init('scaleY'))
471
639
  const rotate = useSharedValue<number | string>(init('rotate'))
640
+ const rotateX = useSharedValue<number | string>(init('rotateX'))
641
+ const rotateY = useSharedValue<number | string>(init('rotateY'))
472
642
  const opacity = useSharedValue<number | string>(init('opacity'))
473
643
  const width = useSharedValue<number | string>(init('width'))
474
644
  const height = useSharedValue<number | string>(init('height'))
@@ -489,6 +659,8 @@ function useAnimatableSharedValues(
489
659
  scaleX,
490
660
  scaleY,
491
661
  rotate,
662
+ rotateX,
663
+ rotateY,
492
664
  opacity,
493
665
  width,
494
666
  height,
@@ -735,113 +907,73 @@ function restValue(
735
907
  return undefined
736
908
  }
737
909
 
738
- function stableSig(value: unknown): string {
739
- if (value === undefined) return ''
740
- try {
741
- return stableStringify(value)
742
- } catch {
743
- return String(value)
744
- }
745
- }
746
-
747
910
  /**
748
- * JSON.stringify with keys sorted at every level gives a stable signature
749
- * regardless of property declaration order. Functions serialize as `null` so a
750
- * change in easing-fn reference is invisible here; that's fine for v0.1
751
- * (easing swaps are rare and the worklet wrapper handles correctness).
911
+ * Per-layer resolved targets: each declared gesture sub-state collapses to a
912
+ * map of primitive endpoints (numbers or color strings), already passed
913
+ * through `targetEndValue` so the worklet can use them directly without
914
+ * inspecting `AnimatableValue` shapes on the UI thread.
752
915
  */
753
- function stableStringify(v: unknown): string {
754
- if (v === null || typeof v !== 'object') {
755
- if (typeof v === 'function' || v === undefined) return 'null'
756
- return JSON.stringify(v)
757
- }
758
- if (Array.isArray(v)) {
759
- return '[' + v.map(stableStringify).join(',') + ']'
916
+ type ResolvedGestureLayers = {
917
+ pressed?: Record<string, number | string>
918
+ focused?: Record<string, number | string>
919
+ focusVisible?: Record<string, number | string>
920
+ hovered?: Record<string, number | string>
921
+ }
922
+
923
+ function resolveGestureLayers(
924
+ gesture: GestureSubStates<unknown> | undefined,
925
+ ): ResolvedGestureLayers | null {
926
+ if (!gesture) return null
927
+ const out: ResolvedGestureLayers = {}
928
+ for (const layer of GESTURE_LAYER_NAMES) {
929
+ const subState = gesture[layer]
930
+ if (!subState) continue
931
+ const resolved: Record<string, number | string> = {}
932
+ for (const key of ALL_KEYS) {
933
+ const raw = (subState as Record<string, unknown>)[key]
934
+ if (raw === undefined) continue
935
+ const t = targetEndValue(raw as AnimatableValue<number | string>)
936
+ if (t !== undefined) resolved[key] = t
937
+ }
938
+ out[layer] = resolved
760
939
  }
761
- const obj = v as Record<string, unknown>
762
- const keys = Object.keys(obj).sort()
763
- return (
764
- '{' +
765
- keys
766
- .map((k) => JSON.stringify(k) + ':' + stableStringify(obj[k]))
767
- .join(',') +
768
- '}'
769
- )
940
+ return out
770
941
  }
771
942
 
772
943
  /**
773
- * Merge gesture sub-state targets over the base `animate` record. Keys touched
774
- * by any declared sub-state are always present in the result so releasing a
775
- * gesture animates the property back to a defined value (the base `animate`
776
- * value when present, otherwise `DEFAULT_RESTING`). Sub-states layer in
777
- * priority order (lowest first):
778
- * `hovered` < `focused` < `focusVisible` < `pressed`.
944
+ * Drive a single gesture layer's progress shared value 0↔1 with its own
945
+ * transition. Resolution priority for the layer config:
946
+ * `transition.<layerName>` top-level `transition` library default spring.
947
+ * On exit, snap to 0 instantly so the unmount-bound base SV finishes its exit
948
+ * animation without a stale layer pulling the value off-target.
949
+ *
950
+ * The hook is invoked unconditionally (one call per layer) so hook order
951
+ * stays stable even when `gesture` adds or removes sub-states across renders.
779
952
  */
780
- function mergeGestureTargets(
781
- base: Partial<Record<AnimatableKey, AnimatableValue<number | string>>>,
782
- gesture: GestureSubStates<unknown> | undefined,
783
- active: {
784
- pressed: boolean
785
- focused: boolean
786
- focusVisible: boolean
787
- hovered: boolean
788
- },
789
- ): Partial<Record<AnimatableKey, AnimatableValue<number | string>>> {
790
- if (!gesture) return base
791
- const merged: Partial<
792
- Record<AnimatableKey, AnimatableValue<number | string>>
793
- > = {
794
- ...base,
795
- }
796
- const subStates = [
797
- gesture.hovered,
798
- gesture.focused,
799
- gesture.focusVisible,
800
- gesture.pressed,
801
- ] as Array<
802
- Partial<Record<AnimatableKey, AnimatableValue<number | string>>> | undefined
803
- >
804
- for (const sub of subStates) {
805
- if (!sub) continue
806
- for (const k of ALL_KEYS) {
807
- if (k in sub && !(k in merged)) {
808
- merged[k] = DEFAULT_RESTING[k]
809
- }
953
+ function useGestureLayerProgress<S>(
954
+ progress: SharedValue<number>,
955
+ active: boolean,
956
+ declared: boolean,
957
+ layer: GestureLayerName,
958
+ transition: Transition<S> | undefined,
959
+ isExiting: boolean,
960
+ shouldReduceMotion: boolean,
961
+ ): void {
962
+ const layerCfgSig = stableSig(gestureLayerTransitionFor(layer, transition))
963
+ useEffect(() => {
964
+ if (!declared) return
965
+ if (isExiting) {
966
+ progress.value = 0
967
+ return
810
968
  }
811
- }
812
- if (active.hovered && gesture.hovered) {
813
- Object.assign(
814
- merged,
815
- gesture.hovered as Partial<
816
- Record<AnimatableKey, AnimatableValue<number | string>>
817
- >,
818
- )
819
- }
820
- if (active.focused && gesture.focused) {
821
- Object.assign(
822
- merged,
823
- gesture.focused as Partial<
824
- Record<AnimatableKey, AnimatableValue<number | string>>
825
- >,
826
- )
827
- }
828
- if (active.focusVisible && gesture.focusVisible) {
829
- Object.assign(
830
- merged,
831
- gesture.focusVisible as Partial<
832
- Record<AnimatableKey, AnimatableValue<number | string>>
833
- >,
834
- )
835
- }
836
- if (active.pressed && gesture.pressed) {
837
- Object.assign(
838
- merged,
839
- gesture.pressed as Partial<
840
- Record<AnimatableKey, AnimatableValue<number | string>>
841
- >,
842
- )
843
- }
844
- return merged
969
+ const target = active ? 1 : 0
970
+ const cfg = shouldReduceMotion
971
+ ? ({ type: 'no-animation' } as const)
972
+ : (gestureLayerTransitionFor(layer, transition) ??
973
+ ({ type: 'spring' } as const))
974
+ progress.value = resolveTransition(cfg, target) as never
975
+ // eslint-disable-next-line react-hooks/exhaustive-deps
976
+ }, [active, declared, isExiting, shouldReduceMotion, layerCfgSig])
845
977
  }
846
978
 
847
979
  type GestureHandlers = Record<string, (event: unknown) => void>
@@ -863,6 +995,13 @@ function useGestureHandlers(
863
995
  setFocusVisible: (next: boolean) => void,
864
996
  setHovered: (next: boolean) => void,
865
997
  ): GestureHandlers {
998
+ // Deps key on declared-ness, not object identity — a fresh `gesture={...}`
999
+ // literal each render must not rebuild handlers if the same sub-states are
1000
+ // declared.
1001
+ const hasPressed = gesture?.pressed ? 1 : 0
1002
+ const hasFocused = gesture?.focused ? 1 : 0
1003
+ const hasFocusVisible = gesture?.focusVisible ? 1 : 0
1004
+ const hasHovered = gesture?.hovered ? 1 : 0
866
1005
  return useMemo(() => {
867
1006
  if (!gesture) return {}
868
1007
  const handlers: GestureHandlers = {}
@@ -903,10 +1042,10 @@ function useGestureHandlers(
903
1042
  return handlers
904
1043
  // eslint-disable-next-line react-hooks/exhaustive-deps
905
1044
  }, [
906
- gesture?.pressed ? 1 : 0,
907
- gesture?.focused ? 1 : 0,
908
- gesture?.focusVisible ? 1 : 0,
909
- gesture?.hovered ? 1 : 0,
1045
+ hasPressed,
1046
+ hasFocused,
1047
+ hasFocusVisible,
1048
+ hasHovered,
910
1049
  rest.onTouchStart,
911
1050
  rest.onTouchEnd,
912
1051
  rest.onTouchCancel,