@tamagui/animations-css 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.
@@ -3,160 +3,141 @@ import {
3
3
  getAnimatedProperties,
4
4
  hasAnimation as hasNormalizedAnimation,
5
5
  getEffectiveAnimation,
6
- getAnimationConfigsForKeys,
7
6
  } from '@tamagui/animation-helpers'
8
7
  import { useIsomorphicLayoutEffect } from '@tamagui/constants'
9
8
  import { ResetPresence, usePresence } from '@tamagui/use-presence'
10
- import type { AnimationDriver, UniversalAnimatedNumber } from '@tamagui/web'
9
+ import type { AnimationDriver } from '@tamagui/web'
11
10
  import { transformsToString } from '@tamagui/web'
12
- import React, { useState } from 'react' // import { animate } from '@tamagui/cubic-bezier-animator'
11
+ import React from 'react'
13
12
 
14
- const EXTRACT_MS_REGEX = /(\d+(?:\.\d+)?)\s*ms/
15
- const EXTRACT_S_REGEX = /(\d+(?:\.\d+)?)\s*s/
16
-
17
- /**
18
- * Helper function to extract duration from CSS animation string
19
- * Examples: "ease-in 200ms" -> 200, "cubic-bezier(0.215, 0.610, 0.355, 1.000) 400ms" -> 400
20
- * "ease-in 0.5s" -> 500, "slow 2s" -> 2000
21
- */
22
- function extractDuration(animation: string): number {
23
- // Try to match milliseconds first
24
- const msMatch = animation.match(EXTRACT_MS_REGEX)
25
- if (msMatch) {
26
- return Number.parseInt(msMatch[1], 10)
27
- }
28
-
29
- // Try to match seconds and convert to milliseconds
30
- const sMatch = animation.match(EXTRACT_S_REGEX)
31
- if (sMatch) {
32
- return Math.round(Number.parseFloat(sMatch[1]) * 1000)
13
+ import {
14
+ useAnimatedNumber,
15
+ useAnimatedNumberReaction,
16
+ useAnimatedNumberStyle,
17
+ useAnimatedNumbersStyle,
18
+ } from './animated-number'
19
+
20
+ // rAF-driven animated number is browser-only. read (don't call) at module scope
21
+ // so ssr never touches requestAnimationFrame.
22
+ const hasRAF = typeof requestAnimationFrame !== 'undefined'
23
+
24
+ // resolve once all WAAPI animations on `node` finish. mirrors base-ui's
25
+ // useAnimationsFinished: resolves immediately when the browser exposes no
26
+ // animations (zero-animation elements), and re-checks after an aborted
27
+ // animation in case a property it depended on changed mid-flight and started a
28
+ // new one. falls back to immediate resolve when getAnimations is unavailable
29
+ // (ssr / older webviews). resolves `false` when animations were canceled with
30
+ // nothing left running (interruption).
31
+ function waitForAnimations(node: HTMLElement): Promise<boolean> {
32
+ if (typeof node.getAnimations !== 'function') {
33
+ return Promise.resolve(true)
33
34
  }
34
-
35
- // Default to 300ms if no duration found
36
- return 300
35
+ return new Promise<boolean>((resolve) => {
36
+ const check = () => {
37
+ const animations = node.getAnimations()
38
+ if (animations.length === 0) {
39
+ resolve(true)
40
+ return
41
+ }
42
+ Promise.all(animations.map((a) => a.finished))
43
+ .then(() => resolve(true))
44
+ .catch(() => {
45
+ const remaining = node.getAnimations()
46
+ if (remaining.some((a) => a.playState === 'running' || a.pending)) {
47
+ check()
48
+ return
49
+ }
50
+ resolve(false)
51
+ })
52
+ }
53
+ // css transitions register as pending until the next style recalc, so give
54
+ // the browser one frame to start them before we read getAnimations.
55
+ if (hasRAF) {
56
+ requestAnimationFrame(check)
57
+ } else {
58
+ check()
59
+ }
60
+ })
37
61
  }
38
62
 
39
- const MS_DURATION_REGEX = /(\d+(?:\.\d+)?)\s*ms/
40
- const S_DURATION_REGEX = /(\d+(?:\.\d+)?)\s*s(?!tiffness)/
63
+ const DURATION_REGEX = /(\d+(?:\.\d+)?)\s*(?:ms|s(?!tiffness))/
41
64
 
42
65
  /**
43
66
  * Apply duration override to a CSS animation string
44
67
  * Replaces the existing duration with the override value
45
68
  */
46
69
  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
- }
70
+ const replaced = animation.replace(DURATION_REGEX, `${durationMs}ms`)
71
+ return replaced === animation ? `${durationMs}ms ${animation}` : replaced
72
+ }
52
73
 
53
- // Replace seconds duration
54
- const sReplaced = animation.replace(S_DURATION_REGEX, `${durationMs}ms`)
55
- if (sReplaced !== animation) {
56
- return sReplaced
57
- }
74
+ const CSS_TRANSFORM_PROPERTIES: Record<string, string[]> = {
75
+ transform: ['translate', 'scale', 'rotate', 'transform'],
76
+ x: ['translate'],
77
+ y: ['translate'],
78
+ scale: ['scale'],
79
+ scaleX: ['scale'],
80
+ scaleY: ['scale'],
81
+ rotate: ['rotate'],
82
+ rotateX: ['transform'],
83
+ rotateY: ['transform'],
84
+ rotateZ: ['transform'],
85
+ skewX: ['transform'],
86
+ skewY: ['transform'],
87
+ }
58
88
 
59
- // No duration found, prepend the duration
60
- return `${durationMs}ms ${animation}`
89
+ const getCSSProperties = (key: string) => {
90
+ return CSS_TRANSFORM_PROPERTIES[key] || [key]
61
91
  }
62
92
 
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
93
+ const hyphenatedPropertyCache: Record<string, string> = {}
94
+ const emptyProperties: string[] = []
77
95
 
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[] = []
96
+ function hyphenateProperty(property: string): string {
97
+ if (property.startsWith('--')) return property
98
+ return (hyphenatedPropertyCache[property] ||= property.replace(
99
+ /[A-Z]/g,
100
+ (letter) => `-${letter.toLowerCase()}`
101
+ ))
102
+ }
85
103
 
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)`)
104
+ function getLifecycleCSSProperties(keys: Set<string> | undefined): string[] {
105
+ if (!keys?.size) return emptyProperties
106
+ const properties = new Set<string>()
107
+ for (const key of keys) {
108
+ for (const property of getCSSProperties(key)) {
109
+ properties.add(hyphenateProperty(property))
110
+ }
119
111
  }
120
-
121
- return parts.join(' ')
112
+ return [...properties].sort()
122
113
  }
123
114
 
124
- /**
125
- * Apply a style object to a DOM node, handling transform keys specially
126
- */
127
- function applyStylesToNode(
115
+ function readComputedProperties(
128
116
  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
117
+ properties: readonly string[]
118
+ ): Record<string, string> {
119
+ const computed = getComputedStyle(node)
120
+ const values: Record<string, string> = {}
121
+ for (const property of properties) {
122
+ const value = computed.getPropertyValue(property)
123
+ if (value) values[property] = value
137
124
  }
125
+ return values
126
+ }
138
127
 
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
- }
128
+ function applyCSSProperties(node: HTMLElement, values: Record<string, string>): void {
129
+ for (const property in values) {
130
+ node.style.setProperty(property, values[property])
154
131
  }
155
132
  }
156
133
 
157
- export function createAnimations<A extends object>(animations: A): AnimationDriver<A> {
158
- const reactionListeners = new WeakMap<any, Set<Function>>()
134
+ function clearCSSProperties(node: HTMLElement, properties: readonly string[]): void {
135
+ for (const property of properties) {
136
+ node.style.removeProperty(property)
137
+ }
138
+ }
159
139
 
140
+ export function createAnimations<A extends object>(animations: A): AnimationDriver<A> {
160
141
  return {
161
142
  animations,
162
143
  usePresence,
@@ -164,79 +145,10 @@ export function createAnimations<A extends object>(animations: A): AnimationDriv
164
145
  inputStyle: 'css',
165
146
  outputStyle: 'css',
166
147
 
167
- useAnimatedNumber(initial): UniversalAnimatedNumber<Function> {
168
- const [val, setVal] = React.useState(initial)
169
- const finishTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
170
-
171
- return {
172
- getInstance() {
173
- return setVal
174
- },
175
- getValue() {
176
- return val
177
- },
178
- setValue(next, config, onFinish) {
179
- setVal(next)
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
-
202
- // call reaction listeners with the new value
203
- const listeners = reactionListeners.get(setVal)
204
- if (listeners) {
205
- listeners.forEach((listener) => listener(next))
206
- }
207
- },
208
- stop() {
209
- if (finishTimerRef.current) {
210
- clearTimeout(finishTimerRef.current)
211
- finishTimerRef.current = null
212
- }
213
- },
214
- }
215
- },
216
-
217
- useAnimatedNumberReaction({ value }, onValue) {
218
- React.useEffect(() => {
219
- const instance = value.getInstance()
220
- let queue = reactionListeners.get(instance)
221
- if (!queue) {
222
- const next = new Set<Function>()
223
- reactionListeners.set(instance, next)
224
- queue = next!
225
- }
226
- queue.add(onValue)
227
- return () => {
228
- queue?.delete(onValue)
229
- }
230
- }, [])
231
- },
232
-
233
- useAnimatedNumberStyle(val, getStyle) {
234
- return getStyle(val.getValue())
235
- },
236
-
237
- useAnimatedNumbersStyle(vals, getStyle) {
238
- return getStyle(...vals.map((v) => v.getValue()))
239
- },
148
+ useAnimatedNumber,
149
+ useAnimatedNumberReaction,
150
+ useAnimatedNumberStyle,
151
+ useAnimatedNumbersStyle,
240
152
 
241
153
  // @ts-ignore - styleState is added by createComponent
242
154
  useAnimations: ({
@@ -246,11 +158,23 @@ export function createAnimations<A extends object>(animations: A): AnimationDriv
246
158
  componentState,
247
159
  stateRef,
248
160
  styleState,
161
+ onTransition,
249
162
  }: any) => {
250
163
  const isHydrating = componentState.unmounted === true
251
164
  const isEntering = !!componentState.unmounted
252
165
  const isExiting = presence?.[0] === false
253
166
  const sendExitComplete = presence?.[1]
167
+ const onTransitionRef = React.useRef(onTransition)
168
+ onTransitionRef.current = onTransition
169
+ const emit = (
170
+ phase: 'start' | 'end',
171
+ cause: 'enter' | 'exit' | 'update',
172
+ finished?: boolean
173
+ ) => {
174
+ onTransitionRef.current?.(
175
+ phase === 'end' ? { phase, cause, finished } : { phase, cause }
176
+ )
177
+ }
254
178
 
255
179
  // Track if we just finished entering (transition from entering to not entering)
256
180
  // This is needed because the CSS transition happens on the render AFTER t_unmounted is removed
@@ -264,11 +188,23 @@ export function createAnimations<A extends object>(animations: A): AnimationDriv
264
188
  const exitCycleIdRef = React.useRef(0)
265
189
  const exitCompletedRef = React.useRef(false)
266
190
  const wasExitingRef = React.useRef(false)
267
- const exitInterruptedRef = React.useRef(false)
268
191
  const sendExitCompleteRef = React.useRef(sendExitComplete)
269
- const lastNonExitingStyleRef = React.useRef<Record<string, string>>({})
192
+ const lastMountedStyleRef = React.useRef<Record<string, string>>({})
270
193
  sendExitCompleteRef.current = sendExitComplete
271
194
 
195
+ const exitCSSProperties = getLifecycleCSSProperties(
196
+ styleState?.programLifecycleStyleKeys?.exit
197
+ )
198
+ const exitCSSPropertiesSignature = exitCSSProperties.join('\0')
199
+
200
+ // onTransition lifecycle bookkeeping (independent from presence completion)
201
+ const enterCycleIdRef = React.useRef(0)
202
+ const enterStartedRef = React.useRef(false)
203
+ const updateCycleIdRef = React.useRef(0)
204
+ const updateInFlightRef = React.useRef(false)
205
+ const prevUpdateSigRef = React.useRef<string | null>(null)
206
+ const exitStartedRef = React.useRef(false)
207
+
272
208
  // detect transition into/out of exiting state
273
209
  const justStartedExiting = isExiting && !wasExitingRef.current
274
210
  const justStoppedExiting = !isExiting && wasExitingRef.current
@@ -278,10 +214,8 @@ export function createAnimations<A extends object>(animations: A): AnimationDriv
278
214
  exitCycleIdRef.current++
279
215
  exitCompletedRef.current = false
280
216
  }
281
- // track interruptions so we know to force-restart transitions
282
217
  if (justStoppedExiting) {
283
218
  exitCycleIdRef.current++
284
- exitInterruptedRef.current = true
285
219
  }
286
220
 
287
221
  // track previous exiting state
@@ -289,14 +223,32 @@ export function createAnimations<A extends object>(animations: A): AnimationDriv
289
223
  wasExitingRef.current = isExiting
290
224
  })
291
225
 
226
+ // Snapshot the actual mounted CSS values for every property with an exit
227
+ // clause. Most program values live in generated classes rather than the
228
+ // inline `style` object, so computed style is the only complete source.
229
+ // The snapshot becomes the reset point for normal and interrupted exits.
292
230
  useIsomorphicLayoutEffect(() => {
231
+ if (isExiting) return
293
232
  const host = stateRef.current.host
294
- if (isExiting || !host) return
295
- const computedStyle = getComputedStyle(host as HTMLElement)
296
- lastNonExitingStyleRef.current = {
297
- opacity: computedStyle.opacity,
233
+ if (!host || !exitCSSProperties.length) {
234
+ lastMountedStyleRef.current = {}
235
+ return
298
236
  }
299
- })
237
+ const node = host as HTMLElement
238
+ const capture = () => {
239
+ if (stateRef.current.host !== node || wasExitingRef.current) return
240
+ lastMountedStyleRef.current = readComputedProperties(node, exitCSSProperties)
241
+ }
242
+
243
+ // The first mounted layout effect can run while an enter clause is still
244
+ // active. Capture again after that concrete transition settles so a later
245
+ // exit restarts from the mounted value, not the enter value.
246
+ if (justFinishedEntering) {
247
+ void waitForAnimations(node).then(capture)
248
+ } else {
249
+ capture()
250
+ }
251
+ }, [isExiting, justFinishedEntering, exitCSSPropertiesSignature])
300
252
 
301
253
  // use effectiveTransition computed by createComponent (single source of truth)
302
254
  const effectiveTransition = styleState?.effectiveTransition ?? props.transition
@@ -343,6 +295,33 @@ export function createAnimations<A extends object>(animations: A): AnimationDriv
343
295
  keys = ['all']
344
296
  }
345
297
 
298
+ let transition: string | undefined
299
+ const getTransition = () => {
300
+ if (transition !== undefined) return transition
301
+ const delay = normalized.delay ? ` ${normalized.delay}ms` : ''
302
+ const duration = normalized.config?.duration
303
+ transition = keys
304
+ .flatMap((key) => {
305
+ const propertyAnimation = normalized.properties[key]
306
+ let animation = defaultAnimation
307
+ if (typeof propertyAnimation === 'string') {
308
+ animation = animations[propertyAnimation]
309
+ } else if (propertyAnimation?.type) {
310
+ animation = animations[propertyAnimation.type]
311
+ }
312
+ if (animation && duration) {
313
+ animation = applyDurationOverride(animation, duration)
314
+ }
315
+ return animation
316
+ ? getCSSProperties(key).map(
317
+ (property) => `${property} ${animation}${delay}`
318
+ )
319
+ : []
320
+ })
321
+ .join(', ')
322
+ return transition
323
+ }
324
+
346
325
  useIsomorphicLayoutEffect(() => {
347
326
  const host = stateRef.current.host
348
327
  if (!sendExitComplete || !isExiting || !host) return
@@ -351,11 +330,23 @@ export function createAnimations<A extends object>(animations: A): AnimationDriv
351
330
  // capture current cycle id for this effect
352
331
  const cycleId = exitCycleIdRef.current
353
332
 
354
- // helper to complete exit with guards
355
- const completeExit = () => {
333
+ // emit exit start once per cycle
334
+ if (!exitStartedRef.current) {
335
+ exitStartedRef.current = true
336
+ emit('start', 'exit')
337
+ }
338
+
339
+ // helper to complete exit with guards. the exit 'end' event fires
340
+ // immediately before presence safeToRemove so users can observe exit
341
+ // completion without reaching into presence internals.
342
+ const completeExit = (finished = true) => {
356
343
  if (cycleId !== exitCycleIdRef.current) return
357
344
  if (exitCompletedRef.current) return
358
345
  exitCompletedRef.current = true
346
+ if (exitStartedRef.current) {
347
+ exitStartedRef.current = false
348
+ emit('end', 'exit', finished)
349
+ }
359
350
  sendExitCompleteRef.current?.()
360
351
  }
361
352
 
@@ -365,221 +356,42 @@ export function createAnimations<A extends object>(animations: A): AnimationDriv
365
356
  return
366
357
  }
367
358
 
368
- // Force transition restart for interrupted exits
369
- // When an exit is interrupted and restarted, the element may already be at
370
- // the exit style, so no CSS transition fires. We need to:
371
- // 1. Reset to non-exit state
372
- // 2. Force reflow
373
- // 3. Re-apply exit state to trigger transition
359
+ // React can apply the exit class in the same render batch as the
360
+ // transition. Restart from the last mounted computed values so normal
361
+ // and interrupted exits both produce a concrete browser transition.
374
362
  let rafId: number | undefined
375
- const wasInterrupted = exitInterruptedRef.current
376
- // flag to ignore transitioncancel during reset (we intentionally cancel the old transition)
377
- let ignoreCancelEvents = wasInterrupted
378
- // get enter/exit styles for potential restart
379
- const enterStyle = props.enterStyle as Record<string, unknown> | undefined
380
- const exitStyle = props.exitStyle as Record<string, unknown> | undefined
381
-
382
- // Build the exit transition string - needed for both normal and interrupted exits
383
- const delayStr = normalized.delay ? ` ${normalized.delay}ms` : ''
384
- const durationOverride = normalized.config?.duration
385
- const exitTransitionString = keys
386
- .map((key) => {
387
- const propAnimation = normalized.properties[key]
388
- let animationValue: string | null = null
389
- if (typeof propAnimation === 'string') {
390
- animationValue = animations[propAnimation]
391
- } else if (
392
- propAnimation &&
393
- typeof propAnimation === 'object' &&
394
- propAnimation.type
395
- ) {
396
- animationValue = animations[propAnimation.type]
397
- } else if (defaultAnimation) {
398
- animationValue = defaultAnimation
399
- }
400
- if (animationValue && durationOverride) {
401
- animationValue = applyDurationOverride(animationValue, durationOverride)
402
- }
403
- return animationValue ? `${key} ${animationValue}${delayStr}` : null
404
- })
405
- .filter(Boolean)
406
- .join(', ')
363
+ let disposed = false
407
364
 
408
- const getResetValue = (key: string) => {
409
- if (key === 'opacity') {
410
- return (
411
- style?.opacity ??
412
- props.opacity ??
413
- lastNonExitingStyleRef.current.opacity ??
414
- 1
415
- )
416
- }
417
- if (TRANSFORM_KEYS.includes(key as any)) {
418
- return key === 'scale' || key === 'scaleX' || key === 'scaleY' ? 1 : 0
419
- }
420
- return enterStyle?.[key]
421
- }
422
-
423
- if (wasInterrupted) {
424
- exitInterruptedRef.current = false
425
- // disable transition, reset to enter state
365
+ const mountedStyle = lastMountedStyleRef.current
366
+ const canRestart =
367
+ exitCSSProperties.length > 0 && Object.keys(mountedStyle).length > 0
368
+ let exitTarget: Record<string, string> | undefined
369
+ if (canRestart) {
426
370
  node.style.transition = 'none'
427
-
428
- // reset: apply active/open state for each exit property (not enterStyle,
429
- // which may equal exitStyle — see comment in the normal exit path below)
430
- if (exitStyle) {
431
- const resetStyle: Record<string, unknown> = {}
432
- for (const key of Object.keys(exitStyle)) {
433
- const resetValue = getResetValue(key)
434
- if (resetValue !== undefined) {
435
- resetStyle[key] = resetValue
436
- }
437
- }
438
- applyStylesToNode(node, resetStyle)
439
- } else {
440
- // fallback if no exitStyle defined
441
- node.style.opacity = '1'
442
- node.style.transform = 'none'
443
- }
444
-
445
- // force reflow
371
+ // With transitions disabled, the exit classes expose their final
372
+ // targets immediately. Capture those, then restore the mounted values.
373
+ exitTarget = readComputedProperties(node, exitCSSProperties)
374
+ applyCSSProperties(node, mountedStyle)
446
375
  void node.offsetHeight
447
- } else if (exitStyle) {
448
- // For normal (non-interrupted) exits, we need to ensure the CSS transition is
449
- // processed by the browser BEFORE the exitStyle takes effect. The issue is that
450
- // React may have already applied exitStyle in the same render batch. To fix this:
451
- // 1. Disable transition and reset to non-exit state
452
- // 2. Force reflow so browser processes the reset
453
- // 3. Use RAF to ensure we're in a new frame
454
- // 4. Re-enable transition and apply exitStyle
455
- // This mirrors the interrupted exit handling approach (which also uses RAF).
456
- ignoreCancelEvents = true
457
- node.style.transition = 'none'
458
-
459
- // Reset to the active/open state (not enterStyle, which may equal exitStyle).
460
- // enterStyle is the "unmounted" initial state and can share values with exitStyle
461
- // (e.g., both have opacity: 0). resetting to enterStyle would mean no value change
462
- // when exitStyle is applied, so the CSS transition wouldn't fire.
463
- const resetStyle: Record<string, unknown> = {}
464
- for (const key of Object.keys(exitStyle)) {
465
- const resetValue = getResetValue(key)
466
- if (resetValue !== undefined) {
467
- resetStyle[key] = resetValue
468
- }
469
- }
470
- applyStylesToNode(node, resetStyle)
471
-
472
- // Force reflow
473
- void node.offsetHeight
474
-
475
- // Use RAF to ensure transition is applied in a new frame
476
376
  rafId = requestAnimationFrame(() => {
477
377
  if (cycleId !== exitCycleIdRef.current) return
478
- // Re-enable transition
479
- node.style.transition = exitTransitionString
480
- // Force reflow to ensure transition is active
378
+ node.style.transition = getTransition()
481
379
  void node.offsetHeight
482
- // Apply exit styles - this triggers the animation
483
- applyStylesToNode(node, exitStyle)
484
- // Re-enable cancel event handling
485
- ignoreCancelEvents = false
380
+ applyCSSProperties(node, exitTarget!)
486
381
  })
487
382
  }
488
383
 
489
- /**
490
- * Exit animation handling for Dialog/Modal components
491
- *
492
- * The Challenge: When users close dialogs (via Escape key or clicking outside),
493
- * the element can disappear from the DOM before CSS transitions finish, which causes:
494
- * 1. Dialogs to stick around on screen
495
- * 2. Event handlers to stop working
496
- *
497
- * Fix: Calculate the MAXIMUM duration across all animated properties, not just
498
- * the default. With animateOnly and per-property configs, different properties
499
- * can have different durations, and we need to wait for the LONGEST one.
500
- */
501
-
502
- // calculate max duration across all animated properties
503
- let maxDuration = defaultAnimation ? extractDuration(defaultAnimation) : 200
504
-
505
- // check per-property animation durations using shared helper
506
- const animationConfigs = getAnimationConfigsForKeys(
507
- normalized,
508
- animations as Record<string, string>,
509
- keys,
510
- defaultAnimation
511
- )
512
- for (const animationValue of animationConfigs.values()) {
513
- if (animationValue) {
514
- const duration = extractDuration(animationValue)
515
- if (duration > maxDuration) {
516
- maxDuration = duration
517
- }
518
- }
519
- }
520
-
521
- const delay = normalized.delay ?? 0
522
- const fallbackTimeout = maxDuration + delay
523
-
524
- const timeoutId = setTimeout(() => {
525
- completeExit()
526
- }, fallbackTimeout)
527
-
528
- // track number of transitioning properties to wait for all to finish
529
- // (each property fires its own transitionend event)
530
- const transitioningProps = new Set(keys)
531
- let completedCount = 0
532
-
533
- const onFinishAnimation = (event: TransitionEvent) => {
534
- // only count transitions on THIS element, not bubbled from children
535
- if (event.target !== node) return
536
-
537
- // map CSS property names to our key names
538
- // e.g., transitionend fires with propertyName 'transform' for scale/x/y
539
- const eventProp = event.propertyName
540
- if (transitioningProps.has(eventProp) || eventProp === 'all') {
541
- completedCount++
542
- // wait for all properties to finish
543
- if (completedCount >= transitioningProps.size) {
544
- clearTimeout(timeoutId)
545
- completeExit()
546
- }
547
- }
548
- }
549
-
550
- // on cancel, still complete (element is exiting and animation was interrupted)
551
- // the guards prevent duplicate completion if this is a stale cycle
552
- const onCancelAnimation = () => {
553
- // ignore cancel events during reset phase (we intentionally cancel the old transition)
554
- if (ignoreCancelEvents) return
555
- clearTimeout(timeoutId)
556
- completeExit()
557
- }
558
-
559
- node.addEventListener('transitionend', onFinishAnimation)
560
- node.addEventListener('transitioncancel', onCancelAnimation)
561
-
562
- // For interrupted exits, re-enable transition and re-apply exit styles
563
- // This must happen AFTER listeners are set up so we catch the transitionend
564
- if (wasInterrupted) {
565
- rafId = requestAnimationFrame(() => {
566
- if (cycleId !== exitCycleIdRef.current) return
567
- // re-enable transition using the pre-built string
568
- node.style.transition = exitTransitionString
569
- // force reflow again
570
- void node.offsetHeight
571
- // now apply exit styles - this triggers the transition
572
- applyStylesToNode(node, exitStyle)
573
- // re-enable cancel event handling now that reset is complete
574
- ignoreCancelEvents = false
575
- })
576
- }
384
+ // wait for the browser's concrete animations. this covers `all`,
385
+ // transform aliases, delays, and concurrent WAAPI animations without
386
+ // guessing property names or maintaining a duration timer.
387
+ void waitForAnimations(node).then((finished) => {
388
+ if (!disposed) completeExit(finished)
389
+ })
577
390
 
578
391
  return () => {
579
- clearTimeout(timeoutId)
392
+ disposed = true
580
393
  if (rafId !== undefined) cancelAnimationFrame(rafId)
581
- node.removeEventListener('transitionend', onFinishAnimation)
582
- node.removeEventListener('transitioncancel', onCancelAnimation)
394
+ clearCSSProperties(node, exitCSSProperties)
583
395
  // restore transition: the exit handling sets node.style.transition='none'
584
396
  // directly on the DOM (bypassing React). if exit is interrupted (e.g. same-key
585
397
  // re-entry in AnimatePresence), React won't re-apply its managed transition
@@ -587,7 +399,88 @@ export function createAnimations<A extends object>(animations: A): AnimationDriv
587
399
  // override lets React's value take effect again.
588
400
  node.style.transition = ''
589
401
  }
590
- }, [isExiting])
402
+ }, [isExiting, exitCSSPropertiesSignature])
403
+
404
+ // signature of the animatable style, so the update effect can detect
405
+ // in-place style changes. the css driver applies most style values as
406
+ // atomic classNames (not inline style), so the signature must include the
407
+ // className map. only computed when a listener is attached.
408
+ const styleSignature = onTransition
409
+ ? (() => {
410
+ const { transition: _t, ...rest } = style
411
+ return `${JSON.stringify(styleState?.classNames ?? null)}|${JSON.stringify(rest)}`
412
+ })()
413
+ : ''
414
+
415
+ // enter lifecycle: emit start when the enter transition kicks off, end
416
+ // once every animation on the node finishes (getAnimations-based, resolves
417
+ // immediately for zero-animation elements). the promise outlives benign
418
+ // re-renders because it keys off the cycle id, not the effect lifetime.
419
+ useIsomorphicLayoutEffect(() => {
420
+ const host = stateRef.current.host
421
+ if (!onTransitionRef.current || isExiting || !justFinishedEntering || !host) {
422
+ return
423
+ }
424
+ const node = host as HTMLElement
425
+ const cycleId = ++enterCycleIdRef.current
426
+ enterStartedRef.current = true
427
+ emit('start', 'enter')
428
+ void waitForAnimations(node).then((finished) => {
429
+ if (cycleId !== enterCycleIdRef.current || !enterStartedRef.current) return
430
+ enterStartedRef.current = false
431
+ emit('end', 'enter', finished)
432
+ })
433
+ }, [justFinishedEntering, isExiting])
434
+
435
+ // update lifecycle: a style change while mounted (not entering or exiting).
436
+ // a new update that supersedes an in-flight one emits end(finished:false).
437
+ useIsomorphicLayoutEffect(() => {
438
+ const host = stateRef.current.host
439
+ if (
440
+ !onTransitionRef.current ||
441
+ isEntering ||
442
+ justFinishedEntering ||
443
+ isExiting ||
444
+ !host
445
+ ) {
446
+ // keep the signature current so leaving enter/exit isn't seen as an update
447
+ prevUpdateSigRef.current = styleSignature
448
+ return
449
+ }
450
+ if (prevUpdateSigRef.current === null) {
451
+ prevUpdateSigRef.current = styleSignature
452
+ return
453
+ }
454
+ if (styleSignature === prevUpdateSigRef.current) return
455
+ prevUpdateSigRef.current = styleSignature
456
+
457
+ const node = host as HTMLElement
458
+ if (updateInFlightRef.current) {
459
+ emit('end', 'update', false)
460
+ }
461
+ updateInFlightRef.current = true
462
+ const cycleId = ++updateCycleIdRef.current
463
+ emit('start', 'update')
464
+ void waitForAnimations(node).then((finished) => {
465
+ if (cycleId !== updateCycleIdRef.current) return
466
+ updateInFlightRef.current = false
467
+ emit('end', 'update', finished)
468
+ })
469
+ }, [styleSignature, isEntering, justFinishedEntering, isExiting])
470
+
471
+ // interruption: emit a finished:false end for an enter canceled by an exit,
472
+ // or an exit canceled by a re-enter (before its own completion fired).
473
+ useIsomorphicLayoutEffect(() => {
474
+ if (justStartedExiting && enterStartedRef.current) {
475
+ enterCycleIdRef.current++
476
+ enterStartedRef.current = false
477
+ emit('end', 'enter', false)
478
+ }
479
+ if (justStoppedExiting && exitStartedRef.current && !exitCompletedRef.current) {
480
+ exitStartedRef.current = false
481
+ emit('end', 'exit', false)
482
+ }
483
+ }, [justStartedExiting, justStoppedExiting])
591
484
 
592
485
  // tamagui doesnt even use animation output during hydration
593
486
  if (isHydrating) {
@@ -603,38 +496,7 @@ export function createAnimations<A extends object>(animations: A): AnimationDriv
603
496
  style.transform = transformsToString(style.transform)
604
497
  }
605
498
 
606
- // Build CSS transition string
607
- // TODO: we disabled the transform transition, because it will create issue for inverse function and animate function
608
- // for non layout transform properties either use animate function or find a workaround to do it with css
609
- const delayStr = normalized.delay ? ` ${normalized.delay}ms` : ''
610
- const durationOverride = normalized.config?.duration
611
- style.transition = keys
612
- .map((key) => {
613
- // Check for property-specific animation, fall back to default
614
- const propAnimation = normalized.properties[key]
615
- let animationValue: string | null = null
616
-
617
- if (typeof propAnimation === 'string') {
618
- animationValue = animations[propAnimation]
619
- } else if (
620
- propAnimation &&
621
- typeof propAnimation === 'object' &&
622
- propAnimation.type
623
- ) {
624
- animationValue = animations[propAnimation.type]
625
- } else if (defaultAnimation) {
626
- animationValue = defaultAnimation
627
- }
628
-
629
- // Apply global duration override if specified
630
- if (animationValue && durationOverride) {
631
- animationValue = applyDurationOverride(animationValue, durationOverride)
632
- }
633
-
634
- return animationValue ? `${key} ${animationValue}${delayStr}` : null
635
- })
636
- .filter(Boolean)
637
- .join(', ')
499
+ style.transition = getTransition()
638
500
 
639
501
  if (process.env.NODE_ENV === 'development' && props['debug'] === 'verbose') {
640
502
  console.info('CSS animation', {
@@ -652,61 +514,3 @@ export function createAnimations<A extends object>(animations: A): AnimationDriv
652
514
  },
653
515
  }
654
516
  }
655
-
656
- // layout animations
657
- // useIsomorphicLayoutEffect(() => {
658
- // if (!host || !props.layout) {
659
- // return
660
- // }
661
- // // @ts-ignore
662
- // const boundingBox = host?.getBoundingClientRect()
663
- // if (isChanged(initialPositionRef.current, boundingBox)) {
664
- // const transform = invert(
665
- // host,
666
- // boundingBox,
667
- // initialPositionRef.current
668
- // )
669
-
670
- // animate({
671
- // from: transform,
672
- // to: { x: 0, y: 0, scaleX: 1, scaleY: 1 },
673
- // duration: 1000,
674
- // onUpdate: ({ x, y, scaleX, scaleY }) => {
675
- // // @ts-ignore
676
- // host.style.transform = `translate(${x}px, ${y}px) scaleX(${scaleX}) scaleY(${scaleY})`
677
- // // TODO: handle childRef inverse scale
678
- // // childRef.current.style.transform = `scaleX(${1 / scaleX}) scaleY(${
679
- // // 1 / scaleY
680
- // // })`
681
- // },
682
- // // TODO: extract ease-in from string and convert/map it to a cubicBezier array
683
- // cubicBezier: [0, 1.38, 1, -0.41],
684
- // })
685
- // }
686
- // initialPositionRef.current = boundingBox
687
- // })
688
-
689
- // style.transition = `${keys} ${animation}${
690
- // props.layout ? ',width 0s, height 0s, margin 0s, padding 0s, transform' : ''
691
- // }`
692
-
693
- // const isChanged = (initialBox: any, finalBox: any) => {
694
- // // we just mounted, so we don't have complete data yet
695
- // if (!initialBox || !finalBox) return false
696
-
697
- // // deep compare the two boxes
698
- // return JSON.stringify(initialBox) !== JSON.stringify(finalBox)
699
- // }
700
-
701
- // const invert = (el, from, to) => {
702
- // const { x: fromX, y: fromY, width: fromWidth, height: fromHeight } = from
703
- // const { x, y, width, height } = to
704
-
705
- // const transform = {
706
- // x: x - fromX - (fromWidth - width) / 2,
707
- // y: y - fromY - (fromHeight - height) / 2,
708
- // scaleX: width / fromWidth,
709
- // scaleY: height / fromHeight,
710
- // }
711
-
712
- // el.style.transform = `