fix(mobile): replay cold-start notification taps after auth bootstrap

This commit is contained in:
Yun Chan 2026-09-28 02:16:24 +09:00
parent 7a8e1e3e25
commit 9a5333c617
5 changed files with 396 additions and 22 deletions

View file

@ -58,6 +58,10 @@ import { MobileAdsProvider } from './src/lib/mobile-ads-context'
import { DeviceProvider } from './src/lib/device-context'
import { useNotificationRuntime } from './src/features/notifications/use-notification-runtime'
import type { NotificationNavigationTarget } from './src/features/notifications/notification-contract'
import {
notificationNavigationQueue,
type NotificationNavigationReadiness,
} from './src/features/notifications/notification-navigation-queue'
import { loadPendingInviteToken } from './src/features/teams/pending-invite'
import { listTeams } from './src/features/teams/team-service'
import { listHistoryPage } from './src/features/history/history-service'
@ -200,7 +204,6 @@ function RootNavigator(): React.ReactElement {
} = useMobilePreferences()
const [authEntryRoute, setAuthEntryRoute] = useState<AuthEntryRoute>('Login')
const navigationRef = useNavigationContainerRef<RootStackParamList>()
const pendingNotificationTarget = useRef<NotificationNavigationTarget | null>(null)
const lastRoutedIncomingMediaId = useRef<string | null>(null)
const previousNeedsOnboarding = useRef<boolean | null>(null)
const navigationTheme = useMemo(
@ -213,17 +216,37 @@ function RootNavigator(): React.ReactElement {
!authLoading && privacyCleanupState === 'ready',
)
const navigateFromNotification = useCallback((target: NotificationNavigationTarget): void => {
if (user === null && target.name !== 'InviteAccept') return
if (!navigationRef.isReady()) {
pendingNotificationTarget.current = target
return
// Readiness is read through a ref so the notification callbacks keep a
// stable identity; the flush effect below re-runs when it changes.
const authenticatedAppReady = !authLoading
&& !preferencesLoading
&& user !== null
&& !needsOnboarding
&& !recoveryMode
const authenticatedAppReadyRef = useRef(authenticatedAppReady)
authenticatedAppReadyRef.current = authenticatedAppReady
const flushNotificationNavigation = useCallback((): void => {
const readiness: NotificationNavigationReadiness = {
navigationReady: navigationRef.isReady(),
authenticatedAppReady: authenticatedAppReadyRef.current,
}
const target = notificationNavigationQueue.take(readiness)
if (target === null) return
const navigator = navigationRef as unknown as {
navigate: (name: string, params: Record<string, string>) => void
}
navigator.navigate(target.name, target.params ?? {})
}, [navigationRef, user])
}, [navigationRef])
// Opened notifications can arrive before auth bootstrap finished (cold
// start: the native payload is one-shot) or while signed out. Queue the
// target in the app-lifetime queue and replay it once the authenticated
// stack is ready instead of dropping it.
const navigateFromNotification = useCallback((target: NotificationNavigationTarget): void => {
notificationNavigationQueue.offer(target)
flushNotificationNavigation()
}, [flushNotificationNavigation])
const routeIncomingMedia = useCallback(async (): Promise<void> => {
if (user === null || needsOnboarding || recoveryMode || !navigationRef.isReady()) return
@ -285,6 +308,12 @@ function RootNavigator(): React.ReactElement {
navigationRef.resetRoot({ index: 0, routes: [{ name: 'Main' }] })
}, [authLoading, navigationRef, needsOnboarding, preferencesLoading, user])
// Declared after the onboarding reset so a replayed notification target is
// pushed on top of the fresh Main root rather than wiped by resetRoot.
useEffect(() => {
if (authenticatedAppReady) flushNotificationNavigation()
}, [authenticatedAppReady, flushNotificationNavigation])
useEffect(() => {
if (!user?.id) return
let active = true
@ -319,9 +348,7 @@ function RootNavigator(): React.ReactElement {
theme={navigationTheme}
linking={linking}
onReady={() => {
const pending = pendingNotificationTarget.current
pendingNotificationTarget.current = null
if (pending !== null) navigateFromNotification(pending)
flushNotificationNavigation()
void routeIncomingMedia()
}}
>