Before submitting a new issue
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>
);
}
- Run on iOS (New Architecture). The band sweeps left→right on a 1.5 s loop.
- Press stop shimmering —
translateX is gone from animate and loop is gone from the transition.
- 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.
- iOS, New Architecture, a recycling list (FlashList, or FlatList with rows that mount/unmount as they scroll).
- 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.
- Fling-scroll hard and repeatedly, in both directions, while rows are still resolving. Navigate to another tab and back a few times (this exercises
didMoveToWindow → reapplyLoopAnimations).
- 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:
-
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.
-
Re-attach (didMoveToWindow → reapplyLoopAnimations, 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
Before submitting a new issue
Bug summary
We use
EaseViewfor 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
Steps to reproduce
Steps to reproduce
A. Deterministic — a mounted view keeps a loop its props no longer ask for
Derived from the source (
updatePropsonly callsremoveEaseAnimationForKey:when the new transitiontype == "none"), so a still-timingtransition that simply stopped looping is never torn down.translateXis gone fromanimateandloopis gone from the transition.translateXreturns to identity.Actual: the band keeps sweeping forever; the saved loop is still in
_loopAnimationsand still on the layer.The same shape reproduces by flipping only
loop: 'repeat' → undefinedwhile keepingtranslateXanimated.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.
EaseViewshimmer (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.didMoveToWindow→reapplyLoopAnimations).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)
EaseViewkeeps running — and can resurrect — a looping animation that its current props no longer ask for.Two gaps:
Props update (
updateProps, iOSios/EaseView.mm; AndroidEaseView.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 inanimatedPropertiesat all (or no longer declareloop: 'repeat' | 'reverse'), the existingCAAnimation/ infiniteValueAnimatoris left in place and keeps driving the layer.Re-attach (
didMoveToWindow→reapplyLoopAnimations, iOS). iOS dropsCAAnimations when a layer leaves the window, so_loopAnimationsis 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
_loopAnimationson re-attach, drop every saved/running loop whose property is no longer inanimatedPropertiesor whose transition no longer saysrepeat/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
animatedPropertiesat 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 owncleanup()(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 patchagainst 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
Reproducible example repository
https://github.com/NicholasBoccuzzi/react-native-ease-stale-loop-repro