diff --git a/app.json b/app.json index e7fc297..84a864b 100644 --- a/app.json +++ b/app.json @@ -10,7 +10,7 @@ "newArchEnabled": true, "ios": { "supportsTablet": false, - "buildNumber": "14", + "buildNumber": "15", "googleServicesFile": "./GoogleService-Info.plist", "bundleIdentifier": "com.moadong.moadong", "associatedDomains": [ @@ -26,7 +26,7 @@ }, "android": { "jsEngine": "hermes", - "versionCode": 14, + "versionCode": 15, "adaptiveIcon": { "backgroundColor": "#E6F4FE", "foregroundImage": "./assets/images/android-icon-foreground.png", diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 9057aef..b35027c 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,10 +1,17 @@ +import { useHomeWebViewPreloadContext } from '@/contexts/home-webview-preload-context'; import { HomeWebViewScreen } from '@/ui/home/home-webview-screen'; -import React, { Suspense, lazy, useState } from 'react'; +import React, { Suspense, lazy, useCallback, useState } from 'react'; const LazyHomeScreen = lazy(() => import('@/ui/home/home-screen')); export default function HomeTab() { const [webViewFailed, setWebViewFailed] = useState(false); + const { markFailed } = useHomeWebViewPreloadContext(); + + const handleWebViewError = useCallback(() => { + markFailed(); + setWebViewFailed(true); + }, [markFailed]); if (webViewFailed) { return ( @@ -14,5 +21,5 @@ export default function HomeTab() { ); } - return setWebViewFailed(true)} />; + return ; } diff --git a/app/_layout.tsx b/app/_layout.tsx index 8f3cbb4..f6a5b2d 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,5 +1,5 @@ import { DefaultTheme, ThemeProvider } from '@react-navigation/native'; -import { Stack } from 'expo-router'; +import { Stack, usePathname } from 'expo-router'; import * as SplashScreen from 'expo-splash-screen'; import { StatusBar } from 'expo-status-bar'; import { getTrackingPermissionsAsync, requestTrackingPermissionsAsync } from 'expo-tracking-transparency'; @@ -12,6 +12,10 @@ import { SafeAreaProvider } from 'react-native-safe-area-context'; import { BootstrapErrorDialog } from '@/components/bootstrap-error-dialog'; import { CustomSplashScreen } from '@/components/custom-splash-screen'; import { ForceUpdateDialog } from '@/components/force-update-dialog'; +import { + HomeWebViewPreloadProvider, + useHomeWebViewPreloadContext, +} from '@/contexts/home-webview-preload-context'; import { MixpanelProvider } from '@/contexts/mixpanel-context'; import { SubscribedClubsProvider } from '@/contexts/subscribed-clubs-context'; import { useFcm } from '@/hooks/use-fcm'; @@ -36,6 +40,15 @@ type BootstrapStatus = 'idle' | 'running' | 'success' | 'failed'; const EMPTY_SUBSCRIBED_CLUB_IDS: string[] = []; export default function RootLayout() { + return ( + + + + ); +} + +function RootLayoutContent() { + const pathname = usePathname(); const [showSplash, setShowSplash] = useState(true); const [forceUpdateRequired, setForceUpdateRequired] = useState(false); const [forceUpdateChecked, setForceUpdateChecked] = useState(false); @@ -44,9 +57,15 @@ export default function RootLayout() { const [bootstrapResult, setBootstrapResult] = useState(null); const bootstrapStatusRef = useRef('idle'); const nativeSplashHiddenRef = useRef(false); + const { isSettled: homeWebViewPreloadSettled, status: homeWebViewPreloadStatus } = + useHomeWebViewPreloadContext(); const bootstrapSucceeded = bootstrapStatus === 'success'; - const shouldBlockSplash = forceUpdateRequired || !bootstrapSucceeded; + const shouldWaitForHomeWebView = pathname === '/'; + const shouldBlockSplash = + forceUpdateRequired || + !bootstrapSucceeded || + (shouldWaitForHomeWebView && !homeWebViewPreloadSettled); // 강제 업데이트가 필요한 경우(또는 체크 전)에는 FCM 권한 프롬프트/핸들러 설정이 뜨지 않도록 비활성화 useFcm(forceUpdateChecked && !forceUpdateRequired && bootstrapSucceeded); @@ -181,11 +200,22 @@ export default function RootLayout() { customSplashDismissed: Date.now(), }); if (shouldBlockSplash) { - console.log('⛔️ 스플래시 유지:', { forceUpdateRequired, bootstrapStatus }); + console.log('⛔️ 스플래시 유지:', { + forceUpdateRequired, + bootstrapStatus, + pathname, + homeWebViewPreloadStatus, + }); return; } setShowSplash(false); - }, [forceUpdateRequired, bootstrapStatus, shouldBlockSplash]); + }, [ + forceUpdateRequired, + bootstrapStatus, + pathname, + homeWebViewPreloadStatus, + shouldBlockSplash, + ]); const handleRetryBootstrap = useCallback(async () => { if (bootstrapStatus === 'running') { @@ -205,6 +235,8 @@ export default function RootLayout() { showSplash, bootstrapStatus, forceUpdateRequired, + pathname, + homeWebViewPreloadStatus, }); } diff --git a/components/custom-splash-screen.tsx b/components/custom-splash-screen.tsx index 28f63a3..d7c4eab 100644 --- a/components/custom-splash-screen.tsx +++ b/components/custom-splash-screen.tsx @@ -29,7 +29,6 @@ interface CustomSplashScreenProps { type IntroAnimationStep = 'logoOpacity' | 'logoScale' | 'textOpacity' | 'textTranslateY'; const INTRO_ANIMATION_STEP_COUNT = 4; -const MIN_SPLASH_VISIBLE_DURATION_MS = 700; const SPLASH_FADE_OUT_DURATION_MS = 300; export function CustomSplashScreen({ onFinish, isReady, blockFinish = false }: CustomSplashScreenProps) { @@ -41,7 +40,6 @@ export function CustomSplashScreen({ onFinish, isReady, blockFinish = false }: C const [introCompleted, setIntroCompleted] = useState(false); const hasAnimatedOnce = useRef(false); const hasStartedFadeOut = useRef(false); - const animationStartedAt = useRef(Date.now()); const completedIntroAnimationSteps = useRef(new Set()); const markIntroAnimationStepComplete = useCallback((step: IntroAnimationStep) => { @@ -56,24 +54,19 @@ export function CustomSplashScreen({ onFinish, isReady, blockFinish = false }: C } }, []); - const triggerFadeOut = useCallback((delayMs: number) => { - fadeOutOpacity.value = withDelay( - delayMs, - withTiming( - 0, - { duration: SPLASH_FADE_OUT_DURATION_MS, easing: Easing.in(Easing.ease) }, - (finished) => { - if (finished) { - runOnJS(onFinish)(); - } + const triggerFadeOut = useCallback(() => { + fadeOutOpacity.value = withTiming( + 0, + { duration: SPLASH_FADE_OUT_DURATION_MS, easing: Easing.in(Easing.ease) }, + (finished) => { + if (finished) { + runOnJS(onFinish)(); } - ) + } ); }, [fadeOutOpacity, onFinish]); const startAnimation = useCallback(() => { - animationStartedAt.current = Date.now(); - logoOpacity.value = withTiming( 1, { @@ -154,9 +147,7 @@ export function CustomSplashScreen({ onFinish, isReady, blockFinish = false }: C } hasStartedFadeOut.current = true; - const elapsedMs = Date.now() - animationStartedAt.current; - const remainingMinimumMs = Math.max(0, MIN_SPLASH_VISIBLE_DURATION_MS - elapsedMs); - triggerFadeOut(remainingMinimumMs); + triggerFadeOut(); }, [isReady, introCompleted, blockFinish, fadeOutOpacity, triggerFadeOut]); const logoAnimatedStyle = useAnimatedStyle(() => ({ diff --git a/contexts/home-webview-preload-context.tsx b/contexts/home-webview-preload-context.tsx new file mode 100644 index 0000000..d6cc95c --- /dev/null +++ b/contexts/home-webview-preload-context.tsx @@ -0,0 +1,71 @@ +import React, { createContext, useCallback, useContext, useMemo, useState } from 'react'; + +export type HomeWebViewPreloadStatus = 'idle' | 'loading' | 'ready' | 'failed'; + +interface HomeWebViewPreloadContextType { + status: HomeWebViewPreloadStatus; + isSettled: boolean; + markLoading: () => void; + markReady: () => void; + markFailed: () => void; + reset: () => void; +} + +const HomeWebViewPreloadContext = createContext( + undefined, +); + +interface HomeWebViewPreloadProviderProps { + children: React.ReactNode; +} + +export function HomeWebViewPreloadProvider({ + children, +}: HomeWebViewPreloadProviderProps) { + const [status, setStatus] = useState('idle'); + + const markLoading = useCallback(() => { + setStatus((currentStatus) => (currentStatus === 'ready' ? currentStatus : 'loading')); + }, []); + + const markReady = useCallback(() => { + setStatus('ready'); + }, []); + + const markFailed = useCallback(() => { + setStatus('failed'); + }, []); + + const reset = useCallback(() => { + setStatus('idle'); + }, []); + + const value = useMemo( + () => ({ + status, + isSettled: status === 'ready' || status === 'failed', + markLoading, + markReady, + markFailed, + reset, + }), + [markFailed, markLoading, markReady, reset, status], + ); + + return ( + + {children} + + ); +} + +export function useHomeWebViewPreloadContext(): HomeWebViewPreloadContextType { + const context = useContext(HomeWebViewPreloadContext); + if (!context) { + throw new Error( + 'useHomeWebViewPreloadContext must be used within HomeWebViewPreloadProvider', + ); + } + + return context; +} diff --git a/contexts/index.ts b/contexts/index.ts index 632cfc4..04f6fa6 100644 --- a/contexts/index.ts +++ b/contexts/index.ts @@ -3,5 +3,5 @@ */ export * from './mixpanel-context'; +export * from './home-webview-preload-context'; export * from './subscribed-clubs-context'; - diff --git a/ios/app.xcodeproj/project.pbxproj b/ios/app.xcodeproj/project.pbxproj index 871139c..d5e513c 100644 --- a/ios/app.xcodeproj/project.pbxproj +++ b/ios/app.xcodeproj/project.pbxproj @@ -417,7 +417,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = app/app.entitlements; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 15; DEVELOPMENT_TEAM = 2QMK9GBWN6; ENABLE_BITCODE = NO; GCC_PREPROCESSOR_DEFINITIONS = ( @@ -454,7 +454,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = app/app.entitlements; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 15; DEVELOPMENT_TEAM = 2QMK9GBWN6; INFOPLIST_FILE = app/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; diff --git a/ios/app/Info.plist b/ios/app/Info.plist index 7f1ed91..aed8a97 100644 --- a/ios/app/Info.plist +++ b/ios/app/Info.plist @@ -39,7 +39,7 @@ CFBundleVersion - 14 + 15 LSMinimumSystemVersion 12.0 LSRequiresIPhoneOS diff --git a/ui/home/home-webview-screen.tsx b/ui/home/home-webview-screen.tsx index 587a2be..9872e68 100644 --- a/ui/home/home-webview-screen.tsx +++ b/ui/home/home-webview-screen.tsx @@ -1,3 +1,4 @@ +import { useHomeWebViewPreloadContext } from '@/contexts/home-webview-preload-context'; import { useMixpanelContext } from '@/contexts/mixpanel-context'; import { useSubscribedClubsContext } from '@/contexts/subscribed-clubs-context'; import { appendSessionId, getWebViewUserAgent } from '@/utils/webview'; @@ -20,8 +21,10 @@ export function HomeWebViewScreen({ onError }: HomeWebViewScreenProps) { const insets = useSafeAreaInsets(); const router = useRouter(); const webViewRef = useRef(null); + const loadFailedRef = useRef(false); const [loaded, setLoaded] = useState(false); + const { markLoading, markReady, markFailed } = useHomeWebViewPreloadContext(); const { sessionId, isLoading: sessionLoading } = useMixpanelContext(); const { subscribedClubIds, toggleSubscribe } = useSubscribedClubsContext(); @@ -29,9 +32,11 @@ export function HomeWebViewScreen({ onError }: HomeWebViewScreenProps) { useEffect(() => { if (url) { + loadFailedRef.current = false; + markLoading(); console.log('[StartupTiming] homeWebViewSourceReady', Date.now()); } - }, [url]); + }, [markLoading, url]); const sendMessage = useCallback((data: object) => { webViewRef.current?.injectJavaScript( @@ -97,8 +102,19 @@ export function HomeWebViewScreen({ onError }: HomeWebViewScreenProps) { const handleLoadEnd = useCallback(() => { console.log('[StartupTiming] homeWebViewLoadEnd', Date.now()); + if (loadFailedRef.current) { + return; + } + + markReady(); setLoaded(true); - }, []); + }, [markReady]); + + const handleError = useCallback(() => { + loadFailedRef.current = true; + markFailed(); + onError(); + }, [markFailed, onError]); return ( @@ -115,8 +131,8 @@ export function HomeWebViewScreen({ onError }: HomeWebViewScreenProps) { userAgent={USER_AGENT} onMessage={handleMessage} onLoadEnd={handleLoadEnd} - onError={onError} - onHttpError={onError} + onError={handleError} + onHttpError={handleError} javaScriptEnabled domStorageEnabled pullToRefreshEnabled