Skip to content

Looping animation survives on a recycled/reused EaseView and never stops #54

Description

@NicholasBoccuzzi

Before submitting a new issue

  • I tested using the latest version of the library, as the bug might be already fixed.
  • I tested using a supported version of react native.
  • I checked for possible duplicate issues, with possible answers.

Bug summary

We use EaseView for a shimmer skeleton (translateX: -64 → +64, duration: 1500, easing: 'linear', loop: 'repeat'). A user reported a fully-loaded content card on one of our list screens sliding horizontally on its own, indefinitely, with no touch input. It is triggered by fling-scrolling the list while placements are still loading.

Frame-by-frame tracking of the card across a 349-frame screen recording gives a perfect linear sawtooth with no decay: 85.33 pt/s, sweep 128.0 pt, period 1.50011 s — i.e. exactly our skeleton's shimmer animation, still running on the settled content view. The content component that ends up animating contains no 64pt shimmer of its own, so the animation crossed component boundaries onto a view that had been recycled for different content.

Library version

0.8.0

Environment info

React Native 0.86.0, with React 19.2.3 and Expo SDK 57 (~57.0.1, 57.0.2 installed).

Also worth noting alongside it: Reanimated 4.5.1, and the New Architecture is implied by that stack (RN 0.86 is Fabric-only).

Steps to reproduce

Steps to reproduce

A. Deterministic — a mounted view keeps a loop its props no longer ask for

Derived from the source (updateProps only calls removeEaseAnimationForKey: when the new transition type == "none"), so a still-timing transition that simply stopped looping is never torn down.

import { useState } from 'react';
import { Button, StyleSheet, View } from 'react-native';
import { EaseView } from 'react-native-ease';
 
export default function Repro() {
  const [shimmering, setShimmering] = useState(true);
 
  return (
    <View style={{ padding: 40, gap: 20 }}>
      <View style={{ height: 60, width: 200, overflow: 'hidden', backgroundColor: '#eee' }}>
        <EaseView
          initialAnimate={shimmering ? { translateX: -64 } : undefined}
          animate={shimmering ? { translateX: 64 } : { opacity: 1 }}
          transition={{ type: 'timing', duration: 1500, easing: 'linear',
                        loop: shimmering ? 'repeat' : undefined }}
          style={StyleSheet.absoluteFill}
        >
          <View style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.25)' }} />
        </EaseView>
      </View>
 
      <Button title="stop shimmering" onPress={() => setShimmering(false)} />
    </View>
  );
}
  1. Run on iOS (New Architecture). The band sweeps left→right on a 1.5 s loop.
  2. Press stop shimmeringtranslateX is gone from animate and loop is gone from the transition.
  3. Expected: the sweep stops and translateX returns to identity.
    Actual: the band keeps sweeping forever; the saved loop is still in _loopAnimations and still on the layer.

The same shape reproduces by flipping only loop: 'repeat' → undefined while keeping translateX animated.

B. The real-world path — a loop survives onto recycled content (intermittent)

This is how it reached a user: a recycling list whose rows render a shimmer while loading and then swap to content.

  1. iOS, New Architecture, a recycling list (FlashList, or FlatList with rows that mount/unmount as they scroll).
  2. Each row renders an EaseView shimmer (translateX: -w → +w, duration: 1500, easing: 'linear', loop: 'repeat') while its data loads, and real content once loaded — stagger the load 300–1500 ms per row.
  3. Fling-scroll hard and repeatedly, in both directions, while rows are still resolving. Navigate to another tab and back a few times (this exercises didMoveToWindowreapplyLoopAnimations).
  4. Expected: every shimmer dies with its skeleton.
    Actual: occasionally a fully-loaded content row keeps translating horizontally on a 1.5 s linear loop and never stops, even after further scrolling or tab switches.

Intermittent — it may take several passes. In our case it reached production and a user recorded it; we have not yet reduced B to a deterministic script, which is why A is the useful test case for the fix.

Root cause (from reading the native sources)

EaseView keeps running — and can resurrect — a looping animation that its current props no longer ask for.

Two gaps:

  1. Props update (updateProps, iOS ios/EaseView.mm; Android EaseView.kt). Saved/running loops are only torn down when the incoming transition is 'none'. If a view is reused for new content whose props simply don't include that property in animatedProperties at all (or no longer declare loop: 'repeat' | 'reverse'), the existing CAAnimation / infinite ValueAnimator is left in place and keeps driving the layer.

  2. Re-attach (didMoveToWindowreapplyLoopAnimations, iOS). iOS drops CAAnimations when a layer leaves the window, so _loopAnimations is replayed on re-attach. That snapshot is replayed unconditionally, so a stale loop is re-added onto a view that is now hosting different content — even if the current props want no loop.

Under Fabric view recycling (or any unmount path that bypasses prepareForRecycle), that means a shimmer can outlive its skeleton and animate real content. We have not pinned down the exact lifecycle path that lets it survive — the evidence above is forensic (frame analysis + source reading), not an instrumented native trace.

Suggested fix

Treat the current props as the source of truth for loops: on every props update, and again before replaying _loopAnimations on re-attach, drop every saved/running loop whose property is no longer in animatedProperties or whose transition no longer says repeat/reverse.

One caveat we hit: cancelling mid-shimmer leaves the property wherever it happened to be, turning a sliding card into a permanently offset one. So teardown must also reset the property — but only when the property is no longer in animatedProperties at all. If it is still animated and merely stopped looping, the rest of the props update writes it, and resetting first causes a visible jump. Identity values can come from the package's own cleanup() (Android) / prepareForRecycle (iOS).

This is a pure safety net: it only ever removes animations the current props don't want, and is a no-op for a view whose props still declare the loop.

We are running this as a local pnpm patch against 0.8.0; the diff is below and we're happy to open it as a PR if the approach looks right to you.

Patch against react-native-ease@0.8.0
diff --git a/android/src/main/java/com/ease/EaseView.kt b/android/src/main/java/com/ease/EaseView.kt
index 190c88f45e521679a639f727c23edcae2acda71a..959356bc4af0ba21a7d83cc275953e47929cbf7e 100644
--- a/android/src/main/java/com/ease/EaseView.kt
+++ b/android/src/main/java/com/ease/EaseView.kt
@@ -124,6 +124,60 @@ class EaseView(context: Context) : ReactViewGroup(context) {
         }
     }
 
+    private fun removeStaleLoopAnimations() {
+        val mask = animatedProperties
+        val properties = listOf(
+            "alpha" to (MASK_OPACITY to "opacity"),
+            "translationX" to (MASK_TRANSLATE_X to "translateX"),
+            "translationY" to (MASK_TRANSLATE_Y to "translateY"),
+            "scaleX" to (MASK_SCALE_X to "scaleX"),
+            "scaleY" to (MASK_SCALE_Y to "scaleY"),
+            "rotation" to (MASK_ROTATE to "rotate"),
+            "rotationX" to (MASK_ROTATE_X to "rotateX"),
+            "rotationY" to (MASK_ROTATE_Y to "rotateY"),
+            "animateBorderRadius" to (MASK_BORDER_RADIUS to "borderRadius"),
+            "backgroundColor" to (MASK_BACKGROUND_COLOR to "backgroundColor"),
+            "animateBorderWidth" to (MASK_BORDER_WIDTH to "borderWidth"),
+            "borderColor" to (MASK_BORDER_COLOR to "borderColor"),
+            "elevation" to (MASK_ELEVATION to "elevation"),
+        )
+
+        for ((animationName, property) in properties) {
+            val animator = runningAnimators[animationName] as? ValueAnimator ?: continue
+            if (animator.repeatCount != ValueAnimator.INFINITE) continue
+
+            val (propertyMask, propertyName) = property
+            val config = getTransitionConfig(propertyName)
+            val keepsLoop = mask and propertyMask != 0 &&
+                (config.loop == "repeat" || config.loop == "reverse")
+            if (!keepsLoop) {
+                animator.cancel()
+                runningAnimators.remove(animationName)
+                if (mask and propertyMask == 0) {
+                    resetProperty(animationName)
+                }
+            }
+        }
+    }
+
+    private fun resetProperty(propertyName: String) {
+        when (propertyName) {
+            "alpha" -> alpha = 1f
+            "translationX" -> translationX = 0f
+            "translationY" -> translationY = 0f
+            "scaleX" -> scaleX = 1f
+            "scaleY" -> scaleY = 1f
+            "rotation" -> rotation = 0f
+            "rotationX" -> rotationX = 0f
+            "rotationY" -> rotationY = 0f
+            "animateBorderRadius" -> setAnimateBorderRadius(0f)
+            "backgroundColor" -> applyBackgroundColor(Color.TRANSPARENT)
+            "animateBorderWidth" -> setAnimateBorderWidth(0f)
+            "borderColor" -> applyBorderColor(Color.BLACK)
+            "elevation" -> elevation = 0f
+        }
+    }
+
     companion object {
         // Bitmask flags — must match JS constants
         const val MASK_OPACITY = 1 shl 0
@@ -329,6 +383,7 @@ class EaseView(context: Context) : ReactViewGroup(context) {
         borderColor: Int,
         elevation: Float
     ) {
+        removeStaleLoopAnimations()
         if (pendingBatchAnimationCount > 0) {
             onTransitionEnd?.invoke(false)
         }
diff --git a/ios/EaseView.mm b/ios/EaseView.mm
index 2acab0b7cad2461daf23343917d38ab8950a04ba..f8d16e2f73a6e1e0a1b3afe194cfef86d9e0859a 100644
--- a/ios/EaseView.mm
+++ b/ios/EaseView.mm
@@ -155,6 +155,10 @@ transitionConfigForProperty(const std::string &name,
   return transitionConfigFromStruct(t.defaultConfig);
 }
 
+static BOOL isLoopingTransition(const EaseTransitionConfig &config) {
+  return config.loop == "repeat" || config.loop == "reverse";
+}
+
 // Find lowest property name with a set mask bit among transform properties
 static std::string lowestTransformPropertyName(int mask) {
   if (mask & kMaskTranslateX)
@@ -374,6 +378,155 @@ static std::string lowestTransformPropertyName(int mask) {
   [_loopAnimations removeObjectForKey:key];
 }
 
+- (BOOL)shouldKeepLoopAnimationForKey:(NSString *)key
+                                props:(const EaseViewProps &)props {
+  int mask = props.animatedProperties;
+  std::string propertyName;
+  int propertyMask = 0;
+
+  if ([key isEqualToString:kAnimKeyOpacity]) {
+    propertyName = "opacity";
+    propertyMask = kMaskOpacity;
+  } else if ([key isEqualToString:kAnimKeyTransformTransX]) {
+    propertyName = "translateX";
+    propertyMask = kMaskTranslateX;
+  } else if ([key isEqualToString:kAnimKeyTransformTransY]) {
+    propertyName = "translateY";
+    propertyMask = kMaskTranslateY;
+  } else if ([key isEqualToString:kAnimKeyTransformScaleX]) {
+    propertyName = "scaleX";
+    propertyMask = kMaskScaleX;
+  } else if ([key isEqualToString:kAnimKeyTransformScaleY]) {
+    propertyName = "scaleY";
+    propertyMask = kMaskScaleY;
+  } else if ([key isEqualToString:kAnimKeyTransformRotateZ]) {
+    propertyName = "rotate";
+    propertyMask = kMaskRotate;
+  } else if ([key isEqualToString:kAnimKeyTransformRotateX]) {
+    propertyName = "rotateX";
+    propertyMask = kMaskRotateX;
+  } else if ([key isEqualToString:kAnimKeyTransformRotateY]) {
+    propertyName = "rotateY";
+    propertyMask = kMaskRotateY;
+  } else if ([key isEqualToString:kAnimKeyCornerRadius]) {
+    propertyName = "borderRadius";
+    propertyMask = kMaskBorderRadius;
+  } else if ([key isEqualToString:kAnimKeyBackgroundColor]) {
+    propertyName = "backgroundColor";
+    propertyMask = kMaskBackgroundColor;
+  } else if ([key isEqualToString:kAnimKeyBorderWidth]) {
+    propertyName = "borderWidth";
+    propertyMask = kMaskBorderWidth;
+  } else if ([key isEqualToString:kAnimKeyBorderColor]) {
+    propertyName = "borderColor";
+    propertyMask = kMaskBorderColor;
+  } else if ([key isEqualToString:kAnimKeyShadowOpacity]) {
+    propertyName = "shadowOpacity";
+    propertyMask = kMaskShadowOpacity;
+  } else if ([key isEqualToString:kAnimKeyShadowRadius]) {
+    propertyName = "shadowRadius";
+    propertyMask = kMaskShadowRadius;
+  } else if ([key isEqualToString:kAnimKeyShadowColor]) {
+    propertyName = "shadowColor";
+    propertyMask = kMaskShadowColor;
+  } else if ([key isEqualToString:kAnimKeyShadowOffset]) {
+    propertyName = "shadowOffset";
+    propertyMask = kMaskShadowOffset;
+  } else {
+    return NO;
+  }
+
+  if ((mask & propertyMask) == 0) {
+    return NO;
+  }
+  return isLoopingTransition(transitionConfigForProperty(propertyName, props));
+}
+
+- (int)animationMaskForKey:(NSString *)key {
+  if ([key isEqualToString:kAnimKeyOpacity]) {
+    return kMaskOpacity;
+  } else if ([key isEqualToString:kAnimKeyTransformTransX]) {
+    return kMaskTranslateX;
+  } else if ([key isEqualToString:kAnimKeyTransformTransY]) {
+    return kMaskTranslateY;
+  } else if ([key isEqualToString:kAnimKeyTransformScaleX]) {
+    return kMaskScaleX;
+  } else if ([key isEqualToString:kAnimKeyTransformScaleY]) {
+    return kMaskScaleY;
+  } else if ([key isEqualToString:kAnimKeyTransformRotateZ]) {
+    return kMaskRotate;
+  } else if ([key isEqualToString:kAnimKeyTransformRotateX]) {
+    return kMaskRotateX;
+  } else if ([key isEqualToString:kAnimKeyTransformRotateY]) {
+    return kMaskRotateY;
+  } else if ([key isEqualToString:kAnimKeyCornerRadius]) {
+    return kMaskBorderRadius;
+  } else if ([key isEqualToString:kAnimKeyBackgroundColor]) {
+    return kMaskBackgroundColor;
+  } else if ([key isEqualToString:kAnimKeyBorderWidth]) {
+    return kMaskBorderWidth;
+  } else if ([key isEqualToString:kAnimKeyBorderColor]) {
+    return kMaskBorderColor;
+  } else if ([key isEqualToString:kAnimKeyShadowOpacity]) {
+    return kMaskShadowOpacity;
+  } else if ([key isEqualToString:kAnimKeyShadowRadius]) {
+    return kMaskShadowRadius;
+  } else if ([key isEqualToString:kAnimKeyShadowColor]) {
+    return kMaskShadowColor;
+  } else if ([key isEqualToString:kAnimKeyShadowOffset]) {
+    return kMaskShadowOffset;
+  }
+  return 0;
+}
+
+- (void)resetEasePropertyForKey:(NSString *)key {
+  if ([key isEqualToString:kAnimKeyOpacity]) {
+    self.layer.opacity = 1.0;
+  } else if ([key isEqualToString:kAnimKeyTransformTransX]) {
+    [self.layer setValue:@0.0 forKeyPath:@"transform.translation.x"];
+  } else if ([key isEqualToString:kAnimKeyTransformTransY]) {
+    [self.layer setValue:@0.0 forKeyPath:@"transform.translation.y"];
+  } else if ([key isEqualToString:kAnimKeyTransformScaleX]) {
+    [self.layer setValue:@1.0 forKeyPath:@"transform.scale.x"];
+  } else if ([key isEqualToString:kAnimKeyTransformScaleY]) {
+    [self.layer setValue:@1.0 forKeyPath:@"transform.scale.y"];
+  } else if ([key isEqualToString:kAnimKeyTransformRotateZ]) {
+    [self.layer setValue:@0.0 forKeyPath:@"transform.rotation"];
+  } else if ([key isEqualToString:kAnimKeyTransformRotateX]) {
+    [self.layer setValue:@0.0 forKeyPath:@"transform.rotation.x"];
+  } else if ([key isEqualToString:kAnimKeyTransformRotateY]) {
+    [self.layer setValue:@0.0 forKeyPath:@"transform.rotation.y"];
+  } else if ([key isEqualToString:kAnimKeyCornerRadius]) {
+    self.layer.cornerRadius = 0;
+  } else if ([key isEqualToString:kAnimKeyBackgroundColor]) {
+    self.layer.backgroundColor = nil;
+  } else if ([key isEqualToString:kAnimKeyBorderWidth]) {
+    self.layer.borderWidth = 0;
+  } else if ([key isEqualToString:kAnimKeyBorderColor]) {
+    self.layer.borderColor = nil;
+  } else if ([key isEqualToString:kAnimKeyShadowOpacity]) {
+    self.layer.shadowOpacity = 0;
+  } else if ([key isEqualToString:kAnimKeyShadowRadius]) {
+    self.layer.shadowRadius = 0;
+  } else if ([key isEqualToString:kAnimKeyShadowColor]) {
+    self.layer.shadowColor = nil;
+  } else if ([key isEqualToString:kAnimKeyShadowOffset]) {
+    self.layer.shadowOffset = CGSizeZero;
+  }
+}
+
+- (void)removeStaleLoopAnimationsForProps:(const EaseViewProps &)props {
+  NSArray<NSString *> *savedKeys = [_loopAnimations.allKeys copy];
+  for (NSString *key in savedKeys) {
+    if (![self shouldKeepLoopAnimationForKey:key props:props]) {
+      [self removeEaseAnimationForKey:key];
+      if ((props.animatedProperties & [self animationMaskForKey:key]) == 0) {
+        [self resetEasePropertyForKey:key];
+      }
+    }
+  }
+}
+
 - (void)reapplyLoopAnimations {
   if (_loopAnimations.count == 0) {
     return;
@@ -813,6 +966,7 @@ static std::string lowestTransformPropertyName(int mask) {
 
   [CATransaction begin];
   [CATransaction setDisableActions:YES];
+  [self removeStaleLoopAnimationsForProps:newViewProps];
 
   if (_transformOriginX != newViewProps.transformOriginX ||
       _transformOriginY != newViewProps.transformOriginY) {
@@ -1242,6 +1396,11 @@ static std::string lowestTransformPropertyName(int mask) {
   // When the view re-attaches (e.g. after a react-navigation tab switch),
   // re-apply any loop animations that were running.
   if (self.window != nil && !_isFirstMount) {
+    if (_props) {
+      const auto &viewProps =
+          *std::static_pointer_cast<const EaseViewProps>(_props);
+      [self removeStaleLoopAnimationsForProps:viewProps];
+    }
     [self reapplyLoopAnimations];
   }
 }

Reproducible example repository

https://github.com/NicholasBoccuzzi/react-native-ease-stale-loop-repro

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions