diff --git a/example/app/issues/loop-cancel/_layout.tsx b/example/app/issues/loop-cancel/_layout.tsx
new file mode 100644
index 0000000..91be450
--- /dev/null
+++ b/example/app/issues/loop-cancel/_layout.tsx
@@ -0,0 +1,23 @@
+import { Tabs, Stack } from 'expo-router';
+
+export default function LoopCancelLayout() {
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+}
diff --git a/example/app/issues/loop-cancel/index.tsx b/example/app/issues/loop-cancel/index.tsx
new file mode 100644
index 0000000..7daaf9f
--- /dev/null
+++ b/example/app/issues/loop-cancel/index.tsx
@@ -0,0 +1,138 @@
+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';
+
+// Audit finding M4: a loop cancelled through the all-'none' transition path
+// resurrects when the view re-attaches to the window (e.g. after a tab
+// switch), because the saved loop snapshot was not cleared on cancel.
+//
+// Steps to reproduce:
+// 1. Observe the spinner — it should loop continuously.
+// 2. Press "Cancel loop". The spinner stops.
+// 3. Switch to the "Other" tab, then come back.
+// 4. Without the fix, the spinner is spinning again even though the
+// transition is still 'none'. With the fix, it stays stopped.
+
+export default function LoopCancelTab() {
+ const insets = useSafeAreaInsets();
+ const [cancelled, setCancelled] = useState(false);
+ const [mountKey, setMountKey] = useState(0);
+
+ return (
+
+ Cancelled loop reproducer
+
+ Press Cancel loop, switch to the Other tab and come back. The spinner
+ must stay stopped.
+
+
+ transition: {cancelled ? "'none' (cancelled)" : 'timing + loop'}
+
+
+
+
+
+
+
+
+
+ setCancelled(true)}
+ >
+ Cancel loop
+
+ {
+ setCancelled(false);
+ setMountKey((k) => k + 1);
+ }}
+ >
+ Restart (remount)
+
+
+
+ );
+}
+
+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: 8,
+ lineHeight: 20,
+ },
+ status: {
+ fontSize: 12,
+ fontFamily: 'monospace',
+ color: '#6666aa',
+ marginBottom: 32,
+ },
+ demo: {
+ alignItems: 'center',
+ marginBottom: 32,
+ },
+ box: {
+ width: 80,
+ height: 80,
+ backgroundColor: '#4a90d9',
+ borderRadius: 12,
+ alignItems: 'center',
+ justifyContent: 'flex-start',
+ paddingTop: 8,
+ },
+ indicator: {
+ width: 12,
+ height: 12,
+ borderRadius: 6,
+ backgroundColor: '#fff',
+ },
+ 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/loop-cancel/other.tsx b/example/app/issues/loop-cancel/other.tsx
new file mode 100644
index 0000000..0e05fad
--- /dev/null
+++ b/example/app/issues/loop-cancel/other.tsx
@@ -0,0 +1,33 @@
+import { StyleSheet, Text, View } from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+
+export default function OtherTab() {
+ const insets = useSafeAreaInsets();
+ return (
+
+ Other tab
+
+ Switch back to the Loop tab. A cancelled loop must not restart.
+
+
+ );
+}
+
+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 802d658..755ff05 100644
--- a/example/src/demos/index.ts
+++ b/example/src/demos/index.ts
@@ -147,6 +147,11 @@ export const demos: Record = {
title: 'Issue #45 — Android modal background',
section: 'Issues',
},
+ 'issue-loop-cancel': {
+ route: '/issues/loop-cancel',
+ title: 'Audit — Cancelled loop resurrects',
+ section: 'Issues',
+ },
};
interface SectionData {
diff --git a/ios/EaseView.mm b/ios/EaseView.mm
index b10a2c9..55ef4db 100644
--- a/ios/EaseView.mm
+++ b/ios/EaseView.mm
@@ -172,6 +172,22 @@ static EaseTransitionConfig transitionConfigFromStruct(const T &src) {
return "translateX"; // fallback
}
+// CAAnimation strongly retains its delegate, and the loop animations saved in
+// _loopAnimations never complete, so making the view itself the delegate
+// would create a permanent retain cycle (view → _loopAnimations → animation →
+// view) that leaks the view on any release path that skips prepareForRecycle.
+// Animations retain this proxy instead; it holds the view weakly and forwards
+// the completion callback.
+@interface EaseAnimationDelegateProxy : NSObject
+@property(nonatomic, weak) EaseView *target;
+@end
+
+@implementation EaseAnimationDelegateProxy
+- (void)animationDidStop:(CAAnimation *)anim finished:(BOOL)flag {
+ [self.target animationDidStop:anim finished:flag];
+}
+@end
+
@implementation EaseView {
BOOL _isFirstMount;
BOOL _hasPendingFirstMountUpdate;
@@ -188,6 +204,7 @@ @implementation EaseView {
// through addAnimation's copy — so phase continues seamlessly via
// (currentMediaTime - beginTime) mod period.
NSMutableDictionary *_loopAnimations;
+ EaseAnimationDelegateProxy *_delegateProxy;
}
+ (ComponentDescriptorProvider)componentDescriptorProvider {
@@ -204,6 +221,8 @@ - (instancetype)initWithFrame:(CGRect)frame {
_transformOriginX = 0.5;
_transformOriginY = 0.5;
_loopAnimations = [NSMutableDictionary dictionary];
+ _delegateProxy = [EaseAnimationDelegateProxy new];
+ _delegateProxy.target = self;
}
return self;
}
@@ -323,7 +342,7 @@ - (void)applyAnimationForKeyPath:(NSString *)keyPath
animation.beginTime = CACurrentMediaTime();
}
[animation setValue:@(_animationBatchId) forKey:@"easeBatchId"];
- animation.delegate = self;
+ animation.delegate = _delegateProxy;
[self.layer addAnimation:animation forKey:animationKey];
if (isLooping) {
@@ -815,6 +834,9 @@ - (void)updateProps:(const Props::Shared &)props
// All transitions are 'none' — set values immediately
[self beginAnimationBatch];
[self.layer removeAllAnimations];
+ // Drop saved loop snapshots so didMoveToWindow doesn't re-add the
+ // cancelled loops.
+ [_loopAnimations removeAllObjects];
if (mask & kMaskOpacity)
self.layer.opacity = newViewProps.animateOpacity;
if (hasTransform)