From 5cf580d1c9844a4c445441509e15c8d7a32339d8 Mon Sep 17 00:00:00 2001 From: sterdsterd Date: Sun, 3 May 2026 20:43:33 +0900 Subject: [PATCH 01/20] =?UTF-8?q?fix(native):=20FCM=20=ED=86=A0=ED=81=B0/?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EB=A1=9C=EB=93=9C=20console.log=20=5F=5FDEV?= =?UTF-8?q?=5F=5F=20=EA=B0=80=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 프로덕션 빌드에서 디바이스 FCM 토큰과 푸시 페이로드가 콘솔에 평문 출력되어 토큰 탈취 시 임의 푸시 알림 발송 위험. APNs/FCM 토큰 log, 등록 완료 log, foreground 메시지 페이로드 dump 모두 __DEV__ 가드로 감쌌다. 진단용 warn/error 는 유지. Refs: MAT-454 --- apps/native/src/hooks/useFcmToken.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/native/src/hooks/useFcmToken.ts b/apps/native/src/hooks/useFcmToken.ts index e8507711b..1e36a3569 100644 --- a/apps/native/src/hooks/useFcmToken.ts +++ b/apps/native/src/hooks/useFcmToken.ts @@ -55,7 +55,7 @@ const useFcmToken = () => { // 2. APNs 토큰 확인 (iOS 필수: 이게 없으면 FCM 토큰이 있어도 동작 안 함) if (Platform.OS === 'ios') { const apnsToken = await messaging().getAPNSToken(); - console.log('[FCM] APNs Token:', apnsToken); + if (__DEV__) console.log('[FCM] APNs Token:', apnsToken); if (!apnsToken) { console.error('[FCM] APNs Token is missing! Swizzling might be failed.'); // 여기서 APNs 토큰이 없다면 iOS 설정 문제(Capabilities 등)일 가능성이 큼 @@ -64,12 +64,12 @@ const useFcmToken = () => { // 3. FCM 토큰 가져오기 const token = await messaging().getToken(); - console.log('[FCM] Device FCM Token:', token); + if (__DEV__) console.log('[FCM] Device FCM Token:', token); if (token && !hasRegistered.current) { await postPushToken(token); hasRegistered.current = true; - console.log('[FCM] Token registered to server'); + if (__DEV__) console.log('[FCM] Token registered to server'); } } catch (error) { console.error('[FCM] Registration failed:', error); @@ -80,7 +80,9 @@ const useFcmToken = () => { // 4. 포그라운드 메시지 수신 (앱이 켜져 있을 때 로그 확인용) const unsubscribe = messaging().onMessage(async (remoteMessage) => { - console.log('[FCM] A new FCM message arrived!', JSON.stringify(remoteMessage)); + if (__DEV__) { + console.log('[FCM] A new FCM message arrived!', JSON.stringify(remoteMessage)); + } // 앱이 켜져 있을 때도 상단 알림을 띄우고 싶다면 expo-notifications 사용 await Notifications.scheduleNotificationAsync({ From 085c804b12e0ffe6573db59b740151b06666228d Mon Sep 17 00:00:00 2001 From: sterdsterd Date: Sun, 3 May 2026 20:44:59 +0900 Subject: [PATCH 02/20] =?UTF-8?q?fix(pointer-content-renderer):=20ContentW?= =?UTF-8?q?ebView=20origin/navigation=20=EC=B0=A8=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WebView 가 originWhitelist={['*']} 로 모든 origin 으로 navigation 을 허용해 오픈 리다이렉트에 취약. 번들 HTML 외 외부 navigation 을 file:// / about:blank / data: 만 허용하도록 좁히고, 추가로 onShouldStartLoadWithRequest 에서 블록리스트 외 요청을 명시적으로 차단해 defense-in-depth 적용. dev 빌드는 localhost 디버그 자산도 허용. CSS/JS subresource (jsdelivr KaTeX) 는 originWhitelist 의 적용 대상이 아니므로 정상 로드 유지. Refs: MAT-455 --- .../src/native/ContentWebView.tsx | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/pointer-content-renderer/src/native/ContentWebView.tsx b/packages/pointer-content-renderer/src/native/ContentWebView.tsx index 473335ffe..7349949c4 100644 --- a/packages/pointer-content-renderer/src/native/ContentWebView.tsx +++ b/packages/pointer-content-renderer/src/native/ContentWebView.tsx @@ -1,13 +1,34 @@ import { forwardRef, useImperativeHandle, useState } from 'react'; import { View, ActivityIndicator } from 'react-native'; import WebView from 'react-native-webview'; -import type { WebViewSource } from 'react-native-webview/lib/WebViewTypes'; +import type { + WebViewSource, + ShouldStartLoadRequest, +} from 'react-native-webview/lib/WebViewTypes'; import type { ViewStyle, StyleProp, ImageRequireSource } from 'react-native'; import type { RNToWebViewMessage, UserAnswer, ContentMode } from '../types'; import { useContentBridge, type AnswerEventPayload } from './useContentBridge'; +// 번들된 정적 HTML 만 로드. 외부 origin 으로의 navigation 은 모두 차단. +// (CSS/JS subresource 는 originWhitelist 적용 대상이 아니므로 jsdelivr CDN 은 정상 로드됨.) +const ALLOWED_ORIGIN_WHITELIST = ['file://', 'about:blank']; + +const shouldAllowRequest = (request: ShouldStartLoadRequest): boolean => { + const url = request.url; + if (url.startsWith('file://')) return true; + if (url.startsWith('about:blank')) return true; + // RN WebView 가 내부적으로 사용하는 data: URL (iOS injectedJavaScript 등) + if (url.startsWith('data:')) return true; + // dev 빌드의 Metro/localhost 자산 (예: source map) + if (__DEV__ && (url.startsWith('http://localhost') || url.startsWith('http://127.0.0.1'))) { + return true; + } + if (__DEV__) console.warn('[ContentWebView] blocked navigation:', url); + return false; +}; + /** * Accepts: * - `number` (Metro asset id from `require('...')`) — runtime WebView resolves via resolveAssetSource @@ -109,7 +130,9 @@ export const ContentWebView = forwardRef {isLoading && ( Date: Sun, 3 May 2026 20:45:40 +0900 Subject: [PATCH 03/20] =?UTF-8?q?chore(pointer-content-renderer):=20KaTeX/?= =?UTF-8?q?kopubbatang=20CDN=20SRI=20hash=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit jsdelivr CDN 으로 가져오는 katex CSS/JS 와 font-kopub CSS 가 변조 되어도 탐지 불가능했던 무결성 결함. 실제 자원에서 산출한 SHA-384 integrity 와 crossorigin="anonymous" 추가. KaTeX 0.16.9 / font-kopub 1.0 핀 버전 그대로 유지하므로 hash 갱신 트리거는 버전 bump 시점에 한정. Refs: MAT-456 --- .../pointer-content-renderer/src/web/index.html | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/pointer-content-renderer/src/web/index.html b/packages/pointer-content-renderer/src/web/index.html index 2f33998d5..e9a554f8f 100644 --- a/packages/pointer-content-renderer/src/web/index.html +++ b/packages/pointer-content-renderer/src/web/index.html @@ -3,9 +3,19 @@ - - - + + +
From b28b356da7b05bd03b49966df6a816608fb75528 Mon Sep 17 00:00:00 2001 From: sterdsterd Date: Sun, 3 May 2026 20:46:14 +0900 Subject: [PATCH 04/20] =?UTF-8?q?fix(native):=20useSocialLoginCallback=20O?= =?UTF-8?q?Auth=20URL=20scheme=20=ED=95=B8=EB=93=A4=EB=9F=AC=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR-4 에서 useNativeOAuth 로 인앱 SDK 기반 소셜 로그인이 도입되며 url scheme 콜백 (exp:// / pointer://auth/callback) 흐름은 더 이상 도달 불가. 그대로 두면 getInitialURL().then 미catch 의 unhandled rejection 위험과 addEventListener 와의 race condition 만 남는다. hook 파일 삭제 + RootNavigator 호출 제거 + hooks barrel export 정리. 앱 내부 라우팅용 url scheme 처리 (예: 알림 딥링크) 는 useDeepLinkHandler 에서 별도 담당하므로 영향 없음. Refs: MAT-457 --- apps/native/src/hooks/index.ts | 1 - .../src/hooks/useSocialLoginCallback.ts | 71 ------------------- apps/native/src/navigation/RootNavigator.tsx | 3 - 3 files changed, 75 deletions(-) delete mode 100644 apps/native/src/hooks/useSocialLoginCallback.ts diff --git a/apps/native/src/hooks/index.ts b/apps/native/src/hooks/index.ts index c4b7dc85d..c26164334 100644 --- a/apps/native/src/hooks/index.ts +++ b/apps/native/src/hooks/index.ts @@ -3,5 +3,4 @@ export { default as useDeepLinkHandler, handleDeepLink } from './useDeepLinkHand export { default as useFcmToken } from './useFcmToken'; export { default as useInvalidateStudyData } from './useInvalidateStudyData'; export { default as useLoadAssets } from './useLoadAssets'; -export { default as useSocialLoginCallback } from './useSocialLoginCallback'; export { default as useInvalidateAll } from './useInvalidateAll'; diff --git a/apps/native/src/hooks/useSocialLoginCallback.ts b/apps/native/src/hooks/useSocialLoginCallback.ts deleted file mode 100644 index 733d77b33..000000000 --- a/apps/native/src/hooks/useSocialLoginCallback.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { useEffect } from 'react'; -import * as Linking from 'expo-linking'; -import { Platform } from 'react-native'; - -import { setAccessToken, setRefreshToken } from '@utils'; -import { useAuthStore } from '@stores'; -import { useOnboardingStore } from '@features/student/onboarding/store/useOnboardingStore'; - -const shouldStartOnboarding = (flag?: string | string[] | null): boolean => { - if (flag === undefined || flag === null) { - return false; - } - - if (Array.isArray(flag)) { - return flag.some((value) => shouldStartOnboarding(value)); - } - - return flag.toLowerCase() === 'true'; -}; - -const useSocialLoginCallback = () => { - const { setSessionStatus, setRole } = useAuthStore(); - const startOnboarding = useOnboardingStore((state) => state.start); - const completeOnboarding = useOnboardingStore((state) => state.complete); - - useEffect(() => { - const handleUrl = (event: { url: string }) => { - const { url } = event; - - const parsed = Linking.parse(url); - - const isExpoGoScheme = parsed.scheme === 'exp' && parsed.path?.includes('auth/callback'); - const isPointerScheme = parsed.scheme === 'pointer' && parsed.path?.includes('auth/callback'); - const isWebPath = Platform.OS === 'web' && parsed.path?.includes('/auth/callback'); - - if (!isExpoGoScheme && !isPointerScheme && !isWebPath) return; - - const { success, isFirstLogin, accessToken, refreshToken } = parsed.queryParams ?? {}; - - if (!success || !accessToken) { - setSessionStatus('unauthenticated'); - return; - } - - setAccessToken(String(accessToken)); - if (refreshToken) setRefreshToken(String(refreshToken)); - - setRole('student'); - setSessionStatus('authenticated'); - - // isFirstLogin인 경우에만 온보딩, 아니면 바로 메인 홈으로 - if (shouldStartOnboarding(isFirstLogin)) { - startOnboarding(); - } else { - completeOnboarding(); - } - }; - - const sub = Linking.addEventListener('url', handleUrl); - - Linking.getInitialURL().then((url) => { - if (url) handleUrl({ url }); - }); - - return () => { - sub.remove(); - }; - }, [setSessionStatus, setRole, startOnboarding, completeOnboarding]); -}; - -export default useSocialLoginCallback; diff --git a/apps/native/src/navigation/RootNavigator.tsx b/apps/native/src/navigation/RootNavigator.tsx index cbca1bfbb..4cce3f567 100644 --- a/apps/native/src/navigation/RootNavigator.tsx +++ b/apps/native/src/navigation/RootNavigator.tsx @@ -7,7 +7,6 @@ import StudentNavigator from '@navigation/student/StudentNavigator'; import AuthNavigator from '@navigation/auth/AuthNavigator'; import { useAuthStore } from '@stores'; import { LoadingScreen } from '@components/common'; -import { useSocialLoginCallback } from '@hooks'; import { useSignupStore } from '@features/auth/signup/store/useSignupStore'; import { useOnboardingStore } from '@features/student/onboarding/store/useOnboardingStore'; @@ -25,8 +24,6 @@ const RootNavigator = () => { const step1Completed = useSignupStore((s) => s.step1Completed); const onboardingStatus = useOnboardingStore((s) => s.status); - useSocialLoginCallback(); - const getActiveScreen = () => { if ( sessionStatus === 'unknown' || From 84dbe95d546d73703f5e175ec65cf7bd6de9887b Mon Sep 17 00:00:00 2001 From: sterdsterd Date: Sun, 3 May 2026 20:47:52 +0900 Subject: [PATCH 05/20] =?UTF-8?q?fix(native):=20useDeepLinkHandler=20?= =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EA=B8=B0=EB=B0=98=20=EB=8C=80?= =?UTF-8?q?=EA=B8=B0=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 저사양 기기에서 콜드 스타트가 3s 를 넘으면 busy-wait polling 이 timeout 되어 알림 딥링크가 무시되던 문제. 100ms 간격 polling 을 NavigationContainer onReady fan-out 에 연결된 module-level subscriber 로 교체하고, 기본 timeout 을 30s 로 확장. navigationRef 자체의 addListener('state', ...) 는 attach 전에 호출하면 throw 하므로 App 의 NavigationContainer onReady prop 을 단일 ready 신호 지점으로 사용 (handleNavigationReady). Refs: MAT-458 --- apps/native/App.tsx | 7 ++- apps/native/src/hooks/useDeepLinkHandler.ts | 15 ++----- apps/native/src/services/navigation/index.ts | 7 ++- .../src/services/navigation/navigationRef.ts | 43 +++++++++++++++++++ 4 files changed, 58 insertions(+), 14 deletions(-) diff --git a/apps/native/App.tsx b/apps/native/App.tsx index 2b7ee52a3..69a056c95 100644 --- a/apps/native/App.tsx +++ b/apps/native/App.tsx @@ -17,7 +17,7 @@ import { toastConfig } from '@/features/student/scrap/components/Notification/To import { PointingFeedbackQueueWiring } from '@/features/student/problem/services/PointingFeedbackQueueWiring'; import { env } from '@utils'; import { initializeKakaoSDK } from '@react-native-kakao/core'; -import { navigationRef } from '@/services/navigation'; +import { navigationRef, handleNavigationReady } from '@/services/navigation'; initializeKakaoSDK(env.kakaoNativeAppKey); @@ -53,7 +53,10 @@ export default function App() { {isReady && ( - + diff --git a/apps/native/src/hooks/useDeepLinkHandler.ts b/apps/native/src/hooks/useDeepLinkHandler.ts index 8228cdc5c..d18722e04 100644 --- a/apps/native/src/hooks/useDeepLinkHandler.ts +++ b/apps/native/src/hooks/useDeepLinkHandler.ts @@ -4,7 +4,7 @@ import messaging, { type FirebaseMessagingTypes } from '@react-native-firebase/m import * as Notifications from 'expo-notifications'; import { CommonActions } from '@react-navigation/native'; -import { navigationRef, isNavigationReady } from '@/services/navigation'; +import { navigationRef, waitForNavigationReady } from '@/services/navigation'; import { parseDeepLinkUrl, isValidDeepLink } from '@/utils/deepLink'; import { getPublishDetailById } from '@/apis/controller/student/study'; import { useProblemSessionStore, getInitialScreenForPhase } from '@/stores'; @@ -32,16 +32,9 @@ const handleDeepLink = async (url: string | undefined | null) => { return false; } - // 네비게이션이 준비될 때까지 대기 (최대 3초) - const waitForNavigation = async (timeout = 3000): Promise => { - const startTime = Date.now(); - while (!isNavigationReady() && Date.now() - startTime < timeout) { - await new Promise((resolve) => setTimeout(resolve, 100)); - } - return isNavigationReady(); - }; - - const isReady = await waitForNavigation(); + // navigationRef 가 ready 가 될 때까지 이벤트 기반으로 대기 (busy-wait 제거). + // 저사양 기기의 콜드 스타트 (>3s) 에서도 딥링크가 손실되지 않도록 30s 까지 허용. + const isReady = await waitForNavigationReady(); if (!isReady) { console.warn('[DeepLink] Navigation not ready, cannot handle deep link'); return false; diff --git a/apps/native/src/services/navigation/index.ts b/apps/native/src/services/navigation/index.ts index 438d157d3..cf43ce556 100644 --- a/apps/native/src/services/navigation/index.ts +++ b/apps/native/src/services/navigation/index.ts @@ -1 +1,6 @@ -export { navigationRef, isNavigationReady } from './navigationRef'; +export { + navigationRef, + isNavigationReady, + waitForNavigationReady, + handleNavigationReady, +} from './navigationRef'; diff --git a/apps/native/src/services/navigation/navigationRef.ts b/apps/native/src/services/navigation/navigationRef.ts index 597b4dfd1..6a9da9ffe 100644 --- a/apps/native/src/services/navigation/navigationRef.ts +++ b/apps/native/src/services/navigation/navigationRef.ts @@ -12,3 +12,46 @@ export const navigationRef = createNavigationContainerRef(); * 네비게이션이 준비되었는지 확인 */ export const isNavigationReady = () => navigationRef.isReady(); + +const readySubscribers = new Set<() => void>(); + +/** + * NavigationContainer 의 onReady prop 에 연결한다 (App.tsx). + * navigationRef 가 attach 된 시점에 호출되어 대기 중인 subscriber 들을 깨운다. + */ +export const handleNavigationReady = () => { + for (const cb of readySubscribers) cb(); + readySubscribers.clear(); +}; + +/** + * navigationRef 가 준비될 때까지 이벤트 기반으로 대기. + * + * - 이미 ready 상태면 즉시 resolve(true). + * - 그렇지 않으면 handleNavigationReady 가 호출될 때까지 module-level subscriber + * 에 등록 (busy-wait polling 제거). + * - timeoutMs 내에 ready 가 안 되면 resolve(false). 기본 30s — 저사양 기기의 + * 콜드 스타트 (>3s) 에서도 딥링크가 손실되지 않도록 여유 확보. + * + * NavigationContainer 가 mount 되기 전에 navigationRef.addListener 를 직접 + * 호출하면 throw 하므로, App 레벨 onReady prop 을 단일 fan-out 지점으로 사용. + */ +export const waitForNavigationReady = (timeoutMs = 30_000): Promise => { + if (navigationRef.isReady()) return Promise.resolve(true); + + return new Promise((resolve) => { + let settled = false; + const handler = () => finish(navigationRef.isReady()); + + const finish = (ready: boolean) => { + if (settled) return; + settled = true; + clearTimeout(timer); + readySubscribers.delete(handler); + resolve(ready); + }; + + readySubscribers.add(handler); + const timer = setTimeout(() => finish(false), timeoutMs); + }); +}; From fc57af28e920914ea7a8729e1f4d5a575a9fb7f0 Mon Sep 17 00:00:00 2001 From: sterdsterd Date: Sun, 3 May 2026 20:48:37 +0900 Subject: [PATCH 06/20] =?UTF-8?q?fix(native):=20useFcmToken=20=EB=AF=B8?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=20listener=20ref=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 선언만 되고 어떤 subscription 도 할당되지 않은 채 cleanup 도 없는 notificationListener / responseListener ref 제거. expo-notifications 의 알림 응답 처리는 useDeepLinkHandler 가 담당하고 있어 중복으로 ref 를 채울 필요가 없다. Refs: MAT-459 --- apps/native/src/hooks/useFcmToken.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/native/src/hooks/useFcmToken.ts b/apps/native/src/hooks/useFcmToken.ts index 1e36a3569..80ddbafb5 100644 --- a/apps/native/src/hooks/useFcmToken.ts +++ b/apps/native/src/hooks/useFcmToken.ts @@ -25,8 +25,6 @@ Notifications.setNotificationHandler({ */ const useFcmToken = () => { const hasRegistered = useRef(false); - const notificationListener = useRef(null); - const responseListener = useRef(null); useEffect(() => { // 웹에서는 FCM을 사용하지 않음 From 54e7a62e1e6f92cc8d8f70e4a629147db6842195 Mon Sep 17 00:00:00 2001 From: sterdsterd Date: Sun, 3 May 2026 20:48:57 +0900 Subject: [PATCH 07/20] =?UTF-8?q?fix(native):=20LoginScreen=20=EC=86=8C?= =?UTF-8?q?=EC=85=9C=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=ED=95=B8=EB=93=A4?= =?UTF-8?q?=EB=9F=AC=20unhandled=20rejection=20=EC=B0=A8=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handleSocialButtonPress 가 async 로 선언돼 Pressable onPress 에 Promise 를 반환했고, signInWithProvider 의 reject 가 unhandled rejection 으로 새어 나갈 수 있었다. 핸들러를 sync 로 바꾸고 void signInWithProvider(provider) 형태로 명시. 사용자 가시 에러는 useNativeOAuth 의 error state 로 이미 surface 되므로 silent fallback 우려 없음. Refs: MAT-460 --- apps/native/src/features/auth/login/screens/LoginScreen.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/native/src/features/auth/login/screens/LoginScreen.tsx b/apps/native/src/features/auth/login/screens/LoginScreen.tsx index 937b3ea35..04afc1cd2 100644 --- a/apps/native/src/features/auth/login/screens/LoginScreen.tsx +++ b/apps/native/src/features/auth/login/screens/LoginScreen.tsx @@ -18,8 +18,9 @@ const LoginScreen = () => { const navigation = useNavigation(); const { isLoading, loadingProvider, error, signInWithProvider } = useNativeOAuth(); - const handleSocialButtonPress = async (provider: OAuthProvider) => { - await signInWithProvider(provider); + const handleSocialButtonPress = (provider: OAuthProvider) => { + // useNativeOAuth 의 error state 로 이미 사용자에게 노출되므로 여기서는 void. + void signInWithProvider(provider); }; const handleEmailButtonPress = () => { From cf8bea0d51da6df2ee206e4eebf9578ebab212d3 Mon Sep 17 00:00:00 2001 From: sterdsterd Date: Sun, 3 May 2026 20:49:26 +0900 Subject: [PATCH 08/20] =?UTF-8?q?fix(native):=20serializeJSONToHTML=20JSON?= =?UTF-8?q?.parse=20=ED=8E=98=EC=9D=BC=EC=84=B8=EC=9D=B4=ED=94=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit string 입력 분기에서 JSON.parse 가 try/catch 없이 호출돼 malformed 콘텐츠가 들어오면 호출자 (ProblemViewer) 까지 그대로 throw 가 전파되어 화면 전체가 크래시되던 결함. parse 단계만 try/catch 로 감싸고 발생 detail 을 포함한 새 Error 로 재던져 디버깅을 쉽게 함. pointer-content-renderer 의 동명 함수 (MAT-451) 와 동일 패턴. Refs: MAT-461 --- .../student/problem/utils/serializeJSONToHTML.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/native/src/features/student/problem/utils/serializeJSONToHTML.ts b/apps/native/src/features/student/problem/utils/serializeJSONToHTML.ts index e471a2031..18935fd7f 100644 --- a/apps/native/src/features/student/problem/utils/serializeJSONToHTML.ts +++ b/apps/native/src/features/student/problem/utils/serializeJSONToHTML.ts @@ -267,8 +267,16 @@ function serializeNode(node: JSONNode): string { export function serializeJSONToHTML(doc: JSONNode | string): string { let json: JSONNode; - if (typeof doc === 'string') json = JSON.parse(doc); - else json = doc; + if (typeof doc === 'string') { + try { + json = JSON.parse(doc); + } catch (e) { + const detail = e instanceof Error ? e.message : String(e); + throw new Error(`serializeJSONToHTML: invalid JSON input — ${detail}`); + } + } else { + json = doc; + } if (json.type !== 'doc') { throw new Error('root node must be type=doc'); From eae6783d812580ad602df9849520c114eda6889e Mon Sep 17 00:00:00 2001 From: sterdsterd Date: Sun, 3 May 2026 20:49:40 +0900 Subject: [PATCH 09/20] =?UTF-8?q?fix(native):=20PointingScreen=20render=20?= =?UTF-8?q?body=20=EC=82=AC=EC=9D=B4=EB=93=9C=EC=9D=B4=ED=8E=99=ED=8A=B8?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 빈 pointings 배열을 감지하던 console.warn 이 render 함수 내부에 있어 React pure render 원칙을 위반하고 매 렌더마다 동일한 경고가 중복 출력되는 노이즈 결함. 진단 로그가 실질적으로 가치를 제공하지 못해 단순 삭제 (Linear 와 사용자 결정). Refs: MAT-462 --- .../src/features/student/problem/screens/PointingScreen.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/native/src/features/student/problem/screens/PointingScreen.tsx b/apps/native/src/features/student/problem/screens/PointingScreen.tsx index 5613773ad..c5c23cb5e 100644 --- a/apps/native/src/features/student/problem/screens/PointingScreen.tsx +++ b/apps/native/src/features/student/problem/screens/PointingScreen.tsx @@ -49,8 +49,6 @@ const PointingScreen = ({ const pointings = useProblemSessionStore(useShallow(selectPointingsForPointing)); - if (pointings.length === 0) console.warn('[PointingScreen] empty pointings array'); - const { advanceMessage, advanceButtonLabel } = useMemo(() => { if (phase === 'MAIN_POINTINGS') { return { From 67f5085579f1024ec6205e74b9d2ca8e973f40be Mon Sep 17 00:00:00 2001 From: sterdsterd Date: Sun, 3 May 2026 20:52:08 +0900 Subject: [PATCH 10/20] =?UTF-8?q?chore(native):=20useDeepLinkHandler=20?= =?UTF-8?q?=EB=AF=B8=EC=82=AC=EC=9A=A9=20useCallback=20import=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR-7 lint pass 마무리. 사전 존재하던 unused import 제거. --- apps/native/src/hooks/useDeepLinkHandler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/native/src/hooks/useDeepLinkHandler.ts b/apps/native/src/hooks/useDeepLinkHandler.ts index d18722e04..e64374589 100644 --- a/apps/native/src/hooks/useDeepLinkHandler.ts +++ b/apps/native/src/hooks/useDeepLinkHandler.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef, useCallback } from 'react'; +import { useEffect, useRef } from 'react'; import { Platform, Alert, Dimensions } from 'react-native'; import messaging, { type FirebaseMessagingTypes } from '@react-native-firebase/messaging'; import * as Notifications from 'expo-notifications'; From e5fe03a37dc92a1738578706a909cf9bb5a414cb Mon Sep 17 00:00:00 2001 From: sterdsterd Date: Sun, 3 May 2026 20:55:04 +0900 Subject: [PATCH 11/20] =?UTF-8?q?fix(native):=20handleNavigationReady=20su?= =?UTF-8?q?bscriber=20clear=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fast Refresh / NavigationContainer 재mount 시 onReady 가 재발화될 수 있어 clear() 를 하면 두 번째 mount 사이에 등록된 subscriber 가 손실된다. finish() 안에서 자기 자신을 delete 하므로 leak 없이 정리됨. OMC code-reviewer MEDIUM 후속 픽스. --- apps/native/src/services/navigation/navigationRef.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/native/src/services/navigation/navigationRef.ts b/apps/native/src/services/navigation/navigationRef.ts index 6a9da9ffe..5255ed674 100644 --- a/apps/native/src/services/navigation/navigationRef.ts +++ b/apps/native/src/services/navigation/navigationRef.ts @@ -20,8 +20,11 @@ const readySubscribers = new Set<() => void>(); * navigationRef 가 attach 된 시점에 호출되어 대기 중인 subscriber 들을 깨운다. */ export const handleNavigationReady = () => { - for (const cb of readySubscribers) cb(); - readySubscribers.clear(); + // snapshot 후 호출 — 각 handler 가 finish() 안에서 자기 자신을 delete 하므로 + // 반복 중 mutation 을 피하면서도 leak 없이 정리된다. clear() 를 하지 않는 이유는 + // Fast Refresh / container 재mount 시 onReady 가 다시 발화될 수 있어, 그 사이 + // 등록된 subscriber 가 손실되지 않도록 함이다. + [...readySubscribers].forEach((cb) => cb()); }; /** From 0b7155e927b2fe9b592fcd241e94825c8fad4efa Mon Sep 17 00:00:00 2001 From: sterdsterd Date: Sun, 3 May 2026 21:00:16 +0900 Subject: [PATCH 12/20] =?UTF-8?q?style(pointer-content-renderer):=20Conten?= =?UTF-8?q?tWebView=20import=20=ED=95=9C=20=EC=A4=84=EB=A1=9C=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit prettier/prettier — multi-line type import 가 100자 width 안에 들어가므로 single-line 으로 강제됨. CI lint fail 픽스. --- .../pointer-content-renderer/src/native/ContentWebView.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/pointer-content-renderer/src/native/ContentWebView.tsx b/packages/pointer-content-renderer/src/native/ContentWebView.tsx index 7349949c4..0f939d8df 100644 --- a/packages/pointer-content-renderer/src/native/ContentWebView.tsx +++ b/packages/pointer-content-renderer/src/native/ContentWebView.tsx @@ -1,10 +1,7 @@ import { forwardRef, useImperativeHandle, useState } from 'react'; import { View, ActivityIndicator } from 'react-native'; import WebView from 'react-native-webview'; -import type { - WebViewSource, - ShouldStartLoadRequest, -} from 'react-native-webview/lib/WebViewTypes'; +import type { WebViewSource, ShouldStartLoadRequest } from 'react-native-webview/lib/WebViewTypes'; import type { ViewStyle, StyleProp, ImageRequireSource } from 'react-native'; import type { RNToWebViewMessage, UserAnswer, ContentMode } from '../types'; From ec1c1e4b830c97107132c4c9bf9b5e398d0337c2 Mon Sep 17 00:00:00 2001 From: sterdsterd Date: Sun, 3 May 2026 21:28:08 +0900 Subject: [PATCH 13/20] =?UTF-8?q?fix(pointer-content-renderer):=20dev=20?= =?UTF-8?q?=EB=AA=A8=EB=93=9C=EC=97=90=EC=84=9C=20Metro=20http=20=EC=9E=90?= =?UTF-8?q?=EC=82=B0=20=ED=97=88=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expo dev 빌드는 require('./*.html') 자산을 Metro bundler 가 http://:8081/... 로 서빙한다. originWhitelist 가 file:// 만 허용해 해당 URL 이 차단되었고, RN WebView 가 외부 핸들러로 fallback 하면서 Safari 가 열리고 컨텐츠가 보이지 않던 문제 수정. - __DEV__ 일 때 originWhitelist 에 http://, https:// 포함 - onShouldStartLoadWithRequest 의 dev 예외를 모든 host 로 확장 - 프로덕션 빌드는 file:// + about:blank 만 허용 (보안 가드 유지) --- .../src/native/ContentWebView.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/pointer-content-renderer/src/native/ContentWebView.tsx b/packages/pointer-content-renderer/src/native/ContentWebView.tsx index 0f939d8df..62e0ad4be 100644 --- a/packages/pointer-content-renderer/src/native/ContentWebView.tsx +++ b/packages/pointer-content-renderer/src/native/ContentWebView.tsx @@ -10,7 +10,13 @@ import { useContentBridge, type AnswerEventPayload } from './useContentBridge'; // 번들된 정적 HTML 만 로드. 외부 origin 으로의 navigation 은 모두 차단. // (CSS/JS subresource 는 originWhitelist 적용 대상이 아니므로 jsdelivr CDN 은 정상 로드됨.) -const ALLOWED_ORIGIN_WHITELIST = ['file://', 'about:blank']; +// +// dev 모드에서는 Metro bundler 가 require('./*.html') 자산을 +// `http://:8081/...` 로 서빙하므로 http/https 도 허용해야 한다. +// 프로덕션 번들에서는 file:// 만 허용. +const ALLOWED_ORIGIN_WHITELIST = __DEV__ + ? ['file://', 'about:blank', 'http://', 'https://'] + : ['file://', 'about:blank']; const shouldAllowRequest = (request: ShouldStartLoadRequest): boolean => { const url = request.url; @@ -18,8 +24,9 @@ const shouldAllowRequest = (request: ShouldStartLoadRequest): boolean => { if (url.startsWith('about:blank')) return true; // RN WebView 가 내부적으로 사용하는 data: URL (iOS injectedJavaScript 등) if (url.startsWith('data:')) return true; - // dev 빌드의 Metro/localhost 자산 (예: source map) - if (__DEV__ && (url.startsWith('http://localhost') || url.startsWith('http://127.0.0.1'))) { + // dev 빌드: Metro 가 어떤 host (LAN IP / localhost) 로 서빙되든 허용. + // 프로덕션에서는 file:// 만 통과되므로 보안 가드 유지. + if (__DEV__ && (url.startsWith('http://') || url.startsWith('https://'))) { return true; } if (__DEV__) console.warn('[ContentWebView] blocked navigation:', url); From 963d91a7345b0ab93a1ddf64b3cfce65f15b7377 Mon Sep 17 00:00:00 2001 From: sterdsterd Date: Sun, 3 May 2026 22:05:15 +0900 Subject: [PATCH 14/20] =?UTF-8?q?fix(pointer-content-renderer):=20ContentW?= =?UTF-8?q?ebView=20source=20=ED=83=80=EC=9E=85=20=EC=A2=81=ED=9E=98=20+?= =?UTF-8?q?=20data:=20=EC=B0=A8=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR-7 코드 리뷰 후속 픽스. 두 가지 우려 반영. 1. ContentWebViewHtmlSource 가 WebViewSource (외부 https uri 포함) 까지 허용해 API/실제 동작 (originWhitelist 가 file:// 만 허용) 이 불일치했다. 타입을 ImageRequireSource | { html: string } 으로 좁혀 의도와 일치시킴. 2. shouldAllowRequest 에서 data: URL 무조건 허용 → 임의 HTML/JS 실행 우회 경로가 됨. data: 분기 제거. RN WebView 의 injectedJavaScript / postMessage 는 navigation 이 아니라 영향 없음. --- .../src/native/ContentWebView.tsx | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/pointer-content-renderer/src/native/ContentWebView.tsx b/packages/pointer-content-renderer/src/native/ContentWebView.tsx index 62e0ad4be..a1f391f21 100644 --- a/packages/pointer-content-renderer/src/native/ContentWebView.tsx +++ b/packages/pointer-content-renderer/src/native/ContentWebView.tsx @@ -9,7 +9,7 @@ import type { RNToWebViewMessage, UserAnswer, ContentMode } from '../types'; import { useContentBridge, type AnswerEventPayload } from './useContentBridge'; // 번들된 정적 HTML 만 로드. 외부 origin 으로의 navigation 은 모두 차단. -// (CSS/JS subresource 는 originWhitelist 적용 대상이 아니므로 jsdelivr CDN 은 정상 로드됨.) +// (CSS/JS subresource 는 originWhitelist 적용 대상이 아니므로 jsdelivr KaTeX CDN 은 정상 로드됨.) // // dev 모드에서는 Metro bundler 가 require('./*.html') 자산을 // `http://:8081/...` 로 서빙하므로 http/https 도 허용해야 한다. @@ -22,10 +22,10 @@ const shouldAllowRequest = (request: ShouldStartLoadRequest): boolean => { const url = request.url; if (url.startsWith('file://')) return true; if (url.startsWith('about:blank')) return true; - // RN WebView 가 내부적으로 사용하는 data: URL (iOS injectedJavaScript 등) - if (url.startsWith('data:')) return true; // dev 빌드: Metro 가 어떤 host (LAN IP / localhost) 로 서빙되든 허용. // 프로덕션에서는 file:// 만 통과되므로 보안 가드 유지. + // data: navigation 은 의도적으로 차단 — 임의 HTML/JS 실행 우회 경로가 됨. + // (injectedJavaScript / postMessage 는 navigation 이 아니라 영향 없음.) if (__DEV__ && (url.startsWith('http://') || url.startsWith('https://'))) { return true; } @@ -34,11 +34,14 @@ const shouldAllowRequest = (request: ShouldStartLoadRequest): boolean => { }; /** - * Accepts: - * - `number` (Metro asset id from `require('...')`) — runtime WebView resolves via resolveAssetSource - * - `{ uri: string }` / `{ html: string }` — standard WebView sources + * 번들된 정적 HTML 자산 또는 inline HTML 만 지원한다. + * 외부 https URL ({ uri }) 은 의도적으로 받지 않는다 — `originWhitelist` 가 + * 프로덕션에서 file://, about:blank 만 허용하므로 외부 uri 를 넘기면 초기 로드가 차단된다. + * + * - `number` (Metro asset id from `require('@assets/webview/content.html')`) + * - `{ html: string }` — 테스트/스토리북용 inline HTML */ -export type ContentWebViewHtmlSource = WebViewSource | ImageRequireSource; +export type ContentWebViewHtmlSource = ImageRequireSource | { html: string }; /** * Arguments for {@link ContentWebViewHandle.sendBookmarkResult}. From 57ce344b76ba03c86592d594d78ea950106be42e Mon Sep 17 00:00:00 2001 From: sterdsterd Date: Sun, 3 May 2026 23:09:27 +0900 Subject: [PATCH 15/20] =?UTF-8?q?refactor(pointer-content-renderer):=20Web?= =?UTF-8?q?View=20navigation=20policy=20=EB=A5=BC=20hook=20=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=BC=EC=9B=90=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR-7 코드 리뷰 후속. originWhitelist 가 미스매치 시 외부 앱 (Linking) 으로 fallback 하는 RN WebView 동작 때문에, 이전 구현은 fail-open 경로가 있었다. - originWhitelist 를 ['*'] 로 두고 통과 layer 로만 사용 - onShouldStartLoadWithRequest 에서 deny-by-default 로 정책 강제 - file://, about:blank, dev Metro asset (port 8081 + /assets/*) 만 허용 - dev http(s) allow-all 도 함께 좁혀 외부 redirect 는 dev 에서도 차단됨 --- .../src/native/ContentWebView.tsx | 48 +++++++++++-------- 1 file changed, 29 insertions(+), 19 deletions(-) diff --git a/packages/pointer-content-renderer/src/native/ContentWebView.tsx b/packages/pointer-content-renderer/src/native/ContentWebView.tsx index a1f391f21..da66ef2e4 100644 --- a/packages/pointer-content-renderer/src/native/ContentWebView.tsx +++ b/packages/pointer-content-renderer/src/native/ContentWebView.tsx @@ -8,27 +8,36 @@ import type { RNToWebViewMessage, UserAnswer, ContentMode } from '../types'; import { useContentBridge, type AnswerEventPayload } from './useContentBridge'; -// 번들된 정적 HTML 만 로드. 외부 origin 으로의 navigation 은 모두 차단. -// (CSS/JS subresource 는 originWhitelist 적용 대상이 아니므로 jsdelivr KaTeX CDN 은 정상 로드됨.) -// -// dev 모드에서는 Metro bundler 가 require('./*.html') 자산을 -// `http://:8081/...` 로 서빙하므로 http/https 도 허용해야 한다. -// 프로덕션 번들에서는 file:// 만 허용. -const ALLOWED_ORIGIN_WHITELIST = __DEV__ - ? ['file://', 'about:blank', 'http://', 'https://'] - : ['file://', 'about:blank']; +// RN WebView 의 originWhitelist 미매칭 시 동작은 "차단" 이 아니라 외부 앱 +// (Linking) 으로의 fallback 이다. 따라서 originWhitelist 는 통과 layer 로 두고, +// 실제 navigation 정책은 onShouldStartLoadWithRequest 에서 deny-by-default 로 +// 강제한다. (CSS/JS subresource 는 originWhitelist 적용 대상이 아니므로 +// jsdelivr KaTeX CDN 은 두 layer 어느 쪽에서도 막히지 않고 정상 로드됨.) +const WEBVIEW_ORIGIN_WHITELIST = ['*']; + +// dev 빌드의 Metro bundler 자산 (`http://:8081/assets/...`) +// 만 좁게 허용. 외부 redirect 는 dev 에서도 차단된다. +const isMetroAssetUrl = (url: string): boolean => { + if (!__DEV__) return false; + try { + const parsed = new URL(url); + return ( + (parsed.protocol === 'http:' || parsed.protocol === 'https:') && + parsed.port === '8081' && + parsed.pathname.startsWith('/assets/') + ); + } catch { + return false; + } +}; const shouldAllowRequest = (request: ShouldStartLoadRequest): boolean => { - const url = request.url; + const { url } = request; + if (url.startsWith('file://')) return true; if (url.startsWith('about:blank')) return true; - // dev 빌드: Metro 가 어떤 host (LAN IP / localhost) 로 서빙되든 허용. - // 프로덕션에서는 file:// 만 통과되므로 보안 가드 유지. - // data: navigation 은 의도적으로 차단 — 임의 HTML/JS 실행 우회 경로가 됨. - // (injectedJavaScript / postMessage 는 navigation 이 아니라 영향 없음.) - if (__DEV__ && (url.startsWith('http://') || url.startsWith('https://'))) { - return true; - } + if (isMetroAssetUrl(url)) return true; + if (__DEV__) console.warn('[ContentWebView] blocked navigation:', url); return false; }; @@ -137,8 +146,9 @@ export const ContentWebView = forwardRef {isLoading && ( From b558ed09d5fc4ceefebe4344b76a146de376122d Mon Sep 17 00:00:00 2001 From: sterdsterd Date: Sun, 3 May 2026 23:09:45 +0900 Subject: [PATCH 16/20] =?UTF-8?q?fix(native):=20=EC=BD=9C=EB=93=9C?= =?UTF-8?q?=EC=8A=A4=ED=83=80=ED=8A=B8=20=EB=94=A5=EB=A7=81=ED=81=AC=20?= =?UTF-8?q?=EC=9C=A0=EC=8B=A4=20=E2=80=94=20auth=20hydration=20=ED=9B=84?= =?UTF-8?q?=20route=20=EB=93=B1=EB=A1=9D=EA=B9=8C=EC=A7=80=20=EB=8C=80?= =?UTF-8?q?=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR-7 코드 리뷰 후속 (P1). RootNavigator 가 sessionStatus 에 따라 단일 root screen (Splash | Auth | StudentApp) 만 등록하므로, 알림으로 종료 상태에서 앱이 켜질 때 sessionStatus === 'hydrating' 단계에서는 Splash 만 navigator 에 존재한다. 이때 navigationRef.isReady() 가 true 여도 'StudentApp' 으로의 CommonActions.navigate 가 silent no-op 이 되어 딥링크가 유실된다. 수정: - navigationRef 에 stateSubscribers + handleNavigationStateChange 추가, App.tsx 의 NavigationContainer onStateChange prop 에 연결. - waitForRouteRegistered(routeName, timeoutMs) helper 추가 — onReady + onStateChange 둘 다 구독해 원하는 route 가 등록될 때까지 대기. - useDeepLinkHandler 가 waitForNavigationReady 다음에 'StudentApp' 등록을 추가로 기다린 뒤 dispatch. - 곁들여 waitForNavigationReady 의 finish/handler/timer ordering 정리 (timer 를 let 으로 선언 후 할당 — 미래 편집 시 TDZ 위험 제거). --- apps/native/App.tsx | 9 ++- apps/native/src/hooks/useDeepLinkHandler.ts | 17 ++++- apps/native/src/services/navigation/index.ts | 2 + .../src/services/navigation/navigationRef.ts | 73 +++++++++++++++++-- 4 files changed, 92 insertions(+), 9 deletions(-) diff --git a/apps/native/App.tsx b/apps/native/App.tsx index 69a056c95..f0e5220c4 100644 --- a/apps/native/App.tsx +++ b/apps/native/App.tsx @@ -17,7 +17,11 @@ import { toastConfig } from '@/features/student/scrap/components/Notification/To import { PointingFeedbackQueueWiring } from '@/features/student/problem/services/PointingFeedbackQueueWiring'; import { env } from '@utils'; import { initializeKakaoSDK } from '@react-native-kakao/core'; -import { navigationRef, handleNavigationReady } from '@/services/navigation'; +import { + navigationRef, + handleNavigationReady, + handleNavigationStateChange, +} from '@/services/navigation'; initializeKakaoSDK(env.kakaoNativeAppKey); @@ -56,7 +60,8 @@ export default function App() { + onReady={handleNavigationReady} + onStateChange={handleNavigationStateChange}> diff --git a/apps/native/src/hooks/useDeepLinkHandler.ts b/apps/native/src/hooks/useDeepLinkHandler.ts index e64374589..4037a890a 100644 --- a/apps/native/src/hooks/useDeepLinkHandler.ts +++ b/apps/native/src/hooks/useDeepLinkHandler.ts @@ -4,7 +4,11 @@ import messaging, { type FirebaseMessagingTypes } from '@react-native-firebase/m import * as Notifications from 'expo-notifications'; import { CommonActions } from '@react-navigation/native'; -import { navigationRef, waitForNavigationReady } from '@/services/navigation'; +import { + navigationRef, + waitForNavigationReady, + waitForRouteRegistered, +} from '@/services/navigation'; import { parseDeepLinkUrl, isValidDeepLink } from '@/utils/deepLink'; import { getPublishDetailById } from '@/apis/controller/student/study'; import { useProblemSessionStore, getInitialScreenForPhase } from '@/stores'; @@ -40,6 +44,17 @@ const handleDeepLink = async (url: string | undefined | null) => { return false; } + // RootNavigator 가 sessionStatus 에 따라 단일 root screen 만 등록하므로, + // navigation ready 만으로는 부족하다. 콜드스타트 hydrating 단계에는 Splash 만 + // 등록되어 'StudentApp' 으로의 navigate 가 silent no-op 이 된다. auth hydration + // 후 StudentApp 이 등록될 때까지 추가 대기한다. (qna/publish 둘 다 StudentApp + // 안에서 처리되므로 동일 route 를 게이트로 사용.) + const studentAppReady = await waitForRouteRegistered('StudentApp'); + if (!studentAppReady) { + console.warn('[DeepLink] StudentApp route 미등록 — 알림이 unauthenticated 상태에 도착했을 가능성'); + return false; + } + console.log('[DeepLink] Handling deep link:', parsed); try { diff --git a/apps/native/src/services/navigation/index.ts b/apps/native/src/services/navigation/index.ts index cf43ce556..9396495b4 100644 --- a/apps/native/src/services/navigation/index.ts +++ b/apps/native/src/services/navigation/index.ts @@ -2,5 +2,7 @@ export { navigationRef, isNavigationReady, waitForNavigationReady, + waitForRouteRegistered, handleNavigationReady, + handleNavigationStateChange, } from './navigationRef'; diff --git a/apps/native/src/services/navigation/navigationRef.ts b/apps/native/src/services/navigation/navigationRef.ts index 5255ed674..f366da564 100644 --- a/apps/native/src/services/navigation/navigationRef.ts +++ b/apps/native/src/services/navigation/navigationRef.ts @@ -14,19 +14,30 @@ export const navigationRef = createNavigationContainerRef(); export const isNavigationReady = () => navigationRef.isReady(); const readySubscribers = new Set<() => void>(); +const stateSubscribers = new Set<() => void>(); /** * NavigationContainer 의 onReady prop 에 연결한다 (App.tsx). * navigationRef 가 attach 된 시점에 호출되어 대기 중인 subscriber 들을 깨운다. + * + * snapshot 후 fan-out — 각 handler 가 finish() 안에서 자기 자신을 delete 하므로 + * 반복 중 mutation 을 피하면서도 leak 없이 정리된다. clear() 를 하지 않는 이유는 + * Fast Refresh / container 재mount 시 onReady 가 다시 발화될 수 있어, 그 사이 + * 등록된 subscriber 가 손실되지 않도록 함이다. */ export const handleNavigationReady = () => { - // snapshot 후 호출 — 각 handler 가 finish() 안에서 자기 자신을 delete 하므로 - // 반복 중 mutation 을 피하면서도 leak 없이 정리된다. clear() 를 하지 않는 이유는 - // Fast Refresh / container 재mount 시 onReady 가 다시 발화될 수 있어, 그 사이 - // 등록된 subscriber 가 손실되지 않도록 함이다. [...readySubscribers].forEach((cb) => cb()); }; +/** + * NavigationContainer 의 onStateChange prop 에 연결한다 (App.tsx). + * Root state 가 변할 때마다 stateSubscribers 를 깨워 콜드스타트 직후 root route + * (예: Splash → StudentApp) 가 교체되는 것을 감지한다. + */ +export const handleNavigationStateChange = () => { + [...stateSubscribers].forEach((cb) => cb()); +}; + /** * navigationRef 가 준비될 때까지 이벤트 기반으로 대기. * @@ -44,7 +55,7 @@ export const waitForNavigationReady = (timeoutMs = 30_000): Promise => return new Promise((resolve) => { let settled = false; - const handler = () => finish(navigationRef.isReady()); + let timer: ReturnType; const finish = (ready: boolean) => { if (settled) return; @@ -54,7 +65,57 @@ export const waitForNavigationReady = (timeoutMs = 30_000): Promise => resolve(ready); }; + const handler = () => finish(navigationRef.isReady()); + + readySubscribers.add(handler); + timer = setTimeout(() => finish(false), timeoutMs); + }); +}; + +const isRouteRegistered = (routeName: keyof RootStackParamList): boolean => { + if (!navigationRef.isReady()) return false; + const state = navigationRef.getRootState(); + return state?.routes?.some((r) => r.name === routeName) ?? false; +}; + +/** + * 특정 root route 가 RootNavigator 에 등록될 때까지 이벤트 기반으로 대기. + * + * RootNavigator 가 sessionStatus 에 따라 단일 root screen 만 등록하는 구조 (Splash | + * Auth | StudentApp) 라, navigation ready 만 기다려서는 콜드스타트 직후 알림 딥링크가 + * 유실될 수 있다. 예) sessionStatus === 'hydrating' → Splash 만 등록 → StudentApp + * 으로 navigate 하려 해도 silent no-op. 이 helper 는 onReady + onStateChange 를 모두 + * 구독해, 원하는 route 가 등록될 때까지 기다린다. + * + * - 이미 등록되어 있으면 즉시 resolve(true). + * - timeoutMs 내에 등록 안 되면 resolve(false). (예: 사용자가 unauthenticated 인 + * 채로 알림을 받은 비정상 시나리오 — 이 경우 deep link 는 자연스럽게 폐기됨.) + */ +export const waitForRouteRegistered = ( + routeName: keyof RootStackParamList, + timeoutMs = 30_000 +): Promise => { + if (isRouteRegistered(routeName)) return Promise.resolve(true); + + return new Promise((resolve) => { + let settled = false; + let timer: ReturnType; + + const finish = (ok: boolean) => { + if (settled) return; + settled = true; + clearTimeout(timer); + readySubscribers.delete(handler); + stateSubscribers.delete(handler); + resolve(ok); + }; + + const handler = () => { + if (isRouteRegistered(routeName)) finish(true); + }; + readySubscribers.add(handler); - const timer = setTimeout(() => finish(false), timeoutMs); + stateSubscribers.add(handler); + timer = setTimeout(() => finish(false), timeoutMs); }); }; From a5e30337a65ae213a1039275d7db084557e15399 Mon Sep 17 00:00:00 2001 From: sterdsterd Date: Sun, 3 May 2026 23:12:03 +0900 Subject: [PATCH 17/20] =?UTF-8?q?style(native):=20useDeepLinkHandler=20con?= =?UTF-8?q?sole.warn=20=EC=A4=84=EB=B0=94=EA=BF=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit prettier — 100자 width 초과 문자열을 multi-line 으로 강제. CI format:check 픽스. --- apps/native/src/hooks/useDeepLinkHandler.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/native/src/hooks/useDeepLinkHandler.ts b/apps/native/src/hooks/useDeepLinkHandler.ts index 4037a890a..b342112d1 100644 --- a/apps/native/src/hooks/useDeepLinkHandler.ts +++ b/apps/native/src/hooks/useDeepLinkHandler.ts @@ -51,7 +51,9 @@ const handleDeepLink = async (url: string | undefined | null) => { // 안에서 처리되므로 동일 route 를 게이트로 사용.) const studentAppReady = await waitForRouteRegistered('StudentApp'); if (!studentAppReady) { - console.warn('[DeepLink] StudentApp route 미등록 — 알림이 unauthenticated 상태에 도착했을 가능성'); + console.warn( + '[DeepLink] StudentApp route 미등록 — 알림이 unauthenticated 상태에 도착했을 가능성' + ); return false; } From 0e4bf7145238ae3ba280110054ad9db8ff545087 Mon Sep 17 00:00:00 2001 From: sterdsterd Date: Tue, 5 May 2026 00:14:35 +0900 Subject: [PATCH 18/20] fix(native): address PR review cleanup --- apps/native/src/services/navigation/navigationRef.ts | 8 ++++---- .../src/native/ContentWebView.tsx | 5 +++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/native/src/services/navigation/navigationRef.ts b/apps/native/src/services/navigation/navigationRef.ts index f366da564..b661c4b3c 100644 --- a/apps/native/src/services/navigation/navigationRef.ts +++ b/apps/native/src/services/navigation/navigationRef.ts @@ -55,12 +55,12 @@ export const waitForNavigationReady = (timeoutMs = 30_000): Promise => return new Promise((resolve) => { let settled = false; - let timer: ReturnType; + let timer: ReturnType | undefined; const finish = (ready: boolean) => { if (settled) return; settled = true; - clearTimeout(timer); + if (timer) clearTimeout(timer); readySubscribers.delete(handler); resolve(ready); }; @@ -99,12 +99,12 @@ export const waitForRouteRegistered = ( return new Promise((resolve) => { let settled = false; - let timer: ReturnType; + let timer: ReturnType | undefined; const finish = (ok: boolean) => { if (settled) return; settled = true; - clearTimeout(timer); + if (timer) clearTimeout(timer); readySubscribers.delete(handler); stateSubscribers.delete(handler); resolve(ok); diff --git a/packages/pointer-content-renderer/src/native/ContentWebView.tsx b/packages/pointer-content-renderer/src/native/ContentWebView.tsx index da66ef2e4..7d4432b4c 100644 --- a/packages/pointer-content-renderer/src/native/ContentWebView.tsx +++ b/packages/pointer-content-renderer/src/native/ContentWebView.tsx @@ -44,8 +44,9 @@ const shouldAllowRequest = (request: ShouldStartLoadRequest): boolean => { /** * 번들된 정적 HTML 자산 또는 inline HTML 만 지원한다. - * 외부 https URL ({ uri }) 은 의도적으로 받지 않는다 — `originWhitelist` 가 - * 프로덕션에서 file://, about:blank 만 허용하므로 외부 uri 를 넘기면 초기 로드가 차단된다. + * 외부 URL source ({ uri }) 은 의도적으로 받지 않는다. `originWhitelist` 는 + * 외부 앱 fallback 방지를 위한 통과 layer 이며, 실제 navigation 차단은 + * `shouldAllowRequest` 에서 deny-by-default 로 강제한다. * * - `number` (Metro asset id from `require('@assets/webview/content.html')`) * - `{ html: string }` — 테스트/스토리북용 inline HTML From da166573a2100e6d7998e42d650e2f9096dc72dc Mon Sep 17 00:00:00 2001 From: sterdsterd Date: Tue, 5 May 2026 00:34:10 +0900 Subject: [PATCH 19/20] fix(native): avoid duplicate deep link ready wait --- apps/native/src/hooks/useDeepLinkHandler.ts | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/apps/native/src/hooks/useDeepLinkHandler.ts b/apps/native/src/hooks/useDeepLinkHandler.ts index b342112d1..29d53ac07 100644 --- a/apps/native/src/hooks/useDeepLinkHandler.ts +++ b/apps/native/src/hooks/useDeepLinkHandler.ts @@ -4,11 +4,7 @@ import messaging, { type FirebaseMessagingTypes } from '@react-native-firebase/m import * as Notifications from 'expo-notifications'; import { CommonActions } from '@react-navigation/native'; -import { - navigationRef, - waitForNavigationReady, - waitForRouteRegistered, -} from '@/services/navigation'; +import { navigationRef, waitForRouteRegistered } from '@/services/navigation'; import { parseDeepLinkUrl, isValidDeepLink } from '@/utils/deepLink'; import { getPublishDetailById } from '@/apis/controller/student/study'; import { useProblemSessionStore, getInitialScreenForPhase } from '@/stores'; @@ -36,18 +32,10 @@ const handleDeepLink = async (url: string | undefined | null) => { return false; } - // navigationRef 가 ready 가 될 때까지 이벤트 기반으로 대기 (busy-wait 제거). - // 저사양 기기의 콜드 스타트 (>3s) 에서도 딥링크가 손실되지 않도록 30s 까지 허용. - const isReady = await waitForNavigationReady(); - if (!isReady) { - console.warn('[DeepLink] Navigation not ready, cannot handle deep link'); - return false; - } - // RootNavigator 가 sessionStatus 에 따라 단일 root screen 만 등록하므로, - // navigation ready 만으로는 부족하다. 콜드스타트 hydrating 단계에는 Splash 만 - // 등록되어 'StudentApp' 으로의 navigate 가 silent no-op 이 된다. auth hydration - // 후 StudentApp 이 등록될 때까지 추가 대기한다. (qna/publish 둘 다 StudentApp + // 콜드스타트 hydrating 단계에는 Splash 만 등록되어 'StudentApp' 으로의 navigate 가 + // silent no-op 이 된다. waitForRouteRegistered 는 navigation ready 와 auth hydration + // 후 StudentApp 등록을 단일 30s budget 으로 기다린다. (qna/publish 둘 다 StudentApp // 안에서 처리되므로 동일 route 를 게이트로 사용.) const studentAppReady = await waitForRouteRegistered('StudentApp'); if (!studentAppReady) { From 510810df137dc12f6f5fbdf28d66e89964ee6263 Mon Sep 17 00:00:00 2001 From: sterdsterd Date: Tue, 5 May 2026 00:42:22 +0900 Subject: [PATCH 20/20] fix(native): handle Google sign-in cancellation --- .../auth/login/hooks/useNativeOAuth.ts | 41 +++++++++++++------ 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/apps/native/src/features/auth/login/hooks/useNativeOAuth.ts b/apps/native/src/features/auth/login/hooks/useNativeOAuth.ts index 14d5f32db..da737fb85 100644 --- a/apps/native/src/features/auth/login/hooks/useNativeOAuth.ts +++ b/apps/native/src/features/auth/login/hooks/useNativeOAuth.ts @@ -1,5 +1,5 @@ import { useState, useCallback, useEffect } from 'react'; -import { GoogleSignin } from '@react-native-google-signin/google-signin'; +import { GoogleSignin, statusCodes } from '@react-native-google-signin/google-signin'; import { login as kakaoLogin, logout as kakaoLogout } from '@react-native-kakao/user'; import * as AppleAuthentication from 'expo-apple-authentication'; import { useNavigation } from '@react-navigation/native'; @@ -25,6 +25,25 @@ type UseNativeOAuthReturn = OAuthState & { signOut: () => Promise; }; +const APPLE_CANCEL_CODE = 'ERR_REQUEST_CANCELED'; + +const createOAuthCancelError = (code: string) => { + const error = new Error('OAuth sign in cancelled'); + return Object.assign(error, { code }); +}; + +const getErrorCode = (error: unknown) => { + if (typeof error === 'object' && error !== null && 'code' in error) { + return String((error as { code: unknown }).code); + } + return null; +}; + +const isOAuthCancelError = (error: unknown) => { + const code = getErrorCode(error); + return code === APPLE_CANCEL_CODE || code === statusCodes.SIGN_IN_CANCELLED; +}; + const useNativeOAuth = (): UseNativeOAuthReturn => { const [state, setState] = useState({ loadingProvider: null, @@ -46,14 +65,17 @@ const useNativeOAuth = (): UseNativeOAuthReturn => { const getGoogleToken = async (): Promise => { await GoogleSignin.hasPlayServices(); - await GoogleSignin.signIn(); - const tokens = await GoogleSignin.getTokens(); - if (!tokens.idToken) { + const result = await GoogleSignin.signIn(); + if (result.type === 'cancelled') { + throw createOAuthCancelError(statusCodes.SIGN_IN_CANCELLED); + } + + if (!result.data.idToken) { throw new Error('Google ID token not found'); } - return tokens.idToken; + return result.data.idToken; }; const getKakaoToken = async (): Promise => { @@ -170,13 +192,8 @@ const useNativeOAuth = (): UseNativeOAuthReturn => { } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; - // Apple 로그인 취소는 에러로 처리하지 않음 - if ( - typeof error === 'object' && - error !== null && - 'code' in error && - (error as { code: string }).code === 'ERR_REQUEST_CANCELED' - ) { + // OAuth 취소는 에러로 처리하지 않음. + if (isOAuthCancelError(error)) { setState({ loadingProvider: null, error: null }); return; }