@tamagui/animations-css 2.0.0-rc.3 → 2.0.0-rc.30

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,6 +1 @@
1
- {
2
- "version": 3,
3
- "sources": ["../../src/index.ts"],
4
- "mappings": "AAAA,cAAc;",
5
- "names": []
6
- }
1
+ {"version":3,"names":[],"sources":["../../src/index.ts"],"sourcesContent":[null],"mappings":"AAAA,cAAc","ignoreList":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tamagui/animations-css",
3
- "version": "2.0.0-rc.3",
3
+ "version": "2.0.0-rc.30",
4
4
  "gitHead": "a49cc7ea6b93ba384e77a4880ae48ac4a5635c14",
5
5
  "license": "MIT",
6
6
  "source": "src/index.ts",
@@ -18,15 +18,12 @@
18
18
  "./package.json": "./package.json",
19
19
  ".": {
20
20
  "types": "./types/index.d.ts",
21
- "react-native": {
22
- "module": "./dist/esm/index.native.js",
23
- "import": "./dist/esm/index.native.js",
24
- "require": "./dist/cjs/index.native.js"
25
- },
21
+ "react-native": "./dist/esm/index.native.js",
22
+ "browser": "./dist/esm/index.mjs",
26
23
  "module": "./dist/esm/index.mjs",
27
24
  "import": "./dist/esm/index.mjs",
28
25
  "require": "./dist/cjs/index.cjs",
29
- "default": "./dist/cjs/index.native.js"
26
+ "default": "./dist/esm/index.mjs"
30
27
  }
31
28
  },
32
29
  "publishConfig": {
@@ -39,14 +36,14 @@
39
36
  "clean:build": "tamagui-build clean:build"
40
37
  },
41
38
  "dependencies": {
42
- "@tamagui/animation-helpers": "2.0.0-rc.3",
43
- "@tamagui/constants": "2.0.0-rc.3",
44
- "@tamagui/cubic-bezier-animator": "2.0.0-rc.3",
45
- "@tamagui/use-presence": "2.0.0-rc.3",
46
- "@tamagui/web": "2.0.0-rc.3"
39
+ "@tamagui/animation-helpers": "2.0.0-rc.30",
40
+ "@tamagui/constants": "2.0.0-rc.30",
41
+ "@tamagui/cubic-bezier-animator": "2.0.0-rc.30",
42
+ "@tamagui/use-presence": "2.0.0-rc.30",
43
+ "@tamagui/web": "2.0.0-rc.30"
47
44
  },
48
45
  "devDependencies": {
49
- "@tamagui/build": "2.0.0-rc.3",
46
+ "@tamagui/build": "2.0.0-rc.30",
50
47
  "react": ">=19",
51
48
  "react-dom": "*"
52
49
  },
@@ -3,6 +3,7 @@ import {
3
3
  getAnimatedProperties,
4
4
  hasAnimation as hasNormalizedAnimation,
5
5
  getEffectiveAnimation,
6
+ getAnimationConfigsForKeys,
6
7
  } from '@tamagui/animation-helpers'
7
8
  import { useIsomorphicLayoutEffect } from '@tamagui/constants'
8
9
  import { ResetPresence, usePresence } from '@tamagui/use-presence'
@@ -10,6 +11,9 @@ import type { AnimationDriver, UniversalAnimatedNumber } from '@tamagui/web'
10
11
  import { transformsToString } from '@tamagui/web'
11
12
  import React, { useState } from 'react' // import { animate } from '@tamagui/cubic-bezier-animator'
12
13
 
14
+ const EXTRACT_MS_REGEX = /(\d+(?:\.\d+)?)\s*ms/
15
+ const EXTRACT_S_REGEX = /(\d+(?:\.\d+)?)\s*s/
16
+
13
17
  /**
14
18
  * Helper function to extract duration from CSS animation string
15
19
  * Examples: "ease-in 200ms" -> 200, "cubic-bezier(0.215, 0.610, 0.355, 1.000) 400ms" -> 400
@@ -17,13 +21,13 @@ import React, { useState } from 'react' // import { animate } from '@tamagui/cub
17
21
  */
18
22
  function extractDuration(animation: string): number {
19
23
  // Try to match milliseconds first
20
- const msMatch = animation.match(/(\d+(?:\.\d+)?)\s*ms/)
24
+ const msMatch = animation.match(EXTRACT_MS_REGEX)
21
25
  if (msMatch) {
22
26
  return Number.parseInt(msMatch[1], 10)
23
27
  }
24
28
 
25
29
  // Try to match seconds and convert to milliseconds
26
- const sMatch = animation.match(/(\d+(?:\.\d+)?)\s*s/)
30
+ const sMatch = animation.match(EXTRACT_S_REGEX)
27
31
  if (sMatch) {
28
32
  return Math.round(Number.parseFloat(sMatch[1]) * 1000)
29
33
  }
@@ -32,6 +36,124 @@ function extractDuration(animation: string): number {
32
36
  return 300
33
37
  }
34
38
 
39
+ const MS_DURATION_REGEX = /(\d+(?:\.\d+)?)\s*ms/
40
+ const S_DURATION_REGEX = /(\d+(?:\.\d+)?)\s*s(?!tiffness)/
41
+
42
+ /**
43
+ * Apply duration override to a CSS animation string
44
+ * Replaces the existing duration with the override value
45
+ */
46
+ function applyDurationOverride(animation: string, durationMs: number): string {
47
+ // Replace ms duration
48
+ const msReplaced = animation.replace(MS_DURATION_REGEX, `${durationMs}ms`)
49
+ if (msReplaced !== animation) {
50
+ return msReplaced
51
+ }
52
+
53
+ // Replace seconds duration
54
+ const sReplaced = animation.replace(S_DURATION_REGEX, `${durationMs}ms`)
55
+ if (sReplaced !== animation) {
56
+ return sReplaced
57
+ }
58
+
59
+ // No duration found, prepend the duration
60
+ return `${durationMs}ms ${animation}`
61
+ }
62
+
63
+ // transform keys that need special handling
64
+ const TRANSFORM_KEYS = [
65
+ 'x',
66
+ 'y',
67
+ 'scale',
68
+ 'scaleX',
69
+ 'scaleY',
70
+ 'rotate',
71
+ 'rotateX',
72
+ 'rotateY',
73
+ 'rotateZ',
74
+ 'skewX',
75
+ 'skewY',
76
+ ] as const
77
+
78
+ /**
79
+ * Build a CSS transform string from a style object containing transform properties
80
+ */
81
+ function buildTransformString(style: Record<string, unknown> | undefined): string {
82
+ if (!style) return ''
83
+
84
+ const parts: string[] = []
85
+
86
+ if (style.x !== undefined || style.y !== undefined) {
87
+ const x = style.x ?? 0
88
+ const y = style.y ?? 0
89
+ parts.push(`translate(${x}px, ${y}px)`)
90
+ }
91
+ if (style.scale !== undefined) {
92
+ parts.push(`scale(${style.scale})`)
93
+ }
94
+ if (style.scaleX !== undefined) {
95
+ parts.push(`scaleX(${style.scaleX})`)
96
+ }
97
+ if (style.scaleY !== undefined) {
98
+ parts.push(`scaleY(${style.scaleY})`)
99
+ }
100
+ if (style.rotate !== undefined) {
101
+ const val = style.rotate
102
+ const unit = typeof val === 'string' && val.includes('deg') ? '' : 'deg'
103
+ parts.push(`rotate(${val}${unit})`)
104
+ }
105
+ if (style.rotateX !== undefined) {
106
+ parts.push(`rotateX(${style.rotateX}deg)`)
107
+ }
108
+ if (style.rotateY !== undefined) {
109
+ parts.push(`rotateY(${style.rotateY}deg)`)
110
+ }
111
+ if (style.rotateZ !== undefined) {
112
+ parts.push(`rotateZ(${style.rotateZ}deg)`)
113
+ }
114
+ if (style.skewX !== undefined) {
115
+ parts.push(`skewX(${style.skewX}deg)`)
116
+ }
117
+ if (style.skewY !== undefined) {
118
+ parts.push(`skewY(${style.skewY}deg)`)
119
+ }
120
+
121
+ return parts.join(' ')
122
+ }
123
+
124
+ /**
125
+ * Apply a style object to a DOM node, handling transform keys specially
126
+ */
127
+ function applyStylesToNode(
128
+ node: HTMLElement,
129
+ style: Record<string, unknown> | undefined
130
+ ): void {
131
+ if (!style) return
132
+
133
+ // collect transform values
134
+ const transformStr = buildTransformString(style)
135
+ if (transformStr) {
136
+ node.style.transform = transformStr
137
+ }
138
+
139
+ // apply non-transform properties
140
+ for (const [key, value] of Object.entries(style)) {
141
+ if (TRANSFORM_KEYS.includes(key as any)) continue
142
+ if (value === undefined) continue
143
+
144
+ if (key === 'opacity') {
145
+ node.style.opacity = String(value)
146
+ } else if (key === 'backgroundColor') {
147
+ node.style.backgroundColor = String(value)
148
+ } else if (key === 'color') {
149
+ node.style.color = String(value)
150
+ } else {
151
+ // generic fallback
152
+ node.style[key as any] = typeof value === 'number' ? `${value}px` : String(value)
153
+ }
154
+ }
155
+ }
156
+
35
157
  export function createAnimations<A extends object>(animations: A): AnimationDriver<A> {
36
158
  const reactionListeners = new WeakMap<any, Set<Function>>()
37
159
 
@@ -39,21 +161,12 @@ export function createAnimations<A extends object>(animations: A): AnimationDriv
39
161
  animations,
40
162
  usePresence,
41
163
  ResetPresence,
42
- supportsCSS: true,
43
164
  inputStyle: 'css',
44
165
  outputStyle: 'css',
45
- classNameAnimation: true,
46
166
 
47
167
  useAnimatedNumber(initial): UniversalAnimatedNumber<Function> {
48
168
  const [val, setVal] = React.useState(initial)
49
- const [onFinish, setOnFinish] = useState<Function | undefined>()
50
-
51
- useIsomorphicLayoutEffect(() => {
52
- if (onFinish) {
53
- onFinish?.()
54
- setOnFinish(undefined)
55
- }
56
- }, [onFinish])
169
+ const finishTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
57
170
 
58
171
  return {
59
172
  getInstance() {
@@ -64,14 +177,40 @@ export function createAnimations<A extends object>(animations: A): AnimationDriv
64
177
  },
65
178
  setValue(next, config, onFinish) {
66
179
  setVal(next)
67
- setOnFinish(onFinish)
180
+
181
+ // clear any pending finish callback from a previous setValue
182
+ if (finishTimerRef.current) {
183
+ clearTimeout(finishTimerRef.current)
184
+ finishTimerRef.current = null
185
+ }
186
+
187
+ if (onFinish) {
188
+ if (
189
+ !config ||
190
+ config.type === 'direct' ||
191
+ (config.type === 'timing' && config.duration === 0)
192
+ ) {
193
+ onFinish()
194
+ } else {
195
+ // estimate duration: use explicit duration, or fall back to
196
+ // default CSS transition duration for spring-type configs
197
+ const duration = config.type === 'timing' ? config.duration : 300
198
+ finishTimerRef.current = setTimeout(onFinish, duration)
199
+ }
200
+ }
201
+
68
202
  // call reaction listeners with the new value
69
203
  const listeners = reactionListeners.get(setVal)
70
204
  if (listeners) {
71
205
  listeners.forEach((listener) => listener(next))
72
206
  }
73
207
  },
74
- stop() {},
208
+ stop() {
209
+ if (finishTimerRef.current) {
210
+ clearTimeout(finishTimerRef.current)
211
+ finishTimerRef.current = null
212
+ }
213
+ },
75
214
  }
76
215
  },
77
216
 
@@ -95,7 +234,16 @@ export function createAnimations<A extends object>(animations: A): AnimationDriv
95
234
  return getStyle(val.getValue())
96
235
  },
97
236
 
98
- useAnimations: ({ props, presence, style, componentState, stateRef }) => {
237
+ // @ts-ignore - styleState is added by createComponent
238
+ useAnimations: ({
239
+ props,
240
+ presence,
241
+ style,
242
+ componentState,
243
+ stateRef,
244
+ styleState,
245
+ }: any) => {
246
+ const isHydrating = componentState.unmounted === true
99
247
  const isEntering = !!componentState.unmounted
100
248
  const isExiting = presence?.[0] === false
101
249
  const sendExitComplete = presence?.[1]
@@ -108,8 +256,37 @@ export function createAnimations<A extends object>(animations: A): AnimationDriv
108
256
  wasEnteringRef.current = isEntering
109
257
  })
110
258
 
259
+ // exit cycle guards to prevent stale/duplicate completion
260
+ const exitCycleIdRef = React.useRef(0)
261
+ const exitCompletedRef = React.useRef(false)
262
+ const wasExitingRef = React.useRef(false)
263
+ const exitInterruptedRef = React.useRef(false)
264
+
265
+ // detect transition into/out of exiting state
266
+ const justStartedExiting = isExiting && !wasExitingRef.current
267
+ const justStoppedExiting = !isExiting && wasExitingRef.current
268
+
269
+ // start new exit cycle only on transition INTO exiting
270
+ if (justStartedExiting) {
271
+ exitCycleIdRef.current++
272
+ exitCompletedRef.current = false
273
+ }
274
+ // track interruptions so we know to force-restart transitions
275
+ if (justStoppedExiting) {
276
+ exitCycleIdRef.current++
277
+ exitInterruptedRef.current = true
278
+ }
279
+
280
+ // track previous exiting state
281
+ React.useEffect(() => {
282
+ wasExitingRef.current = isExiting
283
+ })
284
+
285
+ // use effectiveTransition computed by createComponent (single source of truth)
286
+ const effectiveTransition = styleState?.effectiveTransition ?? props.transition
287
+
111
288
  // Normalize the transition prop to a consistent format
112
- const normalized = normalizeTransition(props.transition)
289
+ const normalized = normalizeTransition(effectiveTransition)
113
290
 
114
291
  // Determine animation state and get effective animation
115
292
  // Use 'enter' if we're entering OR if we just finished entering (transition is happening)
@@ -155,6 +332,137 @@ export function createAnimations<A extends object>(animations: A): AnimationDriv
155
332
  if (!sendExitComplete || !isExiting || !host) return
156
333
  const node = host as HTMLElement
157
334
 
335
+ // capture current cycle id for this effect
336
+ const cycleId = exitCycleIdRef.current
337
+
338
+ // helper to complete exit with guards
339
+ const completeExit = () => {
340
+ if (cycleId !== exitCycleIdRef.current) return
341
+ if (exitCompletedRef.current) return
342
+ exitCompletedRef.current = true
343
+ sendExitComplete()
344
+ }
345
+
346
+ // if no properties to animate (animateOnly=[]), complete immediately
347
+ if (keys.length === 0) {
348
+ completeExit()
349
+ return
350
+ }
351
+
352
+ // Force transition restart for interrupted exits
353
+ // When an exit is interrupted and restarted, the element may already be at
354
+ // the exit style, so no CSS transition fires. We need to:
355
+ // 1. Reset to non-exit state
356
+ // 2. Force reflow
357
+ // 3. Re-apply exit state to trigger transition
358
+ let rafId: number | undefined
359
+ const wasInterrupted = exitInterruptedRef.current
360
+ // flag to ignore transitioncancel during reset (we intentionally cancel the old transition)
361
+ let ignoreCancelEvents = wasInterrupted
362
+ // get enter/exit styles for potential restart
363
+ const enterStyle = props.enterStyle as Record<string, unknown> | undefined
364
+ const exitStyle = props.exitStyle as Record<string, unknown> | undefined
365
+
366
+ // Build the exit transition string - needed for both normal and interrupted exits
367
+ const delayStr = normalized.delay ? ` ${normalized.delay}ms` : ''
368
+ const durationOverride = normalized.config?.duration
369
+ const exitTransitionString = keys
370
+ .map((key) => {
371
+ const propAnimation = normalized.properties[key]
372
+ let animationValue: string | null = null
373
+ if (typeof propAnimation === 'string') {
374
+ animationValue = animations[propAnimation]
375
+ } else if (
376
+ propAnimation &&
377
+ typeof propAnimation === 'object' &&
378
+ propAnimation.type
379
+ ) {
380
+ animationValue = animations[propAnimation.type]
381
+ } else if (defaultAnimation) {
382
+ animationValue = defaultAnimation
383
+ }
384
+ if (animationValue && durationOverride) {
385
+ animationValue = applyDurationOverride(animationValue, durationOverride)
386
+ }
387
+ return animationValue ? `${key} ${animationValue}${delayStr}` : null
388
+ })
389
+ .filter(Boolean)
390
+ .join(', ')
391
+
392
+ if (wasInterrupted) {
393
+ exitInterruptedRef.current = false
394
+ // disable transition, reset to enter state
395
+ node.style.transition = 'none'
396
+
397
+ // reset: apply active/open state for each exit property (not enterStyle,
398
+ // which may equal exitStyle — see comment in the normal exit path below)
399
+ if (exitStyle) {
400
+ const resetStyle: Record<string, unknown> = {}
401
+ for (const key of Object.keys(exitStyle)) {
402
+ if (key === 'opacity') {
403
+ resetStyle[key] = 1
404
+ } else if (TRANSFORM_KEYS.includes(key as any)) {
405
+ resetStyle[key] =
406
+ key === 'scale' || key === 'scaleX' || key === 'scaleY' ? 1 : 0
407
+ } else if (enterStyle?.[key] !== undefined) {
408
+ resetStyle[key] = enterStyle[key]
409
+ }
410
+ }
411
+ applyStylesToNode(node, resetStyle)
412
+ } else {
413
+ // fallback if no exitStyle defined
414
+ node.style.opacity = '1'
415
+ node.style.transform = 'none'
416
+ }
417
+
418
+ // force reflow
419
+ void node.offsetHeight
420
+ } else if (exitStyle) {
421
+ // For normal (non-interrupted) exits, we need to ensure the CSS transition is
422
+ // processed by the browser BEFORE the exitStyle takes effect. The issue is that
423
+ // React may have already applied exitStyle in the same render batch. To fix this:
424
+ // 1. Disable transition and reset to non-exit state
425
+ // 2. Force reflow so browser processes the reset
426
+ // 3. Use RAF to ensure we're in a new frame
427
+ // 4. Re-enable transition and apply exitStyle
428
+ // This mirrors the interrupted exit handling approach (which also uses RAF).
429
+ ignoreCancelEvents = true
430
+ node.style.transition = 'none'
431
+
432
+ // Reset to the active/open state (not enterStyle, which may equal exitStyle).
433
+ // enterStyle is the "unmounted" initial state and can share values with exitStyle
434
+ // (e.g., both have opacity: 0). resetting to enterStyle would mean no value change
435
+ // when exitStyle is applied, so the CSS transition wouldn't fire.
436
+ const resetStyle: Record<string, unknown> = {}
437
+ for (const key of Object.keys(exitStyle)) {
438
+ if (key === 'opacity') {
439
+ resetStyle[key] = 1
440
+ } else if (TRANSFORM_KEYS.includes(key as any)) {
441
+ resetStyle[key] =
442
+ key === 'scale' || key === 'scaleX' || key === 'scaleY' ? 1 : 0
443
+ } else if (enterStyle?.[key] !== undefined) {
444
+ resetStyle[key] = enterStyle[key]
445
+ }
446
+ }
447
+ applyStylesToNode(node, resetStyle)
448
+
449
+ // Force reflow
450
+ void node.offsetHeight
451
+
452
+ // Use RAF to ensure transition is applied in a new frame
453
+ rafId = requestAnimationFrame(() => {
454
+ if (cycleId !== exitCycleIdRef.current) return
455
+ // Re-enable transition
456
+ node.style.transition = exitTransitionString
457
+ // Force reflow to ensure transition is active
458
+ void node.offsetHeight
459
+ // Apply exit styles - this triggers the animation
460
+ applyStylesToNode(node, exitStyle)
461
+ // Re-enable cancel event handling
462
+ ignoreCancelEvents = false
463
+ })
464
+ }
465
+
158
466
  /**
159
467
  * Exit animation handling for Dialog/Modal components
160
468
  *
@@ -162,34 +470,115 @@ export function createAnimations<A extends object>(animations: A): AnimationDriv
162
470
  * the element can disappear from the DOM before CSS transitions finish, which causes:
163
471
  * 1. Dialogs to stick around on screen
164
472
  * 2. Event handlers to stop working
473
+ *
474
+ * Fix: Calculate the MAXIMUM duration across all animated properties, not just
475
+ * the default. With animateOnly and per-property configs, different properties
476
+ * can have different durations, and we need to wait for the LONGEST one.
165
477
  */
166
478
 
167
- // Use timeout as primary, transition events as backup for reliable exit handling
168
- const animationDuration = defaultAnimation
169
- ? extractDuration(defaultAnimation)
170
- : 200
479
+ // calculate max duration across all animated properties
480
+ let maxDuration = defaultAnimation ? extractDuration(defaultAnimation) : 200
481
+
482
+ // check per-property animation durations using shared helper
483
+ const animationConfigs = getAnimationConfigsForKeys(
484
+ normalized,
485
+ animations as Record<string, string>,
486
+ keys,
487
+ defaultAnimation
488
+ )
489
+ for (const animationValue of animationConfigs.values()) {
490
+ if (animationValue) {
491
+ const duration = extractDuration(animationValue)
492
+ if (duration > maxDuration) {
493
+ maxDuration = duration
494
+ }
495
+ }
496
+ }
497
+
171
498
  const delay = normalized.delay ?? 0
172
- const fallbackTimeout = animationDuration + delay
499
+ const fallbackTimeout = maxDuration + delay
173
500
 
174
501
  const timeoutId = setTimeout(() => {
175
- sendExitComplete?.()
502
+ completeExit()
176
503
  }, fallbackTimeout)
177
504
 
178
- // Listen for transition completion events as backup
179
- const onFinishAnimation = () => {
505
+ // track number of transitioning properties to wait for all to finish
506
+ // (each property fires its own transitionend event)
507
+ const transitioningProps = new Set(keys)
508
+ let completedCount = 0
509
+
510
+ const onFinishAnimation = (event: TransitionEvent) => {
511
+ // only count transitions on THIS element, not bubbled from children
512
+ if (event.target !== node) return
513
+
514
+ // map CSS property names to our key names
515
+ // e.g., transitionend fires with propertyName 'transform' for scale/x/y
516
+ const eventProp = event.propertyName
517
+ if (transitioningProps.has(eventProp) || eventProp === 'all') {
518
+ completedCount++
519
+ // wait for all properties to finish
520
+ if (completedCount >= transitioningProps.size) {
521
+ clearTimeout(timeoutId)
522
+ completeExit()
523
+ }
524
+ }
525
+ }
526
+
527
+ // on cancel, still complete (element is exiting and animation was interrupted)
528
+ // the guards prevent duplicate completion if this is a stale cycle
529
+ const onCancelAnimation = () => {
530
+ // ignore cancel events during reset phase (we intentionally cancel the old transition)
531
+ if (ignoreCancelEvents) return
180
532
  clearTimeout(timeoutId)
181
- sendExitComplete?.()
533
+ completeExit()
182
534
  }
183
535
 
184
536
  node.addEventListener('transitionend', onFinishAnimation)
185
- node.addEventListener('transitioncancel', onFinishAnimation)
537
+ node.addEventListener('transitioncancel', onCancelAnimation)
538
+
539
+ // For interrupted exits, re-enable transition and re-apply exit styles
540
+ // This must happen AFTER listeners are set up so we catch the transitionend
541
+ if (wasInterrupted) {
542
+ rafId = requestAnimationFrame(() => {
543
+ if (cycleId !== exitCycleIdRef.current) return
544
+ // re-enable transition using the pre-built string
545
+ node.style.transition = exitTransitionString
546
+ // force reflow again
547
+ void node.offsetHeight
548
+ // now apply exit styles - this triggers the transition
549
+ applyStylesToNode(node, exitStyle)
550
+ // re-enable cancel event handling now that reset is complete
551
+ ignoreCancelEvents = false
552
+ })
553
+ }
186
554
 
187
555
  return () => {
188
556
  clearTimeout(timeoutId)
557
+ if (rafId !== undefined) cancelAnimationFrame(rafId)
189
558
  node.removeEventListener('transitionend', onFinishAnimation)
190
- node.removeEventListener('transitioncancel', onFinishAnimation)
559
+ node.removeEventListener('transitioncancel', onCancelAnimation)
560
+ // restore transition: the exit handling sets node.style.transition='none'
561
+ // directly on the DOM (bypassing React). if exit is interrupted (e.g. same-key
562
+ // re-entry in AnimatePresence), React won't re-apply its managed transition
563
+ // value because it hasn't changed in the virtual DOM. clearing the inline
564
+ // override lets React's value take effect again.
565
+ node.style.transition = ''
191
566
  }
192
- }, [sendExitComplete, isExiting])
567
+ }, [
568
+ sendExitComplete,
569
+ isExiting,
570
+ stateRef,
571
+ keys,
572
+ normalized,
573
+ defaultAnimation,
574
+ props.enterStyle,
575
+ props.exitStyle,
576
+ ])
577
+
578
+ // tamagui doesnt even use animation output during hydration
579
+ if (isHydrating) {
580
+ return null
581
+ }
193
582
 
194
583
  // Check if we have any animation to apply
195
584
  if (!hasNormalizedAnimation(normalized)) {
@@ -204,6 +593,7 @@ export function createAnimations<A extends object>(animations: A): AnimationDriv
204
593
  // TODO: we disabled the transform transition, because it will create issue for inverse function and animate function
205
594
  // for non layout transform properties either use animate function or find a workaround to do it with css
206
595
  const delayStr = normalized.delay ? ` ${normalized.delay}ms` : ''
596
+ const durationOverride = normalized.config?.duration
207
597
  style.transition = keys
208
598
  .map((key) => {
209
599
  // Check for property-specific animation, fall back to default
@@ -222,6 +612,11 @@ export function createAnimations<A extends object>(animations: A): AnimationDriv
222
612
  animationValue = defaultAnimation
223
613
  }
224
614
 
615
+ // Apply global duration override if specified
616
+ if (animationValue && durationOverride) {
617
+ animationValue = applyDurationOverride(animationValue, durationOverride)
618
+ }
619
+
225
620
  return animationValue ? `${key} ${animationValue}${delayStr}` : null
226
621
  })
227
622
  .filter(Boolean)