diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b9d26fc..6193573 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,7 +51,7 @@ jobs: # ============================== - name: Build App (required for bundle check) - run: npm run build + run: npx expo export --platform web - name: Check Bundle Size run: node scripts/checkBundleSize.js diff --git a/src/__tests__/hooks/useAnimationStateMachine.test.ts b/src/__tests__/hooks/useAnimationStateMachine.test.ts new file mode 100644 index 0000000..4789237 --- /dev/null +++ b/src/__tests__/hooks/useAnimationStateMachine.test.ts @@ -0,0 +1,72 @@ +import { act, renderHook } from '@testing-library/react-native'; +import { useAnimationStateMachine } from '../../hooks/useAnimationStateMachine'; + +describe('useAnimationStateMachine', () => { + it('starts CLOSED by default', () => { + const { result } = renderHook(() => useAnimationStateMachine()); + expect(result.current.animState).toBe('CLOSED'); + expect(result.current.isVisible).toBe(false); + }); + + it('CLOSED → OPEN transitions to OPENING', () => { + const { result } = renderHook(() => useAnimationStateMachine()); + act(() => result.current.send('OPEN')); + expect(result.current.animState).toBe('OPENING'); + expect(result.current.isVisible).toBe(true); + }); + + it('OPENING → ANIMATION_DONE transitions to OPEN', () => { + const { result } = renderHook(() => useAnimationStateMachine()); + act(() => result.current.send('OPEN')); + act(() => result.current.send('ANIMATION_DONE')); + expect(result.current.animState).toBe('OPEN'); + }); + + it('OPEN → CLOSE transitions to CLOSING', () => { + const { result } = renderHook(() => useAnimationStateMachine()); + act(() => result.current.send('OPEN')); + act(() => result.current.send('ANIMATION_DONE')); + act(() => result.current.send('CLOSE')); + expect(result.current.animState).toBe('CLOSING'); + }); + + it('CLOSING → ANIMATION_DONE transitions to CLOSED', () => { + const { result } = renderHook(() => useAnimationStateMachine()); + act(() => result.current.send('OPEN')); + act(() => result.current.send('ANIMATION_DONE')); + act(() => result.current.send('CLOSE')); + act(() => result.current.send('ANIMATION_DONE')); + expect(result.current.animState).toBe('CLOSED'); + expect(result.current.isVisible).toBe(false); + }); + + it('CLOSING → OPEN interrupts back to OPENING (race condition prevention)', () => { + const { result } = renderHook(() => useAnimationStateMachine()); + act(() => result.current.send('OPEN')); + act(() => result.current.send('ANIMATION_DONE')); + act(() => result.current.send('CLOSE')); + // Re-open while closing — must not get stuck + act(() => result.current.send('OPEN')); + expect(result.current.animState).toBe('OPENING'); + }); + + it('ignores illegal transitions (CLOSED → CLOSE is a no-op)', () => { + const { result } = renderHook(() => useAnimationStateMachine()); + act(() => result.current.send('CLOSE')); + expect(result.current.animState).toBe('CLOSED'); + }); + + it('ignores illegal transitions (OPEN → OPEN is a no-op)', () => { + const { result } = renderHook(() => useAnimationStateMachine()); + act(() => result.current.send('OPEN')); + act(() => result.current.send('ANIMATION_DONE')); + act(() => result.current.send('OPEN')); + expect(result.current.animState).toBe('OPEN'); + }); + + it('accepts a custom initial state', () => { + const { result } = renderHook(() => useAnimationStateMachine('OPEN')); + expect(result.current.animState).toBe('OPEN'); + expect(result.current.isVisible).toBe(true); + }); +}); diff --git a/src/components/mobile/FilterSheet.tsx b/src/components/mobile/FilterSheet.tsx index 76809af..f1c91ea 100644 --- a/src/components/mobile/FilterSheet.tsx +++ b/src/components/mobile/FilterSheet.tsx @@ -18,6 +18,7 @@ import Animated, { } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useAdaptiveFrameRate } from '../../hooks/useAdaptiveFrameRate'; +import { useAnimationStateMachine } from '../../hooks/useAnimationStateMachine'; import { ErrorBoundary } from '../common/ErrorBoundary'; const { height: SCREEN_HEIGHT } = Dimensions.get('window'); @@ -84,25 +85,35 @@ export function FilterSheet({ const overlayOpacity = useSharedValue(0); const [localValues, setLocalValues] = useState(values); const { durationMultiplier } = useAdaptiveFrameRate(); + const { animState, send, isVisible } = useAnimationStateMachine(); const open = useCallback(() => { + send('OPEN'); translateY.value = withTiming(0, { duration: 280 * durationMultiplier }); - overlayOpacity.value = withTiming(1, { duration: 280 * durationMultiplier }); - }, [translateY, overlayOpacity, durationMultiplier]); + overlayOpacity.value = withTiming(1, { duration: 280 * durationMultiplier }, finished => { + if (finished) runOnJS(send)('ANIMATION_DONE'); + }); + }, [translateY, overlayOpacity, durationMultiplier, send]); const close = useCallback(() => { + send('CLOSE'); translateY.value = withTiming(SHEET_HEIGHT, { duration: 220 * durationMultiplier }); overlayOpacity.value = withTiming(0, { duration: 220 * durationMultiplier }, finished => { - if (finished) runOnJS(onClose)(); + if (finished) { + runOnJS(send)('ANIMATION_DONE'); + runOnJS(onClose)(); + } }); - }, [translateY, overlayOpacity, onClose, durationMultiplier]); + }, [translateY, overlayOpacity, onClose, durationMultiplier, send]); useEffect(() => { - if (visible) { + if (visible && (animState === 'CLOSED' || animState === 'CLOSING')) { setLocalValues(values); open(); + } else if (!visible && (animState === 'OPEN' || animState === 'OPENING')) { + close(); } - }, [visible, values, open]); + }, [visible, values, animState, open, close]); const handleSelect = useCallback((key: string, value: string) => { setLocalValues(prev => ({ ...prev, [key]: value })); @@ -126,7 +137,7 @@ export function FilterSheet({ transform: [{ translateY: translateY.value }], })); - if (!visible) return null; + if (!isVisible) return null; return ( diff --git a/src/hooks/useAnimationStateMachine.ts b/src/hooks/useAnimationStateMachine.ts new file mode 100644 index 0000000..9e8d7ad --- /dev/null +++ b/src/hooks/useAnimationStateMachine.ts @@ -0,0 +1,57 @@ +import { useCallback, useReducer } from 'react'; + +/** + * States for a sheet/modal animation lifecycle. + * + * CLOSED ──open──► OPENING ──done──► OPEN + * OPEN ──close─► CLOSING ──done──► CLOSED + * + * Illegal transitions are silently ignored, preventing race conditions when + * open/close are called while an animation is already in flight. + */ +export type AnimationState = 'CLOSED' | 'OPENING' | 'OPEN' | 'CLOSING'; + +type Action = 'OPEN' | 'CLOSE' | 'ANIMATION_DONE'; + +const TRANSITIONS: Record>> = { + CLOSED: { OPEN: 'OPENING' }, + OPENING: { ANIMATION_DONE: 'OPEN', CLOSE: 'CLOSING' }, + OPEN: { CLOSE: 'CLOSING' }, + CLOSING: { ANIMATION_DONE: 'CLOSED', OPEN: 'OPENING' }, +}; + +function reducer(state: AnimationState, action: Action): AnimationState { + return TRANSITIONS[state][action] ?? state; +} + +export interface UseAnimationStateMachineReturn { + animState: AnimationState; + /** Trigger the open animation (ignored if already open/opening) */ + send: (action: Action) => void; + isVisible: boolean; +} + +/** + * Hook that manages animation lifecycle via an explicit state machine. + * Prevents race conditions by only allowing valid state transitions. + * + * @param initial - Starting state (default: 'CLOSED') + * + * @example + * const { animState, send, isVisible } = useAnimationStateMachine(); + * // Start open animation: send('OPEN') + * // Signal done: send('ANIMATION_DONE') + * // Start close animation: send('CLOSE') + */ +export function useAnimationStateMachine( + initial: AnimationState = 'CLOSED' +): UseAnimationStateMachineReturn { + const [animState, dispatch] = useReducer(reducer, initial); + + const send = useCallback((action: Action) => dispatch(action), []); + + // Component should be mounted whenever not fully CLOSED + const isVisible = animState !== 'CLOSED'; + + return { animState, send, isVisible }; +}