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)