@rootnative/inertia 0.0.0-alpha.7 → 0.0.2

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 (42) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +1 -1
  3. package/dist/{chunk-ALRHDFZE.mjs → chunk-2DHKV2XY.mjs} +1 -1
  4. package/dist/chunk-34Q4UM6V.js +8 -0
  5. package/dist/{chunk-CWLFUYIY.mjs → chunk-HFDZAHWP.mjs} +1 -1
  6. package/dist/{chunk-7UDYEFBU.js → chunk-IWLU3VDE.js} +361 -288
  7. package/dist/chunk-KYJROYCG.js +8 -0
  8. package/dist/{chunk-DWCLIBYO.mjs → chunk-LYMLQ6WQ.mjs} +362 -289
  9. package/dist/{chunk-TDSO63CJ.js → chunk-NKA6XR77.js} +2 -2
  10. package/dist/{chunk-6SMPIOIC.mjs → chunk-TUMNV4P7.mjs} +1 -1
  11. package/dist/{chunk-2HYD2ZBK.js → chunk-TUYHTHVV.js} +2 -2
  12. package/dist/{chunk-NXDJZD6A.mjs → chunk-V6BTXHZK.mjs} +1 -1
  13. package/dist/chunk-X5J5M3K3.js +8 -0
  14. package/dist/{chunk-JVBXPF2G.mjs → chunk-ZN3PNGHQ.mjs} +1 -1
  15. package/dist/index.d.mts +229 -43
  16. package/dist/index.d.ts +229 -43
  17. package/dist/index.js +199 -23
  18. package/dist/index.mjs +191 -18
  19. package/dist/motion/Image.js +3 -3
  20. package/dist/motion/Image.mjs +2 -2
  21. package/dist/motion/Pressable.js +3 -3
  22. package/dist/motion/Pressable.mjs +2 -2
  23. package/dist/motion/ScrollView.js +3 -3
  24. package/dist/motion/ScrollView.mjs +2 -2
  25. package/dist/motion/Text.js +3 -3
  26. package/dist/motion/Text.mjs +2 -2
  27. package/dist/motion/View.js +3 -3
  28. package/dist/motion/View.mjs +2 -2
  29. package/llms.txt +6 -1
  30. package/package.json +1 -1
  31. package/src/index.ts +10 -0
  32. package/src/motion/createMotionComponent.tsx +624 -501
  33. package/src/values/index.ts +13 -0
  34. package/src/values/useAnimation.ts +15 -1
  35. package/src/values/useAnimator.ts +72 -0
  36. package/src/values/useColorCascade.ts +107 -0
  37. package/src/values/useInterpolatedStyle.ts +319 -0
  38. package/src/values/useMotionValue.ts +20 -2
  39. package/src/values/useSpring.ts +12 -0
  40. package/dist/chunk-3UTJJ4A3.js +0 -8
  41. package/dist/chunk-4QGXK6TF.js +0 -8
  42. package/dist/chunk-Z7HIOFKQ.js +0 -8
@@ -7,6 +7,7 @@ import {
7
7
  useState,
8
8
  } from 'react'
9
9
  import Animated, {
10
+ cancelAnimation,
10
11
  interpolateColor,
11
12
  runOnJS,
12
13
  useAnimatedStyle,
@@ -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,604 @@ 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
+ }
307
+
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
+ )
335
+ }
316
336
 
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 fronta 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>)
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
+ }
358
409
  }
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>)
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>)
419
+ }
369
420
  }
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
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
+ }
386
437
  }
387
438
  }
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
- }
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
+ const sharedValues = useAnimatableSharedValues((key) => {
452
+ // Shadow offset synthetics seed from the corresponding axis on the
453
+ // `shadowOffset: { width, height }` source — the consumer doesn't write
454
+ // `shadowOffsetWidth` / `shadowOffsetHeight` directly. Fall back to the
455
+ // generic resting default when neither initial nor animate touched it.
456
+ if (SHADOW_OFFSET_KEY_SET.has(key)) {
457
+ const axis = shadowOffsetAxisFor(key as ShadowOffsetKey)
458
+ if (initial === false) {
459
+ return (
460
+ shadowOffsetAxisValue(animateRecord.shadowOffset, axis) ??
461
+ DEFAULT_RESTING[key]
462
+ )
463
+ }
409
464
  return (
465
+ shadowOffsetAxisValue(
466
+ initialRecord?.shadowOffset as
467
+ | { width?: number; height?: number }
468
+ | undefined,
469
+ axis,
470
+ ) ??
410
471
  shadowOffsetAxisValue(animateRecord.shadowOffset, axis) ??
411
472
  DEFAULT_RESTING[key]
412
473
  )
413
474
  }
475
+ if (initial === false) {
476
+ const a = animateRecord[key]
477
+ return restValue(a) ?? DEFAULT_RESTING[key]
478
+ }
414
479
  return (
415
- shadowOffsetAxisValue(
416
- initialRecord?.shadowOffset as
417
- | { width?: number; height?: number }
418
- | undefined,
419
- axis,
420
- ) ??
421
- shadowOffsetAxisValue(animateRecord.shadowOffset, axis) ??
480
+ initialRecord?.[key] ??
481
+ restValue(animateRecord[key]) ??
422
482
  DEFAULT_RESTING[key]
423
483
  )
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]
484
+ })
485
+
486
+ // One progress SV per gesture layer, allocated unconditionally for hook
487
+ // stability. Each layer's progress animates 0↔1 with its own transition
488
+ // when its activation flips; the worklet reads them when compositing.
489
+ // Initial value is 0 — even if a sub-state is somehow active on mount,
490
+ // the activation effect below will animate it to 1 on the next tick.
491
+ const pressedProgress = useSharedValue(0)
492
+ const focusedProgress = useSharedValue(0)
493
+ const focusVisibleProgress = useSharedValue(0)
494
+ const hoveredProgress = useSharedValue(0)
495
+
496
+ // Cancel any in-flight gesture-layer springs on unmount, matching the
497
+ // per-key guard in `useAnimatableSharedValues`. Each SV is identity-stable.
498
+ useEffect(
499
+ () => () => {
500
+ cancelAnimation(pressedProgress)
501
+ cancelAnimation(focusedProgress)
502
+ cancelAnimation(focusVisibleProgress)
503
+ cancelAnimation(hoveredProgress)
504
+ },
505
+ // The progress SVs are identity-stable per hook instance.
506
+ // eslint-disable-next-line react-hooks/exhaustive-deps
507
+ [],
433
508
  )
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
484
509
 
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
- }
510
+ // Mirror gesture targets into a UI-runtime-resident shared value so the
511
+ // animated-style worklet can read the latest layer values without having
512
+ // to capture `gesture` directly (which would re-register the worklet on
513
+ // every render where the consumer passes a fresh literal). The signature
514
+ // dependency means we only push to the SV when targets actually change —
515
+ // the SV ref itself is stable across renders.
516
+ //
517
+ // The resolved value is a layer-keyed map of primitive endpoints (numbers
518
+ // or color strings); sequence/`{ to }` step shapes on a sub-state collapse
519
+ // to their final endpoint via `targetEndValue` because a gesture layer
520
+ // describes a steady target, not a keyframe sequence.
521
+ const gestureSV = useSharedValue<ResolvedGestureLayers | null>(
522
+ resolveGestureLayers(gesture),
523
+ )
524
+ const gestureTargetsSig = stableSig(gesture)
525
+ useEffect(() => {
526
+ gestureSV.value = resolveGestureLayers(gesture)
527
+ // eslint-disable-next-line react-hooks/exhaustive-deps
528
+ }, [gestureTargetsSig])
529
+
530
+ // The base record drives the per-key shared values. Gesture sub-state
531
+ // targets are intentionally NOT merged here — they layer on top in the
532
+ // worklet. Exit values still take precedence over `animate` while exiting
533
+ // because the base SV is what <Presence> waits on to settle.
534
+ const baseRecord =
535
+ isExiting && exitRecord
536
+ ? { ...animateRecord, ...exitRecord }
537
+ : animateRecord
538
+ const baseSig =
539
+ stableSig(baseRecord) +
540
+ (isExiting ? '|exit' : '') +
541
+ (shouldReduceMotion ? '|rm' : '')
542
+ const transitionSig = stableSig(transition)
543
+
544
+ // Stable ref to the live `safeToRemove` so the effect's settle-counter
545
+ // closure can reach the latest <Presence> binding without retriggering.
546
+ const safeToRemoveRef = useRef<(() => void) | undefined>(undefined)
547
+ safeToRemoveRef.current = presence?.safeToRemove
548
+
549
+ useEffect(() => {
550
+ // Exit fast-path: nothing to animate (or no exit prop), tell <Presence>
551
+ // immediately so the unmount isn't gated on a phantom animation.
552
+ if (
553
+ isExiting &&
554
+ (!exitRecord || Object.keys(exitRecord).length === 0)
555
+ ) {
556
+ safeToRemoveRef.current?.()
557
+ return
558
+ }
492
559
 
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?.()
560
+ let pending = 0
561
+ let done = false
562
+ const onSettle = () => {
563
+ if (done) return
564
+ pending--
565
+ if (pending <= 0) {
566
+ done = true
567
+ if (isExiting) safeToRemoveRef.current?.()
568
+ }
501
569
  }
502
- }
503
570
 
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++
571
+ // Count transform axes participating in this effect run so the factory
572
+ // can coalesce their terminal callbacks into a single transform-group
573
+ // event. `undefined` when no transform axis is animating, which lets
574
+ // the factory skip the coalescing branch entirely.
575
+ let transformPending = 0
576
+ for (const k of ALL_KEYS) {
577
+ if (TRANSFORM_KEY_SET.has(k) && baseRecord[k] !== undefined) {
578
+ transformPending++
579
+ }
512
580
  }
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),
581
+ const transformGroup: TransformGroup | undefined =
582
+ transformPending > 0 ? { remaining: transformPending } : undefined
583
+
584
+ for (const key of ALL_KEYS) {
585
+ // Shadow offset synthetics read their target from the nested
586
+ // `shadowOffset: { width, height }` source on `baseRecord` — the
587
+ // animate / exit record never has `shadowOffsetWidth` etc. on it
588
+ // directly. The synthetic transition follows the same `shadowOffset`
589
+ // top-level transition entry (no per-axis split).
590
+ const target: AnimatableValue<number | string> | undefined =
591
+ SHADOW_OFFSET_KEY_SET.has(key)
592
+ ? shadowOffsetAxisValue(
593
+ baseRecord.shadowOffset,
594
+ shadowOffsetAxisFor(key as ShadowOffsetKey),
595
+ )
596
+ : baseRecord[key]
597
+ 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,
528
610
  )
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
535
- // 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
- }
563
-
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
- )
611
+ if (isExiting) pending++
612
+ const factory = makeKeyCallbackFactory(
613
+ key,
614
+ sharedValues[key],
615
+ targetEndValue(target),
616
+ onAnimationEndRef,
617
+ {
618
+ stepCount: stepCountOf(target),
619
+ totalIterations: totalIterationsOf(cfg),
620
+ },
621
+ isExiting ? onSettle : undefined,
622
+ TRANSFORM_KEY_SET.has(key) ? transformGroup : undefined,
623
+ )
624
+ sharedValues[key].value = resolveAnimatableValue(
625
+ target,
626
+ cfg,
627
+ factory,
628
+ ) as never
629
+ }
619
630
 
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
631
+ // No exit-targeted keys (only `animate` keys present, no `exit`)
632
+ // release immediately rather than wait for animations that aren't
633
+ // headed toward an exit value.
634
+ if (isExiting && pending === 0) {
635
+ safeToRemoveRef.current?.()
681
636
  }
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
637
+ // eslint-disable-next-line react-hooks/exhaustive-deps
638
+ }, [baseSig, transitionSig])
639
+
640
+ // Per-layer progress: when a sub-state activation flips, animate its
641
+ // progress SV 0↔1 with the layer's own transition (or the parent
642
+ // transition / library default, in priority order). On exit we snap every
643
+ // layer to 0 instantly so the unmount-bound base SV isn't fighting a
644
+ // stale layer contribution mid-fade.
645
+ //
646
+ // The `declared` flag short-circuits the effect when the consumer hasn't
647
+ // wired the corresponding sub-state — so a Motion primitive without a
648
+ // `gesture` prop (or with only some sub-states declared) makes zero extra
649
+ // `withSpring` / `withTiming` calls on mount.
650
+ useGestureLayerProgress(
651
+ pressedProgress,
652
+ pressed,
653
+ gesture?.pressed != null,
654
+ 'pressed',
655
+ transition,
656
+ isExiting,
657
+ shouldReduceMotion,
658
+ )
659
+ useGestureLayerProgress(
660
+ focusedProgress,
661
+ focused,
662
+ gesture?.focused != null,
663
+ 'focused',
664
+ transition,
665
+ isExiting,
666
+ shouldReduceMotion,
667
+ )
668
+ useGestureLayerProgress(
669
+ focusVisibleProgress,
670
+ focusVisible,
671
+ gesture?.focusVisible != null,
672
+ 'focusVisible',
673
+ transition,
674
+ isExiting,
675
+ shouldReduceMotion,
676
+ )
677
+ useGestureLayerProgress(
678
+ hoveredProgress,
679
+ hovered,
680
+ gesture?.hovered != null,
681
+ 'hovered',
682
+ transition,
683
+ isExiting,
684
+ shouldReduceMotion,
685
+ )
686
+
687
+ // Shared-element transition wiring. `useSharedLayout` allocates FLIP
688
+ // shared values (identity at rest), measures via the merged `onLayout`,
689
+ // and on first-mount snaps the FLIP transform to a source rect popped
690
+ // from the registry. The worklet below appends those entries to the
691
+ // transform array so they compose with the user's animate transforms —
692
+ // multiple `translateX` entries sum, multiple `scaleX` entries multiply,
693
+ // which is exactly the FLIP semantic.
694
+ const sharedLayout = useSharedLayout({
695
+ layoutId,
696
+ userRef: ref,
697
+ transition: isTopLevelTransition(transition) ? transition : undefined,
698
+ shouldReduceMotion,
699
+ userOnLayout,
700
+ })
701
+ const flip = sharedLayout.flip
702
+ const hasLayoutId = layoutId !== undefined
703
+
704
+ const animatedStyle = useAnimatedStyle(() => {
705
+ const activeKeys = activeKeysRef.current!
706
+ const hasTransform = hasTransformRef.current
707
+ const hasShadowOffset = hasShadowOffsetRef.current
708
+ const out: Record<string, unknown> = {}
709
+ const transform: Array<Record<string, unknown>> = []
710
+ // shadow-offset reassembly buffers. The two synthetic axis SVs feed in
711
+ // here and the recomposed `{ width, height }` object lands on `out`
712
+ // after the loop so RN gets a single `shadowOffset` style prop.
713
+ let shadowOffsetW = 0
714
+ let shadowOffsetH = 0
715
+
716
+ // Read each progress SV exactly once so the chain below sees a coherent
717
+ // snapshot for this frame. Reading them on the UI thread is cheap.
718
+ const ph = hoveredProgress.value
719
+ const pf = focusedProgress.value
720
+ const pfv = focusVisibleProgress.value
721
+ const pp = pressedProgress.value
722
+
723
+ const layers = gestureSV.value
724
+ // Locals are suffixed `Layer` so they don't shadow the outer `pressed` /
725
+ // `focused` / `focusVisible` / `hovered` JS-state booleans — Reanimated's
726
+ // worklet closure tracker would otherwise pick those up as captured
727
+ // dependencies and re-register the worklet on every activation flip.
728
+ const hoveredLayer = layers ? layers.hovered : null
729
+ const focusedLayer = layers ? layers.focused : null
730
+ const focusVisibleLayer = layers ? layers.focusVisible : null
731
+ const pressedLayer = layers ? layers.pressed : null
732
+
733
+ for (const key of activeKeys) {
734
+ let v = sharedValues[key].value
735
+ const isColor = COLOR_KEY_SET.has(key)
736
+
737
+ // Composite gesture layers in priority order (lowest first). Each
738
+ // active layer pulls the value toward its pre-resolved primitive
739
+ // endpoint by `progress`; numeric keys lerp, color keys go through
740
+ // Reanimated's RGBA `interpolateColor`. We skip layers with progress
741
+ // 0 to avoid an `interpolateColor(0, ...)` call that would parse the
742
+ // target color string for no visible effect.
743
+ if (hoveredLayer && ph > 0 && hoveredLayer[key] !== undefined) {
744
+ const t = hoveredLayer[key]
745
+ v = isColor
746
+ ? interpolateColor(ph, [0, 1], [v as string, t as string])
747
+ : (v as number) + ((t as number) - (v as number)) * ph
748
+ }
749
+ if (focusedLayer && pf > 0 && focusedLayer[key] !== undefined) {
750
+ const t = focusedLayer[key]
751
+ v = isColor
752
+ ? interpolateColor(pf, [0, 1], [v as string, t as string])
753
+ : (v as number) + ((t as number) - (v as number)) * pf
754
+ }
755
+ if (
756
+ focusVisibleLayer &&
757
+ pfv > 0 &&
758
+ focusVisibleLayer[key] !== undefined
759
+ ) {
760
+ const t = focusVisibleLayer[key]
761
+ v = isColor
762
+ ? interpolateColor(pfv, [0, 1], [v as string, t as string])
763
+ : (v as number) + ((t as number) - (v as number)) * pfv
764
+ }
765
+ if (pressedLayer && pp > 0 && pressedLayer[key] !== undefined) {
766
+ const t = pressedLayer[key]
767
+ v = isColor
768
+ ? interpolateColor(pp, [0, 1], [v as string, t as string])
769
+ : (v as number) + ((t as number) - (v as number)) * pp
770
+ }
771
+
772
+ if (TRANSFORM_KEY_SET.has(key)) {
773
+ transform.push(
774
+ ROTATION_KEYS.has(key) ? { [key]: `${v}deg` } : { [key]: v },
775
+ )
776
+ } else if (key === 'shadowOffsetWidth') {
777
+ shadowOffsetW = v as number
778
+ } else if (key === 'shadowOffsetHeight') {
779
+ shadowOffsetH = v as number
780
+ } else {
781
+ out[key] = v
782
+ }
687
783
  }
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
784
+ // Shared-element FLIP transforms append after the user's transform
785
+ // entries so they compose multiplicatively in the same `transform`
786
+ // array separate style entries with `transform` keys would
787
+ // last-write-wins, which is what we explicitly avoid here. At rest
788
+ // (dx, dy, sx, sy) = (0, 0, 1, 1) so the contribution is a no-op
789
+ // when no shared-element transition is active.
790
+ if (hasLayoutId) {
791
+ transform.push({ translateX: flip.dx.value })
792
+ transform.push({ translateY: flip.dy.value })
793
+ transform.push({ scaleX: flip.sx.value })
794
+ transform.push({ scaleY: flip.sy.value })
697
795
  }
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
796
+ if (hasTransform || hasLayoutId) out.transform = transform
797
+ if (hasShadowOffset) {
798
+ out.shadowOffset = { width: shadowOffsetW, height: shadowOffsetH }
703
799
  }
800
+ return out
801
+ })
704
802
 
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
- )
803
+ // Exiting children are tap-deaf: the next press should fall through to
804
+ // 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
806
+ // `pointerEvents` as a prop in favor of the style key, so we merge it
807
+ // alongside the animated style instead of spreading as a prop.
808
+ const mergedStyle = useMemo(
809
+ () =>
810
+ (isExiting
811
+ ? [style, animatedStyle, EXITING_POINTER_EVENTS_STYLE]
812
+ : [style, animatedStyle]) as unknown,
813
+ [style, animatedStyle, isExiting],
814
+ )
748
815
 
749
- const gestureHandlers = useGestureHandlers(
750
- gesture,
751
- rest as Record<string, unknown>,
752
- setPressed,
753
- setFocused,
754
- setFocusVisible,
755
- setHovered,
756
- )
816
+ const gestureHandlers = useGestureHandlers(
817
+ gesture,
818
+ rest as Record<string, unknown>,
819
+ setPressed,
820
+ setFocused,
821
+ setFocusVisible,
822
+ setHovered,
823
+ )
757
824
 
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
- )
825
+ // Resolve the `layout` prop into a Reanimated `LinearTransition` builder.
826
+ // Memoized on the value's stable signature so a fresh `layout={true}` or
827
+ // `layout={{ ... }}` literal each render doesn't rebuild the builder. When
828
+ // reduced motion is active we pass `undefined` — see `resolveLayout` for
829
+ // why we don't pass a duration-0 builder instead.
830
+ const layoutSig = stableSig(layout)
831
+ const layoutTransition = useMemo(
832
+ () =>
833
+ shouldReduceMotion ? undefined : resolveLayoutTransition(layout),
834
+ // eslint-disable-next-line react-hooks/exhaustive-deps
835
+ [layoutSig, shouldReduceMotion],
836
+ )
769
837
 
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
- )
838
+ return (
839
+ <AnimatedComponent
840
+ ref={sharedLayout.setRef as never}
841
+ {...(rest as object)}
842
+ {...gestureHandlers}
843
+ onLayout={sharedLayout.onLayout}
844
+ layout={layoutTransition}
845
+ style={mergedStyle}
846
+ />
847
+ )
848
+ },
849
+ )
850
+
851
+ MotionAnimated.displayName = `Motion(${Component.displayName ?? Component.name ?? 'Component'})`
852
+
853
+ // Dispatch: route prop-less instances to the zero-cost `PlainHost`, and
854
+ // anything carrying an animation prop to the full `MotionAnimated` body.
855
+ const Motion = forwardRef<unknown, Props>(function Motion(props, ref) {
856
+ if (hasMotionProps(props as Record<string, unknown>)) {
857
+ return <MotionAnimated ref={ref} {...props} />
858
+ }
859
+ return <PlainHost ref={ref} {...props} />
780
860
  })
781
861
 
782
862
  Motion.displayName = `Motion(${Component.displayName ?? Component.name ?? 'Component'})`
@@ -784,6 +864,32 @@ export function createMotionComponent<C extends ComponentType<any>>(
784
864
  return Motion as unknown as MotionComponent<C>
785
865
  }
786
866
 
867
+ /**
868
+ * The props that make a `Motion.*` instance actually animate. If none are
869
+ * present, the instance is a plain animated host (see `PlainHost`). A `style`
870
+ * function is intentionally excluded — it throws in dev and is not an
871
+ * animation driver; a plain host forwards a static `style` object untouched.
872
+ */
873
+ const MOTION_PROP_KEYS = [
874
+ 'initial',
875
+ 'animate',
876
+ 'exit',
877
+ 'transition',
878
+ 'variants',
879
+ 'controller',
880
+ 'gesture',
881
+ 'layout',
882
+ 'layoutId',
883
+ 'onAnimationEnd',
884
+ ] as const
885
+
886
+ function hasMotionProps(props: Record<string, unknown>): boolean {
887
+ for (const key of MOTION_PROP_KEYS) {
888
+ if (props[key] !== undefined) return true
889
+ }
890
+ return false
891
+ }
892
+
787
893
  type SharedValueMap = Record<AnimatableKey, SharedValue<number | string>>
788
894
 
789
895
  /**
@@ -863,6 +969,23 @@ function useAnimatableSharedValues(
863
969
  shadowOffsetHeight,
864
970
  }
865
971
  }
972
+
973
+ // Cancel every in-flight per-key animation when the primitive unmounts, so
974
+ // a mid-flight (or infinite-repeat) `withSpring` / `withTiming` doesn't keep
975
+ // ticking its worklet against orphaned shared values. This mirrors the
976
+ // value-layer hooks' unmount guard (`useMotionValue` / `useSpring` /
977
+ // `useAnimation`). The SV map is identity-stable per instance.
978
+ const map = ref.current
979
+ useEffect(
980
+ () => () => {
981
+ for (const key in map) {
982
+ cancelAnimation(map[key as AnimatableKey])
983
+ }
984
+ },
985
+ // `map` is identity-stable per hook instance.
986
+ // eslint-disable-next-line react-hooks/exhaustive-deps
987
+ [],
988
+ )
866
989
  return ref.current
867
990
  }
868
991