diff --git a/AGENTS.md b/AGENTS.md index 6a8fb5c..6ca7fe4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,6 +60,15 @@ transition={{ type: 'spring', damping: 10 }} → transitionType="spring", tran - `docs/docs/api-reference.mdx` (API reference table) - `skills/react-native-ease-refactor/SKILL.md` (supported properties list, transition category keys, decision tree) +## Fixing a Bug + +When fixing a reported bug, add a reproducer to the example app so we can manually verify the fix and catch regressions later. + +1. Create a new layout under `example/app/issues//` — use whatever structure the bug needs (tabs, modals, nested stacks). Self-contained so it can be opened directly from the home screen. +2. Register it in `example/src/demos/index.ts` under section `'Issues'` with a `route` field pointing at the new layout (e.g. `route: '/issues/42'`). No `component` is needed — the route owns its own rendering. +3. Include enough context in the screen itself: title, GitHub issue link, repro steps, and visual cues so it's obvious whether the bug is present (e.g. a `mountId` to distinguish "remount" from "still mounted but broken"). +4. The reproducer should fail visibly on the broken code path and pass once the fix is in. Don't skip this even when the bug feels obvious — the regression test pays for itself the first time someone touches related code. + ## Development Commands ```sh diff --git a/example/app/[demo].tsx b/example/app/[demo].tsx index 913e2dc..42766cc 100644 --- a/example/app/[demo].tsx +++ b/example/app/[demo].tsx @@ -7,7 +7,7 @@ export default function DemoScreen() { const { demo } = useLocalSearchParams<{ demo: string }>(); const entry = demos[demo!]; - if (!entry) { + if (!entry?.component) { return null; } diff --git a/example/app/index.tsx b/example/app/index.tsx index d0ff100..c4924ae 100644 --- a/example/app/index.tsx +++ b/example/app/index.tsx @@ -1,27 +1,47 @@ import { useRouter } from 'expo-router'; +import { useMemo, useState } from 'react'; import { SectionList, Text, Pressable, StyleSheet, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { getDemoSections } from '../src/demos'; - -const sections = getDemoSections(); +import { getDemoSections, TABS, type TabKey } from '../src/demos'; export default function HomeScreen() { const router = useRouter(); const insets = useSafeAreaInsets(); + const [tab, setTab] = useState('api'); + + const sections = useMemo(() => getDemoSections(tab), [tab]); return ( item.key} - contentContainerStyle={[styles.content, { paddingTop: insets.top }]} + contentContainerStyle={styles.content} stickySectionHeadersEnabled={false} ListHeaderComponent={ - + react-native-ease Native animations, zero JS overhead + + {TABS.map(({ key, label }) => { + const active = tab === key; + return ( + setTab(key)} + style={[styles.tab, active && styles.tabActive]} + > + + {label} + + + ); + })} + } renderSectionHeader={({ section }) => ( @@ -30,7 +50,7 @@ export default function HomeScreen() { renderItem={({ item }) => ( [styles.row, pressed && styles.rowPressed]} - onPress={() => router.push(`/${item.key}`)} + onPress={() => router.push(item.route)} > {item.title} @@ -58,6 +78,31 @@ const styles = StyleSheet.create({ fontSize: 15, color: '#8888aa', }, + tabBar: { + flexDirection: 'row', + backgroundColor: '#16213e', + borderRadius: 12, + padding: 4, + marginTop: 20, + gap: 4, + }, + tab: { + flex: 1, + paddingVertical: 10, + borderRadius: 8, + alignItems: 'center', + }, + tabActive: { + backgroundColor: '#2a3a5e', + }, + tabLabel: { + fontSize: 14, + fontWeight: '600', + color: '#8888aa', + }, + tabLabelActive: { + color: '#fff', + }, sectionHeader: { fontSize: 13, fontWeight: '600', diff --git a/example/app/issues/42/_layout.tsx b/example/app/issues/42/_layout.tsx new file mode 100644 index 0000000..1181228 --- /dev/null +++ b/example/app/issues/42/_layout.tsx @@ -0,0 +1,23 @@ +import { Tabs, Stack } from 'expo-router'; + +export default function Issue42Layout() { + return ( + <> + + + + + + + ); +} diff --git a/example/app/issues/42/index.tsx b/example/app/issues/42/index.tsx new file mode 100644 index 0000000..7ab1e4b --- /dev/null +++ b/example/app/issues/42/index.tsx @@ -0,0 +1,125 @@ +import { useState } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { EaseView } from 'react-native-ease'; + +// Issue #42: Loop animations stop after switching tabs in React Navigation. +// https://github.com/AppAndFlow/react-native-ease/issues/42 +// +// Steps to reproduce: +// 1. Observe the spinner and pulse on this tab — both should loop continuously. +// 2. Switch to the "Other" tab. +// 3. Switch back. Without the fix, both animations are frozen. +// 4. Mount-id below should NOT change — the component isn't being remounted; +// only the iOS view detaches from the window, which removes its CAAnimations. + +export default function LoopTab() { + const insets = useSafeAreaInsets(); + // Stable id per mount — verifies the component is NOT being remounted on tab + // switch. If this number changes when you come back, the bug is unrelated. + const [mountId] = useState(() => Math.floor(Math.random() * 10000)); + + return ( + + Issue #42 reproducer + + Switch to the Other tab and back. Both animations should keep looping. + + mount #{mountId} + + + + Spin (repeat) + + + + + + + Pulse (reverse) + + + + + ); +} + +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, + }, + mountId: { + fontSize: 12, + fontFamily: 'monospace', + color: '#6666aa', + marginBottom: 32, + }, + row: { + flexDirection: 'row', + gap: 24, + justifyContent: 'center', + }, + demo: { + alignItems: 'center', + gap: 12, + }, + demoLabel: { + fontSize: 13, + color: '#8888aa', + }, + 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', + }, + circle: { + width: 80, + height: 80, + borderRadius: 40, + backgroundColor: '#d94a90', + }, +}); diff --git a/example/app/issues/42/other.tsx b/example/app/issues/42/other.tsx new file mode 100644 index 0000000..8109386 --- /dev/null +++ b/example/app/issues/42/other.tsx @@ -0,0 +1,57 @@ +import { ScrollView, 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. Animations should still be running. + + {Array.from({ length: 30 }).map((_, i) => ( + + Item {i + 1} + + ))} + + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + backgroundColor: '#1a1a2e', + }, + content: { + paddingHorizontal: 20, + gap: 8, + }, + heading: { + fontSize: 22, + fontWeight: '700', + color: '#fff', + marginBottom: 8, + }, + body: { + fontSize: 14, + color: '#aaaacc', + marginBottom: 16, + lineHeight: 20, + }, + item: { + padding: 16, + backgroundColor: '#16213e', + borderRadius: 12, + }, + itemText: { + color: '#e0e0ff', + fontSize: 15, + }, +}); diff --git a/example/src/demos/index.ts b/example/src/demos/index.ts index ab14eb5..3fcc18a 100644 --- a/example/src/demos/index.ts +++ b/example/src/demos/index.ts @@ -30,7 +30,14 @@ import { SpinDemo } from './SpinDemo'; import { UniwindDemo } from './uniwind/UniwindDemo'; interface DemoEntry { - component: ComponentType; + /** Component rendered inside the catch-all `[demo].tsx` screen. */ + component?: ComponentType; + /** + * Custom expo-router path. Used by issue reproducers that need their own + * layout (tabs, modals, etc.) under `app/issues/...`. When omitted, the + * registry key is used as a single-segment route handled by `[demo].tsx`. + */ + route?: string; title: string; section: string; } @@ -127,13 +134,41 @@ export const demos: Record = { }, } : {}), + // --- Issue reproducers --- + // Each entry routes to its own layout under `app/issues//`. Add a new + // reproducer when fixing a bug so we can test for regressions. + 'issue-42': { + route: '/issues/42', + title: 'Issue #42 — Tabs loop', + section: 'Issues', + }, }; interface SectionData { title: string; - data: { key: string; title: string }[]; + data: { key: string; title: string; route: string }[]; } +export type TabKey = 'api' | 'demos' | 'issues'; + +export const TABS: { key: TabKey; label: string }[] = [ + { key: 'api', label: 'API' }, + { key: 'demos', label: 'Demos' }, + { key: 'issues', label: 'Issues' }, +]; + +// Section → top-level tab. API covers building-block primitives, Demos covers +// compound showcase screens, Issues covers regression reproducers. +const sectionToTab: Record = { + Basic: 'api', + Transform: 'api', + Timing: 'api', + Style: 'api', + Loop: 'api', + Advanced: 'demos', + Issues: 'issues', +}; + const sectionOrder = [ 'Basic', 'Transform', @@ -141,14 +176,19 @@ const sectionOrder = [ 'Style', 'Loop', 'Advanced', + 'Issues', ]; -export function getDemoSections(): SectionData[] { - const grouped = new Map(); +export function getDemoSections(tab: TabKey): SectionData[] { + const grouped = new Map< + string, + { key: string; title: string; route: string }[] + >(); for (const [key, entry] of Object.entries(demos)) { + if (sectionToTab[entry.section] !== tab) continue; const list = grouped.get(entry.section) ?? []; - list.push({ key, title: entry.title }); + list.push({ key, title: entry.title, route: entry.route ?? `/${key}` }); grouped.set(entry.section, list); } diff --git a/ios/EaseView.mm b/ios/EaseView.mm index f1070f1..b10a2c9 100644 --- a/ios/EaseView.mm +++ b/ios/EaseView.mm @@ -181,6 +181,13 @@ @implementation EaseView { CGFloat _transformOriginX; CGFloat _transformOriginY; CGFloat _transformPerspective; + // Snapshot of in-flight loop animations, keyed by animation key. iOS + // removes CAAnimations when a layer leaves the window hierarchy (e.g. + // react-navigation tab switches), so we re-add these on re-attach. + // Each saved animation has an explicit beginTime, which iOS preserves + // through addAnimation's copy — so phase continues seamlessly via + // (currentMediaTime - beginTime) mod period. + NSMutableDictionary *_loopAnimations; } + (ComponentDescriptorProvider)componentDescriptorProvider { @@ -196,6 +203,7 @@ - (instancetype)initWithFrame:(CGRect)frame { _hasPendingFirstMountUpdate = NO; _transformOriginX = 0.5; _transformOriginY = 0.5; + _loopAnimations = [NSMutableDictionary dictionary]; } return self; } @@ -303,13 +311,48 @@ - (void)applyAnimationForKeyPath:(NSString *)keyPath toValue:toValue config:config loop:loop]; + BOOL isLooping = + loop && (config.loop == "repeat" || config.loop == "reverse"); if (config.delay > 0) { animation.beginTime = CACurrentMediaTime() + (config.delay / 1000.0); animation.fillMode = kCAFillModeBackwards; + } else if (isLooping) { + // Set explicit beginTime so the phase survives the addAnimation copy. + // Without this, re-adding the saved animation later would reset to a + // fresh "now" and visually restart the loop from the start. + animation.beginTime = CACurrentMediaTime(); } [animation setValue:@(_animationBatchId) forKey:@"easeBatchId"]; animation.delegate = self; [self.layer addAnimation:animation forKey:animationKey]; + + if (isLooping) { + _loopAnimations[animationKey] = animation; + } else { + [_loopAnimations removeObjectForKey:animationKey]; + } +} + +// Remove the explicit animation from both the layer and our saved snapshot +// so it doesn't get re-added when the view re-enters a window. +- (void)removeEaseAnimationForKey:(NSString *)key { + [self.layer removeAnimationForKey:key]; + [_loopAnimations removeObjectForKey:key]; +} + +- (void)reapplyLoopAnimations { + if (_loopAnimations.count == 0) { + return; + } + [CATransaction begin]; + [CATransaction setDisableActions:YES]; + for (NSString *key in _loopAnimations) { + // Increment to balance the eventual animationDidStop callback when the + // view detaches again (or the loop is replaced). + _pendingAnimationCount++; + [self.layer addAnimation:_loopAnimations[key] forKey:key]; + } + [CATransaction commit]; } /// Compose a CATransform3D from EaseViewProps target values. @@ -816,7 +859,7 @@ - (void)updateProps:(const Props::Shared &)props transitionConfigForProperty("opacity", newViewProps); if (opacityConfig.type == "none") { self.layer.opacity = newViewProps.animateOpacity; - [self.layer removeAnimationForKey:kAnimKeyOpacity]; + [self removeEaseAnimationForKey:kAnimKeyOpacity]; } else { self.layer.opacity = newViewProps.animateOpacity; [self @@ -867,7 +910,7 @@ - (void)updateProps:(const Props::Shared &)props if (transformConfig.type == "none") { self.layer.transform = [self targetTransformFromProps:newViewProps]; - [self.layer removeAnimationForKey:kAnimKeyTransform]; + [self removeEaseAnimationForKey:kAnimKeyTransform]; } else { // Read "from" values from the presentation layer BEFORE setting // the new model transform. During an active animation, CA tracks @@ -973,7 +1016,7 @@ - (void)updateProps:(const Props::Shared &)props transitionConfigForProperty("borderRadius", newViewProps); self.layer.cornerRadius = newViewProps.animateBorderRadius; if (brConfig.type == "none") { - [self.layer removeAnimationForKey:kAnimKeyCornerRadius]; + [self removeEaseAnimationForKey:kAnimKeyCornerRadius]; } else { [self applyAnimationForKeyPath:@"cornerRadius" animationKey:kAnimKeyCornerRadius @@ -996,7 +1039,7 @@ - (void)updateProps:(const Props::Shared &)props .CGColor; self.layer.backgroundColor = toColor; if (bgConfig.type == "none") { - [self.layer removeAnimationForKey:kAnimKeyBackgroundColor]; + [self removeEaseAnimationForKey:kAnimKeyBackgroundColor]; } else { CGColorRef fromColor = (__bridge CGColorRef) [self presentationValueForKeyPath:@"backgroundColor"]; @@ -1016,7 +1059,7 @@ - (void)updateProps:(const Props::Shared &)props transitionConfigForProperty("borderWidth", newViewProps); self.layer.borderWidth = newViewProps.animateBorderWidth; if (config.type == "none") { - [self.layer removeAnimationForKey:kAnimKeyBorderWidth]; + [self removeEaseAnimationForKey:kAnimKeyBorderWidth]; } else { [self applyAnimationForKeyPath:@"borderWidth" animationKey:kAnimKeyBorderWidth @@ -1037,7 +1080,7 @@ - (void)updateProps:(const Props::Shared &)props RCTUIColorFromSharedColor(newViewProps.animateBorderColor).CGColor; self.layer.borderColor = toColor; if (config.type == "none") { - [self.layer removeAnimationForKey:kAnimKeyBorderColor]; + [self removeEaseAnimationForKey:kAnimKeyBorderColor]; } else { CGColorRef fromColor = (__bridge CGColorRef) [self presentationValueForKeyPath:@"borderColor"]; @@ -1057,7 +1100,7 @@ - (void)updateProps:(const Props::Shared &)props transitionConfigForProperty("shadowOpacity", newViewProps); self.layer.shadowOpacity = newViewProps.animateShadowOpacity; if (config.type == "none") { - [self.layer removeAnimationForKey:kAnimKeyShadowOpacity]; + [self removeEaseAnimationForKey:kAnimKeyShadowOpacity]; } else { [self applyAnimationForKeyPath:@"shadowOpacity" animationKey:kAnimKeyShadowOpacity @@ -1076,7 +1119,7 @@ - (void)updateProps:(const Props::Shared &)props transitionConfigForProperty("shadowRadius", newViewProps); self.layer.shadowRadius = newViewProps.animateShadowRadius; if (config.type == "none") { - [self.layer removeAnimationForKey:kAnimKeyShadowRadius]; + [self removeEaseAnimationForKey:kAnimKeyShadowRadius]; } else { [self applyAnimationForKeyPath:@"shadowRadius" animationKey:kAnimKeyShadowRadius @@ -1097,7 +1140,7 @@ - (void)updateProps:(const Props::Shared &)props RCTUIColorFromSharedColor(newViewProps.animateShadowColor).CGColor; self.layer.shadowColor = toColor; if (config.type == "none") { - [self.layer removeAnimationForKey:kAnimKeyShadowColor]; + [self removeEaseAnimationForKey:kAnimKeyShadowColor]; } else { CGColorRef fromColor = (__bridge CGColorRef) [self presentationValueForKeyPath:@"shadowColor"]; @@ -1121,7 +1164,7 @@ - (void)updateProps:(const Props::Shared &)props newViewProps.animateShadowOffsetY); self.layer.shadowOffset = targetOffset; if (config.type == "none") { - [self.layer removeAnimationForKey:kAnimKeyShadowOffset]; + [self removeEaseAnimationForKey:kAnimKeyShadowOffset]; } else { CGSize fromOffset = [[self presentationValueForKeyPath:@"shadowOffset"] CGSizeValue]; @@ -1157,6 +1200,13 @@ - (void)finalizeUpdates:(RNComponentViewUpdateMask)updateMask { - (void)didMoveToWindow { [super didMoveToWindow]; [self tryApplyPendingFirstMountProps]; + + // iOS removes CAAnimations when a layer leaves the window hierarchy. + // 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) { + [self reapplyLoopAnimations]; + } } - (void)invalidateLayer { @@ -1206,29 +1256,29 @@ - (void)invalidateLayer { RCTUIColorFromSharedColor(viewProps.animateBackgroundColor).CGColor; } if (mask & kMaskBorderWidth) { - [self.layer removeAnimationForKey:kAnimKeyBorderWidth]; + [self removeEaseAnimationForKey:kAnimKeyBorderWidth]; self.layer.borderWidth = viewProps.animateBorderWidth; } if (mask & kMaskBorderColor) { - [self.layer removeAnimationForKey:kAnimKeyBorderColor]; + [self removeEaseAnimationForKey:kAnimKeyBorderColor]; self.layer.borderColor = RCTUIColorFromSharedColor(viewProps.animateBorderColor).CGColor; } if (mask & kMaskShadowOpacity) { - [self.layer removeAnimationForKey:kAnimKeyShadowOpacity]; + [self removeEaseAnimationForKey:kAnimKeyShadowOpacity]; self.layer.shadowOpacity = viewProps.animateShadowOpacity; } if (mask & kMaskShadowRadius) { - [self.layer removeAnimationForKey:kAnimKeyShadowRadius]; + [self removeEaseAnimationForKey:kAnimKeyShadowRadius]; self.layer.shadowRadius = viewProps.animateShadowRadius; } if (mask & kMaskShadowColor) { - [self.layer removeAnimationForKey:kAnimKeyShadowColor]; + [self removeEaseAnimationForKey:kAnimKeyShadowColor]; self.layer.shadowColor = RCTUIColorFromSharedColor(viewProps.animateShadowColor).CGColor; } if (mask & kMaskShadowOffset) { - [self.layer removeAnimationForKey:kAnimKeyShadowOffset]; + [self removeEaseAnimationForKey:kAnimKeyShadowOffset]; self.layer.shadowOffset = CGSizeMake(viewProps.animateShadowOffsetX, viewProps.animateShadowOffsetY); } @@ -1259,6 +1309,7 @@ - (void)animationDidStop:(CAAnimation *)anim finished:(BOOL)flag { - (void)prepareForRecycle { [super prepareForRecycle]; [self.layer removeAllAnimations]; + [_loopAnimations removeAllObjects]; _isFirstMount = YES; _hasPendingFirstMountUpdate = NO; _pendingAnimationCount = 0;