diff --git a/shared/constants/navigate-append-once-root-has.test.ts b/shared/constants/navigate-append-once-root-has.test.ts
new file mode 100644
index 000000000000..5a6a5cd1df06
--- /dev/null
+++ b/shared/constants/navigate-append-once-root-has.test.ts
@@ -0,0 +1,85 @@
+///
+import {navigateAppendOnceRootHas, navigationRef} from '@/constants/router'
+
+const dispatch = jest.fn()
+const listeners = new Set<() => void>()
+let rootState: unknown
+
+const loggedIn = {key: 'loggedIn-1', name: 'loggedIn'}
+const loggedOut = {
+ key: 'loggedOut-1',
+ name: 'loggedOut',
+ state: {index: 0, key: 'loggedOutStack-1', routes: [{key: 'login-1', name: 'login'}], type: 'stack'},
+}
+
+const setRootRoutes = (routes: Array) => {
+ rootState = {index: routes.length - 1, key: 'root-1', routeNames: [], routes, stale: false, type: 'stack'}
+}
+const emitState = () => {
+ for (const l of [...listeners]) {
+ l()
+ }
+}
+
+beforeEach(() => {
+ dispatch.mockReset()
+ listeners.clear()
+ // 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'] = () => rootState
+ nr['isReady'] = () => true
+ nr['addListener'] = (_: string, cb: () => void) => {
+ listeners.add(cb)
+ return () => listeners.delete(cb)
+ }
+})
+
+afterEach(() => {
+ jest.useRealTimers()
+})
+
+// Each test pushes distinct params: navigateAppend's module-private `_pendingAppend` dupe cache
+// would otherwise swallow a same-shaped push from an earlier test.
+const pushOf = (username: string) =>
+ expect.objectContaining({payload: {name: 'username', params: {username}}, type: 'PUSH'})
+
+test('pushes right away when the root already has the route', () => {
+ setRootRoutes([loggedOut])
+
+ navigateAppendOnceRootHas('loggedOut', {name: 'username', params: {username: 'testuser-a'}} as never)
+
+ expect(dispatch).toHaveBeenCalledTimes(1)
+ expect(dispatch).toHaveBeenCalledWith(pushOf('testuser-a'))
+})
+
+test('waits for the root route to mount, then pushes once', () => {
+ setRootRoutes([loggedIn])
+
+ navigateAppendOnceRootHas('loggedOut', {name: 'username', params: {username: 'testuser-b'}} as never)
+ expect(dispatch).not.toHaveBeenCalled()
+
+ emitState()
+ expect(dispatch).not.toHaveBeenCalled()
+
+ setRootRoutes([loggedOut])
+ emitState()
+ expect(dispatch).toHaveBeenCalledTimes(1)
+ expect(dispatch).toHaveBeenCalledWith(pushOf('testuser-b'))
+
+ emitState()
+ expect(dispatch).toHaveBeenCalledTimes(1)
+})
+
+test('gives up if the root route does not mount before the timeout', () => {
+ jest.useFakeTimers()
+ setRootRoutes([loggedIn])
+
+ navigateAppendOnceRootHas('loggedOut', {name: 'username', params: {username: 'testuser-c'}} as never, 5000)
+ jest.advanceTimersByTime(5000)
+
+ setRootRoutes([loggedOut])
+ emitState()
+ expect(dispatch).not.toHaveBeenCalled()
+})
diff --git a/shared/constants/router.tsx b/shared/constants/router.tsx
index 583a2edb7505..a06bcf764bbb 100644
--- a/shared/constants/router.tsx
+++ b/shared/constants/router.tsx
@@ -452,6 +452,33 @@ export function navigateAppend(path: NavigateAppendType, replace?: boolean): boo
return true
}
+// Push once the root stack has a `rootRouteName` route. For a push whose target lives in a
+// conditional root group that a store change is about to mount (e.g. the logged-out stack): a push
+// dispatched before the group mounts reaches no navigator that can handle it and is dropped. Gives
+// up after `timeoutMs` so a group that never mounts can't fire the push at some unrelated later time.
+export const navigateAppendOnceRootHas = (
+ rootRouteName: string,
+ path: NavigateAppendType,
+ timeoutMs = 5000
+) => {
+ const rootHas = () => getRootState()?.routes?.some(r => r.name === rootRouteName) ?? false
+ if (rootHas()) {
+ navigateAppend(path)
+ return
+ }
+ const n = _getNavigator()
+ if (!n) {
+ return
+ }
+ const timer = setTimeout(() => unsub(), timeoutMs)
+ const unsub = n.addListener('state', () => {
+ if (!rootHas()) return
+ clearTimeout(timer)
+ unsub()
+ navigateAppend(path)
+ })
+}
+
export const switchTab = (name: Tabs.AppTab) => {
if (DEBUG_NAV) {
console.log('[Nav] switchTab', {name})
diff --git a/shared/patches/react-native-screens+4.27.0.patch b/shared/patches/react-native-screens+4.27.0.patch
index 9e51059e0884..4554f9f2e3b0 100644
--- a/shared/patches/react-native-screens+4.27.0.patch
+++ b/shared/patches/react-native-screens+4.27.0.patch
@@ -114,6 +114,33 @@ index add33c4..8022575 100644
}
}
#endif // RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
+diff --git a/node_modules/react-native-screens/ios/tabs/host/RNSTabBarController.mm b/node_modules/react-native-screens/ios/tabs/host/RNSTabBarController.mm
+index 06c1957..222e098 100644
+--- a/node_modules/react-native-screens/ios/tabs/host/RNSTabBarController.mm
++++ b/node_modules/react-native-screens/ios/tabs/host/RNSTabBarController.mm
+@@ -307,12 +307,22 @@ - (BOOL)updateSelectedViewControllerTo:(nullable UIViewController *)nextSelected
+ RCTAssert(![NSString rnscreens_isBlankOrNull:screenKey],
+ @"[RNScreens] The screenKey MUST NOT be null if the view controller is not null");
+
++ BOOL isInitialSelection = _navigationState == nil;
+ [self progressNavigationState:screenKey withOrigin:actionOrigin];
+
+ if (currSelectedViewController == nextSelectedViewController) {
+ return YES;
+ }
+
++ // setViewControllers: already selected index 0; don't slide the iOS 26 glass pill to the startup tab.
++ if (isInitialSelection) {
++ [UIView performWithoutAnimation:^{
++ [self setSelectedViewController:nextSelectedViewController];
++ [self.tabBar layoutIfNeeded];
++ }];
++ return YES;
++ }
++
+ [self setSelectedViewController:nextSelectedViewController];
+ return YES;
+ }
diff --git a/node_modules/react-native-screens/ios/utils/UINavigationBar+RNSUtility.h b/node_modules/react-native-screens/ios/utils/UINavigationBar+RNSUtility.h
index 0e7010d..8e3af12 100644
--- a/node_modules/react-native-screens/ios/utils/UINavigationBar+RNSUtility.h
diff --git a/shared/router-v2/account-switch-header-avatar.native.tsx b/shared/router-v2/account-switch-header-avatar.native.tsx
index b12f24b89bc1..cae606b7058f 100644
--- a/shared/router-v2/account-switch-header-avatar.native.tsx
+++ b/shared/router-v2/account-switch-header-avatar.native.tsx
@@ -32,7 +32,7 @@ const AccountSwitchHeaderAvatar = () => {
handledLongPressRef.current = true
C.ignorePromise(Haptics.selectionAsync())
rememberAccountSwitchTab(username, recentAccount.username, C.Router2.getTab())
- setUserSwitching(true)
+ setUserSwitching(true, recentAccount.username)
login(recentAccount.username, '')
}
diff --git a/shared/router-v2/account-switch.test.tsx b/shared/router-v2/account-switch.test.tsx
index bd35eb979d10..dcfeee0ec3cc 100644
--- a/shared/router-v2/account-switch.test.tsx
+++ b/shared/router-v2/account-switch.test.tsx
@@ -5,7 +5,9 @@ import {
clearPendingAccountSwitch,
consumePendingAccountSwitchTab,
getMostRecentlyUsedAccount,
+ peekPendingAccountSwitchTab,
rememberAccountSwitchTab,
+ showLoggedInScreens,
} from './account-switch'
const account = (username: string, hasStoredSecret = true) => ({
@@ -44,6 +46,14 @@ describe('pending account-switch tab', () => {
expect(consumePendingAccountSwitchTab('bob')).toBeUndefined()
})
+ test('peeks the remembered tab for the target account without consuming it', () => {
+ rememberAccountSwitchTab('alice', 'bob', Tabs.fsTab)
+
+ expect(peekPendingAccountSwitchTab('alice')).toBeUndefined()
+ expect(peekPendingAccountSwitchTab('bob')).toBe(Tabs.fsTab)
+ expect(consumePendingAccountSwitchTab('bob')).toBe(Tabs.fsTab)
+ })
+
test('does not consume the tab before the account changes', () => {
rememberAccountSwitchTab('alice', 'bob', Tabs.fsTab)
@@ -73,3 +83,24 @@ describe('pending account-switch tab', () => {
expect(consumePendingAccountSwitchTab('bob')).toBeUndefined()
})
})
+
+describe('showLoggedInScreens', () => {
+ const state = (loggedIn: boolean, userSwitching = false, userSwitchingFromLoggedIn = false) => ({
+ loggedIn,
+ userSwitching,
+ userSwitchingFromLoggedIn,
+ })
+
+ test('follows loggedIn when no switch is running', () => {
+ expect(showLoggedInScreens(state(true))).toBe(true)
+ expect(showLoggedInScreens(state(false))).toBe(false)
+ })
+
+ test('holds the logged-in screens through the loggedIn flap of a switch that started logged in', () => {
+ expect(showLoggedInScreens(state(false, true, true))).toBe(true)
+ })
+
+ test('keeps the logged-out screens for a switch that started logged out', () => {
+ expect(showLoggedInScreens(state(false, true, false))).toBe(false)
+ })
+})
diff --git a/shared/router-v2/account-switch.tsx b/shared/router-v2/account-switch.tsx
index 5568af4a57f9..8148579f141e 100644
--- a/shared/router-v2/account-switch.tsx
+++ b/shared/router-v2/account-switch.tsx
@@ -30,6 +30,9 @@ export const rememberAccountSwitchTab = (
: undefined
}
+export const peekPendingAccountSwitchTab = (currentUsername: string) =>
+ pendingAccountSwitch?.targetUsername === currentUsername ? pendingAccountSwitch.tab : undefined
+
export const consumePendingAccountSwitchTab = (currentUsername: string) => {
const pending = pendingAccountSwitch
if (pending?.targetUsername !== currentUsername) return
@@ -37,6 +40,18 @@ export const consumePendingAccountSwitchTab = (currentUsername: string) => {
return pending.tab
}
+// Whether the root navigator shows the logged-in screens. A switch that starts while logged in flaps
+// loggedIn false and back between the service's loggedOut and loggedIn notifications. Following
+// that would swap the native root stack to loggedOut and back right before the navKey remount, and
+// that churn leaves RNS screens from the unmounted navigator on screen, swallowing every touch. So
+// hold the logged-in screens through such a switch. A switch that starts logged out (e.g. a
+// notification tap on the login screen) keeps the logged-out screens until it lands.
+export const showLoggedInScreens = (s: {
+ loggedIn: boolean
+ userSwitching: boolean
+ userSwitchingFromLoggedIn: boolean
+}) => s.loggedIn || (s.userSwitching && s.userSwitchingFromLoggedIn)
+
export const clearPendingAccountSwitch = (currentUsername: string) => {
if (pendingAccountSwitch?.targetUsername !== currentUsername) {
pendingAccountSwitch = undefined
diff --git a/shared/router-v2/account-switcher/index.tsx b/shared/router-v2/account-switcher/index.tsx
index fd8a03d5fa25..d99a9974bd38 100644
--- a/shared/router-v2/account-switcher/index.tsx
+++ b/shared/router-v2/account-switcher/index.tsx
@@ -36,7 +36,7 @@ const AccountSwitcher = (p: {onSelected?: () => void}) => {
if (isMobile) {
rememberAccountSwitchTab(you, username, C.Router2.getTab())
}
- setUserSwitching(true)
+ setUserSwitching(true, username)
login(username, '')
}
diff --git a/shared/router-v2/linking-initial-url.test.ts b/shared/router-v2/linking-initial-url.test.ts
index 4930318e5da8..b84bfdc6fc8a 100644
--- a/shared/router-v2/linking-initial-url.test.ts
+++ b/shared/router-v2/linking-initial-url.test.ts
@@ -6,6 +6,7 @@ import {useConfigState} from '@/stores/config'
import {useCurrentUserState} from '@/stores/current-user'
import {useNavigationIntentsState} from '@/stores/navigation-intents'
import {usePushState} from '@/stores/push'
+import {peekPendingAccountSwitchTab, rememberAccountSwitchTab} from './account-switch'
import {createLinkingConfig} from './linking'
const setCurrentUser = (uid: string) => {
@@ -53,9 +54,26 @@ beforeEach(() => {
afterEach(() => {
handleAppLink.mockReset()
+ rememberAccountSwitchTab('', '', undefined)
resetAllStores()
})
+test('an account switch starts on the switcher tab without consuming it before onReady', async () => {
+ rememberAccountSwitchTab('testuser', 'testuser-mac', Tabs.teamsTab)
+ setCurrentUser('testuser-mac')
+ setStartup({conversation: 'conv-1', conversationUid: 'testuser-mac', tab: Tabs.chatTab})
+
+ await expect(getInitialURL()).resolves.toBe(`keybase://${Tabs.teamsTab}`)
+ expect(peekPendingAccountSwitchTab('testuser-mac')).toBe(Tabs.teamsTab)
+})
+
+test('a switcher tab remembered for another account does not preempt the saved route', async () => {
+ rememberAccountSwitchTab('current-uid', 'testuser-mac', Tabs.teamsTab)
+ setStartup({tab: Tabs.chatTab})
+
+ await expect(getInitialURL()).resolves.toBe(`keybase://${Tabs.chatTab}`)
+})
+
test('a logged out app has no initial url', async () => {
useConfigState.getState().dispatch.setLoggedIn(false)
setStartup({tab: Tabs.chatTab})
diff --git a/shared/router-v2/linking.tsx b/shared/router-v2/linking.tsx
index 6f3442f00728..5e3f2835e07b 100644
--- a/shared/router-v2/linking.tsx
+++ b/shared/router-v2/linking.tsx
@@ -8,6 +8,7 @@ import {usePushState} from '@/stores/push'
import type {LinkingOptions} from '@react-navigation/native'
import type {RootParamList} from './route-params'
import {Linking} from 'react-native'
+import {peekPendingAccountSwitchTab} from './account-switch'
import {emitDeepLink, normalizeUrl, setInitialURLOnce} from './deep-link-emitter'
// Re-exported so existing importers ('@/router-v2/linking') keep working; the
// definitions live in the dependency-free './deep-link-emitter' leaf.
@@ -255,6 +256,13 @@ export const createLinkingConfig = (
const {loggedIn, startup, androidShare} = useConfigState.getState()
if (!loggedIn) return null
+ // An account switch remounts the navigator. Start it on the switcher's tab: switching there
+ // after mount slides the iOS 26 glass tab pill over from the first tab.
+ const accountSwitchTab = peekPendingAccountSwitchTab(useCurrentUserState.getState().username)
+ if (accountSwitchTab) {
+ return setInitialURLOnce(`keybase://${accountSwitchTab}`)
+ }
+
const {tab: startupTab, followUser: startupFollowUser} = startup
let startupConversation = startup.conversation
if (!isValidConversationIDKey(startupConversation)) {
diff --git a/shared/router-v2/router.tsx b/shared/router-v2/router.tsx
index cb9916196462..bb00269359e2 100644
--- a/shared/router-v2/router.tsx
+++ b/shared/router-v2/router.tsx
@@ -32,7 +32,7 @@ import {createBottomTabNavigator} from '@react-navigation/bottom-tabs'
import {isLiquidGlassSupported as _isLiquidGlassSupported} from '@callstack/liquid-glass'
import {Platform, StatusBar, View} from 'react-native'
import AccountSwitchHeaderAvatar from './account-switch-header-avatar'
-import {clearPendingAccountSwitch, consumePendingAccountSwitchTab} from './account-switch'
+import {clearPendingAccountSwitch, consumePendingAccountSwitchTab, showLoggedInScreens} from './account-switch'
import {useCurrentUserState} from '@/stores/current-user'
import {useNavigationIntentsState} from '@/stores/navigation-intents'
const isLiquidGlassSupported = isMobile ? (_isLiquidGlassSupported as boolean) : false
@@ -604,8 +604,8 @@ if (isMobile) {
}
}
- const useIsLoggedInNative = () => useConfigState(s => s.loggedIn)
- const useIsLoggedOutNative = () => !useConfigState(s => s.loggedIn)
+ const useIsLoggedInNative = () => useConfigState(showLoggedInScreens)
+ const useIsLoggedOutNative = () => !useConfigState(showLoggedInScreens)
const nativeModalScreensConfig = routeMapToStaticScreens(modalRoutes, makeLayout, true, false, false)
const nativePhoneRootScreensConfig = routeMapToStaticScreens(
diff --git a/shared/router-v2/tab-bar.desktop.tsx b/shared/router-v2/tab-bar.desktop.tsx
index 4a3592c7adcf..8a5d71f9d2eb 100644
--- a/shared/router-v2/tab-bar.desktop.tsx
+++ b/shared/router-v2/tab-bar.desktop.tsx
@@ -265,7 +265,7 @@ function Tab(props: TabProps) {
const accountRows = useConfigState.getState().configuredAccounts
const row = accountRows.find(a => a.username !== current && a.hasStoredSecret)
if (row) {
- setUserSwitching(true)
+ setUserSwitching(true, row.username)
login(row.username, '')
} else {
onSelectTab(tab)
diff --git a/shared/router-v2/use-user-switch-nav-key.test.tsx b/shared/router-v2/use-user-switch-nav-key.test.tsx
index 9acc547b40d7..4ba36b04e610 100644
--- a/shared/router-v2/use-user-switch-nav-key.test.tsx
+++ b/shared/router-v2/use-user-switch-nav-key.test.tsx
@@ -1,10 +1,23 @@
/** @jest-environment jsdom */
///
import {act, cleanup, renderHook} from '@testing-library/react'
+import {navigationRef} from '@/constants/router'
+import {useConfigState} from '@/stores/config'
import {useCurrentUserState} from '@/stores/current-user'
+import {useNavigationIntentsState} from '@/stores/navigation-intents'
import {resetAllStores} from '@/util/zustand'
import {useUserSwitchNavKey} from './use-user-switch-nav-key'
+beforeEach(() => {
+ // the jest mock's container ref is a plain object, so stub the method the hook reads
+ ;(navigationRef as unknown as Record)['isReady'] = () => true
+})
+
+const readiness = () => {
+ const {navigationReady, navigationReadyForUid} = useNavigationIntentsState.getState()
+ return {navigationReady, navigationReadyForUid}
+}
+
const setUsername = (username: string) => {
act(() => {
useCurrentUserState
@@ -13,8 +26,24 @@ const setUsername = (username: string) => {
})
}
+const startSwitchTo = (username: string) => {
+ act(() => {
+ useConfigState.getState().dispatch.setUserSwitching(true, username)
+ })
+}
+
+// setLoggedIn(false) between the service's loggedOut and loggedIn notifications, and a logout,
+// both run resetAllStores(), which blanks the current user
+const blankCurrentUser = () => {
+ act(() => {
+ resetAllStores()
+ })
+}
+
afterEach(() => {
cleanup()
+ // config's resetState carries the switch across resets, so end it explicitly
+ useConfigState.getState().dispatch.setUserSwitching(false)
resetAllStores()
})
@@ -50,3 +79,77 @@ test('an account switch that blanks username mid-flight still changes the nav ke
setUsername('testuser-mac')
expect(result.current).toBe('testuser-mac')
})
+
+test('a switch that lands back on the account the navigator shows ends the switch', () => {
+ setUsername('testuser')
+ const {result} = renderHook(() => useUserSwitchNavKey())
+ blankCurrentUser()
+ startSwitchTo('testuser')
+
+ setUsername('testuser')
+
+ expect(result.current).toBe('')
+ expect(useConfigState.getState().userSwitching).toBe(false)
+})
+
+test('a first switch after starting logged out ends when its account arrives', () => {
+ const {result} = renderHook(() => useUserSwitchNavKey())
+ startSwitchTo('testuser')
+
+ setUsername('testuser')
+
+ expect(result.current).toBe('')
+ expect(useConfigState.getState().userSwitching).toBe(false)
+})
+
+test('a stale username mid-switch does not end the switch, and the remount leaves it for onReady', () => {
+ setUsername('testuser')
+ const {result} = renderHook(() => useUserSwitchNavKey())
+ startSwitchTo('testuser-mac')
+ blankCurrentUser()
+
+ setUsername('testuser')
+ expect(result.current).toBe('')
+ expect(useConfigState.getState().userSwitching).toBe(true)
+
+ setUsername('testuser-mac')
+ expect(result.current).toBe('testuser-mac')
+ expect(useConfigState.getState().userSwitching).toBe(true)
+})
+
+test('logging back in on the mounted navigator restores navigation readiness for that account', () => {
+ setUsername('testuser')
+ renderHook(() => useUserSwitchNavKey())
+ // a logout's store reset clears readiness
+ blankCurrentUser()
+ expect(readiness().navigationReady).toBe(false)
+
+ setUsername('testuser')
+
+ expect(readiness()).toEqual({navigationReady: true, navigationReadyForUid: 'testuser'})
+})
+
+test('a switch that lands on the mounted navigator ends only after readiness is back', () => {
+ setUsername('testuser')
+ renderHook(() => useUserSwitchNavKey())
+ blankCurrentUser()
+ startSwitchTo('testuser')
+ let readyWhenSwitchEnded: boolean | undefined
+ const unsub = useConfigState.subscribe((s, p) => {
+ if (p.userSwitching && !s.userSwitching) {
+ readyWhenSwitchEnded = useNavigationIntentsState.getState().navigationReady
+ }
+ })
+
+ setUsername('testuser')
+ unsub()
+
+ expect(readyWhenSwitchEnded).toBe(true)
+})
+
+test('the first render leaves navigation readiness to onReady', () => {
+ setUsername('testuser')
+ renderHook(() => useUserSwitchNavKey())
+
+ expect(readiness().navigationReady).toBe(false)
+})
diff --git a/shared/router-v2/use-user-switch-nav-key.tsx b/shared/router-v2/use-user-switch-nav-key.tsx
index 2b0cb8ed41ab..46877f6d817c 100644
--- a/shared/router-v2/use-user-switch-nav-key.tsx
+++ b/shared/router-v2/use-user-switch-nav-key.tsx
@@ -1,20 +1,45 @@
import * as React from 'react'
+import {navigationRef} from '@/constants/router'
+import {useConfigState} from '@/stores/config'
import {useCurrentUserState} from '@/stores/current-user'
+import {useNavigationIntentsState} from '@/stores/navigation-intents'
// Remount the navigator when switching between two logged-in users.
// A switch arrives as 'a' → '' → 'b' because the mid-switch setLoggedIn(false)
// resets all stores, so only ever compare against the last non-empty username.
// Ignore '' → username (initial login) so in-flight unbox requests aren't interrupted.
+//
+// A remount's onReady marks navigation ready for the new account and ends the switch. A user who
+// arrives without a remount gets no onReady, so this hook does both:
+// - After a logout or the mid-switch reset, which clear navigation readiness, the mounted navigator
+// now serves the arriving account, so mark it ready. Otherwise every deep link and notification
+// intent stays queued.
+// - End a switch that landed on the mounted navigator (e.g. logged out, then a notification tap for
+// that same account), after readiness so the intent it replays can run. Match the switch's target
+// rather than just "no remount": a stale username mid-switch must not end a switch still in flight.
export const useUserSwitchNavKey = () => {
const username = useCurrentUserState(s => s.username)
const [navKey, setNavKey] = React.useState('')
const prevUsernameRef = React.useRef(username)
+ const lastSeenUsernameRef = React.useRef(username)
React.useEffect(() => {
+ const cameFromBlank = !lastSeenUsernameRef.current
+ lastSeenUsernameRef.current = username
if (!username) return
const prev = prevUsernameRef.current
prevUsernameRef.current = username
if (prev && prev !== username) {
setNavKey(username)
+ return
+ }
+ if (cameFromBlank && navigationRef.isReady()) {
+ useNavigationIntentsState
+ .getState()
+ .dispatch.setNavigationReady(true, useCurrentUserState.getState().uid)
+ }
+ const {dispatch, userSwitching, userSwitchingTo} = useConfigState.getState()
+ if (userSwitching && userSwitchingTo === username) {
+ dispatch.setUserSwitching(false)
}
}, [username])
return navKey
diff --git a/shared/stores/config.tsx b/shared/stores/config.tsx
index f0ab46999359..c8e49dd7ecbf 100644
--- a/shared/stores/config.tsx
+++ b/shared/stores/config.tsx
@@ -10,7 +10,7 @@ import type {Tab} from '@/constants/tabs'
import {RPCError, convertToError, isErrorTransient, niceError} from '@/util/errors'
import {type CommonResponseHandler} from '@/engine/types'
import {invalidPasswordErrorString} from '@/constants/config'
-import {navigateAppend} from '@/constants/router'
+import {navigateAppendOnceRootHas} from '@/constants/router'
import {onEngineConnected as onEngineConnectedInPlatform} from '@/util/storeless-actions'
type Store = T.Immutable<{
@@ -50,6 +50,10 @@ type Store = T.Immutable<{
tab?: Tab
}
userSwitching: boolean
+ // The account an in-progress switch is logging into ('' when none or not known)
+ userSwitchingTo: string
+ // Whether the in-progress switch started while logged in
+ userSwitchingFromLoggedIn: boolean
windowShownCount: Map
}>
@@ -88,6 +92,8 @@ const initialStore: Store = {
loaded: false,
},
userSwitching: false,
+ userSwitchingFromLoggedIn: false,
+ userSwitchingTo: '',
windowShownCount: new Map(),
}
@@ -121,7 +127,7 @@ export type State = Store & {
setStartupDetails: (st: Omit) => void
setOutOfDate: (outOfDate: T.Config.OutOfDate) => void
setUpdating: () => void
- setUserSwitching: (sw: boolean) => void
+ setUserSwitching: (sw: boolean, to?: string) => void
toggleRuntimeStats: () => void
updateGregorCategory: (category: string, body: string, dtime?: {offset: number; time: number}) => void
}
@@ -238,8 +244,14 @@ export const useConfigState = Z.createZustand('config', (set, get) => {
'keybase.1.provisionUi.DisplayAndPromptSecret': cancelOnCallback,
'keybase.1.provisionUi.PromptNewDeviceName': (_, response) => {
cancelOnCallback(undefined, response)
- // this account needs provisioning; hand off to the provision flow
- navigateAppend({name: 'username', params: {autoSubmit: true, username}})
+ // This account needs provisioning; hand off to the provision flow. 'username' lives in
+ // the logged-out stack, which the routers keep unmounted while userSwitching is set, so
+ // end the switch and push once that stack is up.
+ get().dispatch.setUserSwitching(false)
+ navigateAppendOnceRootHas('loggedOut', {
+ name: 'username',
+ params: {autoSubmit: true, username},
+ })
},
'keybase.1.provisionUi.chooseDevice': cancelOnCallback,
'keybase.1.provisionUi.chooseGPGMethod': cancelOnCallback,
@@ -282,15 +294,20 @@ export const useConfigState = Z.createZustand('config', (set, get) => {
logger.info('login call succeeded')
get().dispatch.setLoggedIn(true)
} catch (error) {
+ // The routers keep the logged-in screens mounted while userSwitching is set, so a switch
+ // that ends here has to clear it or the logged-out screens can't mount.
if (!(error instanceof RPCError)) {
+ get().dispatch.setUserSwitching(false)
return
}
if (error.code === T.RPCGen.StatusCode.scalreadyloggedin) {
get().dispatch.setLoggedIn(true)
} else if (error.desc !== cancelDesc) {
- // If we're canceling then ignore the error
error.desc = niceError(error)
get().dispatch.setLoginError(error)
+ } else {
+ // We cancelled one of our own prompts: not an error to show, but the switch is over.
+ get().dispatch.setUserSwitching(false)
}
}
}
@@ -463,6 +480,8 @@ export const useConfigState = Z.createZustand('config', (set, get) => {
dispatch: s.dispatch,
startup: {loaded: s.startup.loaded},
userSwitching: s.userSwitching,
+ userSwitchingFromLoggedIn: s.userSwitchingFromLoggedIn,
+ userSwitchingTo: s.userSwitchingTo,
}))
},
revoke: (name, wasCurrentDevice) => {
@@ -579,9 +598,11 @@ export const useConfigState = Z.createZustand('config', (set, get) => {
s.outOfDate.updating = true
})
},
- setUserSwitching: sw => {
+ setUserSwitching: (sw, to) => {
set(s => {
s.userSwitching = sw
+ s.userSwitchingFromLoggedIn = sw && s.loggedIn
+ s.userSwitchingTo = sw ? (to ?? '') : ''
})
},
toggleRuntimeStats: () => {
diff --git a/shared/stores/push.tsx b/shared/stores/push.tsx
index fc0b539289b6..1e1a04534b90 100644
--- a/shared/stores/push.tsx
+++ b/shared/stores/push.tsx
@@ -58,6 +58,10 @@ const mobileInitialStore: Store = {
const initialStore: Store = isMobile ? mobileInitialStore : desktopInitialStore
+// The account a notification tap is switching to, so that when the switch ends only that tap's
+// pending notification is dropped (see the config subscription at the bottom of this file).
+let pushSwitchForUid: string | undefined
+
export const usePushState = Z.createZustand('push', (set, get) => {
if (!isMobile) {
const dispatch: State['dispatch'] = {
@@ -226,7 +230,8 @@ export const usePushState = Z.createZustand('push', (set, get) => {
return
}
logger.info('[Push] switching to account for notification tap')
- configDispatch.setUserSwitching(true)
+ pushSwitchForUid = forUid
+ configDispatch.setUserSwitching(true, account.username)
set(s => {
s.pendingPushNotification = notification
})
@@ -432,9 +437,17 @@ export const usePushState = Z.createZustand('push', (set, get) => {
}
})
-// A login error used to clear the pending push notification via a direct call
-// from config's setLoginError. Subscribing here instead keeps config from
-// importing push (breaks the config <-> push require cycle).
+// Drop the pending push notification when the login or account switch it was waiting on
+// ends without reaching its account:
+// - a login error;
+// - a switch started by a notification tap that ends with that tap's notification still
+// pending. A successful switch consumes it (push-listener replays it when the uid changes,
+// before the router clears userSwitching), so if it's still there the switch never landed,
+// e.g. its login cancelled its own prompt. Left pending, the account-list replay in
+// push-listener would re-run that switch the next time the accounts refresh. A notification
+// parked for an account that isn't configured yet belongs to no switch, so it's kept.
+// Subscribing here instead of calling from config keeps config from importing push (breaks
+// the config <-> push require cycle).
//
// Guard against HMR: the config store instance (and its subscribers) survive
// hot reloads via Z.createZustand's registry, but this module re-evaluates, so
@@ -444,7 +457,15 @@ const _g = globalThis as any
if (!__DEV__ || !_g.__pushLoginErrorSubscribed) {
if (__DEV__) _g.__pushLoginErrorSubscribed = true
useConfigState.subscribe((s, p) => {
- if (s.loginError && s.loginError !== p.loginError) {
+ const loginFailed = !!s.loginError && s.loginError !== p.loginError
+ let endedSwitchForUid: string | undefined
+ if (p.userSwitching && !s.userSwitching) {
+ endedSwitchForUid = pushSwitchForUid
+ pushSwitchForUid = undefined
+ }
+ const pending = usePushState.getState().pendingPushNotification
+ const pendingForUid = pending && 'forUid' in pending ? pending.forUid : undefined
+ if (loginFailed || (!!endedSwitchForUid && pendingForUid === endedSwitchForUid)) {
usePushState.getState().dispatch.clearPendingPushNotification()
}
})
diff --git a/shared/stores/tests/as-mobile.ts b/shared/stores/tests/as-mobile.ts
new file mode 100644
index 000000000000..d6a7de404d18
--- /dev/null
+++ b/shared/stores/tests/as-mobile.ts
@@ -0,0 +1,6 @@
+// Import first in a test file to load modules as mobile. jest.setup.js defaults isMobile to false,
+// and some stores (e.g. push) pick their platform's dispatch once, when the module loads, so
+// flipping the global inside a test is too late for them.
+;(globalThis as unknown as {isMobile: boolean}).isMobile = true
+
+export {}
diff --git a/shared/stores/tests/config.test.ts b/shared/stores/tests/config.test.ts
index 1df0b762284f..f9a096c51e82 100644
--- a/shared/stores/tests/config.test.ts
+++ b/shared/stores/tests/config.test.ts
@@ -1,4 +1,12 @@
///
+jest.mock('@/constants/router', () => ({
+ ...jest.requireActual('@/constants/router'),
+ navigateAppendOnceRootHas: jest.fn(),
+}))
+
+import * as T from '@/constants/types'
+import {navigateAppendOnceRootHas} from '@/constants/router'
+import {RPCError} from '@/util/errors'
import {noConversationIDKey} from '../../constants/types/chat/common'
import {useConfigState} from '../config'
@@ -21,6 +29,8 @@ const resetConfigState = () => {
loaded: false,
},
userSwitching: false,
+ userSwitchingFromLoggedIn: false,
+ userSwitchingTo: '',
} as any)
dispatch.resetState()
}
@@ -120,3 +130,94 @@ test('custom resetState preserves the fields config intentionally carries across
expect(state.userSwitching).toBe(true)
expect(state.globalError).toBeUndefined()
})
+
+const flush = async () => new Promise(resolve => setImmediate(resolve))
+
+const switchWithLoginFailure = async (failure: unknown) => {
+ jest.spyOn(T.RPCGen, 'loginLoginRpcListener').mockRejectedValue(failure)
+ const {dispatch} = useConfigState.getState()
+ dispatch.setUserSwitching(true)
+ dispatch.login('testuser', '')
+ await flush()
+}
+
+describe('login ending an account switch', () => {
+ const mockOnceRootHas = jest.mocked(navigateAppendOnceRootHas)
+
+ afterEach(() => {
+ jest.restoreAllMocks()
+ mockOnceRootHas.mockReset()
+ })
+
+ test('an account that needs provisioning ends the switch before handing off to username', async () => {
+ let switchingAtHandOff: boolean | undefined
+ mockOnceRootHas.mockImplementation(() => {
+ switchingAtHandOff = useConfigState.getState().userSwitching
+ })
+ const cancelled = jest.fn().mockRejectedValue(new RPCError('Canceling RPC', T.RPCGen.StatusCode.scgeneric))
+ jest.spyOn(T.RPCGen, 'loginLoginRpcListener').mockImplementation(listener => {
+ const prompt = (listener as any).customResponseIncomingCallMap['keybase.1.provisionUi.PromptNewDeviceName']
+ prompt({}, {error: jest.fn(), result: jest.fn()})
+ return cancelled()
+ })
+ const {dispatch} = useConfigState.getState()
+ dispatch.setUserSwitching(true)
+ dispatch.login('testuser', '')
+ await flush()
+
+ expect(mockOnceRootHas).toHaveBeenCalledWith('loggedOut', {
+ name: 'username',
+ params: {autoSubmit: true, username: 'testuser'},
+ })
+ expect(switchingAtHandOff).toBe(false)
+ })
+
+ test('a prompt login cancelled itself clears userSwitching without a login error', async () => {
+ await switchWithLoginFailure(new RPCError('Canceling RPC', T.RPCGen.StatusCode.scgeneric))
+
+ const state = useConfigState.getState()
+ expect(state.userSwitching).toBe(false)
+ expect(state.loginError).toBeUndefined()
+ })
+
+ test('a failure that is not an RPCError clears userSwitching', async () => {
+ await switchWithLoginFailure(new Error('boom'))
+
+ expect(useConfigState.getState().userSwitching).toBe(false)
+ })
+
+ test('an RPC error clears userSwitching and records the login error', async () => {
+ await switchWithLoginFailure(new RPCError('bad things', T.RPCGen.StatusCode.scgeneric))
+
+ const state = useConfigState.getState()
+ expect(state.userSwitching).toBe(false)
+ expect(state.loginError?.desc).toBeTruthy()
+ })
+})
+
+test("setUserSwitching records the switch's target, clears it with the flag, and keeps it across resets", () => {
+ const {dispatch} = useConfigState.getState()
+
+ dispatch.setUserSwitching(true, 'testuser')
+ dispatch.resetState()
+ expect(useConfigState.getState().userSwitchingTo).toBe('testuser')
+
+ dispatch.setUserSwitching(false)
+ expect(useConfigState.getState().userSwitchingTo).toBe('')
+})
+
+test('setUserSwitching records whether the switch started logged in, through the mid-switch reset', () => {
+ const {dispatch} = useConfigState.getState()
+
+ dispatch.setUserSwitching(true, 'testuser')
+ expect(useConfigState.getState().userSwitchingFromLoggedIn).toBe(false)
+
+ dispatch.setLoggedIn(true)
+ dispatch.setUserSwitching(true, 'testuser')
+ // the service's loggedOut notification during a switch resets every store
+ dispatch.setLoggedIn(false)
+ expect(useConfigState.getState().userSwitchingFromLoggedIn).toBe(true)
+
+ dispatch.setUserSwitching(false)
+ expect(useConfigState.getState().userSwitchingFromLoggedIn).toBe(false)
+})
diff --git a/shared/stores/tests/push.test.ts b/shared/stores/tests/push.test.ts
new file mode 100644
index 000000000000..ca514b51aa01
--- /dev/null
+++ b/shared/stores/tests/push.test.ts
@@ -0,0 +1,56 @@
+///
+import './as-mobile'
+import * as T from '@/constants/types'
+import {resetAllStores} from '../../util/zustand'
+import {useConfigState} from '../config'
+import {usePushState} from '../push'
+
+const flush = async () => new Promise(resolve => setImmediate(resolve))
+
+const tapFor = (forUid: string) =>
+ ({conversationIDKey: 'conv', forUid, type: 'chat.newmessage', userInteraction: true}) as any
+
+afterEach(() => {
+ jest.restoreAllMocks()
+ // config's resetState carries userSwitching across resets, so clear it explicitly
+ useConfigState.getState().dispatch.setUserSwitching(false)
+ resetAllStores()
+})
+
+test("a switch started by a notification tap that ends with the tap still pending drops it", async () => {
+ // the switch's login never answers; the test ends the switch itself
+ jest.spyOn(T.RPCGen, 'loginLoginRpcListener').mockReturnValue(new Promise(() => {}))
+ const config = useConfigState.getState().dispatch
+ config.setAccounts([{hasStoredSecret: true, uid: 'testuser-uid', username: 'testuser'}])
+
+ usePushState.getState().dispatch.handlePush(tapFor('testuser-uid'))
+ await flush()
+ expect(useConfigState.getState().userSwitching).toBe(true)
+ expect(usePushState.getState().pendingPushNotification).toEqual(tapFor('testuser-uid'))
+
+ config.setUserSwitching(false)
+
+ expect(usePushState.getState().pendingPushNotification).toBeUndefined()
+})
+
+test('a notification parked for an account that is not configured yet survives an unrelated switch', () => {
+ const config = useConfigState.getState().dispatch
+ config.setUserSwitching(true)
+ usePushState.getState().dispatch.setPendingPushNotification(tapFor('testuser-mac-uid'))
+
+ config.setUserSwitching(false)
+
+ expect(usePushState.getState().pendingPushNotification).toEqual(tapFor('testuser-mac-uid'))
+})
+
+test('a pending notification survives the store reset in the middle of a switch', () => {
+ const config = useConfigState.getState().dispatch
+ config.setLoggedIn(true)
+ config.setUserSwitching(true)
+ usePushState.getState().dispatch.setPendingPushNotification(tapFor('testuser-uid'))
+
+ // the service's loggedOut notification during a switch resets every store
+ config.setLoggedIn(false)
+
+ expect(usePushState.getState().pendingPushNotification).toEqual(tapFor('testuser-uid'))
+})