From facee1fcfc647a258fdcce777522c1e69c50386c Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Sat, 2 Aug 2025 23:24:43 -0700 Subject: [PATCH 1/6] Guard recovery prompts --- app/src/hooks/useRecoveryPrompts.ts | 12 +++++- .../src/hooks/useRecoveryPrompts.test.ts | 39 ++++++++++++++++++- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/app/src/hooks/useRecoveryPrompts.ts b/app/src/hooks/useRecoveryPrompts.ts index 532813dde..6666a2bd0 100644 --- a/app/src/hooks/useRecoveryPrompts.ts +++ b/app/src/hooks/useRecoveryPrompts.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11 -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { navigationRef } from '../navigation'; import { usePassport } from '../providers/passportDataProvider'; @@ -27,6 +27,8 @@ export default function useRecoveryPrompts() { onModalDismiss: () => {}, } as const); + const lastPromptCount = useRef(null); + useEffect(() => { async function maybePrompt() { if (!navigationRef.isReady()) { @@ -40,8 +42,13 @@ export default function useRecoveryPrompts() { } const shouldPrompt = loginCount > 0 && (loginCount <= 3 || (loginCount - 3) % 5 === 0); - if (shouldPrompt) { + if ( + shouldPrompt && + !visible && + lastPromptCount.current !== loginCount + ) { showModal(); + lastPromptCount.current = loginCount; } } catch (error) { // Silently fail to avoid breaking the hook @@ -55,6 +62,7 @@ export default function useRecoveryPrompts() { loginCount, cloudBackupEnabled, hasViewedRecoveryPhrase, + visible, showModal, getAllDocuments, ]); diff --git a/app/tests/src/hooks/useRecoveryPrompts.test.ts b/app/tests/src/hooks/useRecoveryPrompts.test.ts index dd6f725c5..4efdf3ddc 100644 --- a/app/tests/src/hooks/useRecoveryPrompts.test.ts +++ b/app/tests/src/hooks/useRecoveryPrompts.test.ts @@ -17,13 +17,13 @@ jest.mock('../../../src/navigation', () => ({ })); const showModal = jest.fn(); -(useModal as jest.Mock).mockReturnValue({ showModal, visible: false }); const getAllDocuments = jest.fn(); (usePassport as jest.Mock).mockReturnValue({ getAllDocuments }); describe('useRecoveryPrompts', () => { beforeEach(() => { showModal.mockClear(); + (useModal as jest.Mock).mockReturnValue({ showModal, visible: false }); getAllDocuments.mockResolvedValue({ doc1: {} as any }); act(() => { useSettingStore.setState({ @@ -86,6 +86,17 @@ describe('useRecoveryPrompts', () => { }); }); + it('does not show modal if already visible', async () => { + (useModal as jest.Mock).mockReturnValueOnce({ showModal, visible: true }); + act(() => { + useSettingStore.setState({ loginCount: 1 }); + }); + renderHook(() => useRecoveryPrompts()); + await waitFor(() => { + expect(showModal).not.toHaveBeenCalled(); + }); + }); + it('does not show modal when recovery phrase has been viewed', async () => { act(() => { useSettingStore.setState({ @@ -123,6 +134,32 @@ describe('useRecoveryPrompts', () => { } }); + it('does not show modal again for same login count when state changes', async () => { + act(() => { + useSettingStore.setState({ loginCount: 1 }); + }); + renderHook(() => useRecoveryPrompts()); + await waitFor(() => { + expect(showModal).toHaveBeenCalledTimes(1); + }); + + showModal.mockClear(); + + act(() => { + useSettingStore.setState({ hasViewedRecoveryPhrase: true }); + }); + await waitFor(() => { + expect(showModal).not.toHaveBeenCalled(); + }); + + act(() => { + useSettingStore.setState({ hasViewedRecoveryPhrase: false }); + }); + await waitFor(() => { + expect(showModal).not.toHaveBeenCalled(); + }); + }); + it('returns correct visible state', () => { const { result } = renderHook(() => useRecoveryPrompts()); expect(result.current.visible).toBe(false); From 01c43e293ed47c67dd9288ce01d16de1cdfd8e7c Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Thu, 9 Oct 2025 13:42:40 -0700 Subject: [PATCH 2/6] refactor(app): gate recovery prompts with allow list (#1251) --- app/src/consts/recoveryPrompts.ts | 33 ++++ app/src/hooks/useRecoveryPrompts.ts | 142 ++++++++++++---- app/src/navigation/index.tsx | 3 + .../src/hooks/useRecoveryPrompts.test.ts | 157 +++++++++++++++--- app/tests/src/navigation.test.ts | 155 +++++++++++------ 5 files changed, 390 insertions(+), 100 deletions(-) create mode 100644 app/src/consts/recoveryPrompts.ts diff --git a/app/src/consts/recoveryPrompts.ts b/app/src/consts/recoveryPrompts.ts new file mode 100644 index 000000000..ac77d2a5c --- /dev/null +++ b/app/src/consts/recoveryPrompts.ts @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +/** + * Screens where we intentionally show the recovery reminder. This allow list is + * intentionally short so that new product surfaces do not accidentally inherit + * the prompt without design review. + */ +export const RECOVERY_PROMPT_ALLOWED_ROUTES = [ + 'Home', + 'ProofHistory', + 'ProofHistoryDetail', + 'ManageDocuments', + 'Settings', +] as const; + +/** + * Capture and scanning flows that should never be interrupted by a modal. We + * keep this block list both for documentation and to use in tests. + */ +export const CRITICAL_RECOVERY_PROMPT_ROUTES = [ + 'DocumentCamera', + 'DocumentCameraTrouble', + 'DocumentNFCMethodSelection', + 'DocumentNFCScan', + 'DocumentNFCTrouble', + 'QRCodeViewFinder', + 'QRCodeTrouble', +] as const; + +export type RecoveryPromptAllowedRoute = + (typeof RECOVERY_PROMPT_ALLOWED_ROUTES)[number]; diff --git a/app/src/hooks/useRecoveryPrompts.ts b/app/src/hooks/useRecoveryPrompts.ts index d9a97413e..56b538f34 100644 --- a/app/src/hooks/useRecoveryPrompts.ts +++ b/app/src/hooks/useRecoveryPrompts.ts @@ -2,15 +2,27 @@ // SPDX-License-Identifier: BUSL-1.1 // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. -import { useEffect, useRef } from 'react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import type { AppStateStatus } from 'react-native'; +import { AppState } from 'react-native'; +import { RECOVERY_PROMPT_ALLOWED_ROUTES } from '@/consts/recoveryPrompts'; import { useModal } from '@/hooks/useModal'; import { navigationRef } from '@/navigation'; import { usePassport } from '@/providers/passportDataProvider'; import { useSettingStore } from '@/stores/settingStore'; -// TODO: need to debug and test the logic. it pops up too often. -export default function useRecoveryPrompts() { +const DEFAULT_ALLOWED_ROUTES = RECOVERY_PROMPT_ALLOWED_ROUTES; + +type UseRecoveryPromptsOptions = { + allowedRoutes?: readonly string[]; + disallowedRoutes?: readonly string[]; +}; + +export default function useRecoveryPrompts({ + allowedRoutes = DEFAULT_ALLOWED_ROUTES, + disallowedRoutes, +}: UseRecoveryPromptsOptions = {}) { const { loginCount, cloudBackupEnabled, hasViewedRecoveryPhrase } = useSettingStore(); const { getAllDocuments } = usePassport(); @@ -30,44 +42,112 @@ export default function useRecoveryPrompts() { } as const); const lastPromptCount = useRef(null); + const appStateStatus = useRef( + (AppState.currentState as AppStateStatus | null) ?? 'active', + ); + const allowedRouteSet = useMemo( + () => new Set(allowedRoutes), + [allowedRoutes], + ); + const disallowedRouteSet = useMemo( + () => (disallowedRoutes ? new Set(disallowedRoutes) : null), + [disallowedRoutes], + ); - useEffect(() => { - async function maybePrompt() { - if (!navigationRef.isReady()) { + const isRouteEligible = useCallback( + (routeName: string | undefined): routeName is string => { + if (!routeName) { + return false; + } + if (!allowedRouteSet.has(routeName)) { + return false; + } + if (disallowedRouteSet?.has(routeName)) { + return false; + } + return true; + }, + [allowedRouteSet, disallowedRouteSet], + ); + + const maybePrompt = useCallback(async () => { + if (!navigationRef.isReady()) { + return; + } + if (appStateStatus.current !== 'active') { + return; + } + const currentRouteName = navigationRef.getCurrentRoute?.()?.name; + if (!isRouteEligible(currentRouteName)) { + return; + } + if (cloudBackupEnabled || hasViewedRecoveryPhrase) { + return; + } + try { + const docs = await getAllDocuments(); + if (Object.keys(docs).length === 0) { return; } - if (!cloudBackupEnabled && !hasViewedRecoveryPhrase) { - try { - const docs = await getAllDocuments(); - if (Object.keys(docs).length === 0) { - return; - } - const shouldPrompt = - loginCount > 0 && (loginCount <= 3 || (loginCount - 3) % 5 === 0); - if ( - shouldPrompt && - !visible && - lastPromptCount.current !== loginCount - ) { - showModal(); - lastPromptCount.current = loginCount; - } - } catch { - // Silently fail to avoid breaking the hook - // If we can't get documents, we shouldn't show the prompt - return; - } + const shouldPrompt = + loginCount > 0 && (loginCount <= 3 || (loginCount - 3) % 5 === 0); + if ( + shouldPrompt && + !visible && + lastPromptCount.current !== loginCount + ) { + showModal(); + lastPromptCount.current = loginCount; } + } catch { + // Silently fail to avoid breaking the hook + // If we can't get documents, we shouldn't show the prompt + return; } - maybePrompt().catch(() => {}); }, [ - loginCount, cloudBackupEnabled, + getAllDocuments, hasViewedRecoveryPhrase, - visible, + isRouteEligible, + loginCount, showModal, - getAllDocuments, + visible, ]); + useEffect(() => { + void maybePrompt(); + }, [maybePrompt]); + + useEffect(() => { + const handleAppStateChange = (nextState: AppStateStatus) => { + const previousState = appStateStatus.current; + appStateStatus.current = nextState; + if ( + (previousState === 'background' || previousState === 'inactive') && + nextState === 'active' + ) { + void maybePrompt(); + } + }; + const subscription = AppState.addEventListener( + 'change', + handleAppStateChange, + ); + return () => { + subscription.remove(); + }; + }, [maybePrompt]); + + useEffect(() => { + const unsubscribe = navigationRef.addListener?.('state', () => { + void maybePrompt(); + }); + return () => { + if (typeof unsubscribe === 'function') { + unsubscribe(); + } + }; + }, [maybePrompt]); + return { visible }; } diff --git a/app/src/navigation/index.tsx b/app/src/navigation/index.tsx index e35d0ce4e..c78eda462 100644 --- a/app/src/navigation/index.tsx +++ b/app/src/navigation/index.tsx @@ -16,6 +16,8 @@ import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { useSelfClient } from '@selfxyz/mobile-sdk-alpha'; import { DefaultNavBar } from '@/components/NavBar'; +import { RECOVERY_PROMPT_ALLOWED_ROUTES } from '@/consts/recoveryPrompts'; +import useRecoveryPrompts from '@/hooks/useRecoveryPrompts'; import AppLayout from '@/layouts/AppLayout'; import accountScreens from '@/navigation/account'; import appScreens from '@/navigation/app'; @@ -90,6 +92,7 @@ const { trackScreenView } = analytics(); const Navigation = createStaticNavigation(AppNavigation); const NavigationWithTracking = () => { + useRecoveryPrompts({ allowedRoutes: RECOVERY_PROMPT_ALLOWED_ROUTES }); const selfClient = useSelfClient(); const trackScreen = () => { const currentRoute = navigationRef.getCurrentRoute(); diff --git a/app/tests/src/hooks/useRecoveryPrompts.test.ts b/app/tests/src/hooks/useRecoveryPrompts.test.ts index e5fcb0e71..c630ab124 100644 --- a/app/tests/src/hooks/useRecoveryPrompts.test.ts +++ b/app/tests/src/hooks/useRecoveryPrompts.test.ts @@ -4,29 +4,82 @@ import { act, renderHook, waitFor } from '@testing-library/react-native'; -import { useModal } from '@/hooks/useModal'; -import useRecoveryPrompts from '@/hooks/useRecoveryPrompts'; -import { usePassport } from '@/providers/passportDataProvider'; -import { useSettingStore } from '@/stores/settingStore'; +const navigationStateListeners: Array<() => void> = []; +let isNavigationReady = true; +const navigationRef = { + isReady: jest.fn(() => isNavigationReady), + navigate: jest.fn(), + addListener: jest.fn((_: string, callback: () => void) => { + navigationStateListeners.push(callback); + return () => { + const index = navigationStateListeners.indexOf(callback); + if (index >= 0) { + navigationStateListeners.splice(index, 1); + } + }; + }), + getCurrentRoute: jest.fn(() => ({ name: 'Home' })), +} as any; + +const appStateListeners: Array<(state: string) => void> = []; jest.mock('@/hooks/useModal'); jest.mock('@/providers/passportDataProvider'); jest.mock('@/navigation', () => ({ - navigationRef: { - isReady: jest.fn(() => true), - navigate: jest.fn(), - }, + navigationRef, })); +jest.mock('react-native', () => { + const actual = jest.requireActual('react-native'); + return { + ...actual, + AppState: { + currentState: 'active', + addEventListener: jest.fn((_: string, handler: (state: string) => void) => { + appStateListeners.push(handler); + return { + remove: () => { + const index = appStateListeners.indexOf(handler); + if (index >= 0) { + appStateListeners.splice(index, 1); + } + }, + }; + }), + }, + }; +}); + +import { + CRITICAL_RECOVERY_PROMPT_ROUTES, + RECOVERY_PROMPT_ALLOWED_ROUTES, +} from '@/consts/recoveryPrompts'; +import { useModal } from '@/hooks/useModal'; +import useRecoveryPrompts from '@/hooks/useRecoveryPrompts'; +import { usePassport } from '@/providers/passportDataProvider'; +import { useSettingStore } from '@/stores/settingStore'; const showModal = jest.fn(); const getAllDocuments = jest.fn(); (usePassport as jest.Mock).mockReturnValue({ getAllDocuments }); +const getAppState = () => + require('react-native').AppState as unknown as { + currentState: string; + addEventListener: jest.Mock; + }; + describe('useRecoveryPrompts', () => { beforeEach(() => { - showModal.mockClear(); + jest.clearAllMocks(); + navigationStateListeners.length = 0; + appStateListeners.length = 0; + isNavigationReady = true; + navigationRef.isReady.mockImplementation(() => isNavigationReady); + navigationRef.getCurrentRoute.mockReturnValue({ name: 'Home' }); (useModal as jest.Mock).mockReturnValue({ showModal, visible: false }); getAllDocuments.mockResolvedValue({ doc1: {} as any }); + const AppState = getAppState(); + AppState.currentState = 'active'; act(() => { useSettingStore.setState({ loginCount: 0, @@ -36,7 +89,7 @@ describe('useRecoveryPrompts', () => { }); }); - it('shows modal on first login', async () => { + it('shows modal on first login for eligible route', async () => { act(() => { useSettingStore.setState({ loginCount: 1 }); }); @@ -46,29 +99,85 @@ describe('useRecoveryPrompts', () => { }); }); - it('does not show modal when login count is 4', async () => { + it('waits for navigation readiness before prompting', async () => { + isNavigationReady = false; + navigationRef.isReady.mockImplementation(() => isNavigationReady); act(() => { - useSettingStore.setState({ loginCount: 4 }); + useSettingStore.setState({ loginCount: 1 }); }); renderHook(() => useRecoveryPrompts()); await waitFor(() => { expect(showModal).not.toHaveBeenCalled(); }); + + isNavigationReady = true; + navigationStateListeners.forEach((listener) => listener()); + + await waitFor(() => { + expect(showModal).toHaveBeenCalled(); + }); }); - it('shows modal on eighth login', async () => { + it.each([...CRITICAL_RECOVERY_PROMPT_ROUTES])( + 'does not show modal when route %s is disallowed', + async (routeName) => { + navigationRef.getCurrentRoute.mockReturnValue({ name: routeName }); + act(() => { + useSettingStore.setState({ loginCount: 1 }); + }); + const { unmount } = renderHook(() => useRecoveryPrompts()); + await waitFor(() => { + expect(showModal).not.toHaveBeenCalled(); + }); + unmount(); + }, + ); + + it('respects custom allow list overrides', async () => { act(() => { - useSettingStore.setState({ loginCount: 8 }); + useSettingStore.setState({ loginCount: 1 }); + }); + renderHook(() => + useRecoveryPrompts({ allowedRoutes: ['Settings'], disallowedRoutes: [] }), + ); + await waitFor(() => { + expect(showModal).not.toHaveBeenCalled(); + }); + + showModal.mockClear(); + navigationRef.getCurrentRoute.mockReturnValue({ name: 'Settings' }); + + renderHook(() => + useRecoveryPrompts({ + allowedRoutes: RECOVERY_PROMPT_ALLOWED_ROUTES, + disallowedRoutes: [], + }), + ); + + await waitFor(() => { + expect(showModal).toHaveBeenCalled(); + }); + }); + + it('prompts when returning from background on eligible route', async () => { + const AppState = getAppState(); + AppState.currentState = 'background'; + act(() => { + useSettingStore.setState({ loginCount: 1 }); }); renderHook(() => useRecoveryPrompts()); + expect(showModal).not.toHaveBeenCalled(); + + appStateListeners.forEach((listener) => listener('active')); + await waitFor(() => { expect(showModal).toHaveBeenCalled(); }); }); - it('does not show modal if backup already enabled', async () => { + it('does not show modal when login count is 4', async () => { act(() => { - useSettingStore.setState({ loginCount: 1, cloudBackupEnabled: true }); + useSettingStore.setState({ loginCount: 4 }); }); renderHook(() => useRecoveryPrompts()); await waitFor(() => { @@ -76,11 +185,19 @@ describe('useRecoveryPrompts', () => { }); }); - it('does not show modal when navigation is not ready', async () => { - const navigationRef = require('@/navigation').navigationRef; - navigationRef.isReady.mockReturnValueOnce(false); + it('shows modal on eighth login', async () => { act(() => { - useSettingStore.setState({ loginCount: 1 }); + useSettingStore.setState({ loginCount: 8 }); + }); + renderHook(() => useRecoveryPrompts()); + await waitFor(() => { + expect(showModal).toHaveBeenCalled(); + }); + }); + + it('does not show modal if backup already enabled', async () => { + act(() => { + useSettingStore.setState({ loginCount: 1, cloudBackupEnabled: true }); }); renderHook(() => useRecoveryPrompts()); await waitFor(() => { diff --git a/app/tests/src/navigation.test.ts b/app/tests/src/navigation.test.ts index db7e963da..9dfef6b0b 100644 --- a/app/tests/src/navigation.test.ts +++ b/app/tests/src/navigation.test.ts @@ -2,56 +2,113 @@ // SPDX-License-Identifier: BUSL-1.1 // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. +import React from 'react'; +import { render } from '@testing-library/react-native'; + +import { RECOVERY_PROMPT_ALLOWED_ROUTES } from '@/consts/recoveryPrompts'; + +const mockNavigationRef = { + isReady: jest.fn(() => true), + navigate: jest.fn(), + addListener: jest.fn(() => jest.fn()), + getCurrentRoute: jest.fn(() => ({ name: 'Home' })), +} as any; + +jest.mock('@/hooks/useRecoveryPrompts', () => jest.fn()); +jest.mock('@selfxyz/mobile-sdk-alpha', () => ({ + useSelfClient: jest.fn(() => ({})), +})); +jest.mock('@/utils/deeplinks', () => ({ + setupUniversalLinkListenerInNavigation: jest.fn(() => jest.fn()), +})); +jest.mock('@/utils/analytics', () => jest.fn(() => ({ + trackScreenView: jest.fn(), +}))); +jest.mock('react-native-gesture-handler', () => ({ + GestureHandlerRootView: ({ children }: { children: React.ReactNode }) => ( + <>{children} + ), +})); +jest.mock('@react-navigation/native', () => { + const React = require('react'); + return { + createNavigationContainerRef: jest.fn(() => mockNavigationRef), + createStaticNavigation: jest.fn(() => + React.forwardRef((props: any) => <>{props.children}), + ), + }; +}); +jest.mock('@react-navigation/native-stack', () => ({ + createNativeStackNavigator: jest.fn((config) => config), +})); + describe('navigation', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('should have the correct navigation screens', () => { - const navigationScreens = require('@/navigation').navigationScreens; - const listOfScreens = Object.keys(navigationScreens).sort(); - expect(listOfScreens).toEqual([ - 'AadhaarUpload', - 'AadhaarUploadError', - 'AadhaarUploadSuccess', - 'AccountRecovery', - 'AccountRecoveryChoice', - 'AccountVerifiedSuccess', - 'CloudBackupSettings', - 'ComingSoon', - 'ConfirmBelonging', - 'CountryPicker', - 'CreateMock', - 'DeferredLinkingInfo', - 'DevFeatureFlags', - 'DevHapticFeedback', - 'DevLoadingScreen', - 'DevPrivateKey', - 'DevSettings', - 'Disclaimer', - 'DocumentCamera', - 'DocumentCameraTrouble', - 'DocumentDataInfo', - 'DocumentDataNotFound', - 'DocumentNFCMethodSelection', - 'DocumentNFCScan', - 'DocumentNFCTrouble', - 'DocumentOnboarding', - 'Home', - 'IDPicker', - 'IdDetails', - 'Launch', - 'Loading', - 'ManageDocuments', - 'MockDataDeepLink', - 'Modal', - 'ProofHistory', - 'ProofHistoryDetail', - 'ProofRequestStatus', - 'Prove', - 'QRCodeTrouble', - 'QRCodeViewFinder', - 'RecoverWithPhrase', - 'SaveRecoveryPhrase', - 'Settings', - 'ShowRecoveryPhrase', - 'Splash', - ]); + jest.isolateModules(() => { + const navigationScreens = require('@/navigation').navigationScreens; + const listOfScreens = Object.keys(navigationScreens).sort(); + expect(listOfScreens).toEqual([ + 'AadhaarUpload', + 'AadhaarUploadError', + 'AadhaarUploadSuccess', + 'AccountRecovery', + 'AccountRecoveryChoice', + 'AccountVerifiedSuccess', + 'CloudBackupSettings', + 'ComingSoon', + 'ConfirmBelonging', + 'CountryPicker', + 'CreateMock', + 'DeferredLinkingInfo', + 'DevFeatureFlags', + 'DevHapticFeedback', + 'DevLoadingScreen', + 'DevPrivateKey', + 'DevSettings', + 'Disclaimer', + 'DocumentCamera', + 'DocumentCameraTrouble', + 'DocumentDataInfo', + 'DocumentDataNotFound', + 'DocumentNFCMethodSelection', + 'DocumentNFCScan', + 'DocumentNFCTrouble', + 'DocumentOnboarding', + 'Home', + 'IDPicker', + 'IdDetails', + 'Launch', + 'Loading', + 'ManageDocuments', + 'MockDataDeepLink', + 'Modal', + 'ProofHistory', + 'ProofHistoryDetail', + 'ProofRequestStatus', + 'Prove', + 'QRCodeTrouble', + 'QRCodeViewFinder', + 'RecoverWithPhrase', + 'SaveRecoveryPhrase', + 'Settings', + 'ShowRecoveryPhrase', + 'Splash', + ]); + }); + }); + + it('wires recovery prompts hook into navigation', () => { + const useRecoveryPrompts = require('@/hooks/useRecoveryPrompts') as jest.Mock; + jest.isolateModules(() => { + const NavigationWithTracking = require('@/navigation').default; + render(); + }); + expect(useRecoveryPrompts).toHaveBeenCalledWith({ + allowedRoutes: RECOVERY_PROMPT_ALLOWED_ROUTES, + }); }); }); From b2e532fc6e6c2d2727fa751bfca0b1bd7023fe30 Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Thu, 9 Oct 2025 14:59:06 -0700 Subject: [PATCH 3/6] fix typing --- app/Gemfile.lock | 4 +- app/ios/Podfile.lock | 4 +- app/src/consts/recoveryPrompts.ts | 31 ++++++------ app/src/hooks/useRecoveryPrompts.ts | 18 +++---- .../src/hooks/useRecoveryPrompts.test.ts | 48 ++++++++++--------- ...navigation.test.ts => navigation.test.tsx} | 31 +++++++----- 6 files changed, 74 insertions(+), 62 deletions(-) rename app/tests/src/{navigation.test.ts => navigation.test.tsx} (84%) diff --git a/app/Gemfile.lock b/app/Gemfile.lock index 70f7d78f6..9d7cee4dc 100644 --- a/app/Gemfile.lock +++ b/app/Gemfile.lock @@ -25,7 +25,7 @@ GEM artifactory (3.0.17) atomos (0.1.3) aws-eventstream (1.4.0) - aws-partitions (1.1170.0) + aws-partitions (1.1171.0) aws-sdk-core (3.233.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) @@ -46,7 +46,7 @@ GEM babosa (1.0.4) base64 (0.3.0) benchmark (0.4.1) - bigdecimal (3.3.0) + bigdecimal (3.3.1) claide (1.1.0) cocoapods (1.16.2) addressable (~> 2.8) diff --git a/app/ios/Podfile.lock b/app/ios/Podfile.lock index 3f9ec842e..2833a31a3 100644 --- a/app/ios/Podfile.lock +++ b/app/ios/Podfile.lock @@ -1960,7 +1960,7 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - RNLocalize (3.5.2): + - RNLocalize (3.5.3): - DoubleConversion - glog - hermes-engine @@ -2558,7 +2558,7 @@ SPEC CHECKSUMS: RNFBRemoteConfig: a569bacaa410acfcaba769370e53a787f80fd13b RNGestureHandler: a63b531307e5b2e6ea21d053a1a7ad4cf9695c57 RNKeychain: 471ceef8c13f15a5534c3cd2674dbbd9d0680e52 - RNLocalize: a2c93da4b4afae4630d4b3be79320c11c4342d1f + RNLocalize: 7683e450496a5aea9a2dab3745bfefa7341d3f5e RNReactNativeHapticFeedback: e526ac4a7ca9fb23c7843ea4fd7d823166054c73 RNScreens: 806e1449a8ec63c2a4e4cf8a63cc80203ccda9b8 RNSentry: 6ad982be2c8e32dab912afb4132b6a0d88484ea0 diff --git a/app/src/consts/recoveryPrompts.ts b/app/src/consts/recoveryPrompts.ts index ac77d2a5c..ac4d228b6 100644 --- a/app/src/consts/recoveryPrompts.ts +++ b/app/src/consts/recoveryPrompts.ts @@ -3,21 +3,19 @@ // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. /** - * Screens where we intentionally show the recovery reminder. This allow list is - * intentionally short so that new product surfaces do not accidentally inherit - * the prompt without design review. + * Capture and scanning flows that should never be interrupted by a modal. We + * keep this block list both for documentation and to use in tests. */ -export const RECOVERY_PROMPT_ALLOWED_ROUTES = [ - 'Home', - 'ProofHistory', - 'ProofHistoryDetail', - 'ManageDocuments', - 'Settings', -] as const; +export type RecoveryPromptAllowedRoute = + (typeof RECOVERY_PROMPT_ALLOWED_ROUTES)[number]; +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. /** - * Capture and scanning flows that should never be interrupted by a modal. We - * keep this block list both for documentation and to use in tests. + * Screens where we intentionally show the recovery reminder. This allow list is + * intentionally short so that new product surfaces do not accidentally inherit + * the prompt without design review. */ export const CRITICAL_RECOVERY_PROMPT_ROUTES = [ 'DocumentCamera', @@ -29,5 +27,10 @@ export const CRITICAL_RECOVERY_PROMPT_ROUTES = [ 'QRCodeTrouble', ] as const; -export type RecoveryPromptAllowedRoute = - (typeof RECOVERY_PROMPT_ALLOWED_ROUTES)[number]; +export const RECOVERY_PROMPT_ALLOWED_ROUTES = [ + 'Home', + 'ProofHistory', + 'ProofHistoryDetail', + 'ManageDocuments', + 'Settings', +] as const; diff --git a/app/src/hooks/useRecoveryPrompts.ts b/app/src/hooks/useRecoveryPrompts.ts index 56b538f34..f7f327ba4 100644 --- a/app/src/hooks/useRecoveryPrompts.ts +++ b/app/src/hooks/useRecoveryPrompts.ts @@ -91,11 +91,7 @@ export default function useRecoveryPrompts({ } const shouldPrompt = loginCount > 0 && (loginCount <= 3 || (loginCount - 3) % 5 === 0); - if ( - shouldPrompt && - !visible && - lastPromptCount.current !== loginCount - ) { + if (shouldPrompt && !visible && lastPromptCount.current !== loginCount) { showModal(); lastPromptCount.current = loginCount; } @@ -115,7 +111,9 @@ export default function useRecoveryPrompts({ ]); useEffect(() => { - void maybePrompt(); + maybePrompt().catch(() => { + // Ignore promise rejection - already handled in maybePrompt + }); }, [maybePrompt]); useEffect(() => { @@ -126,7 +124,9 @@ export default function useRecoveryPrompts({ (previousState === 'background' || previousState === 'inactive') && nextState === 'active' ) { - void maybePrompt(); + maybePrompt().catch(() => { + // Ignore promise rejection - already handled in maybePrompt + }); } }; const subscription = AppState.addEventListener( @@ -140,7 +140,9 @@ export default function useRecoveryPrompts({ useEffect(() => { const unsubscribe = navigationRef.addListener?.('state', () => { - void maybePrompt(); + maybePrompt().catch(() => { + // Ignore promise rejection - already handled in maybePrompt + }); }); return () => { if (typeof unsubscribe === 'function') { diff --git a/app/tests/src/hooks/useRecoveryPrompts.test.ts b/app/tests/src/hooks/useRecoveryPrompts.test.ts index c630ab124..27ee25c36 100644 --- a/app/tests/src/hooks/useRecoveryPrompts.test.ts +++ b/app/tests/src/hooks/useRecoveryPrompts.test.ts @@ -4,6 +4,15 @@ import { act, renderHook, waitFor } from '@testing-library/react-native'; +import { + CRITICAL_RECOVERY_PROMPT_ROUTES, + RECOVERY_PROMPT_ALLOWED_ROUTES, +} from '@/consts/recoveryPrompts'; +import { useModal } from '@/hooks/useModal'; +import useRecoveryPrompts from '@/hooks/useRecoveryPrompts'; +import { usePassport } from '@/providers/passportDataProvider'; +import { useSettingStore } from '@/stores/settingStore'; + const navigationStateListeners: Array<() => void> = []; let isNavigationReady = true; const navigationRef = { @@ -34,30 +43,23 @@ jest.mock('react-native', () => { ...actual, AppState: { currentState: 'active', - addEventListener: jest.fn((_: string, handler: (state: string) => void) => { - appStateListeners.push(handler); - return { - remove: () => { - const index = appStateListeners.indexOf(handler); - if (index >= 0) { - appStateListeners.splice(index, 1); - } - }, - }; - }), + addEventListener: jest.fn( + (_: string, handler: (state: string) => void) => { + appStateListeners.push(handler); + return { + remove: () => { + const index = appStateListeners.indexOf(handler); + if (index >= 0) { + appStateListeners.splice(index, 1); + } + }, + }; + }, + ), }, }; }); -import { - CRITICAL_RECOVERY_PROMPT_ROUTES, - RECOVERY_PROMPT_ALLOWED_ROUTES, -} from '@/consts/recoveryPrompts'; -import { useModal } from '@/hooks/useModal'; -import useRecoveryPrompts from '@/hooks/useRecoveryPrompts'; -import { usePassport } from '@/providers/passportDataProvider'; -import { useSettingStore } from '@/stores/settingStore'; - const showModal = jest.fn(); const getAllDocuments = jest.fn(); (usePassport as jest.Mock).mockReturnValue({ getAllDocuments }); @@ -111,7 +113,7 @@ describe('useRecoveryPrompts', () => { }); isNavigationReady = true; - navigationStateListeners.forEach((listener) => listener()); + navigationStateListeners.forEach(listener => listener()); await waitFor(() => { expect(showModal).toHaveBeenCalled(); @@ -120,7 +122,7 @@ describe('useRecoveryPrompts', () => { it.each([...CRITICAL_RECOVERY_PROMPT_ROUTES])( 'does not show modal when route %s is disallowed', - async (routeName) => { + async routeName => { navigationRef.getCurrentRoute.mockReturnValue({ name: routeName }); act(() => { useSettingStore.setState({ loginCount: 1 }); @@ -168,7 +170,7 @@ describe('useRecoveryPrompts', () => { renderHook(() => useRecoveryPrompts()); expect(showModal).not.toHaveBeenCalled(); - appStateListeners.forEach((listener) => listener('active')); + appStateListeners.forEach(listener => listener('active')); await waitFor(() => { expect(showModal).toHaveBeenCalled(); diff --git a/app/tests/src/navigation.test.ts b/app/tests/src/navigation.test.tsx similarity index 84% rename from app/tests/src/navigation.test.ts rename to app/tests/src/navigation.test.tsx index 9dfef6b0b..53aa94186 100644 --- a/app/tests/src/navigation.test.ts +++ b/app/tests/src/navigation.test.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: BUSL-1.1 // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. -import React from 'react'; +import React, { forwardRef } from 'react'; import { render } from '@testing-library/react-native'; import { RECOVERY_PROMPT_ALLOWED_ROUTES } from '@/consts/recoveryPrompts'; @@ -21,25 +21,29 @@ jest.mock('@selfxyz/mobile-sdk-alpha', () => ({ jest.mock('@/utils/deeplinks', () => ({ setupUniversalLinkListenerInNavigation: jest.fn(() => jest.fn()), })); -jest.mock('@/utils/analytics', () => jest.fn(() => ({ - trackScreenView: jest.fn(), -}))); +jest.mock('@/utils/analytics', () => + jest.fn(() => ({ + trackScreenView: jest.fn(), + })), +); jest.mock('react-native-gesture-handler', () => ({ - GestureHandlerRootView: ({ children }: { children: React.ReactNode }) => ( - <>{children} - ), + GestureHandlerRootView: ({ children }: { children: React.ReactNode }) => + children, })); jest.mock('@react-navigation/native', () => { - const React = require('react'); return { createNavigationContainerRef: jest.fn(() => mockNavigationRef), - createStaticNavigation: jest.fn(() => - React.forwardRef((props: any) => <>{props.children}), - ), + createStaticNavigation: jest.fn(() => { + const MockNavigator = forwardRef( + (props: any, _ref: any) => props.children, + ); + MockNavigator.displayName = 'MockNavigator'; + return MockNavigator; + }), }; }); jest.mock('@react-navigation/native-stack', () => ({ - createNativeStackNavigator: jest.fn((config) => config), + createNativeStackNavigator: jest.fn(config => config), })); describe('navigation', () => { @@ -102,7 +106,8 @@ describe('navigation', () => { }); it('wires recovery prompts hook into navigation', () => { - const useRecoveryPrompts = require('@/hooks/useRecoveryPrompts') as jest.Mock; + const useRecoveryPrompts = + require('@/hooks/useRecoveryPrompts') as jest.Mock; jest.isolateModules(() => { const NavigationWithTracking = require('@/navigation').default; render(); From 7ffa2bdd62e923bb9598becd94f8ef1a75e19a6c Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Thu, 9 Oct 2025 15:00:16 -0700 Subject: [PATCH 4/6] fix header --- app/src/consts/recoveryPrompts.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/src/consts/recoveryPrompts.ts b/app/src/consts/recoveryPrompts.ts index ac4d228b6..d39936a56 100644 --- a/app/src/consts/recoveryPrompts.ts +++ b/app/src/consts/recoveryPrompts.ts @@ -9,9 +9,6 @@ export type RecoveryPromptAllowedRoute = (typeof RECOVERY_PROMPT_ALLOWED_ROUTES)[number]; -// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. -// SPDX-License-Identifier: BUSL-1.1 -// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. /** * Screens where we intentionally show the recovery reminder. This allow list is * intentionally short so that new product surfaces do not accidentally inherit From d2a1f2fe08ca2019802d9cd77be044e8e9b21864 Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Thu, 9 Oct 2025 15:33:03 -0700 Subject: [PATCH 5/6] fix app loading --- app/src/hooks/useModal.ts | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/app/src/hooks/useModal.ts b/app/src/hooks/useModal.ts index 2e29010ca..89b74e6ac 100644 --- a/app/src/hooks/useModal.ts +++ b/app/src/hooks/useModal.ts @@ -3,10 +3,8 @@ // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. import { useCallback, useRef, useState } from 'react'; -import { useNavigation } from '@react-navigation/native'; -import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import type { RootStackParamList } from '@/navigation'; +import { navigationRef } from '@/navigation'; import type { ModalParams } from '@/screens/app/ModalScreen'; import { getModalCallbacks, @@ -16,23 +14,28 @@ import { export const useModal = (params: ModalParams) => { const [visible, setVisible] = useState(false); - const navigation = - useNavigation>(); const callbackIdRef = useRef(); const showModal = useCallback(() => { + if (!navigationRef.isReady()) { + // Navigation not ready yet; avoid throwing and simply skip showing + return; + } setVisible(true); const { onButtonPress, onModalDismiss, ...rest } = params; const id = registerModalCallbacks({ onButtonPress, onModalDismiss }); callbackIdRef.current = id; - navigation.navigate('Modal', { ...rest, callbackId: id }); - }, [params, navigation]); + navigationRef.navigate('Modal', { ...rest, callbackId: id }); + }, [params]); const dismissModal = useCallback(() => { setVisible(false); - const routes = navigation.getState()?.routes; + if (!navigationRef.isReady()) { + return; + } + const routes = navigationRef.getState()?.routes; if (routes?.at(routes.length - 1)?.name === 'Modal') { - navigation.goBack(); + navigationRef.goBack(); } if (callbackIdRef.current !== undefined) { const callbacks = getModalCallbacks(callbackIdRef.current); @@ -47,7 +50,7 @@ export const useModal = (params: ModalParams) => { unregisterModalCallbacks(callbackIdRef.current); callbackIdRef.current = undefined; } - }, [navigation]); + }, []); return { showModal, From 0ca1044483263e81c18dfa5dad7203e63dad3807 Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Thu, 9 Oct 2025 16:55:58 -0700 Subject: [PATCH 6/6] fix tests --- app/jest.setup.js | 41 ++++++++++++--- app/tests/src/hooks/useModal.test.ts | 34 +++++-------- .../src/hooks/useRecoveryPrompts.test.ts | 50 ++++++++++--------- app/tests/src/navigation.test.tsx | 47 ++++++----------- 4 files changed, 89 insertions(+), 83 deletions(-) diff --git a/app/jest.setup.js b/app/jest.setup.js index 36d1c068b..d8c1bcc44 100644 --- a/app/jest.setup.js +++ b/app/jest.setup.js @@ -682,9 +682,25 @@ jest.mock('./src/utils/notifications/notificationService', () => require('./tests/__setup__/notificationServiceMock.js'), ); +// Create a global mock navigation ref that can be used by all tests +global.mockNavigationRef = { + isReady: jest.fn(() => true), + navigate: jest.fn(), + goBack: jest.fn(), + canGoBack: jest.fn(() => true), + dispatch: jest.fn(), + addListener: jest.fn(() => jest.fn()), + removeListener: jest.fn(), + getCurrentRoute: jest.fn(() => ({ name: 'Home' })), + getState: jest.fn(() => ({ routes: [{ name: 'Home' }], index: 0 })), + resetRoot: jest.fn(), + getRootState: jest.fn(), +}; + // Mock React Navigation jest.mock('@react-navigation/native', () => { const actualNav = jest.requireActual('@react-navigation/native'); + const React = jest.requireActual('react'); return { ...actualNav, useNavigation: jest.fn(() => ({ @@ -692,18 +708,27 @@ jest.mock('@react-navigation/native', () => { goBack: jest.fn(), canGoBack: jest.fn(() => true), dispatch: jest.fn(), + getState: jest.fn(() => ({ routes: [{ name: 'Home' }], index: 0 })), })), - createNavigationContainerRef: jest.fn(() => ({ - current: null, - getCurrentRoute: jest.fn(), - })), - createStaticNavigation: jest.fn(() => ({ displayName: 'MockNavigation' })), + createNavigationContainerRef: jest.fn(() => global.mockNavigationRef), + createStaticNavigation: jest.fn(() => { + const MockNavigator = React.forwardRef((props, _ref) => props.children); + MockNavigator.displayName = 'MockNavigator'; + return MockNavigator; + }), }; }); jest.mock('@react-navigation/native-stack', () => ({ - createNativeStackNavigator: jest.fn(() => ({ - displayName: 'MockStackNavigator', - })), + createNativeStackNavigator: jest.fn(config => config), createNavigatorFactory: jest.fn(), })); + +// Mock the navigation module to provide navigationRef to all components/hooks +// This mock will be used by default, but individual tests can override it +jest.mock('./src/navigation', () => ({ + __esModule: true, + navigationRef: global.mockNavigationRef, + navigationScreens: {}, + default: () => null, +})); diff --git a/app/tests/src/hooks/useModal.test.ts b/app/tests/src/hooks/useModal.test.ts index ad2564ff6..48fd02729 100644 --- a/app/tests/src/hooks/useModal.test.ts +++ b/app/tests/src/hooks/useModal.test.ts @@ -2,32 +2,22 @@ // SPDX-License-Identifier: BUSL-1.1 // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. -import { useNavigation } from '@react-navigation/native'; import { act, renderHook } from '@testing-library/react-native'; import { useModal } from '@/hooks/useModal'; import { getModalCallbacks } from '@/utils/modalCallbackRegistry'; -jest.mock('@react-navigation/native', () => ({ - useNavigation: jest.fn(), -})); - -const mockNavigate = jest.fn(); -const mockGoBack = jest.fn(); -const mockGetState = jest.fn(() => ({ - routes: [{ name: 'Home' }, { name: 'Modal' }], -})); - describe('useModal', () => { beforeEach(() => { - (useNavigation as jest.Mock).mockReturnValue({ - navigate: mockNavigate, - goBack: mockGoBack, - getState: mockGetState, + // Reset all mocks including the global navigationRef + jest.clearAllMocks(); + + // Set up the navigation ref mock with proper methods + global.mockNavigationRef.isReady.mockReturnValue(true); + global.mockNavigationRef.getState.mockReturnValue({ + routes: [{ name: 'Home' }, { name: 'Modal' }], + index: 1, }); - mockNavigate.mockClear(); - mockGoBack.mockClear(); - mockGetState.mockClear(); }); it('should navigate to Modal with callbackId and handle dismissal', () => { @@ -45,8 +35,10 @@ describe('useModal', () => { act(() => result.current.showModal()); - expect(mockNavigate).toHaveBeenCalledTimes(1); - const params = mockNavigate.mock.calls[0][1]; + expect(global.mockNavigationRef.navigate).toHaveBeenCalledTimes(1); + const [screenName, params] = + global.mockNavigationRef.navigate.mock.calls[0]; + expect(screenName).toBe('Modal'); expect(params).toMatchObject({ titleText: 'Title', bodyText: 'Body', @@ -58,7 +50,7 @@ describe('useModal', () => { act(() => result.current.dismissModal()); - expect(mockGoBack).toHaveBeenCalled(); + expect(global.mockNavigationRef.goBack).toHaveBeenCalled(); expect(onModalDismiss).toHaveBeenCalled(); expect(getModalCallbacks(id)).toBeUndefined(); }); diff --git a/app/tests/src/hooks/useRecoveryPrompts.test.ts b/app/tests/src/hooks/useRecoveryPrompts.test.ts index 27ee25c36..d30926491 100644 --- a/app/tests/src/hooks/useRecoveryPrompts.test.ts +++ b/app/tests/src/hooks/useRecoveryPrompts.test.ts @@ -15,28 +15,10 @@ import { useSettingStore } from '@/stores/settingStore'; const navigationStateListeners: Array<() => void> = []; let isNavigationReady = true; -const navigationRef = { - isReady: jest.fn(() => isNavigationReady), - navigate: jest.fn(), - addListener: jest.fn((_: string, callback: () => void) => { - navigationStateListeners.push(callback); - return () => { - const index = navigationStateListeners.indexOf(callback); - if (index >= 0) { - navigationStateListeners.splice(index, 1); - } - }; - }), - getCurrentRoute: jest.fn(() => ({ name: 'Home' })), -} as any; - const appStateListeners: Array<(state: string) => void> = []; jest.mock('@/hooks/useModal'); jest.mock('@/providers/passportDataProvider'); -jest.mock('@/navigation', () => ({ - navigationRef, -})); jest.mock('react-native', () => { const actual = jest.requireActual('react-native'); return { @@ -76,8 +58,24 @@ describe('useRecoveryPrompts', () => { navigationStateListeners.length = 0; appStateListeners.length = 0; isNavigationReady = true; - navigationRef.isReady.mockImplementation(() => isNavigationReady); - navigationRef.getCurrentRoute.mockReturnValue({ name: 'Home' }); + + // Setup the global navigation ref mock + global.mockNavigationRef.isReady.mockImplementation( + () => isNavigationReady, + ); + global.mockNavigationRef.getCurrentRoute.mockReturnValue({ name: 'Home' }); + global.mockNavigationRef.addListener.mockImplementation( + (_: string, callback: () => void) => { + navigationStateListeners.push(callback); + return () => { + const index = navigationStateListeners.indexOf(callback); + if (index >= 0) { + navigationStateListeners.splice(index, 1); + } + }; + }, + ); + (useModal as jest.Mock).mockReturnValue({ showModal, visible: false }); getAllDocuments.mockResolvedValue({ doc1: {} as any }); const AppState = getAppState(); @@ -103,7 +101,9 @@ describe('useRecoveryPrompts', () => { it('waits for navigation readiness before prompting', async () => { isNavigationReady = false; - navigationRef.isReady.mockImplementation(() => isNavigationReady); + global.mockNavigationRef.isReady.mockImplementation( + () => isNavigationReady, + ); act(() => { useSettingStore.setState({ loginCount: 1 }); }); @@ -123,7 +123,9 @@ describe('useRecoveryPrompts', () => { it.each([...CRITICAL_RECOVERY_PROMPT_ROUTES])( 'does not show modal when route %s is disallowed', async routeName => { - navigationRef.getCurrentRoute.mockReturnValue({ name: routeName }); + global.mockNavigationRef.getCurrentRoute.mockReturnValue({ + name: routeName, + }); act(() => { useSettingStore.setState({ loginCount: 1 }); }); @@ -147,7 +149,9 @@ describe('useRecoveryPrompts', () => { }); showModal.mockClear(); - navigationRef.getCurrentRoute.mockReturnValue({ name: 'Settings' }); + global.mockNavigationRef.getCurrentRoute.mockReturnValue({ + name: 'Settings', + }); renderHook(() => useRecoveryPrompts({ diff --git a/app/tests/src/navigation.test.tsx b/app/tests/src/navigation.test.tsx index 53aa94186..061fb07a0 100644 --- a/app/tests/src/navigation.test.tsx +++ b/app/tests/src/navigation.test.tsx @@ -2,18 +2,11 @@ // SPDX-License-Identifier: BUSL-1.1 // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. -import React, { forwardRef } from 'react'; +import React from 'react'; import { render } from '@testing-library/react-native'; import { RECOVERY_PROMPT_ALLOWED_ROUTES } from '@/consts/recoveryPrompts'; -const mockNavigationRef = { - isReady: jest.fn(() => true), - navigate: jest.fn(), - addListener: jest.fn(() => jest.fn()), - getCurrentRoute: jest.fn(() => ({ name: 'Home' })), -} as any; - jest.mock('@/hooks/useRecoveryPrompts', () => jest.fn()); jest.mock('@selfxyz/mobile-sdk-alpha', () => ({ useSelfClient: jest.fn(() => ({})), @@ -26,25 +19,6 @@ jest.mock('@/utils/analytics', () => trackScreenView: jest.fn(), })), ); -jest.mock('react-native-gesture-handler', () => ({ - GestureHandlerRootView: ({ children }: { children: React.ReactNode }) => - children, -})); -jest.mock('@react-navigation/native', () => { - return { - createNavigationContainerRef: jest.fn(() => mockNavigationRef), - createStaticNavigation: jest.fn(() => { - const MockNavigator = forwardRef( - (props: any, _ref: any) => props.children, - ); - MockNavigator.displayName = 'MockNavigator'; - return MockNavigator; - }), - }; -}); -jest.mock('@react-navigation/native-stack', () => ({ - createNativeStackNavigator: jest.fn(config => config), -})); describe('navigation', () => { beforeEach(() => { @@ -52,6 +26,8 @@ describe('navigation', () => { }); it('should have the correct navigation screens', () => { + // Unmock @/navigation for this test to get the real navigationScreens + jest.unmock('@/navigation'); jest.isolateModules(() => { const navigationScreens = require('@/navigation').navigationScreens; const listOfScreens = Object.keys(navigationScreens).sort(); @@ -106,12 +82,21 @@ describe('navigation', () => { }); it('wires recovery prompts hook into navigation', () => { + // Temporarily restore the React mock and unmock @/navigation for this test + jest.unmock('@/navigation'); const useRecoveryPrompts = require('@/hooks/useRecoveryPrompts') as jest.Mock; - jest.isolateModules(() => { - const NavigationWithTracking = require('@/navigation').default; - render(); - }); + + // Since we're testing the wiring and not the actual rendering, + // we can just check if the module exports the default component + // and verify the hook is called when the component is imported + const navigation = require('@/navigation'); + expect(navigation.default).toBeDefined(); + + // Render the component to trigger the hooks + const NavigationWithTracking = navigation.default; + render(); + expect(useRecoveryPrompts).toHaveBeenCalledWith({ allowedRoutes: RECOVERY_PROMPT_ALLOWED_ROUTES, });