@rootnative/inertia 0.0.1 → 0.0.3

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 (44) hide show
  1. package/CHANGELOG.md +36 -2
  2. package/dist/{chunk-TDSO63CJ.js → chunk-3XTVY34H.js} +2 -2
  3. package/dist/{chunk-NXDJZD6A.mjs → chunk-46P57VMY.mjs} +1 -1
  4. package/dist/{chunk-7UDYEFBU.js → chunk-BP3Y2SHQ.js} +477 -298
  5. package/dist/{chunk-6SMPIOIC.mjs → chunk-BQQTHG2V.mjs} +1 -1
  6. package/dist/{chunk-DWCLIBYO.mjs → chunk-CSODMRJ7.mjs} +478 -299
  7. package/dist/chunk-FNVFV4EY.js +8 -0
  8. package/dist/chunk-FWQOXA43.js +8 -0
  9. package/dist/chunk-KBP4LR75.js +8 -0
  10. package/dist/{chunk-ALRHDFZE.mjs → chunk-O22NXXCZ.mjs} +1 -1
  11. package/dist/{chunk-CWLFUYIY.mjs → chunk-OQV66TBQ.mjs} +1 -1
  12. package/dist/{chunk-JVBXPF2G.mjs → chunk-SGUHE5CX.mjs} +1 -1
  13. package/dist/{chunk-2HYD2ZBK.js → chunk-W5MC3P4N.js} +2 -2
  14. package/dist/index.d.mts +231 -43
  15. package/dist/index.d.ts +231 -43
  16. package/dist/index.js +212 -23
  17. package/dist/index.mjs +204 -18
  18. package/dist/motion/Image.js +3 -3
  19. package/dist/motion/Image.mjs +2 -2
  20. package/dist/motion/Pressable.js +3 -3
  21. package/dist/motion/Pressable.mjs +2 -2
  22. package/dist/motion/ScrollView.js +3 -3
  23. package/dist/motion/ScrollView.mjs +2 -2
  24. package/dist/motion/Text.js +3 -3
  25. package/dist/motion/Text.mjs +2 -2
  26. package/dist/motion/View.js +3 -3
  27. package/dist/motion/View.mjs +2 -2
  28. package/llms.txt +5 -0
  29. package/package.json +1 -1
  30. package/src/index.ts +10 -0
  31. package/src/layout/index.ts +1 -0
  32. package/src/layout/sharedRegistry.ts +51 -2
  33. package/src/motion/createMotionComponent.tsx +781 -502
  34. package/src/presence/Presence.tsx +73 -10
  35. package/src/values/index.ts +13 -0
  36. package/src/values/useAnimation.ts +15 -1
  37. package/src/values/useAnimator.ts +83 -0
  38. package/src/values/useColorCascade.ts +125 -0
  39. package/src/values/useInterpolatedStyle.ts +319 -0
  40. package/src/values/useMotionValue.ts +20 -2
  41. package/src/values/useSpring.ts +12 -0
  42. package/dist/chunk-3UTJJ4A3.js +0 -8
  43. package/dist/chunk-4QGXK6TF.js +0 -8
  44. package/dist/chunk-Z7HIOFKQ.js +0 -8
@@ -7,13 +7,14 @@ import {
7
7
  useState,
8
8
  } from 'react'
9
9
  import Animated, {
10
+ cancelAnimation,
10
11
  interpolateColor,
11
12
  runOnJS,
12
13
  useAnimatedStyle,
13
14
  useSharedValue,
14
15
  type SharedValue,
15
16
  } from 'react-native-reanimated'
16
- import { type LayoutChangeEvent } from 'react-native'
17
+ import { StyleSheet, type LayoutChangeEvent } from 'react-native'
17
18
  import {
18
19
  lookupNamedTransition,
19
20
  resolveNamedTransitionProp,
@@ -233,48 +234,22 @@ export function createMotionComponent<C extends ComponentType<any>>(
233
234
 
234
235
  type Props = React.ComponentProps<C> & MotionProps<React.ComponentProps<C>>
235
236
 
236
- const Motion = forwardRef<unknown, Props>(function Motion(props, ref) {
237
- const {
238
- initial,
239
- animate,
240
- exit,
241
- transition: transitionProp,
242
- variants,
243
- controller,
244
- gesture,
245
- layout: layoutProp,
246
- layoutId,
247
- onAnimationEnd,
248
- style,
249
- onLayout: userOnLayout,
250
- ...rest
251
- } = props as Props & {
252
- style?: unknown
253
- layout?: LayoutProp | string
254
- layoutId?: string
255
- onLayout?: (event: LayoutChangeEvent) => void
256
- }
257
-
258
- // Resolve registered transition names (from the nearest <MotionConfig
259
- // transitions>) into concrete configs before anything downstream touches
260
- // the props. Resolution is JS-thread and identity-preserving when no
261
- // names are present; when names resolve, the registry entries are stable
262
- // objects, so every signature-keyed memo below stays warm.
263
- const namedTransitions = useNamedTransitions()
264
- const transition = resolveNamedTransitionProp(
265
- transitionProp as Transition<Record<string, unknown>> | undefined,
266
- namedTransitions,
267
- )
268
- const layout: LayoutProp =
269
- typeof layoutProp === 'string'
270
- ? lookupNamedTransition(layoutProp, namedTransitions)
271
- : layoutProp
272
-
273
- // Function-form `style={(state) => ...}` is the Pressable render-prop API.
274
- // Inertia drives press/focus state through `gesture.*` and merges its own
275
- // animated style; a function passed here lands inside a style array where
276
- // the underlying component never invokes it, so the resulting styles are
277
- // silently dropped. Throw loudly in dev rather than ship the footgun.
237
+ // Plain-host fast path. When an instance carries none of the animation-
238
+ // driving props, it needs no shared values, no `useAnimatedStyle` worklet,
239
+ // no gesture state, and no layout wiring — it is just the underlying
240
+ // `Animated.createAnimatedComponent(Component)` with `style`/`ref`/`onLayout`
241
+ // forwarded through. Rendering that directly (instead of the full animated
242
+ // body) keeps a prop-less `Motion.View` a zero-cost pass-through: same host,
243
+ // no per-render animation allocations (Principle 3 — one host concept, no
244
+ // separate "plain" alias). Because `PlainHost` and `MotionAnimated` are
245
+ // distinct component types, React keeps each one's hook list consistent; an
246
+ // instance that gains (or loses) an animation prop after mount crosses the
247
+ // boundary and remounts, which is the correct behavior for that rare edge.
248
+ const PlainHost = forwardRef<unknown, Props>(function PlainHost(props, ref) {
249
+ const { style, ...rest } = props as Props & { style?: unknown }
250
+
251
+ // Same dev guard as the animated body: a `style` function is the Pressable
252
+ // render-prop API, which Inertia doesn't support (see the animated path).
278
253
  if (__DEV__ && typeof style === 'function') {
279
254
  throw new Error(
280
255
  '[inertia] `style` must be a style object or array of style objects, ' +
@@ -284,499 +259,683 @@ export function createMotionComponent<C extends ComponentType<any>>(
284
259
  )
285
260
  }
286
261
 
287
- // <Presence> contract: when an ancestor flips `isPresent` to false the
288
- // child stays rendered until `safeToRemove` is called, giving the exit
289
- // animation time to play. `null` when there is no <Presence> ancestor.
262
+ // Presence coordination. A prop-less child inside <Presence> has no exit
263
+ // animation, so it must signal `safeToRemove` immediately once it starts
264
+ // exiting otherwise it lingers in the snapshot forever. This is a context
265
+ // read plus an unmount-scoped effect: no shared values, no worklet, no
266
+ // per-render allocation. `null` when there is no <Presence> ancestor.
290
267
  const presence = usePresence()
291
268
  const isExiting = presence !== null && presence.isPresent === false
269
+ const safeToRemoveRef = useRef<(() => void) | undefined>(undefined)
270
+ safeToRemoveRef.current = presence?.safeToRemove
271
+ useEffect(() => {
272
+ if (isExiting) safeToRemoveRef.current?.()
273
+ }, [isExiting])
292
274
 
293
- // Resolved reduced-motion preference for this subtree. When true, every
294
- // per-key transition is replaced with `no-animation` below, so values
295
- // snap to target without interpolation. In 'user' mode the OS setting is
296
- // read via Reanimated's `useReducedMotion`, which captures the value once
297
- // at app start — a runtime toggle takes effect on the next launch.
298
- const shouldReduceMotion = useShouldReduceMotion()
299
-
300
- // Pin the latest `onAnimationEnd` in a ref so the worklet callback always
301
- // dispatches against the current closure without re-resolving the
302
- // animation graph. Worklets can read refs via `runOnJS`.
303
- const onAnimationEndRef = useRef(onAnimationEnd)
304
- onAnimationEndRef.current = onAnimationEnd
305
-
306
- // Resolve `animate` against `variants` / `controller`. The controller's
307
- // `current` wins when both are set (typed contract: don't mix
308
- // `controller` and `animate` — controller drives the animation in that
309
- // mode). When `animate` is a string and `variants` exist, look it up.
310
- const variantKey = useControllerKey(controller)
311
- const resolvedAnimate = resolveAnimateInput(
312
- animate as AnimateStyle<unknown> | string | undefined,
313
- variants as VariantsMap<unknown> | undefined,
314
- variantKey,
275
+ return (
276
+ <AnimatedComponent
277
+ ref={ref as never}
278
+ {...(rest as object)}
279
+ style={style}
280
+ />
315
281
  )
282
+ })
283
+ PlainHost.displayName = `MotionPlain(${Component.displayName ?? Component.name ?? 'Component'})`
284
+
285
+ const MotionAnimated = forwardRef<unknown, Props>(
286
+ function MotionAnimated(props, ref) {
287
+ const {
288
+ initial,
289
+ animate,
290
+ exit,
291
+ transition: transitionProp,
292
+ variants,
293
+ controller,
294
+ gesture,
295
+ layout: layoutProp,
296
+ layoutId,
297
+ onAnimationEnd,
298
+ style,
299
+ onLayout: userOnLayout,
300
+ ...rest
301
+ } = props as Props & {
302
+ style?: unknown
303
+ layout?: LayoutProp | string
304
+ layoutId?: string
305
+ onLayout?: (event: LayoutChangeEvent) => void
306
+ }
316
307
 
317
- const animateRecord = (resolvedAnimate ?? {}) as InternalAnimateRecord
318
- const initialRecord =
319
- initial && initial !== false
320
- ? (initial as InternalInitialRecord)
321
- : undefined
322
- const exitRecord = exit ? (exit as InternalAnimateRecord) : undefined
323
-
324
- // Gesture sub-state activation tracked as JS state. Activation flips drive
325
- // the per-layer progress shared values (0↔1); they intentionally do NOT
326
- // re-run the value-driving effect — gesture sub-state targets live on the
327
- // worklet's composition chain, not on the base `animate` SV.
328
- const [pressed, setPressed] = useState(false)
329
- const [focused, setFocused] = useState(false)
330
- const [focusVisible, setFocusVisible] = useState(false)
331
- const [hovered, setHovered] = useState(false)
332
-
333
- // The set of keys this instance animates is a *monotonically growing*
334
- // union, recomputed every render and expanded when a render introduces a
335
- // key not seen before. It never shrinks. Two requirements meet here:
336
- //
337
- // 1. Variants and gesture sub-states contribute the union across *all*
338
- // their branches up front — a key touched by any variant must be
339
- // active so the worklet picks it up when the controller transitions
340
- // to a branch the base `animate` never mentions.
341
- // 2. A literal `animate` object is reactive: a parent that changes
342
- // `animate={{ opacity: 1 }}` to `animate={{ opacity: 1, scale: 2 }}`
343
- // after mount must get `scale` animating. Freezing the set at first
344
- // render silently dropped the new key (its SV updated, but the
345
- // worklet — which iterates this set — never read it).
346
- //
347
- // Growing-only keeps the worklet stable: the `activeKeysRef.current` array
348
- // identity only changes on the renders that actually add a key, so the
349
- // `useAnimatedStyle` worklet (which reads `.current` each frame) sees the
350
- // expansion without churning frame-to-frame.
351
- const touched = new Set<AnimatableKey>()
352
- collectTouchedKeys(touched, animateRecord)
353
- if (initialRecord) collectTouchedKeys(touched, initialRecord)
354
- if (variants) {
355
- for (const variant of Object.values(variants) as object[]) {
356
- if (!variant) continue
357
- collectTouchedKeys(touched, variant as Record<string, unknown>)
308
+ // Resolve registered transition names (from the nearest <MotionConfig
309
+ // transitions>) into concrete configs before anything downstream touches
310
+ // the props. Resolution is JS-thread and identity-preserving when no
311
+ // names are present; when names resolve, the registry entries are stable
312
+ // objects, so every signature-keyed memo below stays warm.
313
+ const namedTransitions = useNamedTransitions()
314
+ const transition = resolveNamedTransitionProp(
315
+ transitionProp as Transition<Record<string, unknown>> | undefined,
316
+ namedTransitions,
317
+ )
318
+ const layout: LayoutProp =
319
+ typeof layoutProp === 'string'
320
+ ? lookupNamedTransition(layoutProp, namedTransitions)
321
+ : layoutProp
322
+
323
+ // Function-form `style={(state) => ...}` is the Pressable render-prop API.
324
+ // Inertia drives press/focus state through `gesture.*` and merges its own
325
+ // animated style; a function passed here lands inside a style array where
326
+ // the underlying component never invokes it, so the resulting styles are
327
+ // silently dropped. Throw loudly in dev rather than ship the footgun.
328
+ if (__DEV__ && typeof style === 'function') {
329
+ throw new Error(
330
+ '[inertia] `style` must be a style object or array of style objects, ' +
331
+ 'not a function. The function-form `style={(state) => ...}` Pressable ' +
332
+ 'API is not supported use `gesture.pressed` (or `gesture.focused`, ' +
333
+ 'etc.) to drive state-dependent styling instead.',
334
+ )
358
335
  }
359
- }
360
- if (gesture) {
361
- for (const subState of [
362
- gesture.pressed,
363
- gesture.focused,
364
- gesture.focusVisible,
365
- gesture.hovered,
366
- ] as Array<object | undefined>) {
367
- if (!subState) continue
368
- collectTouchedKeys(touched, subState as Record<string, unknown>)
336
+
337
+ // <Presence> contract: when an ancestor flips `isPresent` to false the
338
+ // child stays rendered until `safeToRemove` is called, giving the exit
339
+ // animation time to play. `null` when there is no <Presence> ancestor.
340
+ const presence = usePresence()
341
+ const isExiting = presence !== null && presence.isPresent === false
342
+
343
+ // Resolved reduced-motion preference for this subtree. When true, every
344
+ // per-key transition is replaced with `no-animation` below, so values
345
+ // snap to target without interpolation. In 'user' mode the OS setting is
346
+ // read via Reanimated's `useReducedMotion`, which captures the value once
347
+ // at app start — a runtime toggle takes effect on the next launch.
348
+ const shouldReduceMotion = useShouldReduceMotion()
349
+
350
+ // Pin the latest `onAnimationEnd` in a ref so the worklet callback always
351
+ // dispatches against the current closure without re-resolving the
352
+ // animation graph. Worklets can read refs via `runOnJS`.
353
+ const onAnimationEndRef = useRef(onAnimationEnd)
354
+ onAnimationEndRef.current = onAnimationEnd
355
+
356
+ // Resolve `animate` against `variants` / `controller`. The controller's
357
+ // `current` wins when both are set (typed contract: don't mix
358
+ // `controller` and `animate` — controller drives the animation in that
359
+ // mode). When `animate` is a string and `variants` exist, look it up.
360
+ const variantKey = useControllerKey(controller)
361
+ const resolvedAnimate = resolveAnimateInput(
362
+ animate as AnimateStyle<unknown> | string | undefined,
363
+ variants as VariantsMap<unknown> | undefined,
364
+ variantKey,
365
+ )
366
+
367
+ const animateRecord = (resolvedAnimate ?? {}) as InternalAnimateRecord
368
+ const initialRecord =
369
+ initial && initial !== false
370
+ ? (initial as InternalInitialRecord)
371
+ : undefined
372
+ const exitRecord = exit ? (exit as InternalAnimateRecord) : undefined
373
+
374
+ // Gesture sub-state activation tracked as JS state. Activation flips drive
375
+ // the per-layer progress shared values (0↔1); they intentionally do NOT
376
+ // re-run the value-driving effect — gesture sub-state targets live on the
377
+ // worklet's composition chain, not on the base `animate` SV.
378
+ const [pressed, setPressed] = useState(false)
379
+ const [focused, setFocused] = useState(false)
380
+ const [focusVisible, setFocusVisible] = useState(false)
381
+ const [hovered, setHovered] = useState(false)
382
+
383
+ // The set of keys this instance animates is a *monotonically growing*
384
+ // union, recomputed every render and expanded when a render introduces a
385
+ // key not seen before. It never shrinks. Two requirements meet here:
386
+ //
387
+ // 1. Variants and gesture sub-states contribute the union across *all*
388
+ // their branches up front — a key touched by any variant must be
389
+ // active so the worklet picks it up when the controller transitions
390
+ // to a branch the base `animate` never mentions.
391
+ // 2. A literal `animate` object is reactive: a parent that changes
392
+ // `animate={{ opacity: 1 }}` to `animate={{ opacity: 1, scale: 2 }}`
393
+ // after mount must get `scale` animating. Freezing the set at first
394
+ // render silently dropped the new key (its SV updated, but the
395
+ // worklet — which iterates this set — never read it).
396
+ //
397
+ // Growing-only keeps the worklet stable: the `activeKeysRef.current` array
398
+ // identity only changes on the renders that actually add a key, so the
399
+ // `useAnimatedStyle` worklet (which reads `.current` each frame) sees the
400
+ // expansion without churning frame-to-frame.
401
+ const touched = new Set<AnimatableKey>()
402
+ collectTouchedKeys(touched, animateRecord)
403
+ if (initialRecord) collectTouchedKeys(touched, initialRecord)
404
+ if (variants) {
405
+ for (const variant of Object.values(variants) as object[]) {
406
+ if (!variant) continue
407
+ collectTouchedKeys(touched, variant as Record<string, unknown>)
408
+ }
369
409
  }
370
- }
371
- if (exitRecord) collectTouchedKeys(touched, exitRecord)
372
-
373
- const activeKeysRef = useRef<readonly AnimatableKey[] | null>(null)
374
- const hasTransformRef = useRef<boolean>(false)
375
- const hasShadowOffsetRef = useRef<boolean>(false)
376
- // Expand the active set only when this render touched a key we haven't
377
- // recorded yet. When nothing new appears we keep the existing array
378
- // identity so the worklet's captured ref doesn't see a fresh value.
379
- const prevActive = activeKeysRef.current
380
- let grew = prevActive === null
381
- if (!grew && prevActive) {
382
- for (const k of touched) {
383
- if (!prevActive.includes(k)) {
384
- grew = true
385
- break
410
+ if (gesture) {
411
+ for (const subState of [
412
+ gesture.pressed,
413
+ gesture.focused,
414
+ gesture.focusVisible,
415
+ gesture.hovered,
416
+ ] as Array<object | undefined>) {
417
+ if (!subState) continue
418
+ collectTouchedKeys(touched, subState as Record<string, unknown>)
386
419
  }
387
420
  }
388
- }
389
- if (grew) {
390
- const merged = new Set<AnimatableKey>(prevActive ?? [])
391
- for (const k of touched) merged.add(k)
392
- activeKeysRef.current = ALL_KEYS.filter((k) => merged.has(k))
393
- hasTransformRef.current = activeKeysRef.current.some((k) =>
394
- TRANSFORM_KEY_SET.has(k),
395
- )
396
- hasShadowOffsetRef.current = activeKeysRef.current.some((k) =>
397
- SHADOW_OFFSET_KEY_SET.has(k),
398
- )
399
- }
421
+ if (exitRecord) collectTouchedKeys(touched, exitRecord)
422
+
423
+ const activeKeysRef = useRef<readonly AnimatableKey[] | null>(null)
424
+ const hasTransformRef = useRef<boolean>(false)
425
+ const hasShadowOffsetRef = useRef<boolean>(false)
426
+ // Expand the active set only when this render touched a key we haven't
427
+ // recorded yet. When nothing new appears we keep the existing array
428
+ // identity so the worklet's captured ref doesn't see a fresh value.
429
+ const prevActive = activeKeysRef.current
430
+ let grew = prevActive === null
431
+ if (!grew && prevActive) {
432
+ for (const k of touched) {
433
+ if (!prevActive.includes(k)) {
434
+ grew = true
435
+ break
436
+ }
437
+ }
438
+ }
439
+ if (grew) {
440
+ const merged = new Set<AnimatableKey>(prevActive ?? [])
441
+ for (const k of touched) merged.add(k)
442
+ activeKeysRef.current = ALL_KEYS.filter((k) => merged.has(k))
443
+ hasTransformRef.current = activeKeysRef.current.some((k) =>
444
+ TRANSFORM_KEY_SET.has(k),
445
+ )
446
+ hasShadowOffsetRef.current = activeKeysRef.current.some((k) =>
447
+ SHADOW_OFFSET_KEY_SET.has(k),
448
+ )
449
+ }
400
450
 
401
- const sharedValues = useAnimatableSharedValues((key) => {
402
- // Shadow offset synthetics seed from the corresponding axis on the
403
- // `shadowOffset: { width, height }` source the consumer doesn't write
404
- // `shadowOffsetWidth` / `shadowOffsetHeight` directly. Fall back to the
405
- // generic resting default when neither initial nor animate touched it.
406
- if (SHADOW_OFFSET_KEY_SET.has(key)) {
407
- const axis = shadowOffsetAxisFor(key as ShadowOffsetKey)
408
- if (initial === false) {
451
+ // Which keys a record actually drives *right now*. Everything else in
452
+ // the active set is there only because a gesture sub-state, an `exit`
453
+ // target, or a variant branch that isn't current mentions it. Those must
454
+ // rest at whatever the static `style` says the animated style merges
455
+ // AFTER `style`, so resting them at `DEFAULT_RESTING` silently stomps it
456
+ // (`borderColor` → 'transparent', `width` → 0, and so on for every key
457
+ // whose default isn't an identity). Transforms and `opacity` hid this
458
+ // bug for a long time: their defaults happen to be no-ops.
459
+ const drivenNow = new Set<AnimatableKey>()
460
+ collectTouchedKeys(drivenNow, animateRecord)
461
+ if (initialRecord) collectTouchedKeys(drivenNow, initialRecord)
462
+ if (isExiting && exitRecord) collectTouchedKeys(drivenNow, exitRecord)
463
+
464
+ // Monotonic, like the active set. Once a record has driven a key we stop
465
+ // syncing it to the style, so transitioning to a variant that doesn't
466
+ // mention the key leaves it where the previous variant put it — the
467
+ // existing semantic — instead of snapping back to the style value.
468
+ const everDrivenRef = useRef<Set<AnimatableKey>>(new Set())
469
+ for (const k of drivenNow) everDrivenRef.current.add(k)
470
+
471
+ // Resting values pulled off the static `style`, for active keys nothing
472
+ // has driven. Gated so the common `animate`-only instance never flattens
473
+ // a style it has no use for.
474
+ const activeKeys = activeKeysRef.current!
475
+ const everDriven = everDrivenRef.current
476
+ const styleResting: Partial<Record<AnimatableKey, number | string>> = {}
477
+ if (activeKeys.some((k) => !everDriven.has(k))) {
478
+ const flat = StyleSheet.flatten(style as never) as
479
+ | Record<string, unknown>
480
+ | undefined
481
+ if (flat) {
482
+ for (const key of activeKeys) {
483
+ if (everDriven.has(key)) continue
484
+ const v = styleValueFor(flat, key)
485
+ if (v !== undefined) styleResting[key] = v
486
+ }
487
+ }
488
+ }
489
+
490
+ const sharedValues = useAnimatableSharedValues((key) => {
491
+ // Shadow offset synthetics seed from the corresponding axis on the
492
+ // `shadowOffset: { width, height }` source — the consumer doesn't write
493
+ // `shadowOffsetWidth` / `shadowOffsetHeight` directly. Fall back to the
494
+ // static style, then the generic resting default, when neither initial
495
+ // nor animate touched it.
496
+ if (SHADOW_OFFSET_KEY_SET.has(key)) {
497
+ const axis = shadowOffsetAxisFor(key as ShadowOffsetKey)
498
+ if (initial === false) {
499
+ return (
500
+ shadowOffsetAxisValue(animateRecord.shadowOffset, axis) ??
501
+ styleResting[key] ??
502
+ DEFAULT_RESTING[key]
503
+ )
504
+ }
409
505
  return (
506
+ shadowOffsetAxisValue(
507
+ initialRecord?.shadowOffset as
508
+ | { width?: number; height?: number }
509
+ | undefined,
510
+ axis,
511
+ ) ??
410
512
  shadowOffsetAxisValue(animateRecord.shadowOffset, axis) ??
513
+ styleResting[key] ??
411
514
  DEFAULT_RESTING[key]
412
515
  )
413
516
  }
517
+ if (initial === false) {
518
+ const a = animateRecord[key]
519
+ return restValue(a) ?? styleResting[key] ?? DEFAULT_RESTING[key]
520
+ }
414
521
  return (
415
- shadowOffsetAxisValue(
416
- initialRecord?.shadowOffset as
417
- | { width?: number; height?: number }
418
- | undefined,
419
- axis,
420
- ) ??
421
- shadowOffsetAxisValue(animateRecord.shadowOffset, axis) ??
522
+ initialRecord?.[key] ??
523
+ restValue(animateRecord[key]) ??
524
+ styleResting[key] ??
422
525
  DEFAULT_RESTING[key]
423
526
  )
424
- }
425
- if (initial === false) {
426
- const a = animateRecord[key]
427
- return restValue(a) ?? DEFAULT_RESTING[key]
428
- }
429
- return (
430
- initialRecord?.[key] ??
431
- restValue(animateRecord[key]) ??
432
- DEFAULT_RESTING[key]
433
- )
434
- })
435
-
436
- // One progress SV per gesture layer, allocated unconditionally for hook
437
- // stability. Each layer's progress animates 0↔1 with its own transition
438
- // when its activation flips; the worklet reads them when compositing.
439
- // Initial value is 0 — even if a sub-state is somehow active on mount,
440
- // the activation effect below will animate it to 1 on the next tick.
441
- const pressedProgress = useSharedValue(0)
442
- const focusedProgress = useSharedValue(0)
443
- const focusVisibleProgress = useSharedValue(0)
444
- const hoveredProgress = useSharedValue(0)
445
-
446
- // Mirror gesture targets into a UI-runtime-resident shared value so the
447
- // animated-style worklet can read the latest layer values without having
448
- // to capture `gesture` directly (which would re-register the worklet on
449
- // every render where the consumer passes a fresh literal). The signature
450
- // dependency means we only push to the SV when targets actually change —
451
- // the SV ref itself is stable across renders.
452
- //
453
- // The resolved value is a layer-keyed map of primitive endpoints (numbers
454
- // or color strings); sequence/`{ to }` step shapes on a sub-state collapse
455
- // to their final endpoint via `targetEndValue` because a gesture layer
456
- // describes a steady target, not a keyframe sequence.
457
- const gestureSV = useSharedValue<ResolvedGestureLayers | null>(
458
- resolveGestureLayers(gesture),
459
- )
460
- const gestureTargetsSig = stableSig(gesture)
461
- useEffect(() => {
462
- gestureSV.value = resolveGestureLayers(gesture)
463
- // eslint-disable-next-line react-hooks/exhaustive-deps
464
- }, [gestureTargetsSig])
465
-
466
- // The base record drives the per-key shared values. Gesture sub-state
467
- // targets are intentionally NOT merged here — they layer on top in the
468
- // worklet. Exit values still take precedence over `animate` while exiting
469
- // because the base SV is what <Presence> waits on to settle.
470
- const baseRecord =
471
- isExiting && exitRecord
472
- ? { ...animateRecord, ...exitRecord }
473
- : animateRecord
474
- const baseSig =
475
- stableSig(baseRecord) +
476
- (isExiting ? '|exit' : '') +
477
- (shouldReduceMotion ? '|rm' : '')
478
- const transitionSig = stableSig(transition)
479
-
480
- // Stable ref to the live `safeToRemove` so the effect's settle-counter
481
- // closure can reach the latest <Presence> binding without retriggering.
482
- const safeToRemoveRef = useRef<(() => void) | undefined>(undefined)
483
- safeToRemoveRef.current = presence?.safeToRemove
527
+ })
484
528
 
485
- useEffect(() => {
486
- // Exit fast-path: nothing to animate (or no exit prop), tell <Presence>
487
- // immediately so the unmount isn't gated on a phantom animation.
488
- if (isExiting && (!exitRecord || Object.keys(exitRecord).length === 0)) {
489
- safeToRemoveRef.current?.()
490
- return
491
- }
529
+ // Keep never-driven keys tracking the *live* static style. The seed above
530
+ // only runs at mount, but the animated style emits these keys on every
531
+ // frame so without this a `style` that changes afterwards (a theme
532
+ // swap, a conditional colour) would stay invisible for any key some
533
+ // gesture / exit / variant branch happens to mention. Direct assignment,
534
+ // not an animation: this is a static value, not a target.
535
+ const styleRestingSig = stableSig(styleResting)
536
+ useEffect(() => {
537
+ for (const key of activeKeysRef.current!) {
538
+ if (everDrivenRef.current.has(key)) continue
539
+ sharedValues[key].value = (styleResting[key] ??
540
+ DEFAULT_RESTING[key]) as never
541
+ }
542
+ // `styleResting` is rebuilt each render; its signature is the real dep.
543
+ // eslint-disable-next-line react-hooks/exhaustive-deps
544
+ }, [styleRestingSig])
545
+
546
+ // One progress SV per gesture layer, allocated unconditionally for hook
547
+ // stability. Each layer's progress animates 0↔1 with its own transition
548
+ // when its activation flips; the worklet reads them when compositing.
549
+ // Initial value is 0 — even if a sub-state is somehow active on mount,
550
+ // the activation effect below will animate it to 1 on the next tick.
551
+ const pressedProgress = useSharedValue(0)
552
+ const focusedProgress = useSharedValue(0)
553
+ const focusVisibleProgress = useSharedValue(0)
554
+ const hoveredProgress = useSharedValue(0)
555
+
556
+ // Cancel any in-flight gesture-layer springs on unmount, matching the
557
+ // per-key guard in `useAnimatableSharedValues`. Each SV is identity-stable.
558
+ useEffect(
559
+ () => () => {
560
+ cancelAnimation(pressedProgress)
561
+ cancelAnimation(focusedProgress)
562
+ cancelAnimation(focusVisibleProgress)
563
+ cancelAnimation(hoveredProgress)
564
+ },
565
+ // The progress SVs are identity-stable per hook instance.
566
+ // eslint-disable-next-line react-hooks/exhaustive-deps
567
+ [],
568
+ )
492
569
 
493
- let pending = 0
494
- let done = false
495
- const onSettle = () => {
496
- if (done) return
497
- pending--
498
- if (pending <= 0) {
499
- done = true
500
- if (isExiting) safeToRemoveRef.current?.()
570
+ // Mirror gesture targets into a UI-runtime-resident shared value so the
571
+ // animated-style worklet can read the latest layer values without having
572
+ // to capture `gesture` directly (which would re-register the worklet on
573
+ // every render where the consumer passes a fresh literal). The signature
574
+ // dependency means we only push to the SV when targets actually change —
575
+ // the SV ref itself is stable across renders.
576
+ //
577
+ // The resolved value is a layer-keyed map of primitive endpoints (numbers
578
+ // or color strings); sequence/`{ to }` step shapes on a sub-state collapse
579
+ // to their final endpoint via `targetEndValue` because a gesture layer
580
+ // describes a steady target, not a keyframe sequence.
581
+ const gestureSV = useSharedValue<ResolvedGestureLayers | null>(
582
+ resolveGestureLayers(gesture),
583
+ )
584
+ const gestureTargetsSig = stableSig(gesture)
585
+ useEffect(() => {
586
+ gestureSV.value = resolveGestureLayers(gesture)
587
+ // eslint-disable-next-line react-hooks/exhaustive-deps
588
+ }, [gestureTargetsSig])
589
+
590
+ // The base record drives the per-key shared values. Gesture sub-state
591
+ // targets are intentionally NOT merged here — they layer on top in the
592
+ // worklet. Exit values still take precedence over `animate` while exiting
593
+ // because the base SV is what <Presence> waits on to settle.
594
+ const baseRecord =
595
+ isExiting && exitRecord
596
+ ? { ...animateRecord, ...exitRecord }
597
+ : animateRecord
598
+ const baseSig =
599
+ stableSig(baseRecord) +
600
+ (isExiting ? '|exit' : '') +
601
+ (shouldReduceMotion ? '|rm' : '')
602
+ const transitionSig = stableSig(transition)
603
+
604
+ // Stable ref to the live `safeToRemove` so the effect's settle-counter
605
+ // closure can reach the latest <Presence> binding without retriggering.
606
+ const safeToRemoveRef = useRef<(() => void) | undefined>(undefined)
607
+ safeToRemoveRef.current = presence?.safeToRemove
608
+
609
+ useEffect(() => {
610
+ // Exit fast-path: nothing to animate (or no exit prop), tell <Presence>
611
+ // immediately so the unmount isn't gated on a phantom animation.
612
+ if (
613
+ isExiting &&
614
+ (!exitRecord || Object.keys(exitRecord).length === 0)
615
+ ) {
616
+ safeToRemoveRef.current?.()
617
+ return
501
618
  }
502
- }
503
619
 
504
- // Count transform axes participating in this effect run so the factory
505
- // can coalesce their terminal callbacks into a single transform-group
506
- // event. `undefined` when no transform axis is animating, which lets
507
- // the factory skip the coalescing branch entirely.
508
- let transformPending = 0
509
- for (const k of ALL_KEYS) {
510
- if (TRANSFORM_KEY_SET.has(k) && baseRecord[k] !== undefined) {
511
- transformPending++
620
+ let pending = 0
621
+ let done = false
622
+ const onSettle = () => {
623
+ if (done) return
624
+ pending--
625
+ if (pending <= 0) {
626
+ done = true
627
+ if (isExiting) safeToRemoveRef.current?.()
628
+ }
512
629
  }
513
- }
514
- const transformGroup: TransformGroup | undefined =
515
- transformPending > 0 ? { remaining: transformPending } : undefined
516
-
517
- for (const key of ALL_KEYS) {
518
- // Shadow offset synthetics read their target from the nested
519
- // `shadowOffset: { width, height }` source on `baseRecord` the
520
- // animate / exit record never has `shadowOffsetWidth` etc. on it
521
- // directly. The synthetic transition follows the same `shadowOffset`
522
- // top-level transition entry (no per-axis split).
523
- const target: AnimatableValue<number | string> | undefined =
524
- SHADOW_OFFSET_KEY_SET.has(key)
525
- ? shadowOffsetAxisValue(
526
- baseRecord.shadowOffset,
527
- shadowOffsetAxisFor(key as ShadowOffsetKey),
528
- )
529
- : baseRecord[key]
530
- if (target === undefined) continue
531
- // Reduced-motion overrides every per-key transition (and any nested
532
- // sequence-step transition) with `no-animation`, which the resolver
533
- // turns into a direct value assignment. Sequences still iterate but
534
- // each step settles instantly, which matches the "snap to final
630
+
631
+ // Per-key transition resolution, hoisted out of the loop below so the
632
+ // transform-group count can ask about a key's config before the loop
633
+ // reaches it. Reduced motion overrides every per-key transition (and
634
+ // any nested sequence-step transition) with `no-animation`, which the
635
+ // resolver turns into a direct value assignment. Sequences still
636
+ // iterate but each step settles instantly, matching the "snap to final
535
637
  // state" expectation.
536
- const cfg = shouldReduceMotion
537
- ? ({ type: 'no-animation' } as const)
538
- : transitionFor(
539
- SHADOW_OFFSET_KEY_SET.has(key)
540
- ? ('shadowOffset' as keyof typeof baseRecord)
541
- : key,
542
- transition,
543
- )
544
- if (isExiting) pending++
545
- const factory = makeKeyCallbackFactory(
546
- key,
547
- sharedValues[key],
548
- targetEndValue(target),
549
- onAnimationEndRef,
550
- {
551
- stepCount: stepCountOf(target),
552
- totalIterations: totalIterationsOf(cfg),
553
- },
554
- isExiting ? onSettle : undefined,
555
- TRANSFORM_KEY_SET.has(key) ? transformGroup : undefined,
556
- )
557
- sharedValues[key].value = resolveAnimatableValue(
558
- target,
559
- cfg,
560
- factory,
561
- ) as never
562
- }
638
+ const configFor = (key: AnimatableKey): TransitionConfig | undefined =>
639
+ shouldReduceMotion
640
+ ? ({ type: 'no-animation' } as const)
641
+ : transitionFor(
642
+ SHADOW_OFFSET_KEY_SET.has(key)
643
+ ? ('shadowOffset' as keyof typeof baseRecord)
644
+ : key,
645
+ transition,
646
+ )
563
647
 
564
- // No exit-targeted keys (only `animate` keys present, no `exit`)
565
- // release immediately rather than wait for animations that aren't
566
- // headed toward an exit value.
567
- if (isExiting && pending === 0) {
568
- safeToRemoveRef.current?.()
569
- }
570
- // eslint-disable-next-line react-hooks/exhaustive-deps
571
- }, [baseSig, transitionSig])
572
-
573
- // Per-layer progress: when a sub-state activation flips, animate its
574
- // progress SV 0↔1 with the layer's own transition (or the parent
575
- // transition / library default, in priority order). On exit we snap every
576
- // layer to 0 instantly so the unmount-bound base SV isn't fighting a
577
- // stale layer contribution mid-fade.
578
- //
579
- // The `declared` flag short-circuits the effect when the consumer hasn't
580
- // wired the corresponding sub-state — so a Motion primitive without a
581
- // `gesture` prop (or with only some sub-states declared) makes zero extra
582
- // `withSpring` / `withTiming` calls on mount.
583
- useGestureLayerProgress(
584
- pressedProgress,
585
- pressed,
586
- gesture?.pressed != null,
587
- 'pressed',
588
- transition,
589
- isExiting,
590
- shouldReduceMotion,
591
- )
592
- useGestureLayerProgress(
593
- focusedProgress,
594
- focused,
595
- gesture?.focused != null,
596
- 'focused',
597
- transition,
598
- isExiting,
599
- shouldReduceMotion,
600
- )
601
- useGestureLayerProgress(
602
- focusVisibleProgress,
603
- focusVisible,
604
- gesture?.focusVisible != null,
605
- 'focusVisible',
606
- transition,
607
- isExiting,
608
- shouldReduceMotion,
609
- )
610
- useGestureLayerProgress(
611
- hoveredProgress,
612
- hovered,
613
- gesture?.hovered != null,
614
- 'hovered',
615
- transition,
616
- isExiting,
617
- shouldReduceMotion,
618
- )
648
+ // Count transform axes participating in this effect run so the factory
649
+ // can coalesce their terminal callbacks into a single transform-group
650
+ // event. `undefined` when no transform axis is animating, which lets
651
+ // the factory skip the coalescing branch entirely.
652
+ //
653
+ // Endless axes are excluded. A `repeat: 'infinite'` axis never reaches
654
+ // the terminal `'animation'` phase, so counting it would pin
655
+ // `remaining` permanently above zero — a sibling axis with a finite
656
+ // transition would settle, decrement, and have its completion silently
657
+ // swallowed. An axis that never finishes simply isn't part of the group
658
+ // that does.
659
+ let transformPending = 0
660
+ for (const k of ALL_KEYS) {
661
+ if (!TRANSFORM_KEY_SET.has(k) || baseRecord[k] === undefined) continue
662
+ if (!reachesTerminalPhase(configFor(k))) continue
663
+ transformPending++
664
+ }
665
+ const transformGroup: TransformGroup | undefined =
666
+ transformPending > 0 ? { remaining: transformPending } : undefined
667
+
668
+ for (const key of ALL_KEYS) {
669
+ // Shadow offset synthetics read their target from the nested
670
+ // `shadowOffset: { width, height }` source on `baseRecord` — the
671
+ // animate / exit record never has `shadowOffsetWidth` etc. on it
672
+ // directly. The synthetic transition follows the same `shadowOffset`
673
+ // top-level transition entry (no per-axis split).
674
+ const target: AnimatableValue<number | string> | undefined =
675
+ SHADOW_OFFSET_KEY_SET.has(key)
676
+ ? shadowOffsetAxisValue(
677
+ baseRecord.shadowOffset,
678
+ shadowOffsetAxisFor(key as ShadowOffsetKey),
679
+ )
680
+ : baseRecord[key]
681
+ if (target === undefined) continue
682
+ const cfg = configFor(key)
683
+ // <Presence> waits on this counter before unmounting, so only count
684
+ // keys that can actually settle. An endless exit animation (a
685
+ // `repeat: 'infinite'` transition inherited by `exit`, e.g. a pulsing
686
+ // element inside <Presence>) would otherwise never call
687
+ // `safeToRemove` and the child would stay mounted forever. Finite
688
+ // keys still gate the unmount; if every exit key is endless, the
689
+ // post-loop `pending === 0` release fires immediately.
690
+ if (isExiting && reachesTerminalPhase(cfg)) pending++
691
+ const factory = makeKeyCallbackFactory(
692
+ key,
693
+ sharedValues[key],
694
+ targetEndValue(target),
695
+ onAnimationEndRef,
696
+ {
697
+ stepCount: stepCountOf(target),
698
+ totalIterations: totalIterationsOf(cfg),
699
+ },
700
+ isExiting ? onSettle : undefined,
701
+ TRANSFORM_KEY_SET.has(key) ? transformGroup : undefined,
702
+ )
703
+ sharedValues[key].value = resolveAnimatableValue(
704
+ target,
705
+ cfg,
706
+ factory,
707
+ ) as never
708
+ }
619
709
 
620
- // Shared-element transition wiring. `useSharedLayout` allocates FLIP
621
- // shared values (identity at rest), measures via the merged `onLayout`,
622
- // and on first-mount snaps the FLIP transform to a source rect popped
623
- // from the registry. The worklet below appends those entries to the
624
- // transform array so they compose with the user's animate transforms —
625
- // multiple `translateX` entries sum, multiple `scaleX` entries multiply,
626
- // which is exactly the FLIP semantic.
627
- const sharedLayout = useSharedLayout({
628
- layoutId,
629
- userRef: ref,
630
- transition: isTopLevelTransition(transition) ? transition : undefined,
631
- shouldReduceMotion,
632
- userOnLayout,
633
- })
634
- const flip = sharedLayout.flip
635
- const hasLayoutId = layoutId !== undefined
636
-
637
- const animatedStyle = useAnimatedStyle(() => {
638
- const activeKeys = activeKeysRef.current!
639
- const hasTransform = hasTransformRef.current
640
- const hasShadowOffset = hasShadowOffsetRef.current
641
- const out: Record<string, unknown> = {}
642
- const transform: Array<Record<string, unknown>> = []
643
- // shadow-offset reassembly buffers. The two synthetic axis SVs feed in
644
- // here and the recomposed `{ width, height }` object lands on `out`
645
- // after the loop so RN gets a single `shadowOffset` style prop.
646
- let shadowOffsetW = 0
647
- let shadowOffsetH = 0
648
-
649
- // Read each progress SV exactly once so the chain below sees a coherent
650
- // snapshot for this frame. Reading them on the UI thread is cheap.
651
- const ph = hoveredProgress.value
652
- const pf = focusedProgress.value
653
- const pfv = focusVisibleProgress.value
654
- const pp = pressedProgress.value
655
-
656
- const layers = gestureSV.value
657
- // Locals are suffixed `Layer` so they don't shadow the outer `pressed` /
658
- // `focused` / `focusVisible` / `hovered` JS-state booleans — Reanimated's
659
- // worklet closure tracker would otherwise pick those up as captured
660
- // dependencies and re-register the worklet on every activation flip.
661
- const hoveredLayer = layers ? layers.hovered : null
662
- const focusedLayer = layers ? layers.focused : null
663
- const focusVisibleLayer = layers ? layers.focusVisible : null
664
- const pressedLayer = layers ? layers.pressed : null
665
-
666
- for (const key of activeKeys) {
667
- let v = sharedValues[key].value
668
- const isColor = COLOR_KEY_SET.has(key)
669
-
670
- // Composite gesture layers in priority order (lowest first). Each
671
- // active layer pulls the value toward its pre-resolved primitive
672
- // endpoint by `progress`; numeric keys lerp, color keys go through
673
- // Reanimated's RGBA `interpolateColor`. We skip layers with progress
674
- // 0 to avoid an `interpolateColor(0, ...)` call that would parse the
675
- // target color string for no visible effect.
676
- if (hoveredLayer && ph > 0 && hoveredLayer[key] !== undefined) {
677
- const t = hoveredLayer[key]
678
- v = isColor
679
- ? interpolateColor(ph, [0, 1], [v as string, t as string])
680
- : (v as number) + ((t as number) - (v as number)) * ph
710
+ // No exit-targeted keys (only `animate` keys present, no `exit`)
711
+ // release immediately rather than wait for animations that aren't
712
+ // headed toward an exit value.
713
+ if (isExiting && pending === 0) {
714
+ safeToRemoveRef.current?.()
681
715
  }
682
- if (focusedLayer && pf > 0 && focusedLayer[key] !== undefined) {
683
- const t = focusedLayer[key]
684
- v = isColor
685
- ? interpolateColor(pf, [0, 1], [v as string, t as string])
686
- : (v as number) + ((t as number) - (v as number)) * pf
716
+ // eslint-disable-next-line react-hooks/exhaustive-deps
717
+ }, [baseSig, transitionSig])
718
+
719
+ // Per-layer progress: when a sub-state activation flips, animate its
720
+ // progress SV 0↔1 with the layer's own transition (or the parent
721
+ // transition / library default, in priority order). On exit we snap every
722
+ // layer to 0 instantly so the unmount-bound base SV isn't fighting a
723
+ // stale layer contribution mid-fade.
724
+ //
725
+ // The `declared` flag short-circuits the effect when the consumer hasn't
726
+ // wired the corresponding sub-state — so a Motion primitive without a
727
+ // `gesture` prop (or with only some sub-states declared) makes zero extra
728
+ // `withSpring` / `withTiming` calls on mount.
729
+ useGestureLayerProgress(
730
+ pressedProgress,
731
+ pressed,
732
+ gesture?.pressed != null,
733
+ 'pressed',
734
+ transition,
735
+ isExiting,
736
+ shouldReduceMotion,
737
+ )
738
+ useGestureLayerProgress(
739
+ focusedProgress,
740
+ focused,
741
+ gesture?.focused != null,
742
+ 'focused',
743
+ transition,
744
+ isExiting,
745
+ shouldReduceMotion,
746
+ )
747
+ useGestureLayerProgress(
748
+ focusVisibleProgress,
749
+ focusVisible,
750
+ gesture?.focusVisible != null,
751
+ 'focusVisible',
752
+ transition,
753
+ isExiting,
754
+ shouldReduceMotion,
755
+ )
756
+ useGestureLayerProgress(
757
+ hoveredProgress,
758
+ hovered,
759
+ gesture?.hovered != null,
760
+ 'hovered',
761
+ transition,
762
+ isExiting,
763
+ shouldReduceMotion,
764
+ )
765
+
766
+ // Shared-element transition wiring. `useSharedLayout` allocates FLIP
767
+ // shared values (identity at rest), measures via the merged `onLayout`,
768
+ // and on first-mount snaps the FLIP transform to a source rect popped
769
+ // from the registry. The worklet below appends those entries to the
770
+ // transform array so they compose with the user's animate transforms —
771
+ // multiple `translateX` entries sum, multiple `scaleX` entries multiply,
772
+ // which is exactly the FLIP semantic.
773
+ const sharedLayout = useSharedLayout({
774
+ layoutId,
775
+ userRef: ref,
776
+ transition: isTopLevelTransition(transition) ? transition : undefined,
777
+ shouldReduceMotion,
778
+ userOnLayout,
779
+ })
780
+ const flip = sharedLayout.flip
781
+ const hasLayoutId = layoutId !== undefined
782
+
783
+ const animatedStyle = useAnimatedStyle(() => {
784
+ const activeKeys = activeKeysRef.current!
785
+ const hasTransform = hasTransformRef.current
786
+ const hasShadowOffset = hasShadowOffsetRef.current
787
+ const out: Record<string, unknown> = {}
788
+ const transform: Array<Record<string, unknown>> = []
789
+ // shadow-offset reassembly buffers. The two synthetic axis SVs feed in
790
+ // here and the recomposed `{ width, height }` object lands on `out`
791
+ // after the loop so RN gets a single `shadowOffset` style prop.
792
+ let shadowOffsetW = 0
793
+ let shadowOffsetH = 0
794
+
795
+ // Read each progress SV exactly once so the chain below sees a coherent
796
+ // snapshot for this frame. Reading them on the UI thread is cheap.
797
+ const ph = hoveredProgress.value
798
+ const pf = focusedProgress.value
799
+ const pfv = focusVisibleProgress.value
800
+ const pp = pressedProgress.value
801
+
802
+ const layers = gestureSV.value
803
+ // Locals are suffixed `Layer` so they don't shadow the outer `pressed` /
804
+ // `focused` / `focusVisible` / `hovered` JS-state booleans — Reanimated's
805
+ // worklet closure tracker would otherwise pick those up as captured
806
+ // dependencies and re-register the worklet on every activation flip.
807
+ const hoveredLayer = layers ? layers.hovered : null
808
+ const focusedLayer = layers ? layers.focused : null
809
+ const focusVisibleLayer = layers ? layers.focusVisible : null
810
+ const pressedLayer = layers ? layers.pressed : null
811
+
812
+ for (const key of activeKeys) {
813
+ let v = sharedValues[key].value
814
+ const isColor = COLOR_KEY_SET.has(key)
815
+
816
+ // Composite gesture layers in priority order (lowest first). Each
817
+ // active layer pulls the value toward its pre-resolved primitive
818
+ // endpoint by `progress`; numeric keys lerp, color keys go through
819
+ // Reanimated's RGBA `interpolateColor`. We skip layers with progress
820
+ // 0 to avoid an `interpolateColor(0, ...)` call that would parse the
821
+ // target color string for no visible effect.
822
+ if (hoveredLayer && ph > 0 && hoveredLayer[key] !== undefined) {
823
+ const t = hoveredLayer[key]
824
+ v = isColor
825
+ ? interpolateColor(ph, [0, 1], [v as string, t as string])
826
+ : (v as number) + ((t as number) - (v as number)) * ph
827
+ }
828
+ if (focusedLayer && pf > 0 && focusedLayer[key] !== undefined) {
829
+ const t = focusedLayer[key]
830
+ v = isColor
831
+ ? interpolateColor(pf, [0, 1], [v as string, t as string])
832
+ : (v as number) + ((t as number) - (v as number)) * pf
833
+ }
834
+ if (
835
+ focusVisibleLayer &&
836
+ pfv > 0 &&
837
+ focusVisibleLayer[key] !== undefined
838
+ ) {
839
+ const t = focusVisibleLayer[key]
840
+ v = isColor
841
+ ? interpolateColor(pfv, [0, 1], [v as string, t as string])
842
+ : (v as number) + ((t as number) - (v as number)) * pfv
843
+ }
844
+ if (pressedLayer && pp > 0 && pressedLayer[key] !== undefined) {
845
+ const t = pressedLayer[key]
846
+ v = isColor
847
+ ? interpolateColor(pp, [0, 1], [v as string, t as string])
848
+ : (v as number) + ((t as number) - (v as number)) * pp
849
+ }
850
+
851
+ if (TRANSFORM_KEY_SET.has(key)) {
852
+ transform.push(
853
+ ROTATION_KEYS.has(key) ? { [key]: `${v}deg` } : { [key]: v },
854
+ )
855
+ } else if (key === 'shadowOffsetWidth') {
856
+ shadowOffsetW = v as number
857
+ } else if (key === 'shadowOffsetHeight') {
858
+ shadowOffsetH = v as number
859
+ } else {
860
+ out[key] = v
861
+ }
687
862
  }
688
- if (
689
- focusVisibleLayer &&
690
- pfv > 0 &&
691
- focusVisibleLayer[key] !== undefined
692
- ) {
693
- const t = focusVisibleLayer[key]
694
- v = isColor
695
- ? interpolateColor(pfv, [0, 1], [v as string, t as string])
696
- : (v as number) + ((t as number) - (v as number)) * pfv
863
+ // Shared-element FLIP transforms append after the user's transform
864
+ // entries so they compose multiplicatively in the same `transform`
865
+ // array separate style entries with `transform` keys would
866
+ // last-write-wins, which is what we explicitly avoid here. At rest
867
+ // (dx, dy, sx, sy) = (0, 0, 1, 1) so the contribution is a no-op
868
+ // when no shared-element transition is active.
869
+ if (hasLayoutId) {
870
+ transform.push({ translateX: flip.dx.value })
871
+ transform.push({ translateY: flip.dy.value })
872
+ transform.push({ scaleX: flip.sx.value })
873
+ transform.push({ scaleY: flip.sy.value })
697
874
  }
698
- if (pressedLayer && pp > 0 && pressedLayer[key] !== undefined) {
699
- const t = pressedLayer[key]
700
- v = isColor
701
- ? interpolateColor(pp, [0, 1], [v as string, t as string])
702
- : (v as number) + ((t as number) - (v as number)) * pp
875
+ if (hasTransform || hasLayoutId) out.transform = transform
876
+ if (hasShadowOffset) {
877
+ out.shadowOffset = { width: shadowOffsetW, height: shadowOffsetH }
703
878
  }
879
+ return out
880
+ })
704
881
 
705
- if (TRANSFORM_KEY_SET.has(key)) {
706
- transform.push(
707
- ROTATION_KEYS.has(key) ? { [key]: `${v}deg` } : { [key]: v },
708
- )
709
- } else if (key === 'shadowOffsetWidth') {
710
- shadowOffsetW = v as number
711
- } else if (key === 'shadowOffsetHeight') {
712
- shadowOffsetH = v as number
713
- } else {
714
- out[key] = v
715
- }
716
- }
717
- // Shared-element FLIP transforms append after the user's transform
718
- // entries so they compose multiplicatively in the same `transform`
719
- // array — separate style entries with `transform` keys would
720
- // last-write-wins, which is what we explicitly avoid here. At rest
721
- // (dx, dy, sx, sy) = (0, 0, 1, 1) so the contribution is a no-op
722
- // when no shared-element transition is active.
723
- if (hasLayoutId) {
724
- transform.push({ translateX: flip.dx.value })
725
- transform.push({ translateY: flip.dy.value })
726
- transform.push({ scaleX: flip.sx.value })
727
- transform.push({ scaleY: flip.sy.value })
728
- }
729
- if (hasTransform || hasLayoutId) out.transform = transform
730
- if (hasShadowOffset) {
731
- out.shadowOffset = { width: shadowOffsetW, height: shadowOffsetH }
732
- }
733
- return out
734
- })
735
-
736
- // Exiting children are tap-deaf: the next press should fall through to
737
- // whatever is underneath, not re-trigger a soon-to-unmount node. This is
738
- // the moti #297 fix and a v0.1 acceptance criterion. RN 0.71+ deprecates
739
- // `pointerEvents` as a prop in favor of the style key, so we merge it
740
- // alongside the animated style instead of spreading as a prop.
741
- const mergedStyle = useMemo(
742
- () =>
743
- (isExiting
744
- ? [style, animatedStyle, EXITING_POINTER_EVENTS_STYLE]
745
- : [style, animatedStyle]) as unknown,
746
- [style, animatedStyle, isExiting],
747
- )
882
+ // Exiting children are tap-deaf: the next press should fall through to
883
+ // whatever is underneath, not re-trigger a soon-to-unmount node. This is
884
+ // the moti #297 fix and a v0.1 acceptance criterion. RN 0.71+ deprecates
885
+ // `pointerEvents` as a prop in favor of the style key, so we merge it
886
+ // alongside the animated style instead of spreading as a prop.
887
+ const mergedStyle = useMemo(
888
+ () =>
889
+ (isExiting
890
+ ? [style, animatedStyle, EXITING_POINTER_EVENTS_STYLE]
891
+ : [style, animatedStyle]) as unknown,
892
+ [style, animatedStyle, isExiting],
893
+ )
748
894
 
749
- const gestureHandlers = useGestureHandlers(
750
- gesture,
751
- rest as Record<string, unknown>,
752
- setPressed,
753
- setFocused,
754
- setFocusVisible,
755
- setHovered,
756
- )
895
+ const gestureHandlers = useGestureHandlers(
896
+ gesture,
897
+ rest as Record<string, unknown>,
898
+ setPressed,
899
+ setFocused,
900
+ setFocusVisible,
901
+ setHovered,
902
+ )
757
903
 
758
- // Resolve the `layout` prop into a Reanimated `LinearTransition` builder.
759
- // Memoized on the value's stable signature so a fresh `layout={true}` or
760
- // `layout={{ ... }}` literal each render doesn't rebuild the builder. When
761
- // reduced motion is active we pass `undefined` — see `resolveLayout` for
762
- // why we don't pass a duration-0 builder instead.
763
- const layoutSig = stableSig(layout)
764
- const layoutTransition = useMemo(
765
- () => (shouldReduceMotion ? undefined : resolveLayoutTransition(layout)),
766
- // eslint-disable-next-line react-hooks/exhaustive-deps
767
- [layoutSig, shouldReduceMotion],
768
- )
904
+ // Resolve the `layout` prop into a Reanimated `LinearTransition` builder.
905
+ // Memoized on the value's stable signature so a fresh `layout={true}` or
906
+ // `layout={{ ... }}` literal each render doesn't rebuild the builder. When
907
+ // reduced motion is active we pass `undefined` — see `resolveLayout` for
908
+ // why we don't pass a duration-0 builder instead.
909
+ const layoutSig = stableSig(layout)
910
+ const layoutTransition = useMemo(
911
+ () =>
912
+ shouldReduceMotion ? undefined : resolveLayoutTransition(layout),
913
+ // eslint-disable-next-line react-hooks/exhaustive-deps
914
+ [layoutSig, shouldReduceMotion],
915
+ )
769
916
 
770
- return (
771
- <AnimatedComponent
772
- ref={sharedLayout.setRef as never}
773
- {...(rest as object)}
774
- {...gestureHandlers}
775
- onLayout={sharedLayout.onLayout}
776
- layout={layoutTransition}
777
- style={mergedStyle}
778
- />
779
- )
917
+ return (
918
+ <AnimatedComponent
919
+ ref={sharedLayout.setRef as never}
920
+ {...(rest as object)}
921
+ {...gestureHandlers}
922
+ onLayout={sharedLayout.onLayout}
923
+ layout={layoutTransition}
924
+ style={mergedStyle}
925
+ />
926
+ )
927
+ },
928
+ )
929
+
930
+ MotionAnimated.displayName = `Motion(${Component.displayName ?? Component.name ?? 'Component'})`
931
+
932
+ // Dispatch: route prop-less instances to the zero-cost `PlainHost`, and
933
+ // anything carrying an animation prop to the full `MotionAnimated` body.
934
+ const Motion = forwardRef<unknown, Props>(function Motion(props, ref) {
935
+ if (hasMotionProps(props as Record<string, unknown>)) {
936
+ return <MotionAnimated ref={ref} {...props} />
937
+ }
938
+ return <PlainHost ref={ref} {...props} />
780
939
  })
781
940
 
782
941
  Motion.displayName = `Motion(${Component.displayName ?? Component.name ?? 'Component'})`
@@ -784,6 +943,32 @@ export function createMotionComponent<C extends ComponentType<any>>(
784
943
  return Motion as unknown as MotionComponent<C>
785
944
  }
786
945
 
946
+ /**
947
+ * The props that make a `Motion.*` instance actually animate. If none are
948
+ * present, the instance is a plain animated host (see `PlainHost`). A `style`
949
+ * function is intentionally excluded — it throws in dev and is not an
950
+ * animation driver; a plain host forwards a static `style` object untouched.
951
+ */
952
+ const MOTION_PROP_KEYS = [
953
+ 'initial',
954
+ 'animate',
955
+ 'exit',
956
+ 'transition',
957
+ 'variants',
958
+ 'controller',
959
+ 'gesture',
960
+ 'layout',
961
+ 'layoutId',
962
+ 'onAnimationEnd',
963
+ ] as const
964
+
965
+ function hasMotionProps(props: Record<string, unknown>): boolean {
966
+ for (const key of MOTION_PROP_KEYS) {
967
+ if (props[key] !== undefined) return true
968
+ }
969
+ return false
970
+ }
971
+
787
972
  type SharedValueMap = Record<AnimatableKey, SharedValue<number | string>>
788
973
 
789
974
  /**
@@ -863,6 +1048,23 @@ function useAnimatableSharedValues(
863
1048
  shadowOffsetHeight,
864
1049
  }
865
1050
  }
1051
+
1052
+ // Cancel every in-flight per-key animation when the primitive unmounts, so
1053
+ // a mid-flight (or infinite-repeat) `withSpring` / `withTiming` doesn't keep
1054
+ // ticking its worklet against orphaned shared values. This mirrors the
1055
+ // value-layer hooks' unmount guard (`useMotionValue` / `useSpring` /
1056
+ // `useAnimation`). The SV map is identity-stable per instance.
1057
+ const map = ref.current
1058
+ useEffect(
1059
+ () => () => {
1060
+ for (const key in map) {
1061
+ cancelAnimation(map[key as AnimatableKey])
1062
+ }
1063
+ },
1064
+ // `map` is identity-stable per hook instance.
1065
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1066
+ [],
1067
+ )
866
1068
  return ref.current
867
1069
  }
868
1070
 
@@ -1023,6 +1225,67 @@ function shadowOffsetAxisValue(
1023
1225
  return source?.[axis]
1024
1226
  }
1025
1227
 
1228
+ /**
1229
+ * Read an animatable key's resting value out of a **flattened style object**.
1230
+ *
1231
+ * Used for keys that are in the active set but that no record drives — they
1232
+ * have to rest wherever the static `style` put them, because the animated
1233
+ * style merges after `style` and would otherwise override it.
1234
+ *
1235
+ * Three shapes need unwrapping: transform keys live inside the `transform`
1236
+ * array rather than at the top level, `shadowOffset*` synthetics decompose
1237
+ * from the nested object, and rotations are unit-suffixed strings on a style
1238
+ * but plain degrees in our shared values.
1239
+ *
1240
+ * Type-narrow deliberately: only numbers are accepted for numeric slots, so a
1241
+ * `width: '100%'` is left to `DEFAULT_RESTING` rather than seeded into a slot
1242
+ * that a later `withTiming` would try to interpolate as a number.
1243
+ */
1244
+ function styleValueFor(
1245
+ flat: Record<string, unknown>,
1246
+ key: AnimatableKey,
1247
+ ): number | string | undefined {
1248
+ if (SHADOW_OFFSET_KEY_SET.has(key)) {
1249
+ return shadowOffsetAxisValue(
1250
+ flat.shadowOffset as { width?: number; height?: number } | undefined,
1251
+ shadowOffsetAxisFor(key as ShadowOffsetKey),
1252
+ )
1253
+ }
1254
+ if (TRANSFORM_KEY_SET.has(key)) {
1255
+ const list = flat.transform
1256
+ if (!Array.isArray(list)) return undefined
1257
+ for (const entry of list) {
1258
+ if (!entry || typeof entry !== 'object') continue
1259
+ const v = (entry as Record<string, unknown>)[key]
1260
+ if (v === undefined) continue
1261
+ if (ROTATION_KEYS.has(key)) return parseAngleDegrees(v)
1262
+ return typeof v === 'number' ? v : undefined
1263
+ }
1264
+ return undefined
1265
+ }
1266
+ const v = flat[key]
1267
+ if (COLOR_KEY_SET.has(key)) {
1268
+ return typeof v === 'string' || typeof v === 'number' ? v : undefined
1269
+ }
1270
+ return typeof v === 'number' ? v : undefined
1271
+ }
1272
+
1273
+ /**
1274
+ * Style rotations are unit-suffixed strings (`'45deg'`, `'0.5rad'`); the shared
1275
+ * value behind a rotation key holds plain degrees. Anything we can't convert
1276
+ * confidently returns `undefined` so the caller falls back to the resting
1277
+ * default instead of guessing at a unit.
1278
+ */
1279
+ function parseAngleDegrees(v: unknown): number | undefined {
1280
+ if (typeof v === 'number') return v
1281
+ if (typeof v !== 'string') return undefined
1282
+ const deg = /^(-?\d*\.?\d+)deg$/.exec(v)
1283
+ if (deg) return Number(deg[1])
1284
+ const rad = /^(-?\d*\.?\d+)rad$/.exec(v)
1285
+ if (rad) return (Number(rad[1]) * 180) / Math.PI
1286
+ return undefined
1287
+ }
1288
+
1026
1289
  /**
1027
1290
  * Populate `touched` with the `AnimatableKey`s mentioned in `record`. Direct
1028
1291
  * matches (e.g. `opacity`, `width`) come from the key iteration; the nested
@@ -1067,6 +1330,22 @@ function totalIterationsOf(cfg: TransitionConfig | undefined): number {
1067
1330
  return r.count
1068
1331
  }
1069
1332
 
1333
+ /**
1334
+ * Whether an animation built from `cfg` will ever reach its terminal
1335
+ * `'animation'` phase. Everything finite does; `repeat: 'infinite'` never
1336
+ * does, because `dispatch` only promotes a callback to the terminal phase once
1337
+ * `iteration >= totalIterations - 1`, and that comparison is unreachable
1338
+ * against `Infinity`.
1339
+ *
1340
+ * Anything counting pending completions must exclude endless animations, or
1341
+ * the counter never drains: one endless transform axis swallowed every sibling
1342
+ * axis's `onAnimationEnd`, and one endless `exit` key left a `<Presence>` child
1343
+ * mounted forever because `safeToRemove` was gated behind it.
1344
+ */
1345
+ function reachesTerminalPhase(cfg: TransitionConfig | undefined): boolean {
1346
+ return Number.isFinite(totalIterationsOf(cfg))
1347
+ }
1348
+
1070
1349
  /**
1071
1350
  * Pull a single end-value out of an `AnimatableValue` for the
1072
1351
  * `AnimationCallbackInfo.target` field. Plain numbers/strings come through;