@tamagui/animations-motion 2.7.7 → 3.0.0-beta.643.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.
@@ -7,6 +7,8 @@ import {
7
7
  getConfig,
8
8
  getSplitStyles,
9
9
  hooks,
10
+ normalizeValueWithProperty,
11
+ type OnTransition,
10
12
  styleToCSS,
11
13
  Text,
12
14
  TransitionProp,
@@ -15,6 +17,8 @@ import {
15
17
  useIsomorphicLayoutEffect,
16
18
  useThemeWithState,
17
19
  View,
20
+ createRefComponent,
21
+ transformsToString,
18
22
  } from '@tamagui/web'
19
23
  import {
20
24
  animate as animateMotionValue,
@@ -27,14 +31,7 @@ import {
27
31
  useMotionValueEvent,
28
32
  type ValueTransition,
29
33
  } from 'motion/react'
30
- import React, {
31
- forwardRef,
32
- useEffect,
33
- useLayoutEffect,
34
- useMemo,
35
- useRef,
36
- useState,
37
- } from 'react'
34
+ import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
38
35
 
39
36
  const isServer = typeof window === 'undefined'
40
37
 
@@ -43,7 +40,7 @@ const isServer = typeof window === 'undefined'
43
40
  // don't need animations so we return a no-op scope/animate pair.
44
41
  function useAnimateSSRSafe() {
45
42
  if (isServer) {
46
- return [useRef(null), (() => {}) as any] as ReturnType<typeof useAnimate>
43
+ return [useRef(null), (() => {}) as any] as unknown as ReturnType<typeof useAnimate>
47
44
  }
48
45
  return useAnimate()
49
46
  }
@@ -57,6 +54,20 @@ type MotionAnimatedNumberStyle = {
57
54
  motionValues?: MotionValue<number>[]
58
55
  }
59
56
 
57
+ const asStyleRecord = (style: unknown): Record<string, unknown> =>
58
+ style && typeof style === 'object' && !Array.isArray(style)
59
+ ? (style as Record<string, unknown>)
60
+ : {}
61
+
62
+ // raw CSSOM assignment (unlike React's style prop) silently ignores unitless
63
+ // numbers, and the core hands the inline-output driver RN-format numeric
64
+ // values — suffix px the way React would before writing
65
+ function assignInlineStyles(node: HTMLElement, styles: Record<string, unknown>) {
66
+ for (const key in styles) {
67
+ node.style[key] = normalizeValueWithProperty(styles[key], key)
68
+ }
69
+ }
70
+
60
71
  /**
61
72
  * Animation options with optional default and per-property configs.
62
73
  * This extends AnimationOptions to support the default key.
@@ -137,8 +148,15 @@ type MotionRefs = {
137
148
  animationState: 'enter' | 'exit' | 'default'
138
149
  frozenExitTarget: Record<string, unknown> | null
139
150
  exitCompleteScheduled: boolean
151
+ enterCompleteScheduled: boolean
152
+ disableAnimation: boolean
140
153
  wasEntering: boolean
141
154
  wasDisabled: boolean
155
+ onTransition: OnTransition | null | undefined
156
+ enterStarted: boolean
157
+ exitStarted: boolean
158
+ updateInFlight: boolean
159
+ updateControls: AnimationPlaybackControlsWithThen | null
142
160
  wasNoClass: boolean
143
161
  }
144
162
 
@@ -176,6 +194,7 @@ export function createAnimations<A extends Record<string, AnimationConfig>>(
176
194
  stateRef,
177
195
  useStyleEmitter,
178
196
  presence,
197
+ onTransition,
179
198
  styleProps,
180
199
  } = animationProps
181
200
 
@@ -205,12 +224,29 @@ export function createAnimations<A extends Record<string, AnimationConfig>>(
205
224
  animationState: 'default',
206
225
  frozenExitTarget: null,
207
226
  exitCompleteScheduled: false,
227
+ enterCompleteScheduled: false,
228
+ disableAnimation: true,
208
229
  wasEntering: false,
209
230
  wasDisabled: false,
231
+ onTransition: undefined,
232
+ enterStarted: false,
233
+ exitStarted: false,
234
+ updateInFlight: false,
235
+ updateControls: null,
210
236
  wasNoClass: !!styleProps.noClass,
211
237
  }
212
238
  }
213
239
 
240
+ const emit = (
241
+ phase: 'start' | 'end',
242
+ cause: 'enter' | 'exit' | 'update',
243
+ finished?: boolean
244
+ ) => {
245
+ refs.current.onTransition?.(
246
+ phase === 'end' ? { phase, cause, finished } : { phase, cause }
247
+ )
248
+ }
249
+
214
250
  // track entering state transitions
215
251
  const justFinishedEntering = refs.current.wasEntering && !isEntering
216
252
  useEffect(() => {
@@ -227,12 +263,14 @@ export function createAnimations<A extends Record<string, AnimationConfig>>(
227
263
  // disable animation during hydration and mounting (prevents "flying across the page")
228
264
  const disableAnimation = isComponentHydrating || isMounting || !animationKey
229
265
 
230
- const [scope, animate] = useAnimateSSRSafe()
266
+ const [scope] = useAnimateSSRSafe()
231
267
 
232
268
  // sync ref values for reliable access from callbacks
233
269
  refs.current.isExiting = isExiting
234
270
  refs.current.sendExitComplete = sendExitComplete
235
271
  refs.current.animationState = animationState
272
+ refs.current.disableAnimation = disableAnimation
273
+ refs.current.onTransition = onTransition
236
274
 
237
275
  // detect transition into exiting state
238
276
  const justStartedExiting = isExiting && !refs.current.wasExiting
@@ -242,6 +280,7 @@ export function createAnimations<A extends Record<string, AnimationConfig>>(
242
280
  if (justStartedExiting || justStoppedExiting) {
243
281
  refs.current.frozenExitTarget = null
244
282
  refs.current.exitCompleteScheduled = false
283
+ refs.current.enterCompleteScheduled = false
245
284
  }
246
285
 
247
286
  // track previous exiting state
@@ -249,6 +288,14 @@ export function createAnimations<A extends Record<string, AnimationConfig>>(
249
288
  refs.current.wasExiting = isExiting
250
289
  })
251
290
 
291
+ // exit interrupted by a re-enter: emit a finished:false end for it
292
+ useIsomorphicLayoutEffect(() => {
293
+ if (justStoppedExiting && refs.current.exitStarted) {
294
+ refs.current.exitStarted = false
295
+ emit('end', 'exit', false)
296
+ }
297
+ }, [justStoppedExiting])
298
+
252
299
  const {
253
300
  dontAnimate = {},
254
301
  doAnimate,
@@ -285,6 +332,7 @@ export function createAnimations<A extends Record<string, AnimationConfig>>(
285
332
  // read current state from refs (closure variables can be stale)
286
333
  const isCurrentlyExiting = refs.current.isExiting
287
334
  const currentSendExitComplete = refs.current.sendExitComplete
335
+ const currentAnimationState = refs.current.animationState
288
336
 
289
337
  // freeze exit target: once the first exit animation starts, subsequent
290
338
  // renders (e.g. direction change) should not reverse the exit animation.
@@ -345,10 +393,10 @@ export function createAnimations<A extends Record<string, AnimationConfig>>(
345
393
  removeRemovedStyles(prevDont, dontAnimate, node, doAnimate)
346
394
  const changed = getDiff(prevDont, dontAnimate)
347
395
  if (changed) {
348
- Object.assign(node.style, changed as any)
396
+ assignInlineStyles(node, changed)
349
397
  }
350
398
  } else {
351
- Object.assign(node.style, dontAnimate as any)
399
+ assignInlineStyles(node, dontAnimate)
352
400
  }
353
401
  }
354
402
 
@@ -358,7 +406,7 @@ export function createAnimations<A extends Record<string, AnimationConfig>>(
358
406
  if (prevDont) {
359
407
  for (const key in prevDont) {
360
408
  if (key in doAnimate) {
361
- node.style[key] = prevDont[key]
409
+ node.style[key] = normalizeValueWithProperty(prevDont[key], key)
362
410
  if (refs.current.lastDoAnimate) {
363
411
  refs.current.lastDoAnimate[key] = prevDont[key]
364
412
  }
@@ -562,7 +610,14 @@ export function createAnimations<A extends Record<string, AnimationConfig>>(
562
610
  // PopoverClickDuringEnter / AnimatePresenceEnterExit.
563
611
  // the motion-value spring owns transform — keep it out of the
564
612
  // WAAPI animation so the two don't double-drive the property
565
- let waapiDiff = fixedDiff
613
+ let waapiDiff = Object.fromEntries(
614
+ Object.entries(fixedDiff).map(([key, value]) => [
615
+ key,
616
+ Array.isArray(value)
617
+ ? value.map((item) => normalizeValueWithProperty(item, key))
618
+ : normalizeValueWithProperty(value, key),
619
+ ])
620
+ )
566
621
  if (popperHandledTransform && 'transform' in waapiDiff) {
567
622
  waapiDiff = { ...waapiDiff }
568
623
  delete waapiDiff.transform
@@ -577,7 +632,11 @@ export function createAnimations<A extends Record<string, AnimationConfig>>(
577
632
  }
578
633
 
579
634
  if (Object.keys(waapiDiff).length > 0) {
580
- startedControls = animate(scope.current, waapiDiff, animationOptions)
635
+ startedControls = animateMotionValue(
636
+ node,
637
+ waapiDiff,
638
+ animationOptions
639
+ ) as AnimationPlaybackControlsWithThen
581
640
  refs.current.controls = startedControls
582
641
  }
583
642
  refs.current.lastAnimateAt = Date.now()
@@ -589,30 +648,91 @@ export function createAnimations<A extends Record<string, AnimationConfig>>(
589
648
  } finally {
590
649
  // exit completion: notify AnimatePresence when exit animation finishes
591
650
  if (isCurrentlyExiting && currentSendExitComplete) {
651
+ // an enter animation that was still in flight is now interrupted
652
+ if (refs.current.enterStarted) {
653
+ refs.current.enterStarted = false
654
+ emit('end', 'enter', false)
655
+ }
592
656
  if (startedControls) {
593
657
  // new animation started — attach completion handler
658
+ if (!refs.current.exitStarted) {
659
+ refs.current.exitStarted = true
660
+ emit('start', 'exit')
661
+ }
594
662
  refs.current.exitCompleteScheduled = true
663
+ const complete = (finished: boolean) => {
664
+ // guard: only complete if still exiting (prevents stale promise
665
+ // from calling sendExitComplete after a re-entry cancels the exit)
666
+ if (!refs.current.isExiting) return
667
+ if (refs.current.exitStarted) {
668
+ refs.current.exitStarted = false
669
+ // exit 'end' fires immediately before presence safeToRemove
670
+ emit('end', 'exit', finished)
671
+ }
672
+ currentSendExitComplete()
673
+ }
595
674
  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
- })
675
+ .then(() => complete(true))
676
+ .catch(() => complete(false))
608
677
  } else if (!refs.current.exitCompleteScheduled) {
609
678
  // no animation started AND none previously scheduled (e.g. diff=null
610
679
  // on re-render mid-exit because frozenExitTarget matches lastDoAnimate)
611
680
  // — complete immediately only if we've never started an exit animation
681
+ emit('start', 'exit')
682
+ emit('end', 'exit', true)
612
683
  currentSendExitComplete()
613
684
  }
614
685
  // else: exit animation already scheduled via a previous flush,
615
686
  // its .finished promise will call sendExitComplete when done
687
+ } else if (currentAnimationState === 'enter') {
688
+ if (startedControls) {
689
+ if (!refs.current.enterStarted) {
690
+ refs.current.enterStarted = true
691
+ emit('start', 'enter')
692
+ }
693
+ refs.current.enterCompleteScheduled = true
694
+ const complete = (finished: boolean) => {
695
+ if (refs.current.disposed || refs.current.animationState !== 'enter')
696
+ return
697
+ if (refs.current.enterStarted) {
698
+ refs.current.enterStarted = false
699
+ emit('end', 'enter', finished)
700
+ }
701
+ }
702
+ startedControls.finished
703
+ .then(() => complete(true))
704
+ .catch(() => complete(false))
705
+ } else if (
706
+ !refs.current.enterCompleteScheduled &&
707
+ !refs.current.disableAnimation
708
+ ) {
709
+ // no animation needed for this enter (no style diff) — complete
710
+ // immediately. skipped while animation is disabled (mount flushes
711
+ // apply enter styles without animating; completing there would
712
+ // report enter done before the real transition even starts)
713
+ emit('start', 'enter')
714
+ emit('end', 'enter', true)
715
+ }
716
+ } else if (startedControls) {
717
+ // update: a style change while mounted. a new update that supersedes
718
+ // an in-flight one closes it out as finished:false (motion silently
719
+ // replaces the conflicting property animation without settling the
720
+ // old controls, so we can't rely on its promise for the interrupted
721
+ // one). the latest controls reports true on natural completion.
722
+ const controls = startedControls
723
+ if (refs.current.updateInFlight) {
724
+ emit('end', 'update', false)
725
+ }
726
+ refs.current.updateInFlight = true
727
+ refs.current.updateControls = controls
728
+ emit('start', 'update')
729
+ const settle = (finished: boolean) => {
730
+ if (refs.current.updateControls !== controls) return
731
+ refs.current.updateInFlight = false
732
+ refs.current.updateControls = null
733
+ emit('end', 'update', finished)
734
+ }
735
+ controls.finished.then(() => settle(true)).catch(() => settle(false))
616
736
  }
617
737
  }
618
738
  }
@@ -681,8 +801,8 @@ export function createAnimations<A extends Record<string, AnimationConfig>>(
681
801
  if ((justEnabled || justStrippedClasses) && animationState !== 'enter') {
682
802
  const node = stateRef.current.host
683
803
  if (node instanceof HTMLElement) {
684
- if (dontAnimate) Object.assign(node.style, dontAnimate)
685
- if (doAnimate) Object.assign(node.style, doAnimate)
804
+ if (dontAnimate) assignInlineStyles(node, dontAnimate)
805
+ if (doAnimate) assignInlineStyles(node, doAnimate)
686
806
  // keep the popper position motion values in sync with the direct
687
807
  // inline write so a later retarget doesn't start from a stale spot
688
808
  const entry = PopperPositionAnims.get(node)
@@ -1028,13 +1148,103 @@ export const disableAnimationProps: Set<string> = new Set<string>([
1028
1148
  'userSelect',
1029
1149
  ])
1030
1150
 
1151
+ // props equality for the getSplitStyles memo: functions and children can't
1152
+ // affect style output (they pass through), so they don't participate
1153
+ function motionPropsEqual(a: Record<string, any>, b: Record<string, any>) {
1154
+ for (const key in a) {
1155
+ if (!(key in b)) return false
1156
+ }
1157
+ for (const key in b) {
1158
+ if (key === 'children') continue
1159
+ const av = a[key]
1160
+ const bv = b[key]
1161
+ if (typeof bv === 'function' && typeof av === 'function') continue
1162
+ if (!Object.is(av, bv)) return false
1163
+ }
1164
+ return true
1165
+ }
1166
+
1167
+ // one-level style object comparison: style arrays are recreated per render
1168
+ // with usually-identical contents
1169
+ function motionStylesEqual(a: any[], b: any[]) {
1170
+ if (a.length !== b.length) return false
1171
+ for (let i = 0; i < a.length; i++) {
1172
+ const sa = a[i]
1173
+ const sb = b[i]
1174
+ if (sa === sb) continue
1175
+ if (!sa || !sb) return false
1176
+ let aCount = 0
1177
+ for (const key in sa) {
1178
+ aCount++
1179
+ if (!Object.is(sa[key], sb[key])) return false
1180
+ }
1181
+ let bCount = 0
1182
+ for (const _key in sb) {
1183
+ bCount++
1184
+ }
1185
+ if (aCount !== bCount) return false
1186
+ }
1187
+ return true
1188
+ }
1189
+
1031
1190
  const MotionView = createMotionView('div')
1032
1191
  const MotionText = createMotionView('span')
1033
1192
 
1193
+ const transformAliases: Record<string, string> = {
1194
+ x: 'translateX',
1195
+ y: 'translateY',
1196
+ scale: 'scale',
1197
+ scaleX: 'scaleX',
1198
+ scaleY: 'scaleY',
1199
+ rotate: 'rotate',
1200
+ rotateX: 'rotateX',
1201
+ rotateY: 'rotateY',
1202
+ rotateZ: 'rotateZ',
1203
+ skewX: 'skewX',
1204
+ skewY: 'skewY',
1205
+ }
1206
+
1207
+ function compileAnimatedStyle(
1208
+ initialSource: Record<string, unknown>,
1209
+ initialResolved: Record<string, unknown>
1210
+ ) {
1211
+ const shorthands = getConfig().shorthands
1212
+ const entries = Object.keys(initialSource).map((source) => ({
1213
+ source,
1214
+ target: shorthands?.[source] ?? source,
1215
+ tokenValue:
1216
+ typeof initialSource[source] === 'string' &&
1217
+ initialResolved[shorthands?.[source] ?? source] !== initialSource[source]
1218
+ ? initialResolved[shorthands?.[source] ?? source]
1219
+ : undefined,
1220
+ }))
1221
+
1222
+ return (sourceStyle: Record<string, unknown>) => {
1223
+ const resolved: Record<string, unknown> = {}
1224
+ const transforms: Record<string, unknown>[] = []
1225
+
1226
+ for (const entry of entries) {
1227
+ const value = sourceStyle[entry.source]
1228
+ if (value === undefined) continue
1229
+ if (entry.source === 'transform' && Array.isArray(value)) {
1230
+ resolved.transform = transformsToString(value)
1231
+ } else if (entry.source in transformAliases) {
1232
+ transforms.push({ [transformAliases[entry.source]]: value })
1233
+ } else {
1234
+ resolved[entry.target] =
1235
+ entry.tokenValue ?? normalizeValueWithProperty(value, entry.target)
1236
+ }
1237
+ }
1238
+
1239
+ if (transforms.length) resolved.transform = transformsToString(transforms)
1240
+ return resolved
1241
+ }
1242
+ }
1243
+
1034
1244
  function createMotionView(defaultTag: string) {
1035
1245
  const isText = defaultTag === 'span'
1036
1246
 
1037
- const Component = forwardRef((propsIn: any, ref) => {
1247
+ const Component = createRefComponent((propsIn: any, ref) => {
1038
1248
  const { forwardedRef, animation, render = defaultTag, style, ...propsRest } = propsIn
1039
1249
  const [scope, animate] = useAnimateSSRSafe()
1040
1250
  const hostRef = useRef<HTMLElement>(null)
@@ -1070,6 +1280,13 @@ function createMotionView(defaultTag: string) {
1070
1280
  })()
1071
1281
 
1072
1282
  function getProps(props: any) {
1283
+ if (
1284
+ process.env.NODE_ENV === 'development' &&
1285
+ propsIn.debug === 'profile' &&
1286
+ typeof performance !== 'undefined'
1287
+ ) {
1288
+ performance.mark('tamagui-motion-style-split')
1289
+ }
1073
1290
  const out = getSplitStyles(
1074
1291
  props,
1075
1292
  isText ? Text.staticConfig : View.staticConfig,
@@ -1097,12 +1314,76 @@ function createMotionView(defaultTag: string) {
1097
1314
  return out.viewProps
1098
1315
  }
1099
1316
 
1100
- const props = getProps({ ...propsRest, style: nonAnimatedStyles })
1317
+ const resolvedAnimatedStyle = useMemo(() => {
1318
+ if (!animatedStyle) return null
1319
+ const currentValues = animatedStyle.motionValues
1320
+ ? animatedStyle.motionValues.map((value) => value.get())
1321
+ : animatedStyle.motionValue
1322
+ ? [animatedStyle.motionValue.get()]
1323
+ : []
1324
+ const initialSource = animatedStyle.getStyle(...currentValues)
1325
+ const initialResolved = asStyleRecord(getProps({ style: initialSource }).style)
1326
+ return {
1327
+ initial: initialResolved,
1328
+ resolve: compileAnimatedStyle(initialSource, initialResolved),
1329
+ }
1330
+ }, [animatedStyle, state?.theme, state?.name])
1331
+
1332
+ // memoize the full getSplitStyles pass: it costs ~90us per render and its
1333
+ // style-affecting inputs are usually unchanged. functions and children
1334
+ // pass through getSplitStyles untouched, so they refresh on cache hits
1335
+ // without invalidating.
1336
+ const memoRef = useRef<null | {
1337
+ propsRest: any
1338
+ styles: any[]
1339
+ theme: any
1340
+ themeName: string | undefined
1341
+ result: any
1342
+ }>(null)
1343
+
1344
+ let props: any
1345
+ const cached = memoRef.current
1346
+ if (
1347
+ cached &&
1348
+ cached.theme === state?.theme &&
1349
+ cached.themeName === state?.name &&
1350
+ motionStylesEqual(cached.styles, nonAnimatedStyles) &&
1351
+ motionPropsEqual(cached.propsRest, propsRest)
1352
+ ) {
1353
+ props = { ...cached.result }
1354
+ for (const key in propsRest) {
1355
+ const val = propsRest[key]
1356
+ if (key === 'children' || typeof val === 'function') {
1357
+ props[key] = val
1358
+ }
1359
+ }
1360
+ } else {
1361
+ props = getProps({ ...propsRest, style: nonAnimatedStyles })
1362
+ memoRef.current = {
1363
+ propsRest,
1364
+ styles: nonAnimatedStyles,
1365
+ theme: state?.theme,
1366
+ themeName: state?.name,
1367
+ result: props,
1368
+ }
1369
+ }
1370
+
1371
+ if (resolvedAnimatedStyle) {
1372
+ // reassign so the animated initial never leaks into the memo cache
1373
+ props = {
1374
+ ...props,
1375
+ style: {
1376
+ ...asStyleRecord(props.style),
1377
+ ...resolvedAnimatedStyle.initial,
1378
+ },
1379
+ }
1380
+ }
1381
+
1101
1382
  const Element = render || 'div'
1102
1383
  const transformedProps = hooks.usePropsTransform?.(render, props, stateRef, false)
1103
1384
 
1104
- // consumers set animated numbers from layout effects, so subscribe before
1105
- // passive effects to avoid missing a value set in the mounting commit.
1385
+ // subscribe before passive effects so a layout effect cannot set a value
1386
+ // between this node mounting and its MotionValue subscription.
1106
1387
  useIsomorphicLayoutEffect(() => {
1107
1388
  if (!animatedStyle) return
1108
1389
 
@@ -1115,15 +1396,13 @@ function createMotionView(defaultTag: string) {
1115
1396
  : animatedStyle.motionValue
1116
1397
  ? animatedStyle.getStyle(animatedStyle.motionValue.get())
1117
1398
  : null
1118
- const webStyle = currentStyle && getProps({ style: currentStyle }).style
1399
+ const webStyle = currentStyle && resolvedAnimatedStyle?.resolve(currentStyle)
1119
1400
  if (webStyle) {
1120
- Object.assign(node.style, webStyle)
1401
+ assignInlineStyles(node, webStyle)
1121
1402
  seededNode.current = node
1122
1403
  seededInCurrentFrame.current = true
1123
1404
  requestAnimationFrame(() => {
1124
- if (seededNode.current === node) {
1125
- seededInCurrentFrame.current = false
1126
- }
1405
+ if (seededNode.current === node) seededInCurrentFrame.current = false
1127
1406
  })
1128
1407
  }
1129
1408
  }
@@ -1132,7 +1411,7 @@ function createMotionView(defaultTag: string) {
1132
1411
  animationConfig: AnimatedNumberStrategy | undefined
1133
1412
  ): AnimationOptions =>
1134
1413
  animationConfig?.type === 'timing'
1135
- ? { type: 'tween', duration: (animationConfig?.duration || 0) / 1000 }
1414
+ ? { type: 'tween', duration: (animationConfig.duration || 0) / 1000 }
1136
1415
  : animationConfig?.type === 'direct'
1137
1416
  ? { type: 'tween', duration: 0 }
1138
1417
  : { type: 'spring', ...(animationConfig as any) }
@@ -1140,44 +1419,47 @@ function createMotionView(defaultTag: string) {
1140
1419
  const animateNodeTo = (
1141
1420
  nextStyle: Record<string, unknown>,
1142
1421
  transition: AnimationOptions,
1143
- mv: MotionValue
1422
+ motionValue: MotionValue
1144
1423
  ) => {
1145
1424
  if (!(node instanceof HTMLElement) || hostRef.current !== node) return
1146
- const webStyle = getProps({ style: nextStyle }).style
1425
+ const webStyle = resolvedAnimatedStyle?.resolve(nextStyle)
1147
1426
  if (!webStyle) return
1148
- settlePendingMotionOnFinish(mv, animate(node, webStyle as any, transition))
1427
+ settlePendingMotionOnFinish(
1428
+ motionValue,
1429
+ animate(node, webStyle as any, transition)
1430
+ )
1149
1431
  }
1432
+
1150
1433
  const animateChangedValue = (
1151
1434
  nextStyle: Record<string, unknown>,
1152
1435
  transition: AnimationOptions,
1153
- mv: MotionValue
1436
+ motionValue: MotionValue
1154
1437
  ) => {
1155
1438
  if (seededInCurrentFrame.current && seededNode.current === node) {
1156
- const queuedFrame = queuedAnimationFrames.current.get(mv)
1157
- if (queuedFrame !== undefined) {
1158
- cancelAnimationFrame(queuedFrame)
1159
- }
1439
+ const queuedFrame = queuedAnimationFrames.current.get(motionValue)
1440
+ if (queuedFrame !== undefined) cancelAnimationFrame(queuedFrame)
1160
1441
  const frame = requestAnimationFrame(() => {
1161
- if (queuedAnimationFrames.current.get(mv) !== frame) return
1162
- queuedAnimationFrames.current.delete(mv)
1442
+ if (queuedAnimationFrames.current.get(motionValue) !== frame) return
1443
+ queuedAnimationFrames.current.delete(motionValue)
1163
1444
  if (hostRef.current === node) {
1164
- animateNodeTo(nextStyle, transition, mv)
1445
+ animateNodeTo(nextStyle, transition, motionValue)
1165
1446
  } else {
1166
- const onFinish = PendingMotionOnFinish.get(mv)
1167
- PendingMotionOnFinish.delete(mv)
1447
+ const onFinish = PendingMotionOnFinish.get(motionValue)
1448
+ PendingMotionOnFinish.delete(motionValue)
1168
1449
  onFinish?.()
1169
1450
  }
1170
1451
  })
1171
- queuedAnimationFrames.current.set(mv, frame)
1452
+ queuedAnimationFrames.current.set(motionValue, frame)
1172
1453
  return
1173
1454
  }
1174
- animateNodeTo(nextStyle, transition, mv)
1455
+ animateNodeTo(nextStyle, transition, motionValue)
1175
1456
  }
1176
1457
 
1177
1458
  // multi-value path: subscribe to all motion values
1178
1459
  if (animatedStyle.motionValues) {
1179
1460
  const mvs = animatedStyle.motionValues
1180
- const styleForAll = () => animatedStyle.getStyle(...mvs.map((v) => v.get()))
1461
+ const styleForAll = () =>
1462
+ animatedStyle.getStyle(...mvs.map((value) => value.get()))
1181
1463
  const unsubs = mvs.map((mv) =>
1182
1464
  mv.on('change', () =>
1183
1465
  animateChangedValue(
@@ -1201,7 +1483,7 @@ function createMotionView(defaultTag: string) {
1201
1483
  motionValue
1202
1484
  )
1203
1485
  )
1204
- }, [animatedStyle])
1486
+ }, [animatedStyle, resolvedAnimatedStyle])
1205
1487
 
1206
1488
  return <Element {...transformedProps} ref={composedRefs} />
1207
1489
  })
@@ -1,13 +1,13 @@
1
1
  // native stub - animations-motion only works on web (uses framer-motion/motion library)
2
2
  // on native, use @tamagui/animations-react-native or @tamagui/animations-reanimated
3
3
 
4
- import type { AnimationDriver } from '@tamagui/web'
4
+ import type { AnimationDriverStub } from '@tamagui/web'
5
5
 
6
6
  let hasWarnedOnce = false
7
7
 
8
8
  export function createAnimations<A extends Record<string, any>>(
9
9
  _animations: A
10
- ): AnimationDriver<A> {
10
+ ): AnimationDriverStub<A> {
11
11
  if (process.env.NODE_ENV === 'development') {
12
12
  if (!hasWarnedOnce) {
13
13
  hasWarnedOnce = true
@@ -17,10 +17,10 @@ export function createAnimations<A extends Record<string, any>>(
17
17
  }
18
18
  }
19
19
 
20
- // return a noop driver
21
- // @ts-expect-error its an error anyway
20
+ // return an explicit unsupported driver so config resolution can skip it
22
21
  return {
23
22
  isReactNative: false,
23
+ isStub: true,
24
24
  animations: _animations,
25
25
  View: undefined as any,
26
26
  Text: undefined as any,