@tamagui/animations-reanimated 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.
@@ -11,10 +11,11 @@ import {
11
11
  View,
12
12
  type AnimationDriver,
13
13
  type UniversalAnimatedNumber,
14
+ createRefComponent,
14
15
  } from '@tamagui/core'
15
16
  import { ResetPresence, usePresence } from '@tamagui/use-presence'
16
17
  import normalizeColor from '@react-native/normalize-colors'
17
- import React, { forwardRef, useMemo, useRef } from 'react'
18
+ import React, { useMemo, useRef } from 'react'
18
19
  import type { SharedValue } from 'react-native-reanimated'
19
20
  import Animated_, {
20
21
  cancelAnimation,
@@ -83,7 +84,7 @@ type AnimationSnapshot = {
83
84
  * for each animated key (transform sub-keys as `transform:key`), the value the
84
85
  * previous committed render painted for it, when one exists and is animatable.
85
86
  * the worklet animates a mapper-fresh key FROM its seed instead of snapping —
86
- * this is how an enterStyle value painted statically during mount becomes the
87
+ * this is how an enter-clause value painted statically during mount becomes the
87
88
  * start point once the key turns animated. keys with no seed paint their target
88
89
  * directly (a key appearing from nothing has nothing to animate from).
89
90
  */
@@ -199,7 +200,7 @@ const getRemovedAnimatedKeys = (
199
200
  }
200
201
 
201
202
  // implicit start values for style keys that were never painted (e.g. a key
202
- // introduced by exitStyle with no base value). most numerics default to 0.
203
+ // introduced by an exit clause with no base value). most numerics default to 0.
203
204
  const getImplicitDefault = (
204
205
  key: string,
205
206
  targetValue: number | string
@@ -285,7 +286,11 @@ const buildSnapshot = (
285
286
  transforms: Array<Record<string, unknown>>,
286
287
  previousKeys: Set<string>,
287
288
  lastPainted: Record<string, unknown>
288
- ) => {
289
+ ): {
290
+ value: AnimationSnapshot
291
+ painted: Record<string, unknown>
292
+ keys: Set<string>
293
+ } => {
289
294
  const snapshotAnimated: Record<string, unknown> = {}
290
295
  const snapshotStatics = cloneStyleRecord(statics)
291
296
 
@@ -392,13 +397,13 @@ const createReanimatedConfig = (config: TransitionConfig): Record<string, unknow
392
397
  /**
393
398
  * Apply animation to a value based on config, with optional completion callback
394
399
  */
395
- const applyAnimation = (
396
- targetValue: number | string,
400
+ const applyAnimation = <T extends number | string>(
401
+ targetValue: T,
397
402
  config: TransitionConfig,
398
403
  callback?: AnimationCallback,
399
404
  seedValue?: number | string,
400
405
  validateStartAsColor = false
401
- ): number | string => {
406
+ ): T => {
402
407
  'worklet'
403
408
  const delay = config.delay
404
409
  const reanimatedConfig = createReanimatedConfig(config)
@@ -416,6 +421,23 @@ const applyAnimation = (
416
421
  reanimatedConfig as WithSpringConfig,
417
422
  callback
418
423
  )
424
+
425
+ // reanimated stamps lastTimestamp from performance.now() when an animation
426
+ // starts outside a frame (an event handler), then steps it on the frame
427
+ // timestamp, which is the frame's start and lags performance.now() badly
428
+ // when frames are starved. the delta goes negative, and the closed-form
429
+ // spring exponentiates instead of decaying: a tooltip crossing triggers on
430
+ // a loaded machine lands at translate -33,554,430px for a frame before it
431
+ // relaxes back. reanimated clamps the delta's upper bound only, so hold the
432
+ // clock monotonic before it reaches the spring math.
433
+ const innerOnFrame = animatedValue.onFrame
434
+ animatedValue.onFrame = (animation: any, now: number) => {
435
+ 'worklet'
436
+ return innerOnFrame(
437
+ animation,
438
+ now < animation.lastTimestamp ? animation.lastTimestamp : now
439
+ )
440
+ }
419
441
  }
420
442
 
421
443
  // reanimated starts a descriptor from its per-view history for the key — the
@@ -448,7 +470,7 @@ const applyAnimation = (
448
470
  animatedValue = withDelay(delay, animatedValue)
449
471
  }
450
472
 
451
- return animatedValue
473
+ return animatedValue as T
452
474
  }
453
475
 
454
476
  const animateSnapshotValue = (
@@ -461,15 +483,19 @@ const animateSnapshotValue = (
461
483
  gated: boolean,
462
484
  currentlyExiting: boolean,
463
485
  exitCycleId: number,
464
- currentlyCompletingAnimation: boolean,
465
- didAnimateCycleId: number,
486
+ currentlyCompletingEnter: boolean,
487
+ enterCycleId: number,
488
+ currentlyCompletingUpdate: boolean,
489
+ updateCycleId: number,
466
490
  markExitKeyDone: (key: string, cycleId: number, finished: boolean) => void,
467
- markDidAnimateKeyDone: (key: string, cycleId: number, finished: boolean) => void,
491
+ markEnterKeyDone: (key: string, cycleId: number) => void,
492
+ markUpdateKeyDone: (key: string, cycleId: number, finished: boolean) => void,
468
493
  validateStartAsColor = false
469
494
  ): number | string => {
470
495
  'worklet'
471
496
 
472
- const cycleGated = gated && (currentlyExiting || currentlyCompletingAnimation)
497
+ const cycleGated =
498
+ gated && (currentlyExiting || currentlyCompletingEnter || currentlyCompletingUpdate)
473
499
  if (!previouslyEmitted && seedValue === undefined && !cycleGated) {
474
500
  return targetValue
475
501
  }
@@ -480,10 +506,15 @@ const animateSnapshotValue = (
480
506
  'worklet'
481
507
  runOnJS(markExitKeyDone)(key, exitCycleId, finished ?? false)
482
508
  }
483
- } else if (gated && currentlyCompletingAnimation) {
509
+ } else if (gated && currentlyCompletingEnter) {
510
+ callback = () => {
511
+ 'worklet'
512
+ runOnJS(markEnterKeyDone)(key, enterCycleId)
513
+ }
514
+ } else if (gated && currentlyCompletingUpdate) {
484
515
  callback = (finished) => {
485
516
  'worklet'
486
- runOnJS(markDidAnimateKeyDone)(key, didAnimateCycleId, finished ?? false)
517
+ runOnJS(markUpdateKeyDone)(key, updateCycleId, finished ?? false)
487
518
  }
488
519
  }
489
520
 
@@ -620,7 +651,7 @@ function createWebAnimatedComponent(defaultTag: 'div' | 'span') {
620
651
  const isText = defaultTag === 'span'
621
652
 
622
653
  const Component = Animated.createAnimatedComponent(
623
- forwardRef((propsIn: any, ref) => {
654
+ createRefComponent((propsIn: any, ref) => {
624
655
  const { forwardedRef, render = defaultTag, ...rest } = propsIn
625
656
  const hostRef = useRef<HTMLElement>(null)
626
657
  const composedRefs = useComposedRefs(forwardedRef, ref, hostRef)
@@ -650,8 +681,9 @@ function createWebAnimatedComponent(defaultTag: 'div' | 'span') {
650
681
  stateRef as any,
651
682
  false
652
683
  )
684
+ const { nativeID, ...webProps } = transformedProps ?? viewProps
653
685
 
654
- return <Element {...transformedProps} ref={composedRefs} />
686
+ return <Element {...webProps} ref={composedRefs} />
655
687
  })
656
688
  )
657
689
  ;(Component as any).acceptRenderProp = true
@@ -695,7 +727,7 @@ function buildTransitionConfig<A extends Record<string, TransitionConfig>>(
695
727
  }
696
728
 
697
729
  if (normalized.config) {
698
- base = cloneTransitionConfig({ ...base, ...normalized.config })
730
+ base = cloneTransitionConfig({ ...base, ...normalized.config } as TransitionConfig)
699
731
  // infer type: 'timing' if duration is provided without spring params
700
732
  if (
701
733
  base.type !== 'timing' &&
@@ -931,7 +963,7 @@ export function createAnimations<A extends Record<string, TransitionConfig>>(
931
963
  themeName,
932
964
  stateRef,
933
965
  styleState,
934
- onDidAnimate,
966
+ onTransition,
935
967
  } = animationProps
936
968
 
937
969
  // State flags
@@ -967,25 +999,46 @@ export function createAnimations<A extends Record<string, TransitionConfig>>(
967
999
 
968
1000
  // Get sendExitComplete callback from presence
969
1001
  const sendExitComplete = presence?.[1]
970
- const onDidAnimateRef = useRef(onDidAnimate)
971
- useIsomorphicLayoutEffect(() => {
972
- onDidAnimateRef.current = onDidAnimate
973
- }, [onDidAnimate])
974
-
975
- const didAnimateCycleIdRef = useRef(0)
976
- const pendingDidAnimateKeysRef = useRef<Set<string>>(new Set())
977
- const didAnimateCompletedRef = useRef(false)
978
- const isCompletingAnimationRef = useSharedValue(false)
979
- const didAnimateCycleIdShared = useSharedValue(0)
980
- const markDidAnimateKeyDone = useEvent(
1002
+ const onTransitionRef = useRef(onTransition)
1003
+ onTransitionRef.current = onTransition
1004
+ const emit = (
1005
+ phase: 'start' | 'end',
1006
+ cause: 'enter' | 'exit' | 'update',
1007
+ finished?: boolean
1008
+ ) => {
1009
+ onTransitionRef.current?.(
1010
+ phase === 'end' ? { phase, cause, finished } : { phase, cause }
1011
+ )
1012
+ }
1013
+ const enterStartedRef = useRef(false)
1014
+ const exitStartedRef = useRef(false)
1015
+ const updateStartedRef = useRef(false)
1016
+ const isExitingJSRef = useRef(false)
1017
+ isExitingJSRef.current = isExiting
1018
+
1019
+ // =========================================================================
1020
+ // Update cycle state: an in-place style transition while mounted (not
1021
+ // enter/exit). drives the onTransition 'update' lifecycle events and the
1022
+ // components (e.g. accordion HeightAnimator) that retain content through
1023
+ // an in-place transition.
1024
+ // =========================================================================
1025
+ const updateCycleIdRef = useRef(0)
1026
+ const pendingUpdateKeysRef = useRef<Set<string>>(new Set())
1027
+ const updateCompletedRef = useRef(false)
1028
+ const isCompletingUpdateRef = useSharedValue(false)
1029
+ const updateCycleIdShared = useSharedValue(0)
1030
+ const markUpdateKeyDone = useEvent(
981
1031
  (key: string, cycleId: number, finished: boolean) => {
982
- if (!finished || cycleId !== didAnimateCycleIdRef.current) return
983
- if (didAnimateCompletedRef.current) return
984
- pendingDidAnimateKeysRef.current.delete(key)
985
- if (pendingDidAnimateKeysRef.current.size === 0) {
986
- didAnimateCompletedRef.current = true
987
- isCompletingAnimationRef.value = false
988
- onDidAnimateRef.current?.()
1032
+ if (!finished || cycleId !== updateCycleIdRef.current) return
1033
+ if (updateCompletedRef.current) return
1034
+ pendingUpdateKeysRef.current.delete(key)
1035
+ if (pendingUpdateKeysRef.current.size === 0) {
1036
+ updateCompletedRef.current = true
1037
+ isCompletingUpdateRef.value = false
1038
+ if (updateStartedRef.current) {
1039
+ updateStartedRef.current = false
1040
+ emit('end', 'update', true)
1041
+ }
989
1042
  }
990
1043
  }
991
1044
  )
@@ -1017,23 +1070,55 @@ export function createAnimations<A extends Record<string, TransitionConfig>>(
1017
1070
  // check if all exit animations are done
1018
1071
  if (pendingExitKeysRef.current.size === 0) {
1019
1072
  exitCompletedRef.current = true
1073
+ if (exitStartedRef.current) {
1074
+ exitStartedRef.current = false
1075
+ // exit 'end' fires immediately before presence safeToRemove
1076
+ emit('end', 'exit', true)
1077
+ }
1020
1078
  sendExitComplete?.()
1021
1079
  }
1022
1080
  }
1023
1081
  )
1024
1082
 
1083
+ // =========================================================================
1084
+ // Enter cycle state for the onTransition enter 'end' event
1085
+ // =========================================================================
1086
+ const enterCycleIdRef = useRef(0)
1087
+ const pendingEnterKeysRef = useRef<Set<string>>(new Set())
1088
+ const enterCompletedRef = useRef(false)
1089
+
1090
+ const markEnterKeyDone = useEvent((key: string, cycleId: number) => {
1091
+ if (cycleId !== enterCycleIdRef.current) return
1092
+ if (enterCompletedRef.current) return
1093
+ if (isExitingJSRef.current) return
1094
+
1095
+ pendingEnterKeysRef.current.delete(key)
1096
+
1097
+ if (pendingEnterKeysRef.current.size === 0) {
1098
+ enterCompletedRef.current = true
1099
+ if (enterStartedRef.current) {
1100
+ enterStartedRef.current = false
1101
+ emit('end', 'enter', true)
1102
+ }
1103
+ }
1104
+ })
1105
+
1025
1106
  // SharedValue to pass exit state into worklet
1026
1107
  const isExitingRef = useSharedValue(isExiting)
1027
1108
  const exitCycleIdShared = useSharedValue(exitCycleIdRef.current)
1109
+ const isCompletingEnterRef = useSharedValue(false)
1110
+ const enterCycleIdShared = useSharedValue(enterCycleIdRef.current)
1028
1111
 
1029
1112
  // start new exit cycle only on transition INTO exiting (not every render while exiting)
1030
1113
  if (justStartedExiting) {
1031
1114
  exitCycleIdRef.current++
1032
1115
  exitCompletedRef.current = false
1033
1116
  pendingExitKeysRef.current.clear()
1034
- didAnimateCycleIdRef.current++
1035
- pendingDidAnimateKeysRef.current.clear()
1036
- didAnimateCompletedRef.current = true
1117
+ enterCycleIdRef.current++
1118
+ pendingEnterKeysRef.current.clear()
1119
+ updateCycleIdRef.current++
1120
+ pendingUpdateKeysRef.current.clear()
1121
+ updateCompletedRef.current = true
1037
1122
  }
1038
1123
  // invalidate pending callbacks when exit is canceled/interrupted
1039
1124
  if (justStoppedExiting) {
@@ -1045,11 +1130,28 @@ export function createAnimations<A extends Record<string, TransitionConfig>>(
1045
1130
  useIsomorphicLayoutEffect(() => {
1046
1131
  isExitingRef.value = isExiting
1047
1132
  exitCycleIdShared.value = exitCycleIdRef.current
1133
+ isCompletingEnterRef.value =
1134
+ !isExiting && justFinishedEntering && Boolean(onTransitionRef.current)
1135
+ enterCycleIdShared.value = enterCycleIdRef.current
1048
1136
  if (justStartedExiting) {
1049
- isCompletingAnimationRef.value = false
1050
- didAnimateCycleIdShared.value = didAnimateCycleIdRef.current
1137
+ isCompletingUpdateRef.value = false
1138
+ updateCycleIdShared.value = updateCycleIdRef.current
1139
+ }
1140
+ }, [
1141
+ isExiting,
1142
+ justFinishedEntering,
1143
+ exitCycleIdRef.current,
1144
+ enterCycleIdRef.current,
1145
+ justStartedExiting,
1146
+ ])
1147
+
1148
+ // exit interrupted by a re-enter: report the exit as finished:false
1149
+ useIsomorphicLayoutEffect(() => {
1150
+ if (justStoppedExiting && exitStartedRef.current && !exitCompletedRef.current) {
1151
+ exitStartedRef.current = false
1152
+ emit('end', 'exit', false)
1051
1153
  }
1052
- }, [isExiting, exitCycleIdRef.current, justStartedExiting])
1154
+ }, [justStoppedExiting])
1053
1155
 
1054
1156
  // track previous exiting state
1055
1157
  React.useEffect(() => {
@@ -1083,7 +1185,7 @@ export function createAnimations<A extends Record<string, TransitionConfig>>(
1083
1185
  // so React's per-key style diff never touches the key again and the
1084
1186
  // mapper's inline writes survive every commit — reanimated's Fabric
1085
1187
  // commit hook provides this guarantee on native, web has no equivalent.
1086
- // during enter the carry is the enterStyle (the mapper animates over it);
1188
+ // during enter the carry is the enter-clause value (the mapper animates over it);
1087
1189
  // a key appearing post-mount (a just-measured height) paints its value in
1088
1190
  // the same commit the mapper first sees it, a frame before the mapper's
1089
1191
  // first write.
@@ -1162,22 +1264,28 @@ export function createAnimations<A extends Record<string, TransitionConfig>>(
1162
1264
  // unmounts immediately (the dialog-exit-snap). mirrored into a plain ref so the
1163
1265
  // emitter callback (which runs outside render) can read it.
1164
1266
  const pseudoActiveRef = useRef(false)
1165
- const isExitingJSRef = useRef(false)
1166
- isExitingJSRef.current = isExiting
1167
1267
  // the committed render is the worklet's source of truth whenever the emitter is not
1168
1268
  // latched, so every render must publish its snapshot — not just the renders that drop
1169
1269
  // a latch. animated keys live only in this snapshot after mount (staticStyles carries
1170
1270
  // them during mount only), so a render that never publishes leaves the worklet reading
1171
1271
  // an empty snapshot and the animated properties never reach the screen at all.
1272
+ // The first emitter snapshot can already contain the browser's real media-query state
1273
+ // while the first render still contains mediaQueryDefaultActive for hydration. Publish
1274
+ // that render behind the latch, but do not let it replace the fresher runtime snapshot.
1172
1275
  const publishedSnapshotRef = useRef<object | null>(null)
1173
1276
  useIsomorphicLayoutEffect(() => {
1277
+ const renderChanged = publishedSnapshotRef.current !== renderSnapshot.value
1174
1278
  const droppingLatch =
1175
- (isExiting || !pseudoActiveRef.current) && emitterSnapshotRef.value !== null
1279
+ (isExiting ||
1280
+ (!pseudoActiveRef.current &&
1281
+ publishedSnapshotRef.current !== null &&
1282
+ renderChanged)) &&
1283
+ emitterSnapshotRef.value !== null
1176
1284
  // when the latch drops, keys the emitter owned that this render no longer has must
1177
1285
  // be cleared too, otherwise reanimated keeps painting the stale emitted value
1178
1286
  const emitterKeys = droppingLatch ? emitterKeysRef.current : null
1179
1287
 
1180
- if (droppingLatch || publishedSnapshotRef.current !== renderSnapshot.value) {
1288
+ if (droppingLatch || renderChanged) {
1181
1289
  const removedKeys = emitterKeys
1182
1290
  ? {
1183
1291
  ...renderSnapshot.value.removedKeys,
@@ -1309,35 +1417,42 @@ export function createAnimations<A extends Record<string, TransitionConfig>>(
1309
1417
  // Compute and register exit keys synchronously during render to avoid race conditions
1310
1418
  // This must happen BEFORE useAnimatedStyle runs so callbacks have a populated set
1311
1419
  const exitKeysRegistered = useRef(false)
1420
+ const enterKeysRegistered = useRef(false)
1421
+
1422
+ if (!isExiting && justFinishedEntering && onTransition) {
1423
+ const enterKeys = getGatedKeys(renderSnapshot.value)
1424
+ enterCycleIdRef.current++
1425
+ enterCompletedRef.current = false
1426
+ pendingEnterKeysRef.current = new Set(enterKeys)
1427
+ enterKeysRegistered.current = enterKeys.length > 0
1428
+ }
1429
+
1312
1430
  if (justStartedExiting && sendExitComplete) {
1313
1431
  const exitKeys = getGatedKeys(renderSnapshot.value)
1314
1432
  pendingExitKeysRef.current = new Set(exitKeys)
1315
1433
  exitKeysRegistered.current = exitKeys.length > 0
1316
1434
  }
1317
1435
 
1318
- // onDidAnimate is an internal completion hook used by components that
1319
- // need to retain content through an in-place style transition. register
1320
- // the exact keys that can animate before publishing the cycle to the
1321
- // worklet; shared values are only written from the layout effect, never
1322
- // from the mapper that reads them.
1323
- const previousDidAnimateStyleRef = useRef<string | null>(null)
1324
- const didAnimateStyle = onDidAnimate ? JSON.stringify(style) : null
1325
- const previousDidAnimateStyle = previousDidAnimateStyleRef.current
1326
- const shouldRegisterDidAnimate =
1327
- didAnimateStyle !== null &&
1328
- previousDidAnimateStyle !== null &&
1329
- previousDidAnimateStyle !== didAnimateStyle &&
1436
+ // update cycle: a genuine in-place style change while mounted (not
1437
+ // entering or exiting). register the exact keys that can animate before
1438
+ // publishing the cycle to the worklet; shared values are only written
1439
+ // from the layout effect, never from the mapper that reads them.
1440
+ const previousUpdateStyleRef = useRef<string | null>(null)
1441
+ const updateStyle = onTransition ? JSON.stringify(style) : null
1442
+ const previousUpdateStyle = previousUpdateStyleRef.current
1443
+ const shouldRegisterUpdate =
1444
+ updateStyle !== null &&
1445
+ previousUpdateStyle !== null &&
1446
+ previousUpdateStyle !== updateStyle &&
1330
1447
  !isExiting &&
1331
1448
  !isEntering &&
1332
1449
  !justFinishedEntering
1333
- const didAnimateKeys = shouldRegisterDidAnimate
1334
- ? getGatedKeys(renderSnapshot.value)
1335
- : []
1450
+ const updateKeys = shouldRegisterUpdate ? getGatedKeys(renderSnapshot.value) : []
1336
1451
 
1337
1452
  useIsomorphicLayoutEffect(() => {
1338
- if (didAnimateStyle === null) {
1339
- previousDidAnimateStyleRef.current = null
1340
- isCompletingAnimationRef.value = false
1453
+ if (updateStyle === null) {
1454
+ previousUpdateStyleRef.current = null
1455
+ isCompletingUpdateRef.value = false
1341
1456
  return
1342
1457
  }
1343
1458
 
@@ -1345,33 +1460,82 @@ export function createAnimations<A extends Record<string, TransitionConfig>>(
1345
1460
  // captured previous value prevents StrictMode's repeated effect from
1346
1461
  // registering the same transition twice.
1347
1462
  const previousStyleStillCurrent =
1348
- previousDidAnimateStyleRef.current === previousDidAnimateStyle
1349
- previousDidAnimateStyleRef.current = didAnimateStyle
1350
- if (!shouldRegisterDidAnimate || !previousStyleStillCurrent) return
1351
-
1352
- const cycleId = ++didAnimateCycleIdRef.current
1353
- pendingDidAnimateKeysRef.current = new Set(didAnimateKeys)
1354
- didAnimateCompletedRef.current = didAnimateKeys.length === 0
1355
- isCompletingAnimationRef.value = didAnimateKeys.length > 0
1356
- didAnimateCycleIdShared.value = cycleId
1357
- if (didAnimateKeys.length === 0) {
1358
- onDidAnimateRef.current?.()
1463
+ previousUpdateStyleRef.current === previousUpdateStyle
1464
+ previousUpdateStyleRef.current = updateStyle
1465
+ if (!shouldRegisterUpdate || !previousStyleStillCurrent) return
1466
+
1467
+ const cycleId = ++updateCycleIdRef.current
1468
+ pendingUpdateKeysRef.current = new Set(updateKeys)
1469
+ updateCompletedRef.current = updateKeys.length === 0
1470
+ isCompletingUpdateRef.value = updateKeys.length > 0
1471
+ updateCycleIdShared.value = cycleId
1472
+
1473
+ // a superseded in-flight update reports finished:false before the new
1474
+ // cycle starts; a zero-key cycle reports an immediate start/end pair
1475
+ if (updateStartedRef.current) {
1476
+ emit('end', 'update', false)
1477
+ }
1478
+ updateStartedRef.current = true
1479
+ emit('start', 'update')
1480
+ if (updateKeys.length === 0) {
1481
+ updateStartedRef.current = false
1482
+ emit('end', 'update', true)
1359
1483
  }
1360
- }, [didAnimateStyle, shouldRegisterDidAnimate])
1484
+ }, [updateStyle, shouldRegisterUpdate])
1361
1485
 
1362
1486
  // handle zero-animation case in effect (after render commit)
1363
1487
  React.useEffect(() => {
1364
1488
  if (!justStartedExiting || !sendExitComplete) return
1365
1489
 
1490
+ // enter or update still in flight when exit begins is reported as finished:false
1491
+ if (enterStartedRef.current) {
1492
+ enterStartedRef.current = false
1493
+ emit('end', 'enter', false)
1494
+ }
1495
+ if (updateStartedRef.current) {
1496
+ updateStartedRef.current = false
1497
+ emit('end', 'update', false)
1498
+ }
1499
+
1500
+ // emit exit start once per exit cycle
1501
+ if (onTransitionRef.current && !exitStartedRef.current) {
1502
+ exitStartedRef.current = true
1503
+ emit('start', 'exit')
1504
+ }
1505
+
1366
1506
  // if no keys were registered, complete immediately
1367
1507
  if (!exitKeysRegistered.current && pendingExitKeysRef.current.size === 0) {
1368
1508
  if (!exitCompletedRef.current) {
1369
1509
  exitCompletedRef.current = true
1510
+ if (exitStartedRef.current) {
1511
+ exitStartedRef.current = false
1512
+ emit('end', 'exit', true)
1513
+ }
1370
1514
  sendExitComplete()
1371
1515
  }
1372
1516
  }
1373
1517
  }, [justStartedExiting, sendExitComplete])
1374
1518
 
1519
+ React.useEffect(() => {
1520
+ if (isExiting || !justFinishedEntering || !onTransition) return
1521
+
1522
+ // emit enter start once per enter cycle
1523
+ if (!enterStartedRef.current) {
1524
+ enterStartedRef.current = true
1525
+ emit('start', 'enter')
1526
+ }
1527
+
1528
+ if (!enterKeysRegistered.current && pendingEnterKeysRef.current.size === 0) {
1529
+ if (!enterCompletedRef.current) {
1530
+ enterCompletedRef.current = true
1531
+ if (enterStartedRef.current) {
1532
+ enterStartedRef.current = false
1533
+ emit('end', 'enter', true)
1534
+ }
1535
+ }
1536
+ }
1537
+ }, [isExiting, justFinishedEntering, onTransition])
1538
+
1375
1539
  // the worklet's own record of which animated keys it has emitted (mirrors
1376
1540
  // reanimated's per-view style history, which resets whenever a key leaves
1377
1541
  // the output). a key not in here is fresh to the mapper no matter what
@@ -1413,11 +1577,13 @@ export function createAnimations<A extends Record<string, TransitionConfig>>(
1413
1577
  const animatedValues = snapshot.animated
1414
1578
  const staticValues = snapshot.statics
1415
1579
 
1416
- // read exit state from shared values
1580
+ // read lifecycle state from shared values
1417
1581
  const currentlyExiting = isExitingRef.value
1418
1582
  const currentCycleId = exitCycleIdShared.value
1419
- const currentlyCompletingAnimation = isCompletingAnimationRef.value
1420
- const currentDidAnimateCycleId = didAnimateCycleIdShared.value
1583
+ const currentlyCompletingEnter = isCompletingEnterRef.value
1584
+ const currentEnterCycleId = enterCycleIdShared.value
1585
+ const currentlyCompletingUpdate = isCompletingUpdateRef.value
1586
+ const currentUpdateCycleId = updateCycleIdShared.value
1421
1587
 
1422
1588
  // Include static values from emitter (for hover/press style changes)
1423
1589
  for (const key in staticValues) {
@@ -1449,10 +1615,13 @@ export function createAnimations<A extends Record<string, TransitionConfig>>(
1449
1615
  !!snapshot.gatedKeys[key],
1450
1616
  currentlyExiting,
1451
1617
  currentCycleId,
1452
- currentlyCompletingAnimation,
1453
- currentDidAnimateCycleId,
1618
+ currentlyCompletingEnter,
1619
+ currentEnterCycleId,
1620
+ currentlyCompletingUpdate,
1621
+ currentUpdateCycleId,
1454
1622
  markExitKeyDone,
1455
- markDidAnimateKeyDone,
1623
+ markEnterKeyDone,
1624
+ markUpdateKeyDone,
1456
1625
  !!COLOR_STYLE_KEYS[key]
1457
1626
  )
1458
1627
  }
@@ -1487,10 +1656,13 @@ export function createAnimations<A extends Record<string, TransitionConfig>>(
1487
1656
  !!snapshot.gatedKeys[subKey],
1488
1657
  currentlyExiting,
1489
1658
  currentCycleId,
1490
- currentlyCompletingAnimation,
1491
- currentDidAnimateCycleId,
1659
+ currentlyCompletingEnter,
1660
+ currentEnterCycleId,
1661
+ currentlyCompletingUpdate,
1662
+ currentUpdateCycleId,
1492
1663
  markExitKeyDone,
1493
- markDidAnimateKeyDone
1664
+ markEnterKeyDone,
1665
+ markUpdateKeyDone
1494
1666
  ),
1495
1667
  })
1496
1668
  } else {
@@ -1516,9 +1688,12 @@ export function createAnimations<A extends Record<string, TransitionConfig>>(
1516
1688
  isExitingRef,
1517
1689
  exitCycleIdShared,
1518
1690
  markExitKeyDone,
1519
- isCompletingAnimationRef,
1520
- didAnimateCycleIdShared,
1521
- markDidAnimateKeyDone,
1691
+ isCompletingEnterRef,
1692
+ enterCycleIdShared,
1693
+ markEnterKeyDone,
1694
+ isCompletingUpdateRef,
1695
+ updateCycleIdShared,
1696
+ markUpdateKeyDone,
1522
1697
  ]
1523
1698
  : undefined
1524
1699
  )