react-native-puff-pop 1.0.2 → 1.0.4

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.
package/README.md CHANGED
@@ -164,10 +164,13 @@ function App() {
164
164
  | `skeleton` | `boolean` | `true` | Reserve space before animation |
165
165
  | `visible` | `boolean` | `true` | Control visibility |
166
166
  | `animateOnMount` | `boolean` | `true` | Animate when component mounts |
167
+ | `onAnimationStart` | `() => void` | - | Callback when animation starts |
167
168
  | `onAnimationComplete` | `() => void` | - | Callback when animation completes |
168
169
  | `style` | `ViewStyle` | - | Custom container style |
169
170
  | `loop` | `boolean \| number` | `false` | Loop animation (true=infinite, number=times) |
170
171
  | `loopDelay` | `number` | `0` | Delay between loop iterations in ms |
172
+ | `respectReduceMotion` | `boolean` | `true` | Respect system reduce motion setting |
173
+ | `testID` | `string` | - | Test ID for testing purposes |
171
174
 
172
175
  ### Animation Effects (`PuffPopEffect`)
173
176
 
@@ -204,6 +207,22 @@ The component reserves its full space immediately, and only the visual appearanc
204
207
  ### `skeleton={false}`
205
208
  The component's height starts at 0 and expands during animation, pushing other content below it. This creates a more dynamic entrance effect.
206
209
 
210
+ ## Accessibility
211
+
212
+ PuffPop respects the system's "Reduce Motion" accessibility setting by default. When users have enabled reduce motion in their device settings, animations will be instant (0 duration) to avoid discomfort.
213
+
214
+ ```tsx
215
+ // Respect reduce motion setting (default)
216
+ <PuffPop respectReduceMotion={true}>
217
+ <YourComponent />
218
+ </PuffPop>
219
+
220
+ // Ignore reduce motion setting (always animate)
221
+ <PuffPop respectReduceMotion={false}>
222
+ <YourComponent />
223
+ </PuffPop>
224
+ ```
225
+
207
226
  ## License
208
227
 
209
228
  MIT
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { useEffect, useRef, useState, useCallback, } from 'react';
3
- import { View, Animated, StyleSheet, Easing, } from 'react-native';
2
+ import { useEffect, useRef, useState, useCallback, useMemo, } from 'react';
3
+ import { View, Animated, StyleSheet, Easing, AccessibilityInfo, } from 'react-native';
4
4
  /**
5
5
  * Get easing function based on type
6
6
  */
@@ -25,7 +25,7 @@ function getEasing(type) {
25
25
  /**
26
26
  * PuffPop - Animate children with beautiful entrance effects
27
27
  */
28
- export function PuffPop({ children, effect = 'scale', duration = 400, delay = 0, easing = 'easeOut', skeleton = true, visible = true, onAnimationComplete, style, animateOnMount = true, loop = false, loopDelay = 0, }) {
28
+ export function PuffPop({ children, effect = 'scale', duration = 400, delay = 0, easing = 'easeOut', skeleton = true, visible = true, onAnimationComplete, onAnimationStart, style, animateOnMount = true, loop = false, loopDelay = 0, respectReduceMotion = true, testID, }) {
29
29
  // Animation values
30
30
  const opacity = useRef(new Animated.Value(animateOnMount ? 0 : 1)).current;
31
31
  const scale = useRef(new Animated.Value(animateOnMount ? getInitialScale(effect) : 1)).current;
@@ -37,6 +37,42 @@ export function PuffPop({ children, effect = 'scale', duration = 400, delay = 0,
37
37
  const animatedHeight = useRef(new Animated.Value(0)).current;
38
38
  const hasAnimated = useRef(false);
39
39
  const loopAnimationRef = useRef(null);
40
+ const loopTimeoutRef = useRef(null);
41
+ // Reduce motion accessibility support
42
+ const [isReduceMotionEnabled, setIsReduceMotionEnabled] = useState(false);
43
+ useEffect(() => {
44
+ if (!respectReduceMotion)
45
+ return;
46
+ const checkReduceMotion = async () => {
47
+ const reduceMotion = await AccessibilityInfo.isReduceMotionEnabled();
48
+ setIsReduceMotionEnabled(reduceMotion);
49
+ };
50
+ checkReduceMotion();
51
+ const subscription = AccessibilityInfo.addEventListener('reduceMotionChanged', setIsReduceMotionEnabled);
52
+ return () => {
53
+ subscription.remove();
54
+ };
55
+ }, [respectReduceMotion]);
56
+ // Effective duration (0 if reduce motion is enabled)
57
+ const effectiveDuration = respectReduceMotion && isReduceMotionEnabled ? 0 : duration;
58
+ // Memoize effect type checks to avoid repeated includes() calls
59
+ const effectFlags = useMemo(() => ({
60
+ hasScale: ['scale', 'bounce', 'zoom', 'rotateScale', 'flip'].includes(effect),
61
+ hasRotate: ['rotate', 'rotateScale'].includes(effect),
62
+ hasFlip: effect === 'flip',
63
+ hasTranslateX: ['slideLeft', 'slideRight'].includes(effect),
64
+ hasTranslateY: ['slideUp', 'slideDown', 'bounce'].includes(effect),
65
+ hasRotateEffect: ['rotate', 'rotateScale', 'flip'].includes(effect),
66
+ }), [effect]);
67
+ // Memoize interpolations to avoid recreating on every render
68
+ const rotateInterpolation = useMemo(() => rotate.interpolate({
69
+ inputRange: [-360, 0, 360],
70
+ outputRange: ['-360deg', '0deg', '360deg'],
71
+ }), [rotate]);
72
+ const flipInterpolation = useMemo(() => rotate.interpolate({
73
+ inputRange: [-180, 0],
74
+ outputRange: ['-180deg', '0deg'],
75
+ }), [rotate]);
40
76
  // Handle layout measurement for non-skeleton mode
41
77
  const onLayout = useCallback((event) => {
42
78
  if (!skeleton && measuredHeight === null) {
@@ -46,12 +82,16 @@ export function PuffPop({ children, effect = 'scale', duration = 400, delay = 0,
46
82
  }, [skeleton, measuredHeight]);
47
83
  // Animate function
48
84
  const animate = useCallback((toVisible) => {
85
+ // Call onAnimationStart callback
86
+ if (toVisible && onAnimationStart) {
87
+ onAnimationStart();
88
+ }
49
89
  const easingFn = getEasing(easing);
50
90
  // When skeleton is false, we animate height which doesn't support native driver
51
91
  // So we must use JS driver for all animations in that case
52
92
  const useNative = skeleton;
53
93
  const config = {
54
- duration,
94
+ duration: effectiveDuration,
55
95
  easing: easingFn,
56
96
  useNativeDriver: useNative,
57
97
  };
@@ -62,7 +102,7 @@ export function PuffPop({ children, effect = 'scale', duration = 400, delay = 0,
62
102
  ...config,
63
103
  }));
64
104
  // Scale animation
65
- if (['scale', 'bounce', 'zoom', 'rotateScale', 'flip'].includes(effect)) {
105
+ if (effectFlags.hasScale) {
66
106
  const targetScale = toVisible ? 1 : getInitialScale(effect);
67
107
  animations.push(Animated.timing(scale, {
68
108
  toValue: targetScale,
@@ -71,7 +111,7 @@ export function PuffPop({ children, effect = 'scale', duration = 400, delay = 0,
71
111
  }));
72
112
  }
73
113
  // Rotate animation
74
- if (['rotate', 'rotateScale', 'flip'].includes(effect)) {
114
+ if (effectFlags.hasRotate || effectFlags.hasFlip) {
75
115
  const targetRotate = toVisible ? 0 : getInitialRotate(effect);
76
116
  animations.push(Animated.timing(rotate, {
77
117
  toValue: targetRotate,
@@ -79,7 +119,7 @@ export function PuffPop({ children, effect = 'scale', duration = 400, delay = 0,
79
119
  }));
80
120
  }
81
121
  // TranslateX animation
82
- if (['slideLeft', 'slideRight'].includes(effect)) {
122
+ if (effectFlags.hasTranslateX) {
83
123
  const targetX = toVisible ? 0 : getInitialTranslateX(effect);
84
124
  animations.push(Animated.timing(translateX, {
85
125
  toValue: targetX,
@@ -87,7 +127,7 @@ export function PuffPop({ children, effect = 'scale', duration = 400, delay = 0,
87
127
  }));
88
128
  }
89
129
  // TranslateY animation
90
- if (['slideUp', 'slideDown', 'bounce'].includes(effect)) {
130
+ if (effectFlags.hasTranslateY) {
91
131
  const targetY = toVisible ? 0 : getInitialTranslateY(effect);
92
132
  animations.push(Animated.timing(translateY, {
93
133
  toValue: targetY,
@@ -99,7 +139,7 @@ export function PuffPop({ children, effect = 'scale', duration = 400, delay = 0,
99
139
  const targetHeight = toVisible ? measuredHeight : 0;
100
140
  animations.push(Animated.timing(animatedHeight, {
101
141
  toValue: targetHeight,
102
- duration,
142
+ duration: effectiveDuration,
103
143
  easing: easingFn,
104
144
  useNativeDriver: false,
105
145
  }));
@@ -144,7 +184,11 @@ export function PuffPop({ children, effect = 'scale', duration = 400, delay = 0,
144
184
  if (loopCount === -1 || currentIteration < loopCount) {
145
185
  // Add delay between loops if specified
146
186
  if (loopDelay > 0) {
147
- setTimeout(runLoop, loopDelay);
187
+ // Clear any existing timeout before setting a new one
188
+ if (loopTimeoutRef.current) {
189
+ clearTimeout(loopTimeoutRef.current);
190
+ }
191
+ loopTimeoutRef.current = setTimeout(runLoop, loopDelay);
148
192
  }
149
193
  else {
150
194
  runLoop();
@@ -173,11 +217,13 @@ export function PuffPop({ children, effect = 'scale', duration = 400, delay = 0,
173
217
  }
174
218
  }, [
175
219
  delay,
176
- duration,
220
+ effectiveDuration,
177
221
  easing,
178
222
  effect,
223
+ effectFlags,
179
224
  measuredHeight,
180
225
  onAnimationComplete,
226
+ onAnimationStart,
181
227
  opacity,
182
228
  rotate,
183
229
  scale,
@@ -201,44 +247,30 @@ export function PuffPop({ children, effect = 'scale', duration = 400, delay = 0,
201
247
  animate(visible);
202
248
  }
203
249
  }, [visible, animate]);
204
- // Cleanup loop animation on unmount
250
+ // Cleanup loop animation and timeout on unmount
205
251
  useEffect(() => {
206
252
  return () => {
207
253
  if (loopAnimationRef.current) {
208
254
  loopAnimationRef.current.stop();
209
255
  }
256
+ if (loopTimeoutRef.current) {
257
+ clearTimeout(loopTimeoutRef.current);
258
+ }
210
259
  };
211
260
  }, []);
212
- // For non-skeleton mode, measure first
213
- if (!skeleton && measuredHeight === null) {
214
- return (_jsx(View, { style: styles.measureContainer, onLayout: onLayout, children: _jsx(View, { style: styles.hidden, children: children }) }));
215
- }
216
- // Build transform based on effect
217
- const getTransform = () => {
218
- const hasScale = ['scale', 'bounce', 'zoom', 'rotateScale', 'flip'].includes(effect);
219
- const hasRotate = ['rotate', 'rotateScale'].includes(effect);
220
- const hasFlip = effect === 'flip';
221
- const hasTranslateX = ['slideLeft', 'slideRight'].includes(effect);
222
- const hasTranslateY = ['slideUp', 'slideDown', 'bounce'].includes(effect);
261
+ // Memoize transform array to avoid recreating on every render
262
+ // IMPORTANT: All hooks must be called before any conditional returns
263
+ const transform = useMemo(() => {
264
+ const { hasScale, hasRotate, hasFlip, hasTranslateX, hasTranslateY } = effectFlags;
223
265
  const transforms = [];
224
266
  if (hasScale) {
225
267
  transforms.push({ scale });
226
268
  }
227
269
  if (hasRotate) {
228
- transforms.push({
229
- rotate: rotate.interpolate({
230
- inputRange: [-360, 0, 360],
231
- outputRange: ['-360deg', '0deg', '360deg'],
232
- }),
233
- });
270
+ transforms.push({ rotate: rotateInterpolation });
234
271
  }
235
272
  if (hasFlip) {
236
- transforms.push({
237
- rotateY: rotate.interpolate({
238
- inputRange: [-180, 0],
239
- outputRange: ['-180deg', '0deg'],
240
- }),
241
- });
273
+ transforms.push({ rotateY: flipInterpolation });
242
274
  }
243
275
  if (hasTranslateX) {
244
276
  transforms.push({ translateX });
@@ -247,16 +279,27 @@ export function PuffPop({ children, effect = 'scale', duration = 400, delay = 0,
247
279
  transforms.push({ translateY });
248
280
  }
249
281
  return transforms.length > 0 ? transforms : undefined;
250
- };
251
- const animatedStyle = {
282
+ }, [effectFlags, scale, rotateInterpolation, flipInterpolation, translateX, translateY]);
283
+ // Memoize animated style
284
+ const animatedStyle = useMemo(() => ({
252
285
  opacity,
253
- transform: getTransform(),
254
- };
255
- // Container style for non-skeleton mode
256
- const containerAnimatedStyle = !skeleton && measuredHeight !== null
257
- ? { height: animatedHeight, overflow: 'hidden' }
258
- : {};
259
- return (_jsx(Animated.View, { style: [styles.container, style, containerAnimatedStyle], children: _jsx(Animated.View, { style: animatedStyle, children: children }) }));
286
+ transform,
287
+ }), [opacity, transform]);
288
+ // Memoize container style for non-skeleton mode
289
+ const containerAnimatedStyle = useMemo(() => {
290
+ if (!skeleton && measuredHeight !== null) {
291
+ return {
292
+ height: animatedHeight,
293
+ overflow: effectFlags.hasRotateEffect ? 'visible' : 'hidden'
294
+ };
295
+ }
296
+ return {};
297
+ }, [skeleton, measuredHeight, animatedHeight, effectFlags.hasRotateEffect]);
298
+ // For non-skeleton mode, measure first (after all hooks)
299
+ if (!skeleton && measuredHeight === null) {
300
+ return (_jsx(View, { style: styles.measureContainer, onLayout: onLayout, children: _jsx(View, { style: styles.hidden, children: children }) }));
301
+ }
302
+ return (_jsx(Animated.View, { style: [styles.container, style, containerAnimatedStyle], testID: testID, children: _jsx(Animated.View, { style: animatedStyle, children: children }) }));
260
303
  }
261
304
  /**
262
305
  * Get initial scale value based on effect
@@ -69,9 +69,23 @@ export interface PuffPopProps {
69
69
  * @default 0
70
70
  */
71
71
  loopDelay?: number;
72
+ /**
73
+ * Callback when animation starts
74
+ */
75
+ onAnimationStart?: () => void;
76
+ /**
77
+ * Respect system reduce motion accessibility setting
78
+ * When true and reduce motion is enabled, animations will be instant
79
+ * @default true
80
+ */
81
+ respectReduceMotion?: boolean;
82
+ /**
83
+ * Test ID for testing purposes
84
+ */
85
+ testID?: string;
72
86
  }
73
87
  /**
74
88
  * PuffPop - Animate children with beautiful entrance effects
75
89
  */
76
- export declare function PuffPop({ children, effect, duration, delay, easing, skeleton, visible, onAnimationComplete, style, animateOnMount, loop, loopDelay, }: PuffPopProps): ReactElement;
90
+ export declare function PuffPop({ children, effect, duration, delay, easing, skeleton, visible, onAnimationComplete, onAnimationStart, style, animateOnMount, loop, loopDelay, respectReduceMotion, testID, }: PuffPopProps): ReactElement;
77
91
  export default PuffPop;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-puff-pop",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "A React Native animation library for revealing children components with beautiful puff and pop effects",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",
package/src/index.tsx CHANGED
@@ -3,6 +3,7 @@ import {
3
3
  useRef,
4
4
  useState,
5
5
  useCallback,
6
+ useMemo,
6
7
  type ReactNode,
7
8
  type ReactElement,
8
9
  } from 'react';
@@ -11,6 +12,7 @@ import {
11
12
  Animated,
12
13
  StyleSheet,
13
14
  Easing,
15
+ AccessibilityInfo,
14
16
  type LayoutChangeEvent,
15
17
  type StyleProp,
16
18
  type ViewStyle,
@@ -115,6 +117,23 @@ export interface PuffPopProps {
115
117
  * @default 0
116
118
  */
117
119
  loopDelay?: number;
120
+
121
+ /**
122
+ * Callback when animation starts
123
+ */
124
+ onAnimationStart?: () => void;
125
+
126
+ /**
127
+ * Respect system reduce motion accessibility setting
128
+ * When true and reduce motion is enabled, animations will be instant
129
+ * @default true
130
+ */
131
+ respectReduceMotion?: boolean;
132
+
133
+ /**
134
+ * Test ID for testing purposes
135
+ */
136
+ testID?: string;
118
137
  }
119
138
 
120
139
  /**
@@ -151,10 +170,13 @@ export function PuffPop({
151
170
  skeleton = true,
152
171
  visible = true,
153
172
  onAnimationComplete,
173
+ onAnimationStart,
154
174
  style,
155
175
  animateOnMount = true,
156
176
  loop = false,
157
177
  loopDelay = 0,
178
+ respectReduceMotion = true,
179
+ testID,
158
180
  }: PuffPopProps): ReactElement {
159
181
  // Animation values
160
182
  const opacity = useRef(new Animated.Value(animateOnMount ? 0 : 1)).current;
@@ -168,6 +190,56 @@ export function PuffPop({
168
190
  const animatedHeight = useRef(new Animated.Value(0)).current;
169
191
  const hasAnimated = useRef(false);
170
192
  const loopAnimationRef = useRef<Animated.CompositeAnimation | null>(null);
193
+ const loopTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
194
+
195
+ // Reduce motion accessibility support
196
+ const [isReduceMotionEnabled, setIsReduceMotionEnabled] = useState(false);
197
+
198
+ useEffect(() => {
199
+ if (!respectReduceMotion) return;
200
+
201
+ const checkReduceMotion = async () => {
202
+ const reduceMotion = await AccessibilityInfo.isReduceMotionEnabled();
203
+ setIsReduceMotionEnabled(reduceMotion);
204
+ };
205
+
206
+ checkReduceMotion();
207
+
208
+ const subscription = AccessibilityInfo.addEventListener(
209
+ 'reduceMotionChanged',
210
+ setIsReduceMotionEnabled
211
+ );
212
+
213
+ return () => {
214
+ subscription.remove();
215
+ };
216
+ }, [respectReduceMotion]);
217
+
218
+ // Effective duration (0 if reduce motion is enabled)
219
+ const effectiveDuration = respectReduceMotion && isReduceMotionEnabled ? 0 : duration;
220
+
221
+ // Memoize effect type checks to avoid repeated includes() calls
222
+ const effectFlags = useMemo(() => ({
223
+ hasScale: ['scale', 'bounce', 'zoom', 'rotateScale', 'flip'].includes(effect),
224
+ hasRotate: ['rotate', 'rotateScale'].includes(effect),
225
+ hasFlip: effect === 'flip',
226
+ hasTranslateX: ['slideLeft', 'slideRight'].includes(effect),
227
+ hasTranslateY: ['slideUp', 'slideDown', 'bounce'].includes(effect),
228
+ hasRotateEffect: ['rotate', 'rotateScale', 'flip'].includes(effect),
229
+ }), [effect]);
230
+
231
+ // Memoize interpolations to avoid recreating on every render
232
+ const rotateInterpolation = useMemo(() =>
233
+ rotate.interpolate({
234
+ inputRange: [-360, 0, 360],
235
+ outputRange: ['-360deg', '0deg', '360deg'],
236
+ }), [rotate]);
237
+
238
+ const flipInterpolation = useMemo(() =>
239
+ rotate.interpolate({
240
+ inputRange: [-180, 0],
241
+ outputRange: ['-180deg', '0deg'],
242
+ }), [rotate]);
171
243
 
172
244
  // Handle layout measurement for non-skeleton mode
173
245
  const onLayout = useCallback(
@@ -183,12 +255,17 @@ export function PuffPop({
183
255
  // Animate function
184
256
  const animate = useCallback(
185
257
  (toVisible: boolean) => {
258
+ // Call onAnimationStart callback
259
+ if (toVisible && onAnimationStart) {
260
+ onAnimationStart();
261
+ }
262
+
186
263
  const easingFn = getEasing(easing);
187
264
  // When skeleton is false, we animate height which doesn't support native driver
188
265
  // So we must use JS driver for all animations in that case
189
266
  const useNative = skeleton;
190
267
  const config = {
191
- duration,
268
+ duration: effectiveDuration,
192
269
  easing: easingFn,
193
270
  useNativeDriver: useNative,
194
271
  };
@@ -204,7 +281,7 @@ export function PuffPop({
204
281
  );
205
282
 
206
283
  // Scale animation
207
- if (['scale', 'bounce', 'zoom', 'rotateScale', 'flip'].includes(effect)) {
284
+ if (effectFlags.hasScale) {
208
285
  const targetScale = toVisible ? 1 : getInitialScale(effect);
209
286
  animations.push(
210
287
  Animated.timing(scale, {
@@ -216,7 +293,7 @@ export function PuffPop({
216
293
  }
217
294
 
218
295
  // Rotate animation
219
- if (['rotate', 'rotateScale', 'flip'].includes(effect)) {
296
+ if (effectFlags.hasRotate || effectFlags.hasFlip) {
220
297
  const targetRotate = toVisible ? 0 : getInitialRotate(effect);
221
298
  animations.push(
222
299
  Animated.timing(rotate, {
@@ -227,7 +304,7 @@ export function PuffPop({
227
304
  }
228
305
 
229
306
  // TranslateX animation
230
- if (['slideLeft', 'slideRight'].includes(effect)) {
307
+ if (effectFlags.hasTranslateX) {
231
308
  const targetX = toVisible ? 0 : getInitialTranslateX(effect);
232
309
  animations.push(
233
310
  Animated.timing(translateX, {
@@ -238,7 +315,7 @@ export function PuffPop({
238
315
  }
239
316
 
240
317
  // TranslateY animation
241
- if (['slideUp', 'slideDown', 'bounce'].includes(effect)) {
318
+ if (effectFlags.hasTranslateY) {
242
319
  const targetY = toVisible ? 0 : getInitialTranslateY(effect);
243
320
  animations.push(
244
321
  Animated.timing(translateY, {
@@ -254,7 +331,7 @@ export function PuffPop({
254
331
  animations.push(
255
332
  Animated.timing(animatedHeight, {
256
333
  toValue: targetHeight,
257
- duration,
334
+ duration: effectiveDuration,
258
335
  easing: easingFn,
259
336
  useNativeDriver: false,
260
337
  })
@@ -306,7 +383,11 @@ export function PuffPop({
306
383
  if (loopCount === -1 || currentIteration < loopCount) {
307
384
  // Add delay between loops if specified
308
385
  if (loopDelay > 0) {
309
- setTimeout(runLoop, loopDelay);
386
+ // Clear any existing timeout before setting a new one
387
+ if (loopTimeoutRef.current) {
388
+ clearTimeout(loopTimeoutRef.current);
389
+ }
390
+ loopTimeoutRef.current = setTimeout(runLoop, loopDelay);
310
391
  } else {
311
392
  runLoop();
312
393
  }
@@ -335,11 +416,13 @@ export function PuffPop({
335
416
  },
336
417
  [
337
418
  delay,
338
- duration,
419
+ effectiveDuration,
339
420
  easing,
340
421
  effect,
422
+ effectFlags,
341
423
  measuredHeight,
342
424
  onAnimationComplete,
425
+ onAnimationStart,
343
426
  opacity,
344
427
  rotate,
345
428
  scale,
@@ -367,32 +450,22 @@ export function PuffPop({
367
450
  }
368
451
  }, [visible, animate]);
369
452
 
370
- // Cleanup loop animation on unmount
453
+ // Cleanup loop animation and timeout on unmount
371
454
  useEffect(() => {
372
455
  return () => {
373
456
  if (loopAnimationRef.current) {
374
457
  loopAnimationRef.current.stop();
375
458
  }
459
+ if (loopTimeoutRef.current) {
460
+ clearTimeout(loopTimeoutRef.current);
461
+ }
376
462
  };
377
463
  }, []);
378
464
 
379
- // For non-skeleton mode, measure first
380
- if (!skeleton && measuredHeight === null) {
381
- return (
382
- <View style={styles.measureContainer} onLayout={onLayout}>
383
- <View style={styles.hidden}>{children}</View>
384
- </View>
385
- );
386
- }
387
-
388
- // Build transform based on effect
389
- const getTransform = () => {
390
- const hasScale = ['scale', 'bounce', 'zoom', 'rotateScale', 'flip'].includes(effect);
391
- const hasRotate = ['rotate', 'rotateScale'].includes(effect);
392
- const hasFlip = effect === 'flip';
393
- const hasTranslateX = ['slideLeft', 'slideRight'].includes(effect);
394
- const hasTranslateY = ['slideUp', 'slideDown', 'bounce'].includes(effect);
395
-
465
+ // Memoize transform array to avoid recreating on every render
466
+ // IMPORTANT: All hooks must be called before any conditional returns
467
+ const transform = useMemo(() => {
468
+ const { hasScale, hasRotate, hasFlip, hasTranslateX, hasTranslateY } = effectFlags;
396
469
  const transforms = [];
397
470
 
398
471
  if (hasScale) {
@@ -400,21 +473,11 @@ export function PuffPop({
400
473
  }
401
474
 
402
475
  if (hasRotate) {
403
- transforms.push({
404
- rotate: rotate.interpolate({
405
- inputRange: [-360, 0, 360],
406
- outputRange: ['-360deg', '0deg', '360deg'],
407
- }),
408
- });
476
+ transforms.push({ rotate: rotateInterpolation });
409
477
  }
410
478
 
411
479
  if (hasFlip) {
412
- transforms.push({
413
- rotateY: rotate.interpolate({
414
- inputRange: [-180, 0],
415
- outputRange: ['-180deg', '0deg'],
416
- }),
417
- });
480
+ transforms.push({ rotateY: flipInterpolation });
418
481
  }
419
482
 
420
483
  if (hasTranslateX) {
@@ -426,20 +489,39 @@ export function PuffPop({
426
489
  }
427
490
 
428
491
  return transforms.length > 0 ? transforms : undefined;
429
- };
492
+ }, [effectFlags, scale, rotateInterpolation, flipInterpolation, translateX, translateY]);
430
493
 
431
- const animatedStyle = {
494
+ // Memoize animated style
495
+ const animatedStyle = useMemo(() => ({
432
496
  opacity,
433
- transform: getTransform(),
434
- };
497
+ transform,
498
+ }), [opacity, transform]);
499
+
500
+ // Memoize container style for non-skeleton mode
501
+ const containerAnimatedStyle = useMemo(() => {
502
+ if (!skeleton && measuredHeight !== null) {
503
+ return {
504
+ height: animatedHeight,
505
+ overflow: effectFlags.hasRotateEffect ? 'visible' as const : 'hidden' as const
506
+ };
507
+ }
508
+ return {};
509
+ }, [skeleton, measuredHeight, animatedHeight, effectFlags.hasRotateEffect]);
435
510
 
436
- // Container style for non-skeleton mode
437
- const containerAnimatedStyle = !skeleton && measuredHeight !== null
438
- ? { height: animatedHeight, overflow: 'hidden' as const }
439
- : {};
511
+ // For non-skeleton mode, measure first (after all hooks)
512
+ if (!skeleton && measuredHeight === null) {
513
+ return (
514
+ <View style={styles.measureContainer} onLayout={onLayout}>
515
+ <View style={styles.hidden}>{children}</View>
516
+ </View>
517
+ );
518
+ }
440
519
 
441
520
  return (
442
- <Animated.View style={[styles.container, style, containerAnimatedStyle]}>
521
+ <Animated.View
522
+ style={[styles.container, style, containerAnimatedStyle]}
523
+ testID={testID}
524
+ >
443
525
  <Animated.View style={animatedStyle}>{children}</Animated.View>
444
526
  </Animated.View>
445
527
  );