@tamagui/animations-reanimated 2.4.6 → 2.5.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.
@@ -13,6 +13,7 @@ import {
13
13
  type UniversalAnimatedNumber,
14
14
  } from '@tamagui/core'
15
15
  import { ResetPresence, usePresence } from '@tamagui/use-presence'
16
+ import normalizeColor from '@react-native/normalize-colors'
16
17
  import React, { forwardRef, useMemo, useRef } from 'react'
17
18
  import type { SharedValue } from 'react-native-reanimated'
18
19
  import Animated_, {
@@ -73,6 +74,25 @@ const silenceAnimatedComponentDevCheck = (style: unknown) => {
73
74
 
74
75
  type ReanimatedAnimatedNumber = SharedValue<number>
75
76
 
77
+ type AnimationSnapshot = {
78
+ animated: Record<string, unknown>
79
+ statics: Record<string, unknown>
80
+ transforms: Array<Record<string, unknown>>
81
+ gatedKeys: Record<string, boolean>
82
+ /**
83
+ * for each animated key (transform sub-keys as `transform:key`), the value the
84
+ * previous committed render painted for it, when one exists and is animatable.
85
+ * 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
+ * start point once the key turns animated. keys with no seed paint their target
88
+ * directly (a key appearing from nothing has nothing to animate from).
89
+ */
90
+ seeds: Record<string, unknown>
91
+ removedKeys: Record<string, boolean>
92
+ removeTransform: boolean
93
+ clearValue: '' | null
94
+ }
95
+
76
96
  /** Spring animation configuration */
77
97
  type SpringConfig = {
78
98
  type?: 'spring'
@@ -130,10 +150,212 @@ const cloneAnimationValue = (value: unknown): unknown => {
130
150
  return value
131
151
  }
132
152
 
153
+ const cloneStyleRecord = (style: Record<string, unknown>): Record<string, unknown> => {
154
+ const next: Record<string, unknown> = {}
155
+ for (const key in style) {
156
+ next[key] = cloneAnimationValue(style[key])
157
+ }
158
+ return next
159
+ }
160
+
133
161
  const cloneTransitionConfig = (config: TransitionConfig): TransitionConfig => {
134
162
  return cloneAnimationValue(config) as TransitionConfig
135
163
  }
136
164
 
165
+ const getAnimatedTransforms = (value: unknown): Array<Record<string, unknown>> => {
166
+ if (!Array.isArray(value)) return []
167
+ return value.filter(
168
+ (item): item is Record<string, unknown> =>
169
+ item !== null && typeof item === 'object' && !Array.isArray(item)
170
+ )
171
+ }
172
+
173
+ const getRemovedAnimatedKeys = (
174
+ current: Set<string>,
175
+ previous: Set<string>
176
+ ): Record<string, boolean> => {
177
+ const removedKeys: Record<string, boolean> = {}
178
+ for (const key of previous) {
179
+ if (!current.has(key)) removedKeys[key] = true
180
+ }
181
+ return removedKeys
182
+ }
183
+
184
+ // implicit start values for style keys that were never painted (e.g. a key
185
+ // introduced by exitStyle with no base value). most numerics default to 0.
186
+ const getImplicitDefault = (
187
+ key: string,
188
+ targetValue: number | string
189
+ ): number | string => {
190
+ 'worklet'
191
+ const value =
192
+ key === 'opacity' || key === 'scale' || key === 'scaleX' || key === 'scaleY' ? 1 : 0
193
+ if (typeof targetValue !== 'string') return value
194
+
195
+ // reanimated derives a string animation's suffix from its start value. keep
196
+ // units on implicit starts so fresh `50%` and `90deg` keys interpolate.
197
+ let suffixIndex = 0
198
+ while (suffixIndex < targetValue.length) {
199
+ const character = targetValue[suffixIndex]
200
+ const code = character.charCodeAt(0)
201
+ if (
202
+ (code >= 48 && code <= 57) ||
203
+ character === '.' ||
204
+ character === '-' ||
205
+ character === '+'
206
+ ) {
207
+ suffixIndex++
208
+ } else {
209
+ break
210
+ }
211
+ }
212
+ return suffixIndex > 0 ? `${value}${targetValue.slice(suffixIndex)}` : value
213
+ }
214
+
215
+ const COLOR_STYLE_KEYS: Record<string, boolean> = {
216
+ backgroundColor: true,
217
+ borderColor: true,
218
+ borderLeftColor: true,
219
+ borderRightColor: true,
220
+ borderTopColor: true,
221
+ borderBottomColor: true,
222
+ color: true,
223
+ }
224
+
225
+ // reanimated can pass either its rgba output or a processed numeric color back
226
+ // as animation history. snapshot targets are normalized on the JS side below;
227
+ // this worklet conversion exists only to validate that in-flight history.
228
+ const toReanimatedColor = (value: unknown): unknown => {
229
+ 'worklet'
230
+ if (typeof value === 'number') {
231
+ const color = value >>> 0
232
+ const alpha = ((color >>> 24) & 255) / 255
233
+ const red = (color >>> 16) & 255
234
+ const green = (color >>> 8) & 255
235
+ const blue = color & 255
236
+ return `rgba(${red}, ${green}, ${blue}, ${alpha})`
237
+ }
238
+ if (typeof value !== 'string') return value
239
+ const trimmed = value.trim()
240
+ if (trimmed === 'transparent') return 'rgba(0, 0, 0, 0)'
241
+ return trimmed
242
+ }
243
+
244
+ const isReanimatedColorHistory = (value: unknown): boolean => {
245
+ 'worklet'
246
+ const normalized = toReanimatedColor(value)
247
+ return typeof normalized === 'string' && normalized.startsWith('rgba(')
248
+ }
249
+
250
+ const normalizeAnimationColor = (value: unknown): string | undefined => {
251
+ if (typeof value === 'number') {
252
+ return toReanimatedColor(value) as string
253
+ }
254
+ if (typeof value !== 'string') return
255
+
256
+ const color = normalizeColor(value)
257
+ if (color == null) return
258
+ const red = Math.round((color & 0xff000000) >>> 24)
259
+ const green = Math.round((color & 0x00ff0000) >>> 16)
260
+ const blue = Math.round((color & 0x0000ff00) >>> 8)
261
+ const alpha = (color & 0x000000ff) / 255
262
+ return `rgba(${red}, ${green}, ${blue}, ${alpha})`
263
+ }
264
+
265
+ const buildSnapshot = (
266
+ animated: Record<string, unknown>,
267
+ statics: Record<string, unknown>,
268
+ transforms: Array<Record<string, unknown>>,
269
+ previousKeys: Set<string>,
270
+ lastPainted: Record<string, unknown>
271
+ ) => {
272
+ const snapshotAnimated: Record<string, unknown> = {}
273
+ const snapshotStatics = cloneStyleRecord(statics)
274
+
275
+ for (const key in snapshotStatics) {
276
+ if (!COLOR_STYLE_KEYS[key]) continue
277
+ const normalized = normalizeAnimationColor(snapshotStatics[key])
278
+ if (normalized !== undefined) snapshotStatics[key] = normalized
279
+ }
280
+
281
+ for (const key in animated) {
282
+ if (key === 'transform') continue
283
+ const value = animated[key]
284
+ if (COLOR_STYLE_KEYS[key]) {
285
+ const normalized = normalizeAnimationColor(value)
286
+ if (normalized === undefined) {
287
+ // invalid colors must paint plainly. creating a descriptor makes
288
+ // reanimated's string interpolator emit '<letters>NaN'.
289
+ snapshotStatics[key] = cloneAnimationValue(value)
290
+ continue
291
+ }
292
+ snapshotAnimated[key] = normalized
293
+ } else {
294
+ snapshotAnimated[key] = cloneAnimationValue(value)
295
+ }
296
+ }
297
+
298
+ const snapshotTransforms = transforms.map(cloneStyleRecord)
299
+ const keys = new Set(Object.keys(snapshotAnimated))
300
+ for (const transform of snapshotTransforms) {
301
+ const key = Object.keys(transform)[0]
302
+ const value = key ? transform[key] : undefined
303
+ if (key && (typeof value === 'number' || typeof value === 'string')) {
304
+ keys.add(`transform:${key}`)
305
+ }
306
+ }
307
+
308
+ const seeds: Record<string, unknown> = {}
309
+ for (const key of keys) {
310
+ let value = lastPainted[key]
311
+ if (typeof value !== 'number' && typeof value !== 'string') continue
312
+ if (value === 'auto' || (typeof value === 'string' && value.startsWith('calc'))) {
313
+ continue
314
+ }
315
+ if (COLOR_STYLE_KEYS[key]) value = normalizeAnimationColor(value)
316
+ if (value !== undefined) seeds[key] = value
317
+ }
318
+
319
+ const gatedKeys: Record<string, boolean> = {}
320
+ for (const key of keys) gatedKeys[key] = true
321
+
322
+ const removedKeys = getRemovedAnimatedKeys(keys, previousKeys)
323
+ const painted: Record<string, unknown> = {
324
+ ...snapshotStatics,
325
+ ...snapshotAnimated,
326
+ }
327
+ const staticTransforms = getAnimatedTransforms(snapshotStatics.transform)
328
+ delete painted.transform
329
+ for (const transform of staticTransforms) {
330
+ const key = Object.keys(transform)[0]
331
+ if (key) painted[`transform:${key}`] = transform[key]
332
+ }
333
+ for (const transform of snapshotTransforms) {
334
+ const key = Object.keys(transform)[0]
335
+ if (key) painted[`transform:${key}`] = transform[key]
336
+ }
337
+
338
+ return {
339
+ value: {
340
+ animated: snapshotAnimated,
341
+ statics: snapshotStatics,
342
+ transforms: snapshotTransforms,
343
+ gatedKeys,
344
+ seeds,
345
+ removedKeys,
346
+ removeTransform: Object.keys(removedKeys).some((key) =>
347
+ key.startsWith('transform:')
348
+ ),
349
+ clearValue: isWeb ? '' : null,
350
+ },
351
+ painted,
352
+ keys,
353
+ }
354
+ }
355
+
356
+ const getGatedKeys = (snapshot: AnimationSnapshot): string[] =>
357
+ Object.keys(snapshot.gatedKeys)
358
+
137
359
  const createReanimatedConfig = (config: TransitionConfig): Record<string, unknown> => {
138
360
  'worklet'
139
361
  const next: Record<string, unknown> = {}
@@ -156,7 +378,9 @@ const createReanimatedConfig = (config: TransitionConfig): Record<string, unknow
156
378
  const applyAnimation = (
157
379
  targetValue: number | string,
158
380
  config: TransitionConfig,
159
- callback?: AnimationCallback
381
+ callback?: AnimationCallback,
382
+ seedValue?: number | string,
383
+ validateStartAsColor = false
160
384
  ): number | string => {
161
385
  'worklet'
162
386
  const delay = config.delay
@@ -177,6 +401,32 @@ const applyAnimation = (
177
401
  )
178
402
  }
179
403
 
404
+ // reanimated starts a descriptor from its per-view history for the key — the
405
+ // key's last output value, or the descriptor's own toValue when there is
406
+ // none (an instant snap). both are wrong for a key that turns animated with
407
+ // a painted predecessor: history may hold a stale clear-value ('') from when
408
+ // the key was last removed, which poisons the start value (NaN frames). the
409
+ // wrapper substitutes the seed for whatever start value reanimated passes.
410
+ // color history gets the same treatment only when reanimated's own parser
411
+ // rejects it, preserving valid in-flight values during interruption.
412
+ if (seedValue !== undefined || validateStartAsColor) {
413
+ const innerOnStart = animatedValue.onStart
414
+ animatedValue.onStart = (
415
+ animation: unknown,
416
+ value: unknown,
417
+ timestamp: number,
418
+ previousAnimation: unknown
419
+ ) => {
420
+ 'worklet'
421
+ const startValue = validateStartAsColor
422
+ ? isReanimatedColorHistory(value)
423
+ ? toReanimatedColor(value)
424
+ : (seedValue ?? targetValue)
425
+ : seedValue
426
+ innerOnStart(animation, startValue, timestamp, previousAnimation)
427
+ }
428
+ }
429
+
180
430
  if (delay && delay > 0) {
181
431
  animatedValue = withDelay(delay, animatedValue)
182
432
  }
@@ -184,6 +434,53 @@ const applyAnimation = (
184
434
  return animatedValue
185
435
  }
186
436
 
437
+ const animateSnapshotValue = (
438
+ key: string,
439
+ implicitKey: string,
440
+ targetValue: number | string,
441
+ config: TransitionConfig,
442
+ previouslyEmitted: boolean,
443
+ seedValue: unknown,
444
+ gated: boolean,
445
+ currentlyExiting: boolean,
446
+ exitCycleId: number,
447
+ currentlyCompletingAnimation: boolean,
448
+ didAnimateCycleId: number,
449
+ markExitKeyDone: (key: string, cycleId: number, finished: boolean) => void,
450
+ markDidAnimateKeyDone: (key: string, cycleId: number, finished: boolean) => void,
451
+ validateStartAsColor = false
452
+ ): number | string => {
453
+ 'worklet'
454
+
455
+ const cycleGated = gated && (currentlyExiting || currentlyCompletingAnimation)
456
+ if (!previouslyEmitted && seedValue === undefined && !cycleGated) {
457
+ return targetValue
458
+ }
459
+
460
+ let callback: AnimationCallback | undefined
461
+ if (gated && currentlyExiting) {
462
+ callback = (finished) => {
463
+ 'worklet'
464
+ runOnJS(markExitKeyDone)(key, exitCycleId, finished ?? false)
465
+ }
466
+ } else if (gated && currentlyCompletingAnimation) {
467
+ callback = (finished) => {
468
+ 'worklet'
469
+ runOnJS(markDidAnimateKeyDone)(key, didAnimateCycleId, finished ?? false)
470
+ }
471
+ }
472
+
473
+ return applyAnimation(
474
+ targetValue,
475
+ config,
476
+ callback,
477
+ previouslyEmitted
478
+ ? undefined
479
+ : ((seedValue ?? getImplicitDefault(implicitKey, targetValue)) as number | string),
480
+ validateStartAsColor
481
+ )
482
+ }
483
+
187
484
  // =============================================================================
188
485
  // Animatable Properties
189
486
  // =============================================================================
@@ -271,6 +568,29 @@ const canAnimateProperty = (
271
568
  return true
272
569
  }
273
570
 
571
+ const splitAnimationStyles = (
572
+ style: Record<string, unknown>,
573
+ isDark: boolean,
574
+ disableAnimation: boolean,
575
+ animateOnly?: string[]
576
+ ) => {
577
+ const animated: Record<string, unknown> = {}
578
+ const statics: Record<string, unknown> = {}
579
+
580
+ for (const key in style) {
581
+ const value = resolveDynamicValue(style[key], isDark)
582
+ if (value === undefined) continue
583
+
584
+ if (!disableAnimation && canAnimateProperty(key, value, animateOnly)) {
585
+ animated[key] = cloneAnimationValue(value)
586
+ } else {
587
+ statics[key] = cloneAnimationValue(value)
588
+ }
589
+ }
590
+
591
+ return { animated, statics }
592
+ }
593
+
274
594
  // =============================================================================
275
595
  // Animated Components (Web)
276
596
  // =============================================================================
@@ -594,6 +914,7 @@ export function createAnimations<A extends Record<string, TransitionConfig>>(
594
914
  themeName,
595
915
  stateRef,
596
916
  styleState,
917
+ onDidAnimate,
597
918
  } = animationProps
598
919
 
599
920
  // State flags
@@ -629,6 +950,28 @@ export function createAnimations<A extends Record<string, TransitionConfig>>(
629
950
 
630
951
  // Get sendExitComplete callback from presence
631
952
  const sendExitComplete = presence?.[1]
953
+ const onDidAnimateRef = useRef(onDidAnimate)
954
+ useIsomorphicLayoutEffect(() => {
955
+ onDidAnimateRef.current = onDidAnimate
956
+ }, [onDidAnimate])
957
+
958
+ const didAnimateCycleIdRef = useRef(0)
959
+ const pendingDidAnimateKeysRef = useRef<Set<string>>(new Set())
960
+ const didAnimateCompletedRef = useRef(false)
961
+ const isCompletingAnimationRef = useSharedValue(false)
962
+ const didAnimateCycleIdShared = useSharedValue(0)
963
+ const markDidAnimateKeyDone = useEvent(
964
+ (key: string, cycleId: number, finished: boolean) => {
965
+ if (!finished || cycleId !== didAnimateCycleIdRef.current) return
966
+ if (didAnimateCompletedRef.current) return
967
+ pendingDidAnimateKeysRef.current.delete(key)
968
+ if (pendingDidAnimateKeysRef.current.size === 0) {
969
+ didAnimateCompletedRef.current = true
970
+ isCompletingAnimationRef.value = false
971
+ onDidAnimateRef.current?.()
972
+ }
973
+ }
974
+ )
632
975
 
633
976
  // =========================================================================
634
977
  // Exit cycle state for deterministic per-property completion tracking
@@ -671,6 +1014,9 @@ export function createAnimations<A extends Record<string, TransitionConfig>>(
671
1014
  exitCycleIdRef.current++
672
1015
  exitCompletedRef.current = false
673
1016
  pendingExitKeysRef.current.clear()
1017
+ didAnimateCycleIdRef.current++
1018
+ pendingDidAnimateKeysRef.current.clear()
1019
+ didAnimateCompletedRef.current = true
674
1020
  }
675
1021
  // invalidate pending callbacks when exit is canceled/interrupted
676
1022
  if (justStoppedExiting) {
@@ -682,7 +1028,11 @@ export function createAnimations<A extends Record<string, TransitionConfig>>(
682
1028
  useIsomorphicLayoutEffect(() => {
683
1029
  isExitingRef.value = isExiting
684
1030
  exitCycleIdShared.value = exitCycleIdRef.current
685
- }, [isExiting, exitCycleIdRef.current])
1031
+ if (justStartedExiting) {
1032
+ isCompletingAnimationRef.value = false
1033
+ didAnimateCycleIdShared.value = didAnimateCycleIdRef.current
1034
+ }
1035
+ }, [isExiting, exitCycleIdRef.current, justStartedExiting])
686
1036
 
687
1037
  // track previous exiting state
688
1038
  React.useEffect(() => {
@@ -692,51 +1042,85 @@ export function createAnimations<A extends Record<string, TransitionConfig>>(
692
1042
  // =========================================================================
693
1043
  // avoidRerenders: SharedValues for style updates without re-renders
694
1044
  // =========================================================================
695
- const animatedTargetsRef = useSharedValue<Record<string, unknown> | null>(null)
696
- const staticTargetsRef = useSharedValue<Record<string, unknown> | null>(null)
697
- const transformTargetsRef = useSharedValue<Array<Record<string, unknown>> | null>(
698
- null
699
- )
1045
+ const committedRenderKeysRef = useRef(new Set<string>())
1046
+ // full painted style of the last committed source (render or emitter):
1047
+ // statics + animated targets, transform sub-keys flattened as `transform:key`.
1048
+ // seeds read from it.
1049
+ const lastPaintedRef = useRef<Record<string, unknown>>({})
1050
+ // per-key first-animated values React keeps painting under the mapper —
1051
+ // see the styles memo below
1052
+ const carryRef = useRef<Record<string, unknown>>({})
700
1053
 
701
1054
  // Separate styles into animated and static
702
- const { animatedStyles, staticStyles } = useMemo(() => {
703
- const animated: Record<string, unknown> = {}
704
- const staticStyles: Record<string, unknown> = {}
1055
+ const { animatedStyles, staticStyles, nextCarry } = useMemo(() => {
705
1056
  const animateOnly = props.animateOnly as string[] | undefined
1057
+ const { animated, statics } = splitAnimationStyles(
1058
+ style as Record<string, unknown>,
1059
+ isDark,
1060
+ disableAnimation,
1061
+ animateOnly
1062
+ )
706
1063
 
707
- for (const key in style) {
708
- const rawValue = (style as Record<string, unknown>)[key]
709
- const value = resolveDynamicValue(rawValue, isDark)
710
-
711
- if (value === undefined) continue
712
-
713
- if (disableAnimation) {
714
- staticStyles[key] = cloneAnimationValue(value)
715
- continue
716
- }
717
-
718
- if (canAnimateProperty(key, value, animateOnly)) {
719
- animated[key] = cloneAnimationValue(value)
720
- } else {
721
- staticStyles[key] = cloneAnimationValue(value)
722
- }
1064
+ // every animated key keeps its FIRST animated value in React's style for
1065
+ // as long as it stays animated. the value never changes across renders,
1066
+ // so React's per-key style diff never touches the key again and the
1067
+ // mapper's inline writes survive every commit — reanimated's Fabric
1068
+ // commit hook provides this guarantee on native, web has no equivalent.
1069
+ // during enter the carry is the enterStyle (the mapper animates over it);
1070
+ // a key appearing post-mount (a just-measured height) paints its value in
1071
+ // the same commit the mapper first sees it, a frame before the mapper's
1072
+ // first write.
1073
+ // derive from committed state. mutating carryRef during render leaks
1074
+ // abandoned concurrent renders into later commits.
1075
+ const nextCarry = cloneStyleRecord(carryRef.current)
1076
+ for (const key in nextCarry) {
1077
+ if (!(key in animated)) delete nextCarry[key]
723
1078
  }
724
-
725
- // During mount, include animated values in static to prevent flicker
726
- if (isMounting) {
727
- for (const key in animated) {
728
- staticStyles[key] = animated[key]
1079
+ for (const key in animated) {
1080
+ if (!(key in nextCarry)) {
1081
+ nextCarry[key] = cloneAnimationValue(animated[key])
729
1082
  }
1083
+ statics[key] = nextCarry[key]
730
1084
  }
731
1085
 
732
- return { animatedStyles: animated, staticStyles }
733
- }, [disableAnimation, style, isDark, isMounting, props.animateOnly])
1086
+ return { animatedStyles: animated, staticStyles: statics, nextCarry }
1087
+ }, [disableAnimation, style, isDark, props.animateOnly])
1088
+
1089
+ const renderSnapshot = useMemo(
1090
+ () =>
1091
+ buildSnapshot(
1092
+ animatedStyles,
1093
+ staticStyles,
1094
+ getAnimatedTransforms(animatedStyles.transform),
1095
+ committedRenderKeysRef.current,
1096
+ lastPaintedRef.current
1097
+ ),
1098
+ [animatedStyles, staticStyles]
1099
+ )
1100
+ const renderSnapshotRef = useSharedValue<AnimationSnapshot>({
1101
+ animated: {},
1102
+ statics: {},
1103
+ transforms: [],
1104
+ gatedKeys: {},
1105
+ seeds: {},
1106
+ removedKeys: {},
1107
+ removeTransform: false,
1108
+ clearValue: isWeb ? '' : null,
1109
+ })
1110
+ const emitterSnapshotRef = useSharedValue<AnimationSnapshot | null>(null)
1111
+ const emitterKeysRef = useRef<Set<string> | null>(null)
1112
+
1113
+ useIsomorphicLayoutEffect(() => {
1114
+ carryRef.current = nextCarry
1115
+ committedRenderKeysRef.current = renderSnapshot.keys
1116
+ lastPaintedRef.current = renderSnapshot.painted
1117
+ }, [nextCarry, renderSnapshot])
734
1118
 
735
1119
  // reconcile the emitter latch with real re-renders.
736
1120
  //
737
1121
  // the avoidReRenders fast path lets a pure pseudo change (hover/press/focus) push
738
1122
  // styles straight to the worklet via useStyleEmitter with no React commit — it sets
739
- // animatedTargetsRef. the worklet then treats `animatedTargetsRef !== null` as a
1123
+ // emitterSnapshotRef. the worklet then treats `emitterSnapshotRef !== null` as a
740
1124
  // permanent latch: once the emitter has fired even once, it reads its last-emitted
741
1125
  // snapshot and IGNORES `animatedStyles` from every subsequent re-render. so a base
742
1126
  // style that changes through React (e.g. a row's `backgroundColor` flipping with an
@@ -763,14 +1147,39 @@ export function createAnimations<A extends Record<string, TransitionConfig>>(
763
1147
  const pseudoActiveRef = useRef(false)
764
1148
  const isExitingJSRef = useRef(false)
765
1149
  isExitingJSRef.current = isExiting
1150
+ // the committed render is the worklet's source of truth whenever the emitter is not
1151
+ // latched, so every render must publish its snapshot — not just the renders that drop
1152
+ // a latch. animated keys live only in this snapshot after mount (staticStyles carries
1153
+ // them during mount only), so a render that never publishes leaves the worklet reading
1154
+ // an empty snapshot and the animated properties never reach the screen at all.
1155
+ const publishedSnapshotRef = useRef<object | null>(null)
766
1156
  useIsomorphicLayoutEffect(() => {
767
- if (
768
- (isExiting || !pseudoActiveRef.current) &&
769
- animatedTargetsRef.value !== null
770
- ) {
771
- animatedTargetsRef.value = null
772
- staticTargetsRef.value = null
773
- transformTargetsRef.value = null
1157
+ const droppingLatch =
1158
+ (isExiting || !pseudoActiveRef.current) && emitterSnapshotRef.value !== null
1159
+ // when the latch drops, keys the emitter owned that this render no longer has must
1160
+ // be cleared too, otherwise reanimated keeps painting the stale emitted value
1161
+ const emitterKeys = droppingLatch ? emitterKeysRef.current : null
1162
+
1163
+ if (droppingLatch || publishedSnapshotRef.current !== renderSnapshot.value) {
1164
+ const removedKeys = emitterKeys
1165
+ ? {
1166
+ ...renderSnapshot.value.removedKeys,
1167
+ ...getRemovedAnimatedKeys(renderSnapshot.keys, emitterKeys),
1168
+ }
1169
+ : renderSnapshot.value.removedKeys
1170
+ renderSnapshotRef.value = {
1171
+ ...renderSnapshot.value,
1172
+ removedKeys,
1173
+ removeTransform: Object.keys(removedKeys).some((key) =>
1174
+ key.startsWith('transform:')
1175
+ ),
1176
+ }
1177
+ publishedSnapshotRef.current = renderSnapshot.value
1178
+ }
1179
+
1180
+ if (droppingLatch) {
1181
+ emitterSnapshotRef.value = null
1182
+ emitterKeysRef.current = null
774
1183
  }
775
1184
  })
776
1185
 
@@ -828,9 +1237,6 @@ export function createAnimations<A extends Record<string, TransitionConfig>>(
828
1237
  // this emitter snapshot is a transient override to keep latched or a base it can drop.
829
1238
  pseudoActiveRef.current = pseudoActive === true
830
1239
  const animateOnly = props.animateOnly as string[] | undefined
831
- const animated: Record<string, unknown> = {}
832
- const statics: Record<string, unknown> = {}
833
- const transforms: Array<Record<string, unknown>> = []
834
1240
 
835
1241
  // effectiveTransition is computed in createComponent based on entering/exiting pseudo states
836
1242
  // rebuild config whenever transition changes (entering OR exiting pseudo states)
@@ -851,41 +1257,23 @@ export function createAnimations<A extends Record<string, TransitionConfig>>(
851
1257
  isHydrating: configRef.value.isHydrating,
852
1258
  }
853
1259
 
854
- for (const key in nextStyle) {
855
- const rawValue = nextStyle[key]
856
- const value = resolveDynamicValue(rawValue, isDark)
857
-
858
- if (value == undefined) continue
859
-
860
- if (configRef.value.disableAnimation) {
861
- statics[key] = cloneAnimationValue(value)
862
- continue
863
- }
864
-
865
- if (key === 'transform' && Array.isArray(value)) {
866
- for (const t of value as Record<string, unknown>[]) {
867
- if (t && typeof t === 'object') {
868
- const tKey = Object.keys(t)[0]
869
- const tVal = t[tKey]
870
- if (typeof tVal === 'number' || typeof tVal === 'string') {
871
- transforms.push(cloneAnimationValue(t) as Record<string, unknown>)
872
- }
873
- }
874
- }
875
- continue
876
- }
877
-
878
- if (canAnimateProperty(key, value, animateOnly)) {
879
- animated[key] = cloneAnimationValue(value)
880
- } else {
881
- statics[key] = cloneAnimationValue(value)
882
- }
883
- }
884
-
885
- // update shared values - on web, the mapper watches these if passed in dependencies
886
- animatedTargetsRef.value = animated
887
- staticTargetsRef.value = statics
888
- transformTargetsRef.value = transforms
1260
+ const previousKeys = emitterKeysRef.current ?? committedRenderKeysRef.current
1261
+ const { animated, statics } = splitAnimationStyles(
1262
+ nextStyle,
1263
+ isDark,
1264
+ configRef.value.disableAnimation,
1265
+ animateOnly
1266
+ )
1267
+ const snapshot = buildSnapshot(
1268
+ animated,
1269
+ statics,
1270
+ getAnimatedTransforms(animated.transform),
1271
+ previousKeys,
1272
+ lastPaintedRef.current
1273
+ )
1274
+ emitterSnapshotRef.value = snapshot.value
1275
+ emitterKeysRef.current = snapshot.keys
1276
+ lastPaintedRef.current = snapshot.painted
889
1277
 
890
1278
  if (
891
1279
  process.env.NODE_ENV === 'development' &&
@@ -895,7 +1283,7 @@ export function createAnimations<A extends Record<string, TransitionConfig>>(
895
1283
  console.info('[animations-reanimated] useStyleEmitter update', {
896
1284
  animated,
897
1285
  statics,
898
- transforms,
1286
+ transforms: snapshot.value.transforms,
899
1287
  })
900
1288
  }
901
1289
  }
@@ -905,37 +1293,54 @@ export function createAnimations<A extends Record<string, TransitionConfig>>(
905
1293
  // This must happen BEFORE useAnimatedStyle runs so callbacks have a populated set
906
1294
  const exitKeysRegistered = useRef(false)
907
1295
  if (justStartedExiting && sendExitComplete) {
908
- const exitKeys: string[] = []
909
- const animateOnly = props.animateOnly as string[] | undefined
1296
+ const exitKeys = getGatedKeys(renderSnapshot.value)
1297
+ pendingExitKeysRef.current = new Set(exitKeys)
1298
+ exitKeysRegistered.current = exitKeys.length > 0
1299
+ }
910
1300
 
911
- // regular animated properties
912
- for (const key in animatedStyles) {
913
- if (key === 'transform') continue
914
- if (canAnimateProperty(key, animatedStyles[key], animateOnly)) {
915
- exitKeys.push(key)
916
- }
917
- }
1301
+ // onDidAnimate is an internal completion hook used by components that
1302
+ // need to retain content through an in-place style transition. register
1303
+ // the exact keys that can animate before publishing the cycle to the
1304
+ // worklet; shared values are only written from the layout effect, never
1305
+ // from the mapper that reads them.
1306
+ const previousDidAnimateStyleRef = useRef<string | null>(null)
1307
+ const didAnimateStyle = onDidAnimate ? JSON.stringify(style) : null
1308
+ const previousDidAnimateStyle = previousDidAnimateStyleRef.current
1309
+ const shouldRegisterDidAnimate =
1310
+ didAnimateStyle !== null &&
1311
+ previousDidAnimateStyle !== null &&
1312
+ previousDidAnimateStyle !== didAnimateStyle &&
1313
+ !isExiting &&
1314
+ !isEntering &&
1315
+ !justFinishedEntering
1316
+ const didAnimateKeys = shouldRegisterDidAnimate
1317
+ ? getGatedKeys(renderSnapshot.value)
1318
+ : []
918
1319
 
919
- // transform sub-keys (filter by animateOnly if specified)
920
- const transforms = animatedStyles.transform
921
- if (transforms && Array.isArray(transforms)) {
922
- for (const t of transforms) {
923
- if (!t) continue
924
- const tKey = Object.keys(t)[0]
925
- if (tKey) {
926
- // check animateOnly filter for transform sub-keys
927
- if (animateOnly && !animateOnly.includes(tKey)) {
928
- continue
929
- }
930
- exitKeys.push(`transform:${tKey}`)
931
- }
932
- }
1320
+ useIsomorphicLayoutEffect(() => {
1321
+ if (didAnimateStyle === null) {
1322
+ previousDidAnimateStyleRef.current = null
1323
+ isCompletingAnimationRef.value = false
1324
+ return
933
1325
  }
934
1326
 
935
- // register keys for this cycle
936
- pendingExitKeysRef.current = new Set(exitKeys)
937
- exitKeysRegistered.current = exitKeys.length > 0
938
- }
1327
+ // only a committed render may advance the authoritative cycle. the
1328
+ // captured previous value prevents StrictMode's repeated effect from
1329
+ // registering the same transition twice.
1330
+ const previousStyleStillCurrent =
1331
+ previousDidAnimateStyleRef.current === previousDidAnimateStyle
1332
+ previousDidAnimateStyleRef.current = didAnimateStyle
1333
+ if (!shouldRegisterDidAnimate || !previousStyleStillCurrent) return
1334
+
1335
+ const cycleId = ++didAnimateCycleIdRef.current
1336
+ pendingDidAnimateKeysRef.current = new Set(didAnimateKeys)
1337
+ didAnimateCompletedRef.current = didAnimateKeys.length === 0
1338
+ isCompletingAnimationRef.value = didAnimateKeys.length > 0
1339
+ didAnimateCycleIdShared.value = cycleId
1340
+ if (didAnimateKeys.length === 0) {
1341
+ onDidAnimateRef.current?.()
1342
+ }
1343
+ }, [didAnimateStyle, shouldRegisterDidAnimate])
939
1344
 
940
1345
  // handle zero-animation case in effect (after render commit)
941
1346
  React.useEffect(() => {
@@ -950,130 +1355,153 @@ export function createAnimations<A extends Record<string, TransitionConfig>>(
950
1355
  }
951
1356
  }, [justStartedExiting, sendExitComplete])
952
1357
 
1358
+ // the worklet's own record of which animated keys it has emitted (mirrors
1359
+ // reanimated's per-view style history, which resets whenever a key leaves
1360
+ // the output). a key not in here is fresh to the mapper no matter what
1361
+ // React has committed — several renders can coalesce into one mapper run.
1362
+ // mutated in place from the worklet; never watched, so writes don't
1363
+ // retrigger the mapper.
1364
+ const mapperStateRef = useSharedValue<{ emitted: Record<string, boolean> }>({
1365
+ emitted: {},
1366
+ })
953
1367
  // Create animated style
954
1368
  const animatedStyle = useAnimatedStyle(
955
1369
  () => {
956
1370
  'worklet'
957
1371
 
958
- if (disableAnimation || isHydrating) {
1372
+ const mapperState = mapperStateRef.value
1373
+ const config = configRef.value
1374
+ if (config.disableAnimation || config.isHydrating) {
1375
+ // the empty return wipes reanimated's per-key history, so ours
1376
+ // resets with it
1377
+ mapperState.emitted = {}
959
1378
  return {}
960
1379
  }
961
1380
 
1381
+ const previouslyEmitted = mapperState.emitted
1382
+ const emitted: Record<string, boolean> = {}
1383
+
962
1384
  const result: Record<string, any> = {}
963
- const config = configRef.value
964
1385
 
965
1386
  // Check if we have avoidRerenders updates from useStyleEmitter
966
- const emitterAnimated = animatedTargetsRef.value
967
- const emitterStatic = staticTargetsRef.value
968
- const emitterTransforms = transformTargetsRef.value
969
- const hasEmitterUpdates = emitterAnimated !== null
1387
+ const emitterSnapshot = emitterSnapshotRef.value
1388
+ const snapshot = emitterSnapshot ?? renderSnapshotRef.value
970
1389
 
971
- // Use emitter values if available, otherwise use React state values.
1390
+ // Use emitter values if available, otherwise use the committed render snapshot.
972
1391
  // statics must fall back to the render's staticStyles (not {}): once an
973
1392
  // emitter snapshot has applied static keys, reanimated never unsets
974
1393
  // them, so after the latch drops a stale emitted value (e.g. a border
975
1394
  // color that missed animateOnly) would keep painting over the fresh
976
1395
  // style prop forever
977
- const animatedValues = hasEmitterUpdates ? emitterAnimated! : animatedStyles
978
- const staticValues = hasEmitterUpdates ? emitterStatic! : staticStyles
1396
+ const animatedValues = snapshot.animated
1397
+ const staticValues = snapshot.statics
979
1398
 
980
1399
  // read exit state from shared values
981
1400
  const currentlyExiting = isExitingRef.value
982
1401
  const currentCycleId = exitCycleIdShared.value
1402
+ const currentlyCompletingAnimation = isCompletingAnimationRef.value
1403
+ const currentDidAnimateCycleId = didAnimateCycleIdShared.value
983
1404
 
984
1405
  // Include static values from emitter (for hover/press style changes)
985
1406
  for (const key in staticValues) {
986
1407
  result[key] = staticValues[key]
987
1408
  }
1409
+ for (const key in snapshot.removedKeys) {
1410
+ if (!key.startsWith('transform:') && !(key in staticValues)) {
1411
+ result[key] = snapshot.clearValue
1412
+ }
1413
+ }
988
1414
 
989
1415
  // Animate regular properties
990
1416
  for (const key in animatedValues) {
991
1417
  if (key === 'transform') continue
992
1418
 
993
1419
  const targetValue = animatedValues[key]
994
- const propConfig = config.propertyConfigs[key] ?? config.baseConfig
995
-
996
- // create callback for exit tracking
997
- let callback: AnimationCallback | undefined
998
- if (currentlyExiting) {
999
- const capturedKey = key
1000
- const capturedCycleId = currentCycleId
1001
- callback = (finished) => {
1002
- 'worklet'
1003
- runOnJS(markExitKeyDone)(capturedKey, capturedCycleId, finished ?? false)
1004
- }
1420
+ emitted[key] = true
1421
+ if (typeof targetValue !== 'number' && typeof targetValue !== 'string') {
1422
+ result[key] = targetValue
1423
+ continue
1005
1424
  }
1006
-
1007
- result[key] = applyAnimation(targetValue as number, propConfig, callback)
1425
+ result[key] = animateSnapshotValue(
1426
+ key,
1427
+ key,
1428
+ targetValue,
1429
+ config.propertyConfigs[key] ?? config.baseConfig,
1430
+ !!previouslyEmitted[key],
1431
+ snapshot.seeds[key],
1432
+ !!snapshot.gatedKeys[key],
1433
+ currentlyExiting,
1434
+ currentCycleId,
1435
+ currentlyCompletingAnimation,
1436
+ currentDidAnimateCycleId,
1437
+ markExitKeyDone,
1438
+ markDidAnimateKeyDone,
1439
+ !!COLOR_STYLE_KEYS[key]
1440
+ )
1008
1441
  }
1009
1442
 
1010
1443
  // Handle transforms
1011
- const transforms = hasEmitterUpdates
1012
- ? emitterTransforms
1013
- : animatedStyles.transform
1444
+ const transforms = snapshot.transforms
1014
1445
 
1015
1446
  // Animate transform properties with validation
1016
- if (transforms && Array.isArray(transforms)) {
1447
+ if (transforms.length > 0 || snapshot.removeTransform) {
1017
1448
  const validTransforms: Record<string, unknown>[] = []
1018
1449
 
1019
1450
  for (const t of transforms) {
1020
1451
  if (!t) continue
1021
1452
  const keys = Object.keys(t)
1022
1453
  if (keys.length === 0) continue
1023
- const value = t[keys[0]]
1454
+ const transformKey = keys[0]
1455
+ const value = t[transformKey]
1024
1456
  if (typeof value === 'number' || typeof value === 'string') {
1025
- const transformKey = Object.keys(t)[0]
1026
- const targetValue = t[transformKey]
1027
1457
  const propConfig =
1028
1458
  config.propertyConfigs[transformKey] ?? config.baseConfig
1029
1459
 
1030
- // create callback for exit tracking (transform sub-key)
1031
- let callback: AnimationCallback | undefined
1032
- if (currentlyExiting) {
1033
- const capturedKey = `transform:${transformKey}`
1034
- const capturedCycleId = currentCycleId
1035
- callback = (finished) => {
1036
- 'worklet'
1037
- runOnJS(markExitKeyDone)(
1038
- capturedKey,
1039
- capturedCycleId,
1040
- finished ?? false
1041
- )
1042
- }
1043
- }
1044
-
1460
+ const subKey = `transform:${transformKey}`
1461
+ emitted[subKey] = true
1045
1462
  validTransforms.push({
1046
- [transformKey]: applyAnimation(
1047
- targetValue as number,
1463
+ [transformKey]: animateSnapshotValue(
1464
+ subKey,
1465
+ transformKey,
1466
+ value,
1048
1467
  propConfig,
1049
- callback
1468
+ !!previouslyEmitted[subKey],
1469
+ snapshot.seeds[subKey],
1470
+ !!snapshot.gatedKeys[subKey],
1471
+ currentlyExiting,
1472
+ currentCycleId,
1473
+ currentlyCompletingAnimation,
1474
+ currentDidAnimateCycleId,
1475
+ markExitKeyDone,
1476
+ markDidAnimateKeyDone
1050
1477
  ),
1051
1478
  })
1479
+ } else {
1480
+ validTransforms.push(t)
1052
1481
  }
1053
1482
  }
1054
1483
 
1055
- if (validTransforms.length > 0) {
1484
+ if (validTransforms.length > 0 || !('transform' in staticValues)) {
1056
1485
  result.transform = validTransforms
1057
1486
  }
1058
1487
  }
1059
1488
 
1489
+ mapperState.emitted = emitted
1490
+
1060
1491
  return result
1061
1492
  },
1062
1493
  isWeb
1063
1494
  ? [
1064
- animatedStyles,
1065
- staticStyles,
1066
- baseConfig,
1067
- propertyConfigs,
1068
- disableAnimation,
1069
- isHydrating,
1070
- // pass SharedValues so the mapper watches them on web (no babel plugin)
1071
- animatedTargetsRef,
1072
- staticTargetsRef,
1073
- transformTargetsRef,
1495
+ // pass SharedValues so the stable mapper watches them on web (no babel plugin)
1496
+ renderSnapshotRef,
1497
+ emitterSnapshotRef,
1498
+ configRef,
1074
1499
  isExitingRef,
1075
1500
  exitCycleIdShared,
1076
1501
  markExitKeyDone,
1502
+ isCompletingAnimationRef,
1503
+ didAnimateCycleIdShared,
1504
+ markDidAnimateKeyDone,
1077
1505
  ]
1078
1506
  : undefined
1079
1507
  )
@@ -1097,6 +1525,9 @@ export function createAnimations<A extends Record<string, TransitionConfig>>(
1097
1525
  })
1098
1526
  }
1099
1527
 
1528
+ // staticStyles carries animated keys only while mounting or on a key's
1529
+ // first post-mount appearance; after that the mapper owns them and the
1530
+ // commit bridge above keeps React commits from wiping its inline writes.
1100
1531
  return {
1101
1532
  style: [staticStyles, animatedStyle],
1102
1533
  }