@hanzogui/animations-reanimated 7.3.1 → 8.0.0

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 '@hanzogui/core'
15
15
  import { ResetPresence, usePresence } from '@hanzogui/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_, {
@@ -48,12 +49,50 @@ const getDefaultExport = <T,>(module: T | { default: T }): T => {
48
49
 
49
50
  const Animated = getDefaultExport(Animated_)
50
51
 
52
+ const silenceAnimatedComponentDevCheck = (style: unknown) => {
53
+ if (
54
+ process.env.NODE_ENV !== 'development' ||
55
+ isWeb ||
56
+ !style ||
57
+ typeof style !== 'object'
58
+ ) {
59
+ return
60
+ }
61
+
62
+ // react fabric's dev performance logger diffs component props and reads the
63
+ // reanimated guard getter even when the style is on an animated component.
64
+ Object.defineProperty(style, '_requiresAnimatedComponent', {
65
+ configurable: true,
66
+ enumerable: false,
67
+ value: true,
68
+ })
69
+ }
70
+
51
71
  // =============================================================================
52
72
  // Types
53
73
  // =============================================================================
54
74
 
55
75
  type ReanimatedAnimatedNumber = SharedValue<number>
56
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
+
57
96
  /** Spring animation configuration */
58
97
  type SpringConfig = {
59
98
  type?: 'spring'
@@ -95,32 +134,316 @@ const resolveDynamicValue = (value: unknown, isDark: boolean): unknown => {
95
134
  /** Animation completion callback type */
96
135
  type AnimationCallback = (finished?: boolean) => void
97
136
 
137
+ type MapperState = {
138
+ emitted: Record<string, boolean>
139
+ }
140
+
141
+ const updateMapperState = isWeb
142
+ ? (state: SharedValue<MapperState>, emitted: Record<string, boolean>) => {
143
+ state.value.emitted = emitted
144
+ }
145
+ : (state: SharedValue<MapperState>, emitted: Record<string, boolean>) => {
146
+ 'worklet'
147
+ if (!globalThis._WORKLET) return
148
+ state.modify((current) => {
149
+ current.emitted = emitted
150
+ return current
151
+ }, false)
152
+ }
153
+
154
+ const cloneAnimationValue = (value: unknown): unknown => {
155
+ if (Array.isArray(value)) {
156
+ return value.map(cloneAnimationValue)
157
+ }
158
+
159
+ if (value && typeof value === 'object') {
160
+ const next: Record<string, unknown> = {}
161
+ for (const key in value as Record<string, unknown>) {
162
+ next[key] = cloneAnimationValue((value as Record<string, unknown>)[key])
163
+ }
164
+ return next
165
+ }
166
+
167
+ return value
168
+ }
169
+
170
+ const cloneStyleRecord = (style: Record<string, unknown>): Record<string, unknown> => {
171
+ const next: Record<string, unknown> = {}
172
+ for (const key in style) {
173
+ next[key] = cloneAnimationValue(style[key])
174
+ }
175
+ return next
176
+ }
177
+
178
+ const cloneTransitionConfig = (config: TransitionConfig): TransitionConfig => {
179
+ return cloneAnimationValue(config) as TransitionConfig
180
+ }
181
+
182
+ const getAnimatedTransforms = (value: unknown): Array<Record<string, unknown>> => {
183
+ if (!Array.isArray(value)) return []
184
+ return value.filter(
185
+ (item): item is Record<string, unknown> =>
186
+ item !== null && typeof item === 'object' && !Array.isArray(item)
187
+ )
188
+ }
189
+
190
+ const getRemovedAnimatedKeys = (
191
+ current: Set<string>,
192
+ previous: Set<string>
193
+ ): Record<string, boolean> => {
194
+ const removedKeys: Record<string, boolean> = {}
195
+ for (const key of previous) {
196
+ if (!current.has(key)) removedKeys[key] = true
197
+ }
198
+ return removedKeys
199
+ }
200
+
201
+ // 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
+ const getImplicitDefault = (
204
+ key: string,
205
+ targetValue: number | string
206
+ ): number | string => {
207
+ 'worklet'
208
+ const value =
209
+ key === 'opacity' || key === 'scale' || key === 'scaleX' || key === 'scaleY' ? 1 : 0
210
+ if (typeof targetValue !== 'string') return value
211
+
212
+ // reanimated derives a string animation's suffix from its start value. keep
213
+ // units on implicit starts so fresh `50%` and `90deg` keys interpolate.
214
+ let suffixIndex = 0
215
+ while (suffixIndex < targetValue.length) {
216
+ const character = targetValue[suffixIndex]
217
+ const code = character.charCodeAt(0)
218
+ if (
219
+ (code >= 48 && code <= 57) ||
220
+ character === '.' ||
221
+ character === '-' ||
222
+ character === '+'
223
+ ) {
224
+ suffixIndex++
225
+ } else {
226
+ break
227
+ }
228
+ }
229
+ return suffixIndex > 0 ? `${value}${targetValue.slice(suffixIndex)}` : value
230
+ }
231
+
232
+ const COLOR_STYLE_KEYS: Record<string, boolean> = {
233
+ backgroundColor: true,
234
+ borderColor: true,
235
+ borderLeftColor: true,
236
+ borderRightColor: true,
237
+ borderTopColor: true,
238
+ borderBottomColor: true,
239
+ color: true,
240
+ }
241
+
242
+ // reanimated can pass either its rgba output or a processed numeric color back
243
+ // as animation history. snapshot targets are normalized on the JS side below;
244
+ // this worklet conversion exists only to validate that in-flight history.
245
+ const toReanimatedColor = (value: unknown): unknown => {
246
+ 'worklet'
247
+ if (typeof value === 'number') {
248
+ const color = value >>> 0
249
+ const alpha = ((color >>> 24) & 255) / 255
250
+ const red = (color >>> 16) & 255
251
+ const green = (color >>> 8) & 255
252
+ const blue = color & 255
253
+ return `rgba(${red}, ${green}, ${blue}, ${alpha})`
254
+ }
255
+ if (typeof value !== 'string') return value
256
+ const trimmed = value.trim()
257
+ if (trimmed === 'transparent') return 'rgba(0, 0, 0, 0)'
258
+ return trimmed
259
+ }
260
+
261
+ const isReanimatedColorHistory = (value: unknown): boolean => {
262
+ 'worklet'
263
+ const normalized = toReanimatedColor(value)
264
+ return typeof normalized === 'string' && normalized.startsWith('rgba(')
265
+ }
266
+
267
+ const normalizeAnimationColor = (value: unknown): string | undefined => {
268
+ if (typeof value === 'number') {
269
+ return toReanimatedColor(value) as string
270
+ }
271
+ if (typeof value !== 'string') return
272
+
273
+ const color = normalizeColor(value)
274
+ if (color == null) return
275
+ const red = Math.round((color & 0xff000000) >>> 24)
276
+ const green = Math.round((color & 0x00ff0000) >>> 16)
277
+ const blue = Math.round((color & 0x0000ff00) >>> 8)
278
+ const alpha = (color & 0x000000ff) / 255
279
+ return `rgba(${red}, ${green}, ${blue}, ${alpha})`
280
+ }
281
+
282
+ const buildSnapshot = (
283
+ animated: Record<string, unknown>,
284
+ statics: Record<string, unknown>,
285
+ transforms: Array<Record<string, unknown>>,
286
+ previousKeys: Set<string>,
287
+ lastPainted: Record<string, unknown>
288
+ ) => {
289
+ const snapshotAnimated: Record<string, unknown> = {}
290
+ const snapshotStatics = cloneStyleRecord(statics)
291
+
292
+ for (const key in snapshotStatics) {
293
+ if (!COLOR_STYLE_KEYS[key]) continue
294
+ const normalized = normalizeAnimationColor(snapshotStatics[key])
295
+ if (normalized !== undefined) snapshotStatics[key] = normalized
296
+ }
297
+
298
+ for (const key in animated) {
299
+ if (key === 'transform') continue
300
+ const value = animated[key]
301
+ if (COLOR_STYLE_KEYS[key]) {
302
+ const normalized = normalizeAnimationColor(value)
303
+ if (normalized === undefined) {
304
+ // invalid colors must paint plainly. creating a descriptor makes
305
+ // reanimated's string interpolator emit '<letters>NaN'.
306
+ snapshotStatics[key] = cloneAnimationValue(value)
307
+ continue
308
+ }
309
+ snapshotAnimated[key] = normalized
310
+ } else {
311
+ snapshotAnimated[key] = cloneAnimationValue(value)
312
+ }
313
+ }
314
+
315
+ const snapshotTransforms = transforms.map(cloneStyleRecord)
316
+ const keys = new Set(Object.keys(snapshotAnimated))
317
+ for (const transform of snapshotTransforms) {
318
+ const key = Object.keys(transform)[0]
319
+ const value = key ? transform[key] : undefined
320
+ if (key && (typeof value === 'number' || typeof value === 'string')) {
321
+ keys.add(`transform:${key}`)
322
+ }
323
+ }
324
+
325
+ const seeds: Record<string, unknown> = {}
326
+ for (const key of keys) {
327
+ let value = lastPainted[key]
328
+ if (typeof value !== 'number' && typeof value !== 'string') continue
329
+ if (value === 'auto' || (typeof value === 'string' && value.startsWith('calc'))) {
330
+ continue
331
+ }
332
+ if (COLOR_STYLE_KEYS[key]) value = normalizeAnimationColor(value)
333
+ if (value !== undefined) seeds[key] = value
334
+ }
335
+
336
+ const gatedKeys: Record<string, boolean> = {}
337
+ for (const key of keys) gatedKeys[key] = true
338
+
339
+ const removedKeys = getRemovedAnimatedKeys(keys, previousKeys)
340
+ const painted: Record<string, unknown> = {
341
+ ...snapshotStatics,
342
+ ...snapshotAnimated,
343
+ }
344
+ const staticTransforms = getAnimatedTransforms(snapshotStatics.transform)
345
+ delete painted.transform
346
+ for (const transform of staticTransforms) {
347
+ const key = Object.keys(transform)[0]
348
+ if (key) painted[`transform:${key}`] = transform[key]
349
+ }
350
+ for (const transform of snapshotTransforms) {
351
+ const key = Object.keys(transform)[0]
352
+ if (key) painted[`transform:${key}`] = transform[key]
353
+ }
354
+
355
+ return {
356
+ value: {
357
+ animated: snapshotAnimated,
358
+ statics: snapshotStatics,
359
+ transforms: snapshotTransforms,
360
+ gatedKeys,
361
+ seeds,
362
+ removedKeys,
363
+ removeTransform: Object.keys(removedKeys).some((key) =>
364
+ key.startsWith('transform:')
365
+ ),
366
+ clearValue: isWeb ? '' : null,
367
+ },
368
+ painted,
369
+ keys,
370
+ }
371
+ }
372
+
373
+ const getGatedKeys = (snapshot: AnimationSnapshot): string[] =>
374
+ Object.keys(snapshot.gatedKeys)
375
+
376
+ const createReanimatedConfig = (config: TransitionConfig): Record<string, unknown> => {
377
+ 'worklet'
378
+ const next: Record<string, unknown> = {}
379
+ const source = config as Record<string, unknown>
380
+
381
+ for (const key in source) {
382
+ if (key === 'type' || key === 'delay') continue
383
+ const value = source[key]
384
+ if (value !== undefined) {
385
+ next[key] = value
386
+ }
387
+ }
388
+
389
+ return next
390
+ }
391
+
98
392
  /**
99
393
  * Apply animation to a value based on config, with optional completion callback
100
394
  */
101
395
  const applyAnimation = (
102
396
  targetValue: number | string,
103
397
  config: TransitionConfig,
104
- callback?: AnimationCallback
398
+ callback?: AnimationCallback,
399
+ seedValue?: number | string,
400
+ validateStartAsColor = false
105
401
  ): number | string => {
106
402
  'worklet'
107
403
  const delay = config.delay
404
+ const reanimatedConfig = createReanimatedConfig(config)
108
405
 
109
406
  let animatedValue: any
110
407
  if (config.type === 'timing') {
111
408
  animatedValue = withTiming(
112
409
  targetValue as number,
113
- config as WithTimingConfig,
410
+ reanimatedConfig as WithTimingConfig,
114
411
  callback
115
412
  )
116
413
  } else {
117
414
  animatedValue = withSpring(
118
415
  targetValue as number,
119
- config as WithSpringConfig,
416
+ reanimatedConfig as WithSpringConfig,
120
417
  callback
121
418
  )
122
419
  }
123
420
 
421
+ // reanimated starts a descriptor from its per-view history for the key — the
422
+ // key's last output value, or the descriptor's own toValue when there is
423
+ // none (an instant snap). both are wrong for a key that turns animated with
424
+ // a painted predecessor: history may hold a stale clear-value ('') from when
425
+ // the key was last removed, which poisons the start value (NaN frames). the
426
+ // wrapper substitutes the seed for whatever start value reanimated passes.
427
+ // color history gets the same treatment only when reanimated's own parser
428
+ // rejects it, preserving valid in-flight values during interruption.
429
+ if (seedValue !== undefined || validateStartAsColor) {
430
+ const innerOnStart = animatedValue.onStart
431
+ animatedValue.onStart = (
432
+ animation: unknown,
433
+ value: unknown,
434
+ timestamp: number,
435
+ previousAnimation: unknown
436
+ ) => {
437
+ 'worklet'
438
+ const startValue = validateStartAsColor
439
+ ? isReanimatedColorHistory(value)
440
+ ? toReanimatedColor(value)
441
+ : (seedValue ?? targetValue)
442
+ : seedValue
443
+ innerOnStart(animation, startValue, timestamp, previousAnimation)
444
+ }
445
+ }
446
+
124
447
  if (delay && delay > 0) {
125
448
  animatedValue = withDelay(delay, animatedValue)
126
449
  }
@@ -128,6 +451,53 @@ const applyAnimation = (
128
451
  return animatedValue
129
452
  }
130
453
 
454
+ const animateSnapshotValue = (
455
+ key: string,
456
+ implicitKey: string,
457
+ targetValue: number | string,
458
+ config: TransitionConfig,
459
+ previouslyEmitted: boolean,
460
+ seedValue: unknown,
461
+ gated: boolean,
462
+ currentlyExiting: boolean,
463
+ exitCycleId: number,
464
+ currentlyCompletingAnimation: boolean,
465
+ didAnimateCycleId: number,
466
+ markExitKeyDone: (key: string, cycleId: number, finished: boolean) => void,
467
+ markDidAnimateKeyDone: (key: string, cycleId: number, finished: boolean) => void,
468
+ validateStartAsColor = false
469
+ ): number | string => {
470
+ 'worklet'
471
+
472
+ const cycleGated = gated && (currentlyExiting || currentlyCompletingAnimation)
473
+ if (!previouslyEmitted && seedValue === undefined && !cycleGated) {
474
+ return targetValue
475
+ }
476
+
477
+ let callback: AnimationCallback | undefined
478
+ if (gated && currentlyExiting) {
479
+ callback = (finished) => {
480
+ 'worklet'
481
+ runOnJS(markExitKeyDone)(key, exitCycleId, finished ?? false)
482
+ }
483
+ } else if (gated && currentlyCompletingAnimation) {
484
+ callback = (finished) => {
485
+ 'worklet'
486
+ runOnJS(markDidAnimateKeyDone)(key, didAnimateCycleId, finished ?? false)
487
+ }
488
+ }
489
+
490
+ return applyAnimation(
491
+ targetValue,
492
+ config,
493
+ callback,
494
+ previouslyEmitted
495
+ ? undefined
496
+ : ((seedValue ?? getImplicitDefault(implicitKey, targetValue)) as number | string),
497
+ validateStartAsColor
498
+ )
499
+ }
500
+
131
501
  // =============================================================================
132
502
  // Animatable Properties
133
503
  // =============================================================================
@@ -215,6 +585,29 @@ const canAnimateProperty = (
215
585
  return true
216
586
  }
217
587
 
588
+ const splitAnimationStyles = (
589
+ style: Record<string, unknown>,
590
+ isDark: boolean,
591
+ disableAnimation: boolean,
592
+ animateOnly?: string[]
593
+ ) => {
594
+ const animated: Record<string, unknown> = {}
595
+ const statics: Record<string, unknown> = {}
596
+
597
+ for (const key in style) {
598
+ const value = resolveDynamicValue(style[key], isDark)
599
+ if (value === undefined) continue
600
+
601
+ if (!disableAnimation && canAnimateProperty(key, value, animateOnly)) {
602
+ animated[key] = cloneAnimationValue(value)
603
+ } else {
604
+ statics[key] = cloneAnimationValue(value)
605
+ }
606
+ }
607
+
608
+ return { animated, statics }
609
+ }
610
+
218
611
  // =============================================================================
219
612
  // Animated Components (Web)
220
613
  // =============================================================================
@@ -290,17 +683,19 @@ function buildTransitionConfig<A extends Record<string, TransitionConfig>>(
290
683
  const normalized = normalizeTransition(transition)
291
684
  const effectiveKey = getEffectiveAnimation(normalized, animationState)
292
685
 
293
- let base = effectiveKey
294
- ? (animations[effectiveKey as keyof typeof animations] ??
295
- ({ type: 'spring' } as TransitionConfig))
296
- : ({ type: 'spring' } as TransitionConfig)
686
+ let base = cloneTransitionConfig(
687
+ effectiveKey
688
+ ? (animations[effectiveKey as keyof typeof animations] ??
689
+ ({ type: 'spring' } as TransitionConfig))
690
+ : ({ type: 'spring' } as TransitionConfig)
691
+ )
297
692
 
298
693
  if (normalized.delay) {
299
- base = { ...base, delay: normalized.delay }
694
+ base = cloneTransitionConfig({ ...base, delay: normalized.delay })
300
695
  }
301
696
 
302
697
  if (normalized.config) {
303
- base = { ...base, ...normalized.config }
698
+ base = cloneTransitionConfig({ ...base, ...normalized.config })
304
699
  // infer type: 'timing' if duration is provided without spring params
305
700
  if (
306
701
  base.type !== 'timing' &&
@@ -309,7 +704,7 @@ function buildTransitionConfig<A extends Record<string, TransitionConfig>>(
309
704
  normalized.config.stiffness === undefined &&
310
705
  normalized.config.mass === undefined
311
706
  ) {
312
- base = { ...base, type: 'timing' }
707
+ base = cloneTransitionConfig({ ...base, type: 'timing' })
313
708
  }
314
709
  }
315
710
 
@@ -319,18 +714,20 @@ function buildTransitionConfig<A extends Record<string, TransitionConfig>>(
319
714
  for (const key of styleKeys) {
320
715
  const propAnimation = normalized.properties[key]
321
716
  if (typeof propAnimation === 'string') {
322
- propertyConfigs[key] = animations[propAnimation as keyof typeof animations] ?? base
717
+ propertyConfigs[key] = cloneTransitionConfig(
718
+ animations[propAnimation as keyof typeof animations] ?? base
719
+ )
323
720
  } else if (propAnimation && typeof propAnimation === 'object') {
324
721
  const configType = (propAnimation as any).type
325
722
  const baseForProp = configType
326
723
  ? (animations[configType as keyof typeof animations] ?? base)
327
724
  : base
328
- propertyConfigs[key] = {
725
+ propertyConfigs[key] = cloneTransitionConfig({
329
726
  ...baseForProp,
330
727
  ...propAnimation,
331
- } as TransitionConfig
728
+ } as TransitionConfig)
332
729
  } else {
333
- propertyConfigs[key] = base
730
+ propertyConfigs[key] = cloneTransitionConfig(base)
334
731
  }
335
732
  }
336
733
 
@@ -383,10 +780,10 @@ export function createAnimations<A extends Record<string, TransitionConfig>>(
383
780
  // This matches behavior of moti and motion drivers
384
781
  const animations = {} as A
385
782
  for (const key in animationsConfig) {
386
- animations[key] = {
783
+ animations[key] = cloneTransitionConfig({
387
784
  type: 'spring',
388
785
  ...animationsConfig[key],
389
- } as A[typeof key]
786
+ }) as A[typeof key]
390
787
  }
391
788
 
392
789
  return {
@@ -424,30 +821,28 @@ export function createAnimations<A extends Record<string, TransitionConfig>>(
424
821
  sharedValue.value = next
425
822
  onFinish?.()
426
823
  } else {
427
- // animations must start on the UI thread in Reanimated 4.x —
824
+ // animations must start on the ui thread in reanimated 4.x -
428
825
  // withSpring/withTiming return animation descriptors that only
429
826
  // work when assigned to a SharedValue inside the UI runtime
430
827
  const cb = onFinish
431
- ? () => {
828
+ ? (finished?: boolean) => {
432
829
  'worklet'
433
- runOnJS(onFinish)()
830
+ if (finished !== false) {
831
+ runOnJS(onFinish)()
832
+ }
434
833
  }
435
834
  : undefined
436
835
 
836
+ const animationConfig = cloneTransitionConfig(config)
837
+
437
838
  if (isWeb) {
438
- // on web there's no UI thread — set directly
439
- sharedValue.value =
440
- config.type === 'spring'
441
- ? withSpring(next, config as WithSpringConfig, cb)
442
- : withTiming(next, config as WithTimingConfig, cb)
839
+ // on web there's no UI thread - set directly
840
+ sharedValue.value = applyAnimation(next, animationConfig, cb)
443
841
  } else {
444
- runOnUI(() => {
842
+ runOnUI((targetValue: number, animationConfig: TransitionConfig) => {
445
843
  'worklet'
446
- sharedValue.value =
447
- config.type === 'spring'
448
- ? withSpring(next, config as WithSpringConfig, cb)
449
- : withTiming(next, config as WithTimingConfig, cb)
450
- })()
844
+ sharedValue.value = applyAnimation(targetValue, animationConfig, cb)
845
+ })(next, animationConfig)
451
846
  }
452
847
  }
453
848
  },
@@ -491,27 +886,25 @@ export function createAnimations<A extends Record<string, TransitionConfig>>(
491
886
  }, [instance, getStyle])
492
887
  }
493
888
 
494
- // native: use useDerivedValue to compute the style on the UI thread.
495
- // this avoids reading instance.value during render (which triggers
496
- // reanimated's "Reading from `value` during component render" warning)
497
- // while keeping the indirection that gives useAnimatedStyle a pure
498
- // SharedValue closure — no non-SV items to confuse startMapper's
499
- // dependency tracking.
500
889
  const styleVal = useDerivedValue(() => {
501
890
  'worklet'
502
891
  return getStyle(instance.value)
503
892
  })
504
893
 
505
- return useAnimatedStyle(() => {
894
+ const animatedStyle = useAnimatedStyle(() => {
506
895
  'worklet'
507
896
  return styleVal.value
508
897
  })
898
+
899
+ silenceAnimatedComponentDevCheck(animatedStyle)
900
+
901
+ return animatedStyle
509
902
  },
510
903
 
511
904
  useAnimatedNumbersStyle(vals, getStyle) {
512
905
  const instances = vals.map((v) => v.getInstance())
513
906
 
514
- return useAnimatedStyle(
907
+ const animatedStyle = useAnimatedStyle(
515
908
  () => {
516
909
  'worklet'
517
910
  const currentValues = instances.map((inst) => inst.value)
@@ -519,6 +912,10 @@ export function createAnimations<A extends Record<string, TransitionConfig>>(
519
912
  },
520
913
  isWeb ? [getStyle, ...instances] : undefined
521
914
  )
915
+
916
+ silenceAnimatedComponentDevCheck(animatedStyle)
917
+
918
+ return animatedStyle
522
919
  },
523
920
 
524
921
  // =========================================================================
@@ -534,6 +931,7 @@ export function createAnimations<A extends Record<string, TransitionConfig>>(
534
931
  themeName,
535
932
  stateRef,
536
933
  styleState,
934
+ onDidAnimate,
537
935
  } = animationProps
538
936
 
539
937
  // State flags
@@ -569,6 +967,28 @@ export function createAnimations<A extends Record<string, TransitionConfig>>(
569
967
 
570
968
  // Get sendExitComplete callback from presence
571
969
  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(
981
+ (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?.()
989
+ }
990
+ }
991
+ )
572
992
 
573
993
  // =========================================================================
574
994
  // Exit cycle state for deterministic per-property completion tracking
@@ -611,6 +1031,9 @@ export function createAnimations<A extends Record<string, TransitionConfig>>(
611
1031
  exitCycleIdRef.current++
612
1032
  exitCompletedRef.current = false
613
1033
  pendingExitKeysRef.current.clear()
1034
+ didAnimateCycleIdRef.current++
1035
+ pendingDidAnimateKeysRef.current.clear()
1036
+ didAnimateCompletedRef.current = true
614
1037
  }
615
1038
  // invalidate pending callbacks when exit is canceled/interrupted
616
1039
  if (justStoppedExiting) {
@@ -622,7 +1045,11 @@ export function createAnimations<A extends Record<string, TransitionConfig>>(
622
1045
  useIsomorphicLayoutEffect(() => {
623
1046
  isExitingRef.value = isExiting
624
1047
  exitCycleIdShared.value = exitCycleIdRef.current
625
- }, [isExiting, exitCycleIdRef.current])
1048
+ if (justStartedExiting) {
1049
+ isCompletingAnimationRef.value = false
1050
+ didAnimateCycleIdShared.value = didAnimateCycleIdRef.current
1051
+ }
1052
+ }, [isExiting, exitCycleIdRef.current, justStartedExiting])
626
1053
 
627
1054
  // track previous exiting state
628
1055
  React.useEffect(() => {
@@ -632,45 +1059,146 @@ export function createAnimations<A extends Record<string, TransitionConfig>>(
632
1059
  // =========================================================================
633
1060
  // avoidRerenders: SharedValues for style updates without re-renders
634
1061
  // =========================================================================
635
- const animatedTargetsRef = useSharedValue<Record<string, unknown> | null>(null)
636
- const staticTargetsRef = useSharedValue<Record<string, unknown> | null>(null)
637
- const transformTargetsRef = useSharedValue<Array<Record<string, unknown>> | null>(
638
- null
639
- )
1062
+ const committedRenderKeysRef = useRef(new Set<string>())
1063
+ // full painted style of the last committed source (render or emitter):
1064
+ // statics + animated targets, transform sub-keys flattened as `transform:key`.
1065
+ // seeds read from it.
1066
+ const lastPaintedRef = useRef<Record<string, unknown>>({})
1067
+ // per-key first-animated values React keeps painting under the mapper —
1068
+ // see the styles memo below
1069
+ const carryRef = useRef<Record<string, unknown>>({})
640
1070
 
641
1071
  // Separate styles into animated and static
642
- const { animatedStyles, staticStyles } = useMemo(() => {
643
- const animated: Record<string, unknown> = {}
644
- const staticStyles: Record<string, unknown> = {}
1072
+ const { animatedStyles, staticStyles, nextCarry } = useMemo(() => {
645
1073
  const animateOnly = props.animateOnly as string[] | undefined
1074
+ const { animated, statics } = splitAnimationStyles(
1075
+ style as Record<string, unknown>,
1076
+ isDark,
1077
+ disableAnimation,
1078
+ animateOnly
1079
+ )
646
1080
 
647
- for (const key in style) {
648
- const rawValue = (style as Record<string, unknown>)[key]
649
- const value = resolveDynamicValue(rawValue, isDark)
650
-
651
- if (value === undefined) continue
652
-
653
- if (disableAnimation) {
654
- staticStyles[key] = value
655
- continue
656
- }
657
-
658
- if (canAnimateProperty(key, value, animateOnly)) {
659
- animated[key] = value
660
- } else {
661
- staticStyles[key] = value
1081
+ // every animated key keeps its FIRST animated value in React's style for
1082
+ // as long as it stays animated. the value never changes across renders,
1083
+ // so React's per-key style diff never touches the key again and the
1084
+ // mapper's inline writes survive every commit — reanimated's Fabric
1085
+ // commit hook provides this guarantee on native, web has no equivalent.
1086
+ // during enter the carry is the enterStyle (the mapper animates over it);
1087
+ // a key appearing post-mount (a just-measured height) paints its value in
1088
+ // the same commit the mapper first sees it, a frame before the mapper's
1089
+ // first write.
1090
+ // derive from committed state. mutating carryRef during render leaks
1091
+ // abandoned concurrent renders into later commits.
1092
+ const nextCarry = cloneStyleRecord(carryRef.current)
1093
+ for (const key in nextCarry) {
1094
+ if (!(key in animated)) delete nextCarry[key]
1095
+ }
1096
+ for (const key in animated) {
1097
+ if (!(key in nextCarry)) {
1098
+ nextCarry[key] = cloneAnimationValue(animated[key])
662
1099
  }
1100
+ statics[key] = nextCarry[key]
663
1101
  }
664
1102
 
665
- // During mount, include animated values in static to prevent flicker
666
- if (isMounting) {
667
- for (const key in animated) {
668
- staticStyles[key] = animated[key]
1103
+ return { animatedStyles: animated, staticStyles: statics, nextCarry }
1104
+ }, [disableAnimation, style, isDark, props.animateOnly])
1105
+
1106
+ const renderSnapshot = useMemo(
1107
+ () =>
1108
+ buildSnapshot(
1109
+ animatedStyles,
1110
+ staticStyles,
1111
+ getAnimatedTransforms(animatedStyles.transform),
1112
+ committedRenderKeysRef.current,
1113
+ lastPaintedRef.current
1114
+ ),
1115
+ [animatedStyles, staticStyles]
1116
+ )
1117
+ const renderSnapshotRef = useSharedValue<AnimationSnapshot>({
1118
+ animated: {},
1119
+ statics: {},
1120
+ transforms: [],
1121
+ gatedKeys: {},
1122
+ seeds: {},
1123
+ removedKeys: {},
1124
+ removeTransform: false,
1125
+ clearValue: isWeb ? '' : null,
1126
+ })
1127
+ const emitterSnapshotRef = useSharedValue<AnimationSnapshot | null>(null)
1128
+ const emitterKeysRef = useRef<Set<string> | null>(null)
1129
+
1130
+ useIsomorphicLayoutEffect(() => {
1131
+ carryRef.current = nextCarry
1132
+ committedRenderKeysRef.current = renderSnapshot.keys
1133
+ lastPaintedRef.current = renderSnapshot.painted
1134
+ }, [nextCarry, renderSnapshot])
1135
+
1136
+ // reconcile the emitter latch with real re-renders.
1137
+ //
1138
+ // the avoidReRenders fast path lets a pure pseudo change (hover/press/focus) push
1139
+ // styles straight to the worklet via useStyleEmitter with no React commit — it sets
1140
+ // emitterSnapshotRef. the worklet then treats `emitterSnapshotRef !== null` as a
1141
+ // permanent latch: once the emitter has fired even once, it reads its last-emitted
1142
+ // snapshot and IGNORES `animatedStyles` from every subsequent re-render. so a base
1143
+ // style that changes through React (e.g. a row's `backgroundColor` flipping with an
1144
+ // external selection store, not a hover) never reaches the screen — it stays stuck on
1145
+ // the stale emitted value until the next hover re-fires the emitter. (the "active row
1146
+ // highlight gets stuck until you hover it again" report.)
1147
+ //
1148
+ // a real re-render is the freshest source of truth for the base style, so on render we
1149
+ // drop the emitter latch and let the worklet fall back to `animatedStyles`. the one
1150
+ // exception: while a self pseudo (hover/press/focus) is active the emitted style is a
1151
+ // transient override that isn't in this render, so keep it latched — otherwise an
1152
+ // unrelated re-render while hovering would snap back to the base. `pseudoActiveRef`
1153
+ // tracks that flag straight off the emitter (which fires on every pseudo transition), so
1154
+ // the un-hover/un-press emission flips it false and the next render reclaims the style.
1155
+ // no value comparison needed; runtime media stays correct because a render re-reads live
1156
+ // media into `animatedStyles`.
1157
+ // exit overrides the pseudo exception: the exit render's style IS the
1158
+ // authoritative target, and an element that exits while hovered/focused (every
1159
+ // dialog closed by clicking a button inside it) would otherwise keep the stale
1160
+ // emitted base latched — the worklet then "animates" to values already on
1161
+ // screen, every exit-key callback completes in a frame, and AnimatePresence
1162
+ // unmounts immediately (the dialog-exit-snap). mirrored into a plain ref so the
1163
+ // emitter callback (which runs outside render) can read it.
1164
+ const pseudoActiveRef = useRef(false)
1165
+ const isExitingJSRef = useRef(false)
1166
+ isExitingJSRef.current = isExiting
1167
+ // the committed render is the worklet's source of truth whenever the emitter is not
1168
+ // latched, so every render must publish its snapshot — not just the renders that drop
1169
+ // a latch. animated keys live only in this snapshot after mount (staticStyles carries
1170
+ // them during mount only), so a render that never publishes leaves the worklet reading
1171
+ // an empty snapshot and the animated properties never reach the screen at all.
1172
+ const publishedSnapshotRef = useRef<object | null>(null)
1173
+ useIsomorphicLayoutEffect(() => {
1174
+ const droppingLatch =
1175
+ (isExiting || !pseudoActiveRef.current) && emitterSnapshotRef.value !== null
1176
+ // when the latch drops, keys the emitter owned that this render no longer has must
1177
+ // be cleared too, otherwise reanimated keeps painting the stale emitted value
1178
+ const emitterKeys = droppingLatch ? emitterKeysRef.current : null
1179
+
1180
+ if (droppingLatch || publishedSnapshotRef.current !== renderSnapshot.value) {
1181
+ const removedKeys = emitterKeys
1182
+ ? {
1183
+ ...renderSnapshot.value.removedKeys,
1184
+ ...getRemovedAnimatedKeys(renderSnapshot.keys, emitterKeys),
1185
+ }
1186
+ : renderSnapshot.value.removedKeys
1187
+ renderSnapshotRef.value = {
1188
+ ...renderSnapshot.value,
1189
+ removedKeys,
1190
+ removeTransform: Object.keys(removedKeys).some((key) =>
1191
+ key.startsWith('transform:')
1192
+ ),
669
1193
  }
1194
+ publishedSnapshotRef.current = renderSnapshot.value
670
1195
  }
671
1196
 
672
- return { animatedStyles: animated, staticStyles }
673
- }, [disableAnimation, style, isDark, isMounting, props.animateOnly])
1197
+ if (droppingLatch) {
1198
+ emitterSnapshotRef.value = null
1199
+ emitterKeysRef.current = null
1200
+ }
1201
+ })
674
1202
 
675
1203
  // Build animation config with per-property overrides using normalized transition
676
1204
  const { baseConfig, propertyConfigs } = useMemo(() => {
@@ -681,13 +1209,18 @@ export function createAnimations<A extends Record<string, TransitionConfig>>(
681
1209
  }
682
1210
  }
683
1211
 
1212
+ // use effectiveTransition (createComponent's single source of truth) so a
1213
+ // platform-pseudo component with no declared transition resolves its BASE
1214
+ // config to instant ('0ms') too — not just its pseudo flips. otherwise base
1215
+ // style changes on the driver path animate with the default spring and a
1216
+ // hover-out / base color change visibly lingers (looks "stuck").
684
1217
  return buildTransitionConfig(
685
- props.transition,
1218
+ effectiveTransition,
686
1219
  animations,
687
1220
  animationState,
688
1221
  getStyleKeys(animatedStyles)
689
1222
  )
690
- }, [isHydrating, props.transition, animatedStyles, animationState])
1223
+ }, [isHydrating, effectiveTransition, animatedStyles, animationState])
691
1224
 
692
1225
  // Store config in SharedValue for worklet access (concurrent-safe)
693
1226
  // Using useEffect to avoid writing to shared value during render
@@ -699,122 +1232,132 @@ export function createAnimations<A extends Record<string, TransitionConfig>>(
699
1232
  })
700
1233
 
701
1234
  useIsomorphicLayoutEffect(() => {
702
- configRef.set({ baseConfig, propertyConfigs, disableAnimation, isHydrating })
1235
+ configRef.value = {
1236
+ baseConfig,
1237
+ propertyConfigs,
1238
+ disableAnimation,
1239
+ isHydrating,
1240
+ }
703
1241
  }, [baseConfig, propertyConfigs, disableAnimation, isHydrating])
704
1242
 
705
1243
  // =========================================================================
706
- // avoidRerenders: Register style emitter callback
707
- // When hover/press/etc state changes, this is called instead of re-rendering
1244
+ // avoidRerenders: register style emitter callback
1245
+ // when hover/press/etc state changes, this is called instead of re-rendering
708
1246
  // =========================================================================
709
- useStyleEmitter?.((nextStyle: Record<string, unknown>, effectiveTransition) => {
710
- const animateOnly = props.animateOnly as string[] | undefined
711
- const animated: Record<string, unknown> = {}
712
- const statics: Record<string, unknown> = {}
713
- const transforms: Array<Record<string, unknown>> = []
714
-
715
- // effectiveTransition is computed in createComponent based on entering/exiting pseudo states
716
- // rebuild config whenever transition changes (entering OR exiting pseudo states)
717
- const transitionToUse = effectiveTransition ?? props.transition
718
- const { baseConfig: newBase, propertyConfigs: newPropertyConfigs } =
719
- buildTransitionConfig(
720
- transitionToUse,
721
- animations,
722
- animationState,
723
- getStyleKeys(nextStyle)
724
- )
725
-
726
- // update configRef with the new config
727
- configRef.set({
728
- baseConfig: newBase,
729
- propertyConfigs: newPropertyConfigs,
730
- disableAnimation: configRef.get().disableAnimation,
731
- isHydrating: configRef.get().isHydrating,
732
- })
733
-
734
- for (const key in nextStyle) {
735
- const rawValue = nextStyle[key]
736
- const value = resolveDynamicValue(rawValue, isDark)
737
-
738
- if (value == undefined) continue
739
-
740
- if (configRef.get().disableAnimation) {
741
- statics[key] = value
742
- continue
743
- }
744
-
745
- if (key === 'transform' && Array.isArray(value)) {
746
- for (const t of value as Record<string, unknown>[]) {
747
- if (t && typeof t === 'object') {
748
- const tKey = Object.keys(t)[0]
749
- const tVal = t[tKey]
750
- if (typeof tVal === 'number' || typeof tVal === 'string') {
751
- transforms.push(t)
752
- }
753
- }
754
- }
755
- continue
1247
+ useStyleEmitter?.(
1248
+ (nextStyle: Record<string, unknown>, effectiveTransition, pseudoActive) => {
1249
+ // while exiting, the exit render owns the style — a pseudo flip mid-exit
1250
+ // (hover-out as the element fades under the cursor) must not re-latch the
1251
+ // base style over the in-flight exit targets.
1252
+ if (isExitingJSRef.current) return
1253
+ // track whether a self pseudo is active so the render-time layout effect knows whether
1254
+ // this emitter snapshot is a transient override to keep latched or a base it can drop.
1255
+ pseudoActiveRef.current = pseudoActive === true
1256
+ const animateOnly = props.animateOnly as string[] | undefined
1257
+
1258
+ // effectiveTransition is computed in createComponent based on entering/exiting pseudo states
1259
+ // rebuild config whenever transition changes (entering OR exiting pseudo states)
1260
+ const transitionToUse = effectiveTransition ?? props.transition
1261
+ const { baseConfig: newBase, propertyConfigs: newPropertyConfigs } =
1262
+ buildTransitionConfig(
1263
+ transitionToUse,
1264
+ animations,
1265
+ animationState,
1266
+ getStyleKeys(nextStyle)
1267
+ )
1268
+
1269
+ // update configRef with the new config
1270
+ configRef.value = {
1271
+ baseConfig: newBase,
1272
+ propertyConfigs: newPropertyConfigs,
1273
+ disableAnimation: configRef.value.disableAnimation,
1274
+ isHydrating: configRef.value.isHydrating,
756
1275
  }
757
1276
 
758
- if (canAnimateProperty(key, value, animateOnly)) {
759
- animated[key] = value
760
- } else {
761
- statics[key] = value
762
- }
763
- }
764
-
765
- // Update SharedValues - on web, the mapper watches these if passed in dependencies
766
- animatedTargetsRef.set(animated)
767
- staticTargetsRef.set(statics)
768
- transformTargetsRef.set(transforms)
769
-
770
- if (
771
- process.env.NODE_ENV === 'development' &&
772
- props.debug &&
773
- props.debug !== 'profile'
774
- ) {
775
- console.info('[animations-reanimated] useStyleEmitter update', {
1277
+ const previousKeys = emitterKeysRef.current ?? committedRenderKeysRef.current
1278
+ const { animated, statics } = splitAnimationStyles(
1279
+ nextStyle,
1280
+ isDark,
1281
+ configRef.value.disableAnimation,
1282
+ animateOnly
1283
+ )
1284
+ const snapshot = buildSnapshot(
776
1285
  animated,
777
1286
  statics,
778
- transforms,
779
- })
1287
+ getAnimatedTransforms(animated.transform),
1288
+ previousKeys,
1289
+ lastPaintedRef.current
1290
+ )
1291
+ emitterSnapshotRef.value = snapshot.value
1292
+ emitterKeysRef.current = snapshot.keys
1293
+ lastPaintedRef.current = snapshot.painted
1294
+
1295
+ if (
1296
+ process.env.NODE_ENV === 'development' &&
1297
+ props.debug &&
1298
+ props.debug !== 'profile'
1299
+ ) {
1300
+ console.info('[animations-reanimated] useStyleEmitter update', {
1301
+ animated,
1302
+ statics,
1303
+ transforms: snapshot.value.transforms,
1304
+ })
1305
+ }
780
1306
  }
781
- })
1307
+ )
782
1308
 
783
1309
  // Compute and register exit keys synchronously during render to avoid race conditions
784
1310
  // This must happen BEFORE useAnimatedStyle runs so callbacks have a populated set
785
1311
  const exitKeysRegistered = useRef(false)
786
1312
  if (justStartedExiting && sendExitComplete) {
787
- const exitKeys: string[] = []
788
- const animateOnly = props.animateOnly as string[] | undefined
1313
+ const exitKeys = getGatedKeys(renderSnapshot.value)
1314
+ pendingExitKeysRef.current = new Set(exitKeys)
1315
+ exitKeysRegistered.current = exitKeys.length > 0
1316
+ }
789
1317
 
790
- // regular animated properties
791
- for (const key in animatedStyles) {
792
- if (key === 'transform') continue
793
- if (canAnimateProperty(key, animatedStyles[key], animateOnly)) {
794
- exitKeys.push(key)
795
- }
796
- }
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 &&
1330
+ !isExiting &&
1331
+ !isEntering &&
1332
+ !justFinishedEntering
1333
+ const didAnimateKeys = shouldRegisterDidAnimate
1334
+ ? getGatedKeys(renderSnapshot.value)
1335
+ : []
797
1336
 
798
- // transform sub-keys (filter by animateOnly if specified)
799
- const transforms = animatedStyles.transform
800
- if (transforms && Array.isArray(transforms)) {
801
- for (const t of transforms) {
802
- if (!t) continue
803
- const tKey = Object.keys(t)[0]
804
- if (tKey) {
805
- // check animateOnly filter for transform sub-keys
806
- if (animateOnly && !animateOnly.includes(tKey)) {
807
- continue
808
- }
809
- exitKeys.push(`transform:${tKey}`)
810
- }
811
- }
1337
+ useIsomorphicLayoutEffect(() => {
1338
+ if (didAnimateStyle === null) {
1339
+ previousDidAnimateStyleRef.current = null
1340
+ isCompletingAnimationRef.value = false
1341
+ return
812
1342
  }
813
1343
 
814
- // register keys for this cycle
815
- pendingExitKeysRef.current = new Set(exitKeys)
816
- exitKeysRegistered.current = exitKeys.length > 0
817
- }
1344
+ // only a committed render may advance the authoritative cycle. the
1345
+ // captured previous value prevents StrictMode's repeated effect from
1346
+ // registering the same transition twice.
1347
+ 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?.()
1359
+ }
1360
+ }, [didAnimateStyle, shouldRegisterDidAnimate])
818
1361
 
819
1362
  // handle zero-animation case in effect (after render commit)
820
1363
  React.useEffect(() => {
@@ -829,128 +1372,159 @@ export function createAnimations<A extends Record<string, TransitionConfig>>(
829
1372
  }
830
1373
  }, [justStartedExiting, sendExitComplete])
831
1374
 
1375
+ // the worklet's own record of which animated keys it has emitted (mirrors
1376
+ // reanimated's per-view style history, which resets whenever a key leaves
1377
+ // the output). a key not in here is fresh to the mapper no matter what
1378
+ // React has committed — several renders can coalesce into one mapper run.
1379
+ // native updates use modify with the same object and no forced update, so
1380
+ // the mapper doesn't react to its own history write.
1381
+ const mapperStateRef = useSharedValue<MapperState>({
1382
+ emitted: {},
1383
+ })
832
1384
  // Create animated style
833
1385
  const animatedStyle = useAnimatedStyle(
834
1386
  () => {
835
1387
  'worklet'
836
1388
 
837
- if (disableAnimation || isHydrating) {
1389
+ const mapperState = mapperStateRef.value
1390
+ const config = configRef.value
1391
+ if (config.disableAnimation || config.isHydrating) {
1392
+ // the empty return wipes reanimated's per-key history, so ours
1393
+ // resets with it
1394
+ updateMapperState(mapperStateRef, {})
838
1395
  return {}
839
1396
  }
840
1397
 
1398
+ const previouslyEmitted = mapperState.emitted
1399
+ const emitted: Record<string, boolean> = {}
1400
+
841
1401
  const result: Record<string, any> = {}
842
- const config = configRef.get()
843
1402
 
844
1403
  // Check if we have avoidRerenders updates from useStyleEmitter
845
- const emitterAnimated = animatedTargetsRef.value
846
- const emitterStatic = staticTargetsRef.value
847
- const emitterTransforms = transformTargetsRef.value
848
- const hasEmitterUpdates = emitterAnimated !== null
849
-
850
- // Use emitter values if available, otherwise use React state values
851
- const animatedValues = hasEmitterUpdates ? emitterAnimated! : animatedStyles
852
- const staticValues = hasEmitterUpdates ? emitterStatic! : {}
1404
+ const emitterSnapshot = emitterSnapshotRef.value
1405
+ const snapshot = emitterSnapshot ?? renderSnapshotRef.value
1406
+
1407
+ // Use emitter values if available, otherwise use the committed render snapshot.
1408
+ // statics must fall back to the render's staticStyles (not {}): once an
1409
+ // emitter snapshot has applied static keys, reanimated never unsets
1410
+ // them, so after the latch drops a stale emitted value (e.g. a border
1411
+ // color that missed animateOnly) would keep painting over the fresh
1412
+ // style prop forever
1413
+ const animatedValues = snapshot.animated
1414
+ const staticValues = snapshot.statics
853
1415
 
854
1416
  // read exit state from shared values
855
1417
  const currentlyExiting = isExitingRef.value
856
1418
  const currentCycleId = exitCycleIdShared.value
1419
+ const currentlyCompletingAnimation = isCompletingAnimationRef.value
1420
+ const currentDidAnimateCycleId = didAnimateCycleIdShared.value
857
1421
 
858
1422
  // Include static values from emitter (for hover/press style changes)
859
1423
  for (const key in staticValues) {
860
1424
  result[key] = staticValues[key]
861
1425
  }
1426
+ for (const key in snapshot.removedKeys) {
1427
+ if (!key.startsWith('transform:') && !(key in staticValues)) {
1428
+ result[key] = snapshot.clearValue
1429
+ }
1430
+ }
862
1431
 
863
1432
  // Animate regular properties
864
1433
  for (const key in animatedValues) {
865
1434
  if (key === 'transform') continue
866
1435
 
867
1436
  const targetValue = animatedValues[key]
868
- const propConfig = config.propertyConfigs[key] ?? config.baseConfig
869
-
870
- // create callback for exit tracking
871
- let callback: AnimationCallback | undefined
872
- if (currentlyExiting) {
873
- const capturedKey = key
874
- const capturedCycleId = currentCycleId
875
- callback = (finished) => {
876
- 'worklet'
877
- runOnJS(markExitKeyDone)(capturedKey, capturedCycleId, finished ?? false)
878
- }
1437
+ emitted[key] = true
1438
+ if (typeof targetValue !== 'number' && typeof targetValue !== 'string') {
1439
+ result[key] = targetValue
1440
+ continue
879
1441
  }
880
-
881
- result[key] = applyAnimation(targetValue as number, propConfig, callback)
1442
+ result[key] = animateSnapshotValue(
1443
+ key,
1444
+ key,
1445
+ targetValue,
1446
+ config.propertyConfigs[key] ?? config.baseConfig,
1447
+ !!previouslyEmitted[key],
1448
+ snapshot.seeds[key],
1449
+ !!snapshot.gatedKeys[key],
1450
+ currentlyExiting,
1451
+ currentCycleId,
1452
+ currentlyCompletingAnimation,
1453
+ currentDidAnimateCycleId,
1454
+ markExitKeyDone,
1455
+ markDidAnimateKeyDone,
1456
+ !!COLOR_STYLE_KEYS[key]
1457
+ )
882
1458
  }
883
1459
 
884
1460
  // Handle transforms
885
- const transforms = hasEmitterUpdates
886
- ? emitterTransforms
887
- : animatedStyles.transform
1461
+ const transforms = snapshot.transforms
888
1462
 
889
1463
  // Animate transform properties with validation
890
- if (transforms && Array.isArray(transforms)) {
1464
+ if (transforms.length > 0 || snapshot.removeTransform) {
891
1465
  const validTransforms: Record<string, unknown>[] = []
892
1466
 
893
1467
  for (const t of transforms) {
894
1468
  if (!t) continue
895
1469
  const keys = Object.keys(t)
896
1470
  if (keys.length === 0) continue
897
- const value = t[keys[0]]
1471
+ const transformKey = keys[0]
1472
+ const value = t[transformKey]
898
1473
  if (typeof value === 'number' || typeof value === 'string') {
899
- const transformKey = Object.keys(t)[0]
900
- const targetValue = t[transformKey]
901
1474
  const propConfig =
902
1475
  config.propertyConfigs[transformKey] ?? config.baseConfig
903
1476
 
904
- // create callback for exit tracking (transform sub-key)
905
- let callback: AnimationCallback | undefined
906
- if (currentlyExiting) {
907
- const capturedKey = `transform:${transformKey}`
908
- const capturedCycleId = currentCycleId
909
- callback = (finished) => {
910
- 'worklet'
911
- runOnJS(markExitKeyDone)(
912
- capturedKey,
913
- capturedCycleId,
914
- finished ?? false
915
- )
916
- }
917
- }
918
-
1477
+ const subKey = `transform:${transformKey}`
1478
+ emitted[subKey] = true
919
1479
  validTransforms.push({
920
- [transformKey]: applyAnimation(
921
- targetValue as number,
1480
+ [transformKey]: animateSnapshotValue(
1481
+ subKey,
1482
+ transformKey,
1483
+ value,
922
1484
  propConfig,
923
- callback
1485
+ !!previouslyEmitted[subKey],
1486
+ snapshot.seeds[subKey],
1487
+ !!snapshot.gatedKeys[subKey],
1488
+ currentlyExiting,
1489
+ currentCycleId,
1490
+ currentlyCompletingAnimation,
1491
+ currentDidAnimateCycleId,
1492
+ markExitKeyDone,
1493
+ markDidAnimateKeyDone
924
1494
  ),
925
1495
  })
1496
+ } else {
1497
+ validTransforms.push(t)
926
1498
  }
927
1499
  }
928
1500
 
929
- if (validTransforms.length > 0) {
1501
+ if (validTransforms.length > 0 || !('transform' in staticValues)) {
930
1502
  result.transform = validTransforms
931
1503
  }
932
1504
  }
933
1505
 
1506
+ updateMapperState(mapperStateRef, emitted)
1507
+
934
1508
  return result
935
1509
  },
936
1510
  isWeb
937
1511
  ? [
938
- animatedStyles,
939
- baseConfig,
940
- propertyConfigs,
941
- disableAnimation,
942
- isHydrating,
943
- // pass SharedValues so the mapper watches them on web (no babel plugin)
944
- animatedTargetsRef,
945
- staticTargetsRef,
946
- transformTargetsRef,
1512
+ // pass SharedValues so the stable mapper watches them on web (no babel plugin)
1513
+ renderSnapshotRef,
1514
+ emitterSnapshotRef,
1515
+ configRef,
947
1516
  isExitingRef,
948
1517
  exitCycleIdShared,
949
1518
  markExitKeyDone,
1519
+ isCompletingAnimationRef,
1520
+ didAnimateCycleIdShared,
1521
+ markDidAnimateKeyDone,
950
1522
  ]
951
1523
  : undefined
952
1524
  )
953
1525
 
1526
+ silenceAnimatedComponentDevCheck(animatedStyle)
1527
+
954
1528
  // Debug logging
955
1529
  if (
956
1530
  process.env.NODE_ENV === 'development' &&
@@ -968,6 +1542,9 @@ export function createAnimations<A extends Record<string, TransitionConfig>>(
968
1542
  })
969
1543
  }
970
1544
 
1545
+ // staticStyles carries animated keys only while mounting or on a key's
1546
+ // first post-mount appearance; after that the mapper owns them and the
1547
+ // commit bridge above keeps React commits from wiping its inline writes.
971
1548
  return {
972
1549
  style: [staticStyles, animatedStyle],
973
1550
  }