@hanzogui/animations-reanimated 2.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.
@@ -0,0 +1,935 @@
1
+ import { normalizeTransition, getEffectiveAnimation } from '@hanzogui/animation-helpers'
2
+ import {
3
+ getSplitStyles,
4
+ hooks,
5
+ isWeb,
6
+ type PseudoTransitions,
7
+ Text,
8
+ useComposedRefs,
9
+ useEvent,
10
+ useIsomorphicLayoutEffect,
11
+ useThemeWithState,
12
+ View,
13
+ type AnimationDriver,
14
+ type UniversalAnimatedNumber,
15
+ } from '@hanzogui/core'
16
+ import { ResetPresence, usePresence } from '@hanzogui/use-presence'
17
+ import React, { forwardRef, useMemo, useRef } from 'react'
18
+ import type { SharedValue } from 'react-native-reanimated'
19
+ import Animated_, {
20
+ cancelAnimation,
21
+ runOnJS,
22
+ useAnimatedReaction,
23
+ useAnimatedStyle,
24
+ useDerivedValue,
25
+ useSharedValue,
26
+ withDelay,
27
+ withSpring,
28
+ withTiming,
29
+ type WithSpringConfig,
30
+ type WithTimingConfig,
31
+ } from 'react-native-reanimated'
32
+
33
+ // =============================================================================
34
+ // ESM/CJS compatibility
35
+ // =============================================================================
36
+
37
+ /**
38
+ * Handle ESM/CJS module interop for react-native-reanimated
39
+ * @see https://github.com/evanw/esbuild/issues/2480#issuecomment-1833104754
40
+ */
41
+ const getDefaultExport = <T,>(module: T | { default: T }): T => {
42
+ const mod = module as any
43
+ if (mod.__esModule || mod[Symbol.toStringTag] === 'Module') {
44
+ return mod.default || mod
45
+ }
46
+ return mod
47
+ }
48
+
49
+ const Animated = getDefaultExport(Animated_)
50
+
51
+ // =============================================================================
52
+ // Types
53
+ // =============================================================================
54
+
55
+ type ReanimatedAnimatedNumber = SharedValue<number>
56
+
57
+ /** Spring animation configuration */
58
+ type SpringConfig = {
59
+ type?: 'spring'
60
+ delay?: number
61
+ } & Partial<WithSpringConfig>
62
+
63
+ /** Timing animation configuration */
64
+ type TimingConfig = {
65
+ type: 'timing'
66
+ delay?: number
67
+ } & Partial<WithTimingConfig>
68
+
69
+ /** Combined animation configuration type */
70
+ export type TransitionConfig = SpringConfig | TimingConfig
71
+
72
+ /** Options for createAnimations (reserved for future use) */
73
+ export type CreateAnimationsOptions = {}
74
+
75
+ // =============================================================================
76
+ // Utility Functions
77
+ // =============================================================================
78
+
79
+ /**
80
+ * Resolve dynamic theme values (e.g., `{dynamic: {dark: "value", light: "value"}}`)
81
+ */
82
+ const resolveDynamicValue = (value: unknown, isDark: boolean): unknown => {
83
+ if (
84
+ value !== null &&
85
+ typeof value === 'object' &&
86
+ 'dynamic' in value &&
87
+ typeof (value as any).dynamic === 'object'
88
+ ) {
89
+ const dynamic = (value as any).dynamic
90
+ return isDark ? dynamic.dark : dynamic.light
91
+ }
92
+ return value
93
+ }
94
+
95
+ /** Animation completion callback type */
96
+ type AnimationCallback = (finished?: boolean) => void
97
+
98
+ /**
99
+ * Apply animation to a value based on config, with optional completion callback
100
+ */
101
+ const applyAnimation = (
102
+ targetValue: number | string,
103
+ config: TransitionConfig,
104
+ callback?: AnimationCallback
105
+ ): number | string => {
106
+ 'worklet'
107
+ const delay = config.delay
108
+
109
+ let animatedValue: any
110
+ if (config.type === 'timing') {
111
+ animatedValue = withTiming(
112
+ targetValue as number,
113
+ config as WithTimingConfig,
114
+ callback
115
+ )
116
+ } else {
117
+ animatedValue = withSpring(
118
+ targetValue as number,
119
+ config as WithSpringConfig,
120
+ callback
121
+ )
122
+ }
123
+
124
+ if (delay && delay > 0) {
125
+ animatedValue = withDelay(delay, animatedValue)
126
+ }
127
+
128
+ return animatedValue
129
+ }
130
+
131
+ // =============================================================================
132
+ // Animatable Properties
133
+ // =============================================================================
134
+
135
+ const ANIMATABLE_PROPERTIES: Record<string, boolean> = {
136
+ // Transform
137
+ transform: true,
138
+ // Opacity
139
+ opacity: true,
140
+ // Dimensions
141
+ height: true,
142
+ width: true,
143
+ minWidth: true,
144
+ minHeight: true,
145
+ maxWidth: true,
146
+ maxHeight: true,
147
+ // Background
148
+ backgroundColor: true,
149
+ // Border colors
150
+ borderColor: true,
151
+ borderLeftColor: true,
152
+ borderRightColor: true,
153
+ borderTopColor: true,
154
+ borderBottomColor: true,
155
+ // Border radius
156
+ borderRadius: true,
157
+ borderTopLeftRadius: true,
158
+ borderTopRightRadius: true,
159
+ borderBottomLeftRadius: true,
160
+ borderBottomRightRadius: true,
161
+ // Border width
162
+ borderWidth: true,
163
+ borderLeftWidth: true,
164
+ borderRightWidth: true,
165
+ borderTopWidth: true,
166
+ borderBottomWidth: true,
167
+ // Text
168
+ color: true,
169
+ fontSize: true,
170
+ fontWeight: true,
171
+ lineHeight: true,
172
+ letterSpacing: true,
173
+ // Position
174
+ left: true,
175
+ right: true,
176
+ top: true,
177
+ bottom: true,
178
+ // Margin
179
+ margin: true,
180
+ marginTop: true,
181
+ marginBottom: true,
182
+ marginLeft: true,
183
+ marginRight: true,
184
+ marginHorizontal: true,
185
+ marginVertical: true,
186
+ // Padding
187
+ padding: true,
188
+ paddingTop: true,
189
+ paddingBottom: true,
190
+ paddingLeft: true,
191
+ paddingRight: true,
192
+ paddingHorizontal: true,
193
+ paddingVertical: true,
194
+ // Flex/Gap
195
+ gap: true,
196
+ rowGap: true,
197
+ columnGap: true,
198
+ flex: true,
199
+ flexGrow: true,
200
+ flexShrink: true,
201
+ }
202
+
203
+ /**
204
+ * Check if a style property can be animated
205
+ */
206
+ const canAnimateProperty = (
207
+ key: string,
208
+ value: unknown,
209
+ animateOnly?: string[]
210
+ ): boolean => {
211
+ if (!ANIMATABLE_PROPERTIES[key]) return false
212
+ if (value === 'auto') return false
213
+ if (typeof value === 'string' && value.startsWith('calc')) return false
214
+ if (animateOnly && !animateOnly.includes(key)) return false
215
+ return true
216
+ }
217
+
218
+ // =============================================================================
219
+ // Animated Components (Web)
220
+ // =============================================================================
221
+
222
+ /**
223
+ * Create a Gui-compatible animated component for web
224
+ * Supports data- attributes, className, and proper style handling
225
+ */
226
+ function createWebAnimatedComponent(defaultTag: 'div' | 'span') {
227
+ const isText = defaultTag === 'span'
228
+
229
+ const Component = Animated.createAnimatedComponent(
230
+ forwardRef((propsIn: any, ref) => {
231
+ const { forwardedRef, render = defaultTag, ...rest } = propsIn
232
+ const hostRef = useRef<HTMLElement>(null)
233
+ const composedRefs = useComposedRefs(forwardedRef, ref, hostRef)
234
+
235
+ const stateRef = useRef<{ host: HTMLElement | null }>({
236
+ get host() {
237
+ return hostRef.current
238
+ },
239
+ })
240
+
241
+ const [, themeState] = useThemeWithState({})
242
+
243
+ const result = getSplitStyles(
244
+ rest,
245
+ isText ? Text.staticConfig : View.staticConfig,
246
+ themeState?.theme ?? {},
247
+ themeState?.name ?? '',
248
+ { unmounted: false } as any,
249
+ { isAnimated: false, noClass: true }
250
+ )
251
+
252
+ const viewProps = result?.viewProps ?? {}
253
+ const Element = render
254
+ const transformedProps = hooks.usePropsTransform?.(
255
+ render,
256
+ viewProps,
257
+ stateRef as any,
258
+ false
259
+ )
260
+
261
+ return <Element {...transformedProps} ref={composedRefs} />
262
+ })
263
+ )
264
+ ;(Component as any).acceptRenderProp = true
265
+ return Component
266
+ }
267
+
268
+ const AnimatedView = createWebAnimatedComponent('div')
269
+ const AnimatedText = createWebAnimatedComponent('span')
270
+
271
+ // =============================================================================
272
+ // Transition Config Builder
273
+ // =============================================================================
274
+
275
+ type TransitionConfigResult = {
276
+ baseConfig: TransitionConfig
277
+ propertyConfigs: Record<string, TransitionConfig>
278
+ }
279
+
280
+ /**
281
+ * Builds animation config from a transition prop.
282
+ * Shared logic used in both initial render and style emitter updates.
283
+ */
284
+ function buildTransitionConfig<A extends Record<string, TransitionConfig>>(
285
+ transition: any,
286
+ animations: A,
287
+ animationState: 'enter' | 'exit' | 'default',
288
+ styleKeys: Set<string>
289
+ ): TransitionConfigResult {
290
+ const normalized = normalizeTransition(transition)
291
+ const effectiveKey = getEffectiveAnimation(normalized, animationState)
292
+
293
+ let base = effectiveKey
294
+ ? (animations[effectiveKey as keyof typeof animations] ??
295
+ ({ type: 'spring' } as TransitionConfig))
296
+ : ({ type: 'spring' } as TransitionConfig)
297
+
298
+ if (normalized.delay) {
299
+ base = { ...base, delay: normalized.delay }
300
+ }
301
+
302
+ if (normalized.config) {
303
+ base = { ...base, ...normalized.config }
304
+ // infer type: 'timing' if duration is provided without spring params
305
+ if (
306
+ base.type !== 'timing' &&
307
+ normalized.config.duration !== undefined &&
308
+ normalized.config.damping === undefined &&
309
+ normalized.config.stiffness === undefined &&
310
+ normalized.config.mass === undefined
311
+ ) {
312
+ base = { ...base, type: 'timing' }
313
+ }
314
+ }
315
+
316
+ // build per-property configs
317
+ const propertyConfigs: Record<string, TransitionConfig> = {}
318
+
319
+ for (const key of styleKeys) {
320
+ const propAnimation = normalized.properties[key]
321
+ if (typeof propAnimation === 'string') {
322
+ propertyConfigs[key] = animations[propAnimation as keyof typeof animations] ?? base
323
+ } else if (propAnimation && typeof propAnimation === 'object') {
324
+ const configType = (propAnimation as any).type
325
+ const baseForProp = configType
326
+ ? (animations[configType as keyof typeof animations] ?? base)
327
+ : base
328
+ propertyConfigs[key] = {
329
+ ...baseForProp,
330
+ ...propAnimation,
331
+ } as TransitionConfig
332
+ } else {
333
+ propertyConfigs[key] = base
334
+ }
335
+ }
336
+
337
+ return { baseConfig: base, propertyConfigs }
338
+ }
339
+
340
+ /**
341
+ * Extracts all style keys including transform sub-properties.
342
+ */
343
+ function getStyleKeys(style: Record<string, unknown>): Set<string> {
344
+ const keys = new Set(Object.keys(style))
345
+ if (style.transform && Array.isArray(style.transform)) {
346
+ for (const t of style.transform as Record<string, unknown>[]) {
347
+ if (t && typeof t === 'object') {
348
+ keys.add(Object.keys(t)[0])
349
+ }
350
+ }
351
+ }
352
+ return keys
353
+ }
354
+
355
+ // =============================================================================
356
+ // Animation Driver Factory
357
+ // =============================================================================
358
+
359
+ /**
360
+ * Create a Reanimated-based animation driver for Gui.
361
+ *
362
+ * This is a native Reanimated implementation without Moti dependency.
363
+ * It provides smooth spring and timing animations with full support for:
364
+ * - Per-property animation configurations
365
+ * - Exit animations with proper completion callbacks
366
+ * - Dynamic theme value resolution
367
+ * - Transform property animations
368
+ * - avoidReRenders optimization for hover/press/focus states
369
+ *
370
+ * @example
371
+ * ```tsx
372
+ * const animations = createAnimations({
373
+ * fast: { type: 'spring', damping: 20, stiffness: 250 },
374
+ * slow: { type: 'timing', duration: 500 },
375
+ * })
376
+ *
377
+ * ```
378
+ */
379
+ export function createAnimations<A extends Record<string, TransitionConfig>>(
380
+ animationsConfig: A
381
+ ): AnimationDriver<A> {
382
+ // Normalize animation configs - default to spring if not specified
383
+ // This matches behavior of moti and motion drivers
384
+ const animations = {} as A
385
+ for (const key in animationsConfig) {
386
+ animations[key] = {
387
+ type: 'spring',
388
+ ...animationsConfig[key],
389
+ } as A[typeof key]
390
+ }
391
+
392
+ return {
393
+ needsCustomComponent: true,
394
+ View: isWeb ? AnimatedView : Animated.View,
395
+ Text: isWeb ? AnimatedText : Animated.Text,
396
+ isReactNative: true,
397
+ inputStyle: 'value',
398
+ outputStyle: 'inline',
399
+ avoidReRenders: true,
400
+ animations,
401
+ usePresence,
402
+ ResetPresence,
403
+
404
+ // =========================================================================
405
+ // useAnimatedNumber - For imperative animated values
406
+ // =========================================================================
407
+ useAnimatedNumber(initial): UniversalAnimatedNumber<ReanimatedAnimatedNumber> {
408
+ const sharedValue = useSharedValue(initial)
409
+
410
+ return useMemo(
411
+ () => ({
412
+ getInstance() {
413
+ 'worklet'
414
+ return sharedValue
415
+ },
416
+
417
+ getValue() {
418
+ 'worklet'
419
+ return sharedValue.value
420
+ },
421
+
422
+ setValue(next, config = { type: 'spring' }, onFinish) {
423
+ 'worklet'
424
+ const handleFinish = onFinish
425
+ ? () => {
426
+ 'worklet'
427
+ runOnJS(onFinish)()
428
+ }
429
+ : undefined
430
+
431
+ if (config.type === 'direct') {
432
+ sharedValue.value = next
433
+ onFinish?.()
434
+ } else if (config.type === 'spring') {
435
+ sharedValue.value = withSpring(
436
+ next,
437
+ config as WithSpringConfig,
438
+ handleFinish
439
+ )
440
+ } else {
441
+ sharedValue.value = withTiming(
442
+ next,
443
+ config as WithTimingConfig,
444
+ handleFinish
445
+ )
446
+ }
447
+ },
448
+
449
+ stop() {
450
+ 'worklet'
451
+ cancelAnimation(sharedValue)
452
+ },
453
+ }),
454
+ [sharedValue]
455
+ )
456
+ },
457
+
458
+ // =========================================================================
459
+ // useAnimatedNumberReaction - React to animated value changes
460
+ // =========================================================================
461
+ useAnimatedNumberReaction({ value }, onValue) {
462
+ const instance = value.getInstance()
463
+
464
+ return useAnimatedReaction(
465
+ () => instance.value,
466
+ (next, prev) => {
467
+ if (prev !== next) {
468
+ runOnJS(onValue)(next)
469
+ }
470
+ },
471
+ [onValue, instance]
472
+ )
473
+ },
474
+
475
+ // =========================================================================
476
+ // useAnimatedNumberStyle - Create animated styles from values
477
+ // =========================================================================
478
+ useAnimatedNumberStyle(val, getStyle) {
479
+ const instance = val.getInstance()
480
+
481
+ const derivedValue = useDerivedValue(() => instance.value, [instance, getStyle])
482
+
483
+ return useAnimatedStyle(
484
+ () => getStyle(derivedValue.value),
485
+ [val, getStyle, derivedValue, instance]
486
+ )
487
+ },
488
+
489
+ // =========================================================================
490
+ // useAnimations - Main animation hook for components
491
+ // =========================================================================
492
+ useAnimations(animationProps) {
493
+ const {
494
+ props,
495
+ presence,
496
+ style,
497
+ componentState,
498
+ useStyleEmitter,
499
+ themeName,
500
+ stateRef,
501
+ styleState,
502
+ } = animationProps
503
+
504
+ // State flags
505
+ const isHydrating = componentState.unmounted === true
506
+ const isMounting = componentState.unmounted === 'should-enter'
507
+ const isEntering = !!componentState.unmounted
508
+
509
+ // Presence state for exit animations
510
+ const isExiting = presence?.[0] === false
511
+
512
+ // Track if we just finished entering (transition from entering to not entering)
513
+ const wasEnteringRef = useRef(isEntering)
514
+ const justFinishedEntering = wasEnteringRef.current && !isEntering
515
+ React.useEffect(() => {
516
+ wasEnteringRef.current = isEntering
517
+ })
518
+
519
+ // Use effectiveTransition computed by createComponent (single source of truth)
520
+ const effectiveTransition = styleState?.effectiveTransition ?? props.transition
521
+ const normalized = normalizeTransition(effectiveTransition)
522
+ // Use 'enter' if we're mounting OR if we just finished entering
523
+ const animationState: 'enter' | 'exit' | 'default' = isExiting
524
+ ? 'exit'
525
+ : isMounting || justFinishedEntering
526
+ ? 'enter'
527
+ : 'default'
528
+ const animationKey = getEffectiveAnimation(normalized, animationState)
529
+
530
+ const disableAnimation = isHydrating || !animationKey
531
+
532
+ // Theme state for dynamic values - use themeName from props instead of hook
533
+ const isDark = themeName?.startsWith('dark') || false
534
+
535
+ // Get sendExitComplete callback from presence
536
+ const sendExitComplete = presence?.[1]
537
+
538
+ // =========================================================================
539
+ // Exit cycle state for deterministic per-property completion tracking
540
+ // =========================================================================
541
+ const exitCycleIdRef = useRef(0)
542
+ const pendingExitKeysRef = useRef<Set<string>>(new Set())
543
+ const exitCompletedRef = useRef(false)
544
+ const wasExitingRef = useRef(false)
545
+
546
+ // detect transition into/out of exiting state
547
+ const justStartedExiting = isExiting && !wasExitingRef.current
548
+ const justStoppedExiting = !isExiting && wasExitingRef.current
549
+
550
+ // stable callback to mark a property as done (called from worklet via runOnJS)
551
+ const markExitKeyDone = useEvent(
552
+ (key: string, cycleId: number, finished: boolean) => {
553
+ // ignore callbacks from stale cycles
554
+ if (cycleId !== exitCycleIdRef.current) return
555
+ // ignore if already completed
556
+ if (exitCompletedRef.current) return
557
+ // count both finished and canceled animations as "done" during exit
558
+ // (element is leaving anyway, canceled animations shouldn't block completion)
559
+
560
+ pendingExitKeysRef.current.delete(key)
561
+
562
+ // check if all exit animations are done
563
+ if (pendingExitKeysRef.current.size === 0) {
564
+ exitCompletedRef.current = true
565
+ sendExitComplete?.()
566
+ }
567
+ }
568
+ )
569
+
570
+ // SharedValue to pass exit state into worklet
571
+ const isExitingRef = useSharedValue(isExiting)
572
+ const exitCycleIdShared = useSharedValue(exitCycleIdRef.current)
573
+
574
+ // start new exit cycle only on transition INTO exiting (not every render while exiting)
575
+ if (justStartedExiting) {
576
+ exitCycleIdRef.current++
577
+ exitCompletedRef.current = false
578
+ pendingExitKeysRef.current.clear()
579
+ }
580
+ // invalidate pending callbacks when exit is canceled/interrupted
581
+ if (justStoppedExiting) {
582
+ exitCycleIdRef.current++
583
+ pendingExitKeysRef.current.clear()
584
+ }
585
+
586
+ // update shared values (using effect to avoid writing during render)
587
+ useIsomorphicLayoutEffect(() => {
588
+ isExitingRef.value = isExiting
589
+ exitCycleIdShared.value = exitCycleIdRef.current
590
+ }, [isExiting, exitCycleIdRef.current])
591
+
592
+ // track previous exiting state
593
+ React.useEffect(() => {
594
+ wasExitingRef.current = isExiting
595
+ })
596
+
597
+ // =========================================================================
598
+ // avoidRerenders: SharedValues for style updates without re-renders
599
+ // =========================================================================
600
+ const animatedTargetsRef = useSharedValue<Record<string, unknown> | null>(null)
601
+ const staticTargetsRef = useSharedValue<Record<string, unknown> | null>(null)
602
+ const transformTargetsRef = useSharedValue<Array<Record<string, unknown>> | null>(
603
+ null
604
+ )
605
+
606
+ // Separate styles into animated and static
607
+ const { animatedStyles, staticStyles } = useMemo(() => {
608
+ const animated: Record<string, unknown> = {}
609
+ const staticStyles: Record<string, unknown> = {}
610
+ const animateOnly = props.animateOnly as string[] | undefined
611
+
612
+ for (const key in style) {
613
+ const rawValue = (style as Record<string, unknown>)[key]
614
+ const value = resolveDynamicValue(rawValue, isDark)
615
+
616
+ if (value === undefined) continue
617
+
618
+ if (disableAnimation) {
619
+ staticStyles[key] = value
620
+ continue
621
+ }
622
+
623
+ if (canAnimateProperty(key, value, animateOnly)) {
624
+ animated[key] = value
625
+ } else {
626
+ staticStyles[key] = value
627
+ }
628
+ }
629
+
630
+ // During mount, include animated values in static to prevent flicker
631
+ if (isMounting) {
632
+ for (const key in animated) {
633
+ staticStyles[key] = animated[key]
634
+ }
635
+ }
636
+
637
+ return { animatedStyles: animated, staticStyles }
638
+ }, [disableAnimation, style, isDark, isMounting, props.animateOnly])
639
+
640
+ // Build animation config with per-property overrides using normalized transition
641
+ const { baseConfig, propertyConfigs } = useMemo(() => {
642
+ if (isHydrating) {
643
+ return {
644
+ baseConfig: { type: 'timing' as const, duration: 0 },
645
+ propertyConfigs: {} as Record<string, TransitionConfig>,
646
+ }
647
+ }
648
+
649
+ return buildTransitionConfig(
650
+ props.transition,
651
+ animations,
652
+ animationState,
653
+ getStyleKeys(animatedStyles)
654
+ )
655
+ }, [isHydrating, props.transition, animatedStyles, animationState])
656
+
657
+ // Store config in SharedValue for worklet access (concurrent-safe)
658
+ // Using useEffect to avoid writing to shared value during render
659
+ const configRef = useSharedValue({
660
+ baseConfig,
661
+ propertyConfigs,
662
+ disableAnimation,
663
+ isHydrating,
664
+ })
665
+
666
+ useIsomorphicLayoutEffect(() => {
667
+ configRef.set({ baseConfig, propertyConfigs, disableAnimation, isHydrating })
668
+ }, [baseConfig, propertyConfigs, disableAnimation, isHydrating])
669
+
670
+ // =========================================================================
671
+ // avoidRerenders: Register style emitter callback
672
+ // When hover/press/etc state changes, this is called instead of re-rendering
673
+ // =========================================================================
674
+ useStyleEmitter?.((nextStyle: Record<string, unknown>, effectiveTransition) => {
675
+ const animateOnly = props.animateOnly as string[] | undefined
676
+ const animated: Record<string, unknown> = {}
677
+ const statics: Record<string, unknown> = {}
678
+ const transforms: Array<Record<string, unknown>> = []
679
+
680
+ // effectiveTransition is computed in createComponent based on entering/exiting pseudo states
681
+ // rebuild config whenever transition changes (entering OR exiting pseudo states)
682
+ const transitionToUse = effectiveTransition ?? props.transition
683
+ const { baseConfig: newBase, propertyConfigs: newPropertyConfigs } =
684
+ buildTransitionConfig(
685
+ transitionToUse,
686
+ animations,
687
+ animationState,
688
+ getStyleKeys(nextStyle)
689
+ )
690
+
691
+ // update configRef with the new config
692
+ configRef.set({
693
+ baseConfig: newBase,
694
+ propertyConfigs: newPropertyConfigs,
695
+ disableAnimation: configRef.get().disableAnimation,
696
+ isHydrating: configRef.get().isHydrating,
697
+ })
698
+
699
+ for (const key in nextStyle) {
700
+ const rawValue = nextStyle[key]
701
+ const value = resolveDynamicValue(rawValue, isDark)
702
+
703
+ if (value == undefined) continue
704
+
705
+ if (configRef.get().disableAnimation) {
706
+ statics[key] = value
707
+ continue
708
+ }
709
+
710
+ if (key === 'transform' && Array.isArray(value)) {
711
+ for (const t of value as Record<string, unknown>[]) {
712
+ if (t && typeof t === 'object') {
713
+ const tKey = Object.keys(t)[0]
714
+ const tVal = t[tKey]
715
+ if (typeof tVal === 'number' || typeof tVal === 'string') {
716
+ transforms.push(t)
717
+ }
718
+ }
719
+ }
720
+ continue
721
+ }
722
+
723
+ if (canAnimateProperty(key, value, animateOnly)) {
724
+ animated[key] = value
725
+ } else {
726
+ statics[key] = value
727
+ }
728
+ }
729
+
730
+ // Update SharedValues - on web, the mapper watches these if passed in dependencies
731
+ animatedTargetsRef.set(animated)
732
+ staticTargetsRef.set(statics)
733
+ transformTargetsRef.set(transforms)
734
+
735
+ if (
736
+ process.env.NODE_ENV === 'development' &&
737
+ props.debug &&
738
+ props.debug !== 'profile'
739
+ ) {
740
+ console.info('[animations-reanimated] useStyleEmitter update', {
741
+ animated,
742
+ statics,
743
+ transforms,
744
+ })
745
+ }
746
+ })
747
+
748
+ // Compute and register exit keys synchronously during render to avoid race conditions
749
+ // This must happen BEFORE useAnimatedStyle runs so callbacks have a populated set
750
+ const exitKeysRegistered = useRef(false)
751
+ if (justStartedExiting && sendExitComplete) {
752
+ const exitKeys: string[] = []
753
+ const animateOnly = props.animateOnly as string[] | undefined
754
+
755
+ // regular animated properties
756
+ for (const key in animatedStyles) {
757
+ if (key === 'transform') continue
758
+ if (canAnimateProperty(key, animatedStyles[key], animateOnly)) {
759
+ exitKeys.push(key)
760
+ }
761
+ }
762
+
763
+ // transform sub-keys (filter by animateOnly if specified)
764
+ const transforms = animatedStyles.transform
765
+ if (transforms && Array.isArray(transforms)) {
766
+ for (const t of transforms) {
767
+ if (!t) continue
768
+ const tKey = Object.keys(t)[0]
769
+ if (tKey) {
770
+ // check animateOnly filter for transform sub-keys
771
+ if (animateOnly && !animateOnly.includes(tKey)) {
772
+ continue
773
+ }
774
+ exitKeys.push(`transform:${tKey}`)
775
+ }
776
+ }
777
+ }
778
+
779
+ // register keys for this cycle
780
+ pendingExitKeysRef.current = new Set(exitKeys)
781
+ exitKeysRegistered.current = exitKeys.length > 0
782
+ }
783
+
784
+ // handle zero-animation case in effect (after render commit)
785
+ React.useEffect(() => {
786
+ if (!justStartedExiting || !sendExitComplete) return
787
+
788
+ // if no keys were registered, complete immediately
789
+ if (!exitKeysRegistered.current && pendingExitKeysRef.current.size === 0) {
790
+ if (!exitCompletedRef.current) {
791
+ exitCompletedRef.current = true
792
+ sendExitComplete()
793
+ }
794
+ }
795
+ }, [justStartedExiting, sendExitComplete])
796
+
797
+ // Create animated style
798
+ const animatedStyle = useAnimatedStyle(() => {
799
+ 'worklet'
800
+
801
+ if (disableAnimation || isHydrating) {
802
+ return {}
803
+ }
804
+
805
+ const result: Record<string, any> = {}
806
+ const config = configRef.get()
807
+
808
+ // Check if we have avoidRerenders updates from useStyleEmitter
809
+ const emitterAnimated = animatedTargetsRef.value
810
+ const emitterStatic = staticTargetsRef.value
811
+ const emitterTransforms = transformTargetsRef.value
812
+ const hasEmitterUpdates = emitterAnimated !== null
813
+
814
+ // Use emitter values if available, otherwise use React state values
815
+ const animatedValues = hasEmitterUpdates ? emitterAnimated! : animatedStyles
816
+ const staticValues = hasEmitterUpdates ? emitterStatic! : {}
817
+
818
+ // read exit state from shared values
819
+ const currentlyExiting = isExitingRef.value
820
+ const currentCycleId = exitCycleIdShared.value
821
+
822
+ // Include static values from emitter (for hover/press style changes)
823
+ for (const key in staticValues) {
824
+ result[key] = staticValues[key]
825
+ }
826
+
827
+ // Animate regular properties
828
+ for (const key in animatedValues) {
829
+ if (key === 'transform') continue
830
+
831
+ const targetValue = animatedValues[key]
832
+ const propConfig = config.propertyConfigs[key] ?? config.baseConfig
833
+
834
+ // create callback for exit tracking
835
+ let callback: AnimationCallback | undefined
836
+ if (currentlyExiting) {
837
+ const capturedKey = key
838
+ const capturedCycleId = currentCycleId
839
+ callback = (finished) => {
840
+ 'worklet'
841
+ runOnJS(markExitKeyDone)(capturedKey, capturedCycleId, finished ?? false)
842
+ }
843
+ }
844
+
845
+ result[key] = applyAnimation(targetValue as number, propConfig, callback)
846
+ }
847
+
848
+ // Handle transforms
849
+ const transforms = hasEmitterUpdates
850
+ ? emitterTransforms
851
+ : animatedStyles.transform
852
+
853
+ // Animate transform properties with validation
854
+ if (transforms && Array.isArray(transforms)) {
855
+ const validTransforms: Record<string, unknown>[] = []
856
+
857
+ for (const t of transforms) {
858
+ if (!t) continue
859
+ const keys = Object.keys(t)
860
+ if (keys.length === 0) continue
861
+ const value = t[keys[0]]
862
+ if (typeof value === 'number' || typeof value === 'string') {
863
+ const transformKey = Object.keys(t)[0]
864
+ const targetValue = t[transformKey]
865
+ const propConfig = config.propertyConfigs[transformKey] ?? config.baseConfig
866
+
867
+ // create callback for exit tracking (transform sub-key)
868
+ let callback: AnimationCallback | undefined
869
+ if (currentlyExiting) {
870
+ const capturedKey = `transform:${transformKey}`
871
+ const capturedCycleId = currentCycleId
872
+ callback = (finished) => {
873
+ 'worklet'
874
+ runOnJS(markExitKeyDone)(
875
+ capturedKey,
876
+ capturedCycleId,
877
+ finished ?? false
878
+ )
879
+ }
880
+ }
881
+
882
+ validTransforms.push({
883
+ [transformKey]: applyAnimation(
884
+ targetValue as number,
885
+ propConfig,
886
+ callback
887
+ ),
888
+ })
889
+ }
890
+ }
891
+
892
+ if (validTransforms.length > 0) {
893
+ result.transform = validTransforms
894
+ }
895
+ }
896
+
897
+ return result
898
+ }, [
899
+ animatedStyles,
900
+ baseConfig,
901
+ propertyConfigs,
902
+ disableAnimation,
903
+ isHydrating,
904
+ // Pass SharedValues so the mapper watches them on web (see useAnimatedStyle.ts line 470-472)
905
+ animatedTargetsRef,
906
+ staticTargetsRef,
907
+ transformTargetsRef,
908
+ isExitingRef,
909
+ exitCycleIdShared,
910
+ markExitKeyDone,
911
+ ])
912
+
913
+ // Debug logging
914
+ if (
915
+ process.env.NODE_ENV === 'development' &&
916
+ props.debug &&
917
+ props.debug !== 'profile'
918
+ ) {
919
+ console.info('[animations-reanimated] useAnimations', {
920
+ animationKey,
921
+ componentState,
922
+ isExiting,
923
+ animatedStyles,
924
+ staticStyles,
925
+ baseConfig,
926
+ propertyConfigs,
927
+ })
928
+ }
929
+
930
+ return {
931
+ style: [staticStyles, animatedStyle],
932
+ }
933
+ },
934
+ }
935
+ }