@tamagui/animations-motion 2.7.7 → 3.0.0-beta.1097.1

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.
@@ -1,21 +1,38 @@
1
- import { getEffectiveAnimation, normalizeTransition } from '@tamagui/animation-helpers'
1
+ import {
2
+ easingToBezier,
3
+ forAnimationState,
4
+ getTransitionForKey,
5
+ hasTransition,
6
+ resolveTransition,
7
+ styleKeysForProperty,
8
+ type AnimationsConfig,
9
+ type ResolvedEntry,
10
+ type ResolvedTransition,
11
+ } from '@tamagui/animation-helpers'
2
12
  import { ResetPresence, usePresence } from '@tamagui/use-presence'
3
13
  import {
4
14
  type AnimatedNumberStrategy,
5
- type AnimationDriver,
6
- fixStyles,
15
+ type AnimationDriverWithAnimatedNumbers,
7
16
  getConfig,
8
17
  getSplitStyles,
9
18
  hooks,
10
- styleToCSS,
19
+ nonAnimatableStyleProps,
20
+ type OnTransition,
11
21
  Text,
12
22
  TransitionProp,
13
23
  type UniversalAnimatedNumber,
14
24
  useComposedRefs,
15
25
  useIsomorphicLayoutEffect,
16
- useThemeWithState,
17
26
  View,
27
+ createRefComponent,
18
28
  } from '@tamagui/web'
29
+ import {
30
+ fixStyles,
31
+ normalizeValueWithProperty,
32
+ styleToCSS,
33
+ transformsToString,
34
+ useThemeWithState,
35
+ } from '@tamagui/web/internal-runtime'
19
36
  import {
20
37
  animate as animateMotionValue,
21
38
  type AnimationOptions,
@@ -27,14 +44,7 @@ import {
27
44
  useMotionValueEvent,
28
45
  type ValueTransition,
29
46
  } from 'motion/react'
30
- import React, {
31
- forwardRef,
32
- useEffect,
33
- useLayoutEffect,
34
- useMemo,
35
- useRef,
36
- useState,
37
- } from 'react'
47
+ import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
38
48
 
39
49
  const isServer = typeof window === 'undefined'
40
50
 
@@ -43,13 +53,12 @@ const isServer = typeof window === 'undefined'
43
53
  // don't need animations so we return a no-op scope/animate pair.
44
54
  function useAnimateSSRSafe() {
45
55
  if (isServer) {
46
- return [useRef(null), (() => {}) as any] as ReturnType<typeof useAnimate>
56
+ return [useRef(null), (() => {}) as any] as unknown as ReturnType<typeof useAnimate>
47
57
  }
48
58
  return useAnimate()
49
59
  }
50
60
 
51
61
  type MotionAnimatedNumber = MotionValue<number>
52
- type AnimationConfig = ValueTransition
53
62
 
54
63
  type MotionAnimatedNumberStyle = {
55
64
  getStyle: (...args: any[]) => Record<string, unknown>
@@ -57,6 +66,20 @@ type MotionAnimatedNumberStyle = {
57
66
  motionValues?: MotionValue<number>[]
58
67
  }
59
68
 
69
+ const asStyleRecord = (style: unknown): Record<string, unknown> =>
70
+ style && typeof style === 'object' && !Array.isArray(style)
71
+ ? (style as Record<string, unknown>)
72
+ : {}
73
+
74
+ // raw CSSOM assignment (unlike React's style prop) silently ignores unitless
75
+ // numbers, and the core hands the inline-output driver RN-format numeric
76
+ // values — suffix px the way React would before writing
77
+ function assignInlineStyles(node: HTMLElement, styles: Record<string, unknown>) {
78
+ for (const key in styles) {
79
+ node.style[key] = normalizeValueWithProperty(styles[key], key)
80
+ }
81
+ }
82
+
60
83
  /**
61
84
  * Animation options with optional default and per-property configs.
62
85
  * This extends AnimationOptions to support the default key.
@@ -137,21 +160,27 @@ type MotionRefs = {
137
160
  animationState: 'enter' | 'exit' | 'default'
138
161
  frozenExitTarget: Record<string, unknown> | null
139
162
  exitCompleteScheduled: boolean
163
+ enterCompleteScheduled: boolean
164
+ disableAnimation: boolean
140
165
  wasEntering: boolean
141
166
  wasDisabled: boolean
167
+ onTransition: OnTransition | null | undefined
168
+ enterStarted: boolean
169
+ exitStarted: boolean
170
+ updateInFlight: boolean
171
+ updateControls: AnimationPlaybackControlsWithThen | null
142
172
  wasNoClass: boolean
143
173
  }
144
174
 
145
- export function createAnimations<A extends Record<string, AnimationConfig>>(
175
+ export function createAnimations<A extends AnimationsConfig>(
146
176
  animations: A
147
- ): AnimationDriver<A> {
177
+ ): AnimationDriverWithAnimatedNumbers<A> {
148
178
  let isHydratingGlobal: boolean | undefined
149
179
  const hydratingComponents = new Set<Function>()
150
180
 
151
181
  return {
152
182
  View: MotionView,
153
183
  Text: MotionText,
154
- isReactNative: false,
155
184
  inputStyle: 'css',
156
185
  outputStyle: 'inline',
157
186
  avoidReRenders: true,
@@ -176,12 +205,15 @@ export function createAnimations<A extends Record<string, AnimationConfig>>(
176
205
  stateRef,
177
206
  useStyleEmitter,
178
207
  presence,
208
+ onTransition,
179
209
  styleProps,
210
+ styleState,
180
211
  } = animationProps
181
212
 
182
- const animationKey = Array.isArray(props.transition)
183
- ? props.transition[0]
184
- : props.transition
213
+ // createComponent merges a colocated `transition` out of the active
214
+ // pseudo style (`hoverStyle={{ transition: '400ms' }}`), so this is the
215
+ // one that applies right now. same source the other three drivers read.
216
+ const effectiveTransition = styleState?.effectiveTransition ?? props.transition
185
217
 
186
218
  const isComponentHydrating = componentState.unmounted === true
187
219
  const isMounting = componentState.unmounted === 'should-enter'
@@ -205,12 +237,29 @@ export function createAnimations<A extends Record<string, AnimationConfig>>(
205
237
  animationState: 'default',
206
238
  frozenExitTarget: null,
207
239
  exitCompleteScheduled: false,
240
+ enterCompleteScheduled: false,
241
+ disableAnimation: true,
208
242
  wasEntering: false,
209
243
  wasDisabled: false,
244
+ onTransition: undefined,
245
+ enterStarted: false,
246
+ exitStarted: false,
247
+ updateInFlight: false,
248
+ updateControls: null,
210
249
  wasNoClass: !!styleProps.noClass,
211
250
  }
212
251
  }
213
252
 
253
+ const emit = (
254
+ phase: 'start' | 'end',
255
+ cause: 'enter' | 'exit' | 'update',
256
+ finished?: boolean
257
+ ) => {
258
+ refs.current.onTransition?.(
259
+ phase === 'end' ? { phase, cause, finished } : { phase, cause }
260
+ )
261
+ }
262
+
214
263
  // track entering state transitions
215
264
  const justFinishedEntering = refs.current.wasEntering && !isEntering
216
265
  useEffect(() => {
@@ -225,14 +274,16 @@ export function createAnimations<A extends Record<string, AnimationConfig>>(
225
274
  : 'default'
226
275
 
227
276
  // disable animation during hydration and mounting (prevents "flying across the page")
228
- const disableAnimation = isComponentHydrating || isMounting || !animationKey
277
+ const disableAnimation = isComponentHydrating || isMounting || !effectiveTransition
229
278
 
230
- const [scope, animate] = useAnimateSSRSafe()
279
+ const [scope] = useAnimateSSRSafe()
231
280
 
232
281
  // sync ref values for reliable access from callbacks
233
282
  refs.current.isExiting = isExiting
234
283
  refs.current.sendExitComplete = sendExitComplete
235
284
  refs.current.animationState = animationState
285
+ refs.current.disableAnimation = disableAnimation
286
+ refs.current.onTransition = onTransition
236
287
 
237
288
  // detect transition into exiting state
238
289
  const justStartedExiting = isExiting && !refs.current.wasExiting
@@ -242,6 +293,7 @@ export function createAnimations<A extends Record<string, AnimationConfig>>(
242
293
  if (justStartedExiting || justStoppedExiting) {
243
294
  refs.current.frozenExitTarget = null
244
295
  refs.current.exitCompleteScheduled = false
296
+ refs.current.enterCompleteScheduled = false
245
297
  }
246
298
 
247
299
  // track previous exiting state
@@ -249,11 +301,25 @@ export function createAnimations<A extends Record<string, AnimationConfig>>(
249
301
  refs.current.wasExiting = isExiting
250
302
  })
251
303
 
304
+ // exit interrupted by a re-enter: emit a finished:false end for it
305
+ useIsomorphicLayoutEffect(() => {
306
+ if (justStoppedExiting && refs.current.exitStarted) {
307
+ refs.current.exitStarted = false
308
+ emit('end', 'exit', false)
309
+ }
310
+ }, [justStoppedExiting])
311
+
252
312
  const {
253
313
  dontAnimate = {},
254
314
  doAnimate,
255
315
  animationOptions,
256
- } = getMotionAnimatedProps(props as any, style, disableAnimation, animationState)
316
+ } = getMotionAnimatedProps(
317
+ props as any,
318
+ style,
319
+ disableAnimation,
320
+ animationState,
321
+ effectiveTransition
322
+ )
257
323
 
258
324
  const [firstRenderStyle] = useState(style)
259
325
 
@@ -285,6 +351,7 @@ export function createAnimations<A extends Record<string, AnimationConfig>>(
285
351
  // read current state from refs (closure variables can be stale)
286
352
  const isCurrentlyExiting = refs.current.isExiting
287
353
  const currentSendExitComplete = refs.current.sendExitComplete
354
+ const currentAnimationState = refs.current.animationState
288
355
 
289
356
  // freeze exit target: once the first exit animation starts, subsequent
290
357
  // renders (e.g. direction change) should not reverse the exit animation.
@@ -296,7 +363,14 @@ export function createAnimations<A extends Record<string, AnimationConfig>>(
296
363
  // only recompute animation options for exit animations to avoid stale state.
297
364
  const animationOptions =
298
365
  isCurrentlyExiting && currentSendExitComplete
299
- ? getAnimationOptions(props.transition ?? null, 'exit')
366
+ ? getAnimationOptions(
367
+ forAnimationState(
368
+ resolveTransition(effectiveTransition ?? null, {
369
+ animations: animations as Record<string, unknown>,
370
+ }),
371
+ 'exit'
372
+ )
373
+ )
300
374
  : passedOptions
301
375
 
302
376
  try {
@@ -338,18 +412,23 @@ export function createAnimations<A extends Record<string, AnimationConfig>>(
338
412
  return
339
413
  }
340
414
 
341
- // handle case where dontAnimate changes
415
+ // handle case where dontAnimate changes. removal also has to run
416
+ // when dontAnimate disappears entirely (undefined), or a style whose
417
+ // last member left (e.g. a hover-conditioned cursor on unhover)
418
+ // stays stuck on the node forever
342
419
  const prevDont = refs.current.lastDontAnimate
343
420
  if (dontAnimate) {
344
421
  if (prevDont) {
345
422
  removeRemovedStyles(prevDont, dontAnimate, node, doAnimate)
346
423
  const changed = getDiff(prevDont, dontAnimate)
347
424
  if (changed) {
348
- Object.assign(node.style, changed as any)
425
+ assignInlineStyles(node, changed)
349
426
  }
350
427
  } else {
351
- Object.assign(node.style, dontAnimate as any)
428
+ assignInlineStyles(node, dontAnimate)
352
429
  }
430
+ } else if (prevDont) {
431
+ removeRemovedStyles(prevDont, {}, node, doAnimate)
353
432
  }
354
433
 
355
434
  if (doAnimate) {
@@ -358,7 +437,7 @@ export function createAnimations<A extends Record<string, AnimationConfig>>(
358
437
  if (prevDont) {
359
438
  for (const key in prevDont) {
360
439
  if (key in doAnimate) {
361
- node.style[key] = prevDont[key]
440
+ node.style[key] = normalizeValueWithProperty(prevDont[key], key)
362
441
  if (refs.current.lastDoAnimate) {
363
442
  refs.current.lastDoAnimate[key] = prevDont[key]
364
443
  }
@@ -449,7 +528,7 @@ export function createAnimations<A extends Record<string, AnimationConfig>>(
449
528
 
450
529
  const opts = animationOptions as TransitionAnimationOptions
451
530
  const positionTransition =
452
- opts.transform ?? opts.default ?? animationOptions
531
+ opts.x ?? opts.transform ?? opts.default ?? animationOptions
453
532
  const cx = animateMotionValue(
454
533
  entry.x,
455
534
  target.x,
@@ -562,7 +641,14 @@ export function createAnimations<A extends Record<string, AnimationConfig>>(
562
641
  // PopoverClickDuringEnter / AnimatePresenceEnterExit.
563
642
  // the motion-value spring owns transform — keep it out of the
564
643
  // WAAPI animation so the two don't double-drive the property
565
- let waapiDiff = fixedDiff
644
+ let waapiDiff = Object.fromEntries(
645
+ Object.entries(fixedDiff).map(([key, value]) => [
646
+ key,
647
+ Array.isArray(value)
648
+ ? value.map((item) => normalizeValueWithProperty(item, key))
649
+ : normalizeValueWithProperty(value, key),
650
+ ])
651
+ )
566
652
  if (popperHandledTransform && 'transform' in waapiDiff) {
567
653
  waapiDiff = { ...waapiDiff }
568
654
  delete waapiDiff.transform
@@ -577,7 +663,11 @@ export function createAnimations<A extends Record<string, AnimationConfig>>(
577
663
  }
578
664
 
579
665
  if (Object.keys(waapiDiff).length > 0) {
580
- startedControls = animate(scope.current, waapiDiff, animationOptions)
666
+ startedControls = animateMotionValue(
667
+ node,
668
+ waapiDiff,
669
+ animationOptions
670
+ ) as AnimationPlaybackControlsWithThen
581
671
  refs.current.controls = startedControls
582
672
  }
583
673
  refs.current.lastAnimateAt = Date.now()
@@ -589,41 +679,102 @@ export function createAnimations<A extends Record<string, AnimationConfig>>(
589
679
  } finally {
590
680
  // exit completion: notify AnimatePresence when exit animation finishes
591
681
  if (isCurrentlyExiting && currentSendExitComplete) {
682
+ // an enter animation that was still in flight is now interrupted
683
+ if (refs.current.enterStarted) {
684
+ refs.current.enterStarted = false
685
+ emit('end', 'enter', false)
686
+ }
592
687
  if (startedControls) {
593
688
  // new animation started — attach completion handler
689
+ if (!refs.current.exitStarted) {
690
+ refs.current.exitStarted = true
691
+ emit('start', 'exit')
692
+ }
594
693
  refs.current.exitCompleteScheduled = true
694
+ const complete = (finished: boolean) => {
695
+ // guard: only complete if still exiting (prevents stale promise
696
+ // from calling sendExitComplete after a re-entry cancels the exit)
697
+ if (!refs.current.isExiting) return
698
+ if (refs.current.exitStarted) {
699
+ refs.current.exitStarted = false
700
+ // exit 'end' fires immediately before presence safeToRemove
701
+ emit('end', 'exit', finished)
702
+ }
703
+ currentSendExitComplete()
704
+ }
595
705
  startedControls.finished
596
- .then(() => {
597
- // guard: only complete if still exiting (prevents stale promise
598
- // from calling sendExitComplete after a re-entry cancels the exit)
599
- if (refs.current.isExiting) {
600
- currentSendExitComplete()
601
- }
602
- })
603
- .catch(() => {
604
- if (refs.current.isExiting) {
605
- currentSendExitComplete()
606
- }
607
- })
706
+ .then(() => complete(true))
707
+ .catch(() => complete(false))
608
708
  } else if (!refs.current.exitCompleteScheduled) {
609
709
  // no animation started AND none previously scheduled (e.g. diff=null
610
710
  // on re-render mid-exit because frozenExitTarget matches lastDoAnimate)
611
711
  // — complete immediately only if we've never started an exit animation
712
+ emit('start', 'exit')
713
+ emit('end', 'exit', true)
612
714
  currentSendExitComplete()
613
715
  }
614
716
  // else: exit animation already scheduled via a previous flush,
615
717
  // its .finished promise will call sendExitComplete when done
718
+ } else if (currentAnimationState === 'enter') {
719
+ if (startedControls) {
720
+ if (!refs.current.enterStarted) {
721
+ refs.current.enterStarted = true
722
+ emit('start', 'enter')
723
+ }
724
+ refs.current.enterCompleteScheduled = true
725
+ const complete = (finished: boolean) => {
726
+ if (refs.current.disposed || refs.current.animationState !== 'enter')
727
+ return
728
+ if (refs.current.enterStarted) {
729
+ refs.current.enterStarted = false
730
+ emit('end', 'enter', finished)
731
+ }
732
+ }
733
+ startedControls.finished
734
+ .then(() => complete(true))
735
+ .catch(() => complete(false))
736
+ } else if (
737
+ !refs.current.enterCompleteScheduled &&
738
+ !refs.current.disableAnimation
739
+ ) {
740
+ // no animation needed for this enter (no style diff) — complete
741
+ // immediately. skipped while animation is disabled (mount flushes
742
+ // apply enter styles without animating; completing there would
743
+ // report enter done before the real transition even starts)
744
+ emit('start', 'enter')
745
+ emit('end', 'enter', true)
746
+ }
747
+ } else if (startedControls) {
748
+ // update: a style change while mounted. a new update that supersedes
749
+ // an in-flight one closes it out as finished:false (motion silently
750
+ // replaces the conflicting property animation without settling the
751
+ // old controls, so we can't rely on its promise for the interrupted
752
+ // one). the latest controls reports true on natural completion.
753
+ const controls = startedControls
754
+ if (refs.current.updateInFlight) {
755
+ emit('end', 'update', false)
756
+ }
757
+ refs.current.updateInFlight = true
758
+ refs.current.updateControls = controls
759
+ emit('start', 'update')
760
+ const settle = (finished: boolean) => {
761
+ if (refs.current.updateControls !== controls) return
762
+ refs.current.updateInFlight = false
763
+ refs.current.updateControls = null
764
+ emit('end', 'update', finished)
765
+ }
766
+ controls.finished.then(() => settle(true)).catch(() => settle(false))
616
767
  }
617
768
  }
618
769
  }
619
770
 
620
- useStyleEmitter?.((nextStyle, effectiveTransition) => {
771
+ useStyleEmitter?.((nextStyle, emittedTransition) => {
621
772
  const animationProps = getMotionAnimatedProps(
622
773
  props as any,
623
774
  nextStyle,
624
775
  disableAnimation,
625
776
  refs.current.animationState,
626
- effectiveTransition
777
+ emittedTransition ?? effectiveTransition
627
778
  )
628
779
 
629
780
  flushAnimation(animationProps)
@@ -681,8 +832,8 @@ export function createAnimations<A extends Record<string, AnimationConfig>>(
681
832
  if ((justEnabled || justStrippedClasses) && animationState !== 'enter') {
682
833
  const node = stateRef.current.host
683
834
  if (node instanceof HTMLElement) {
684
- if (dontAnimate) Object.assign(node.style, dontAnimate)
685
- if (doAnimate) Object.assign(node.style, doAnimate)
835
+ if (dontAnimate) assignInlineStyles(node, dontAnimate)
836
+ if (doAnimate) assignInlineStyles(node, doAnimate)
686
837
  // keep the popper position motion values in sync with the direct
687
838
  // inline write so a later retarget doesn't start from a stale spot
688
839
  const entry = PopperPositionAnims.get(node)
@@ -831,7 +982,7 @@ export function createAnimations<A extends Record<string, AnimationConfig>>(
831
982
  }
832
983
 
833
984
  function getMotionAnimatedProps(
834
- props: { transition?: TransitionProp | null; animateOnly?: string[] },
985
+ props: { transition?: TransitionProp | null },
835
986
  style: Record<string, unknown>,
836
987
  disable: boolean,
837
988
  animationState: 'enter' | 'exit' | 'default' = 'default',
@@ -843,18 +994,22 @@ export function createAnimations<A extends Record<string, AnimationConfig>>(
843
994
  }
844
995
  }
845
996
 
846
- const animationOptions = getAnimationOptions(
847
- transitionOverride ?? props.transition ?? null,
997
+ const resolved = forAnimationState(
998
+ resolveTransition(transitionOverride ?? props.transition ?? null, {
999
+ animations: animations as Record<string, unknown>,
1000
+ }),
848
1001
  animationState
849
1002
  )
1003
+ const animationOptions = getAnimationOptions(resolved)
850
1004
 
851
1005
  let dontAnimate: Record<string, unknown> | undefined
852
1006
  let doAnimate: Record<string, unknown> | undefined
853
1007
 
854
- const animateOnly = props.animateOnly as string[] | undefined
855
1008
  for (const key in style) {
856
1009
  const value = style[key]
857
- if (disableAnimationProps.has(key) || (animateOnly && !animateOnly.includes(key))) {
1010
+ // a key the transition does not name is set, never animated, which is
1011
+ // what a `transitionProperty` list narrows down to
1012
+ if (disableAnimationProps.has(key) || !getTransitionForKey(resolved, key)) {
858
1013
  dontAnimate ||= {}
859
1014
  dontAnimate[key] = value
860
1015
  } else {
@@ -870,91 +1025,23 @@ export function createAnimations<A extends Record<string, AnimationConfig>>(
870
1025
  }
871
1026
  }
872
1027
 
873
- function getAnimationOptions(
874
- transitionProp: TransitionProp | null,
875
- animationState: 'enter' | 'exit' | 'default' = 'default'
876
- ): TransitionAnimationOptions {
877
- const normalized = normalizeTransition(transitionProp)
1028
+ function getAnimationOptions(resolved: ResolvedTransition): TransitionAnimationOptions {
1029
+ if (!hasTransition(resolved)) return {}
878
1030
 
879
- let effectiveKey = getEffectiveAnimation(normalized, animationState)
880
-
881
- // fallback: if we have enter/exit defined but state is 'default' and no default key,
882
- // use enter timing as fallback to avoid empty animation options
883
- if (!effectiveKey && animationState === 'default') {
884
- effectiveKey = normalized.enter || normalized.exit || null
885
- }
886
-
887
- const globalConfigOverride: Record<string, unknown> | undefined = normalized.config
888
- ? { ...normalized.config }
889
- : undefined
890
-
891
- if (
892
- !effectiveKey &&
893
- Object.keys(normalized.properties).length === 0 &&
894
- !globalConfigOverride
895
- ) {
896
- return {}
897
- }
898
-
899
- const defaultConfig = effectiveKey ? withInferredType(animations[effectiveKey]) : null
900
-
901
- const delay = normalized.delay
902
-
903
- // framer motion's animate() expects default config at the TOP LEVEL
904
1031
  const result: TransitionAnimationOptions = {}
905
1032
 
906
- if (defaultConfig) {
907
- Object.assign(result, defaultConfig)
1033
+ // motion reads the default config from the TOP LEVEL as well as `default`
1034
+ if (resolved.all) {
1035
+ const base = entryToMotion(resolved.all)
1036
+ Object.assign(result, base)
1037
+ result.default = { ...base }
908
1038
  }
909
1039
 
910
- if (globalConfigOverride) {
911
- Object.assign(result, globalConfigOverride)
912
- if (
913
- result.type === undefined &&
914
- result.duration !== undefined &&
915
- result.damping === undefined &&
916
- result.stiffness === undefined &&
917
- result.mass === undefined
918
- ) {
919
- result.type = 'tween'
920
- }
921
- }
922
-
923
- if (delay) {
924
- result.delay = delay
925
- }
926
-
927
- if (defaultConfig || globalConfigOverride || delay) {
928
- result.default = {
929
- ...defaultConfig,
930
- ...globalConfigOverride,
931
- ...(delay ? { delay } : null),
932
- }
933
- }
934
-
935
- for (const [propName, animationNameOrConfig] of Object.entries(
936
- normalized.properties
937
- )) {
938
- if (typeof animationNameOrConfig === 'string') {
939
- result[propName] = withInferredType(animations[animationNameOrConfig])
940
- } else if (animationNameOrConfig && typeof animationNameOrConfig === 'object') {
941
- const baseConfig = animationNameOrConfig.type
942
- ? withInferredType(animations[animationNameOrConfig.type])
943
- : defaultConfig
944
-
945
- result[propName] = {
946
- ...baseConfig,
947
- ...animationNameOrConfig,
948
- } as ValueTransition
949
- }
950
- }
951
-
952
- // we standardize to ms across drivers, motion expects s
953
- convertMsToS(result as ValueTransition)
954
- convertMsToS(result.default)
955
- for (const key in result) {
956
- if (key !== 'default' && typeof result[key] === 'object') {
957
- convertMsToS(result[key])
1040
+ for (const entry of resolved.entries) {
1041
+ if (entry.property === 'all') continue
1042
+ const config = entryToMotion(entry)
1043
+ for (const key of styleKeysForProperty(entry.property)) {
1044
+ result[key] = { ...config }
958
1045
  }
959
1046
  }
960
1047
 
@@ -962,32 +1049,34 @@ export function createAnimations<A extends Record<string, AnimationConfig>>(
962
1049
  }
963
1050
  }
964
1051
 
965
- function withInferredType(config: AnimationConfig | undefined): AnimationConfig {
966
- if (!config) {
967
- return { type: 'spring' }
968
- }
969
- const isTimingBased =
970
- config.duration !== undefined &&
971
- config.damping === undefined &&
972
- config.stiffness === undefined &&
973
- config.mass === undefined
974
- return { type: isTimingBased ? 'tween' : 'spring', ...config }
975
- }
1052
+ /**
1053
+ * one resolved entry as a motion `ValueTransition`.
1054
+ *
1055
+ * durations are in SECONDS here. motion is the only driver that wants that, so
1056
+ * the conversion lives at this single boundary. it used to be a post-pass that
1057
+ * checked `type === 'tween'` and skipped springs and anything typed `'timing'`,
1058
+ * which meant a `{ type: 'spring', duration: 200 }` ran for 200 seconds.
1059
+ */
1060
+ function entryToMotion(entry: ResolvedEntry): ValueTransition {
1061
+ const delay = entry.delayMs ? { delay: entry.delayMs / 1000 } : null
976
1062
 
977
- function convertMsToS(config: ValueTransition | undefined) {
978
- if (!config) return
979
- if (typeof config.delay === 'number') config.delay = config.delay / 1000
980
- if (typeof config.duration === 'number') {
981
- const isTimingBased =
982
- config.type === 'tween' ||
983
- (config.type === undefined &&
984
- config.damping === undefined &&
985
- config.stiffness === undefined &&
986
- config.mass === undefined)
987
- if (isTimingBased) {
988
- config.duration = config.duration / 1000
989
- }
1063
+ if (entry.timing.kind === 'spring') {
1064
+ return {
1065
+ type: 'spring',
1066
+ stiffness: entry.timing.stiffness,
1067
+ damping: entry.timing.damping,
1068
+ mass: entry.timing.mass,
1069
+ ...delay,
1070
+ } as ValueTransition
990
1071
  }
1072
+
1073
+ const bezier = easingToBezier(entry.timing.easing)
1074
+ return {
1075
+ type: 'tween',
1076
+ duration: entry.timing.durationMs / 1000,
1077
+ ...(bezier ? { ease: [bezier[0], bezier[1], bezier[2], bezier[3]] } : null),
1078
+ ...delay,
1079
+ } as ValueTransition
991
1080
  }
992
1081
 
993
1082
  function removeRemovedStyles(
@@ -1006,35 +1095,114 @@ function removeRemovedStyles(
1006
1095
  }
1007
1096
  }
1008
1097
 
1009
- // truly non-animatable CSS properties (discrete, keyword-based, no interpolation)
1010
- // properties like margin, maxHeight, zIndex, etc are animatable and intentionally excluded
1098
+ // truly non-animatable CSS properties (discrete, keyword-based, no
1099
+ // interpolation), from the same list the style emitter uses to promote base
1100
+ // values to atomic classes. handing one of these to motion's animate() leaves
1101
+ // its committed value stuck on the node after the style stops including it,
1102
+ // because only the dontAnimate path clears removed styles. flexBasis is
1103
+ // interpolable but applied discretely by driver choice.
1011
1104
  export const disableAnimationProps: Set<string> = new Set<string>([
1012
- 'alignContent',
1013
- 'alignItems',
1014
- 'boxSizing',
1015
- 'contain',
1016
- 'containerType',
1017
- 'display',
1105
+ ...Object.keys(nonAnimatableStyleProps),
1018
1106
  'flexBasis',
1019
- 'flexDirection',
1020
- 'fontFamily',
1021
- 'justifyContent',
1022
- 'overflow',
1023
- 'overflowX',
1024
- 'overflowY',
1025
- 'pointerEvents',
1026
- 'position',
1027
- 'textWrap',
1028
- 'userSelect',
1029
1107
  ])
1030
1108
 
1109
+ // props equality for the getSplitStyles memo: functions and children can't
1110
+ // affect style output (they pass through), so they don't participate
1111
+ function motionPropsEqual(a: Record<string, any>, b: Record<string, any>) {
1112
+ for (const key in a) {
1113
+ if (!(key in b)) return false
1114
+ }
1115
+ for (const key in b) {
1116
+ if (key === 'children') continue
1117
+ const av = a[key]
1118
+ const bv = b[key]
1119
+ if (typeof bv === 'function' && typeof av === 'function') continue
1120
+ if (!Object.is(av, bv)) return false
1121
+ }
1122
+ return true
1123
+ }
1124
+
1125
+ // one-level style object comparison: style arrays are recreated per render
1126
+ // with usually-identical contents
1127
+ function motionStylesEqual(a: any[], b: any[]) {
1128
+ if (a.length !== b.length) return false
1129
+ for (let i = 0; i < a.length; i++) {
1130
+ const sa = a[i]
1131
+ const sb = b[i]
1132
+ if (sa === sb) continue
1133
+ if (!sa || !sb) return false
1134
+ let aCount = 0
1135
+ for (const key in sa) {
1136
+ aCount++
1137
+ if (!Object.is(sa[key], sb[key])) return false
1138
+ }
1139
+ let bCount = 0
1140
+ for (const _key in sb) {
1141
+ bCount++
1142
+ }
1143
+ if (aCount !== bCount) return false
1144
+ }
1145
+ return true
1146
+ }
1147
+
1031
1148
  const MotionView = createMotionView('div')
1032
1149
  const MotionText = createMotionView('span')
1033
1150
 
1151
+ const transformAliases: Record<string, string> = {
1152
+ x: 'translateX',
1153
+ y: 'translateY',
1154
+ scale: 'scale',
1155
+ scaleX: 'scaleX',
1156
+ scaleY: 'scaleY',
1157
+ rotate: 'rotate',
1158
+ rotateX: 'rotateX',
1159
+ rotateY: 'rotateY',
1160
+ rotateZ: 'rotateZ',
1161
+ skewX: 'skewX',
1162
+ skewY: 'skewY',
1163
+ }
1164
+
1165
+ function compileAnimatedStyle(
1166
+ initialSource: Record<string, unknown>,
1167
+ initialResolved: Record<string, unknown>
1168
+ ) {
1169
+ const shorthands = getConfig().shorthands
1170
+ const entries = Object.keys(initialSource).map((source) => ({
1171
+ source,
1172
+ target: shorthands?.[source] ?? source,
1173
+ tokenValue:
1174
+ typeof initialSource[source] === 'string' &&
1175
+ initialResolved[shorthands?.[source] ?? source] !== initialSource[source]
1176
+ ? initialResolved[shorthands?.[source] ?? source]
1177
+ : undefined,
1178
+ }))
1179
+
1180
+ return (sourceStyle: Record<string, unknown>) => {
1181
+ const resolved: Record<string, unknown> = {}
1182
+ const transforms: Record<string, unknown>[] = []
1183
+
1184
+ for (const entry of entries) {
1185
+ const value = sourceStyle[entry.source]
1186
+ if (value === undefined) continue
1187
+ if (entry.source === 'transform' && Array.isArray(value)) {
1188
+ resolved.transform = transformsToString(value)
1189
+ } else if (entry.source in transformAliases) {
1190
+ transforms.push({ [transformAliases[entry.source]]: value })
1191
+ } else {
1192
+ resolved[entry.target] =
1193
+ entry.tokenValue ?? normalizeValueWithProperty(value, entry.target)
1194
+ }
1195
+ }
1196
+
1197
+ if (transforms.length) resolved.transform = transformsToString(transforms)
1198
+ return resolved
1199
+ }
1200
+ }
1201
+
1034
1202
  function createMotionView(defaultTag: string) {
1035
1203
  const isText = defaultTag === 'span'
1036
1204
 
1037
- const Component = forwardRef((propsIn: any, ref) => {
1205
+ const Component = createRefComponent((propsIn: any, ref) => {
1038
1206
  const { forwardedRef, animation, render = defaultTag, style, ...propsRest } = propsIn
1039
1207
  const [scope, animate] = useAnimateSSRSafe()
1040
1208
  const hostRef = useRef<HTMLElement>(null)
@@ -1070,6 +1238,13 @@ function createMotionView(defaultTag: string) {
1070
1238
  })()
1071
1239
 
1072
1240
  function getProps(props: any) {
1241
+ if (
1242
+ process.env.NODE_ENV === 'development' &&
1243
+ propsIn.debug === 'profile' &&
1244
+ typeof performance !== 'undefined'
1245
+ ) {
1246
+ performance.mark('tamagui-motion-style-split')
1247
+ }
1073
1248
  const out = getSplitStyles(
1074
1249
  props,
1075
1250
  isText ? Text.staticConfig : View.staticConfig,
@@ -1097,12 +1272,76 @@ function createMotionView(defaultTag: string) {
1097
1272
  return out.viewProps
1098
1273
  }
1099
1274
 
1100
- const props = getProps({ ...propsRest, style: nonAnimatedStyles })
1275
+ const resolvedAnimatedStyle = useMemo(() => {
1276
+ if (!animatedStyle) return null
1277
+ const currentValues = animatedStyle.motionValues
1278
+ ? animatedStyle.motionValues.map((value) => value.get())
1279
+ : animatedStyle.motionValue
1280
+ ? [animatedStyle.motionValue.get()]
1281
+ : []
1282
+ const initialSource = animatedStyle.getStyle(...currentValues)
1283
+ const initialResolved = asStyleRecord(getProps({ style: initialSource }).style)
1284
+ return {
1285
+ initial: initialResolved,
1286
+ resolve: compileAnimatedStyle(initialSource, initialResolved),
1287
+ }
1288
+ }, [animatedStyle, state?.theme, state?.name])
1289
+
1290
+ // memoize the full getSplitStyles pass: it costs ~90us per render and its
1291
+ // style-affecting inputs are usually unchanged. functions and children
1292
+ // pass through getSplitStyles untouched, so they refresh on cache hits
1293
+ // without invalidating.
1294
+ const memoRef = useRef<null | {
1295
+ propsRest: any
1296
+ styles: any[]
1297
+ theme: any
1298
+ themeName: string | undefined
1299
+ result: any
1300
+ }>(null)
1301
+
1302
+ let props: any
1303
+ const cached = memoRef.current
1304
+ if (
1305
+ cached &&
1306
+ cached.theme === state?.theme &&
1307
+ cached.themeName === state?.name &&
1308
+ motionStylesEqual(cached.styles, nonAnimatedStyles) &&
1309
+ motionPropsEqual(cached.propsRest, propsRest)
1310
+ ) {
1311
+ props = { ...cached.result }
1312
+ for (const key in propsRest) {
1313
+ const val = propsRest[key]
1314
+ if (key === 'children' || typeof val === 'function') {
1315
+ props[key] = val
1316
+ }
1317
+ }
1318
+ } else {
1319
+ props = getProps({ ...propsRest, style: nonAnimatedStyles })
1320
+ memoRef.current = {
1321
+ propsRest,
1322
+ styles: nonAnimatedStyles,
1323
+ theme: state?.theme,
1324
+ themeName: state?.name,
1325
+ result: props,
1326
+ }
1327
+ }
1328
+
1329
+ if (resolvedAnimatedStyle) {
1330
+ // reassign so the animated initial never leaks into the memo cache
1331
+ props = {
1332
+ ...props,
1333
+ style: {
1334
+ ...asStyleRecord(props.style),
1335
+ ...resolvedAnimatedStyle.initial,
1336
+ },
1337
+ }
1338
+ }
1339
+
1101
1340
  const Element = render || 'div'
1102
1341
  const transformedProps = hooks.usePropsTransform?.(render, props, stateRef, false)
1103
1342
 
1104
- // consumers set animated numbers from layout effects, so subscribe before
1105
- // passive effects to avoid missing a value set in the mounting commit.
1343
+ // subscribe before passive effects so a layout effect cannot set a value
1344
+ // between this node mounting and its MotionValue subscription.
1106
1345
  useIsomorphicLayoutEffect(() => {
1107
1346
  if (!animatedStyle) return
1108
1347
 
@@ -1115,15 +1354,13 @@ function createMotionView(defaultTag: string) {
1115
1354
  : animatedStyle.motionValue
1116
1355
  ? animatedStyle.getStyle(animatedStyle.motionValue.get())
1117
1356
  : null
1118
- const webStyle = currentStyle && getProps({ style: currentStyle }).style
1357
+ const webStyle = currentStyle && resolvedAnimatedStyle?.resolve(currentStyle)
1119
1358
  if (webStyle) {
1120
- Object.assign(node.style, webStyle)
1359
+ assignInlineStyles(node, webStyle)
1121
1360
  seededNode.current = node
1122
1361
  seededInCurrentFrame.current = true
1123
1362
  requestAnimationFrame(() => {
1124
- if (seededNode.current === node) {
1125
- seededInCurrentFrame.current = false
1126
- }
1363
+ if (seededNode.current === node) seededInCurrentFrame.current = false
1127
1364
  })
1128
1365
  }
1129
1366
  }
@@ -1132,7 +1369,7 @@ function createMotionView(defaultTag: string) {
1132
1369
  animationConfig: AnimatedNumberStrategy | undefined
1133
1370
  ): AnimationOptions =>
1134
1371
  animationConfig?.type === 'timing'
1135
- ? { type: 'tween', duration: (animationConfig?.duration || 0) / 1000 }
1372
+ ? { type: 'tween', duration: (animationConfig.duration || 0) / 1000 }
1136
1373
  : animationConfig?.type === 'direct'
1137
1374
  ? { type: 'tween', duration: 0 }
1138
1375
  : { type: 'spring', ...(animationConfig as any) }
@@ -1140,44 +1377,47 @@ function createMotionView(defaultTag: string) {
1140
1377
  const animateNodeTo = (
1141
1378
  nextStyle: Record<string, unknown>,
1142
1379
  transition: AnimationOptions,
1143
- mv: MotionValue
1380
+ motionValue: MotionValue
1144
1381
  ) => {
1145
1382
  if (!(node instanceof HTMLElement) || hostRef.current !== node) return
1146
- const webStyle = getProps({ style: nextStyle }).style
1383
+ const webStyle = resolvedAnimatedStyle?.resolve(nextStyle)
1147
1384
  if (!webStyle) return
1148
- settlePendingMotionOnFinish(mv, animate(node, webStyle as any, transition))
1385
+ settlePendingMotionOnFinish(
1386
+ motionValue,
1387
+ animate(node, webStyle as any, transition)
1388
+ )
1149
1389
  }
1390
+
1150
1391
  const animateChangedValue = (
1151
1392
  nextStyle: Record<string, unknown>,
1152
1393
  transition: AnimationOptions,
1153
- mv: MotionValue
1394
+ motionValue: MotionValue
1154
1395
  ) => {
1155
1396
  if (seededInCurrentFrame.current && seededNode.current === node) {
1156
- const queuedFrame = queuedAnimationFrames.current.get(mv)
1157
- if (queuedFrame !== undefined) {
1158
- cancelAnimationFrame(queuedFrame)
1159
- }
1397
+ const queuedFrame = queuedAnimationFrames.current.get(motionValue)
1398
+ if (queuedFrame !== undefined) cancelAnimationFrame(queuedFrame)
1160
1399
  const frame = requestAnimationFrame(() => {
1161
- if (queuedAnimationFrames.current.get(mv) !== frame) return
1162
- queuedAnimationFrames.current.delete(mv)
1400
+ if (queuedAnimationFrames.current.get(motionValue) !== frame) return
1401
+ queuedAnimationFrames.current.delete(motionValue)
1163
1402
  if (hostRef.current === node) {
1164
- animateNodeTo(nextStyle, transition, mv)
1403
+ animateNodeTo(nextStyle, transition, motionValue)
1165
1404
  } else {
1166
- const onFinish = PendingMotionOnFinish.get(mv)
1167
- PendingMotionOnFinish.delete(mv)
1405
+ const onFinish = PendingMotionOnFinish.get(motionValue)
1406
+ PendingMotionOnFinish.delete(motionValue)
1168
1407
  onFinish?.()
1169
1408
  }
1170
1409
  })
1171
- queuedAnimationFrames.current.set(mv, frame)
1410
+ queuedAnimationFrames.current.set(motionValue, frame)
1172
1411
  return
1173
1412
  }
1174
- animateNodeTo(nextStyle, transition, mv)
1413
+ animateNodeTo(nextStyle, transition, motionValue)
1175
1414
  }
1176
1415
 
1177
1416
  // multi-value path: subscribe to all motion values
1178
1417
  if (animatedStyle.motionValues) {
1179
1418
  const mvs = animatedStyle.motionValues
1180
- const styleForAll = () => animatedStyle.getStyle(...mvs.map((v) => v.get()))
1419
+ const styleForAll = () =>
1420
+ animatedStyle.getStyle(...mvs.map((value) => value.get()))
1181
1421
  const unsubs = mvs.map((mv) =>
1182
1422
  mv.on('change', () =>
1183
1423
  animateChangedValue(
@@ -1201,7 +1441,7 @@ function createMotionView(defaultTag: string) {
1201
1441
  motionValue
1202
1442
  )
1203
1443
  )
1204
- }, [animatedStyle])
1444
+ }, [animatedStyle, resolvedAnimatedStyle])
1205
1445
 
1206
1446
  return <Element {...transformedProps} ref={composedRefs} />
1207
1447
  })