Skip to content

Commit a73b917

Browse files
fix(ios): break CAAnimation delegate retain cycle and clear cancelled loop snapshots (#50)
CAAnimation strongly retains its delegate, and the loop animation snapshots saved in _loopAnimations never complete, so using the view as the animation delegate created a permanent retain cycle (view -> _loopAnimations -> animation -> view). Views released without going through prepareForRecycle (recycle pool at capacity, recycling disabled, surface teardown) leaked. Animations now use a proxy delegate that holds the view weakly and forwards animationDidStop, so completion events and the pending-animation bookkeeping behave as before. The all-'none' transition path also removed the layer animations without clearing _loopAnimations, so a cancelled loop resurrected the next time the view re-attached to a window. Clear the saved snapshots there too. Adds an example-app reproducer under Issues that cancels a loop and switches tabs to exercise the didMoveToWindow re-apply path.
1 parent a112f76 commit a73b917

5 files changed

Lines changed: 222 additions & 1 deletion

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { Tabs, Stack } from 'expo-router';
2+
3+
export default function LoopCancelLayout() {
4+
return (
5+
<>
6+
<Stack.Screen options={{ title: 'Loop cancel resurrect' }} />
7+
<Tabs
8+
screenOptions={{
9+
tabBarActiveTintColor: '#fff',
10+
tabBarInactiveTintColor: '#8888aa',
11+
tabBarStyle: {
12+
backgroundColor: '#1a1a2e',
13+
borderTopColor: '#16213e',
14+
},
15+
headerShown: false,
16+
}}
17+
>
18+
<Tabs.Screen name="index" options={{ title: 'Loop' }} />
19+
<Tabs.Screen name="other" options={{ title: 'Other' }} />
20+
</Tabs>
21+
</>
22+
);
23+
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { useState } from 'react';
2+
import { Pressable, StyleSheet, Text, View } from 'react-native';
3+
import { useSafeAreaInsets } from 'react-native-safe-area-context';
4+
import { EaseView } from 'react-native-ease';
5+
6+
// Audit finding M4: a loop cancelled through the all-'none' transition path
7+
// resurrects when the view re-attaches to the window (e.g. after a tab
8+
// switch), because the saved loop snapshot was not cleared on cancel.
9+
//
10+
// Steps to reproduce:
11+
// 1. Observe the spinner — it should loop continuously.
12+
// 2. Press "Cancel loop". The spinner stops.
13+
// 3. Switch to the "Other" tab, then come back.
14+
// 4. Without the fix, the spinner is spinning again even though the
15+
// transition is still 'none'. With the fix, it stays stopped.
16+
17+
export default function LoopCancelTab() {
18+
const insets = useSafeAreaInsets();
19+
const [cancelled, setCancelled] = useState(false);
20+
const [mountKey, setMountKey] = useState(0);
21+
22+
return (
23+
<View style={[styles.root, { paddingTop: insets.top + 16 }]}>
24+
<Text style={styles.heading}>Cancelled loop reproducer</Text>
25+
<Text style={styles.body}>
26+
Press Cancel loop, switch to the Other tab and come back. The spinner
27+
must stay stopped.
28+
</Text>
29+
<Text style={styles.status}>
30+
transition: {cancelled ? "'none' (cancelled)" : 'timing + loop'}
31+
</Text>
32+
33+
<View style={styles.demo}>
34+
<EaseView
35+
key={mountKey}
36+
initialAnimate={{ rotate: 0 }}
37+
animate={{ rotate: 360 }}
38+
transition={
39+
cancelled
40+
? { type: 'none' }
41+
: {
42+
type: 'timing',
43+
duration: 1000,
44+
easing: 'linear',
45+
loop: 'repeat',
46+
}
47+
}
48+
style={styles.box}
49+
>
50+
<View style={styles.indicator} />
51+
</EaseView>
52+
</View>
53+
54+
<View style={styles.buttons}>
55+
<Pressable
56+
style={[styles.button, cancelled && styles.buttonDisabled]}
57+
disabled={cancelled}
58+
onPress={() => setCancelled(true)}
59+
>
60+
<Text style={styles.buttonText}>Cancel loop</Text>
61+
</Pressable>
62+
<Pressable
63+
style={styles.button}
64+
onPress={() => {
65+
setCancelled(false);
66+
setMountKey((k) => k + 1);
67+
}}
68+
>
69+
<Text style={styles.buttonText}>Restart (remount)</Text>
70+
</Pressable>
71+
</View>
72+
</View>
73+
);
74+
}
75+
76+
const styles = StyleSheet.create({
77+
root: {
78+
flex: 1,
79+
backgroundColor: '#1a1a2e',
80+
paddingHorizontal: 20,
81+
},
82+
heading: {
83+
fontSize: 22,
84+
fontWeight: '700',
85+
color: '#fff',
86+
marginBottom: 8,
87+
},
88+
body: {
89+
fontSize: 14,
90+
color: '#aaaacc',
91+
marginBottom: 8,
92+
lineHeight: 20,
93+
},
94+
status: {
95+
fontSize: 12,
96+
fontFamily: 'monospace',
97+
color: '#6666aa',
98+
marginBottom: 32,
99+
},
100+
demo: {
101+
alignItems: 'center',
102+
marginBottom: 32,
103+
},
104+
box: {
105+
width: 80,
106+
height: 80,
107+
backgroundColor: '#4a90d9',
108+
borderRadius: 12,
109+
alignItems: 'center',
110+
justifyContent: 'flex-start',
111+
paddingTop: 8,
112+
},
113+
indicator: {
114+
width: 12,
115+
height: 12,
116+
borderRadius: 6,
117+
backgroundColor: '#fff',
118+
},
119+
buttons: {
120+
flexDirection: 'row',
121+
gap: 12,
122+
justifyContent: 'center',
123+
},
124+
button: {
125+
paddingHorizontal: 20,
126+
paddingVertical: 12,
127+
borderRadius: 10,
128+
backgroundColor: '#16213e',
129+
},
130+
buttonDisabled: {
131+
opacity: 0.4,
132+
},
133+
buttonText: {
134+
color: '#e0e0ff',
135+
fontSize: 15,
136+
fontWeight: '600',
137+
},
138+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { StyleSheet, Text, View } from 'react-native';
2+
import { useSafeAreaInsets } from 'react-native-safe-area-context';
3+
4+
export default function OtherTab() {
5+
const insets = useSafeAreaInsets();
6+
return (
7+
<View style={[styles.root, { paddingTop: insets.top + 16 }]}>
8+
<Text style={styles.heading}>Other tab</Text>
9+
<Text style={styles.body}>
10+
Switch back to the Loop tab. A cancelled loop must not restart.
11+
</Text>
12+
</View>
13+
);
14+
}
15+
16+
const styles = StyleSheet.create({
17+
root: {
18+
flex: 1,
19+
backgroundColor: '#1a1a2e',
20+
paddingHorizontal: 20,
21+
},
22+
heading: {
23+
fontSize: 22,
24+
fontWeight: '700',
25+
color: '#fff',
26+
marginBottom: 8,
27+
},
28+
body: {
29+
fontSize: 14,
30+
color: '#aaaacc',
31+
lineHeight: 20,
32+
},
33+
});

example/src/demos/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,11 @@ export const demos: Record<string, DemoEntry> = {
147147
title: 'Issue #45 — Android modal background',
148148
section: 'Issues',
149149
},
150+
'issue-loop-cancel': {
151+
route: '/issues/loop-cancel',
152+
title: 'Audit — Cancelled loop resurrects',
153+
section: 'Issues',
154+
},
150155
};
151156

152157
interface SectionData {

ios/EaseView.mm

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,22 @@ static EaseTransitionConfig transitionConfigFromStruct(const T &src) {
172172
return "translateX"; // fallback
173173
}
174174

175+
// CAAnimation strongly retains its delegate, and the loop animations saved in
176+
// _loopAnimations never complete, so making the view itself the delegate
177+
// would create a permanent retain cycle (view → _loopAnimations → animation →
178+
// view) that leaks the view on any release path that skips prepareForRecycle.
179+
// Animations retain this proxy instead; it holds the view weakly and forwards
180+
// the completion callback.
181+
@interface EaseAnimationDelegateProxy : NSObject <CAAnimationDelegate>
182+
@property(nonatomic, weak) EaseView *target;
183+
@end
184+
185+
@implementation EaseAnimationDelegateProxy
186+
- (void)animationDidStop:(CAAnimation *)anim finished:(BOOL)flag {
187+
[self.target animationDidStop:anim finished:flag];
188+
}
189+
@end
190+
175191
@implementation EaseView {
176192
BOOL _isFirstMount;
177193
BOOL _hasPendingFirstMountUpdate;
@@ -188,6 +204,7 @@ @implementation EaseView {
188204
// through addAnimation's copy — so phase continues seamlessly via
189205
// (currentMediaTime - beginTime) mod period.
190206
NSMutableDictionary<NSString *, CAAnimation *> *_loopAnimations;
207+
EaseAnimationDelegateProxy *_delegateProxy;
191208
}
192209

193210
+ (ComponentDescriptorProvider)componentDescriptorProvider {
@@ -204,6 +221,8 @@ - (instancetype)initWithFrame:(CGRect)frame {
204221
_transformOriginX = 0.5;
205222
_transformOriginY = 0.5;
206223
_loopAnimations = [NSMutableDictionary dictionary];
224+
_delegateProxy = [EaseAnimationDelegateProxy new];
225+
_delegateProxy.target = self;
207226
}
208227
return self;
209228
}
@@ -323,7 +342,7 @@ - (void)applyAnimationForKeyPath:(NSString *)keyPath
323342
animation.beginTime = CACurrentMediaTime();
324343
}
325344
[animation setValue:@(_animationBatchId) forKey:@"easeBatchId"];
326-
animation.delegate = self;
345+
animation.delegate = _delegateProxy;
327346
[self.layer addAnimation:animation forKey:animationKey];
328347

329348
if (isLooping) {
@@ -815,6 +834,9 @@ - (void)updateProps:(const Props::Shared &)props
815834
// All transitions are 'none' — set values immediately
816835
[self beginAnimationBatch];
817836
[self.layer removeAllAnimations];
837+
// Drop saved loop snapshots so didMoveToWindow doesn't re-add the
838+
// cancelled loops.
839+
[_loopAnimations removeAllObjects];
818840
if (mask & kMaskOpacity)
819841
self.layer.opacity = newViewProps.animateOpacity;
820842
if (hasTransform)

0 commit comments

Comments
 (0)