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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
72 changes: 72 additions & 0 deletions src/__tests__/hooks/useAnimationStateMachine.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
25 changes: 18 additions & 7 deletions src/components/mobile/FilterSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -84,25 +85,35 @@ export function FilterSheet({
const overlayOpacity = useSharedValue(0);
const [localValues, setLocalValues] = useState<FilterValues>(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 }));
Expand All @@ -126,7 +137,7 @@ export function FilterSheet({
transform: [{ translateY: translateY.value }],
}));

if (!visible) return null;
if (!isVisible) return null;

return (
<ErrorBoundary boundaryName="FilterSheetModal">
Expand Down
57 changes: 57 additions & 0 deletions src/hooks/useAnimationStateMachine.ts
Original file line number Diff line number Diff line change
@@ -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<AnimationState, Partial<Record<Action, AnimationState>>> = {
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 };
}
Loading