Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions example/app/issues/loop-cancel/_layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Tabs, Stack } from 'expo-router';

export default function LoopCancelLayout() {
return (
<>
<Stack.Screen options={{ title: 'Loop cancel resurrect' }} />
<Tabs
screenOptions={{
tabBarActiveTintColor: '#fff',
tabBarInactiveTintColor: '#8888aa',
tabBarStyle: {
backgroundColor: '#1a1a2e',
borderTopColor: '#16213e',
},
headerShown: false,
}}
>
<Tabs.Screen name="index" options={{ title: 'Loop' }} />
<Tabs.Screen name="other" options={{ title: 'Other' }} />
</Tabs>
</>
);
}
138 changes: 138 additions & 0 deletions example/app/issues/loop-cancel/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<View style={[styles.root, { paddingTop: insets.top + 16 }]}>
<Text style={styles.heading}>Cancelled loop reproducer</Text>
<Text style={styles.body}>
Press Cancel loop, switch to the Other tab and come back. The spinner
must stay stopped.
</Text>
<Text style={styles.status}>
transition: {cancelled ? "'none' (cancelled)" : 'timing + loop'}
</Text>

<View style={styles.demo}>
<EaseView
key={mountKey}
initialAnimate={{ rotate: 0 }}
animate={{ rotate: 360 }}
transition={
cancelled
? { type: 'none' }
: {
type: 'timing',
duration: 1000,
easing: 'linear',
loop: 'repeat',
}
}
style={styles.box}
>
<View style={styles.indicator} />
</EaseView>
</View>

<View style={styles.buttons}>
<Pressable
style={[styles.button, cancelled && styles.buttonDisabled]}
disabled={cancelled}
onPress={() => setCancelled(true)}
>
<Text style={styles.buttonText}>Cancel loop</Text>
</Pressable>
<Pressable
style={styles.button}
onPress={() => {
setCancelled(false);
setMountKey((k) => k + 1);
}}
>
<Text style={styles.buttonText}>Restart (remount)</Text>
</Pressable>
</View>
</View>
);
}

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',
},
});
33 changes: 33 additions & 0 deletions example/app/issues/loop-cancel/other.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<View style={[styles.root, { paddingTop: insets.top + 16 }]}>
<Text style={styles.heading}>Other tab</Text>
<Text style={styles.body}>
Switch back to the Loop tab. A cancelled loop must not restart.
</Text>
</View>
);
}

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,
},
});
5 changes: 5 additions & 0 deletions example/src/demos/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,11 @@ export const demos: Record<string, DemoEntry> = {
title: 'Issue #45 — Android modal background',
section: 'Issues',
},
'issue-loop-cancel': {
route: '/issues/loop-cancel',
title: 'Audit — Cancelled loop resurrects',
section: 'Issues',
},
};

interface SectionData {
Expand Down
24 changes: 23 additions & 1 deletion ios/EaseView.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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 <CAAnimationDelegate>
@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;
Expand All @@ -188,6 +204,7 @@ @implementation EaseView {
// through addAnimation's copy — so phase continues seamlessly via
// (currentMediaTime - beginTime) mod period.
NSMutableDictionary<NSString *, CAAnimation *> *_loopAnimations;
EaseAnimationDelegateProxy *_delegateProxy;
}

+ (ComponentDescriptorProvider)componentDescriptorProvider {
Expand All @@ -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;
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down
Loading