From 72e649abcd52398cd19b8dd898e3563669c0f8f1 Mon Sep 17 00:00:00 2001 From: Tboy123-emm Date: Fri, 29 May 2026 23:16:26 +0000 Subject: [PATCH 1/2] feat: animation state machine hook with FilterSheet integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add useAnimationStateMachine hook (CLOSED/OPENING/OPEN/CLOSING) - Pure reducer — illegal transitions are no-ops, preventing race conditions - Refactor FilterSheet to use the state machine: - open() only fires from CLOSED or CLOSING states - close() only fires from OPEN or OPENING states - isVisible drives Modal mount (stays mounted during CLOSING animation) - ANIMATION_DONE advances state after each animation completes - 9 unit tests covering all transitions and race condition scenario --- package-lock.json | 28 +------- package.json | 4 +- .../hooks/useAnimationStateMachine.test.ts | 72 +++++++++++++++++++ src/components/mobile/FilterSheet.tsx | 25 +++++-- src/hooks/useAnimationStateMachine.ts | 57 +++++++++++++++ 5 files changed, 152 insertions(+), 34 deletions(-) create mode 100644 src/__tests__/hooks/useAnimationStateMachine.test.ts create mode 100644 src/hooks/useAnimationStateMachine.ts diff --git a/package-lock.json b/package-lock.json index 13ff082..de9e52b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "teachlink_mobile", - "version": "1.3.0", + "version": "1.4.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "teachlink_mobile", - "version": "1.3.0", + "version": "1.4.0", "dependencies": { "@expo/vector-icons": "^15.0.3", "@react-native-async-storage/async-storage": "2.2.0", @@ -12160,6 +12160,7 @@ "resolved": "https://registry.npmjs.org/jest-expo/-/jest-expo-54.0.17.tgz", "integrity": "sha512-LyIhrsP4xvHEEcR1R024u/LBj3uPpAgB+UljgV+YXWkEHjprnr0KpE4tROsMNYCVTM1pPlAnPuoBmn5gnAN9KA==", "dev": true, + "license": "MIT", "dependencies": { "@expo/config": "~12.0.13", "@expo/json-file": "^10.0.8", @@ -18213,22 +18214,6 @@ } } }, - "node_modules/tailwindcss/node_modules/yaml": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/tar": { "version": "7.5.15", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz", @@ -32706,13 +32691,6 @@ "requires": { "lilconfig": "^3.1.1" } - }, - "yaml": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", - "optional": true, - "peer": true } } }, diff --git a/package.json b/package.json index ece02fe..cd8bdc7 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "deploy:preview": "bash ./scripts/deploy-preview.sh", "prepare": "husky", "fonts:subset": "node ./scripts/subset-fonts.js", - "fonts:analyze": "node ./scripts/analyze-fonts.js" + "fonts:analyze": "node ./scripts/analyze-fonts.js", "audit": "npm audit --audit-level=high", "depcheck": "depcheck", "audit:performance": "tsx src/audit/cli.ts", @@ -103,6 +103,7 @@ }, "devDependencies": { "@babel/core": "^7.20.0", + "@jest/globals": "^29.7.0", "@lhci/cli": "^0.15.1", "@testing-library/jest-native": "^5.4.3", "@testing-library/react-native": "^13.3.3", @@ -110,7 +111,6 @@ "@types/babel__generator": "^7.27.0", "@types/babel__template": "^7.4.4", "@types/babel__traverse": "^7.28.0", - "@jest/globals": "^29.7.0", "@types/jest": "29.5.14", "@types/node": "^25.0.10", "@types/react": "~19.1.0", 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 }; +} From faf9ea0b0bc96cfccfa924c2001ea05e34e14941 Mon Sep 17 00:00:00 2001 From: Tboy123-emm Date: Sat, 30 May 2026 09:19:02 +0000 Subject: [PATCH 2/2] fix: replace missing 'npm run build' with 'npx expo export' in CI workflow The 'build' script does not exist in package.json. expo export produces the 'dist' folder that checkBundleSize.js expects. --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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