@rootnative/inertia 0.0.2 → 0.0.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 (77) hide show
  1. package/CHANGELOG.md +63 -5
  2. package/README.md +5 -3
  3. package/dist/{chunk-ZN3PNGHQ.mjs → chunk-4ASOELLA.mjs} +1 -1
  4. package/dist/{chunk-LYMLQ6WQ.mjs → chunk-55W7BNO2.mjs} +604 -106
  5. package/dist/chunk-5SRD2WCO.js +8 -0
  6. package/dist/{chunk-PTRF47DA.js → chunk-7A6WTXNK.js} +10 -3
  7. package/dist/{chunk-NKA6XR77.js → chunk-CENTULW6.js} +2 -2
  8. package/dist/{chunk-4PEHWDAZ.js → chunk-D5KAHWMC.js} +7 -7
  9. package/dist/{chunk-2DHKV2XY.mjs → chunk-J5Q46OPW.mjs} +1 -1
  10. package/dist/{chunk-RGNX6UZN.mjs → chunk-K3CLP74S.mjs} +2 -2
  11. package/dist/{chunk-IWLU3VDE.js → chunk-MYE5NMXW.js} +623 -123
  12. package/dist/{chunk-HFDZAHWP.mjs → chunk-NAOXQJ7U.mjs} +1 -1
  13. package/dist/{chunk-R63GIUNU.mjs → chunk-QKC3APP4.mjs} +1 -1
  14. package/dist/{chunk-V6BTXHZK.mjs → chunk-RF7J5FRM.mjs} +1 -1
  15. package/dist/{chunk-CY7Y64C3.mjs → chunk-TU2XFPAK.mjs} +10 -4
  16. package/dist/chunk-TVFNJ25Z.js +8 -0
  17. package/dist/chunk-VLDIFL3F.js +8 -0
  18. package/dist/{chunk-7AOERN53.js → chunk-WVRCKOPO.js} +3 -3
  19. package/dist/{chunk-TUYHTHVV.js → chunk-XMEVPEMH.js} +2 -2
  20. package/dist/{chunk-TUMNV4P7.mjs → chunk-YKGY22SB.mjs} +1 -1
  21. package/dist/gestureLayer/index.d.mts +2 -2
  22. package/dist/gestureLayer/index.d.ts +2 -2
  23. package/dist/gestureLayer/index.js +10 -10
  24. package/dist/gestureLayer/index.mjs +3 -3
  25. package/dist/index.d.mts +14 -12
  26. package/dist/index.d.ts +14 -12
  27. package/dist/index.js +68 -176
  28. package/dist/index.mjs +40 -147
  29. package/dist/motion/Image.d.mts +1 -1
  30. package/dist/motion/Image.d.ts +1 -1
  31. package/dist/motion/Image.js +5 -5
  32. package/dist/motion/Image.mjs +4 -4
  33. package/dist/motion/Pressable.d.mts +1 -1
  34. package/dist/motion/Pressable.d.ts +1 -1
  35. package/dist/motion/Pressable.js +5 -5
  36. package/dist/motion/Pressable.mjs +4 -4
  37. package/dist/motion/ScrollView.d.mts +1 -1
  38. package/dist/motion/ScrollView.d.ts +1 -1
  39. package/dist/motion/ScrollView.js +5 -5
  40. package/dist/motion/ScrollView.mjs +4 -4
  41. package/dist/motion/Text.d.mts +1 -1
  42. package/dist/motion/Text.d.ts +1 -1
  43. package/dist/motion/Text.js +5 -5
  44. package/dist/motion/Text.mjs +4 -4
  45. package/dist/motion/View.d.mts +1 -1
  46. package/dist/motion/View.d.ts +1 -1
  47. package/dist/motion/View.js +5 -5
  48. package/dist/motion/View.mjs +4 -4
  49. package/dist/touch/index.d.mts +1 -1
  50. package/dist/touch/index.d.ts +1 -1
  51. package/dist/touch/index.js +3 -3
  52. package/dist/touch/index.mjs +1 -1
  53. package/dist/{types-G1dBU2IQ.d.mts → types-37pN82yh.d.mts} +49 -14
  54. package/dist/{types-G1dBU2IQ.d.ts → types-37pN82yh.d.ts} +49 -14
  55. package/dist/{useGesture-D1e4ixJo.d.ts → useGesture-Cl4uzZIr.d.ts} +1 -1
  56. package/dist/{useGesture-RFY6kTL3.d.mts → useGesture-_IKkS8Dv.d.mts} +1 -1
  57. package/llms.txt +3 -1
  58. package/package.json +1 -1
  59. package/src/index.ts +2 -1
  60. package/src/internal/boxShadow.ts +177 -6
  61. package/src/internal/nonWorkletWarning.ts +4 -6
  62. package/src/internal/warnOnce.ts +28 -0
  63. package/src/layout/index.ts +11 -0
  64. package/src/layout/measureWindow.ts +135 -0
  65. package/src/layout/sharedRegistry.ts +156 -18
  66. package/src/layout/useSharedLayout.ts +256 -91
  67. package/src/motion/createMotionComponent.tsx +550 -55
  68. package/src/motion/installCheck.ts +1 -1
  69. package/src/presence/Presence.tsx +73 -10
  70. package/src/transitions/index.ts +1 -1
  71. package/src/types.ts +52 -12
  72. package/src/values/useAnimator.ts +25 -14
  73. package/src/values/useColorCascade.ts +34 -16
  74. package/src/values/useColorTransition.ts +1 -1
  75. package/dist/chunk-34Q4UM6V.js +0 -8
  76. package/dist/chunk-KYJROYCG.js +0 -8
  77. package/dist/chunk-X5J5M3K3.js +0 -8
@@ -14,7 +14,7 @@ import Animated, {
14
14
  useSharedValue,
15
15
  type SharedValue,
16
16
  } from 'react-native-reanimated'
17
- import { type LayoutChangeEvent } from 'react-native'
17
+ import { StyleSheet, type LayoutChangeEvent } from 'react-native'
18
18
  import {
19
19
  lookupNamedTransition,
20
20
  resolveNamedTransitionProp,
@@ -22,9 +22,17 @@ import {
22
22
  useShouldReduceMotion,
23
23
  } from '../config'
24
24
  import { isFocusVisible } from '../gestures'
25
+ import {
26
+ normalizeBoxShadow,
27
+ prepareBoxShadowAnimation,
28
+ type AnimatedBoxShadowLayer,
29
+ type SplitBoxShadow,
30
+ } from '../internal/boxShadow'
31
+ import { warnOnce } from '../internal/warnOnce'
25
32
  import {
26
33
  resolveLayoutTransition,
27
34
  type LayoutProp,
35
+ type SharedStyleSnapshot,
28
36
  useSharedLayout,
29
37
  } from '../layout'
30
38
  import { usePresence } from '../presence'
@@ -33,6 +41,7 @@ import {
33
41
  resolveAnimatableValue,
34
42
  resolveTransition,
35
43
  stableSig,
44
+ type CallbackFactory,
36
45
  } from '../transitions'
37
46
  import { ensureReanimatedInstalled } from './installCheck'
38
47
  import {
@@ -42,6 +51,7 @@ import {
42
51
  type GestureLayerTransitions,
43
52
  type GestureSubStates,
44
53
  type MotionComponent,
54
+ type BoxShadowInput,
45
55
  type MotionProps,
46
56
  type PerPropertyTransition,
47
57
  type Transition,
@@ -108,6 +118,42 @@ const COLOR_KEYS = [
108
118
  // these keys directly.
109
119
  const SHADOW_OFFSET_KEYS = ['shadowOffsetWidth', 'shadowOffsetHeight'] as const
110
120
 
121
+ // Keys whose shared value holds a structure rather than a scalar. `boxShadow`
122
+ // is the only one: its slot carries an array of layer objects, which
123
+ // Reanimated's animation drivers recurse into, animating each leaf number and
124
+ // color independently (`arrayOnStart` → `objectOnStart` in Reanimated's
125
+ // `animation/util.ts`).
126
+ //
127
+ // Passing the CSS string through instead would NOT work — a box-shadow string
128
+ // isn't a color, so it lands in Reanimated's prefix-number-suffix branch,
129
+ // which is built for values like '100%' and would pull a single number out of
130
+ // a four-value shadow. All string parsing therefore happens on the JS thread,
131
+ // once per change, in `internal/boxShadow.ts`.
132
+ const STRUCTURED_KEYS = ['boxShadow'] as const
133
+
134
+ // Keys a shared-element transition (`layoutId`) carries from the source
135
+ // element to the target, crossfading them alongside the rect FLIP. Everything
136
+ // here is either a scalar or a color, so the worklet's existing lerp /
137
+ // `interpolateColor` branches handle it with no new machinery.
138
+ //
139
+ // Transform keys are deliberately absent: they are the FLIP's job, and
140
+ // carrying them too would apply the same displacement twice.
141
+ //
142
+ // So is `shadowColor`, despite being a color key. Carrying one of the four
143
+ // native shadow props crossfades a shadow's *color* over geometry that snapped
144
+ // — a worse result than letting the whole shadow snap together. (It would also
145
+ // pull `shadowColor` into the active set on any `layoutId` element whose style
146
+ // declares it, tripping the `boxShadow`-with-native-`shadow*` warning for a
147
+ // consumer who never asked for either.)
148
+ const SHARED_STYLE_KEYS = [
149
+ 'opacity',
150
+ 'borderRadius',
151
+ 'backgroundColor',
152
+ 'borderColor',
153
+ 'color',
154
+ 'tintColor',
155
+ ] as const
156
+
111
157
  /**
112
158
  * Per-effect transform-group coordinator. Counts how many transform-axis
113
159
  * terminal callbacks are still pending; when the last one fires, the
@@ -121,6 +167,7 @@ const ALL_KEYS = [
121
167
  ...NUMERIC_TOP_LEVEL_KEYS,
122
168
  ...COLOR_KEYS,
123
169
  ...SHADOW_OFFSET_KEYS,
170
+ ...STRUCTURED_KEYS,
124
171
  ] as const
125
172
  type AnimatableKey = (typeof ALL_KEYS)[number]
126
173
  type TransformKey = (typeof TRANSFORM_KEYS)[number]
@@ -129,6 +176,20 @@ type ShadowOffsetKey = (typeof SHADOW_OFFSET_KEYS)[number]
129
176
  const TRANSFORM_KEY_SET = new Set<AnimatableKey>(TRANSFORM_KEYS)
130
177
  const COLOR_KEY_SET = new Set<AnimatableKey>(COLOR_KEYS)
131
178
  const SHADOW_OFFSET_KEY_SET = new Set<AnimatableKey>(SHADOW_OFFSET_KEYS)
179
+ const STRUCTURED_KEY_SET = new Set<AnimatableKey>(STRUCTURED_KEYS)
180
+ const SHARED_STYLE_KEY_SET = new Set<AnimatableKey>(SHARED_STYLE_KEYS)
181
+
182
+ /**
183
+ * What a per-key shared value can hold. Scalars for every key except
184
+ * `boxShadow`, whose slot carries the layer array (see `STRUCTURED_KEYS`).
185
+ */
186
+ type AnimatableSlotValue = number | string | readonly AnimatedBoxShadowLayer[]
187
+
188
+ /**
189
+ * Resting `boxShadow` — no shadow at all. Frozen and hoisted so every
190
+ * primitive shares one reference and an untouched slot never allocates.
191
+ */
192
+ const NO_BOX_SHADOW: readonly AnimatedBoxShadowLayer[] = Object.freeze([])
132
193
 
133
194
  const GESTURE_LAYER_NAMES = [
134
195
  'hovered',
@@ -144,7 +205,7 @@ const GESTURE_LAYER_NAME_SET = new Set<string>(GESTURE_LAYER_NAMES)
144
205
  // Reanimated's style merging treats it as a no-op when present.
145
206
  const EXITING_POINTER_EVENTS_STYLE = { pointerEvents: 'none' } as const
146
207
 
147
- const DEFAULT_RESTING: Record<AnimatableKey, number | string> = {
208
+ const DEFAULT_RESTING: Record<AnimatableKey, AnimatableSlotValue> = {
148
209
  translateX: 0,
149
210
  translateY: 0,
150
211
  scale: 1,
@@ -171,6 +232,7 @@ const DEFAULT_RESTING: Record<AnimatableKey, number | string> = {
171
232
  shadowColor: 'transparent',
172
233
  shadowOffsetWidth: 0,
173
234
  shadowOffsetHeight: 0,
235
+ boxShadow: NO_BOX_SHADOW,
174
236
  }
175
237
 
176
238
  // Both lookups run after the factory has resolved registered transition
@@ -398,6 +460,24 @@ export function createMotionComponent<C extends ComponentType<any>>(
398
460
  // identity only changes on the renders that actually add a key, so the
399
461
  // `useAnimatedStyle` worklet (which reads `.current` each frame) sees the
400
462
  // expansion without churning frame-to-frame.
463
+ const hasLayoutId = layoutId !== undefined
464
+
465
+ // The static `style`, flattened at most once per render and only when
466
+ // something asks for it. Two readers: the shared-element key scan just
467
+ // below, and the resting-value pass further down. A plain `animate`-only
468
+ // instance triggers neither and never pays for the flatten.
469
+ let flatStyle: Record<string, unknown> | undefined
470
+ let flatStyleRead = false
471
+ const getFlatStyle = () => {
472
+ if (!flatStyleRead) {
473
+ flatStyleRead = true
474
+ flatStyle = StyleSheet.flatten(style as never) as
475
+ | Record<string, unknown>
476
+ | undefined
477
+ }
478
+ return flatStyle
479
+ }
480
+
401
481
  const touched = new Set<AnimatableKey>()
402
482
  collectTouchedKeys(touched, animateRecord)
403
483
  if (initialRecord) collectTouchedKeys(touched, initialRecord)
@@ -420,9 +500,31 @@ export function createMotionComponent<C extends ComponentType<any>>(
420
500
  }
421
501
  if (exitRecord) collectTouchedKeys(touched, exitRecord)
422
502
 
503
+ // Shared-element style carry: a `layoutId` element crossfades the carried
504
+ // keys from its counterpart, so those keys have to be in the active set
505
+ // for the worklet to emit them at all.
506
+ //
507
+ // Only keys this element already has a value for join — from a record
508
+ // above, or from the static style. Never invented: activating a key with
509
+ // no source value rests it at `DEFAULT_RESTING`, and for `color` on a
510
+ // `Motion.Text` that inherits its colour from a parent, that default is
511
+ // `'transparent'` — invisible text on an element the consumer only asked
512
+ // to move. Same failure mode as the `0.0.3` P0, reached from the other
513
+ // side.
514
+ if (hasLayoutId) {
515
+ const flat = getFlatStyle()
516
+ if (flat) {
517
+ for (const key of SHARED_STYLE_KEYS) {
518
+ if (touched.has(key)) continue
519
+ if (styleValueFor(flat, key) !== undefined) touched.add(key)
520
+ }
521
+ }
522
+ }
523
+
423
524
  const activeKeysRef = useRef<readonly AnimatableKey[] | null>(null)
424
525
  const hasTransformRef = useRef<boolean>(false)
425
526
  const hasShadowOffsetRef = useRef<boolean>(false)
527
+ const hasBoxShadowRef = useRef<boolean>(false)
426
528
  // Expand the active set only when this render touched a key we haven't
427
529
  // recorded yet. When nothing new appears we keep the existing array
428
530
  // identity so the worklet's captured ref doesn't see a fresh value.
@@ -446,18 +548,111 @@ export function createMotionComponent<C extends ComponentType<any>>(
446
548
  hasShadowOffsetRef.current = activeKeysRef.current.some((k) =>
447
549
  SHADOW_OFFSET_KEY_SET.has(k),
448
550
  )
551
+ hasBoxShadowRef.current = activeKeysRef.current.includes('boxShadow')
552
+ // Animating `boxShadow` alongside the native `shadow*` keys puts two
553
+ // shadow systems on one element; whichever the underlying view resolves
554
+ // last wins, and the result reads as a bug rather than a choice.
555
+ // Checked here rather than per render — the active key set is what
556
+ // decides it, and this block only runs when that set grows.
557
+ if (
558
+ __DEV__ &&
559
+ hasBoxShadowRef.current &&
560
+ activeKeysRef.current.some(
561
+ (k) => k !== 'boxShadow' && k.startsWith('shadow'),
562
+ )
563
+ ) {
564
+ warnOnce(
565
+ 'boxShadow-with-native-shadow',
566
+ '[inertia] `boxShadow` is animated alongside the native `shadow*` ' +
567
+ 'keys on the same element, which applies two shadow systems at ' +
568
+ 'once. Pick one: `boxShadow` for the cross-platform CSS form, or ' +
569
+ 'the native `shadow*` keys.',
570
+ )
571
+ }
572
+ }
573
+
574
+ // Which keys a record actually drives *right now*. Everything else in
575
+ // the active set is there only because a gesture sub-state, an `exit`
576
+ // target, or a variant branch that isn't current mentions it. Those must
577
+ // rest at whatever the static `style` says — the animated style merges
578
+ // AFTER `style`, so resting them at `DEFAULT_RESTING` silently stomps it
579
+ // (`borderColor` → 'transparent', `width` → 0, and so on for every key
580
+ // whose default isn't an identity). Transforms and `opacity` hid this
581
+ // bug for a long time: their defaults happen to be no-ops.
582
+ const drivenNow = new Set<AnimatableKey>()
583
+ collectTouchedKeys(drivenNow, animateRecord)
584
+ if (initialRecord) collectTouchedKeys(drivenNow, initialRecord)
585
+ if (isExiting && exitRecord) collectTouchedKeys(drivenNow, exitRecord)
586
+
587
+ // Monotonic, like the active set. Once a record has driven a key we stop
588
+ // syncing it to the style, so transitioning to a variant that doesn't
589
+ // mention the key leaves it where the previous variant put it — the
590
+ // existing semantic — instead of snapping back to the style value.
591
+ const everDrivenRef = useRef<Set<AnimatableKey>>(new Set())
592
+ for (const k of drivenNow) everDrivenRef.current.add(k)
593
+
594
+ // Resting values pulled off the static `style`, for active keys nothing
595
+ // has driven. Gated so the common `animate`-only instance never flattens
596
+ // a style it has no use for.
597
+ const activeKeys = activeKeysRef.current!
598
+ const everDriven = everDrivenRef.current
599
+ const styleResting: Partial<Record<AnimatableKey, AnimatableSlotValue>> =
600
+ {}
601
+ // `boxShadow`'s resting value is two halves (animated layers + static
602
+ // inset flags), so it can't ride the scalar map above.
603
+ let styleRestingShadow: SplitBoxShadow | null = null
604
+ if (activeKeys.some((k) => !everDriven.has(k))) {
605
+ const flat = getFlatStyle()
606
+ if (flat) {
607
+ for (const key of activeKeys) {
608
+ if (everDriven.has(key)) continue
609
+ if (key === 'boxShadow') {
610
+ const raw = flat.boxShadow as BoxShadowInput | undefined
611
+ if (raw !== undefined) {
612
+ styleRestingShadow = normalizeBoxShadow(raw)
613
+ styleResting.boxShadow = styleRestingShadow.layers
614
+ }
615
+ continue
616
+ }
617
+ const v = styleValueFor(flat, key)
618
+ if (v !== undefined) styleResting[key] = v
619
+ }
620
+ }
621
+ }
622
+
623
+ // Seed the `boxShadow` slot once, at mount, on the same precedence chain
624
+ // every other key uses (`initial` → `animate` → static style → default).
625
+ // Held in a ref rather than recomputed per render because resolving it
626
+ // may parse a CSS string, and the value is only ever read on the mount
627
+ // pass.
628
+ const boxShadowSeedRef = useRef<SplitBoxShadow | null>(null)
629
+ if (boxShadowSeedRef.current === null) {
630
+ const source =
631
+ initial === false
632
+ ? animateRecord.boxShadow
633
+ : (initialRecord?.boxShadow ?? animateRecord.boxShadow)
634
+ boxShadowSeedRef.current =
635
+ source !== undefined
636
+ ? normalizeBoxShadow(source)
637
+ : (styleRestingShadow ?? {
638
+ layers: [...NO_BOX_SHADOW],
639
+ insets: null,
640
+ })
449
641
  }
450
642
 
451
643
  const sharedValues = useAnimatableSharedValues((key) => {
644
+ if (key === 'boxShadow') return boxShadowSeedRef.current!.layers
452
645
  // Shadow offset synthetics seed from the corresponding axis on the
453
646
  // `shadowOffset: { width, height }` source — the consumer doesn't write
454
647
  // `shadowOffsetWidth` / `shadowOffsetHeight` directly. Fall back to the
455
- // generic resting default when neither initial nor animate touched it.
648
+ // static style, then the generic resting default, when neither initial
649
+ // nor animate touched it.
456
650
  if (SHADOW_OFFSET_KEY_SET.has(key)) {
457
651
  const axis = shadowOffsetAxisFor(key as ShadowOffsetKey)
458
652
  if (initial === false) {
459
653
  return (
460
654
  shadowOffsetAxisValue(animateRecord.shadowOffset, axis) ??
655
+ styleResting[key] ??
461
656
  DEFAULT_RESTING[key]
462
657
  )
463
658
  }
@@ -469,20 +664,52 @@ export function createMotionComponent<C extends ComponentType<any>>(
469
664
  axis,
470
665
  ) ??
471
666
  shadowOffsetAxisValue(animateRecord.shadowOffset, axis) ??
667
+ styleResting[key] ??
472
668
  DEFAULT_RESTING[key]
473
669
  )
474
670
  }
475
671
  if (initial === false) {
476
672
  const a = animateRecord[key]
477
- return restValue(a) ?? DEFAULT_RESTING[key]
673
+ return restValue(a) ?? styleResting[key] ?? DEFAULT_RESTING[key]
478
674
  }
479
675
  return (
480
676
  initialRecord?.[key] ??
481
677
  restValue(animateRecord[key]) ??
678
+ styleResting[key] ??
482
679
  DEFAULT_RESTING[key]
483
680
  )
484
681
  })
485
682
 
683
+ // Keep never-driven keys tracking the *live* static style. The seed above
684
+ // only runs at mount, but the animated style emits these keys on every
685
+ // frame — so without this a `style` that changes afterwards (a theme
686
+ // swap, a conditional colour) would stay invisible for any key some
687
+ // gesture / exit / variant branch happens to mention. Direct assignment,
688
+ // not an animation: this is a static value, not a target.
689
+ // Static `inset` flags for the layers currently in the `boxShadow` slot,
690
+ // parallel to `sharedValues.boxShadow`. Kept out of the animated payload
691
+ // because Reanimated would drive the boolean down its numeric path and
692
+ // hand native a number mid-flight; see `AnimatedBoxShadowLayer`. `null`
693
+ // whenever no layer is inset, which lets the worklet emit the animated
694
+ // array with no per-frame reassembly at all.
695
+ const boxShadowInsets = useSharedValue<boolean[] | null>(
696
+ boxShadowSeedRef.current.insets,
697
+ )
698
+
699
+ const styleRestingSig = stableSig(styleResting)
700
+ useEffect(() => {
701
+ for (const key of activeKeysRef.current!) {
702
+ if (everDrivenRef.current.has(key)) continue
703
+ sharedValues[key].value = (styleResting[key] ??
704
+ DEFAULT_RESTING[key]) as never
705
+ if (key === 'boxShadow') {
706
+ boxShadowInsets.value = styleRestingShadow?.insets ?? null
707
+ }
708
+ }
709
+ // `styleResting` is rebuilt each render; its signature is the real dep.
710
+ // eslint-disable-next-line react-hooks/exhaustive-deps
711
+ }, [styleRestingSig])
712
+
486
713
  // One progress SV per gesture layer, allocated unconditionally for hook
487
714
  // stability. Each layer's progress animates 0↔1 with its own transition
488
715
  // when its activation flips; the worklet reads them when compositing.
@@ -568,20 +795,71 @@ export function createMotionComponent<C extends ComponentType<any>>(
568
795
  }
569
796
  }
570
797
 
798
+ // Per-key transition resolution, hoisted out of the loop below so the
799
+ // transform-group count can ask about a key's config before the loop
800
+ // reaches it. Reduced motion overrides every per-key transition (and
801
+ // any nested sequence-step transition) with `no-animation`, which the
802
+ // resolver turns into a direct value assignment. Sequences still
803
+ // iterate but each step settles instantly, matching the "snap to final
804
+ // state" expectation.
805
+ const configFor = (key: AnimatableKey): TransitionConfig | undefined =>
806
+ shouldReduceMotion
807
+ ? ({ type: 'no-animation' } as const)
808
+ : transitionFor(
809
+ SHADOW_OFFSET_KEY_SET.has(key)
810
+ ? ('shadowOffset' as keyof typeof baseRecord)
811
+ : key,
812
+ transition,
813
+ )
814
+
571
815
  // Count transform axes participating in this effect run so the factory
572
816
  // can coalesce their terminal callbacks into a single transform-group
573
817
  // event. `undefined` when no transform axis is animating, which lets
574
818
  // the factory skip the coalescing branch entirely.
819
+ //
820
+ // Endless axes are excluded. A `repeat: 'infinite'` axis never reaches
821
+ // the terminal `'animation'` phase, so counting it would pin
822
+ // `remaining` permanently above zero — a sibling axis with a finite
823
+ // transition would settle, decrement, and have its completion silently
824
+ // swallowed. An axis that never finishes simply isn't part of the group
825
+ // that does.
575
826
  let transformPending = 0
576
827
  for (const k of ALL_KEYS) {
577
- if (TRANSFORM_KEY_SET.has(k) && baseRecord[k] !== undefined) {
578
- transformPending++
579
- }
828
+ if (!TRANSFORM_KEY_SET.has(k) || baseRecord[k] === undefined) continue
829
+ if (!reachesTerminalPhase(configFor(k))) continue
830
+ transformPending++
580
831
  }
581
832
  const transformGroup: TransformGroup | undefined =
582
833
  transformPending > 0 ? { remaining: transformPending } : undefined
583
834
 
584
835
  for (const key of ALL_KEYS) {
836
+ // Structured keys don't go through `resolveAnimatableValue` — their
837
+ // target is already an array, which that resolver would read as a
838
+ // keyframe sequence.
839
+ if (key === 'boxShadow') {
840
+ const shadowTarget = baseRecord.boxShadow
841
+ if (shadowTarget === undefined) continue
842
+ const cfg = configFor('boxShadow')
843
+ if (isExiting && reachesTerminalPhase(cfg)) pending++
844
+ driveBoxShadow(
845
+ sharedValues.boxShadow,
846
+ boxShadowInsets,
847
+ shadowTarget,
848
+ cfg,
849
+ makeKeyCallbackFactory(
850
+ 'boxShadow',
851
+ sharedValues.boxShadow,
852
+ // The declared target, not the padded one — the padding is an
853
+ // implementation detail of getting Reanimated to interpolate.
854
+ shadowTarget,
855
+ onAnimationEndRef,
856
+ { stepCount: 1, totalIterations: totalIterationsOf(cfg) },
857
+ isExiting ? onSettle : undefined,
858
+ undefined,
859
+ ),
860
+ )
861
+ continue
862
+ }
585
863
  // Shadow offset synthetics read their target from the nested
586
864
  // `shadowOffset: { width, height }` source on `baseRecord` — the
587
865
  // animate / exit record never has `shadowOffsetWidth` etc. on it
@@ -595,20 +873,15 @@ export function createMotionComponent<C extends ComponentType<any>>(
595
873
  )
596
874
  : baseRecord[key]
597
875
  if (target === undefined) continue
598
- // Reduced-motion overrides every per-key transition (and any nested
599
- // sequence-step transition) with `no-animation`, which the resolver
600
- // turns into a direct value assignment. Sequences still iterate but
601
- // each step settles instantly, which matches the "snap to final
602
- // state" expectation.
603
- const cfg = shouldReduceMotion
604
- ? ({ type: 'no-animation' } as const)
605
- : transitionFor(
606
- SHADOW_OFFSET_KEY_SET.has(key)
607
- ? ('shadowOffset' as keyof typeof baseRecord)
608
- : key,
609
- transition,
610
- )
611
- if (isExiting) pending++
876
+ const cfg = configFor(key)
877
+ // <Presence> waits on this counter before unmounting, so only count
878
+ // keys that can actually settle. An endless exit animation (a
879
+ // `repeat: 'infinite'` transition inherited by `exit`, e.g. a pulsing
880
+ // element inside <Presence>) would otherwise never call
881
+ // `safeToRemove` and the child would stay mounted forever. Finite
882
+ // keys still gate the unmount; if every exit key is endless, the
883
+ // post-loop `pending === 0` release fires immediately.
884
+ if (isExiting && reachesTerminalPhase(cfg)) pending++
612
885
  const factory = makeKeyCallbackFactory(
613
886
  key,
614
887
  sharedValues[key],
@@ -691,20 +964,44 @@ export function createMotionComponent<C extends ComponentType<any>>(
691
964
  // transform array so they compose with the user's animate transforms —
692
965
  // multiple `translateX` entries sum, multiple `scaleX` entries multiply,
693
966
  // which is exactly the FLIP semantic.
967
+ //
968
+ // The style half rides the same wiring: `readStyles` lets a later mount
969
+ // (or our own unmount) snapshot the carried keys straight off the shared
970
+ // values, and the worklet below crossfades a consumed snapshot out over
971
+ // whatever this element resolves to. Reached through a ref inside the
972
+ // hook, so building it inline here costs nothing downstream.
973
+ const readSharedStyles = () => {
974
+ if (!hasLayoutId) return undefined
975
+ const out: SharedStyleSnapshot = {}
976
+ let any = false
977
+ for (const key of activeKeysRef.current!) {
978
+ if (!SHARED_STYLE_KEY_SET.has(key)) continue
979
+ const v = sharedValues[key].value
980
+ // Structured slots can't be carried, and a key nothing ever gave a
981
+ // value to has nothing to hand over.
982
+ if (typeof v !== 'number' && typeof v !== 'string') continue
983
+ out[key] = v
984
+ any = true
985
+ }
986
+ return any ? out : undefined
987
+ }
988
+
694
989
  const sharedLayout = useSharedLayout({
695
990
  layoutId,
696
991
  userRef: ref,
697
992
  transition: isTopLevelTransition(transition) ? transition : undefined,
698
993
  shouldReduceMotion,
699
994
  userOnLayout,
995
+ readStyles: readSharedStyles,
700
996
  })
701
997
  const flip = sharedLayout.flip
702
- const hasLayoutId = layoutId !== undefined
998
+ const carry = sharedLayout.carry
703
999
 
704
1000
  const animatedStyle = useAnimatedStyle(() => {
705
1001
  const activeKeys = activeKeysRef.current!
706
1002
  const hasTransform = hasTransformRef.current
707
1003
  const hasShadowOffset = hasShadowOffsetRef.current
1004
+ const hasBoxShadow = hasBoxShadowRef.current
708
1005
  const out: Record<string, unknown> = {}
709
1006
  const transform: Array<Record<string, unknown>> = []
710
1007
  // shadow-offset reassembly buffers. The two synthetic axis SVs feed in
@@ -720,6 +1017,12 @@ export function createMotionComponent<C extends ComponentType<any>>(
720
1017
  const pfv = focusVisibleProgress.value
721
1018
  const pp = pressedProgress.value
722
1019
 
1020
+ // Shared-element style carry. Both reads are cheap and the pair rests
1021
+ // at (null, 0), so a primitive that isn't mid-transition — which is
1022
+ // every primitive, almost all of the time — pays one comparison.
1023
+ const carried = carry.snapshot.value
1024
+ const pc = carry.progress.value
1025
+
723
1026
  const layers = gestureSV.value
724
1027
  // Locals are suffixed `Layer` so they don't shadow the outer `pressed` /
725
1028
  // `focused` / `focusVisible` / `hovered` JS-state booleans — Reanimated's
@@ -731,6 +1034,11 @@ export function createMotionComponent<C extends ComponentType<any>>(
731
1034
  const pressedLayer = layers ? layers.pressed : null
732
1035
 
733
1036
  for (const key of activeKeys) {
1037
+ // `boxShadow` is emitted after the loop: its value is a layer array,
1038
+ // which none of the scalar compositing below can act on. Gesture
1039
+ // sub-states are rejected for this key upstream, so there is no
1040
+ // layer contribution to skip past either.
1041
+ if (key === 'boxShadow') continue
734
1042
  let v = sharedValues[key].value
735
1043
  const isColor = COLOR_KEY_SET.has(key)
736
1044
 
@@ -769,6 +1077,23 @@ export function createMotionComponent<C extends ComponentType<any>>(
769
1077
  : (v as number) + ((t as number) - (v as number)) * pp
770
1078
  }
771
1079
 
1080
+ // Shared-element carry sits above every gesture layer: the element
1081
+ // is arriving from somewhere else, and where it came from outranks
1082
+ // how it is being touched right now. `pc` runs 1 → 0, so the first
1083
+ // frame shows the source's value and the last shows this element's
1084
+ // own — including whatever the layers underneath contributed.
1085
+ //
1086
+ // Transform keys never appear in a snapshot (the FLIP owns them), so
1087
+ // this can't double-apply displacement.
1088
+ if (carried !== null && pc > 0) {
1089
+ const s = carried[key]
1090
+ if (s !== undefined) {
1091
+ v = isColor
1092
+ ? interpolateColor(pc, [0, 1], [v as string, s as string])
1093
+ : (v as number) + ((s as number) - (v as number)) * pc
1094
+ }
1095
+ }
1096
+
772
1097
  if (TRANSFORM_KEY_SET.has(key)) {
773
1098
  transform.push(
774
1099
  ROTATION_KEYS.has(key) ? { [key]: `${v}deg` } : { [key]: v },
@@ -797,12 +1122,28 @@ export function createMotionComponent<C extends ComponentType<any>>(
797
1122
  if (hasShadowOffset) {
798
1123
  out.shadowOffset = { width: shadowOffsetW, height: shadowOffsetH }
799
1124
  }
1125
+ if (hasBoxShadow) {
1126
+ const layers = sharedValues.boxShadow
1127
+ .value as readonly AnimatedBoxShadowLayer[]
1128
+ const insets = boxShadowInsets.value
1129
+ // Fast path — no inset layer anywhere, so the animated array is
1130
+ // already exactly what RN wants and crosses over untouched.
1131
+ if (insets === null) {
1132
+ out.boxShadow = layers
1133
+ } else {
1134
+ const withInset = []
1135
+ for (let i = 0; i < layers.length; i++) {
1136
+ withInset.push({ ...layers[i], inset: insets[i] })
1137
+ }
1138
+ out.boxShadow = withInset
1139
+ }
1140
+ }
800
1141
  return out
801
1142
  })
802
1143
 
803
1144
  // Exiting children are tap-deaf: the next press should fall through to
804
1145
  // whatever is underneath, not re-trigger a soon-to-unmount node. This is
805
- // the moti #297 fix and a v0.1 acceptance criterion. RN 0.71+ deprecates
1146
+ // the moti #297 fix and a `0.0.1` acceptance criterion. RN 0.71+ deprecates
806
1147
  // `pointerEvents` as a prop in favor of the style key, so we merge it
807
1148
  // alongside the animated style instead of spreading as a prop.
808
1149
  const mergedStyle = useMemo(
@@ -890,7 +1231,7 @@ function hasMotionProps(props: Record<string, unknown>): boolean {
890
1231
  return false
891
1232
  }
892
1233
 
893
- type SharedValueMap = Record<AnimatableKey, SharedValue<number | string>>
1234
+ type SharedValueMap = Record<AnimatableKey, SharedValue<AnimatableSlotValue>>
894
1235
 
895
1236
  /**
896
1237
  * Allocate one shared value per animatable key in `ALL_KEYS` and return a
@@ -911,36 +1252,39 @@ type SharedValueMap = Record<AnimatableKey, SharedValue<number | string>>
911
1252
  * `withSpring` / `withTiming` call.
912
1253
  */
913
1254
  function useAnimatableSharedValues(
914
- init: (key: AnimatableKey) => number | string,
1255
+ init: (key: AnimatableKey) => AnimatableSlotValue,
915
1256
  ): SharedValueMap {
916
- const translateX = useSharedValue<number | string>(init('translateX'))
917
- const translateY = useSharedValue<number | string>(init('translateY'))
918
- const scale = useSharedValue<number | string>(init('scale'))
919
- const scaleX = useSharedValue<number | string>(init('scaleX'))
920
- const scaleY = useSharedValue<number | string>(init('scaleY'))
921
- const rotate = useSharedValue<number | string>(init('rotate'))
922
- const rotateX = useSharedValue<number | string>(init('rotateX'))
923
- const rotateY = useSharedValue<number | string>(init('rotateY'))
924
- const opacity = useSharedValue<number | string>(init('opacity'))
925
- const width = useSharedValue<number | string>(init('width'))
926
- const height = useSharedValue<number | string>(init('height'))
927
- const borderRadius = useSharedValue<number | string>(init('borderRadius'))
928
- const shadowOpacity = useSharedValue<number | string>(init('shadowOpacity'))
929
- const shadowRadius = useSharedValue<number | string>(init('shadowRadius'))
930
- const elevation = useSharedValue<number | string>(init('elevation'))
931
- const backgroundColor = useSharedValue<number | string>(
1257
+ const translateX = useSharedValue<AnimatableSlotValue>(init('translateX'))
1258
+ const translateY = useSharedValue<AnimatableSlotValue>(init('translateY'))
1259
+ const scale = useSharedValue<AnimatableSlotValue>(init('scale'))
1260
+ const scaleX = useSharedValue<AnimatableSlotValue>(init('scaleX'))
1261
+ const scaleY = useSharedValue<AnimatableSlotValue>(init('scaleY'))
1262
+ const rotate = useSharedValue<AnimatableSlotValue>(init('rotate'))
1263
+ const rotateX = useSharedValue<AnimatableSlotValue>(init('rotateX'))
1264
+ const rotateY = useSharedValue<AnimatableSlotValue>(init('rotateY'))
1265
+ const opacity = useSharedValue<AnimatableSlotValue>(init('opacity'))
1266
+ const width = useSharedValue<AnimatableSlotValue>(init('width'))
1267
+ const height = useSharedValue<AnimatableSlotValue>(init('height'))
1268
+ const borderRadius = useSharedValue<AnimatableSlotValue>(init('borderRadius'))
1269
+ const shadowOpacity = useSharedValue<AnimatableSlotValue>(
1270
+ init('shadowOpacity'),
1271
+ )
1272
+ const shadowRadius = useSharedValue<AnimatableSlotValue>(init('shadowRadius'))
1273
+ const elevation = useSharedValue<AnimatableSlotValue>(init('elevation'))
1274
+ const backgroundColor = useSharedValue<AnimatableSlotValue>(
932
1275
  init('backgroundColor'),
933
1276
  )
934
- const borderColor = useSharedValue<number | string>(init('borderColor'))
935
- const color = useSharedValue<number | string>(init('color'))
936
- const tintColor = useSharedValue<number | string>(init('tintColor'))
937
- const shadowColor = useSharedValue<number | string>(init('shadowColor'))
938
- const shadowOffsetWidth = useSharedValue<number | string>(
1277
+ const borderColor = useSharedValue<AnimatableSlotValue>(init('borderColor'))
1278
+ const color = useSharedValue<AnimatableSlotValue>(init('color'))
1279
+ const tintColor = useSharedValue<AnimatableSlotValue>(init('tintColor'))
1280
+ const shadowColor = useSharedValue<AnimatableSlotValue>(init('shadowColor'))
1281
+ const shadowOffsetWidth = useSharedValue<AnimatableSlotValue>(
939
1282
  init('shadowOffsetWidth'),
940
1283
  )
941
- const shadowOffsetHeight = useSharedValue<number | string>(
1284
+ const shadowOffsetHeight = useSharedValue<AnimatableSlotValue>(
942
1285
  init('shadowOffsetHeight'),
943
1286
  )
1287
+ const boxShadow = useSharedValue<AnimatableSlotValue>(init('boxShadow'))
944
1288
 
945
1289
  const ref = useRef<SharedValueMap | null>(null)
946
1290
  if (ref.current === null) {
@@ -967,6 +1311,7 @@ function useAnimatableSharedValues(
967
1311
  shadowColor,
968
1312
  shadowOffsetWidth,
969
1313
  shadowOffsetHeight,
1314
+ boxShadow,
970
1315
  }
971
1316
  }
972
1317
 
@@ -1005,8 +1350,11 @@ function useAnimatableSharedValues(
1005
1350
  */
1006
1351
  function makeKeyCallbackFactory(
1007
1352
  key: string,
1008
- sharedValue: SharedValue<number | string>,
1009
- target: number | string | undefined,
1353
+ sharedValue: SharedValue<AnimatableSlotValue>,
1354
+ // Widened past the scalar surface for `boxShadow`, whose declared target is
1355
+ // a CSS string or a layer array. Reaches the consumer as
1356
+ // `AnimationCallbackInfo.target`, which is `unknown`.
1357
+ target: number | string | BoxShadowInput | undefined,
1010
1358
  onAnimationEndRef: {
1011
1359
  current: ((info: AnimationCallbackInfo<unknown>) => void) | undefined
1012
1360
  },
@@ -1026,7 +1374,9 @@ function makeKeyCallbackFactory(
1026
1374
  rawPhase: 'step' | 'animation',
1027
1375
  step: number | undefined,
1028
1376
  finished: boolean,
1029
- value: number | string | undefined,
1377
+ // Widened for `boxShadow`, whose settled value is a layer array. Surfaces
1378
+ // on `AnimationCallbackInfo.value`, which is `unknown` either way.
1379
+ value: AnimatableSlotValue | undefined,
1030
1380
  ) => {
1031
1381
  const isLastIteration = state.iteration >= meta.totalIterations - 1
1032
1382
  let phase: 'step' | 'sequence' | 'repeat' | 'animation'
@@ -1112,18 +1462,25 @@ function makeKeyCallbackFactory(
1112
1462
  * nested object decomposes into the `shadowOffsetWidth` / `shadowOffsetHeight`
1113
1463
  * synthetic axes downstream; consumers don't see the synthetics.
1114
1464
  *
1115
- * v0.1 contract: `shadowOffset` accepts a single `{ width, height }` literal
1116
- * (no sequences, no `{ to }` step objects, no array keyframes). Sequence
1117
- * forms on the nested axes can land in v0.2 if real consumers ask for them.
1465
+ * Contract since `0.0.1`: `shadowOffset` accepts a single `{ width, height }`
1466
+ * literal (no sequences, no `{ to }` step objects, no array keyframes).
1467
+ * Sequence forms on the nested axes can land later if real consumers ask.
1118
1468
  */
1469
+ type ScalarKey = Exclude<AnimatableKey, (typeof STRUCTURED_KEYS)[number]>
1470
+
1119
1471
  type InternalAnimateRecord = Partial<
1120
- Record<AnimatableKey, AnimatableValue<number | string>>
1472
+ Record<ScalarKey, AnimatableValue<number | string>>
1121
1473
  > & {
1122
1474
  shadowOffset?: { width?: number; height?: number }
1475
+ // Structured keys depart from `AnimatableValue` — see `STRUCTURED_KEYS`.
1476
+ // Kept out of the mapped half above so the two don't intersect into an
1477
+ // uninhabitable `AnimatableValue<number | string> & BoxShadowInput`.
1478
+ boxShadow?: BoxShadowInput
1123
1479
  }
1124
1480
 
1125
- type InternalInitialRecord = Partial<Record<AnimatableKey, number | string>> & {
1481
+ type InternalInitialRecord = Partial<Record<ScalarKey, number | string>> & {
1126
1482
  shadowOffset?: { width?: number; height?: number }
1483
+ boxShadow?: BoxShadowInput
1127
1484
  }
1128
1485
 
1129
1486
  /**
@@ -1146,6 +1503,107 @@ function shadowOffsetAxisValue(
1146
1503
  return source?.[axis]
1147
1504
  }
1148
1505
 
1506
+ /**
1507
+ * Drive the `boxShadow` slot toward a new target.
1508
+ *
1509
+ * Two things make this different from the scalar path:
1510
+ *
1511
+ * 1. **Both endpoints are padded to a common layer count first.** Reanimated's
1512
+ * array driver iterates the *current* value's indices and pairs each with
1513
+ * `toValue[i]`, so a target with a different layer count either strands
1514
+ * leaves at `toValue: undefined` or never animates the extras. When the
1515
+ * padding changes the slot's shape, we snap it to the padded base before
1516
+ * starting the animation — otherwise the animation would begin from a
1517
+ * differently-shaped value and the pairing would be lost again.
1518
+ * 2. **`inset` travels separately**, in its own slot, because Reanimated
1519
+ * would drive the boolean down the numeric path.
1520
+ */
1521
+ function driveBoxShadow(
1522
+ slot: SharedValue<AnimatableSlotValue>,
1523
+ insetSlot: SharedValue<boolean[] | null>,
1524
+ target: BoxShadowInput,
1525
+ cfg: TransitionConfig | undefined,
1526
+ factory: CallbackFactory | undefined,
1527
+ ): void {
1528
+ const currentLayers = Array.isArray(slot.value)
1529
+ ? (slot.value as readonly AnimatedBoxShadowLayer[])
1530
+ : NO_BOX_SHADOW
1531
+ const { from, to, insets } = prepareBoxShadowAnimation(
1532
+ { layers: [...currentLayers], insets: insetSlot.value },
1533
+ target,
1534
+ )
1535
+ insetSlot.value = insets
1536
+ if (from.length !== currentLayers.length) slot.value = from
1537
+ // `resolveTransition` is typed for the scalar surface it was written for;
1538
+ // Reanimated itself accepts the structured target and recurses into it.
1539
+ slot.value = resolveTransition(
1540
+ cfg,
1541
+ to as unknown as number,
1542
+ factory?.('animation', undefined),
1543
+ ) as AnimatableSlotValue
1544
+ }
1545
+
1546
+ /**
1547
+ * Read an animatable key's resting value out of a **flattened style object**.
1548
+ *
1549
+ * Used for keys that are in the active set but that no record drives — they
1550
+ * have to rest wherever the static `style` put them, because the animated
1551
+ * style merges after `style` and would otherwise override it.
1552
+ *
1553
+ * Three shapes need unwrapping: transform keys live inside the `transform`
1554
+ * array rather than at the top level, `shadowOffset*` synthetics decompose
1555
+ * from the nested object, and rotations are unit-suffixed strings on a style
1556
+ * but plain degrees in our shared values.
1557
+ *
1558
+ * Type-narrow deliberately: only numbers are accepted for numeric slots, so a
1559
+ * `width: '100%'` is left to `DEFAULT_RESTING` rather than seeded into a slot
1560
+ * that a later `withTiming` would try to interpolate as a number.
1561
+ */
1562
+ function styleValueFor(
1563
+ flat: Record<string, unknown>,
1564
+ key: AnimatableKey,
1565
+ ): number | string | undefined {
1566
+ if (SHADOW_OFFSET_KEY_SET.has(key)) {
1567
+ return shadowOffsetAxisValue(
1568
+ flat.shadowOffset as { width?: number; height?: number } | undefined,
1569
+ shadowOffsetAxisFor(key as ShadowOffsetKey),
1570
+ )
1571
+ }
1572
+ if (TRANSFORM_KEY_SET.has(key)) {
1573
+ const list = flat.transform
1574
+ if (!Array.isArray(list)) return undefined
1575
+ for (const entry of list) {
1576
+ if (!entry || typeof entry !== 'object') continue
1577
+ const v = (entry as Record<string, unknown>)[key]
1578
+ if (v === undefined) continue
1579
+ if (ROTATION_KEYS.has(key)) return parseAngleDegrees(v)
1580
+ return typeof v === 'number' ? v : undefined
1581
+ }
1582
+ return undefined
1583
+ }
1584
+ const v = flat[key]
1585
+ if (COLOR_KEY_SET.has(key)) {
1586
+ return typeof v === 'string' || typeof v === 'number' ? v : undefined
1587
+ }
1588
+ return typeof v === 'number' ? v : undefined
1589
+ }
1590
+
1591
+ /**
1592
+ * Style rotations are unit-suffixed strings (`'45deg'`, `'0.5rad'`); the shared
1593
+ * value behind a rotation key holds plain degrees. Anything we can't convert
1594
+ * confidently returns `undefined` so the caller falls back to the resting
1595
+ * default instead of guessing at a unit.
1596
+ */
1597
+ function parseAngleDegrees(v: unknown): number | undefined {
1598
+ if (typeof v === 'number') return v
1599
+ if (typeof v !== 'string') return undefined
1600
+ const deg = /^(-?\d*\.?\d+)deg$/.exec(v)
1601
+ if (deg) return Number(deg[1])
1602
+ const rad = /^(-?\d*\.?\d+)rad$/.exec(v)
1603
+ if (rad) return (Number(rad[1]) * 180) / Math.PI
1604
+ return undefined
1605
+ }
1606
+
1149
1607
  /**
1150
1608
  * Populate `touched` with the `AnimatableKey`s mentioned in `record`. Direct
1151
1609
  * matches (e.g. `opacity`, `width`) come from the key iteration; the nested
@@ -1190,6 +1648,22 @@ function totalIterationsOf(cfg: TransitionConfig | undefined): number {
1190
1648
  return r.count
1191
1649
  }
1192
1650
 
1651
+ /**
1652
+ * Whether an animation built from `cfg` will ever reach its terminal
1653
+ * `'animation'` phase. Everything finite does; `repeat: 'infinite'` never
1654
+ * does, because `dispatch` only promotes a callback to the terminal phase once
1655
+ * `iteration >= totalIterations - 1`, and that comparison is unreachable
1656
+ * against `Infinity`.
1657
+ *
1658
+ * Anything counting pending completions must exclude endless animations, or
1659
+ * the counter never drains: one endless transform axis swallowed every sibling
1660
+ * axis's `onAnimationEnd`, and one endless `exit` key left a `<Presence>` child
1661
+ * mounted forever because `safeToRemove` was gated behind it.
1662
+ */
1663
+ function reachesTerminalPhase(cfg: TransitionConfig | undefined): boolean {
1664
+ return Number.isFinite(totalIterationsOf(cfg))
1665
+ }
1666
+
1193
1667
  /**
1194
1668
  * Pull a single end-value out of an `AnimatableValue` for the
1195
1669
  * `AnimationCallbackInfo.target` field. Plain numbers/strings come through;
@@ -1304,6 +1778,27 @@ function resolveGestureLayers(
1304
1778
  if (!subState) continue
1305
1779
  const resolved: Record<string, number | string> = {}
1306
1780
  for (const key of ALL_KEYS) {
1781
+ // Structured keys have no place in the layer cascade: compositing them
1782
+ // would mean per-layer, per-field interpolation on the UI thread for
1783
+ // every primitive, whether or not it animates a shadow. Rejected for
1784
+ // now rather than half-supported — a silently-ignored `boxShadow` in a
1785
+ // `gesture` sub-state is exactly the class of bug this library exists
1786
+ // to avoid, so say so.
1787
+ if (STRUCTURED_KEY_SET.has(key)) {
1788
+ if (
1789
+ __DEV__ &&
1790
+ (subState as Record<string, unknown>)[key] !== undefined
1791
+ ) {
1792
+ warnOnce(
1793
+ `gesture-structured-${layer}-${key}`,
1794
+ `[inertia] \`${key}\` is not supported inside \`gesture.${layer}\` ` +
1795
+ 'and will be ignored. Drive it from `animate` (optionally via a ' +
1796
+ 'variant keyed off the same state), or interpolate it yourself ' +
1797
+ 'with `useShadow`.',
1798
+ )
1799
+ }
1800
+ continue
1801
+ }
1307
1802
  // Shadow offset synthetics decompose from the nested `shadowOffset:
1308
1803
  // { width, height }` source on the sub-state, the same as on `animate`.
1309
1804
  if (SHADOW_OFFSET_KEY_SET.has(key)) {