diff --git a/shared/chat/inbox/reselect-conversation.test.ts b/shared/chat/inbox/reselect-conversation.test.ts
index aba95f2a25ed..c0daf574ea06 100644
--- a/shared/chat/inbox/reselect-conversation.test.ts
+++ b/shared/chat/inbox/reselect-conversation.test.ts
@@ -1,22 +1,16 @@
///
import * as T from '@/constants/types'
+import * as Tabs from '@/constants/tabs'
import {resetAllStores} from '@/util/zustand'
import {useConfigState} from '@/stores/config'
-jest.mock('@/constants/router', () => ({
- getModalStack: jest.fn(() => []),
- getVisibleScreen: jest.fn(() => undefined),
- navigateToInbox: jest.fn(),
- navigateToThread: jest.fn(),
-}))
-
jest.mock('@/constants/chat/common', () => ({
...jest.requireActual('@/constants/chat/common'),
getSelectedConversation: jest.fn(),
}))
import * as Common from '@/constants/chat/common'
-import {navigateToInbox, navigateToThread} from '@/constants/router'
+import {installFakeNavigator, makeRootState, restoreNavigator, type FakeNavigator} from '@/test/fake-navigator'
import {maybeChangeSelectedConversation} from './metadata'
const newConvID = 'ff00ff00'
@@ -25,12 +19,21 @@ const mockedSelected = Common.getSelectedConversation as jest.Mock
const layout = (over: Partial): T.RPCChat.UIInboxLayout =>
({reselectInfo: {oldConvID: '', ...over}}) as T.RPCChat.UIInboxLayout
+let nav: FakeNavigator
+
+// navigateToInbox defers a tick, so every assertion below has to let that tick run.
+const runDeferredNavigation = () => jest.advanceTimersByTime(1)
+
beforeEach(() => {
+ jest.useFakeTimers()
+ nav = installFakeNavigator()
useConfigState.setState({loggedIn: true})
global.isMobile = true
})
afterEach(() => {
+ restoreNavigator()
+ jest.useRealTimers()
jest.clearAllMocks()
resetAllStores()
global.isMobile = false
@@ -45,8 +48,8 @@ test('a reselect while a conversation creation is pending does not pop to the in
maybeChangeSelectedConversation(layout({newConvID}))
- expect(navigateToInbox).not.toHaveBeenCalled()
- expect(navigateToThread).not.toHaveBeenCalled()
+ runDeferredNavigation()
+ expect(nav.actions).toEqual([])
})
test('a reselect while the create error screen is up does not pop to the inbox', () => {
@@ -54,7 +57,8 @@ test('a reselect while the create error screen is up does not pop to the inbox',
maybeChangeSelectedConversation(layout({newConvID}))
- expect(navigateToInbox).not.toHaveBeenCalled()
+ runDeferredNavigation()
+ expect(nav.actions).toEqual([])
})
// the real "we are on a dead conversation" case still has to bounce
@@ -63,5 +67,20 @@ test('a reselect with nothing selected still goes to the inbox on mobile', () =>
maybeChangeSelectedConversation(layout({newConvID}))
- expect(navigateToInbox).toHaveBeenCalledWith(false)
+ runDeferredNavigation()
+ // navigateToInbox(false): stay on the chat tab and pop its stack back to the inbox
+ expect(nav.types()).toContain('POP_TO')
+ expect(nav.lastAction()?.payload).toMatchObject({name: 'chatRoot'})
+})
+
+// The bounce is navigateToInbox(false): it must not pull the user off whatever tab they
+// are on. Only the chat tab's own stack gets popped.
+test('a reselect while another tab is up leaves that tab alone', () => {
+ nav = installFakeNavigator({rootState: makeRootState({tab: Tabs.teamsTab})})
+ mockedSelected.mockReturnValue(T.Chat.noConversationIDKey)
+
+ maybeChangeSelectedConversation(layout({newConvID}))
+
+ runDeferredNavigation()
+ expect(nav.actions).toEqual([])
})
diff --git a/shared/common-adapters/name-with-icon.test.tsx b/shared/common-adapters/name-with-icon.test.tsx
index cbeb0206c674..e25b46afdc0f 100644
--- a/shared/common-adapters/name-with-icon.test.tsx
+++ b/shared/common-adapters/name-with-icon.test.tsx
@@ -17,7 +17,6 @@ jest.mock('@/stores/followers', () => ({
},
}))
jest.mock('@/teams/use-teams-list', () => ({useTeamsListNameToIDMap: () => new Map()}))
-jest.mock('@/constants/router', () => ({navToProfile: jest.fn()}))
jest.mock('./avatar', () => ({
__esModule: true,
default: ({
diff --git a/shared/constants/navigate-to-thread.test.ts b/shared/constants/navigate-to-thread.test.ts
index ce66ad1b5589..0a9274262e3a 100644
--- a/shared/constants/navigate-to-thread.test.ts
+++ b/shared/constants/navigate-to-thread.test.ts
@@ -3,12 +3,16 @@
jest.mock('@/constants/chat/layout', () => ({isSplit: false, threadRouteName: 'chatConversation'}))
import * as T from '@/constants/types'
-import {navigateToPendingThread, navigateToThread, navigationRef, setModalRouteNames} from '@/constants/router'
+import type {NavState} from '@/constants/nav-tree'
+import {navigateToPendingThread, navigateToThread} from '@/constants/router'
+import {installFakeNavigator, restoreNavigator, type FakeNavigator, type RecordedAction} from '@/test/fake-navigator'
import {useInboxMetadataState} from '@/chat/inbox/metadata-store'
import {useCurrentUserState} from '@/stores/current-user'
import {useInputIntentState} from '@/chat/conversation/input-intent-store'
-const dispatch = jest.fn()
+let nav: FakeNavigator
+// Set per test by the ordering tests; called at the moment of dispatch.
+let onDispatch: ((action: RecordedAction) => void) | undefined
const loggedIn = {
key: 'loggedIn-1',
@@ -27,15 +31,17 @@ const loggedIn = {
},
}
+// Installs a root state and marks the navigator ready, i.e. the container has mounted.
const setRootRoutes = (routes: Array) => {
- const state = {index: routes.length - 1, key: 'root-1', routeNames: [], routes, stale: false, type: 'stack'}
- // the jest mock's container ref is a plain object, so stub its methods directly
- const nr = navigationRef as unknown as Record
- nr['current'] = {}
- nr['dispatch'] = dispatch
- nr['getRootState'] = () => state
- nr['isReady'] = () => true
- nr['addListener'] = () => () => {}
+ nav.setReady(true)
+ nav.setRootState({
+ index: routes.length - 1,
+ key: 'root-1',
+ routeNames: [],
+ routes,
+ stale: false,
+ type: 'stack',
+ } as NavState)
}
const pendingRoute = {
@@ -45,16 +51,6 @@ const pendingRoute = {
}
const realConvID = 'ff00ff00' as T.Chat.ConversationIDKey
-// Distinct per deep-link test: navigateAppend's `_pendingAppend` "uncommitted dupe" cache is
-// module-level state that the mocked `addListener` never clears (the real navigator would fire
-// its 'state' listener and clear it; this stub's listener never fires), so a later test in this
-// file reusing `realConvID` with an equal-shaped params object would be silently caught by that
-// leftover cache instead of by the code under test. A conv id used nowhere else sidesteps that.
-//
-// Not laziness: there is no reset to put in beforeEach. `_pendingAppend` is module-private and
-// unexported, and jest.resetModules() would hand each test a fresh copy of constants/router with
-// its own `navigationRef`, so the stub installed by setRootRoutes would no longer be the one the
-// code under test reads. Distinct ids are the only lever from outside the module.
const deepLinkConvID = 'aa11aa11' as T.Chat.ConversationIDKey
const deepLinkConvID2 = 'bb22bb22' as T.Chat.ConversationIDKey
const optionsConvID = 'cc33cc33' as T.Chat.ConversationIDKey
@@ -63,11 +59,19 @@ const optionsConvID3 = 'ee55ee55' as T.Chat.ConversationIDKey
const optionsConvID4 = 'ff66ff66' as T.Chat.ConversationIDKey
beforeEach(() => {
- dispatch.mockReset()
- setModalRouteNames(['chatNewChat'])
+ onDispatch = undefined
+ nav = installFakeNavigator({
+ modalRouteNames: ['chatNewChat'],
+ onDispatch: action => onDispatch?.(action),
+ ready: false,
+ })
useInputIntentState.getState().dispatch.resetState()
})
+afterEach(() => {
+ restoreNavigator()
+})
+
// Creating a conversation parks the thread screen on PENDING-WAITING while the RPC runs, so the
// resolved conv is the same chat arriving on the same screen. react-native-screens always animates
// a replace on iOS, so a StackActions.replace here (like a push) makes one new chat read as two
@@ -77,11 +81,11 @@ test('pending -> resolved conversation retargets the live screen instead of anim
navigateToThread(realConvID, 'justCreated')
- expect(dispatch).toHaveBeenCalledTimes(1)
- const action = dispatch.mock.calls[0]?.[0] as {type: string; payload: unknown; source?: string}
- expect(action.type).toBe('SET_PARAMS')
- expect(action.source).toBe(pendingRoute.key)
- expect(action.payload).toMatchObject({conversationIDKey: realConvID})
+ expect(nav.actions).toHaveLength(1)
+ const action = nav.actions[0]
+ expect(action?.type).toBe('SET_PARAMS')
+ expect(action?.source).toBe(pendingRoute.key)
+ expect(action?.payload?.['params']).toMatchObject({conversationIDKey: realConvID})
})
test('no thread on screen still pushes the conversation', () => {
@@ -89,10 +93,10 @@ test('no thread on screen still pushes the conversation', () => {
navigateToThread(realConvID, 'justCreated')
- expect(dispatch).toHaveBeenCalledTimes(1)
- const action = dispatch.mock.calls[0]?.[0] as {type: string; payload: {name: string}}
- expect(action.type).toBe('PUSH')
- expect(action.payload.name).toBe('chatConversation')
+ expect(nav.actions).toHaveLength(1)
+ const action = nav.actions[0]
+ expect(action?.type).toBe('PUSH')
+ expect(action?.payload?.['name']).toBe('chatConversation')
})
// The old `sameVisibleThread && highlightMessageID` early return is gone, so every call issued
@@ -115,11 +119,11 @@ test('reissuing navigateToThread on the same visible thread retargets instead of
navigateToThread(realConvID, 'createdMessagePrivately')
- expect(dispatch).toHaveBeenCalledTimes(1)
- const action = dispatch.mock.calls[0]?.[0] as {type: string; payload: unknown; source?: string}
- expect(action.type).toBe('SET_PARAMS')
- expect(action.source).toBe(visibleThreadRoute.key)
- expect(action.payload).toMatchObject({conversationIDKey: realConvID})
+ expect(nav.actions).toHaveLength(1)
+ const action = nav.actions[0]
+ expect(action?.type).toBe('SET_PARAMS')
+ expect(action?.source).toBe(visibleThreadRoute.key)
+ expect(action?.payload?.['params']).toMatchObject({conversationIDKey: realConvID})
})
// A conversation opened via a `keybase://convid/` deep link lands on chatConversation with
@@ -137,11 +141,11 @@ test('reissuing navigateToThread on a deep-linked thread (single-key params) doe
navigateToThread(deepLinkConvID, 'createdMessagePrivately')
- expect(dispatch).toHaveBeenCalledTimes(1)
- const action = dispatch.mock.calls[0]?.[0] as {type: string; payload: unknown; source?: string}
- expect(action.type).toBe('SET_PARAMS')
- expect(action.source).toBe(deepLinkedThreadRoute.key)
- expect(action.payload).toMatchObject({conversationIDKey: deepLinkConvID})
+ expect(nav.actions).toHaveLength(1)
+ const action = nav.actions[0]
+ expect(action?.type).toBe('SET_PARAMS')
+ expect(action?.source).toBe(deepLinkedThreadRoute.key)
+ expect(action?.payload?.['params']).toMatchObject({conversationIDKey: deepLinkConvID})
})
// Same shape as the deep-link case above, but reached by a reason that never carried an intent -
@@ -157,10 +161,10 @@ test('a plain re-navigate to a deep-linked thread does not push a duplicate', ()
navigateToThread(deepLinkConvID2, 'focused')
- expect(dispatch).toHaveBeenCalledTimes(1)
- const action = dispatch.mock.calls[0]?.[0] as {type: string; payload: unknown; source?: string}
- expect(action.type).toBe('SET_PARAMS')
- expect(action.source).toBe(deepLinkedThreadRoute.key)
+ expect(nav.actions).toHaveLength(1)
+ const action = nav.actions[0]
+ expect(action?.type).toBe('SET_PARAMS')
+ expect(action?.source).toBe(deepLinkedThreadRoute.key)
})
// The options object replaced a positional tail (highlightMessageID, threadSearchQuery,
@@ -170,9 +174,9 @@ test('the options object writes the intent before navigating and forwards thread
setRootRoutes([loggedIn])
const messageID = T.Chat.numberToMessageID(99)
const order: Array = []
- dispatch.mockImplementation(() => {
+ onDispatch = () => {
order.push(`intent:${String(useInputIntentState.getState().intents.has(optionsConvID))}`)
- })
+ }
navigateToThread(optionsConvID, 'justCreated', {
intent: {messageID, type: 'highlight'},
@@ -184,9 +188,9 @@ test('the options object writes the intent before navigating and forwards thread
messageID,
type: 'highlight',
})
- const action = dispatch.mock.calls[0]?.[0] as {type: string; payload: {params: object}}
- expect(action.type).toBe('PUSH')
- expect(action.payload.params).toMatchObject({
+ const action = nav.actions[0]
+ expect(action?.type).toBe('PUSH')
+ expect(action?.payload?.['params']).toMatchObject({
conversationIDKey: optionsConvID,
threadSearch: {query: 'needle'},
})
@@ -201,7 +205,7 @@ test('an aborted navigation writes no intent', () => {
intent: {messageID: T.Chat.numberToMessageID(99), type: 'highlight'},
})
- expect(dispatch).not.toHaveBeenCalled()
+ expect(nav.actions).toEqual([])
expect(useInputIntentState.getState().intents.size).toBe(0)
})
@@ -212,9 +216,9 @@ test('an aborted navigation writes no intent', () => {
test('an injectText intent is written before navigating, and an undefined one writes nothing', () => {
setRootRoutes([loggedIn])
const order: Array = []
- dispatch.mockImplementation(() => {
+ onDispatch = () => {
order.push(`intent:${String(useInputIntentState.getState().intents.has(optionsConvID3))}`)
- })
+ }
navigateToThread(optionsConvID3, 'justCreated', {intent: {text: 'prefill me', type: 'injectText'}})
@@ -247,8 +251,7 @@ test('the pending thread is seeded with the participants so its header title is
const seeded = useInboxMetadataState.getState().participants.get(T.Chat.pendingWaitingConversationIDKey)
expect(seeded?.name).toEqual(['testuser', 'testuser-mac'])
- const action = dispatch.mock.calls[0]?.[0] as {type: string; payload: {params: object}}
- expect(action.payload.params).toMatchObject({
+ expect(nav.actions[0]?.payload?.['params']).toMatchObject({
conversationIDKey: T.Chat.pendingWaitingConversationIDKey,
})
})
@@ -259,18 +262,12 @@ test('the pending thread is seeded with the participants so its header title is
// handler before the nav container is ready. A durable intent left behind by a navigation that
// never occurred would fire on some later, unrelated mount of that conversation.
test('a navigation that cannot dispatch leaves no intent behind', () => {
- const nr = navigationRef as unknown as Record
- nr['current'] = undefined
- nr['dispatch'] = dispatch
- nr['getRootState'] = () => undefined
- nr['isReady'] = () => false
-
const convID = T.Chat.stringToConversationIDKey('conv-no-navigator')
navigateToThread(convID, 'push', {
intent: {messageID: T.Chat.numberToMessageID(7), type: 'highlight'},
})
- expect(dispatch).not.toHaveBeenCalled()
+ expect(nav.actions).toEqual([])
expect(useInputIntentState.getState().intents.get(convID)).toBeUndefined()
})
@@ -281,7 +278,7 @@ test('a navigation that does dispatch keeps the intent for the mount to consume'
intent: {messageID: T.Chat.numberToMessageID(7), type: 'highlight'},
})
- expect(dispatch).toHaveBeenCalled()
+ expect(nav.actions.length).toBeGreaterThan(0)
expect(useInputIntentState.getState().intents.get(convID)).toEqual({
messageID: T.Chat.numberToMessageID(7),
type: 'highlight',
diff --git a/shared/constants/navigator.tsx b/shared/constants/navigator.tsx
new file mode 100644
index 000000000000..375734ffb203
--- /dev/null
+++ b/shared/constants/navigator.tsx
@@ -0,0 +1,319 @@
+// The one seam between the app and React Navigation.
+//
+// Everything that changes navigation goes through a Navigator: a thing that can
+// dispatch an action, read the root state, say whether it is ready, and be listened
+// to. There are exactly two implementations - the real binding to the app's
+// NavigationContainerRef (below), and the in-memory fake in test/fake-navigator.
+//
+// The operations here are the ones that need the tree shape to decide what to
+// dispatch; they read it through NavTree and never re-derive it. Dispatches that
+// target a *specific* navigator rather than the root (e.g. a tab bar acting on the
+// navigation object handed to it by its own navigator) stay where they are - they
+// are not this seam.
+import * as NavTree from './nav-tree'
+import * as Tabs from './tabs'
+import {
+ CommonActions,
+ StackActions,
+ TabActions,
+ createNavigationContainerRef,
+ type NavigationContainerRef,
+} from '@react-navigation/core'
+import {registerDebugClear} from '@/util/debug-registry'
+import {shallowEqual} from './utils'
+import type {NavigateAppendType, RouteKeys, RootParamList} from '@/router-v2/route-params'
+
+type ContainerRef = NavigationContainerRef
+export type NavAction = Parameters[0]
+
+// What an adapter has to provide. Deliberately the smallest surface that the
+// operations below need, so a fake is a handful of lines rather than a mock of
+// React Navigation.
+export type NavigatorRef = {
+ isReady: () => boolean
+ getRootState: () => NavTree.NavState | undefined
+ dispatch: (action: NavAction) => void
+ addListener: (type: 'state', cb: () => void) => () => void
+}
+
+export type Navigator = NavigatorRef & {
+ navigateUp: () => void
+ popStack: () => void
+ clearModals: () => void
+ // Returns whether the target is now the visible route - either because we dispatched,
+ // or because we were already there. False means nothing happened and nothing will.
+ navigateAppend: (path: NavigateAppendType, replace?: boolean) => boolean
+ navUpToScreen: (nameOrPath: RouteKeys | NavigateAppendType, replaceIfMissing?: boolean) => void
+ switchTab: (name: Tabs.AppTab) => void
+ // Returns whether chatRoot now carries these params - by dispatch, or because it
+ // already did. False means the nav tree was not in a state where anything could happen.
+ setChatRootParams: (params: Partial>) => boolean
+}
+
+const DEBUG_NAV = __DEV__ && (false as boolean)
+
+export const makeNavigator = (ref: NavigatorRef): Navigator => {
+ // A push dispatched this tick isn't in getRootState() until React Navigation commits, so the
+ // visible-route dupe check below misses repeat taps that land before the commit (e.g. a janky JS
+ // thread queueing both). Track the in-flight push until the next state event; the time bound is a
+ // backstop in case the container tears down before the listener fires.
+ let pendingAppend: {name: string; params?: object; time: number} | undefined
+
+ const navigateUp = () => {
+ if (DEBUG_NAV) {
+ console.log('[Nav] navigateUp')
+ }
+ if (!ref.isReady()) return
+ ref.dispatch(CommonActions.goBack())
+ }
+
+ const popStack = () => {
+ if (DEBUG_NAV) {
+ console.log('[Nav] popStack')
+ }
+ if (!ref.isReady()) return
+ ref.dispatch(StackActions.popToTop())
+ }
+
+ const clearModals = () => {
+ if (DEBUG_NAV) {
+ console.log('[Nav] clearModals')
+ }
+ if (!ref.isReady()) return
+ const ns = ref.getRootState()
+ if (!NavTree.isLoggedIn(ns)) {
+ return
+ }
+ const rootRoutes = ns?.routes ?? []
+ const keepRoutes = rootRoutes.filter((route, index) => index === 0 || !NavTree.isModalRouteName(route.name))
+ if (keepRoutes.length !== rootRoutes.length) {
+ ref.dispatch({
+ ...CommonActions.reset({
+ ...ns,
+ index: keepRoutes.length - 1,
+ routes: keepRoutes,
+ } as Parameters[0]),
+ target: ns?.key,
+ })
+ }
+ }
+
+ const navigateAppend = (path: NavigateAppendType, replace?: boolean): boolean => {
+ if (DEBUG_NAV) {
+ console.log('[Nav] navigateAppend', {path})
+ }
+ if (!ref.isReady()) {
+ return false
+ }
+ const ns = ref.getRootState()
+ if (!ns) {
+ return false
+ }
+ const nextPath = path as {name: string | number | symbol; params: object}
+ const routeName = typeof nextPath.name === 'string' ? nextPath.name : String(nextPath.name)
+ const params = nextPath.params
+ if (!routeName) {
+ if (DEBUG_NAV) {
+ console.log('[Nav] navigateAppend no routeName bail', routeName)
+ }
+ return false
+ }
+ const visible = NavTree.visibleScreen(ns)
+ if (visible) {
+ if (routeName === visible.name && shallowEqual(visible.params, params)) {
+ console.log('Skipping append dupe')
+ // Already the visible route with these params - the caller's goal is met.
+ return true
+ }
+ }
+
+ if (replace) {
+ if (visible?.name === routeName) {
+ ref.dispatch(CommonActions.setParams(params))
+ return true
+ } else {
+ ref.dispatch(StackActions.replace(routeName, params))
+ return true
+ }
+ }
+
+ if (
+ pendingAppend?.name === routeName &&
+ shallowEqual(pendingAppend.params, params) &&
+ Date.now() - pendingAppend.time < 1000
+ ) {
+ console.log('Skipping append dupe (uncommitted)')
+ // An identical push is already in flight and uncommitted.
+ return true
+ }
+ pendingAppend = {name: routeName, params, time: Date.now()}
+ const unsub = ref.addListener('state', () => {
+ pendingAppend = undefined
+ unsub()
+ })
+ ref.dispatch(StackActions.push(routeName, params))
+ return true
+ }
+
+ const navUpToScreen = (nameOrPath: RouteKeys | NavigateAppendType, replaceIfMissing = false) => {
+ if (DEBUG_NAV) {
+ console.log('[Nav] navUpToScreen', {nameOrPath, replaceIfMissing})
+ }
+ if (!ref.isReady()) return
+ const activeStackState = NavTree.activeStack(ref.getRootState())
+ const activeStackKey = activeStackState?.key
+ if (typeof nameOrPath === 'string') {
+ const action = StackActions.popTo(nameOrPath)
+ ref.dispatch(activeStackKey ? {...action, target: activeStackKey} : action)
+ return
+ }
+
+ const routeName = nameOrPath.name
+ const params = nameOrPath.params as object
+
+ const activeStackRoutes = activeStackState?.routes as Array | undefined
+ let routeIndex = -1
+ if (activeStackRoutes) {
+ for (let i = activeStackRoutes.length - 1; i >= 0; i--) {
+ if (activeStackRoutes[i]?.name === routeName) {
+ routeIndex = i
+ break
+ }
+ }
+ }
+ if (routeIndex >= 0 && activeStackState) {
+ const nextRoutes = activeStackRoutes!
+ .slice(0, routeIndex + 1)
+ .map((route, index) => (index === routeIndex ? {...route, params} : route))
+ ref.dispatch({
+ ...CommonActions.reset({
+ ...activeStackState,
+ index: routeIndex,
+ routes: nextRoutes,
+ } as Parameters[0]),
+ target: activeStackKey,
+ })
+ return
+ }
+
+ if (replaceIfMissing) {
+ const action = StackActions.replace(routeName, params)
+ ref.dispatch(activeStackKey ? {...action, target: activeStackKey} : action)
+ return
+ }
+
+ const action = StackActions.popTo(routeName)
+ ref.dispatch(activeStackKey ? {...action, target: activeStackKey} : action)
+ }
+
+ const switchTab = (name: Tabs.AppTab) => {
+ if (DEBUG_NAV) {
+ console.log('[Nav] switchTab', {name})
+ }
+ if (!ref.isReady()) return
+ const tabNavState = NavTree.tabNavigatorState(ref.getRootState())
+ if (!tabNavState?.key) return
+ ref.dispatch({
+ ...TabActions.jumpTo(name),
+ target: tabNavState.key,
+ })
+ }
+
+ const setChatRootParams = (params: Partial>): boolean => {
+ if (!ref.isReady()) return false
+ const tabNavState = NavTree.tabNavigatorState(ref.getRootState())
+ if (!tabNavState?.key) return false
+ const tabRoutes = tabNavState.routes as Array
+ const chatTabIndex = tabRoutes.findIndex(r => r.name === Tabs.chatTab)
+ if (chatTabIndex < 0) return false
+ const chatTabRoute = tabRoutes[chatTabIndex]
+ const chatStackState = chatTabRoute?.state
+ const chatStackRoutes = chatStackState?.routes as Array | undefined
+ const chatStackIndex = chatStackState?.index ?? 0
+ const currentChatRoute = chatStackRoutes?.[chatStackIndex]
+ const currentChatRoot = chatStackRoutes?.[0]
+ const updatedRoutes = tabRoutes.map((route, i) => {
+ if (i !== chatTabIndex) return route
+ const currentParams = currentChatRoot?.name === 'chatRoot' ? currentChatRoot.params : undefined
+ return {
+ ...route,
+ state: {
+ ...(route.state ?? {}),
+ index: 0,
+ routes: [{name: 'chatRoot', params: {...currentParams, ...params}}],
+ },
+ }
+ })
+ const nextChatRoot = updatedRoutes[chatTabIndex]?.state?.routes[0]
+ if (
+ tabNavState.index === chatTabIndex &&
+ currentChatRoute?.name === 'chatRoot' &&
+ chatStackState?.key &&
+ nextChatRoot?.params
+ ) {
+ // When split chat is already showing chatRoot, update that route in place instead of
+ // resetting the whole tab navigator. This avoids an extra same-screen navigation when
+ // the tab becomes visible and chat selects a thread immediately afterward.
+ if (!shallowEqual(currentChatRoute.params, nextChatRoot.params)) {
+ ref.dispatch({
+ ...CommonActions.navigate('chatRoot', nextChatRoot.params, {merge: true}),
+ target: chatStackState.key,
+ })
+ }
+ // Either we just merged the params in, or they were already what we wanted.
+ return true
+ }
+ ref.dispatch({
+ ...CommonActions.reset({...tabNavState, index: chatTabIndex, routes: updatedRoutes} as Parameters<
+ typeof CommonActions.reset
+ >[0]),
+ target: tabNavState.key,
+ })
+ return true
+ }
+
+ return {
+ addListener: ref.addListener,
+ clearModals,
+ dispatch: ref.dispatch,
+ getRootState: ref.getRootState,
+ isReady: ref.isReady,
+ navUpToScreen,
+ navigateAppend,
+ navigateUp,
+ popStack,
+ setChatRootParams,
+ switchTab,
+ }
+}
+
+// ---- The real adapter ----
+
+export const navigationRef = createNavigationContainerRef()
+
+registerDebugClear(() => {
+ navigationRef.current = null
+})
+
+const containerRefAdapter: NavigatorRef = {
+ addListener: (type, cb) => (navigationRef.isReady() ? navigationRef.addListener(type, cb) : () => {}),
+ dispatch: action => {
+ if (navigationRef.isReady()) {
+ navigationRef.dispatch(action)
+ }
+ },
+ getRootState: () => (navigationRef.isReady() ? navigationRef.getRootState() : undefined),
+ isReady: () => navigationRef.isReady(),
+}
+
+const realNavigator = makeNavigator(containerRefAdapter)
+
+let currentNavigator: Navigator = realNavigator
+
+export const getNavigator = () => currentNavigator
+
+// Swaps in another adapter - the in-memory fake in tests. Passing nothing restores
+// the real one.
+export const setNavigator = (navigator?: Navigator) => {
+ currentNavigator = navigator ?? realNavigator
+}
diff --git a/shared/constants/router.tsx b/shared/constants/router.tsx
index 4a296d4972fb..4c1cd2b072c3 100644
--- a/shared/constants/router.tsx
+++ b/shared/constants/router.tsx
@@ -5,21 +5,14 @@ import {clearInputIntent, setInputIntent, type InputIntent} from '@/chat/convers
import {refreshInboxLayout} from '@/chat/inbox/inbox-refresh'
import {useCurrentUserState} from '@/stores/current-user'
import * as Tabs from './tabs'
-import {
- StackActions,
- TabActions,
- CommonActions,
- type NavigationContainerRef,
- NavigationContext,
- createNavigationContainerRef,
-} from '@react-navigation/core'
+import {CommonActions, type NavigationContainerRef, NavigationContext} from '@react-navigation/core'
import type {StaticScreenProps} from '@react-navigation/core'
import type {NavigateAppendType, RouteKeys, RootParamList as KBRootParamList} from '@/router-v2/route-params'
import * as NavTree from './nav-tree'
+import {getNavigator} from './navigator'
import type {GetOptionsRet, RouteDef} from './types/router'
import {isSplit, threadRouteName} from './chat/layout'
-import {ignorePromise, shallowEqual} from './utils'
-import {registerDebugClear} from '@/util/debug-registry'
+import {ignorePromise} from './utils'
import {makeUUID} from '@/util/uuid'
import * as Meta from './chat/meta'
import * as Strings from './strings'
@@ -53,18 +46,12 @@ type ScreenComponent> = (
p: StaticScreenProps>
) => React.ReactElement
-export const navigationRef = createNavigationContainerRef()
-
-registerDebugClear(() => {
- navigationRef.current = null
-})
-
export type {Route, NavState} from './nav-tree'
-type Route = NavTree.Route
type NavState = NavTree.NavState
-export type Navigator = NavigationContainerRef
+export type NavigationRef = NavigationContainerRef
export {setModalRouteNames} from './nav-tree'
+export {navigationRef} from './navigator'
const DEBUG_NAV = __DEV__ && (false as boolean)
@@ -85,21 +72,11 @@ const uiParticipantsToParticipantInfo = (
return participantInfo
}
-export const getRootState = (): NavState | undefined => {
- if (!navigationRef.isReady()) return
- return navigationRef.getRootState()
-}
+export const getRootState = (): NavState | undefined => getNavigator().getRootState()
export const getTab = (navState?: T.Immutable): undefined | Tabs.Tab =>
NavTree.currentTab(navState || getRootState())
-export const _getNavigator = () => {
- return navigationRef.isReady() ? navigationRef : undefined
-}
-
-const getActiveStackState = (navState?: T.Immutable) =>
- NavTree.activeStack(navState || getRootState())
-
// Public API
// gives you loggedin/tab/stackitems + modals
export const getVisiblePath = (navState?: T.Immutable, includeModals?: boolean) =>
@@ -185,179 +162,32 @@ export function makeScreen>(
}
}
+// Free-function facade over the default Navigator. Every call site in the app goes
+// through these; the adapter underneath is what tests swap.
export const clearModals = () => {
- if (DEBUG_NAV) {
- console.log('[Nav] clearModals')
- }
- const n = _getNavigator()
- if (!n) return
- const ns = getRootState()
- if (!NavTree.isLoggedIn(ns)) {
- return
- }
- const rootRoutes = ns?.routes ?? []
- const keepRoutes = rootRoutes.filter((route, index) => index === 0 || !NavTree.isModalRouteName(route.name))
- if (keepRoutes.length !== rootRoutes.length) {
- n.dispatch({
- ...CommonActions.reset({
- ...ns,
- index: keepRoutes.length - 1,
- routes: keepRoutes,
- } as Parameters[0]),
- target: ns?.key,
- })
- }
+ getNavigator().clearModals()
}
export const navigateUp = () => {
- if (DEBUG_NAV) {
- console.log('[Nav] navigateUp')
- }
- const n = _getNavigator()
- return n?.dispatch(CommonActions.goBack())
+ getNavigator().navigateUp()
}
export const popStack = () => {
- if (DEBUG_NAV) {
- console.log('[Nav] popStack')
- }
- const n = _getNavigator()
- n?.dispatch(StackActions.popToTop())
+ getNavigator().popStack()
}
export function navUpToScreen(name: RouteKeys): void
export function navUpToScreen(path: NavigateAppendType, replaceIfMissing?: boolean): void
export function navUpToScreen(nameOrPath: RouteKeys | NavigateAppendType, replaceIfMissing = false) {
- if (DEBUG_NAV) {
- console.log('[Nav] navUpToScreen', {nameOrPath, replaceIfMissing})
- }
- const n = _getNavigator()
- if (!n) return
- const activeStackState = getActiveStackState()
- const activeStackKey = activeStackState?.key
- if (typeof nameOrPath === 'string') {
- const action = StackActions.popTo(nameOrPath)
- n.dispatch(activeStackKey ? {...action, target: activeStackKey} : action)
- return
- }
-
- const routeName = nameOrPath.name
- const params = nameOrPath.params as object
-
- const activeStackRoutes = activeStackState?.routes as Array | undefined
- let routeIndex = -1
- if (activeStackRoutes) {
- for (let i = activeStackRoutes.length - 1; i >= 0; i--) {
- if (activeStackRoutes[i]?.name === routeName) {
- routeIndex = i
- break
- }
- }
- }
- if (routeIndex >= 0 && activeStackState) {
- const nextRoutes = activeStackRoutes!.slice(0, routeIndex + 1).map((route, index) =>
- index === routeIndex ? {...route, params} : route
- )
- n.dispatch({
- ...CommonActions.reset({
- ...activeStackState,
- index: routeIndex,
- routes: nextRoutes,
- } as Parameters[0]),
- target: activeStackKey,
- })
- return
- }
-
- if (replaceIfMissing) {
- const action = StackActions.replace(routeName, params)
- n.dispatch(activeStackKey ? {...action, target: activeStackKey} : action)
- return
- }
-
- const action = StackActions.popTo(routeName)
- n.dispatch(activeStackKey ? {...action, target: activeStackKey} : action)
+ getNavigator().navUpToScreen(nameOrPath, replaceIfMissing)
}
-// A push dispatched this tick isn't in getRootState() until React Navigation commits, so the
-// visible-route dupe check below misses repeat taps that land before the commit (e.g. a janky JS
-// thread queueing both). Track the in-flight push until the next state event; the time bound is a
-// backstop in case the container tears down before the listener fires.
-let _pendingAppend: {name: string; params?: object; time: number} | undefined
-
-// Returns whether the target is now the visible route - either because we dispatched, or
-// because we were already there. False means nothing happened and nothing will.
export function navigateAppend(path: NavigateAppendType, replace?: boolean): boolean {
- if (DEBUG_NAV) {
- console.log('[Nav] navigateAppend', {path})
- }
- const n = _getNavigator()
- if (!n) {
- return false
- }
- const ns = getRootState()
- if (!ns) {
- return false
- }
- const nextPath = path as {name: string | number | symbol; params: object}
- const routeName = typeof nextPath.name === 'string' ? nextPath.name : String(nextPath.name)
- const params = nextPath.params
- if (!routeName) {
- if (DEBUG_NAV) {
- console.log('[Nav] navigateAppend no routeName bail', routeName)
- }
- return false
- }
- const vp = getVisiblePath(ns)
- const visible = vp.at(-1)
- if (visible) {
- if (routeName === visible.name && shallowEqual(visible.params, params)) {
- console.log('Skipping append dupe')
- // Already the visible route with these params - the caller's goal is met.
- return true
- }
- }
-
- if (replace) {
- if (visible?.name === routeName) {
- n.dispatch(CommonActions.setParams(params))
- return true
- } else {
- n.dispatch(StackActions.replace(routeName, params))
- return true
- }
- }
-
- if (
- _pendingAppend?.name === routeName &&
- shallowEqual(_pendingAppend.params, params) &&
- Date.now() - _pendingAppend.time < 1000
- ) {
- console.log('Skipping append dupe (uncommitted)')
- // An identical push is already in flight and uncommitted.
- return true
- }
- _pendingAppend = {name: routeName, params, time: Date.now()}
- const unsub = n.addListener('state', () => {
- _pendingAppend = undefined
- unsub()
- })
- n.dispatch(StackActions.push(routeName, params))
- return true
+ return getNavigator().navigateAppend(path, replace)
}
export const switchTab = (name: Tabs.AppTab) => {
- if (DEBUG_NAV) {
- console.log('[Nav] switchTab', {name})
- }
- const n = _getNavigator()
- if (!n) return
- const tabNavState = NavTree.tabNavigatorState(getRootState())
- if (!tabNavState?.key) return
- n.dispatch({
- ...TabActions.jumpTo(name),
- target: tabNavState.key,
- })
+ getNavigator().switchTab(name)
}
export const navToProfile = (username: string) => {
@@ -621,63 +451,8 @@ export const previewConversation = (p: PreviewConversationParams) => {
ignorePromise(previewConversationTeam())
}
-// Returns whether chatRoot now carries these params - by dispatch, or because it already did.
-// False means the nav tree was not in a state where anything could happen.
-export const setChatRootParams = (
- params: Partial>
-): boolean => {
- const n = _getNavigator()
- if (!n) return false
- const tabNavState = NavTree.tabNavigatorState(getRootState())
- if (!tabNavState?.key) return false
- const tabRoutes = tabNavState.routes as Array
- const chatTabIndex = tabRoutes.findIndex(r => r.name === Tabs.chatTab)
- if (chatTabIndex < 0) return false
- const chatTabRoute = tabRoutes[chatTabIndex]
- const chatStackState = chatTabRoute?.state
- const chatStackRoutes = chatStackState?.routes as Array | undefined
- const chatStackIndex = chatStackState?.index ?? 0
- const currentChatRoute = chatStackRoutes?.[chatStackIndex]
- const currentChatRoot = chatStackRoutes?.[0]
- const updatedRoutes = tabRoutes.map((route, i) => {
- if (i !== chatTabIndex) return route
- const currentParams = currentChatRoot?.name === 'chatRoot' ? currentChatRoot.params : undefined
- return {
- ...route,
- state: {
- ...(route.state ?? {}),
- index: 0,
- routes: [{name: 'chatRoot', params: {...currentParams, ...params}}],
- },
- }
- })
- const nextChatRoot = updatedRoutes[chatTabIndex]?.state?.routes[0]
- if (
- tabNavState.index === chatTabIndex &&
- currentChatRoute?.name === 'chatRoot' &&
- chatStackState?.key &&
- nextChatRoot?.params
- ) {
- // When split chat is already showing chatRoot, update that route in place instead of
- // resetting the whole tab navigator. This avoids an extra same-screen navigation when
- // the tab becomes visible and chat selects a thread immediately afterward.
- if (!shallowEqual(currentChatRoute.params, nextChatRoot.params)) {
- n.dispatch({
- ...CommonActions.navigate('chatRoot', nextChatRoot.params, {merge: true}),
- target: chatStackState.key,
- })
- }
- // Either we just merged the params in, or they were already what we wanted.
- return true
- }
- n.dispatch({
- ...CommonActions.reset({...tabNavState, index: chatTabIndex, routes: updatedRoutes} as Parameters<
- typeof CommonActions.reset
- >[0]),
- target: tabNavState.key,
- })
- return true
-}
+export const setChatRootParams = (params: Partial>): boolean =>
+ getNavigator().setChatRootParams(params)
export const setThreadInputCommandStatus = (
conversationIDKey: T.Chat.ConversationIDKey,
@@ -741,9 +516,9 @@ const navToThread = (
if (DEBUG_NAV) {
console.log('[Nav] navToThread', conversationIDKey)
}
- const n = _getNavigator()
- if (!n) return false
- const rs = getRootState()
+ const nav = getNavigator()
+ if (!nav.isReady()) return false
+ const rs = nav.getRootState()
if (!rs?.key) return false
const params = {
conversationIDKey,
@@ -760,7 +535,7 @@ const navToThread = (
} else {
// Phone: switch to the chat tab, then push the conversation above the tabs.
const nextState = NavTree.pushedAboveTabs(Tabs.chatTab, {name: 'chatConversation', params})
- n.dispatch({
+ nav.dispatch({
...CommonActions.reset(nextState as Parameters[0]),
target: rs.key,
})
@@ -845,9 +620,9 @@ export const navigateToThread = (
// re-measures a title subview it first measured empty, so a blank pending title would
// leave the bar blank for the real conv too. Same-conversation retargets ride this path
// too: the screen is already showing real content, so setParams is a plain in-place merge.
- const n = _getNavigator()
- n?.dispatch({...CommonActions.setParams(params), source: visible?.key})
- navigated = !!n
+ const nav = getNavigator()
+ nav.dispatch({...CommonActions.setParams(params), source: visible?.key})
+ navigated = nav.isReady()
} else {
navigated = navigateAppend({name: threadRouteName, params})
}
diff --git a/shared/constants/tests/navigator.test.ts b/shared/constants/tests/navigator.test.ts
new file mode 100644
index 000000000000..4fd169ed91b3
--- /dev/null
+++ b/shared/constants/tests/navigator.test.ts
@@ -0,0 +1,352 @@
+///
+import * as Tabs from '@/constants/tabs'
+import {CommonActions} from '@react-navigation/core'
+import {
+ clearModals,
+ navUpToScreen,
+ navigateAppend,
+ navigateUp,
+ popStack,
+ setChatRootParams,
+ switchTab,
+} from '@/constants/router'
+import {
+ installFakeNavigator,
+ makeRootState,
+ restoreNavigator,
+ type FakeNavigator,
+} from '@/test/fake-navigator'
+
+let nav: FakeNavigator
+
+afterEach(() => {
+ restoreNavigator()
+ jest.useRealTimers()
+})
+
+// The two adapters have to agree about readiness or the fake is not a stand-in: the real one
+// drops dispatches and reports no root state until the container has mounted, and callers rely
+// on that instead of guarding themselves (constants/router's same-conversation retarget
+// dispatches straight through the adapter and only then asks whether it was ready).
+describe('readiness', () => {
+ test('a not-ready navigator drops raw dispatches and reports no root state', () => {
+ nav = installFakeNavigator({ready: false})
+
+ nav.dispatch(CommonActions.goBack())
+
+ expect(nav.actions).toEqual([])
+ expect(nav.getRootState()).toBeUndefined()
+ })
+
+ test('and serves both once the container has mounted', () => {
+ nav = installFakeNavigator({ready: false})
+ nav.setReady(true)
+
+ nav.dispatch(CommonActions.goBack())
+
+ expect(nav.actions).toEqual([{type: 'GO_BACK'}])
+ expect(nav.getRootState()?.key).toBe('root')
+ })
+})
+
+// ---- navigateUp / popStack ----
+
+describe('navigateUp and popStack', () => {
+ test('go back and pop-to-top are dispatched at the root, untargeted', () => {
+ nav = installFakeNavigator()
+
+ navigateUp()
+ popStack()
+
+ expect(nav.actions).toEqual([{type: 'GO_BACK'}, {type: 'POP_TO_TOP'}])
+ })
+
+ test('a not-ready navigator dispatches neither', () => {
+ nav = installFakeNavigator({ready: false})
+
+ navigateUp()
+ popStack()
+
+ expect(nav.actions).toEqual([])
+ })
+})
+
+// ---- navigateAppend ----
+
+describe('navigateAppend', () => {
+ beforeEach(() => {
+ nav = installFakeNavigator({
+ rootState: makeRootState({tabStack: [{name: 'chatRoot'}, {name: 'profile', params: {username: 'testuser'}}]}),
+ })
+ })
+
+ test('pushes a screen that is not already visible', () => {
+ expect(navigateAppend({name: 'profile', params: {username: 'testuser-mac'}})).toBe(true)
+
+ expect(nav.pushes()).toEqual([{name: 'profile', params: {username: 'testuser-mac'}}])
+ })
+
+ // The caller's goal - "that screen with those params is what the user is looking at" -
+ // is already met, so this reports success without a second identical screen.
+ test('is a no-op when the target is already the visible route with the same params', () => {
+ expect(navigateAppend({name: 'profile', params: {username: 'testuser'}})).toBe(true)
+
+ expect(nav.actions).toEqual([])
+ })
+
+ test('a not-ready navigator dispatches nothing and reports failure', () => {
+ nav.setReady(false)
+
+ expect(navigateAppend({name: 'profile', params: {username: 'testuser-mac'}})).toBe(false)
+ expect(nav.actions).toEqual([])
+ })
+
+ test('replace retargets the visible screen in place when the name matches', () => {
+ expect(navigateAppend({name: 'profile', params: {username: 'testuser-mac'}}, true)).toBe(true)
+
+ expect(nav.lastAction()?.type).toBe('SET_PARAMS')
+ expect(nav.lastAction()?.payload).toEqual({params: {username: 'testuser-mac'}})
+ })
+
+ test('replace swaps the screen when the visible one is a different route', () => {
+ expect(navigateAppend({name: 'chatNewChat', params: {namespace: 'chat', title: 'New chat'}}, true)).toBe(true)
+
+ expect(nav.lastAction()?.type).toBe('REPLACE')
+ expect(nav.lastAction()?.payload).toMatchObject({name: 'chatNewChat'})
+ })
+})
+
+// A push dispatched this tick is not in getRootState() until React Navigation commits, so
+// the visible-route check above cannot see it. Repeat taps that land inside that window -
+// a janky JS thread queueing both - would otherwise push the same screen twice.
+describe('navigateAppend in-flight dedupe', () => {
+ beforeEach(() => {
+ jest.useFakeTimers()
+ nav = installFakeNavigator()
+ })
+
+ test('a second identical push before the state commits is dropped', () => {
+ navigateAppend({name: 'profile', params: {username: 'testuser'}})
+ navigateAppend({name: 'profile', params: {username: 'testuser'}})
+
+ expect(nav.pushes()).toEqual([{name: 'profile', params: {username: 'testuser'}}])
+ })
+
+ test('a different push inside the window still goes through', () => {
+ navigateAppend({name: 'profile', params: {username: 'testuser'}})
+ navigateAppend({name: 'profile', params: {username: 'testuser-mac'}})
+
+ expect(nav.pushes()).toHaveLength(2)
+ })
+
+ // The window is a backstop for the container tearing down before the state listener
+ // fires; it must not swallow a genuine repeat navigation forever.
+ test('the same push is allowed again once the 1000ms window has passed', () => {
+ navigateAppend({name: 'profile', params: {username: 'testuser'}})
+ jest.advanceTimersByTime(1001)
+ navigateAppend({name: 'profile', params: {username: 'testuser'}})
+
+ expect(nav.pushes()).toHaveLength(2)
+ })
+
+ test('the window is still closed one tick before it expires', () => {
+ navigateAppend({name: 'profile', params: {username: 'testuser'}})
+ jest.advanceTimersByTime(999)
+ navigateAppend({name: 'profile', params: {username: 'testuser'}})
+
+ expect(nav.pushes()).toHaveLength(1)
+ })
+
+ // The commit is the real end of the window: once the navigator reports new state the
+ // visible-route check can see the pushed screen, so the backstop stands down.
+ test('a committed state event ends the window early', () => {
+ navigateAppend({name: 'profile', params: {username: 'testuser'}})
+ nav.setRootState(makeRootState({tabStack: [{name: 'chatRoot'}, {name: 'peopleRoot'}]}))
+ navigateAppend({name: 'profile', params: {username: 'testuser'}})
+
+ expect(nav.pushes()).toHaveLength(2)
+ })
+})
+
+// ---- navUpToScreen ----
+
+describe('navUpToScreen', () => {
+ const stack = [{name: 'teamsRoot'}, {name: 'team', params: {teamID: 'T1'}}, {name: 'teamChannel'}]
+
+ beforeEach(() => {
+ nav = installFakeNavigator({rootState: makeRootState({tab: Tabs.teamsTab, tabStack: stack})})
+ })
+
+ test('a bare route name pops the active stack back to it', () => {
+ navUpToScreen('teamsRoot')
+
+ expect(nav.lastAction()).toMatchObject({
+ payload: {name: 'teamsRoot'},
+ target: `${Tabs.teamsTab}-stack`,
+ type: 'POP_TO',
+ })
+ })
+
+ // A path carries params, and popTo cannot set them, so the stack is rebuilt in place:
+ // truncated at the target with the new params written onto it.
+ test('a path already in the stack resets the stack onto it with the new params', () => {
+ navUpToScreen({name: 'team', params: {teamID: 'T2'}})
+
+ const action = nav.lastAction()
+ expect(action?.type).toBe('RESET')
+ expect(action?.target).toBe(`${Tabs.teamsTab}-stack`)
+ const payload = action?.payload as {index: number; routes: Array<{name: string; params?: object}>}
+ expect(payload.index).toBe(1)
+ expect(payload.routes.map(r => r.name)).toEqual(['teamsRoot', 'team'])
+ expect(payload.routes[1]?.params).toEqual({teamID: 'T2'})
+ })
+
+ test('a path that is not in the stack is put in place of the current screen when asked', () => {
+ navUpToScreen({name: 'teamMember', params: {teamID: 'T1', username: 'testuser'}}, true)
+
+ expect(nav.lastAction()).toMatchObject({
+ payload: {name: 'teamMember', params: {teamID: 'T1', username: 'testuser'}},
+ target: `${Tabs.teamsTab}-stack`,
+ type: 'REPLACE',
+ })
+ })
+
+ test('a path that is not in the stack pops towards it otherwise', () => {
+ navUpToScreen({name: 'teamMember', params: {teamID: 'T1', username: 'testuser'}})
+
+ expect(nav.lastAction()).toMatchObject({payload: {name: 'teamMember'}, type: 'POP_TO'})
+ })
+
+ test('a not-ready navigator dispatches nothing', () => {
+ nav.setReady(false)
+ navUpToScreen('teamsRoot')
+
+ expect(nav.actions).toEqual([])
+ })
+})
+
+// ---- clearModals ----
+
+describe('clearModals', () => {
+ test('drops the modals and keeps the tab navigator and non-modal pushed screens', () => {
+ nav = installFakeNavigator({
+ modalRouteNames: ['chatInfoPanel', 'chatNewChat'],
+ rootState: makeRootState({
+ above: [{name: 'chatConversation', params: {conversationIDKey: 'CONV'}}, {name: 'chatInfoPanel'}],
+ }),
+ })
+
+ clearModals()
+
+ const action = nav.lastAction()
+ expect(action?.type).toBe('RESET')
+ expect(action?.target).toBe('root')
+ const payload = action?.payload as {index: number; routes: Array<{name: string}>}
+ expect(payload.routes.map(r => r.name)).toEqual(['loggedIn', 'chatConversation'])
+ expect(payload.index).toBe(1)
+ })
+
+ test('dispatches nothing when there is no modal to clear', () => {
+ nav = installFakeNavigator({modalRouteNames: ['chatInfoPanel']})
+
+ clearModals()
+
+ expect(nav.actions).toEqual([])
+ })
+
+ // A modal can sit above the logged-out stack too, so "nothing to clear" is not what makes
+ // this a no-op - clearModals only ever acts on the logged-in root.
+ test('dispatches nothing when logged out, even with a modal on top', () => {
+ nav = installFakeNavigator({
+ modalRouteNames: ['chatInfoPanel'],
+ rootState: makeRootState({above: [{name: 'chatInfoPanel'}], loggedIn: false}),
+ })
+
+ clearModals()
+
+ expect(nav.actions).toEqual([])
+ })
+})
+
+// ---- setChatRootParams ----
+
+describe('setChatRootParams', () => {
+ test('merges into the live chatRoot in place when it is already showing', () => {
+ nav = installFakeNavigator({
+ rootState: makeRootState({tabStack: [{name: 'chatRoot', params: {conversationIDKey: 'OLD'}}]}),
+ })
+
+ expect(setChatRootParams({conversationIDKey: 'NEW' as never})).toBe(true)
+
+ // in place, targeting the chat tab's own stack - not a reset of the tab navigator
+ expect(nav.lastAction()).toMatchObject({
+ payload: {name: 'chatRoot', params: {conversationIDKey: 'NEW'}},
+ target: `${Tabs.chatTab}-stack`,
+ type: 'NAVIGATE',
+ })
+ })
+
+ test('dispatches nothing when chatRoot already carries those params', () => {
+ nav = installFakeNavigator({
+ rootState: makeRootState({tabStack: [{name: 'chatRoot', params: {conversationIDKey: 'SAME'}}]}),
+ })
+
+ expect(setChatRootParams({conversationIDKey: 'SAME' as never})).toBe(true)
+ expect(nav.actions).toEqual([])
+ })
+
+ // Something else is on top of the chat stack, so the tab navigator is reset back onto a
+ // chatRoot carrying the merged params.
+ test('resets the tab navigator when chatRoot is not the current screen', () => {
+ nav = installFakeNavigator({
+ rootState: makeRootState({
+ tabStack: [{name: 'chatRoot', params: {conversationIDKey: 'OLD'}}, {name: 'chatInfoPanel'}],
+ }),
+ })
+
+ expect(setChatRootParams({conversationIDKey: 'NEW' as never})).toBe(true)
+
+ const action = nav.lastAction()
+ expect(action?.type).toBe('RESET')
+ expect(action?.target).toBe('tabs')
+ const payload = action?.payload as {routes: Array<{state?: {routes: Array<{params?: object}>}}>}
+ expect(payload.routes[0]?.state?.routes).toEqual([
+ {name: 'chatRoot', params: {conversationIDKey: 'NEW'}},
+ ])
+ })
+
+ test('reports failure when there is no chat tab to target', () => {
+ nav = installFakeNavigator({rootState: makeRootState({tab: Tabs.teamsTab})})
+
+ expect(setChatRootParams({conversationIDKey: 'NEW' as never})).toBe(false)
+ expect(nav.actions).toEqual([])
+ })
+
+ test('reports failure when logged out', () => {
+ nav = installFakeNavigator({rootState: makeRootState({loggedIn: false})})
+
+ expect(setChatRootParams({conversationIDKey: 'NEW' as never})).toBe(false)
+ expect(nav.actions).toEqual([])
+ })
+})
+
+// ---- switchTab ----
+
+describe('switchTab', () => {
+ test('jumps within the tab navigator, not the root stack', () => {
+ nav = installFakeNavigator()
+
+ switchTab(Tabs.teamsTab)
+
+ expect(nav.lastAction()).toMatchObject({payload: {name: Tabs.teamsTab}, target: 'tabs', type: 'JUMP_TO'})
+ })
+
+ // The logged-out root has a stack of its own, with a key a tab jump could be aimed at.
+ test('dispatches nothing when logged out', () => {
+ nav = installFakeNavigator({rootState: makeRootState({loggedIn: false})})
+
+ switchTab(Tabs.teamsTab)
+
+ expect(nav.actions).toEqual([])
+ })
+})
diff --git a/shared/git/delete-repo.test.tsx b/shared/git/delete-repo.test.tsx
index 630c3226e33b..c52d5f0919c3 100644
--- a/shared/git/delete-repo.test.tsx
+++ b/shared/git/delete-repo.test.tsx
@@ -8,7 +8,6 @@ jest.mock('@/constants', () => ({
...(jest.requireActual('@/constants') as object),
useRPC: jest.fn(),
}))
-jest.mock('@/constants/router', () => ({navigateUp: jest.fn()}))
// the confirmation gate is the logic under test; the chrome around it is
// native/electron-only
jest.mock('@/common-adapters', () => {
diff --git a/shared/login/recover-password/flow-prompts.test.tsx b/shared/login/recover-password/flow-prompts.test.tsx
index 876f548fee3a..e01d3efcb8a6 100644
--- a/shared/login/recover-password/flow-prompts.test.tsx
+++ b/shared/login/recover-password/flow-prompts.test.tsx
@@ -4,16 +4,6 @@ import {resetAllStores} from '@/util/zustand'
import {useConfigState} from '@/stores/config'
import {RPCError} from '@/util/errors'
-jest.mock('@/constants/router', () => {
- const actual = jest.requireActual('@/constants/router')
- return {
- ...actual,
- clearModals: jest.fn(),
- navigateAppend: jest.fn(),
- navigateUp: jest.fn(),
- }
-})
-
import {
cancelRecoverPassword,
startRecoverPassword,
@@ -22,22 +12,25 @@ import {
submitRecoverPasswordPaperKey,
submitRecoverPasswordPassword,
} from './flow'
+import {installFakeNavigator, makeRootState, restoreNavigator, type FakeNavigator} from '@/test/fake-navigator'
-const {
- clearModals: mockClearModals,
- navigateAppend: mockNavigateAppend,
- navigateUp: mockNavigateUp,
-} = require('@/constants/router') as {
- clearModals: jest.Mock
- navigateAppend: jest.Mock
- navigateUp: jest.Mock
-}
+let nav: FakeNavigator
+
+// Recovery runs from a modal, so the fake starts with one open: clearModals only has
+// something to dispatch when a modal is actually on screen. Nothing below replaces onto
+// this name - a replace onto the visible route collapses into a setParams instead.
+const openModal = 'recoverPasswordPromptResetPassword'
+
+beforeEach(() => {
+ nav = installFakeNavigator({
+ modalRouteNames: [openModal],
+ rootState: makeRootState({above: [{name: openModal}]}),
+ })
+})
afterEach(() => {
+ restoreNavigator()
jest.restoreAllMocks()
- mockClearModals.mockReset()
- mockNavigateAppend.mockReset()
- mockNavigateUp.mockReset()
resetAllStores()
})
@@ -85,7 +78,7 @@ describe('device selection', () => {
code: T.RPCGen.StatusCode.scinputcanceled,
desc: 'Input canceled',
})
- expect(mockNavigateUp).toHaveBeenCalled()
+ expect(nav.types()).toContain('GO_BACK')
})
test('selecting no device answers with an empty device id', async () => {
@@ -131,10 +124,11 @@ describe('device selection', () => {
{error: jest.fn(), result: jest.fn()} as any
)
- expect(mockNavigateAppend).toHaveBeenCalledWith(
- {name: 'recoverPasswordDeviceSelector', params: {devices: []}},
- true
- )
+ expect(nav.navigations()).toContainEqual({
+ name: 'recoverPasswordDeviceSelector',
+ params: {devices: []},
+ replace: true,
+ })
})
})
@@ -148,10 +142,11 @@ describe('paper key prompt', () => {
response as any
)
- expect(mockNavigateAppend).toHaveBeenCalledWith(
- {name: 'recoverPasswordPaperKey', params: {error: 'nope'}},
- true
- )
+ expect(nav.navigations()).toContainEqual({
+ name: 'recoverPasswordPaperKey',
+ params: {error: 'nope'},
+ replace: true,
+ })
submitRecoverPasswordPaperKey('one two three')
@@ -166,10 +161,11 @@ describe('paper key prompt', () => {
{error: jest.fn(), result: jest.fn()} as any
)
- expect(mockNavigateAppend).toHaveBeenCalledWith(
- {name: 'recoverPasswordPaperKey', params: {error: undefined}},
- true
- )
+ expect(nav.navigations()).toContainEqual({
+ name: 'recoverPasswordPaperKey',
+ params: {error: undefined},
+ replace: true,
+ })
})
test('backing out of the paper key prompt restarts recovery from the top', async () => {
@@ -205,9 +201,10 @@ describe('new password prompt', () => {
response as any
)
- expect(mockNavigateAppend).toHaveBeenCalledWith({
+ expect(nav.navigations()).toContainEqual({
name: 'recoverPasswordSetPassword',
params: {error: undefined},
+ replace: false,
})
submitRecoverPasswordPassword('hunter2hunter2')
@@ -223,10 +220,11 @@ describe('new password prompt', () => {
{error: jest.fn(), result: jest.fn()} as any
)
- expect(mockNavigateAppend).toHaveBeenCalledWith(
- {name: 'recoverPasswordSetPassword', params: {error: 'too short'}},
- true
- )
+ expect(nav.navigations()).toContainEqual({
+ name: 'recoverPasswordSetPassword',
+ params: {error: 'too short'},
+ replace: true,
+ })
})
test('cancelling the new password prompt rejects the rpc without restarting', async () => {
@@ -253,13 +251,11 @@ test('a device-recovery explanation replaces the current screen', async () => {
{kind: T.RPCGen.DeviceType.mobile, name: 'testuser-mac'} as any
)
- expect(mockNavigateAppend).toHaveBeenCalledWith(
- {
- name: 'recoverPasswordExplainDevice',
- params: {deviceName: 'testuser-mac', deviceType: T.RPCGen.DeviceType.mobile, username: 'testuser'},
- },
- true
- )
+ expect(nav.navigations()).toContainEqual({
+ name: 'recoverPasswordExplainDevice',
+ params: {deviceName: 'testuser-mac', deviceType: T.RPCGen.DeviceType.mobile, username: 'testuser'},
+ replace: true,
+ })
})
test('a reset prompt that is not a password reset hands off to the account reset flow', async () => {
@@ -271,10 +267,11 @@ test('a reset prompt that is not a password reset hands off to the account reset
response as any
)
- expect(mockNavigateAppend).toHaveBeenCalledWith(
- {name: 'recoverPasswordPromptResetAccount', params: {skipPassword: true, username: 'testuser'}},
- true
- )
+ expect(nav.navigations()).toContainEqual({
+ name: 'recoverPasswordPromptResetAccount',
+ params: {skipPassword: true, username: 'testuser'},
+ replace: true,
+ })
expect(response.result).toHaveBeenCalledWith(T.RPCGen.ResetPromptResponse.nothing)
})
@@ -285,7 +282,7 @@ describe('completion', () => {
first.resolve()
await flush()
- expect(mockClearModals).toHaveBeenCalled()
+ expect(nav.modalsCleared()).toBe(true)
})
test('a cancelled recovery shows no error screen and leaves modals alone', async () => {
@@ -294,10 +291,9 @@ describe('completion', () => {
first.reject(new RPCError('Input canceled', T.RPCGen.StatusCode.scinputcanceled))
await flush()
- expect(mockClearModals).not.toHaveBeenCalled()
- expect(mockNavigateAppend).not.toHaveBeenCalledWith(
- expect.objectContaining({name: 'recoverPasswordError'}),
- true
+ expect(nav.modalsCleared()).toBe(false)
+ expect(nav.navigations()).not.toContainEqual(
+ expect.objectContaining({name: 'recoverPasswordError', replace: true})
)
})
@@ -308,11 +304,12 @@ describe('completion', () => {
first.reject(error)
await flush()
- expect(mockNavigateAppend).toHaveBeenCalledWith(
- {name: 'recoverPasswordError', params: {error: error.message}},
- true
- )
- expect(mockClearModals).not.toHaveBeenCalled()
+ expect(nav.navigations()).toContainEqual({
+ name: 'recoverPasswordError',
+ params: {error: error.message},
+ replace: true,
+ })
+ expect(nav.modalsCleared()).toBe(false)
})
test('a failure while logged in shows the error as a modal', async () => {
@@ -323,10 +320,11 @@ describe('completion', () => {
first.reject(error)
await flush()
- expect(mockNavigateAppend).toHaveBeenCalledWith(
- {name: 'recoverPasswordErrorModal', params: {error: error.message}},
- true
- )
+ expect(nav.navigations()).toContainEqual({
+ name: 'recoverPasswordErrorModal',
+ params: {error: error.message},
+ replace: true,
+ })
})
test('handlers stop responding once the run is over', async () => {
diff --git a/shared/login/recover-password/flow.test.tsx b/shared/login/recover-password/flow.test.tsx
index 4d42a02652ca..6629e6c3483f 100644
--- a/shared/login/recover-password/flow.test.tsx
+++ b/shared/login/recover-password/flow.test.tsx
@@ -2,37 +2,22 @@
import * as T from '@/constants/types'
import {resetAllStores} from '@/util/zustand'
-jest.mock('@/constants/router', () => {
- const actual = jest.requireActual('@/constants/router')
- return {
- ...actual,
- clearModals: jest.fn(),
- navigateAppend: jest.fn(),
- navigateUp: jest.fn(),
- }
-})
-
+import {installFakeNavigator, restoreNavigator, type FakeNavigator} from '@/test/fake-navigator'
import {
startRecoverPassword,
submitRecoverPasswordDeviceSelect,
submitRecoverPasswordReset,
} from './flow'
-const {
- clearModals: mockClearModals,
- navigateAppend: mockNavigateAppend,
- navigateUp: mockNavigateUp,
-} = require('@/constants/router') as {
- clearModals: jest.Mock
- navigateAppend: jest.Mock
- navigateUp: jest.Mock
-}
+let nav: FakeNavigator
+
+beforeEach(() => {
+ nav = installFakeNavigator()
+})
afterEach(() => {
+ restoreNavigator()
jest.restoreAllMocks()
- mockClearModals.mockReset()
- mockNavigateAppend.mockReset()
- mockNavigateUp.mockReset()
resetAllStores()
})
@@ -70,21 +55,19 @@ test('startRecoverPassword exposes device selection handlers', async () => {
startRecoverPassword({username: 'alice'})
await flush()
- expect(mockNavigateAppend).toHaveBeenCalledWith(
- {
- name: 'recoverPasswordDeviceSelector',
- params: {
- devices: [
- expect.objectContaining({
- id: T.Devices.stringToDeviceID('device-1'),
- name: 'phone',
- type: 'mobile',
- }),
- ],
- },
+ expect(nav.navigations()).toContainEqual({
+ name: 'recoverPasswordDeviceSelector',
+ params: {
+ devices: [
+ expect.objectContaining({
+ id: T.Devices.stringToDeviceID('device-1'),
+ name: 'phone',
+ type: 'mobile',
+ }),
+ ],
},
- false
- )
+ replace: false,
+ })
submitRecoverPasswordDeviceSelect(T.Devices.stringToDeviceID('device-1'))
submitRecoverPasswordDeviceSelect(T.Devices.stringToDeviceID('device-1'))
@@ -156,9 +139,10 @@ test('reset-password prompt resolves callback and local banner handler', async (
startRecoverPassword({onResetEmailSent, username: 'alice'})
await flush()
- expect(mockNavigateAppend).toHaveBeenCalledWith({
+ expect(nav.navigations()).toContainEqual({
name: 'recoverPasswordPromptResetPassword',
params: {username: 'alice'},
+ replace: false,
})
submitRecoverPasswordReset(T.RPCGen.ResetPromptResponse.confirmReset)
@@ -167,7 +151,7 @@ test('reset-password prompt resolves callback and local banner handler', async (
expect(promptResponse?.result).toHaveBeenCalledTimes(1)
expect(promptResponse?.result).toHaveBeenCalledWith(T.RPCGen.ResetPromptResponse.confirmReset)
expect(onResetEmailSent).toHaveBeenCalledTimes(1)
- expect(mockNavigateUp).toHaveBeenCalledTimes(1)
+ expect(nav.types().filter(t => t === 'GO_BACK')).toHaveLength(1)
} finally {
finishListener()
await flush()
diff --git a/shared/login/reset/account-reset.test.ts b/shared/login/reset/account-reset.test.ts
index 306c657c90ae..616b1280c8fa 100644
--- a/shared/login/reset/account-reset.test.ts
+++ b/shared/login/reset/account-reset.test.ts
@@ -5,30 +5,26 @@ import {RPCError} from '@/util/errors'
const mockStartProvision = jest.fn()
-jest.mock('@/constants/router', () => {
- const actual = jest.requireActual('@/constants/router')
- return {
- ...actual,
- navUpToScreen: jest.fn(),
- navigateAppend: jest.fn(),
- }
-})
-
jest.mock('@/provision/flow', () => ({
startProvision: (...args: Array) => mockStartProvision(...args),
}))
+import {installFakeNavigator, restoreNavigator, type FakeNavigator} from '@/test/fake-navigator'
import {enterResetPipeline, startAccountReset, submitResetPrompt} from './account-reset'
-const {navigateAppend: mockNavigateAppend, navUpToScreen: mockNavUpToScreen} = require('@/constants/router') as {
- navigateAppend: jest.Mock
- navUpToScreen: jest.Mock
-}
+let nav: FakeNavigator
+
+// The confirm screen is handed a one-shot key, and the only way to learn it is to read
+// the params the flow navigated with.
+const lastResetKey = () => (nav.navigations().at(-1)?.params as {resetKey?: string} | undefined)?.resetKey ?? ''
+
+beforeEach(() => {
+ nav = installFakeNavigator()
+})
afterEach(() => {
+ restoreNavigator()
jest.restoreAllMocks()
- mockNavigateAppend.mockReset()
- mockNavUpToScreen.mockReset()
mockStartProvision.mockReset()
resetAllStores()
})
@@ -38,13 +34,11 @@ const flush = async () => new Promise(resolve => setImmediate(resolve))
test('startAccountReset navigates into the reset flow', () => {
startAccountReset(true, 'testuser')
- expect(mockNavigateAppend).toHaveBeenCalledWith(
- {
- name: 'recoverPasswordPromptResetAccount',
- params: {skipPassword: true, username: 'testuser'},
- },
- true
- )
+ expect(nav.navigations()).toContainEqual({
+ name: 'recoverPasswordPromptResetAccount',
+ params: {skipPassword: true, username: 'testuser'},
+ replace: true,
+ })
})
test('enterResetPipeline exposes a submit handler for the confirm screen and starts provision on confirm', async () => {
@@ -71,12 +65,12 @@ test('enterResetPipeline exposes a submit handler for the confirm screen and sta
enterResetPipeline({username: 'testuser'})
await flush()
- const resetKey = mockNavigateAppend.mock.calls[mockNavigateAppend.mock.calls.length - 1]?.[0]?.params
- ?.resetKey as string
- expect(mockNavigateAppend).toHaveBeenCalledWith(
- {name: 'resetConfirm', params: {hasWallet: true, resetKey}},
- true
- )
+ const resetKey = lastResetKey()
+ expect(nav.navigations()).toContainEqual({
+ name: 'resetConfirm',
+ params: {hasWallet: true, resetKey},
+ replace: true,
+ })
submitResetPrompt(resetKey, T.RPCGen.ResetPromptResponse.confirmReset)
@@ -107,13 +101,11 @@ test('enterResetPipeline responds and starts the reset flow for non-complete pro
await Promise.resolve()
expect(result).toHaveBeenCalledWith(T.RPCGen.ResetPromptResponse.nothing)
- expect(mockNavigateAppend).toHaveBeenCalledWith(
- {
- name: 'recoverPasswordPromptResetAccount',
- params: {skipPassword: true, username: 'testuser'},
- },
- true
- )
+ expect(nav.navigations()).toContainEqual({
+ name: 'recoverPasswordPromptResetAccount',
+ params: {skipPassword: true, username: 'testuser'},
+ replace: true,
+ })
})
test('submitResetPrompt sends cancel responses back to the login flow', async () => {
@@ -139,12 +131,11 @@ test('submitResetPrompt sends cancel responses back to the login flow', async ()
try {
enterResetPipeline({username: 'testuser'})
await flush()
- const resetKey = mockNavigateAppend.mock.calls[mockNavigateAppend.mock.calls.length - 1]?.[0]?.params
- ?.resetKey as string
+ const resetKey = lastResetKey()
submitResetPrompt(resetKey, T.RPCGen.ResetPromptResponse.cancelReset)
expect(result).toHaveBeenCalledWith(T.RPCGen.ResetPromptResponse.cancelReset)
- expect(mockNavUpToScreen).toHaveBeenCalledWith('login')
+ expect(nav.actions).toContainEqual(expect.objectContaining({payload: {name: 'login'}, type: 'POP_TO'}))
} finally {
finishListener()
await flush()
@@ -174,12 +165,11 @@ test('submitResetPrompt sends nothing responses back to the login flow', async (
try {
enterResetPipeline({username: 'testuser'})
await flush()
- const resetKey = mockNavigateAppend.mock.calls[mockNavigateAppend.mock.calls.length - 1]?.[0]?.params
- ?.resetKey as string
+ const resetKey = lastResetKey()
submitResetPrompt(resetKey, T.RPCGen.ResetPromptResponse.nothing)
expect(result).toHaveBeenCalledWith(T.RPCGen.ResetPromptResponse.nothing)
- expect(mockNavUpToScreen).toHaveBeenCalledWith('login')
+ expect(nav.actions).toContainEqual(expect.objectContaining({payload: {name: 'login'}, type: 'POP_TO'}))
} finally {
finishListener()
await flush()
@@ -209,8 +199,7 @@ test('enterResetPipeline disposes an unconsumed reset prompt when the listener e
enterResetPipeline({username: 'testuser'})
await flush()
- const resetKey = mockNavigateAppend.mock.calls[mockNavigateAppend.mock.calls.length - 1]?.[0]?.params
- ?.resetKey as string
+ const resetKey = lastResetKey()
finishListener()
await flush()
@@ -231,10 +220,11 @@ test('reset progress before verification shows the check-your-email screen', asy
enterResetPipeline({username: 'testuser'})
await flush()
- expect(mockNavigateAppend).toHaveBeenCalledWith(
- {name: 'resetWaiting', params: {endTime: undefined, pipelineStarted: false, username: 'testuser'}},
- true
- )
+ expect(nav.navigations()).toContainEqual({
+ name: 'resetWaiting',
+ params: {endTime: undefined, pipelineStarted: false, username: 'testuser'},
+ replace: true,
+ })
})
test('reset progress after verification passes the countdown end time in milliseconds', async () => {
@@ -248,10 +238,11 @@ test('reset progress after verification passes the countdown end time in millise
enterResetPipeline({username: 'testuser'})
await flush()
- expect(mockNavigateAppend).toHaveBeenCalledWith(
- {name: 'resetWaiting', params: {endTime: 1700000000000, pipelineStarted: true, username: 'testuser'}},
- true
- )
+ expect(nav.navigations()).toContainEqual({
+ name: 'resetWaiting',
+ params: {endTime: 1700000000000, pipelineStarted: true, username: 'testuser'},
+ replace: true,
+ })
})
test('an rpc failure clears then reports the error to the caller', async () => {
diff --git a/shared/provision/flow-prompts.test.tsx b/shared/provision/flow-prompts.test.tsx
index 8e29255d30b0..cc00cfd73980 100644
--- a/shared/provision/flow-prompts.test.tsx
+++ b/shared/provision/flow-prompts.test.tsx
@@ -7,16 +7,6 @@ import {useWaitingState} from '@/stores/waiting'
import {waitingKeyProvision} from '@/constants/strings'
import {RPCError} from '@/util/errors'
-jest.mock('@/constants/router', () => {
- const actual = jest.requireActual('@/constants/router')
- return {
- ...actual,
- clearModals: jest.fn(),
- navigateAppend: jest.fn(),
- navigateUp: jest.fn(),
- }
-})
-
import {
cancelProvision,
startProvision,
@@ -25,16 +15,26 @@ import {
submitProvisionUsername,
} from './flow'
-const {clearModals: mockClearModals, navigateAppend: mockNavigateAppend} = require('@/constants/router') as {
- clearModals: jest.Mock
- navigateAppend: jest.Mock
-}
+import {installFakeNavigator, makeRootState, restoreNavigator, type FakeNavigator} from '@/test/fake-navigator'
+
+let nav: FakeNavigator
+
+// Provisioning runs from a modal, so the fake starts with one open: clearModals only has
+// something to dispatch when a modal is actually on screen. This one is never a
+// navigation target below, so a replace onto another screen stays a replace.
+const openModal = 'deviceAdd'
+
+beforeEach(() => {
+ nav = installFakeNavigator({
+ modalRouteNames: [openModal],
+ rootState: makeRootState({above: [{name: openModal}]}),
+ })
+})
afterEach(() => {
+ restoreNavigator()
cancelProvision()
jest.restoreAllMocks()
- mockClearModals.mockReset()
- mockNavigateAppend.mockReset()
resetAllStores()
})
@@ -81,14 +81,12 @@ describe('final error handling', () => {
attempt.reject(new RPCError('no such user', T.RPCGen.StatusCode.scnotfound))
await flush()
- expect(mockNavigateAppend).toHaveBeenCalledWith(
- {
- name: 'username',
- params: {inlineErrorCode: T.RPCGen.StatusCode.scnotfound, username: 'testuser'},
- },
- true
- )
- expect(mockClearModals).not.toHaveBeenCalled()
+ expect(nav.navigations()).toContainEqual({
+ name: 'username',
+ params: {inlineErrorCode: T.RPCGen.StatusCode.scnotfound, username: 'testuser'},
+ replace: true,
+ })
+ expect(nav.modalsCleared()).toBe(false)
})
test('a malformed username also stays on the username screen', async () => {
@@ -97,13 +95,11 @@ describe('final error handling', () => {
attempt.reject(new RPCError('bad username', T.RPCGen.StatusCode.scbadusername))
await flush()
- expect(mockNavigateAppend).toHaveBeenCalledWith(
- {
- name: 'username',
- params: {inlineErrorCode: T.RPCGen.StatusCode.scbadusername, username: 'testuser'},
- },
- true
- )
+ expect(nav.navigations()).toContainEqual({
+ name: 'username',
+ params: {inlineErrorCode: T.RPCGen.StatusCode.scbadusername, username: 'testuser'},
+ replace: true,
+ })
})
test('any other error clears modals and shows the error screen with the rpc details', async () => {
@@ -115,23 +111,21 @@ describe('final error handling', () => {
attempt.reject(error)
await flush()
- expect(mockClearModals).toHaveBeenCalled()
- expect(mockNavigateAppend).toHaveBeenCalledWith(
- {
- name: 'error',
- params: {
- error: {
- code: T.RPCGen.StatusCode.scdeviceprovisionoffline,
- desc: error.desc,
- details: error.details,
- fields: [{key: 'has_active_device', value: '1'}],
- message: error.message,
- },
- username: 'testuser',
+ expect(nav.modalsCleared()).toBe(true)
+ expect(nav.navigations()).toContainEqual({
+ name: 'error',
+ params: {
+ error: {
+ code: T.RPCGen.StatusCode.scdeviceprovisionoffline,
+ desc: error.desc,
+ details: error.details,
+ fields: [{key: 'has_active_device', value: '1'}],
+ message: error.message,
},
+ username: 'testuser',
},
- true
- )
+ replace: true,
+ })
})
test('an error caused by our own cancel shows nothing', async () => {
@@ -140,8 +134,8 @@ describe('final error handling', () => {
attempt.reject(new RPCError('Input canceled', T.RPCGen.StatusCode.scgeneric))
await flush()
- expect(mockClearModals).not.toHaveBeenCalled()
- expect(mockNavigateAppend).not.toHaveBeenCalledWith(expect.objectContaining({name: 'error'}), true)
+ expect(nav.modalsCleared()).toBe(false)
+ expect(nav.navigations()).not.toContainEqual(expect.objectContaining({name: 'error', replace: true}))
})
test('a kex cancel from the daemon shows nothing', async () => {
@@ -150,7 +144,7 @@ describe('final error handling', () => {
attempt.reject(new RPCError('kex canceled by caller', T.RPCGen.StatusCode.scgeneric))
await flush()
- expect(mockNavigateAppend).not.toHaveBeenCalledWith(expect.objectContaining({name: 'error'}), true)
+ expect(nav.navigations()).not.toContainEqual(expect.objectContaining({name: 'error', replace: true}))
})
test('a non-rpc failure does not navigate anywhere', async () => {
@@ -159,8 +153,8 @@ describe('final error handling', () => {
attempt.reject(new Error('boom'))
await flush()
- expect(mockClearModals).not.toHaveBeenCalled()
- expect(mockNavigateAppend).not.toHaveBeenCalledWith(expect.objectContaining({name: 'error'}), true)
+ expect(nav.modalsCleared()).toBe(false)
+ expect(nav.navigations()).not.toContainEqual(expect.objectContaining({name: 'error', replace: true}))
})
})
@@ -174,10 +168,11 @@ describe('passphrase prompts', () => {
response as any
)
- expect(mockNavigateAppend).toHaveBeenCalledWith(
- {name: 'password', params: {error: undefined, username: 'testuser'}},
- false
- )
+ expect(nav.navigations()).toContainEqual({
+ name: 'password',
+ params: {error: undefined, username: 'testuser'},
+ replace: false,
+ })
})
test('the service rejecting the password is rewritten to a readable error and replaces the screen', async () => {
@@ -189,10 +184,11 @@ describe('passphrase prompts', () => {
response as any
)
- expect(mockNavigateAppend).toHaveBeenCalledWith(
- {name: 'password', params: {error: 'Incorrect password.', username: 'testuser'}},
- true
- )
+ expect(nav.navigations()).toContainEqual({
+ name: 'password',
+ params: {error: 'Incorrect password.', username: 'testuser'},
+ replace: true,
+ })
})
test('any other retry label is passed through verbatim', async () => {
@@ -204,10 +200,11 @@ describe('passphrase prompts', () => {
response as any
)
- expect(mockNavigateAppend).toHaveBeenCalledWith(
- {name: 'password', params: {error: 'Try again', username: 'testuser'}},
- true
- )
+ expect(nav.navigations()).toContainEqual({
+ name: 'password',
+ params: {error: 'Try again', username: 'testuser'},
+ replace: true,
+ })
})
test('a paper key prompt names the device the user picked', async () => {
@@ -226,10 +223,11 @@ describe('passphrase prompts', () => {
response as any
)
- expect(mockNavigateAppend).toHaveBeenCalledWith(
- {name: 'paperkey', params: {deviceName: 'paper key one', error: undefined}},
- false
- )
+ expect(nav.navigations()).toContainEqual({
+ name: 'paperkey',
+ params: {deviceName: 'paper key one', error: undefined},
+ replace: false,
+ })
})
})
@@ -243,18 +241,16 @@ describe('text code prompt', () => {
response as any
)
- expect(mockNavigateAppend).toHaveBeenCalledWith(
- {
- name: 'codePage',
- params: {
- deviceName: '',
- error: undefined,
- otherDevice: expect.objectContaining({name: ''}),
- textCode: 'one two three',
- },
+ expect(nav.navigations()).toContainEqual({
+ name: 'codePage',
+ params: {
+ deviceName: '',
+ error: undefined,
+ otherDevice: expect.objectContaining({name: ''}),
+ textCode: 'one two three',
},
- false
- )
+ replace: false,
+ })
submitProvisionTextCode(' one,two\n\nthree ')
@@ -270,12 +266,12 @@ describe('text code prompt', () => {
response as any
)
- expect(mockNavigateAppend).toHaveBeenCalledWith(
+ expect(nav.navigations()).toContainEqual(
expect.objectContaining({
name: 'codePage',
params: expect.objectContaining({error: 'nope', textCode: 'four five six'}),
- }),
- true
+ replace: true,
+ })
)
})
})
@@ -314,9 +310,10 @@ test('starting provisioning while logged in logs out first', async () => {
await flush()
expect(logout).toHaveBeenCalledWith({force: false, keepSecrets: true}, 'config:loginAsOther')
- expect(mockNavigateAppend).toHaveBeenCalledWith({
+ expect(nav.navigations()).toContainEqual({
name: 'username',
params: {fromReset: false, username: 'testuser'},
+ replace: false,
})
})
diff --git a/shared/provision/flow.test.tsx b/shared/provision/flow.test.tsx
index 2f68a7990fef..885b269aba70 100644
--- a/shared/provision/flow.test.tsx
+++ b/shared/provision/flow.test.tsx
@@ -3,16 +3,6 @@ import * as T from '@/constants/types'
import {resetAllStores} from '@/util/zustand'
import {RPCError} from '@/util/errors'
-jest.mock('@/constants/router', () => {
- const actual = jest.requireActual('@/constants/router')
- return {
- ...actual,
- clearModals: jest.fn(),
- navigateAppend: jest.fn(),
- navigateUp: jest.fn(),
- }
-})
-
import {
cancelProvision,
pauseProvision,
@@ -23,16 +13,26 @@ import {
startProvision,
} from './flow'
-const {clearModals: mockClearModals, navigateAppend: mockNavigateAppend} = require('@/constants/router') as {
- clearModals: jest.Mock
- navigateAppend: jest.Mock
-}
+import {installFakeNavigator, makeRootState, restoreNavigator, type FakeNavigator} from '@/test/fake-navigator'
+
+let nav: FakeNavigator
+
+// Provisioning runs from a modal, so the fake starts with one open: clearModals only has
+// something to dispatch when a modal is actually on screen. This one is never a
+// navigation target below, so a replace onto another screen stays a replace.
+const openModal = 'deviceAdd'
+
+beforeEach(() => {
+ nav = installFakeNavigator({
+ modalRouteNames: [openModal],
+ rootState: makeRootState({above: [{name: openModal}]}),
+ })
+})
afterEach(() => {
+ restoreNavigator()
cancelProvision()
jest.restoreAllMocks()
- mockClearModals.mockReset()
- mockNavigateAppend.mockReset()
resetAllStores()
})
@@ -73,9 +73,10 @@ const mockLoginAttempts = () => {
test('startProvision navigates to the username screen', () => {
startProvision('alice', true)
- expect(mockNavigateAppend).toHaveBeenCalledWith({
+ expect(nav.navigations()).toContainEqual({
name: 'username',
params: {fromReset: true, username: 'alice'},
+ replace: false,
})
})
@@ -92,7 +93,7 @@ test('chooseDevice prompt navigates with devices and the selection resolves once
response as any
)
- expect(mockNavigateAppend).toHaveBeenCalledWith({
+ expect(nav.navigations()).toContainEqual({
name: 'selectOtherDevice',
params: {
devices: [
@@ -104,6 +105,7 @@ test('chooseDevice prompt navigates with devices and the selection resolves once
],
username: 'alice',
},
+ replace: false,
})
submitProvisionDeviceSelect('phone')
@@ -130,10 +132,11 @@ test('changing an earlier answer restarts the RPC and replays recorded answers',
{errorMessage: ''} as any,
nameResponse1 as any
)
- expect(mockNavigateAppend).toHaveBeenCalledWith(
- {name: 'setPublicName', params: {devices: [], error: undefined}},
- false
- )
+ expect(nav.navigations()).toContainEqual({
+ name: 'setPublicName',
+ params: {devices: [], error: undefined},
+ replace: false,
+ })
submitProvisionDeviceName('dev1')
expect(nameResponse1.result).toHaveBeenCalledWith('dev1')
@@ -148,10 +151,11 @@ test('changing an earlier answer restarts the RPC and replays recorded answers',
{pinentry: {retryLabel: '', type: T.RPCGen.PassphraseType.passPhrase}} as any,
passphraseResponse as any
)
- expect(mockNavigateAppend).toHaveBeenCalledWith(
- {name: 'password', params: {error: undefined, username: 'alice'}},
- false
- )
+ expect(nav.navigations()).toContainEqual({
+ name: 'password',
+ params: {error: undefined, username: 'alice'},
+ replace: false,
+ })
// the user goes back and submits a different device name: the pending password
// prompt is cancelled and the RPC restarts
@@ -162,14 +166,14 @@ test('changing an earlier answer restarts the RPC and replays recorded answers',
const attempt2 = attempts[1]!
// the device name prompt in the new attempt is auto-submitted with the new answer
- mockNavigateAppend.mockClear()
+ nav.clearActions()
const nameResponse2 = {error: jest.fn(), result: jest.fn()}
attempt2.listener.customResponseIncomingCallMap?.['keybase.1.provisionUi.PromptNewDeviceName']?.(
{errorMessage: ''} as any,
nameResponse2 as any
)
expect(nameResponse2.result).toHaveBeenCalledWith('dev2')
- expect(mockNavigateAppend).not.toHaveBeenCalled()
+ expect(nav.actions).toEqual([])
attempt2.resolve()
await flush()
@@ -204,35 +208,29 @@ test('a cancelled add-device run does not clear modals out from under a retry',
{phrase: 'one two three', previousErr: ''} as any,
response1 as any
)
- expect(mockNavigateAppend).toHaveBeenCalledWith(
- expect.objectContaining({name: 'codePage'}),
- false
- )
+ expect(nav.navigations()).toContainEqual(expect.objectContaining({name: 'codePage', replace: false}))
// the user cancels, then tries again: the dead run must not clear modals or eat the new run's UI
startAddNewDevice('mobile')
expect(response1.error).toHaveBeenCalled()
await flush()
- expect(mockClearModals).not.toHaveBeenCalled()
+ expect(nav.modalsCleared()).toBe(false)
expect(attempts.length).toBe(2)
const attempt2 = attempts[1]!
- mockNavigateAppend.mockClear()
+ nav.clearActions()
const response2 = {error: jest.fn(), result: jest.fn()}
attempt2.listener.customResponseIncomingCallMap?.['keybase.1.provisionUi.DisplayAndPromptSecret']?.(
{phrase: 'four five six', previousErr: ''} as any,
response2 as any
)
- expect(mockNavigateAppend).toHaveBeenCalledWith(
- expect.objectContaining({name: 'codePage'}),
- false
- )
+ expect(nav.navigations()).toContainEqual(expect.objectContaining({name: 'codePage', replace: false}))
expect(response2.error).not.toHaveBeenCalled()
attempt2.resolve()
await flush()
// the successful run still clears modals when it finishes
- expect(mockClearModals).toHaveBeenCalled()
+ expect(nav.modalsCleared()).toBe(true)
})
test('cancel before any prompt kills the RPC at its first prompt', async () => {
@@ -259,11 +257,11 @@ test('cancel before any prompt kills the RPC at its first prompt', async () => {
response as any
)
expect(response.error).toHaveBeenCalled()
- expect(mockNavigateAppend).not.toHaveBeenCalledWith(expect.objectContaining({name: 'codePage'}), false)
+ expect(nav.navigations()).not.toContainEqual(expect.objectContaining({name: 'codePage', replace: false}))
finishListener(new RPCError('Input canceled', T.RPCGen.StatusCode.scinputcanceled))
await flush()
- expect(mockClearModals).not.toHaveBeenCalled()
+ expect(nav.modalsCleared()).toBe(false)
})
test('pause during server work cancels the attempt and parks the run', async () => {
@@ -279,7 +277,7 @@ test('pause during server work cancels the attempt and parks the run', async ()
// parked: no restart, no error navigation
expect(attempts.length).toBe(1)
- expect(mockNavigateAppend).not.toHaveBeenCalledWith(expect.objectContaining({name: 'error'}), true)
+ expect(nav.navigations()).not.toContainEqual(expect.objectContaining({name: 'error', replace: true}))
})
test('resubmit while parked restarts and replays recorded answers', async () => {
@@ -308,14 +306,14 @@ test('resubmit while parked restarts and replays recorded answers', async () =>
expect(attempts.length).toBe(2)
// the new attempt auto-submits the replayed answer without navigating
- mockNavigateAppend.mockClear()
+ nav.clearActions()
const nameResponse2 = {error: jest.fn(), result: jest.fn()}
attempts[1]!.listener.customResponseIncomingCallMap?.['keybase.1.provisionUi.PromptNewDeviceName']?.(
{errorMessage: ''} as any,
nameResponse2 as any
)
expect(nameResponse2.result).toHaveBeenCalledWith('dev2')
- expect(mockNavigateAppend).not.toHaveBeenCalled()
+ expect(nav.actions).toEqual([])
attempts[1]!.resolve()
await flush()
@@ -336,7 +334,7 @@ test('cancel while parked tears the run down', async () => {
submitProvisionDeviceName('dev1')
await flush()
expect(attempts.length).toBe(1)
- expect(mockNavigateAppend).not.toHaveBeenCalledWith(expect.objectContaining({name: 'error'}), true)
+ expect(nav.navigations()).not.toContainEqual(expect.objectContaining({name: 'error', replace: true}))
})
test('a prompt arriving after pause is rejected and does not navigate', async () => {
@@ -349,14 +347,14 @@ test('a prompt arriving after pause is rejected and does not navigate', async ()
pauseProvision()
await flush()
- mockNavigateAppend.mockClear()
+ nav.clearActions()
const response = {error: jest.fn(), result: jest.fn()}
attempt1.listener.customResponseIncomingCallMap?.['keybase.1.secretUi.getPassphrase']?.(
{pinentry: {retryLabel: '', type: T.RPCGen.PassphraseType.passPhrase}} as any,
response as any
)
expect(response.error).toHaveBeenCalled()
- expect(mockNavigateAppend).not.toHaveBeenCalled()
+ expect(nav.actions).toEqual([])
})
test('pause with a pending prompt still resumes when the same step is resubmitted', async () => {
@@ -392,7 +390,7 @@ test('pause with a pending prompt still resumes when the same step is resubmitte
const attempt2 = attempts[1]!
// the new attempt auto-submits the replayed answer without navigating
- mockNavigateAppend.mockClear()
+ nav.clearActions()
const nameResponse2 = {error: jest.fn(), result: jest.fn()}
attempt2.listener.customResponseIncomingCallMap?.['keybase.1.provisionUi.PromptNewDeviceName']?.(
{errorMessage: ''} as any,
diff --git a/shared/provision/waiting-overlay.test.tsx b/shared/provision/waiting-overlay.test.tsx
index ad41ed8ac09a..961f8055b8a4 100644
--- a/shared/provision/waiting-overlay.test.tsx
+++ b/shared/provision/waiting-overlay.test.tsx
@@ -8,7 +8,6 @@ import {useWaitingState} from '@/stores/waiting'
import {waitingKeyProvision} from '@/constants/strings'
const mockPauseProvision = jest.fn()
-const mockNavigateUp = jest.fn()
const mockAddListener = jest.fn()
jest.mock('@/common-adapters', () => {
@@ -32,22 +31,21 @@ jest.mock('@react-navigation/native', () => ({
useNavigation: () => ({addListener: mockAddListener}),
}))
-jest.mock('@/constants/router', () => ({
- navigateUp: (...args: Array) => mockNavigateUp(...args),
-}))
-
jest.mock('./flow', () => ({
pauseProvision: (...args: Array) => mockPauseProvision(...args),
}))
+import {installFakeNavigator, restoreNavigator, type FakeNavigator} from '@/test/fake-navigator'
import ProvisionWaitingOverlay from './waiting-overlay'
type BeforeRemoveEvent = {data: {action: {type: string}}}
describe('ProvisionWaitingOverlay', () => {
let beforeRemove: undefined | ((e: BeforeRemoveEvent) => void)
+ let nav: FakeNavigator
beforeEach(() => {
+ nav = installFakeNavigator()
jest.useFakeTimers()
beforeRemove = undefined
mockAddListener.mockImplementation((event: string, callback: (e: BeforeRemoveEvent) => void) => {
@@ -59,11 +57,11 @@ describe('ProvisionWaitingOverlay', () => {
})
afterEach(() => {
+ restoreNavigator()
cleanup()
jest.useRealTimers()
mockAddListener.mockReset()
mockPauseProvision.mockReset()
- mockNavigateUp.mockReset()
resetAllStores()
})
@@ -121,7 +119,7 @@ describe('ProvisionWaitingOverlay', () => {
act(() => screen.getByText('Cancel').click())
expect(mockPauseProvision).toHaveBeenCalled()
- expect(mockNavigateUp).toHaveBeenCalled()
+ expect(nav.types()).toContain('GO_BACK')
})
test('popping the screen while waiting pauses the flow', () => {
diff --git a/shared/router-v2/tab-bar.desktop.tsx b/shared/router-v2/tab-bar.desktop.tsx
index 4a3592c7adcf..a3d0b601de4d 100644
--- a/shared/router-v2/tab-bar.desktop.tsx
+++ b/shared/router-v2/tab-bar.desktop.tsx
@@ -26,7 +26,7 @@ import {dumpLogs} from '@/util/storeless-actions'
const {hideWindow, ctlQuit} = KB2.functions
export type Props = {
- navigation: C.Router2.Navigator
+ navigation: C.Router2.NavigationRef
state: C.Router2.NavState
}
diff --git a/shared/settings/password.test.tsx b/shared/settings/password.test.tsx
index bec8ce687d5b..7c03744e9ad2 100644
--- a/shared/settings/password.test.tsx
+++ b/shared/settings/password.test.tsx
@@ -8,12 +8,6 @@ jest.mock('@/constants', () => ({
...(jest.requireActual('@/constants') as object),
useRPC: jest.fn(),
}))
-jest.mock('@/constants/router', () => ({
- clearModals: jest.fn(),
- navigateAppend: jest.fn(),
- navigateUp: jest.fn(),
- switchTab: jest.fn(),
-}))
// the real components pull in native/electron-only rendering; we only care
// about the password validation logic here
jest.mock('@/common-adapters', () => {
@@ -65,8 +59,8 @@ import {act, cleanup, fireEvent, render, renderHook, screen} from '@testing-libr
import * as C from '@/constants'
import RPCError from '@/util/rpcerror'
import * as T from '@/constants/types'
-import {navigateUp} from '@/constants/router'
import {resetAllStores} from '@/util/zustand'
+import {installFakeNavigator, restoreNavigator, type FakeNavigator} from '@/test/fake-navigator'
import {UpdatePassword, useSubmitNewPassword} from './password'
const typePasswords = (password: string, confirm: string) => {
@@ -76,7 +70,14 @@ const typePasswords = (password: string, confirm: string) => {
const saveButton = () => screen.getByText('Save') as HTMLButtonElement
+let nav: FakeNavigator
+
+beforeEach(() => {
+ nav = installFakeNavigator()
+})
+
afterEach(() => {
+ restoreNavigator()
cleanup()
jest.clearAllMocks()
jest.restoreAllMocks()
@@ -164,7 +165,7 @@ test('useSubmitNewPassword force-changes the password and navigates back', () =>
act(() => {
rpc.resolveNext()
})
- expect(navigateUp).toHaveBeenCalled()
+ expect(nav.types()).toContain('GO_BACK')
expect(result.current.error).toBe('')
})
@@ -206,7 +207,7 @@ test('useSubmitNewPassword logs the user out after a successful change when aske
// requestLogout() starts by asking the service whether logging out is safe
expect(rpcs.submitFor(T.RPCGen.userCanLogoutRpcPromise)).toHaveBeenCalled()
- expect(navigateUp).toHaveBeenCalled()
+ expect(nav.types()).toContain('GO_BACK')
})
test('useSubmitNewPassword leaves the session alone when it is not asked to log out', () => {
@@ -221,7 +222,7 @@ test('useSubmitNewPassword leaves the session alone when it is not asked to log
})
expect(rpcs.submitFor(T.RPCGen.userCanLogoutRpcPromise)).not.toHaveBeenCalled()
- expect(navigateUp).toHaveBeenCalled()
+ expect(nav.types()).toContain('GO_BACK')
})
test('useSubmitNewPassword does not log out when the change fails', () => {
@@ -239,7 +240,7 @@ test('useSubmitNewPassword does not log out when the change fails', () => {
})
expect(rpcs.submitFor(T.RPCGen.userCanLogoutRpcPromise)).not.toHaveBeenCalled()
- expect(navigateUp).not.toHaveBeenCalled()
+ expect(nav.types()).not.toContain('GO_BACK')
expect(result.current.error).toBe('too weak')
})
@@ -254,7 +255,7 @@ test('useSubmitNewPassword shows the service description on failure and clears i
rpc.rejectNext(new RPCError('too weak', T.RPCGen.StatusCode.scgeneric))
})
expect(result.current.error).toBe('too weak')
- expect(navigateUp).not.toHaveBeenCalled()
+ expect(nav.types()).not.toContain('GO_BACK')
act(() => {
result.current.onSave('longenough2')
diff --git a/shared/settings/use-delete-account.test.tsx b/shared/settings/use-delete-account.test.tsx
index eacdf3d5b395..c78d81911660 100644
--- a/shared/settings/use-delete-account.test.tsx
+++ b/shared/settings/use-delete-account.test.tsx
@@ -11,20 +11,15 @@ jest.mock('@/constants', () => ({
},
useRPC: jest.fn(),
}))
-jest.mock('@/constants/router', () => ({
- clearModals: jest.fn(),
- navigateAppend: jest.fn(),
-}))
-
import {act, cleanup, renderHook} from '@testing-library/react'
import * as C from '@/constants'
import * as T from '@/constants/types'
import RPCError from '@/util/rpcerror'
import logger from '@/logger'
-import {clearModals, navigateAppend} from '@/constants/router'
import {resetAllStores} from '@/util/zustand'
import {useConfigState} from '@/stores/config'
import {useCurrentUserState} from '@/stores/current-user'
+import {installFakeNavigator, makeRootState, restoreNavigator, type FakeNavigator} from '@/test/fake-navigator'
import {useDeleteAccount} from './use-delete-account'
type DeleteSubmit = (
@@ -33,6 +28,18 @@ type DeleteSubmit = (
reject: (error: RPCError) => void
) => void
+// The delete flow runs from a confirmation modal, so the fake starts with one open:
+// clearModals only has something to dispatch when a modal is actually on screen.
+const deleteModal = 'settingsDeleteConfirm'
+let nav: FakeNavigator
+
+beforeEach(() => {
+ nav = installFakeNavigator({
+ modalRouteNames: [deleteModal],
+ rootState: makeRootState({above: [{name: deleteModal}]}),
+ })
+})
+
const mockDeleteRPC = () => {
const pending = new Array<{reject: (error: RPCError) => void; resolve: () => void}>()
const submit = jest.fn(
@@ -53,6 +60,7 @@ const mockDeleteRPC = () => {
}
afterEach(() => {
+ restoreNavigator()
cleanup()
mockAndroidIsTestDevice.value = false
jest.clearAllMocks()
@@ -91,8 +99,12 @@ test('deletes forever, records the deleted self and sends the user to login', ()
})
expect(setJustDeletedSelf).toHaveBeenCalledWith('testuser')
- expect(clearModals).toHaveBeenCalled()
- expect(navigateAppend).toHaveBeenCalledWith({name: C.Tabs.loginTab, params: {}})
+ // clearModals: the confirmation modal is reset away, leaving only the tab navigator
+ const cleared = nav.actions.find(a => a.type === 'RESET')
+ expect((cleared?.payload as {routes: Array<{name: string}>} | undefined)?.routes.map(r => r.name)).toEqual([
+ 'loggedIn',
+ ])
+ expect(nav.pushes()).toContainEqual({name: C.Tabs.loginTab, params: {}})
})
test('passes an undefined passphrase through for accounts without one', () => {
@@ -126,8 +138,7 @@ test('pre-launch test devices never reach the delete rpc', () => {
})
expect(rpc.submit).not.toHaveBeenCalled()
- expect(clearModals).not.toHaveBeenCalled()
- expect(navigateAppend).not.toHaveBeenCalled()
+ expect(nav.actions).toEqual([])
})
test('logs and stays put when the delete rpc fails', () => {
@@ -151,5 +162,5 @@ test('logs and stays put when the delete rpc fails', () => {
expect.objectContaining({code: T.RPCGen.StatusCode.scgeneric})
)
expect(setJustDeletedSelf).not.toHaveBeenCalled()
- expect(clearModals).not.toHaveBeenCalled()
+ expect(nav.actions).toEqual([])
})
diff --git a/shared/settings/use-request-logout.test.tsx b/shared/settings/use-request-logout.test.tsx
index f38b72098479..86ce8650740d 100644
--- a/shared/settings/use-request-logout.test.tsx
+++ b/shared/settings/use-request-logout.test.tsx
@@ -6,19 +6,14 @@ jest.mock('@/constants', () => ({
...(jest.requireActual('@/constants') as object),
useRPC: jest.fn(),
}))
-jest.mock('@/constants/router', () => ({
- navigateAppend: jest.fn(),
- switchTab: jest.fn(),
-}))
-
import {act, cleanup, renderHook, waitFor} from '@testing-library/react'
import * as C from '@/constants'
import * as T from '@/constants/types'
import * as Tabs from '@/constants/tabs'
-import {navigateAppend, switchTab} from '@/constants/router'
import {settingsPasswordTab} from '@/constants/settings'
import {resetAllStores} from '@/util/zustand'
import {usePushState} from '@/stores/push'
+import {installFakeNavigator, restoreNavigator, type FakeNavigator} from '@/test/fake-navigator'
import {useRequestLogout} from './use-request-logout'
type MutableGlobals = {isMobile: boolean}
@@ -44,7 +39,14 @@ const mockCanLogoutRPC = () => {
}
}
+let nav: FakeNavigator
+
+beforeEach(() => {
+ nav = installFakeNavigator()
+})
+
afterEach(() => {
+ restoreNavigator()
cleanup()
jest.clearAllMocks()
jest.restoreAllMocks()
@@ -78,7 +80,7 @@ test('logs out after unregistering the push token when the service allows it', a
await waitFor(() => expect(logoutRPC).toHaveBeenCalledWith({force: false, keepSecrets: false}))
// the API call needs the still-logged-in session, so the token has to go first
expect(order).toEqual(['deleteToken', 'logout'])
- expect(navigateAppend).not.toHaveBeenCalled()
+ expect(nav.actions).toEqual([])
})
test('a failing logout rpc is swallowed', async () => {
@@ -102,7 +104,7 @@ test('a failing logout rpc is swallowed', async () => {
await waitFor(() => expect(logoutRPC).toHaveBeenCalled())
// failures are swallowed: nothing navigates and the caller never sees a rejection
- expect(navigateAppend).not.toHaveBeenCalled()
+ expect(nav.actions).toEqual([])
})
test('desktop routes to the password tab when the user cannot log out yet', () => {
@@ -119,8 +121,10 @@ test('desktop routes to the password tab when the user cannot log out yet', () =
})
expect(logoutRPC).not.toHaveBeenCalled()
- expect(switchTab).toHaveBeenCalledWith(Tabs.settingsTab)
- expect(navigateAppend).toHaveBeenCalledWith({name: settingsPasswordTab, params: {}})
+ expect(nav.actions).toContainEqual(
+ expect.objectContaining({payload: {name: Tabs.settingsTab}, type: 'JUMP_TO'})
+ )
+ expect(nav.pushes()).toContainEqual({name: settingsPasswordTab, params: {}})
})
test('mobile pushes the password tab without switching tabs', () => {
@@ -136,6 +140,6 @@ test('mobile pushes the password tab without switching tabs', () => {
rpc.answer(false)
})
- expect(switchTab).not.toHaveBeenCalled()
- expect(navigateAppend).toHaveBeenCalledWith({name: settingsPasswordTab, params: {}})
+ expect(nav.types()).not.toContain('JUMP_TO')
+ expect(nav.pushes()).toContainEqual({name: settingsPasswordTab, params: {}})
})
diff --git a/shared/signup/use-request-auto-invite.test.tsx b/shared/signup/use-request-auto-invite.test.tsx
index c6fe7725b19c..eccc01bde234 100644
--- a/shared/signup/use-request-auto-invite.test.tsx
+++ b/shared/signup/use-request-auto-invite.test.tsx
@@ -7,19 +7,19 @@ import {useConfigState} from '@/stores/config'
import {useWaitingState} from '@/stores/waiting'
import {waitingKeySignup} from '@/constants/strings'
-jest.mock('@/constants/router', () => {
- const actual = jest.requireActual('@/constants/router')
- return {...actual, navigateAppend: jest.fn()}
-})
-
+import {installFakeNavigator, restoreNavigator, type FakeNavigator} from '@/test/fake-navigator'
import useRequestAutoInvite from './use-request-auto-invite'
-const {navigateAppend: mockNavigateAppend} = require('@/constants/router') as {navigateAppend: jest.Mock}
+let nav: FakeNavigator
+
+beforeEach(() => {
+ nav = installFakeNavigator()
+})
afterEach(() => {
+ restoreNavigator()
cleanup()
jest.restoreAllMocks()
- mockNavigateAppend.mockReset()
resetAllStores()
})
@@ -33,7 +33,7 @@ test('fetches an invite code and moves on to the username screen', async () => {
result.current('testuser')
await waitFor(() =>
- expect(mockNavigateAppend).toHaveBeenCalledWith({
+ expect(nav.pushes()).toContainEqual({
name: 'signupEnterUsername',
params: {inviteCode: 'invite-code', username: 'testuser'},
})
@@ -51,7 +51,7 @@ test('logs out first when an account is already signed in', async () => {
result.current('testuser')
await waitFor(() =>
- expect(mockNavigateAppend).toHaveBeenCalledWith({
+ expect(nav.pushes()).toContainEqual({
name: 'signupEnterUsername',
params: {inviteCode: 'invite-code', username: 'testuser'},
})
@@ -66,7 +66,7 @@ test('a failed invite code fetch still continues with an empty code', async () =
result.current('testuser')
await waitFor(() =>
- expect(mockNavigateAppend).toHaveBeenCalledWith({
+ expect(nav.pushes()).toContainEqual({
name: 'signupEnterUsername',
params: {inviteCode: '', username: 'testuser'},
})
@@ -85,5 +85,5 @@ test('a request already in flight is ignored', async () => {
// nothing to wait for; give any queued work a full macrotask to run anyway
await new Promise(resolve => setTimeout(resolve, 0))
expect(getCode).not.toHaveBeenCalled()
- expect(mockNavigateAppend).not.toHaveBeenCalled()
+ expect(nav.actions).toEqual([])
})
diff --git a/shared/teams/actions.test.ts b/shared/teams/actions.test.ts
index 8d645a586c5b..8d411a692327 100644
--- a/shared/teams/actions.test.ts
+++ b/shared/teams/actions.test.ts
@@ -1,66 +1,61 @@
///
import * as T from '@/constants/types'
-const mockNavigateAppend = jest.fn()
-jest.mock('@/constants/router', () => ({
- clearModals: jest.fn(),
- navUpToScreen: jest.fn(),
- navigateAppend: (...args: Array) => mockNavigateAppend(...args),
- navigateUp: jest.fn(),
-}))
-
import {RPCError} from '@/util/errors'
+import {installFakeNavigator, restoreNavigator, type FakeNavigator} from '@/test/fake-navigator'
import {handleContactSettingsBlock, handleNotAdded} from './actions'
+let nav: FakeNavigator
+
+beforeEach(() => {
+ nav = installFakeNavigator()
+})
+
+afterEach(() => {
+ restoreNavigator()
+})
+
const contactSettingsError = (fields: unknown) =>
new RPCError('blocked', T.RPCGen.StatusCode.scteamcontactsettingsblock, fields)
describe('handleContactSettingsBlock', () => {
- beforeEach(() => {
- mockNavigateAppend.mockClear()
- })
-
test('ignores other error codes', () => {
expect(handleContactSettingsBlock(new RPCError('nope', T.RPCGen.StatusCode.scgeneric))).toBe(false)
- expect(mockNavigateAppend).not.toHaveBeenCalled()
+ expect(nav.actions).toEqual([])
})
test('navigates with the blocked usernames', () => {
expect(
handleContactSettingsBlock(contactSettingsError([{key: 'usernames', value: 'testuser,testuser-mac'}]))
).toBe(true)
- expect(mockNavigateAppend).toHaveBeenCalledWith({
- name: 'contactRestricted',
- params: {source: 'teamAddAllFailed', usernames: ['testuser', 'testuser-mac']},
- })
+ expect(nav.pushes()).toEqual([
+ {
+ name: 'contactRestricted',
+ params: {source: 'teamAddAllFailed', usernames: ['testuser', 'testuser-mac']},
+ },
+ ])
})
// '' splits into [''], which used to put a blank row on the contactRestricted screen
test('has no usernames when the field is empty or missing', () => {
for (const fields of [[{key: 'usernames', value: ''}], [{key: 'other', value: 'testuser'}], undefined]) {
- mockNavigateAppend.mockClear()
+ nav.clearActions()
expect(handleContactSettingsBlock(contactSettingsError(fields))).toBe(true)
- expect(mockNavigateAppend).toHaveBeenCalledWith({
- name: 'contactRestricted',
- params: {source: 'teamAddAllFailed', usernames: []},
- })
+ expect(nav.pushes()).toEqual([
+ {name: 'contactRestricted', params: {source: 'teamAddAllFailed', usernames: []}},
+ ])
}
})
})
describe('handleNotAdded', () => {
- beforeEach(() => {
- mockNavigateAppend.mockClear()
- })
-
test('navigates only when somebody was skipped', () => {
handleNotAdded([])
handleNotAdded(undefined)
- expect(mockNavigateAppend).not.toHaveBeenCalled()
+ expect(nav.actions).toEqual([])
handleNotAdded([{username: 'testuser'}])
- expect(mockNavigateAppend).toHaveBeenCalledWith({
- name: 'contactRestricted',
- params: {source: 'teamAddSomeFailed', usernames: ['testuser']},
- })
+ expect(nav.pushes()).toEqual([
+ {name: 'contactRestricted', params: {source: 'teamAddSomeFailed', usernames: ['testuser']}},
+ ])
})
})
diff --git a/shared/test/fake-navigator.ts b/shared/test/fake-navigator.ts
new file mode 100644
index 000000000000..93332a730ded
--- /dev/null
+++ b/shared/test/fake-navigator.ts
@@ -0,0 +1,188 @@
+// The in-memory Navigator adapter: the second implementation of the seam, so that
+// substituting navigation in a test is installing an adapter rather than mocking a
+// module. It records what was dispatched and serves whatever root state the test set;
+// it deliberately does not reduce actions into state - a test that needs the tree to
+// change says so with setRootState.
+import * as NavTree from '@/constants/nav-tree'
+import * as Tabs from '@/constants/tabs'
+import {makeNavigator, setNavigator, type Navigator, type NavigatorRef} from '@/constants/navigator'
+
+// The action shapes React Navigation's creators produce, narrowed to what assertions need.
+export type RecordedAction = {
+ type: string
+ payload?: Record
+ target?: string
+ source?: string
+}
+
+export type FakeNavigator = Navigator & {
+ actions: Array
+ lastAction: () => RecordedAction | undefined
+ // Every route name pushed, in order, with the params it was pushed with.
+ pushes: () => Array<{name?: unknown; params?: unknown}>
+ // Every screen navigateAppend asked for, in order: pushes plus the replaces it
+ // dispatches when called with replace=true.
+ navigations: () => Array<{name?: unknown; params?: unknown; replace: boolean}>
+ // Whether a reset of the root stack left no modal behind, which is what clearModals does.
+ modalsCleared: () => boolean
+ // The dispatched action types, in order (PUSH, RESET, GO_BACK, JUMP_TO, ...).
+ types: () => Array
+ clearActions: () => void
+ // Replaces the root state and fires the 'state' listeners, as a real commit would.
+ setRootState: (state?: NavTree.NavState) => void
+ setReady: (ready: boolean) => void
+}
+
+type RouteSpec = {name: string; params?: object}
+
+// A keyed, logged-in root state. Keys are stable and readable so that `target`/`source`
+// assertions can name them: 'root', 'tabs', '-stack', '-'.
+export const makeRootState = (p?: {
+ tab?: Tabs.AppTab
+ // screens inside the selected tab's stack; defaults to that tab's root screen
+ tabStack?: ReadonlyArray
+ // screens in the root stack above the tab navigator: modals and phone-pushed screens
+ above?: ReadonlyArray
+ // false builds the logged-out root instead; `above` still applies, `tab`/`tabStack` do not
+ loggedIn?: boolean
+}): NavTree.NavState => {
+ const tab = p?.tab ?? Tabs.chatTab
+ const above = p?.above ?? []
+ if (p?.loggedIn === false) {
+ // The logged-out root is a real stack with its own key and screens, so a reader that
+ // forgets to check which root it is looking at finds something to act on.
+ return {
+ index: above.length,
+ key: 'root',
+ routes: [
+ {
+ key: 'loggedOut',
+ name: 'loggedOut',
+ state: {
+ index: 0,
+ key: 'loggedOut-stack',
+ routes: [{key: 'login-0', name: 'login'}],
+ type: 'stack',
+ },
+ },
+ ...above.map((r, i) => ({key: `${r.name}-above-${i}`, name: r.name, params: r.params})),
+ ],
+ type: 'stack',
+ }
+ }
+ const tabStack = p?.tabStack ?? [{name: NavTree.tabRoots[tab]}]
+ return {
+ index: above.length,
+ key: 'root',
+ routes: [
+ {
+ key: 'loggedIn',
+ name: 'loggedIn',
+ state: {
+ index: 0,
+ key: 'tabs',
+ routes: [
+ {
+ key: tab,
+ name: tab,
+ state: {
+ index: tabStack.length - 1,
+ key: `${tab}-stack`,
+ routes: tabStack.map((r, i) => ({key: `${r.name}-${i}`, name: r.name, params: r.params})),
+ type: 'stack',
+ },
+ },
+ ],
+ type: 'tab',
+ },
+ },
+ ...above.map((r, i) => ({key: `${r.name}-above-${i}`, name: r.name, params: r.params})),
+ ],
+ type: 'stack',
+ }
+}
+
+export const makeFakeNavigator = (p?: {
+ rootState?: NavTree.NavState
+ ready?: boolean
+ // Called at the moment of dispatch, for tests that assert ordering against it.
+ onDispatch?: (action: RecordedAction) => void
+}): FakeNavigator => {
+ const actions: Array = []
+ let rootState = p?.rootState ?? makeRootState()
+ let ready = p?.ready ?? true
+ const listeners = new Set<() => void>()
+
+ const ref: NavigatorRef = {
+ addListener: (_type, cb) => {
+ listeners.add(cb)
+ return () => listeners.delete(cb)
+ },
+ dispatch: action => {
+ if (!ready) return
+ const recorded = action as unknown as RecordedAction
+ actions.push(recorded)
+ p?.onDispatch?.(recorded)
+ },
+ getRootState: () => (ready ? rootState : undefined),
+ isReady: () => ready,
+ }
+
+ const navigator = makeNavigator(ref)
+
+ return {
+ ...navigator,
+ actions,
+ clearActions: () => {
+ actions.length = 0
+ },
+ lastAction: () => actions.at(-1),
+ modalsCleared: () =>
+ actions.some(
+ a =>
+ a.type === 'RESET' &&
+ a.target === 'root' &&
+ ((a.payload?.['routes'] ?? []) as ReadonlyArray<{name: string}>).every(
+ r => !NavTree.isModalRouteName(r.name)
+ )
+ ),
+ navigations: () =>
+ actions
+ .filter(a => a.type === 'PUSH' || a.type === 'REPLACE')
+ .map(a => ({name: a.payload?.['name'], params: a.payload?.['params'], replace: a.type === 'REPLACE'})),
+ pushes: () =>
+ actions
+ .filter(a => a.type === 'PUSH')
+ .map(a => ({name: a.payload?.['name'], params: a.payload?.['params']})),
+ setReady: next => {
+ ready = next
+ },
+ setRootState: next => {
+ rootState = next
+ for (const cb of [...listeners]) {
+ cb()
+ }
+ },
+ types: () => actions.map(a => a.type),
+ }
+}
+
+// Installs the fake as the app-wide Navigator, so the free-function facade
+// (C.Router2.navigateAppend and friends) drives it. Also registers the modal route
+// names, which the tree readers require.
+export const installFakeNavigator = (p?: {
+ rootState?: NavTree.NavState
+ ready?: boolean
+ modalRouteNames?: Iterable
+ onDispatch?: (action: RecordedAction) => void
+}): FakeNavigator => {
+ NavTree.setModalRouteNames(p?.modalRouteNames ?? [])
+ const fake = makeFakeNavigator(p)
+ setNavigator(fake)
+ return fake
+}
+
+export const restoreNavigator = () => {
+ setNavigator()
+ NavTree.setModalRouteNames([])
+}
diff --git a/shared/test/mocks/react-navigation-core.js b/shared/test/mocks/react-navigation-core.js
index 79739b7e6795..988ece9828d1 100644
--- a/shared/test/mocks/react-navigation-core.js
+++ b/shared/test/mocks/react-navigation-core.js
@@ -52,15 +52,23 @@ exports.useFocusEffect = fn => {
}
exports.createNavigationContainerRef = () => makeNavigationContainerRef()
+// Action creators, matching @react-navigation/routers' real payload shapes so that a
+// test asserting on a dispatched action is asserting what production dispatches.
exports.CommonActions = {
goBack: () => ({type: 'GO_BACK'}),
- navigate: payload => ({payload, type: 'NAVIGATE'}),
+ navigate: (name, params, options) => ({
+ payload: {merge: options && options.merge, name, params, pop: options && options.pop},
+ type: 'NAVIGATE',
+ }),
reset: payload => ({payload, type: 'RESET'}),
- setParams: payload => ({payload, type: 'SET_PARAMS'}),
+ setParams: params => ({payload: {params}, type: 'SET_PARAMS'}),
}
exports.StackActions = {
- popTo: name => ({payload: {name}, type: 'POP_TO'}),
+ popTo: (name, params, options) => ({
+ payload: {merge: options && options.merge, name, params},
+ type: 'POP_TO',
+ }),
popToTop: () => ({type: 'POP_TO_TOP'}),
push: (name, params) => ({payload: {name, params}, type: 'PUSH'}),
replace: (name, params) => ({payload: {name, params}, type: 'REPLACE'}),