diff --git a/app/(tabs)/(home)/scanning.tsx b/app/(tabs)/(home)/scanning.tsx index 7ce25b5..abeafe0 100644 --- a/app/(tabs)/(home)/scanning.tsx +++ b/app/(tabs)/(home)/scanning.tsx @@ -1,5 +1,6 @@ import { useAuth } from '@clerk/expo'; import LottieView from 'lottie-react-native'; +import { useIsFocused } from '@react-navigation/native'; import { router, Stack, useLocalSearchParams } from 'expo-router'; import { useEffect, useRef, useState } from 'react'; import { BackHandler, StyleSheet, Text, TouchableOpacity, useWindowDimensions, View } from 'react-native'; @@ -12,7 +13,7 @@ import { AppIcon } from '@/components/ui/app-icon'; import { useGuardedPress } from '@/utils/press-guard'; const POLLING_INTERVAL_MS = 2_000; -const MAX_POLLING_MS = 30_000; +const SCAN_SCREEN_TIMEOUT_MS = 20_000; const SHORT_SCREEN_HEIGHT = 760; const VERY_SHORT_SCREEN_HEIGHT = 700; const DEFAULT_ANIMATION_SIZE = 280; @@ -21,6 +22,7 @@ const VERY_SHORT_ANIMATION_SIZE = 188; export default function ScanningScreen() { const { getToken, isLoaded, isSignedIn } = useAuth(); + const isFocused = useIsFocused(); const { url: urlParam } = useLocalSearchParams<{ url?: string | string[] }>(); const url = getUrlParam(urlParam); const { height: windowHeight } = useWindowDimensions(); @@ -28,6 +30,8 @@ export default function ScanningScreen() { const [errorMessage, setErrorMessage] = useState(''); const [retryKey, setRetryKey] = useState(0); const getTokenRef = useRef(getToken); + const currentAbortControllerRef = useRef(null); + const hasNavigatedRef = useRef(false); const isShortScreen = windowHeight <= SHORT_SCREEN_HEIGHT; const isVeryShortScreen = windowHeight <= VERY_SHORT_SCREEN_HEIGHT; const animationSize = isVeryShortScreen @@ -42,44 +46,79 @@ export default function ScanningScreen() { useEffect(() => { const abortController = new AbortController(); - let timeoutId: ReturnType | null = null; - let timedOut = false; + let screenTimeoutId: ReturnType | null = null; + let isActive = true; + let didTimeout = false; + let hasNavigated = false; + + const clearScreenTimeout = () => { + if (screenTimeoutId) { + clearTimeout(screenTimeoutId); + screenTimeoutId = null; + } + }; + + const handleScreenTimeout = () => { + if (!isActive || didTimeout || hasNavigated) { + return; + } + + didTimeout = true; + abortController.abort(); + setErrorMessage(getAnalysisErrorMessage(new Error('TIMEOUT'))); + }; + + currentAbortControllerRef.current = abortController; + hasNavigatedRef.current = false; if (!isLoaded) { - return () => abortController.abort(); + return () => { + isActive = false; + if (currentAbortControllerRef.current === abortController) { + currentAbortControllerRef.current = null; + } + abortController.abort(); + }; } if (!isSignedIn) { setErrorMessage('로그인 상태를 확인할 수 없습니다. 다시 로그인한 뒤 시도해주세요.'); - return () => abortController.abort(); + return () => { + isActive = false; + if (currentAbortControllerRef.current === abortController) { + currentAbortControllerRef.current = null; + } + abortController.abort(); + }; } if (!url) { setErrorMessage('검사할 URL을 찾을 수 없습니다. 링크를 다시 입력해주세요.'); - return () => abortController.abort(); + return () => { + isActive = false; + if (currentAbortControllerRef.current === abortController) { + currentAbortControllerRef.current = null; + } + abortController.abort(); + }; } setErrorMessage(''); - - const timeoutPromise = new Promise((_, reject) => { - timeoutId = setTimeout(() => { - timedOut = true; - abortController.abort(); - reject(new Error('TIMEOUT')); - }, MAX_POLLING_MS); - }); - - Promise.race([ - runAnalysisPolling({ - getToken: () => getTokenRef.current(), - url, - signal: abortController.signal, - }), - timeoutPromise, - ]) + screenTimeoutId = setTimeout(handleScreenTimeout, SCAN_SCREEN_TIMEOUT_MS); + + runAnalysisPolling({ + getToken: () => getTokenRef.current(), + url, + signal: abortController.signal, + canNavigate: () => isActive && !didTimeout && !hasNavigated, + onNavigate: () => { + hasNavigated = true; + hasNavigatedRef.current = true; + clearScreenTimeout(); + }, + }) .catch((error) => { - if (timedOut) { - setErrorMessage(getAnalysisErrorMessage(new Error('TIMEOUT'))); + if (!isActive || hasNavigated || didTimeout) { return; } @@ -90,25 +129,42 @@ export default function ScanningScreen() { setErrorMessage(getAnalysisErrorMessage(error)); }) .finally(() => { - if (timeoutId) { - clearTimeout(timeoutId); - } + clearScreenTimeout(); }); return () => { - if (timeoutId) { - clearTimeout(timeoutId); + isActive = false; + clearScreenTimeout(); + if (currentAbortControllerRef.current === abortController) { + currentAbortControllerRef.current = null; } - abortController.abort(); }; }, [isLoaded, isSignedIn, retryKey, url]); const hasError = errorMessage.length > 0; const isScanning = !hasError; + const isScanningAnimationVisible = isFocused && isScanning; const guardedRetry = useGuardedPress(() => setRetryKey((key) => key + 1)); const guardedBack = useGuardedPress(() => router.back()); + useEffect(() => { + if (!isScanningAnimationVisible) { + return undefined; + } + + const animationTimeoutId = setTimeout(() => { + if (hasNavigatedRef.current) { + return; + } + + currentAbortControllerRef.current?.abort(); + setErrorMessage(getAnalysisErrorMessage(new Error('TIMEOUT'))); + }, SCAN_SCREEN_TIMEOUT_MS); + + return () => clearTimeout(animationTimeoutId); + }, [isScanningAnimationVisible, retryKey, url]); + useEffect(() => { if (!isScanning) { return undefined; @@ -164,8 +220,8 @@ export default function ScanningScreen() { > @@ -218,16 +274,20 @@ async function runAnalysisPolling({ getToken, url, signal, + canNavigate, + onNavigate, }: { getToken: () => Promise; url: string; signal: AbortSignal; + canNavigate: () => boolean; + onNavigate: () => void; }) { - const deadline = Date.now() + MAX_POLLING_MS; + const deadline = Date.now() + SCAN_SCREEN_TIMEOUT_MS; let analysis = await requestAnalysis(getToken, url, { signal }); while (!signal.aborted) { - const handled = handleAnalysisResult(analysis, url, signal); + const handled = handleAnalysisResult(analysis, url, signal, canNavigate, onNavigate); if (handled) { return; @@ -244,7 +304,13 @@ async function runAnalysisPolling({ } } -function handleAnalysisResult(analysis: AnalysisResponse, fallbackUrl: string, signal: AbortSignal) { +function handleAnalysisResult( + analysis: AnalysisResponse, + fallbackUrl: string, + signal: AbortSignal, + canNavigate: () => boolean, + onNavigate: () => void, +) { if (analysis.status === 'queued') { return false; } @@ -257,7 +323,8 @@ function handleAnalysisResult(analysis: AnalysisResponse, fallbackUrl: string, s throw new Error('MISSING_VERDICT'); } - if (!signal.aborted) { + if (!signal.aborted && canNavigate()) { + onNavigate(); router.replace({ pathname: getResultPath(analysis.verdict), params: {