diff --git a/android/src/main/java/com/ease/EaseView.kt b/android/src/main/java/com/ease/EaseView.kt
index 190c88f..6bc6835 100644
--- a/android/src/main/java/com/ease/EaseView.kt
+++ b/android/src/main/java/com/ease/EaseView.kt
@@ -124,6 +124,76 @@ class EaseView(context: Context) : ReactViewGroup(context) {
}
}
+ // Drop every running loop the current props no longer ask for.
+ //
+ // The per-property paths in applyAnimateValues() only cancel an animator
+ // when the property is still in animatedProperties AND its value changed.
+ // Props that drop a property from `animate` entirely, or that keep
+ // animating it but stop asking for `loop`, hit neither gate — the infinite
+ // ValueAnimator keeps driving the view forever, even after the view is
+ // reused for different content. Treat the current props as the source of
+ // truth instead.
+ private fun removeStaleLoopAnimations(
+ opacity: Float,
+ translateX: Float,
+ translateY: Float,
+ scaleX: Float,
+ scaleY: Float,
+ rotate: Float,
+ rotateX: Float,
+ rotateY: Float,
+ borderRadius: Float,
+ borderWidth: Float,
+ elevation: Float
+ ) {
+ if (runningAnimators.isEmpty()) return
+
+ val mask = animatedProperties
+ for ((animatorKey, property) in LOOPABLE_PROPERTIES) {
+ val animator = runningAnimators[animatorKey] as? ValueAnimator ?: continue
+ if (animator.repeatCount != ValueAnimator.INFINITE) continue
+
+ val (propertyMask, configName) = property
+ val stillAnimated = mask and propertyMask != 0
+ if (stillAnimated && getTransitionConfig(configName).loop.let { it == "repeat" || it == "reverse" }) {
+ continue
+ }
+
+ animator.cancel()
+ runningAnimators.remove(animatorKey)
+
+ // cancel() strands the property wherever the sweep happened to be,
+ // so write the resting state the current props ask for. Every
+ // animate* prop carries its identity value once JS clears the mask
+ // bit, so the incoming values are correct either way. Colors are
+ // skipped — an unset color has no identity value and the style now
+ // owns it.
+ val target: Float
+ val prev: Float?
+ val apply: (Float) -> Unit
+ when (animatorKey) {
+ "alpha" -> { target = opacity; prev = prevOpacity; apply = { this.alpha = it } }
+ "translationX" -> { target = translateX; prev = prevTranslateX; apply = { this.translationX = it } }
+ "translationY" -> { target = translateY; prev = prevTranslateY; apply = { this.translationY = it } }
+ "scaleX" -> { target = scaleX; prev = prevScaleX; apply = { this.scaleX = it } }
+ "scaleY" -> { target = scaleY; prev = prevScaleY; apply = { this.scaleY = it } }
+ "rotation" -> { target = rotate; prev = prevRotate; apply = { this.rotation = it } }
+ "rotationX" -> { target = rotateX; prev = prevRotateX; apply = { this.rotationX = it } }
+ "rotationY" -> { target = rotateY; prev = prevRotateY; apply = { this.rotationY = it } }
+ "animateBorderRadius" -> { target = borderRadius; prev = prevBorderRadius; apply = { setAnimateBorderRadius(it) } }
+ "animateBorderWidth" -> { target = borderWidth; prev = prevBorderWidth; apply = { setAnimateBorderWidth(it) } }
+ "elevation" -> { target = elevation; prev = prevElevation; apply = { this.elevation = it } }
+ else -> continue
+ }
+
+ // Skip only when a path below will animate this property anyway:
+ // those read the live view value as their "from", so writing the
+ // target first would flatten the animation into a no-op.
+ if (stillAnimated && prev != null && prev != target) continue
+ apply(target)
+ }
+ }
+
companion object {
// Bitmask flags — must match JS constants
const val MASK_OPACITY = 1 shl 0
@@ -140,6 +210,23 @@ class EaseView(context: Context) : ReactViewGroup(context) {
const val MASK_BORDER_COLOR = 1 shl 11
// Masks 12-15 are shadow properties (iOS only)
const val MASK_ELEVATION = 1 shl 16
+
+ // runningAnimators key → (mask bit, getTransitionConfig name)
+ private val LOOPABLE_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"),
+ )
}
// --- Transform origin (0–1 fractions) ---
@@ -473,6 +560,12 @@ class EaseView(context: Context) : ReactViewGroup(context) {
onTransitionEnd?.invoke(true)
} else {
// Subsequent updates: animate changed properties (skip non-animated)
+ // Runs after the animationBatchId bump above so the cancellations
+ // can't be mistaken for this batch's animations completing.
+ removeStaleLoopAnimations(
+ opacity, translateX, translateY, scaleX, scaleY,
+ rotate, rotateX, rotateY, borderRadius, borderWidth, elevation
+ )
var anyPropertyChanged = false
if (prevOpacity != null && mask and MASK_OPACITY != 0 && prevOpacity != opacity) {
diff --git a/example/app/issues/54/_layout.tsx b/example/app/issues/54/_layout.tsx
new file mode 100644
index 0000000..f492cd2
--- /dev/null
+++ b/example/app/issues/54/_layout.tsx
@@ -0,0 +1,23 @@
+import { Tabs, Stack } from 'expo-router';
+
+export default function StaleLoopLayout() {
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+}
diff --git a/example/app/issues/54/index.tsx b/example/app/issues/54/index.tsx
new file mode 100644
index 0000000..a651ba7
--- /dev/null
+++ b/example/app/issues/54/index.tsx
@@ -0,0 +1,195 @@
+import { useState } from 'react';
+import { Pressable, StyleSheet, Text, View } from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import { EaseView } from 'react-native-ease';
+
+// Issue #54 — a looping animation survives on an EaseView whose current props
+// no longer ask for it.
+// https://github.com/appandflow/react-native-ease/issues/54
+//
+// Both cards below keep the SAME EaseView instance across the load — no key,
+// no remount — which is what a list row does when it swaps a skeleton for its
+// loaded content. The teardown paths in updateProps are gated on "property is
+// still in animatedProperties AND its value changed", so neither card hits
+// them and the shimmer keeps sweeping over the content.
+//
+// Steps to reproduce:
+// 1. Both cards shimmer — a band sweeps left→right on a 1.5 s linear loop.
+// 2. Press "Load content". Both cards swap to their loaded state.
+// 3. Expected: both bands stop.
+// Without the fix: both keep sweeping forever.
+// 4. Switch to the Other tab and back. Without the fix the loops are replayed
+// by didMoveToWindow → reapplyLoopAnimations even after being cancelled.
+// 5. "Back to skeleton" remounts both views to start the shimmer over.
+
+const SWEEP = 64;
+
+export default function StaleLoopTab() {
+ const insets = useSafeAreaInsets();
+ const [loaded, setLoaded] = useState(false);
+ // Loops are only created on first mount, so resetting has to remount the
+ // views. Loading must NOT — the bug needs the same EaseView instance.
+ const [mountKey, setMountKey] = useState(0);
+
+ return (
+
+ Issue #54 — stale shimmer loop
+
+ Press Load content. Both bands must stop sweeping. Then switch to the
+ Other tab and back — they must still be stopped.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ setLoaded(true)}
+ >
+ Load content
+
+ {
+ setLoaded(false);
+ setMountKey((k) => k + 1);
+ }}
+ >
+ Back to skeleton
+
+
+
+ );
+}
+
+function Card({
+ title,
+ detail,
+ children,
+}: {
+ title: string;
+ detail: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+ {title}
+ {detail}
+ {children}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ root: {
+ flex: 1,
+ backgroundColor: '#1a1a2e',
+ paddingHorizontal: 20,
+ },
+ heading: {
+ fontSize: 22,
+ fontWeight: '700',
+ color: '#fff',
+ marginBottom: 8,
+ },
+ body: {
+ fontSize: 14,
+ color: '#aaaacc',
+ marginBottom: 28,
+ lineHeight: 20,
+ },
+ card: {
+ marginBottom: 28,
+ },
+ cardTitle: {
+ fontSize: 15,
+ fontWeight: '600',
+ color: '#e0e0ff',
+ marginBottom: 4,
+ },
+ cardDetail: {
+ fontSize: 11,
+ fontFamily: 'monospace',
+ color: '#6666aa',
+ marginBottom: 10,
+ },
+ track: {
+ height: 64,
+ borderRadius: 12,
+ backgroundColor: '#16213e',
+ overflow: 'hidden',
+ },
+ band: {
+ width: 56,
+ height: '100%',
+ alignSelf: 'center',
+ backgroundColor: '#4a90d9',
+ },
+ buttons: {
+ flexDirection: 'row',
+ gap: 12,
+ justifyContent: 'center',
+ },
+ button: {
+ paddingHorizontal: 20,
+ paddingVertical: 12,
+ borderRadius: 10,
+ backgroundColor: '#16213e',
+ },
+ buttonDisabled: {
+ opacity: 0.4,
+ },
+ buttonText: {
+ color: '#e0e0ff',
+ fontSize: 15,
+ fontWeight: '600',
+ },
+});
diff --git a/example/app/issues/54/other.tsx b/example/app/issues/54/other.tsx
new file mode 100644
index 0000000..32aa642
--- /dev/null
+++ b/example/app/issues/54/other.tsx
@@ -0,0 +1,37 @@
+import { StyleSheet, Text, View } from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+
+// Switching to this tab detaches the shimmer views from the window, which is
+// what makes didMoveToWindow → reapplyLoopAnimations run when you come back.
+export default function OtherTab() {
+ const insets = useSafeAreaInsets();
+
+ return (
+
+ Other tab
+
+ Go back to the Shimmer tab. Any card that was loaded must still be
+ still.
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ root: {
+ flex: 1,
+ backgroundColor: '#1a1a2e',
+ paddingHorizontal: 20,
+ },
+ heading: {
+ fontSize: 22,
+ fontWeight: '700',
+ color: '#fff',
+ marginBottom: 8,
+ },
+ body: {
+ fontSize: 14,
+ color: '#aaaacc',
+ lineHeight: 20,
+ },
+});
diff --git a/example/src/demos/index.ts b/example/src/demos/index.ts
index 6ec1cf2..d46a16a 100644
--- a/example/src/demos/index.ts
+++ b/example/src/demos/index.ts
@@ -153,6 +153,11 @@ export const demos: Record = {
title: 'Issue #45 — Android modal background',
section: 'Issues',
},
+ 'issue-54': {
+ route: '/issues/54',
+ title: 'Issue #54 — Stale shimmer loop',
+ section: 'Issues',
+ },
'issue-loop-cancel': {
route: '/issues/loop-cancel',
title: 'Audit — Cancelled loop resurrects',
diff --git a/ios/EaseView.mm b/ios/EaseView.mm
index 2acab0b..04358ba 100644
--- a/ios/EaseView.mm
+++ b/ios/EaseView.mm
@@ -155,6 +155,66 @@ static EaseTransitionConfig transitionConfigFromStruct(const T &src) {
return transitionConfigFromStruct(t.defaultConfig);
}
+static bool isLoopingTransition(const EaseTransitionConfig &config) {
+ return config.loop == "repeat" || config.loop == "reverse";
+}
+
+// Map a saved loop animation key back to the property it drives. Returns the
+// mask bit and writes the transition-config property name, or 0 if the key is
+// not one a loop can be saved under.
+static int easePropertyForLoopKey(NSString *key, std::string &outName) {
+ if ([key isEqualToString:kAnimKeyOpacity]) {
+ outName = "opacity";
+ return kMaskOpacity;
+ } else if ([key isEqualToString:kAnimKeyTransformTransX]) {
+ outName = "translateX";
+ return kMaskTranslateX;
+ } else if ([key isEqualToString:kAnimKeyTransformTransY]) {
+ outName = "translateY";
+ return kMaskTranslateY;
+ } else if ([key isEqualToString:kAnimKeyTransformScaleX]) {
+ outName = "scaleX";
+ return kMaskScaleX;
+ } else if ([key isEqualToString:kAnimKeyTransformScaleY]) {
+ outName = "scaleY";
+ return kMaskScaleY;
+ } else if ([key isEqualToString:kAnimKeyTransformRotateZ]) {
+ outName = "rotate";
+ return kMaskRotate;
+ } else if ([key isEqualToString:kAnimKeyTransformRotateX]) {
+ outName = "rotateX";
+ return kMaskRotateX;
+ } else if ([key isEqualToString:kAnimKeyTransformRotateY]) {
+ outName = "rotateY";
+ return kMaskRotateY;
+ } else if ([key isEqualToString:kAnimKeyCornerRadius]) {
+ outName = "borderRadius";
+ return kMaskBorderRadius;
+ } else if ([key isEqualToString:kAnimKeyBackgroundColor]) {
+ outName = "backgroundColor";
+ return kMaskBackgroundColor;
+ } else if ([key isEqualToString:kAnimKeyBorderWidth]) {
+ outName = "borderWidth";
+ return kMaskBorderWidth;
+ } else if ([key isEqualToString:kAnimKeyBorderColor]) {
+ outName = "borderColor";
+ return kMaskBorderColor;
+ } else if ([key isEqualToString:kAnimKeyShadowOpacity]) {
+ outName = "shadowOpacity";
+ return kMaskShadowOpacity;
+ } else if ([key isEqualToString:kAnimKeyShadowRadius]) {
+ outName = "shadowRadius";
+ return kMaskShadowRadius;
+ } else if ([key isEqualToString:kAnimKeyShadowColor]) {
+ outName = "shadowColor";
+ return kMaskShadowColor;
+ } else if ([key isEqualToString:kAnimKeyShadowOffset]) {
+ outName = "shadowOffset";
+ return kMaskShadowOffset;
+ }
+ return 0;
+}
+
// Find lowest property name with a set mask bit among transform properties
static std::string lowestTransformPropertyName(int mask) {
if (mask & kMaskTranslateX)
@@ -374,6 +434,82 @@ - (void)removeEaseAnimationForKey:(NSString *)key {
[_loopAnimations removeObjectForKey:key];
}
+// Drop every saved loop the current props no longer ask for.
+//
+// The per-property paths in updateProps: only tear an animation down when the
+// property is still in animatedProperties AND its value changed. Props that
+// drop a property from `animate` entirely, or that keep animating it but stop
+// asking for `loop`, therefore hit neither gate — the infinite CAAnimation
+// stays on the layer forever, and _loopAnimations replays it on every
+// didMoveToWindow. Treat the current props as the source of truth instead.
+- (void)removeStaleLoopAnimationsForProps:(const EaseViewProps &)props {
+ if (_loopAnimations.count == 0) {
+ return;
+ }
+
+ int mask = props.animatedProperties;
+ BOOL needsTransformReset = NO;
+
+ // updateProps: already runs inside one, but didMoveToWindow does not, and
+ // the model writes below must not start implicit animations.
+ [CATransaction begin];
+ [CATransaction setDisableActions:YES];
+
+ for (NSString *key in [_loopAnimations.allKeys copy]) {
+ std::string propertyName;
+ int propertyMask = easePropertyForLoopKey(key, propertyName);
+ if (propertyMask == 0) {
+ continue;
+ }
+
+ BOOL stillAnimated = (mask & propertyMask) != 0;
+ if (stillAnimated &&
+ isLoopingTransition(transitionConfigForProperty(propertyName, props))) {
+ continue;
+ }
+
+ [self removeEaseAnimationForKey:key];
+
+ // Removing the animation drops the presentation layer back to the model,
+ // which still holds the loop's target — a shimmer frozen mid-sweep. Reset
+ // only when the property left animatedProperties: while it is still
+ // animated the model already holds the value the current props ask for,
+ // and writing here would cause a visible jump. Every animate* prop
+ // defaults to its identity value once JS clears the mask bit, so the
+ // current props are also the correct resting state.
+ if (stillAnimated) {
+ continue;
+ }
+ if (propertyMask & kMaskAnyTransform) {
+ // Recompose the whole matrix rather than writing a sub-key path: a
+ // transform carrying m34 perspective can't be reliably decomposed, and
+ // this keeps any sub-property that IS still animated at its own value.
+ needsTransformReset = YES;
+ } else if (propertyMask == kMaskOpacity) {
+ self.layer.opacity = props.animateOpacity;
+ } else if (propertyMask == kMaskBorderRadius) {
+ self.layer.cornerRadius = props.animateBorderRadius;
+ } else if (propertyMask == kMaskBorderWidth) {
+ self.layer.borderWidth = props.animateBorderWidth;
+ } else if (propertyMask == kMaskShadowOpacity) {
+ self.layer.shadowOpacity = props.animateShadowOpacity;
+ } else if (propertyMask == kMaskShadowRadius) {
+ self.layer.shadowRadius = props.animateShadowRadius;
+ } else if (propertyMask == kMaskShadowOffset) {
+ self.layer.shadowOffset =
+ CGSizeMake(props.animateShadowOffsetX, props.animateShadowOffsetY);
+ }
+ // Colors (background, border, shadow) are deliberately not reset: an
+ // unset SharedColor has no identity value, so writing one here would
+ // clobber a color the style is now responsible for.
+ }
+
+ if (needsTransformReset) {
+ self.layer.transform = [self targetTransformFromProps:props];
+ }
+ [CATransaction commit];
+}
+
- (void)reapplyLoopAnimations {
if (_loopAnimations.count == 0) {
return;
@@ -887,6 +1023,9 @@ - (void)updateProps:(const Props::Shared &)props
} else {
// Subsequent updates: animate changed properties
[self beginAnimationBatch];
+ // After beginAnimationBatch so the removals below can't be mistaken for
+ // this batch's animations completing.
+ [self removeStaleLoopAnimationsForProps:newViewProps];
BOOL anyPropertyChanged = NO;
if ((mask & kMaskOpacity) &&
@@ -1242,6 +1381,12 @@ - (void)didMoveToWindow {
// 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) {
+ // The snapshot may predate a props update that stopped asking for the
+ // loop, so filter it against the current props before replaying.
+ if (_props) {
+ [self removeStaleLoopAnimationsForProps:*std::static_pointer_cast<
+ const EaseViewProps>(_props)];
+ }
[self reapplyLoopAnimations];
}
}