@tamagui/animations-css 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.
Files changed (44) hide show
  1. package/dist/cjs/animated-number.cjs +194 -0
  2. package/dist/cjs/animated-number.native.cjs +291 -0
  3. package/dist/cjs/animated-number.native.js +293 -0
  4. package/dist/cjs/animated-number.native.js.map +1 -0
  5. package/dist/cjs/createAnimations.cjs +321 -418
  6. package/dist/cjs/createAnimations.native.cjs +430 -0
  7. package/dist/cjs/createAnimations.native.js +405 -511
  8. package/dist/cjs/createAnimations.native.js.map +1 -1
  9. package/dist/cjs/extras.cjs +36 -0
  10. package/dist/cjs/extras.native.cjs +38 -0
  11. package/dist/cjs/extras.native.js +40 -0
  12. package/dist/cjs/extras.native.js.map +1 -0
  13. package/dist/cjs/index.cjs +9 -11
  14. package/dist/cjs/index.native.cjs +20 -0
  15. package/dist/cjs/index.native.js +9 -10
  16. package/dist/cjs/index.native.js.map +1 -1
  17. package/dist/esm/animated-number.mjs +164 -0
  18. package/dist/esm/animated-number.mjs.map +1 -0
  19. package/dist/esm/animated-number.native.js +259 -0
  20. package/dist/esm/animated-number.native.js.map +1 -0
  21. package/dist/esm/createAnimations.mjs +307 -393
  22. package/dist/esm/createAnimations.mjs.map +1 -1
  23. package/dist/esm/createAnimations.native.js +390 -486
  24. package/dist/esm/createAnimations.native.js.map +1 -1
  25. package/dist/esm/extras.mjs +17 -0
  26. package/dist/esm/extras.mjs.map +1 -0
  27. package/dist/esm/extras.native.js +17 -0
  28. package/dist/esm/extras.native.js.map +1 -0
  29. package/dist/esm/index.js +1 -2
  30. package/dist/esm/index.mjs +1 -2
  31. package/dist/esm/index.native.js +1 -2
  32. package/package.json +27 -9
  33. package/src/animated-number.tsx +262 -0
  34. package/src/createAnimations.tsx +294 -555
  35. package/src/extras.ts +24 -0
  36. package/types/animated-number.d.ts +19 -0
  37. package/types/animated-number.d.ts.map +11 -0
  38. package/types/createAnimations.d.ts +2 -1
  39. package/types/createAnimations.d.ts.map +2 -2
  40. package/types/extras.d.ts +6 -0
  41. package/types/extras.d.ts.map +11 -0
  42. package/dist/esm/index.js.map +0 -1
  43. package/dist/esm/index.mjs.map +0 -1
  44. package/dist/esm/index.native.js.map +0 -1
@@ -1,162 +1,128 @@
1
1
  import {
2
- normalizeTransition,
3
- getAnimatedProperties,
4
- hasAnimation as hasNormalizedAnimation,
5
- getEffectiveAnimation,
6
- getAnimationConfigsForKeys,
2
+ forAnimationState,
3
+ hasTransition,
4
+ resolveTransition,
5
+ toCSSTransition,
6
+ type AnimationsConfig,
7
7
  } from '@tamagui/animation-helpers'
8
8
  import { useIsomorphicLayoutEffect } from '@tamagui/constants'
9
9
  import { ResetPresence, usePresence } from '@tamagui/use-presence'
10
- import type { AnimationDriver, UniversalAnimatedNumber } from '@tamagui/web'
11
- import { transformsToString } from '@tamagui/web'
12
- import React, { useState } from 'react' // import { animate } from '@tamagui/cubic-bezier-animator'
13
-
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)
10
+ import type { AnimationDriver } from '@tamagui/web'
11
+ import { transformsToString } from '@tamagui/web/internal-runtime'
12
+ import React from 'react'
13
+
14
+ // rAF-driven animated number is browser-only. read (don't call) at module scope
15
+ // so ssr never touches requestAnimationFrame.
16
+ const hasRAF = typeof requestAnimationFrame !== 'undefined'
17
+
18
+ // resolve once all WAAPI animations on `node` finish. mirrors base-ui's
19
+ // useAnimationsFinished: resolves immediately when the browser exposes no
20
+ // animations (zero-animation elements), and re-checks after an aborted
21
+ // animation in case a property it depended on changed mid-flight and started a
22
+ // new one. falls back to immediate resolve when getAnimations is unavailable
23
+ // (ssr / older webviews). resolves `false` when animations were canceled with
24
+ // nothing left running (interruption).
25
+ function waitForAnimations(node: HTMLElement): Promise<boolean> {
26
+ if (typeof node.getAnimations !== 'function') {
27
+ return Promise.resolve(true)
33
28
  }
29
+ return new Promise<boolean>((resolve) => {
30
+ const check = () => {
31
+ const animations = node.getAnimations()
32
+ if (animations.length === 0) {
33
+ resolve(true)
34
+ return
35
+ }
36
+ Promise.all(animations.map((a) => a.finished))
37
+ .then(() => resolve(true))
38
+ .catch(() => {
39
+ const remaining = node.getAnimations()
40
+ if (remaining.some((a) => a.playState === 'running' || a.pending)) {
41
+ check()
42
+ return
43
+ }
44
+ resolve(false)
45
+ })
46
+ }
47
+ // css transitions register as pending until the next style recalc, so give
48
+ // the browser one frame to start them before we read getAnimations.
49
+ if (hasRAF) {
50
+ requestAnimationFrame(check)
51
+ } else {
52
+ check()
53
+ }
54
+ })
55
+ }
34
56
 
35
- // Default to 300ms if no duration found
36
- return 300
57
+ const CSS_TRANSFORM_PROPERTIES: Record<string, string[]> = {
58
+ transform: ['translate', 'scale', 'rotate', 'transform'],
59
+ x: ['translate'],
60
+ y: ['translate'],
61
+ scale: ['scale'],
62
+ scaleX: ['scale'],
63
+ scaleY: ['scale'],
64
+ rotate: ['rotate'],
65
+ rotateX: ['transform'],
66
+ rotateY: ['transform'],
67
+ rotateZ: ['transform'],
68
+ skewX: ['transform'],
69
+ skewY: ['transform'],
37
70
  }
38
71
 
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
- }
72
+ const getCSSProperties = (key: string) => {
73
+ return CSS_TRANSFORM_PROPERTIES[key] || [key]
74
+ }
52
75
 
53
- // Replace seconds duration
54
- const sReplaced = animation.replace(S_DURATION_REGEX, `${durationMs}ms`)
55
- if (sReplaced !== animation) {
56
- return sReplaced
57
- }
76
+ const hyphenatedPropertyCache: Record<string, string> = {}
77
+ const emptyProperties: string[] = []
58
78
 
59
- // No duration found, prepend the duration
60
- return `${durationMs}ms ${animation}`
79
+ function hyphenateProperty(property: string): string {
80
+ if (property.startsWith('--')) return property
81
+ return (hyphenatedPropertyCache[property] ||= property.replace(
82
+ /[A-Z]/g,
83
+ (letter) => `-${letter.toLowerCase()}`
84
+ ))
61
85
  }
62
86
 
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)`)
87
+ function getLifecycleCSSProperties(keys: Set<string> | undefined): string[] {
88
+ if (!keys?.size) return emptyProperties
89
+ const properties = new Set<string>()
90
+ for (const key of keys) {
91
+ for (const property of getCSSProperties(key)) {
92
+ properties.add(hyphenateProperty(property))
93
+ }
119
94
  }
120
-
121
- return parts.join(' ')
95
+ return [...properties].sort()
122
96
  }
123
97
 
124
- /**
125
- * Apply a style object to a DOM node, handling transform keys specially
126
- */
127
- function applyStylesToNode(
98
+ function readComputedProperties(
128
99
  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
100
+ properties: readonly string[]
101
+ ): Record<string, string> {
102
+ const computed = getComputedStyle(node)
103
+ const values: Record<string, string> = {}
104
+ for (const property of properties) {
105
+ const value = computed.getPropertyValue(property)
106
+ if (value) values[property] = value
137
107
  }
108
+ return values
109
+ }
138
110
 
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
- }
111
+ function applyCSSProperties(node: HTMLElement, values: Record<string, string>): void {
112
+ for (const property in values) {
113
+ node.style.setProperty(property, values[property])
154
114
  }
155
115
  }
156
116
 
157
- export function createAnimations<A extends object>(animations: A): AnimationDriver<A> {
158
- const reactionListeners = new WeakMap<any, Set<Function>>()
117
+ function clearCSSProperties(node: HTMLElement, properties: readonly string[]): void {
118
+ for (const property of properties) {
119
+ node.style.removeProperty(property)
120
+ }
121
+ }
159
122
 
123
+ export function createAnimations<A extends AnimationsConfig>(
124
+ animations: A
125
+ ): AnimationDriver<A> {
160
126
  return {
161
127
  animations,
162
128
  usePresence,
@@ -164,80 +130,6 @@ export function createAnimations<A extends object>(animations: A): AnimationDriv
164
130
  inputStyle: 'css',
165
131
  outputStyle: 'css',
166
132
 
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
- },
240
-
241
133
  // @ts-ignore - styleState is added by createComponent
242
134
  useAnimations: ({
243
135
  props,
@@ -246,11 +138,23 @@ export function createAnimations<A extends object>(animations: A): AnimationDriv
246
138
  componentState,
247
139
  stateRef,
248
140
  styleState,
141
+ onTransition,
249
142
  }: any) => {
250
143
  const isHydrating = componentState.unmounted === true
251
144
  const isEntering = !!componentState.unmounted
252
145
  const isExiting = presence?.[0] === false
253
146
  const sendExitComplete = presence?.[1]
147
+ const onTransitionRef = React.useRef(onTransition)
148
+ onTransitionRef.current = onTransition
149
+ const emit = (
150
+ phase: 'start' | 'end',
151
+ cause: 'enter' | 'exit' | 'update',
152
+ finished?: boolean
153
+ ) => {
154
+ onTransitionRef.current?.(
155
+ phase === 'end' ? { phase, cause, finished } : { phase, cause }
156
+ )
157
+ }
254
158
 
255
159
  // Track if we just finished entering (transition from entering to not entering)
256
160
  // This is needed because the CSS transition happens on the render AFTER t_unmounted is removed
@@ -264,11 +168,23 @@ export function createAnimations<A extends object>(animations: A): AnimationDriv
264
168
  const exitCycleIdRef = React.useRef(0)
265
169
  const exitCompletedRef = React.useRef(false)
266
170
  const wasExitingRef = React.useRef(false)
267
- const exitInterruptedRef = React.useRef(false)
268
171
  const sendExitCompleteRef = React.useRef(sendExitComplete)
269
- const lastNonExitingStyleRef = React.useRef<Record<string, string>>({})
172
+ const lastMountedStyleRef = React.useRef<Record<string, string>>({})
270
173
  sendExitCompleteRef.current = sendExitComplete
271
174
 
175
+ const exitCSSProperties = getLifecycleCSSProperties(
176
+ styleState?.programLifecycleStyleKeys?.exit
177
+ )
178
+ const exitCSSPropertiesSignature = exitCSSProperties.join('\0')
179
+
180
+ // onTransition lifecycle bookkeeping (independent from presence completion)
181
+ const enterCycleIdRef = React.useRef(0)
182
+ const enterStartedRef = React.useRef(false)
183
+ const updateCycleIdRef = React.useRef(0)
184
+ const updateInFlightRef = React.useRef(false)
185
+ const prevUpdateSigRef = React.useRef<string | null>(null)
186
+ const exitStartedRef = React.useRef(false)
187
+
272
188
  // detect transition into/out of exiting state
273
189
  const justStartedExiting = isExiting && !wasExitingRef.current
274
190
  const justStoppedExiting = !isExiting && wasExitingRef.current
@@ -278,10 +194,8 @@ export function createAnimations<A extends object>(animations: A): AnimationDriv
278
194
  exitCycleIdRef.current++
279
195
  exitCompletedRef.current = false
280
196
  }
281
- // track interruptions so we know to force-restart transitions
282
197
  if (justStoppedExiting) {
283
198
  exitCycleIdRef.current++
284
- exitInterruptedRef.current = true
285
199
  }
286
200
 
287
201
  // track previous exiting state
@@ -289,58 +203,59 @@ export function createAnimations<A extends object>(animations: A): AnimationDriv
289
203
  wasExitingRef.current = isExiting
290
204
  })
291
205
 
206
+ // Snapshot the actual mounted CSS values for every property with an exit
207
+ // clause. Most program values live in generated classes rather than the
208
+ // inline `style` object, so computed style is the only complete source.
209
+ // The snapshot becomes the reset point for normal and interrupted exits.
292
210
  useIsomorphicLayoutEffect(() => {
211
+ if (isExiting) return
293
212
  const host = stateRef.current.host
294
- if (isExiting || !host) return
295
- const computedStyle = getComputedStyle(host as HTMLElement)
296
- lastNonExitingStyleRef.current = {
297
- opacity: computedStyle.opacity,
213
+ if (!host || !exitCSSProperties.length) {
214
+ lastMountedStyleRef.current = {}
215
+ return
298
216
  }
299
- })
217
+ const node = host as HTMLElement
218
+ const capture = () => {
219
+ if (stateRef.current.host !== node || wasExitingRef.current) return
220
+ lastMountedStyleRef.current = readComputedProperties(node, exitCSSProperties)
221
+ }
222
+
223
+ // The first mounted layout effect can run while an enter clause is still
224
+ // active. Capture again after that concrete transition settles so a later
225
+ // exit restarts from the mounted value, not the enter value.
226
+ if (justFinishedEntering) {
227
+ void waitForAnimations(node).then(capture)
228
+ } else {
229
+ capture()
230
+ }
231
+ }, [isExiting, justFinishedEntering, exitCSSPropertiesSignature])
300
232
 
301
233
  // use effectiveTransition computed by createComponent (single source of truth)
302
234
  const effectiveTransition = styleState?.effectiveTransition ?? props.transition
303
235
 
304
- // Normalize the transition prop to a consistent format
305
- const normalized = normalizeTransition(effectiveTransition)
306
-
307
- // Determine animation state and get effective animation
308
- // Use 'enter' if we're entering OR if we just finished entering (transition is happening)
236
+ // one parser, one preset lookup, one spring solver, shared with the other
237
+ // three drivers. this driver decides nothing about what a transition means.
309
238
  const animationState = isExiting
310
239
  ? 'exit'
311
240
  : isEntering || justFinishedEntering
312
241
  ? 'enter'
313
242
  : 'default'
314
- const effectiveAnimationKey = getEffectiveAnimation(normalized, animationState)
315
- const defaultAnimation = effectiveAnimationKey
316
- ? animations[effectiveAnimationKey]
317
- : null
318
- const animatedProperties = getAnimatedProperties(normalized)
319
-
320
- // Determine which properties to animate
321
- // - animateOnly prop is an exclusive filter (only animate those properties)
322
- // - per-property configs WITHOUT a default = only animate those specific properties
323
- // - per-property configs WITH a default = per-property overrides + default for rest
324
- const hasDefault =
325
- normalized.default !== null ||
326
- normalized.enter !== null ||
327
- normalized.exit !== null
328
- const hasPerPropertyConfigs = animatedProperties.length > 0
329
-
330
- let keys: string[]
331
- if (props.animateOnly) {
332
- // animateOnly is explicit filter
333
- keys = props.animateOnly
334
- } else if (hasPerPropertyConfigs && !hasDefault) {
335
- // object format without default: { opacity: '200ms' } = only animate opacity
336
- keys = animatedProperties
337
- } else if (hasPerPropertyConfigs && hasDefault) {
338
- // array format or object with default: 'all' first, then per-property overrides
339
- // CSS transition specificity: later declarations override earlier ones for the same property
340
- keys = ['all', ...animatedProperties]
341
- } else {
342
- // simple string format: 'quick' = animate all
343
- keys = ['all']
243
+ const resolved = forAnimationState(
244
+ resolveTransition(effectiveTransition, {
245
+ animations: animations as Record<string, unknown>,
246
+ }),
247
+ animationState
248
+ )
249
+
250
+ // the entries stand as authored; the browser resolves them under css
251
+ // last-wins, and narrowing to a property list is `transitionProperty`
252
+ const hasKeys = hasTransition(resolved)
253
+
254
+ let transition: string | undefined
255
+ const getTransition = () => {
256
+ if (transition !== undefined) return transition
257
+ transition = toCSSTransition(resolved) ?? ''
258
+ return transition
344
259
  }
345
260
 
346
261
  useIsomorphicLayoutEffect(() => {
@@ -351,235 +266,68 @@ export function createAnimations<A extends object>(animations: A): AnimationDriv
351
266
  // capture current cycle id for this effect
352
267
  const cycleId = exitCycleIdRef.current
353
268
 
354
- // helper to complete exit with guards
355
- const completeExit = () => {
269
+ // emit exit start once per cycle
270
+ if (!exitStartedRef.current) {
271
+ exitStartedRef.current = true
272
+ emit('start', 'exit')
273
+ }
274
+
275
+ // helper to complete exit with guards. the exit 'end' event fires
276
+ // immediately before presence safeToRemove so users can observe exit
277
+ // completion without reaching into presence internals.
278
+ const completeExit = (finished = true) => {
356
279
  if (cycleId !== exitCycleIdRef.current) return
357
280
  if (exitCompletedRef.current) return
358
281
  exitCompletedRef.current = true
282
+ if (exitStartedRef.current) {
283
+ exitStartedRef.current = false
284
+ emit('end', 'exit', finished)
285
+ }
359
286
  sendExitCompleteRef.current?.()
360
287
  }
361
288
 
362
- // if no properties to animate (animateOnly=[]), complete immediately
363
- if (keys.length === 0) {
289
+ // if no properties animate (`transition="none"`), complete immediately
290
+ if (!hasKeys) {
364
291
  completeExit()
365
292
  return
366
293
  }
367
294
 
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
295
+ // React can apply the exit class in the same render batch as the
296
+ // transition. Restart from the last mounted computed values so normal
297
+ // and interrupted exits both produce a concrete browser transition.
374
298
  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(', ')
407
-
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
- }
299
+ let disposed = false
422
300
 
423
- if (wasInterrupted) {
424
- exitInterruptedRef.current = false
425
- // disable transition, reset to enter state
301
+ const mountedStyle = lastMountedStyleRef.current
302
+ const canRestart =
303
+ exitCSSProperties.length > 0 && Object.keys(mountedStyle).length > 0
304
+ let exitTarget: Record<string, string> | undefined
305
+ if (canRestart) {
426
306
  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
307
+ // With transitions disabled, the exit classes expose their final
308
+ // targets immediately. Capture those, then restore the mounted values.
309
+ exitTarget = readComputedProperties(node, exitCSSProperties)
310
+ applyCSSProperties(node, mountedStyle)
446
311
  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
312
  rafId = requestAnimationFrame(() => {
477
313
  if (cycleId !== exitCycleIdRef.current) return
478
- // Re-enable transition
479
- node.style.transition = exitTransitionString
480
- // Force reflow to ensure transition is active
314
+ node.style.transition = getTransition()
481
315
  void node.offsetHeight
482
- // Apply exit styles - this triggers the animation
483
- applyStylesToNode(node, exitStyle)
484
- // Re-enable cancel event handling
485
- ignoreCancelEvents = false
316
+ applyCSSProperties(node, exitTarget!)
486
317
  })
487
318
  }
488
319
 
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
- }
320
+ // wait for the browser's concrete animations. this covers `all`,
321
+ // transform aliases, delays, and concurrent WAAPI animations without
322
+ // guessing property names or maintaining a duration timer.
323
+ void waitForAnimations(node).then((finished) => {
324
+ if (!disposed) completeExit(finished)
325
+ })
577
326
 
578
327
  return () => {
579
- clearTimeout(timeoutId)
328
+ disposed = true
580
329
  if (rafId !== undefined) cancelAnimationFrame(rafId)
581
- node.removeEventListener('transitionend', onFinishAnimation)
582
- node.removeEventListener('transitioncancel', onCancelAnimation)
330
+ clearCSSProperties(node, exitCSSProperties)
583
331
  // restore transition: the exit handling sets node.style.transition='none'
584
332
  // directly on the DOM (bypassing React). if exit is interrupted (e.g. same-key
585
333
  // re-entry in AnimatePresence), React won't re-apply its managed transition
@@ -587,7 +335,88 @@ export function createAnimations<A extends object>(animations: A): AnimationDriv
587
335
  // override lets React's value take effect again.
588
336
  node.style.transition = ''
589
337
  }
590
- }, [isExiting])
338
+ }, [isExiting, exitCSSPropertiesSignature])
339
+
340
+ // signature of the animatable style, so the update effect can detect
341
+ // in-place style changes. the css driver applies most style values as
342
+ // atomic classNames (not inline style), so the signature must include the
343
+ // className map. only computed when a listener is attached.
344
+ const styleSignature = onTransition
345
+ ? (() => {
346
+ const { transition: _t, ...rest } = style
347
+ return `${JSON.stringify(styleState?.classNames ?? null)}|${JSON.stringify(rest)}`
348
+ })()
349
+ : ''
350
+
351
+ // enter lifecycle: emit start when the enter transition kicks off, end
352
+ // once every animation on the node finishes (getAnimations-based, resolves
353
+ // immediately for zero-animation elements). the promise outlives benign
354
+ // re-renders because it keys off the cycle id, not the effect lifetime.
355
+ useIsomorphicLayoutEffect(() => {
356
+ const host = stateRef.current.host
357
+ if (!onTransitionRef.current || isExiting || !justFinishedEntering || !host) {
358
+ return
359
+ }
360
+ const node = host as HTMLElement
361
+ const cycleId = ++enterCycleIdRef.current
362
+ enterStartedRef.current = true
363
+ emit('start', 'enter')
364
+ void waitForAnimations(node).then((finished) => {
365
+ if (cycleId !== enterCycleIdRef.current || !enterStartedRef.current) return
366
+ enterStartedRef.current = false
367
+ emit('end', 'enter', finished)
368
+ })
369
+ }, [justFinishedEntering, isExiting])
370
+
371
+ // update lifecycle: a style change while mounted (not entering or exiting).
372
+ // a new update that supersedes an in-flight one emits end(finished:false).
373
+ useIsomorphicLayoutEffect(() => {
374
+ const host = stateRef.current.host
375
+ if (
376
+ !onTransitionRef.current ||
377
+ isEntering ||
378
+ justFinishedEntering ||
379
+ isExiting ||
380
+ !host
381
+ ) {
382
+ // keep the signature current so leaving enter/exit isn't seen as an update
383
+ prevUpdateSigRef.current = styleSignature
384
+ return
385
+ }
386
+ if (prevUpdateSigRef.current === null) {
387
+ prevUpdateSigRef.current = styleSignature
388
+ return
389
+ }
390
+ if (styleSignature === prevUpdateSigRef.current) return
391
+ prevUpdateSigRef.current = styleSignature
392
+
393
+ const node = host as HTMLElement
394
+ if (updateInFlightRef.current) {
395
+ emit('end', 'update', false)
396
+ }
397
+ updateInFlightRef.current = true
398
+ const cycleId = ++updateCycleIdRef.current
399
+ emit('start', 'update')
400
+ void waitForAnimations(node).then((finished) => {
401
+ if (cycleId !== updateCycleIdRef.current) return
402
+ updateInFlightRef.current = false
403
+ emit('end', 'update', finished)
404
+ })
405
+ }, [styleSignature, isEntering, justFinishedEntering, isExiting])
406
+
407
+ // interruption: emit a finished:false end for an enter canceled by an exit,
408
+ // or an exit canceled by a re-enter (before its own completion fired).
409
+ useIsomorphicLayoutEffect(() => {
410
+ if (justStartedExiting && enterStartedRef.current) {
411
+ enterCycleIdRef.current++
412
+ enterStartedRef.current = false
413
+ emit('end', 'enter', false)
414
+ }
415
+ if (justStoppedExiting && exitStartedRef.current && !exitCompletedRef.current) {
416
+ exitStartedRef.current = false
417
+ emit('end', 'exit', false)
418
+ }
419
+ }, [justStartedExiting, justStoppedExiting])
591
420
 
592
421
  // tamagui doesnt even use animation output during hydration
593
422
  if (isHydrating) {
@@ -595,7 +424,7 @@ export function createAnimations<A extends object>(animations: A): AnimationDriv
595
424
  }
596
425
 
597
426
  // Check if we have any animation to apply
598
- if (!hasNormalizedAnimation(normalized)) {
427
+ if (!hasTransition(resolved)) {
599
428
  return null
600
429
  }
601
430
 
@@ -603,45 +432,13 @@ export function createAnimations<A extends object>(animations: A): AnimationDriv
603
432
  style.transform = transformsToString(style.transform)
604
433
  }
605
434
 
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(', ')
435
+ style.transition = getTransition()
638
436
 
639
437
  if (process.env.NODE_ENV === 'development' && props['debug'] === 'verbose') {
640
438
  console.info('CSS animation', {
641
439
  props,
642
440
  animations,
643
- normalized,
644
- defaultAnimation,
441
+ resolved,
645
442
  style,
646
443
  isEntering,
647
444
  isExiting,
@@ -652,61 +449,3 @@ export function createAnimations<A extends object>(animations: A): AnimationDriv
652
449
  },
653
450
  }
654
451
  }
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 = `