From e0d6df28802b76dded6c5a077884771ac247e032 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 10 Sep 2026 17:03:00 -0400 Subject: [PATCH 01/28] fix(mobile): keep the logged-in screens mounted through an account switch A switch flaps config.loggedIn false and back to true. The mobile root stack followed it, so every switch swapped to the logged-out stack, back, and then remounted the navigator, three native rebuilds in about 130 ms. RNS logged unbalanced appearance transitions, and could leave the torn-down navigator's screens on top. Every touch was then dropped and the app looked frozen. It also sometimes logged an unhandled POP for the root 'loggedIn' screen. Hold the mobile logged-in screens through a switch that started logged in (showLoggedInScreens). A switch that starts logged out, e.g. a notification tap on the login screen, keeps the logged-out screens until it lands. Desktop keeps its loggedIn || userSwitching gate. Holding the logged-in screens means userSwitching must clear whenever a switch ends without the remount: - login() now clears it when it cancels one of its own prompts, and when it fails without an RPCError. Otherwise the app stayed on the old account's screens with reset stores. - The provisioning hand-off clears it, then pushes 'username' through the new navigateAppendOnceRootHas once the logged-out stack has mounted. A push dispatched before then was dropped. - When a switch started by a notification tap ends, the push store drops that tap's pending notification. A successful switch has already consumed it. Left behind, it would re-run the failed switch on the next account-list refresh. A notification parked for an account that isn't configured yet is left alone. - A switch that lands on the navigator that's already mounted (same account, or the first switch after launching logged out) gets no remount and so no onReady. useUserSwitchNavKey now ends it when the arriving username is the switch's recorded target. Matching the target, not just "no remount", keeps a stale username mid-switch from ending a switch still in flight. Before ending it, the hook marks the mounted navigator ready for the account: a logout's store reset clears navigation readiness and only onReady restored it, so after a re-login without a remount every deep link and notification intent stayed queued. --- .../navigate-append-once-root-has.test.ts | 85 +++++++++++++++ shared/constants/router.tsx | 27 +++++ shared/router-v2/account-link-switch.tsx | 2 +- .../account-switch-header-avatar.native.tsx | 2 +- shared/router-v2/account-switch.test.tsx | 22 ++++ shared/router-v2/account-switch.tsx | 12 ++ shared/router-v2/account-switcher/index.tsx | 2 +- shared/router-v2/router.tsx | 6 +- shared/router-v2/tab-bar.desktop.tsx | 2 +- .../use-user-switch-nav-key.test.tsx | 103 ++++++++++++++++++ shared/router-v2/use-user-switch-nav-key.tsx | 25 +++++ shared/stores/config.tsx | 23 +++- shared/stores/tests/config.test.ts | 90 +++++++++++++++ 13 files changed, 389 insertions(+), 12 deletions(-) create mode 100644 shared/constants/navigate-append-once-root-has.test.ts 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/router-v2/account-link-switch.tsx b/shared/router-v2/account-link-switch.tsx index 8873a6a75c95..af4badda4b1e 100644 --- a/shared/router-v2/account-link-switch.tsx +++ b/shared/router-v2/account-link-switch.tsx @@ -44,7 +44,7 @@ export const subscribeIntentAccountSwitch = () => { } switchingFor = intent.id logger.info('[AccountLink] switching accounts for a tapped push') - dispatch.setUserSwitching(true) + dispatch.setUserSwitching(true, account.username) dispatch.login(account.username, '') } const dropOnFailure = (s: ConfigState, old: ConfigState) => { 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..0768364aa956 100644 --- a/shared/router-v2/account-switch.test.tsx +++ b/shared/router-v2/account-switch.test.tsx @@ -6,6 +6,7 @@ import { consumePendingAccountSwitchTab, getMostRecentlyUsedAccount, rememberAccountSwitchTab, + showLoggedInScreens, } from './account-switch' const account = (username: string, hasStoredSecret = true) => ({ @@ -73,3 +74,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..6339d3b44ead 100644 --- a/shared/router-v2/account-switch.tsx +++ b/shared/router-v2/account-switch.tsx @@ -37,6 +37,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/router.tsx b/shared/router-v2/router.tsx index 33bbfed02154..575b054751c8 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 @@ -592,8 +592,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 e2647e305a4c..8c9839885af7 100644 --- a/shared/stores/config.tsx +++ b/shared/stores/config.tsx @@ -18,7 +18,7 @@ import { } 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"; import { useDaemonState } from "@/stores/daemon"; import { getEngine, hasEngine } from "@/engine/require"; @@ -57,6 +57,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; }>; @@ -92,6 +96,8 @@ const initialStore: Store = { loaded: false, }, userSwitching: false, + userSwitchingFromLoggedIn: false, + userSwitchingTo: "", windowShownCount: new Map(), }; @@ -124,7 +130,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, @@ -248,8 +254,11 @@ 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({ + // 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 }, }); @@ -474,6 +483,8 @@ export const useConfigState = Z.createZustand("config", (set, get) => { httpSrv: s.httpSrv, startup: { loaded: s.startup.loaded }, userSwitching: s.userSwitching, + userSwitchingFromLoggedIn: s.userSwitchingFromLoggedIn, + userSwitchingTo: s.userSwitchingTo, })); }, revoke: (name, wasCurrentDevice) => { @@ -589,7 +600,7 @@ export const useConfigState = Z.createZustand("config", (set, get) => { s.outOfDate.updating = true; }); }, - setUserSwitching: (sw) => { + setUserSwitching: (sw, to) => { if (sw && !get().userSwitching) { Z.resetAllStores(); if (hasEngine()) { @@ -598,6 +609,8 @@ export const useConfigState = Z.createZustand("config", (set, get) => { } set((s) => { s.userSwitching = sw; + s.userSwitchingFromLoggedIn = sw && s.loggedIn; + s.userSwitchingTo = sw ? (to ?? "") : ""; }); }, toggleRuntimeStats: () => { diff --git a/shared/stores/tests/config.test.ts b/shared/stores/tests/config.test.ts index c58f7d52c7ee..dce58b5db4ad 100644 --- a/shared/stores/tests/config.test.ts +++ b/shared/stores/tests/config.test.ts @@ -1,6 +1,12 @@ /// +jest.mock('@/constants/router', () => ({ + ...jest.requireActual('@/constants/router'), + navigateAppendOnceRootHas: jest.fn(), +})) + import * as T from '../../constants/types' import * as Tabs from '../../constants/tabs' +import {navigateAppendOnceRootHas} from '../../constants/router' import {RPCError} from '../../util/errors' import {useDaemonState} from '../daemon' import {noConversationIDKey} from '../../constants/types/chat/common' @@ -23,6 +29,8 @@ const resetConfigState = () => { loaded: false, }, userSwitching: false, + userSwitchingFromLoggedIn: false, + userSwitchingTo: '', } as any) dispatch.resetState() } @@ -144,6 +152,7 @@ test('loggedIn and loggedOut notifications do not set the session themselves', ( }) describe('login', () => { + const mockOnceRootHas = jest.mocked(navigateAppendOnceRootHas) const originalDaemonDispatch = useDaemonState.getState().dispatch let refresh: jest.Mock beforeEach(() => { @@ -153,6 +162,7 @@ describe('login', () => { afterEach(() => { jest.restoreAllMocks() useDaemonState.setState({dispatch: originalDaemonDispatch}) + mockOnceRootHas.mockReset() }) const flush = async () => new Promise(resolve => setImmediate(resolve)) @@ -192,4 +202,84 @@ describe('login', () => { expect(switchingWhenRead).toEqual([false]) expect(useConfigState.getState().loginError).toBeDefined() }) + + const switchWithLoginFailure = async (failure: unknown) => { + jest.spyOn(T.RPCGen, 'loginLoginRpcListener').mockRejectedValue(failure) + const {dispatch} = useConfigState.getState() + dispatch.setUserSwitching(true) + dispatch.login('testuser', '') + await flush() + } + + 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) }) From c6d4d7b71e05f3ff3e831f841b5c5425000bd33c Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 10 Sep 2026 17:19:17 -0400 Subject: [PATCH 02/28] fix(ios): start the glass tab bar on the right tab instead of animating to it On cold start the native tab controller selects the first tab when it gets its children, then moves to the startup tab, sliding the iOS 26 glass pill across. Patch react-native-screens to make that first selection without animation. An account switch remounts the navigator on the first tab and jumped to the remembered tab after onReady. Start the remounted navigator on that tab via the linking initial URL instead; onReady still consumes it as a fallback. --- .../patches/react-native-screens+4.28.0.patch | 27 +++++++++++++++++++ shared/router-v2/account-switch.test.tsx | 9 +++++++ shared/router-v2/account-switch.tsx | 3 +++ shared/router-v2/linking.tsx | 8 ++++++ 4 files changed, 47 insertions(+) diff --git a/shared/patches/react-native-screens+4.28.0.patch b/shared/patches/react-native-screens+4.28.0.patch index 8d3960254305..a120e94b6639 100644 --- a/shared/patches/react-native-screens+4.28.0.patch +++ b/shared/patches/react-native-screens+4.28.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.test.tsx b/shared/router-v2/account-switch.test.tsx index 0768364aa956..dcfeee0ec3cc 100644 --- a/shared/router-v2/account-switch.test.tsx +++ b/shared/router-v2/account-switch.test.tsx @@ -5,6 +5,7 @@ import { clearPendingAccountSwitch, consumePendingAccountSwitchTab, getMostRecentlyUsedAccount, + peekPendingAccountSwitchTab, rememberAccountSwitchTab, showLoggedInScreens, } from './account-switch' @@ -45,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) diff --git a/shared/router-v2/account-switch.tsx b/shared/router-v2/account-switch.tsx index 6339d3b44ead..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 diff --git a/shared/router-v2/linking.tsx b/shared/router-v2/linking.tsx index 8133ba109318..7b22cedf5732 100644 --- a/shared/router-v2/linking.tsx +++ b/shared/router-v2/linking.tsx @@ -11,6 +11,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. @@ -338,6 +339,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} = startup let startupConversation = startup.conversation if (!isValidConversationIDKey(startupConversation)) { From c46be7add86453bf89a4a7a9cba3a83b8518978e Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 10 Sep 2026 21:36:57 -0400 Subject: [PATCH 03/28] test(router): cover the account-switch tab in getInitialURL --- shared/router-v2/linking-initial-url.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/shared/router-v2/linking-initial-url.test.ts b/shared/router-v2/linking-initial-url.test.ts index 699a313e793c..5e4f96a307bb 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 {setPushTapAck, useNavigationIntentsState} from '@/stores/navigation-intents' import {usePushState} from '@/stores/push' +import {peekPendingAccountSwitchTab, rememberAccountSwitchTab} from './account-switch' import {createLinkingConfig} from './linking' import {enqueuePushTapRoute} from './deep-link-emitter' @@ -63,9 +64,26 @@ afterEach(() => { const {intent, dispatch} = useNavigationIntentsState.getState() if (intent) dispatch.acknowledge(intent.id) jest.restoreAllMocks() + 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}) From dabf0ebb91e29147afaa7ee41afa578b747fc61b Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 16 Sep 2026 10:03:31 -0400 Subject: [PATCH 04/28] fix(config): keep holding logged-in screens when a switch starts during another switch --- shared/stores/config.tsx | 5 ++++- shared/stores/tests/config.test.ts | 13 +++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/shared/stores/config.tsx b/shared/stores/config.tsx index 8c9839885af7..4eac53d4ea9e 100644 --- a/shared/stores/config.tsx +++ b/shared/stores/config.tsx @@ -608,8 +608,11 @@ export const useConfigState = Z.createZustand("config", (set, get) => { } } set((s) => { + // A second switch that starts after the mid-switch logout would read loggedIn as false, so + // keep holding the logged-in screens if the switch already in flight is holding them. + s.userSwitchingFromLoggedIn = + sw && (s.loggedIn || (s.userSwitching && s.userSwitchingFromLoggedIn)); s.userSwitching = sw; - s.userSwitchingFromLoggedIn = sw && s.loggedIn; s.userSwitchingTo = sw ? (to ?? "") : ""; }); }, diff --git a/shared/stores/tests/config.test.ts b/shared/stores/tests/config.test.ts index dce58b5db4ad..27aa765bf768 100644 --- a/shared/stores/tests/config.test.ts +++ b/shared/stores/tests/config.test.ts @@ -283,3 +283,16 @@ test('setUserSwitching records whether the switch started logged in, through the dispatch.setUserSwitching(false) expect(useConfigState.getState().userSwitchingFromLoggedIn).toBe(false) }) + +test('a switch started during another switch keeps the first switch\'s logged-in state and takes the new target', () => { + const {dispatch} = useConfigState.getState() + + dispatch.setLoggedIn(true) + dispatch.setUserSwitching(true, 'testuser') + dispatch.setLoggedIn(false) + dispatch.setUserSwitching(true, 'testuser-mac') + + const state = useConfigState.getState() + expect(state.userSwitchingFromLoggedIn).toBe(true) + expect(state.userSwitchingTo).toBe('testuser-mac') +}) From 8c330136796af5f5bd2846d3af93bfeb846d9062 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 16 Sep 2026 10:12:20 -0400 Subject: [PATCH 05/28] fix(config): don't let a superseded login or its navigator end a newer account switch --- shared/router-v2/router.tsx | 13 +++++- shared/stores/config.tsx | 23 +++++++++++ shared/stores/tests/config.test.ts | 66 ++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 2 deletions(-) diff --git a/shared/router-v2/router.tsx b/shared/router-v2/router.tsx index 575b054751c8..429e12cfb9f2 100644 --- a/shared/router-v2/router.tsx +++ b/shared/router-v2/router.tsx @@ -262,7 +262,13 @@ function DesktopRouter() { const isDarkMode = useDarkModeState(s => s.isDarkMode()) const navKey = Common.useUserSwitchNavKey() - const currentUid = useCurrentUserState(s => s.uid) + const {currentUid, username} = useCurrentUserState( + C.useShallow(s => ({ + currentUid: s.uid, + username: s.username, + })) + ) + const endUserSwitchLandedOn = useConfigState(s => s.dispatch.endUserSwitchLandedOn) const setNavigationReady = useNavigationIntentsState(s => s.dispatch.setNavigationReady) React.useEffect( @@ -292,6 +298,7 @@ function DesktopRouter() { onReady={() => { onStateChange() setNavigationReady(true, currentUid) + endUserSwitchLandedOn(username) }} onStateChange={onStateChange} onUnhandledAction={onUnhandledAction} @@ -655,8 +662,9 @@ function NativeRouter() { const theme = Kb.Styles.useTheme() const loggedInLoaded = useHandshakeEverDone() - const {loggedIn, startupLoaded, userSwitching} = useConfigState( + const {endUserSwitchLandedOn, loggedIn, startupLoaded, userSwitching} = useConfigState( C.useShallow(s => ({ + endUserSwitchLandedOn: s.dispatch.endUserSwitchLandedOn, loggedIn: s.loggedIn, startupLoaded: s.startup.loaded, userSwitching: s.userSwitching, @@ -703,6 +711,7 @@ function NativeRouter() { C.Router2.switchTab(tab) } setNavigationReady(true, currentUid) + endUserSwitchLandedOn(username) } if (!loggedInLoaded || (loggedIn && !startupLoaded)) { diff --git a/shared/stores/config.tsx b/shared/stores/config.tsx index 4eac53d4ea9e..aa8f1319c0bd 100644 --- a/shared/stores/config.tsx +++ b/shared/stores/config.tsx @@ -104,6 +104,7 @@ const initialStore: Store = { export type State = Store & { dispatch: { checkForUpdate: () => void; + endUserSwitchLandedOn: (username: string) => void; initAppUpdateLoop: () => void; installerRan: () => void; loadIsOnline: () => void; @@ -142,6 +143,9 @@ export type State = Store & { export const useConfigState = Z.createZustand("config", (set, get) => { let inflightRefreshAccounts: Promise | undefined; + // Bumped by every login. A login that fails after a newer one started (e.g. picking a second + // account mid-switch) must not end the newer switch or show its own error. + let loginGeneration = 0; const _checkForUpdate = async () => { try { @@ -204,6 +208,14 @@ export const useConfigState = Z.createZustand("config", (set, get) => { }; ignorePromise(f()); }, + endUserSwitchLandedOn: (username) => { + // A navigator that comes up for an account a newer switch has already moved past (the user + // picked another account mid-switch) must leave that switch running. + const { userSwitching, userSwitchingTo } = get(); + if (userSwitching && (!userSwitchingTo || userSwitchingTo === username)) { + get().dispatch.setUserSwitching(false); + } + }, initAppUpdateLoop: () => { const f = async () => { while (true) { @@ -245,6 +257,8 @@ export const useConfigState = Z.createZustand("config", (set, get) => { }); }; const ignoreCallback = () => {}; + const generation = ++loginGeneration; + const superseded = () => generation !== loginGeneration; const f = async () => { try { await T.RPCGen.loginLoginRpcListener({ @@ -254,6 +268,7 @@ export const useConfigState = Z.createZustand("config", (set, get) => { "keybase.1.provisionUi.DisplayAndPromptSecret": cancelOnCallback, "keybase.1.provisionUi.PromptNewDeviceName": (_, response) => { cancelOnCallback(undefined, response); + if (superseded()) return; // 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. @@ -272,6 +287,7 @@ export const useConfigState = Z.createZustand("config", (set, get) => { // Service asking us again due to a bad passphrase? if (params.pinentry.retryLabel) { cancelOnCallback(params, response); + if (superseded()) return; let retryLabel = params.pinentry.retryLabel; if (retryLabel === invalidPasswordErrorString) { retryLabel = "Incorrect password."; @@ -308,6 +324,13 @@ export const useConfigState = Z.createZustand("config", (set, get) => { }); logger.info("login call succeeded"); } catch (error) { + if (superseded()) { + logger.info( + "login failed after a newer login started, ignoring", + error, + ); + return; + } // Nothing else ends a cancelled switch, and the logged-out status it withheld applies only then if (!(error instanceof RPCError) || error.desc === cancelDesc) { get().dispatch.setUserSwitching(false); diff --git a/shared/stores/tests/config.test.ts b/shared/stores/tests/config.test.ts index 27aa765bf768..c35375ee0e35 100644 --- a/shared/stores/tests/config.test.ts +++ b/shared/stores/tests/config.test.ts @@ -248,6 +248,61 @@ describe('login', () => { expect(useConfigState.getState().userSwitching).toBe(false) }) + test("a login that fails after a newer one started leaves the newer switch alone, and the newer one's failure still ends it", async () => { + const rejects: Array<(e: unknown) => void> = [] + jest.spyOn(T.RPCGen, 'loginLoginRpcListener').mockImplementation( + async () => new Promise((_resolve, reject) => rejects.push(reject)) + ) + const {dispatch} = useConfigState.getState() + dispatch.setUserSwitching(true, 'testuser') + dispatch.login('testuser', '') + await flush() + dispatch.setUserSwitching(true, 'testuser-mac') + dispatch.login('testuser-mac', '') + await flush() + + rejects[0]?.(new RPCError('bad things', T.RPCGen.StatusCode.scgeneric)) + await flush() + let state = useConfigState.getState() + expect(state.userSwitching).toBe(true) + expect(state.userSwitchingTo).toBe('testuser-mac') + expect(state.loginError).toBeUndefined() + + rejects[1]?.(new RPCError('bad things', T.RPCGen.StatusCode.scgeneric)) + await flush() + state = useConfigState.getState() + expect(state.userSwitching).toBe(false) + expect(state.loginError?.desc).toBeTruthy() + }) + + test('prompts that arrive for a login after a newer one started do not end the newer switch', async () => { + const listeners: Array = [] + jest.spyOn(T.RPCGen, 'loginLoginRpcListener').mockImplementation(async listener => { + listeners.push(listener) + return new Promise(() => {}) + }) + const {dispatch} = useConfigState.getState() + dispatch.setUserSwitching(true, 'testuser') + dispatch.login('testuser', '') + await flush() + dispatch.setUserSwitching(true, 'testuser-mac') + dispatch.login('testuser-mac', '') + await flush() + + const response = () => ({error: jest.fn(), result: jest.fn()}) + const stale = listeners[0].customResponseIncomingCallMap + stale['keybase.1.provisionUi.PromptNewDeviceName']({}, response()) + stale['keybase.1.secretUi.getPassphrase']( + {pinentry: {retryLabel: 'Incorrect password.', type: T.RPCGen.PassphraseType.passPhrase}}, + response() + ) + + const state = useConfigState.getState() + expect(mockOnceRootHas).not.toHaveBeenCalled() + expect(state.userSwitching).toBe(true) + expect(state.loginError).toBeUndefined() + }) + test('an RPC error clears userSwitching and records the login error', async () => { await switchWithLoginFailure(new RPCError('bad things', T.RPCGen.StatusCode.scgeneric)) @@ -257,6 +312,17 @@ describe('login', () => { }) }) +test('a navigator ready for the switch target ends the switch, one ready for a superseded target does not', () => { + const {dispatch} = useConfigState.getState() + + dispatch.setUserSwitching(true, 'testuser-mac') + dispatch.endUserSwitchLandedOn('testuser') + expect(useConfigState.getState().userSwitching).toBe(true) + + dispatch.endUserSwitchLandedOn('testuser-mac') + expect(useConfigState.getState().userSwitching).toBe(false) +}) + test("setUserSwitching records the switch's target, clears it with the flag, and keeps it across resets", () => { const {dispatch} = useConfigState.getState() From 942d4c8d00e28ef542fea0fc7d47461594c1e390 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 16 Sep 2026 10:35:09 -0400 Subject: [PATCH 06/28] fix(router): disable account switching while a switch is in progress --- .../router-v2/account-switcher/index.test.tsx | 69 +++++++++++++++++++ shared/router-v2/account-switcher/index.tsx | 7 +- shared/router-v2/tab-bar.desktop.tsx | 3 +- 3 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 shared/router-v2/account-switcher/index.test.tsx diff --git a/shared/router-v2/account-switcher/index.test.tsx b/shared/router-v2/account-switcher/index.test.tsx new file mode 100644 index 000000000000..ceb1bba8e533 --- /dev/null +++ b/shared/router-v2/account-switcher/index.test.tsx @@ -0,0 +1,69 @@ +/** @jest-environment jsdom */ +/// +import type * as React from 'react' +import {act, cleanup, fireEvent, render, screen} from '@testing-library/react' +import * as T from '@/constants/types' +import {useConfigState} from '@/stores/config' +import {useCurrentUserState} from '@/stores/current-user' +import {resetAllStores} from '@/util/zustand' + +jest.mock('@/common-adapters', () => { + const React = require('react') + const Pass = ({children}: {children?: React.ReactNode}) => React.createElement('div', null, children) + return { + Avatar: () => null, + Box2: Pass, + Divider: () => null, + ListItem: ({body, onClick}: {body?: React.ReactNode; onClick?: () => void}) => + React.createElement('button', {onClick, type: 'button'}, body), + ProgressIndicator: () => null, + ScrollView: Pass, + Styles: { + createStyleHook: () => () => ({}), + platformStyles: () => ({}), + }, + Text: ({children}: {children?: React.ReactNode}) => React.createElement('span', null, children), + } +}) + +import AccountSwitcher from '.' + +beforeEach(() => { + useCurrentUserState + .getState() + .dispatch.setBootstrap({deviceID: 'd', deviceName: 'dn', uid: 'testuser', username: 'testuser'}) + useConfigState.getState().dispatch.setAccounts([ + {fullname: '', hasStoredSecret: true, uid: 'testuser-mac', username: 'testuser-mac'}, + ]) +}) + +afterEach(() => { + cleanup() + jest.restoreAllMocks() + act(() => { + resetAllStores() + }) +}) + +const loginSpy = () => jest.spyOn(T.RPCGen, 'loginLoginRpcListener').mockImplementation(async () => new Promise(() => {})) + +test('an account row starts a switch when no switch is running', () => { + const login = loginSpy() + render() + + fireEvent.click(screen.getByRole('button', {name: 'testuser-mac'})) + + expect(login).toHaveBeenCalled() +}) + +test('account rows are disabled while a switch is running, even after the reset clears the login waiting key', () => { + const login = loginSpy() + act(() => { + useConfigState.getState().dispatch.setUserSwitching(true, 'testuser-other') + }) + render() + + fireEvent.click(screen.getByRole('button', {name: 'testuser-mac'})) + + expect(login).not.toHaveBeenCalled() +}) diff --git a/shared/router-v2/account-switcher/index.tsx b/shared/router-v2/account-switcher/index.tsx index d99a9974bd38..72c9b27279d8 100644 --- a/shared/router-v2/account-switcher/index.tsx +++ b/shared/router-v2/account-switcher/index.tsx @@ -19,6 +19,7 @@ const AccountSwitcher = (p: {onSelected?: () => void}) => { logoutAndTryToLogInAs: onSelectAccountLoggedOut, logoutToLoggedOutFlow: onLoginAsAnotherUser, setUserSwitching, + userSwitching, } = useConfigState( C.useShallow(s => ({ accountRows: s.configuredAccounts, @@ -26,11 +27,14 @@ const AccountSwitcher = (p: {onSelected?: () => void}) => { logoutAndTryToLogInAs: s.dispatch.logoutAndTryToLogInAs, logoutToLoggedOutFlow: s.dispatch.logoutToLoggedOutFlow, setUserSwitching: s.dispatch.setUserSwitching, + userSwitching: s.userSwitching, })) ) const you = useCurrentUserState(s => s.username) const fullname = _fullnames.get(you)?.fullname ?? '' - const waiting = C.Waiting.useAnyWaiting(C.waitingKeyConfigLogin) + // The mid-switch store reset clears the login waiting key while the switch is still running, so + // also hold the rows on userSwitching or a second switch can start before the first lands. + const waiting = C.Waiting.useAnyWaiting(C.waitingKeyConfigLogin) || userSwitching const onSelectAccountLoggedIn = (username: string) => { if (isMobile) { @@ -124,6 +128,7 @@ const MobileHeader = (props: Props) => { mode="Primary" fullWidth={true} waitingKey={C.waitingKeyConfigLoginAsOther} + disabled={props.waiting} /> diff --git a/shared/router-v2/tab-bar.desktop.tsx b/shared/router-v2/tab-bar.desktop.tsx index 8a5d71f9d2eb..02d98130ed86 100644 --- a/shared/router-v2/tab-bar.desktop.tsx +++ b/shared/router-v2/tab-bar.desktop.tsx @@ -262,7 +262,8 @@ function Tab(props: TabProps) { ) const onQuickSwitch = isPeopleTab ? () => { - const accountRows = useConfigState.getState().configuredAccounts + const {configuredAccounts: accountRows, userSwitching} = useConfigState.getState() + if (userSwitching) return const row = accountRows.find(a => a.username !== current && a.hasStoredSecret) if (row) { setUserSwitching(true, row.username) From d397a6e453c47a05f9e93842912fba0e13e6595f Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 16 Sep 2026 10:36:21 -0400 Subject: [PATCH 07/28] fix(router): disable desktop 'Log in as another user' during an account switch --- shared/router-v2/tab-bar.desktop.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/shared/router-v2/tab-bar.desktop.tsx b/shared/router-v2/tab-bar.desktop.tsx index 02d98130ed86..706a1dd0872b 100644 --- a/shared/router-v2/tab-bar.desktop.tsx +++ b/shared/router-v2/tab-bar.desktop.tsx @@ -53,7 +53,12 @@ const Header = () => { const username = useCurrentUserState(s => s.username) const fullname = useUsersState(s => s.infoMap.get(username)?.fullname ?? '') - const logoutToLoggedOutFlow = useConfigState(s => s.dispatch.logoutToLoggedOutFlow) + const {logoutToLoggedOutFlow, userSwitching} = useConfigState( + C.useShallow(s => ({ + logoutToLoggedOutFlow: s.dispatch.logoutToLoggedOutFlow, + userSwitching: s.userSwitching, + })) + ) const onHelp = () => { void openURL('https://book.keybase.io') } const onQuit = () => { if (!__DEV__) { @@ -80,7 +85,7 @@ const Header = () => { const makePopup = (p: Kb.Popup2Parms) => { const {attachTo, hidePopup} = p const menuItems: Kb.MenuItems = [ - {onClick: onAddAccount, title: 'Log in as another user'}, + {disabled: userSwitching, onClick: onAddAccount, title: 'Log in as another user'}, {onClick: onSettings, title: 'Settings'}, {onClick: onHelp, title: 'Help'}, {danger: true, onClick: onSignOut, title: 'Sign out'}, From f52796b750df82f462edca316fccf6833b5ef04f Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 16 Sep 2026 10:36:55 -0400 Subject: [PATCH 08/28] fix(router): drop the desktop menu handler too, since disabled items still fire --- shared/router-v2/tab-bar.desktop.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/shared/router-v2/tab-bar.desktop.tsx b/shared/router-v2/tab-bar.desktop.tsx index 706a1dd0872b..9810bd4ad6e3 100644 --- a/shared/router-v2/tab-bar.desktop.tsx +++ b/shared/router-v2/tab-bar.desktop.tsx @@ -85,7 +85,8 @@ const Header = () => { const makePopup = (p: Kb.Popup2Parms) => { const {attachTo, hidePopup} = p const menuItems: Kb.MenuItems = [ - {disabled: userSwitching, onClick: onAddAccount, title: 'Log in as another user'}, + // the desktop menu only styles disabled items, so drop the handler too + {disabled: userSwitching, onClick: userSwitching ? undefined : onAddAccount, title: 'Log in as another user'}, {onClick: onSettings, title: 'Settings'}, {onClick: onHelp, title: 'Help'}, {danger: true, onClick: onSignOut, title: 'Sign out'}, From f61d0824f244bd104a8768e4d4086a750384165e Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 24 Sep 2026 08:52:10 -0400 Subject: [PATCH 09/28] refactor(config): start every account switch through switchToAccount The rule that a switch can't start during another one was repeated at each entry point. switchToAccount now refuses while a switch runs, so the carry-over of userSwitchingFromLoggedIn for a nested switch is gone. Starting a switch must name its target, and useUserSwitchNavKey ends one through endUserSwitchLandedOn, so there is one rule for when a switch has landed. The superseded-login guard stays: a tapped push can still start a switch while a password login is running. --- shared/constants/init/shared.test.ts | 10 ++--- shared/router-v2/account-link-switch.tsx | 3 +- .../account-switch-header-avatar.native.tsx | 13 +++--- shared/router-v2/account-switcher/index.tsx | 13 +++--- shared/router-v2/intent-consumption.test.ts | 8 ++-- shared/router-v2/linking.test.ts | 4 +- shared/router-v2/tab-bar.desktop.tsx | 10 +---- shared/router-v2/use-user-switch-nav-key.tsx | 5 +-- shared/stores/config.tsx | 32 +++++++++----- shared/stores/tests/config.test.ts | 43 ++++++++++--------- 10 files changed, 69 insertions(+), 72 deletions(-) diff --git a/shared/constants/init/shared.test.ts b/shared/constants/init/shared.test.ts index 6ed968b2b26e..61b78f52c071 100644 --- a/shared/constants/init/shared.test.ts +++ b/shared/constants/init/shared.test.ts @@ -33,7 +33,7 @@ describe('loadAccountsStep', () => { test('does not wait for accounts while switching', async () => { withDeferredRefreshAccounts() - useConfigState.getState().dispatch.setUserSwitching(true) + useConfigState.getState().dispatch.setUserSwitching(true, 'testuser') useDaemonState.setState(s => { s.bootstrapStatus = {loggedIn: false} as any }) @@ -107,7 +107,7 @@ describe('onNetworkOnlineChanged', () => { test('does not re-read during an account switch', () => { const reRead = spyOnReRead() - useConfigState.getState().dispatch.setUserSwitching(true) + useConfigState.getState().dispatch.setUserSwitching(true, 'testuser') onNetworkOnlineChanged(true, false) expect(reRead).not.toHaveBeenCalled() }) @@ -257,7 +257,7 @@ describe('the session comes from the daemon; notifications only say to read it', test('during an account switch a logged-out reply is ignored, and the new user still replaces the old', async () => { await readReplying(userA) markAccountState() - useConfigState.getState().dispatch.setUserSwitching(true) + useConfigState.getState().dispatch.setUserSwitching(true, 'testuser') const {changes, unsub} = loginChanges() await readReplying(loggedOut) @@ -274,7 +274,7 @@ describe('the session comes from the daemon; notifications only say to read it', test('a switch whose login fails ends logged out, no longer switching', async () => { await readReplying(userA) - useConfigState.getState().dispatch.setUserSwitching(true) + useConfigState.getState().dispatch.setUserSwitching(true, 'testuser') await readReplying(loggedOut) useConfigState.getState().dispatch.setLoginError(new Error('bad password') as never) @@ -289,7 +289,7 @@ describe('the session comes from the daemon; notifications only say to read it', ['ended by a non-RPC error', new Error('engine reset')], ])('a switch whose login is %s ends logged out, no longer switching', async (_, error) => { await readReplying(userA) - useConfigState.getState().dispatch.setUserSwitching(true) + useConfigState.getState().dispatch.setUserSwitching(true, 'testuser') await readReplying(loggedOut) expect(useConfigState.getState().loggedIn).toBe(true) diff --git a/shared/router-v2/account-link-switch.tsx b/shared/router-v2/account-link-switch.tsx index af4badda4b1e..fb57cc65ba4b 100644 --- a/shared/router-v2/account-link-switch.tsx +++ b/shared/router-v2/account-link-switch.tsx @@ -44,8 +44,7 @@ export const subscribeIntentAccountSwitch = () => { } switchingFor = intent.id logger.info('[AccountLink] switching accounts for a tapped push') - dispatch.setUserSwitching(true, account.username) - dispatch.login(account.username, '') + dispatch.switchToAccount(account.username) } const dropOnFailure = (s: ConfigState, old: ConfigState) => { const loginFailed = !!s.loginError && s.loginError !== old.loginError diff --git a/shared/router-v2/account-switch-header-avatar.native.tsx b/shared/router-v2/account-switch-header-avatar.native.tsx index cae606b7058f..74f461b2df2c 100644 --- a/shared/router-v2/account-switch-header-avatar.native.tsx +++ b/shared/router-v2/account-switch-header-avatar.native.tsx @@ -15,11 +15,10 @@ const openAccountSwitcher = () => { const AccountSwitchHeaderAvatar = () => { const styles = useStyles() const username = useCurrentUserState(s => s.username) - const {configuredAccounts, login, setUserSwitching, userSwitching} = useConfigState( + const {configuredAccounts, switchToAccount, userSwitching} = useConfigState( C.useShallow(s => ({ configuredAccounts: s.configuredAccounts, - login: s.dispatch.login, - setUserSwitching: s.dispatch.setUserSwitching, + switchToAccount: s.dispatch.switchToAccount, userSwitching: s.userSwitching, })) ) @@ -27,13 +26,13 @@ const AccountSwitchHeaderAvatar = () => { const handledLongPressRef = React.useRef(false) const switchToRecentAccount = () => { - if (userSwitching || !recentAccount) return + if (!recentAccount) return + const tab = C.Router2.getTab() + if (!switchToAccount(recentAccount.username)) return handledLongPressRef.current = true C.ignorePromise(Haptics.selectionAsync()) - rememberAccountSwitchTab(username, recentAccount.username, C.Router2.getTab()) - setUserSwitching(true, recentAccount.username) - login(recentAccount.username, '') + rememberAccountSwitchTab(username, recentAccount.username, tab) } const onPressIn = () => { diff --git a/shared/router-v2/account-switcher/index.tsx b/shared/router-v2/account-switcher/index.tsx index 72c9b27279d8..b31ece36ace6 100644 --- a/shared/router-v2/account-switcher/index.tsx +++ b/shared/router-v2/account-switcher/index.tsx @@ -15,18 +15,16 @@ const AccountSwitcher = (p: {onSelected?: () => void}) => { const _fullnames = useUsersState(s => s.infoMap) const { accountRows: _accountRows, - login, logoutAndTryToLogInAs: onSelectAccountLoggedOut, logoutToLoggedOutFlow: onLoginAsAnotherUser, - setUserSwitching, + switchToAccount, userSwitching, } = useConfigState( C.useShallow(s => ({ accountRows: s.configuredAccounts, - login: s.dispatch.login, logoutAndTryToLogInAs: s.dispatch.logoutAndTryToLogInAs, logoutToLoggedOutFlow: s.dispatch.logoutToLoggedOutFlow, - setUserSwitching: s.dispatch.setUserSwitching, + switchToAccount: s.dispatch.switchToAccount, userSwitching: s.userSwitching, })) ) @@ -37,11 +35,10 @@ const AccountSwitcher = (p: {onSelected?: () => void}) => { const waiting = C.Waiting.useAnyWaiting(C.waitingKeyConfigLogin) || userSwitching const onSelectAccountLoggedIn = (username: string) => { - if (isMobile) { - rememberAccountSwitchTab(you, username, C.Router2.getTab()) + const tab = C.Router2.getTab() + if (switchToAccount(username) && isMobile) { + rememberAccountSwitchTab(you, username, tab) } - setUserSwitching(true, username) - login(username, '') } const accountRows = _accountRows.filter(account => account.username !== you) diff --git a/shared/router-v2/intent-consumption.test.ts b/shared/router-v2/intent-consumption.test.ts index 9afdc67f1808..4f0cde98e3e1 100644 --- a/shared/router-v2/intent-consumption.test.ts +++ b/shared/router-v2/intent-consumption.test.ts @@ -65,7 +65,7 @@ test('a stale intent that is dropped without navigating still acks its tap route const ack = mockAckPushTap const now = jest.spyOn(Date, 'now') now.mockReturnValue(1_000) - useConfigState.getState().dispatch.setUserSwitching(true) + useConfigState.getState().dispatch.setUserSwitching(true, 'testuser') const listener = jest.fn() const unsubscribe = subscribeNavigationIntents(listener, jest.fn()) @@ -108,7 +108,7 @@ test('a stale intent is discarded instead of navigating', () => { now.mockReturnValue(1_000) // block consumption so the intent sits in the queue while time passes - useConfigState.getState().dispatch.setUserSwitching(true) + useConfigState.getState().dispatch.setUserSwitching(true, 'testuser') const listener = jest.fn() const handleAppLink = jest.fn() const unsubscribe = subscribeNavigationIntents(listener, handleAppLink) @@ -129,7 +129,7 @@ test('an intent that is still within its lifetime is consumed after the block cl const now = jest.spyOn(Date, 'now') now.mockReturnValue(1_000) - useConfigState.getState().dispatch.setUserSwitching(true) + useConfigState.getState().dispatch.setUserSwitching(true, 'testuser') const listener = jest.fn() const unsubscribe = subscribeNavigationIntents(listener, jest.fn()) @@ -177,7 +177,7 @@ test('an account-targeted intent survives the store reset an account switch perf const listener = jest.fn() const unsubscribe = subscribeNavigationIntents(listener, jest.fn()) - useConfigState.getState().dispatch.setUserSwitching(true) + useConfigState.getState().dispatch.setUserSwitching(true, 'testuser') enqueuePushTapRoute({id: 4444, targetUid: 'target-uid', url: 'keybase://convid/switch-target-conversation'}) expect(listener).not.toHaveBeenCalled() diff --git a/shared/router-v2/linking.test.ts b/shared/router-v2/linking.test.ts index e26df0725ce4..d587c1ec0a55 100644 --- a/shared/router-v2/linking.test.ts +++ b/shared/router-v2/linking.test.ts @@ -92,7 +92,7 @@ test('waits until the intended account is active', () => { test('waits for an account switch to finish', () => { useNavigationIntentsState.getState().dispatch.setNavigationReady(true, 'current-uid') - useConfigState.getState().dispatch.setUserSwitching(true) + useConfigState.getState().dispatch.setUserSwitching(true, 'testuser') const listener = jest.fn() const unsubscribe = subscribeNavigationIntents(listener, jest.fn()) @@ -108,7 +108,7 @@ test('waits for an account switch to finish', () => { test('waits for the replacement router after the current account changes', () => { const navigationDispatch = useNavigationIntentsState.getState().dispatch navigationDispatch.setNavigationReady(true, 'current-uid') - useConfigState.getState().dispatch.setUserSwitching(true) + useConfigState.getState().dispatch.setUserSwitching(true, 'testuser') const listener = jest.fn() const unsubscribe = subscribeNavigationIntents(listener, jest.fn()) diff --git a/shared/router-v2/tab-bar.desktop.tsx b/shared/router-v2/tab-bar.desktop.tsx index 9810bd4ad6e3..4f769468c8d6 100644 --- a/shared/router-v2/tab-bar.desktop.tsx +++ b/shared/router-v2/tab-bar.desktop.tsx @@ -260,20 +260,14 @@ function Tab(props: TabProps) { const isPeopleTab = index === 0 const {label} = Tabs.desktopTabMeta[tab] const current = useCurrentUserState(s => s.username) - const {login, setUserSwitching} = useConfigState( - C.useShallow(s => ({ - login: s.dispatch.login, - setUserSwitching: s.dispatch.setUserSwitching, - })) - ) + const switchToAccount = useConfigState(s => s.dispatch.switchToAccount) const onQuickSwitch = isPeopleTab ? () => { const {configuredAccounts: accountRows, userSwitching} = useConfigState.getState() if (userSwitching) return const row = accountRows.find(a => a.username !== current && a.hasStoredSecret) if (row) { - setUserSwitching(true, row.username) - login(row.username, '') + switchToAccount(row.username) } else { onSelectTab(tab) } diff --git a/shared/router-v2/use-user-switch-nav-key.tsx b/shared/router-v2/use-user-switch-nav-key.tsx index 46877f6d817c..770838e73f98 100644 --- a/shared/router-v2/use-user-switch-nav-key.tsx +++ b/shared/router-v2/use-user-switch-nav-key.tsx @@ -37,10 +37,7 @@ export const useUserSwitchNavKey = () => { .getState() .dispatch.setNavigationReady(true, useCurrentUserState.getState().uid) } - const {dispatch, userSwitching, userSwitchingTo} = useConfigState.getState() - if (userSwitching && userSwitchingTo === username) { - dispatch.setUserSwitching(false) - } + useConfigState.getState().dispatch.endUserSwitchLandedOn(username) }, [username]) return navKey } diff --git a/shared/stores/config.tsx b/shared/stores/config.tsx index aa8f1319c0bd..48e6d1e82f2a 100644 --- a/shared/stores/config.tsx +++ b/shared/stores/config.tsx @@ -57,7 +57,7 @@ type Store = T.Immutable<{ tab?: Tab; }; userSwitching: boolean; - // The account an in-progress switch is logging into ('' when none or not known) + // The account an in-progress switch is logging into ('' when none) userSwitchingTo: string; // Whether the in-progress switch started while logged in userSwitchingFromLoggedIn: boolean; @@ -131,7 +131,10 @@ export type State = Store & { setStartupDetails: (st: Omit) => void; setOutOfDate: (outOfDate: T.Config.OutOfDate) => void; setUpdating: () => void; - setUserSwitching: (sw: boolean, to?: string) => void; + // Starting a switch names its target; switchToAccount is the one place that does. + setUserSwitching: (...args: [sw: true, to: string] | [sw: false]) => void; + // Starts a switch to a stored account unless one is already running. Returns whether it started. + switchToAccount: (username: string) => boolean; toggleRuntimeStats: () => void; updateGregorCategory: ( category: string, @@ -143,8 +146,9 @@ export type State = Store & { export const useConfigState = Z.createZustand("config", (set, get) => { let inflightRefreshAccounts: Promise | undefined; - // Bumped by every login. A login that fails after a newer one started (e.g. picking a second - // account mid-switch) must not end the newer switch or show its own error. + // Bumped by every login. A login that fails after a newer one started (e.g. a tapped push for + // another account switching while a password login is still running) must not end the newer + // switch or show its own error. let loginGeneration = 0; const _checkForUpdate = async () => { @@ -212,7 +216,7 @@ export const useConfigState = Z.createZustand("config", (set, get) => { // A navigator that comes up for an account a newer switch has already moved past (the user // picked another account mid-switch) must leave that switch running. const { userSwitching, userSwitchingTo } = get(); - if (userSwitching && (!userSwitchingTo || userSwitchingTo === username)) { + if (userSwitching && userSwitchingTo === username) { get().dispatch.setUserSwitching(false); } }, @@ -623,7 +627,10 @@ export const useConfigState = Z.createZustand("config", (set, get) => { s.outOfDate.updating = true; }); }, - setUserSwitching: (sw, to) => { + setUserSwitching: (...args) => { + const [sw, to] = args; + // Read before the reset below, which clears loggedIn + const fromLoggedIn = sw && get().loggedIn; if (sw && !get().userSwitching) { Z.resetAllStores(); if (hasEngine()) { @@ -631,14 +638,17 @@ export const useConfigState = Z.createZustand("config", (set, get) => { } } set((s) => { - // A second switch that starts after the mid-switch logout would read loggedIn as false, so - // keep holding the logged-in screens if the switch already in flight is holding them. - s.userSwitchingFromLoggedIn = - sw && (s.loggedIn || (s.userSwitching && s.userSwitchingFromLoggedIn)); s.userSwitching = sw; - s.userSwitchingTo = sw ? (to ?? "") : ""; + s.userSwitchingFromLoggedIn = fromLoggedIn; + s.userSwitchingTo = sw ? to : ""; }); }, + switchToAccount: (username) => { + if (get().userSwitching) return false; + get().dispatch.setUserSwitching(true, username); + get().dispatch.login(username, ""); + return true; + }, toggleRuntimeStats: () => { const f = async () => { await T.RPCGen.configToggleRuntimeStatsRpcPromise(); diff --git a/shared/stores/tests/config.test.ts b/shared/stores/tests/config.test.ts index c35375ee0e35..8ddba8efe83e 100644 --- a/shared/stores/tests/config.test.ts +++ b/shared/stores/tests/config.test.ts @@ -195,7 +195,7 @@ describe('login', () => { .mockRejectedValue(new RPCError('bad password', T.RPCGen.StatusCode.scgeneric)) const switchingWhenRead: Array = [] refresh.mockImplementation(() => switchingWhenRead.push(useConfigState.getState().userSwitching)) - useConfigState.getState().dispatch.setUserSwitching(true) + useConfigState.getState().dispatch.setUserSwitching(true, 'testuser') useConfigState.getState().dispatch.login('testuser', 'password') await flush() @@ -206,7 +206,7 @@ describe('login', () => { const switchWithLoginFailure = async (failure: unknown) => { jest.spyOn(T.RPCGen, 'loginLoginRpcListener').mockRejectedValue(failure) const {dispatch} = useConfigState.getState() - dispatch.setUserSwitching(true) + dispatch.setUserSwitching(true, 'testuser') dispatch.login('testuser', '') await flush() } @@ -223,7 +223,7 @@ describe('login', () => { return cancelled() }) const {dispatch} = useConfigState.getState() - dispatch.setUserSwitching(true) + dispatch.setUserSwitching(true, 'testuser') dispatch.login('testuser', '') await flush() @@ -248,17 +248,16 @@ describe('login', () => { expect(useConfigState.getState().userSwitching).toBe(false) }) - test("a login that fails after a newer one started leaves the newer switch alone, and the newer one's failure still ends it", async () => { + test("a login that fails after a switch started leaves the switch alone, and the newer one's failure still ends it", async () => { const rejects: Array<(e: unknown) => void> = [] jest.spyOn(T.RPCGen, 'loginLoginRpcListener').mockImplementation( async () => new Promise((_resolve, reject) => rejects.push(reject)) ) const {dispatch} = useConfigState.getState() - dispatch.setUserSwitching(true, 'testuser') - dispatch.login('testuser', '') + dispatch.login('testuser', 'password') await flush() - dispatch.setUserSwitching(true, 'testuser-mac') - dispatch.login('testuser-mac', '') + // e.g. a tapped push for another account while a password login is still running + dispatch.switchToAccount('testuser-mac') await flush() rejects[0]?.(new RPCError('bad things', T.RPCGen.StatusCode.scgeneric)) @@ -275,18 +274,17 @@ describe('login', () => { expect(state.loginError?.desc).toBeTruthy() }) - test('prompts that arrive for a login after a newer one started do not end the newer switch', async () => { + test('prompts that arrive for a login after a switch started do not end the switch', async () => { const listeners: Array = [] jest.spyOn(T.RPCGen, 'loginLoginRpcListener').mockImplementation(async listener => { listeners.push(listener) return new Promise(() => {}) }) const {dispatch} = useConfigState.getState() - dispatch.setUserSwitching(true, 'testuser') - dispatch.login('testuser', '') + dispatch.login('testuser', 'password') await flush() - dispatch.setUserSwitching(true, 'testuser-mac') - dispatch.login('testuser-mac', '') + // e.g. a tapped push for another account while a password login is still running + dispatch.switchToAccount('testuser-mac') await flush() const response = () => ({error: jest.fn(), result: jest.fn()}) @@ -350,15 +348,18 @@ test('setUserSwitching records whether the switch started logged in, through the expect(useConfigState.getState().userSwitchingFromLoggedIn).toBe(false) }) -test('a switch started during another switch keeps the first switch\'s logged-in state and takes the new target', () => { +test('switchToAccount starts a switch to its target and logs in, and refuses while one is running', () => { + const loginSpy = jest.spyOn(T.RPCGen, 'loginLoginRpcListener').mockReturnValue(new Promise(() => {})) const {dispatch} = useConfigState.getState() - dispatch.setLoggedIn(true) - dispatch.setUserSwitching(true, 'testuser') - dispatch.setLoggedIn(false) - dispatch.setUserSwitching(true, 'testuser-mac') + expect(dispatch.switchToAccount('testuser')).toBe(true) + expect(useConfigState.getState().userSwitchingTo).toBe('testuser') + expect(loginSpy).toHaveBeenCalledTimes(1) - const state = useConfigState.getState() - expect(state.userSwitchingFromLoggedIn).toBe(true) - expect(state.userSwitchingTo).toBe('testuser-mac') + expect(dispatch.switchToAccount('testuser-mac')).toBe(false) + expect(useConfigState.getState().userSwitchingTo).toBe('testuser') + expect(loginSpy).toHaveBeenCalledTimes(1) + + loginSpy.mockRestore() + dispatch.setUserSwitching(false) }) From d7585f82ba3229777ead518919c557443fab3295 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 24 Sep 2026 08:52:10 -0400 Subject: [PATCH 10/28] fix(router): log the push navigateAppendOnceRootHas drops --- .../navigate-append-once-root-has.test.ts | 17 +++++++++++++++++ shared/constants/router.tsx | 6 +++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/shared/constants/navigate-append-once-root-has.test.ts b/shared/constants/navigate-append-once-root-has.test.ts index 5a6a5cd1df06..62b817186142 100644 --- a/shared/constants/navigate-append-once-root-has.test.ts +++ b/shared/constants/navigate-append-once-root-has.test.ts @@ -1,4 +1,5 @@ /// +import logger from '@/logger' import {navigateAppendOnceRootHas, navigationRef} from '@/constants/router' const dispatch = jest.fn() @@ -38,6 +39,7 @@ beforeEach(() => { afterEach(() => { jest.useRealTimers() + jest.restoreAllMocks() }) // Each test pushes distinct params: navigateAppend's module-private `_pendingAppend` dupe cache @@ -76,10 +78,25 @@ test('gives up if the root route does not mount before the timeout', () => { jest.useFakeTimers() setRootRoutes([loggedIn]) + const warn = jest.spyOn(logger, 'warn').mockImplementation(() => {}) + navigateAppendOnceRootHas('loggedOut', {name: 'username', params: {username: 'testuser-c'}} as never, 5000) jest.advanceTimersByTime(5000) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('loggedOut never mounted, dropping username')) setRootRoutes([loggedOut]) emitState() expect(dispatch).not.toHaveBeenCalled() }) + +test('logs the push it drops when there is no navigator', () => { + setRootRoutes([loggedIn]) + const nr = navigationRef as unknown as Record + nr['isReady'] = () => false + const warn = jest.spyOn(logger, 'warn').mockImplementation(() => {}) + + navigateAppendOnceRootHas('loggedOut', {name: 'username', params: {username: 'testuser-d'}} as never) + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('no navigator, dropping username')) + expect(dispatch).not.toHaveBeenCalled() +}) diff --git a/shared/constants/router.tsx b/shared/constants/router.tsx index a06bcf764bbb..84865b086506 100644 --- a/shared/constants/router.tsx +++ b/shared/constants/router.tsx @@ -468,9 +468,13 @@ export const navigateAppendOnceRootHas = ( } const n = _getNavigator() if (!n) { + logger.warn(`[Nav] navigateAppendOnceRootHas: no navigator, dropping ${path.name}`) return } - const timer = setTimeout(() => unsub(), timeoutMs) + const timer = setTimeout(() => { + unsub() + logger.warn(`[Nav] navigateAppendOnceRootHas: ${rootRouteName} never mounted, dropping ${path.name}`) + }, timeoutMs) const unsub = n.addListener('state', () => { if (!rootHas()) return clearTimeout(timer) From dbdab1f8920af01fa4a9a83239fe4a60eb3c0ace Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 24 Sep 2026 08:52:10 -0400 Subject: [PATCH 11/28] fix(router): hold desktop's logged-in screens by the same rule as native --- shared/router-v2/router.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/shared/router-v2/router.tsx b/shared/router-v2/router.tsx index 429e12cfb9f2..d8656da84636 100644 --- a/shared/router-v2/router.tsx +++ b/shared/router-v2/router.tsx @@ -187,17 +187,17 @@ if (!isMobile) { const useIsLoadingDesktop = () => !useHandshakeEverDone() - // During an account switch loggedIn flaps false between the service's loggedOut and - // loggedIn notifications; keep the app (and its left nav) mounted through that gap. + // Same rule as native: keep the app (and its left nav) mounted through the loggedIn flap of a + // switch that started logged in. const useIsLoggedInDesktop = () => { const loaded = useHandshakeEverDone() - const loggedIn = useConfigState(s => s.loggedIn || s.userSwitching) + const loggedIn = useConfigState(showLoggedInScreens) return loaded && loggedIn } const useIsLoggedOutDesktop = () => { const loaded = useHandshakeEverDone() - const loggedIn = useConfigState(s => s.loggedIn || s.userSwitching) + const loggedIn = useConfigState(showLoggedInScreens) return loaded && !loggedIn } From 6d6f7e85676987af954cd11d9533e68942fac676 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 24 Sep 2026 08:52:10 -0400 Subject: [PATCH 12/28] test(router): use testuser placeholders in the account-switch tests --- shared/router-v2/account-switch.test.tsx | 34 ++++++++++++------------ 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/shared/router-v2/account-switch.test.tsx b/shared/router-v2/account-switch.test.tsx index dcfeee0ec3cc..e801621f902a 100644 --- a/shared/router-v2/account-switch.test.tsx +++ b/shared/router-v2/account-switch.test.tsx @@ -40,47 +40,47 @@ describe('pending account-switch tab', () => { }) test('returns the remembered tab after the username changes and consumes it once', () => { - rememberAccountSwitchTab('alice', 'bob', Tabs.chatTab) + rememberAccountSwitchTab('testuser', 'testuser-mac', Tabs.chatTab) - expect(consumePendingAccountSwitchTab('bob')).toBe(Tabs.chatTab) - expect(consumePendingAccountSwitchTab('bob')).toBeUndefined() + expect(consumePendingAccountSwitchTab('testuser-mac')).toBe(Tabs.chatTab) + expect(consumePendingAccountSwitchTab('testuser-mac')).toBeUndefined() }) test('peeks the remembered tab for the target account without consuming it', () => { - rememberAccountSwitchTab('alice', 'bob', Tabs.fsTab) + rememberAccountSwitchTab('testuser', 'testuser-mac', Tabs.fsTab) - expect(peekPendingAccountSwitchTab('alice')).toBeUndefined() - expect(peekPendingAccountSwitchTab('bob')).toBe(Tabs.fsTab) - expect(consumePendingAccountSwitchTab('bob')).toBe(Tabs.fsTab) + expect(peekPendingAccountSwitchTab('testuser')).toBeUndefined() + expect(peekPendingAccountSwitchTab('testuser-mac')).toBe(Tabs.fsTab) + expect(consumePendingAccountSwitchTab('testuser-mac')).toBe(Tabs.fsTab) }) test('does not consume the tab before the account changes', () => { - rememberAccountSwitchTab('alice', 'bob', Tabs.fsTab) + rememberAccountSwitchTab('testuser', 'testuser-mac', Tabs.fsTab) - expect(consumePendingAccountSwitchTab('alice')).toBeUndefined() - expect(consumePendingAccountSwitchTab('bob')).toBe(Tabs.fsTab) + expect(consumePendingAccountSwitchTab('testuser')).toBeUndefined() + expect(consumePendingAccountSwitchTab('testuser-mac')).toBe(Tabs.fsTab) }) test('keeps the pending tab when switching ends on the target account', () => { - rememberAccountSwitchTab('alice', 'bob', Tabs.teamsTab) + rememberAccountSwitchTab('testuser', 'testuser-mac', Tabs.teamsTab) - clearPendingAccountSwitch('bob') + clearPendingAccountSwitch('testuser-mac') - expect(consumePendingAccountSwitchTab('bob')).toBe(Tabs.teamsTab) + expect(consumePendingAccountSwitchTab('testuser-mac')).toBe(Tabs.teamsTab) }) test('clears the pending tab when switching fails after blanking the username', () => { - rememberAccountSwitchTab('alice', 'bob', Tabs.teamsTab) + rememberAccountSwitchTab('testuser', 'testuser-mac', Tabs.teamsTab) clearPendingAccountSwitch('') - expect(consumePendingAccountSwitchTab('bob')).toBeUndefined() + expect(consumePendingAccountSwitchTab('testuser-mac')).toBeUndefined() }) test('ignores routes that are not application tabs', () => { - rememberAccountSwitchTab('alice', 'bob', Tabs.loginTab) + rememberAccountSwitchTab('testuser', 'testuser-mac', Tabs.loginTab) - expect(consumePendingAccountSwitchTab('bob')).toBeUndefined() + expect(consumePendingAccountSwitchTab('testuser-mac')).toBeUndefined() }) }) From 24ce8516377ffef5bb00d60a6a63a1975e002846 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 24 Sep 2026 09:21:07 -0400 Subject: [PATCH 13/28] fix(router): hold the logged-in screens through a brief logged-out session A desktop account switch passes through a logged-out session for tens of milliseconds: the engine reset on the service's loggedOut cancels the login, which ends the switch before the new account's loggedIn arrives. The router followed it and flashed the relogin screen. The routers now switch to the logged-out screens only once the session has stayed logged out for 500 ms. Only the UI waits; the logout and its store reset still happen immediately. --- shared/router-v2/logged-in-screens.test.tsx | 69 +++++++++++++++++++++ shared/router-v2/logged-in-screens.tsx | 31 +++++++++ shared/router-v2/router.tsx | 31 +++++---- 3 files changed, 118 insertions(+), 13 deletions(-) create mode 100644 shared/router-v2/logged-in-screens.test.tsx create mode 100644 shared/router-v2/logged-in-screens.tsx diff --git a/shared/router-v2/logged-in-screens.test.tsx b/shared/router-v2/logged-in-screens.test.tsx new file mode 100644 index 000000000000..212c450b2615 --- /dev/null +++ b/shared/router-v2/logged-in-screens.test.tsx @@ -0,0 +1,69 @@ +/** @jest-environment jsdom */ +/// +import {act, cleanup, renderHook} from '@testing-library/react' +import {useConfigState} from '@/stores/config' +import {loggedOutScreensDelayMs, useShowLoggedInScreensHeld} from './logged-in-screens' + +const setLoggedIn = (loggedIn: boolean) => { + act(() => { + useConfigState.setState({loggedIn}) + }) +} + +beforeEach(() => { + jest.useFakeTimers() +}) + +afterEach(() => { + cleanup() + jest.useRealTimers() + useConfigState.setState({loggedIn: false}) +}) + +test('shows the logged-in screens as soon as the session is logged in', () => { + const {result} = renderHook(() => useShowLoggedInScreensHeld()) + expect(result.current).toBe(false) + + setLoggedIn(true) + expect(result.current).toBe(true) +}) + +test('holds the logged-in screens through a logged-out blip shorter than the delay', () => { + setLoggedIn(true) + const {result} = renderHook(() => useShowLoggedInScreensHeld()) + + setLoggedIn(false) + act(() => { + jest.advanceTimersByTime(loggedOutScreensDelayMs - 1) + }) + expect(result.current).toBe(true) + + setLoggedIn(true) + act(() => { + jest.advanceTimersByTime(loggedOutScreensDelayMs) + }) + expect(result.current).toBe(true) + + // and the next blip is held from the start too + setLoggedIn(false) + expect(result.current).toBe(true) +}) + +test('shows the logged-out screens once the logout has held for the delay, and again after a later blip', () => { + setLoggedIn(true) + const {result} = renderHook(() => useShowLoggedInScreensHeld()) + + setLoggedIn(false) + act(() => { + jest.advanceTimersByTime(loggedOutScreensDelayMs) + }) + expect(result.current).toBe(false) + + setLoggedIn(true) + setLoggedIn(false) + expect(result.current).toBe(true) + act(() => { + jest.advanceTimersByTime(loggedOutScreensDelayMs) + }) + expect(result.current).toBe(false) +}) diff --git a/shared/router-v2/logged-in-screens.tsx b/shared/router-v2/logged-in-screens.tsx new file mode 100644 index 000000000000..f0b72482eb60 --- /dev/null +++ b/shared/router-v2/logged-in-screens.tsx @@ -0,0 +1,31 @@ +import * as React from 'react' +import {useConfigState} from '@/stores/config' +import {showLoggedInScreens} from './account-switch' + +// How long the logged-in screens stay up after the session says logged out. An account switch on +// desktop passes through a logged-out session for tens of milliseconds (its engine reset cancels +// the login, which ends the switch before the new account's loggedIn arrives), and showing the +// logged-out screens for that long is a visible flash. Only the UI waits: the logout itself, and +// the store reset that clears the old account's data, happen immediately. +export const loggedOutScreensDelayMs = 500 + +// Whether the root navigator shows the logged-in screens: showLoggedInScreens, but a change to +// false only lands once it has held for loggedOutScreensDelayMs. +export const useShowLoggedInScreensHeld = () => { + const show = useConfigState(showLoggedInScreens) + const [held, setHeld] = React.useState(show) + if (show && !held) { + setHeld(true) + } + React.useEffect(() => { + if (show) return + const id = setTimeout(() => setHeld(false), loggedOutScreensDelayMs) + return () => clearTimeout(id) + }, [show]) + return show || held +} + +// The router computes the held value once, above the navigator, so the logged-in and logged-out +// groups never disagree. +export const LoggedInScreensContext = React.createContext(false) +export const useLoggedInScreens = () => React.useContext(LoggedInScreensContext) diff --git a/shared/router-v2/router.tsx b/shared/router-v2/router.tsx index d8656da84636..ca156072da7b 100644 --- a/shared/router-v2/router.tsx +++ b/shared/router-v2/router.tsx @@ -32,7 +32,8 @@ 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, showLoggedInScreens} from './account-switch' +import {clearPendingAccountSwitch, consumePendingAccountSwitchTab} from './account-switch' +import {LoggedInScreensContext, useLoggedInScreens, useShowLoggedInScreensHeld} from './logged-in-screens' import {useCurrentUserState} from '@/stores/current-user' import {useNavigationIntentsState} from '@/stores/navigation-intents' const isLiquidGlassSupported = isMobile ? (_isLiquidGlassSupported as boolean) : false @@ -187,17 +188,15 @@ if (!isMobile) { const useIsLoadingDesktop = () => !useHandshakeEverDone() - // Same rule as native: keep the app (and its left nav) mounted through the loggedIn flap of a - // switch that started logged in. const useIsLoggedInDesktop = () => { const loaded = useHandshakeEverDone() - const loggedIn = useConfigState(showLoggedInScreens) + const loggedIn = useLoggedInScreens() return loaded && loggedIn } const useIsLoggedOutDesktop = () => { const loaded = useHandshakeEverDone() - const loggedIn = useConfigState(showLoggedInScreens) + const loggedIn = useLoggedInScreens() return loaded && !loggedIn } @@ -270,6 +269,7 @@ function DesktopRouter() { ) const endUserSwitchLandedOn = useConfigState(s => s.dispatch.endUserSwitchLandedOn) const setNavigationReady = useNavigationIntentsState(s => s.dispatch.setNavigationReady) + const showLoggedIn = useShowLoggedInScreensHeld() React.useEffect( () => subscribeNavigationIntents(handleAppLink, handleAppLink), @@ -305,9 +305,11 @@ function DesktopRouter() { ref={setDesktopNavRef} theme={isDarkMode ? darkTheme : lightTheme} > - - - + + + + + ) } @@ -599,8 +601,8 @@ if (isMobile) { } } - const useIsLoggedInNative = () => useConfigState(showLoggedInScreens) - const useIsLoggedOutNative = () => !useConfigState(showLoggedInScreens) + const useIsLoggedInNative = () => useLoggedInScreens() + const useIsLoggedOutNative = () => !useLoggedInScreens() const nativeModalScreensConfig = routeMapToStaticScreens(modalRoutes, makeLayout, true, false, false) const nativePhoneRootScreensConfig = routeMapToStaticScreens( @@ -693,6 +695,7 @@ function NativeRouter() { const bar = barStyle === 'default' ? null : const navKey = Common.useUserSwitchNavKey() const setNavigationReady = useNavigationIntentsState(s => s.dispatch.setNavigationReady) + const showLoggedIn = useShowLoggedInScreensHeld() const setNativeNavRef = (ref: typeof C.Router2.navigationRef.current) => { setNavRef(ref) setNavigationReady(ref?.isReady() ?? false) @@ -737,9 +740,11 @@ function NativeRouter() { ref={setNativeNavRef} theme={isDarkMode ? darkTheme : lightTheme} > - - - + + + + + ) From 12e645a1771702a0173179d0e9bce574f5fdea6d Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 24 Sep 2026 09:26:10 -0400 Subject: [PATCH 14/28] fix(router): style desktop headers by the held logged-in screens, provide them from one place --- shared/router-v2/header/index.desktop.tsx | 4 ++-- shared/router-v2/logged-in-screens.tsx | 10 +++++++--- shared/router-v2/router.tsx | 12 +++++------- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/shared/router-v2/header/index.desktop.tsx b/shared/router-v2/header/index.desktop.tsx index 7595a1e3b617..1350a75c2238 100644 --- a/shared/router-v2/header/index.desktop.tsx +++ b/shared/router-v2/header/index.desktop.tsx @@ -3,7 +3,7 @@ import * as Kb from '@/common-adapters' import * as Platform from '@/constants/platform' import SyncingFolders from './syncing-folders' import KB2 from '@/util/electron' -import {useConfigState} from '@/stores/config' +import {useLoggedInScreens} from '../logged-in-screens' import {useShellState} from '@/stores/shell' import type {HeaderBackButtonProps} from '@react-navigation/elements' import type {NativeStackHeaderProps} from '@react-navigation/native-stack' @@ -397,7 +397,7 @@ type HeaderProps = Omit s.useNativeFrame) - const loggedIn = useConfigState(s => s.loggedIn) + const loggedIn = useLoggedInScreens() const isMaximized = useShellState(s => s.windowState.isMaximized) const {headerMode, title, headerTitle, headerRightActions, subHeader} = _options const {headerRight, headerTransparent, headerShadowVisible, headerBottomStyle, headerStyle, headerLeft} = diff --git a/shared/router-v2/logged-in-screens.tsx b/shared/router-v2/logged-in-screens.tsx index f0b72482eb60..23d3284e26c1 100644 --- a/shared/router-v2/logged-in-screens.tsx +++ b/shared/router-v2/logged-in-screens.tsx @@ -25,7 +25,11 @@ export const useShowLoggedInScreensHeld = () => { return show || held } -// The router computes the held value once, above the navigator, so the logged-in and logged-out -// groups never disagree. -export const LoggedInScreensContext = React.createContext(false) +// Computed once, above the navigator, so the logged-in and logged-out groups (and the headers that +// style themselves by it) never disagree. +const LoggedInScreensContext = React.createContext(false) export const useLoggedInScreens = () => React.useContext(LoggedInScreensContext) + +export const LoggedInScreensProvider = ({children}: {children: React.ReactNode}) => ( + {children} +) diff --git a/shared/router-v2/router.tsx b/shared/router-v2/router.tsx index ca156072da7b..f418b8ad8396 100644 --- a/shared/router-v2/router.tsx +++ b/shared/router-v2/router.tsx @@ -33,7 +33,7 @@ import {isLiquidGlassSupported as _isLiquidGlassSupported} from '@callstack/liqu import {Platform, StatusBar, View} from 'react-native' import AccountSwitchHeaderAvatar from './account-switch-header-avatar' import {clearPendingAccountSwitch, consumePendingAccountSwitchTab} from './account-switch' -import {LoggedInScreensContext, useLoggedInScreens, useShowLoggedInScreensHeld} from './logged-in-screens' +import {LoggedInScreensProvider, useLoggedInScreens} from './logged-in-screens' import {useCurrentUserState} from '@/stores/current-user' import {useNavigationIntentsState} from '@/stores/navigation-intents' const isLiquidGlassSupported = isMobile ? (_isLiquidGlassSupported as boolean) : false @@ -269,7 +269,6 @@ function DesktopRouter() { ) const endUserSwitchLandedOn = useConfigState(s => s.dispatch.endUserSwitchLandedOn) const setNavigationReady = useNavigationIntentsState(s => s.dispatch.setNavigationReady) - const showLoggedIn = useShowLoggedInScreensHeld() React.useEffect( () => subscribeNavigationIntents(handleAppLink, handleAppLink), @@ -305,11 +304,11 @@ function DesktopRouter() { ref={setDesktopNavRef} theme={isDarkMode ? darkTheme : lightTheme} > - + - + ) } @@ -695,7 +694,6 @@ function NativeRouter() { const bar = barStyle === 'default' ? null : const navKey = Common.useUserSwitchNavKey() const setNavigationReady = useNavigationIntentsState(s => s.dispatch.setNavigationReady) - const showLoggedIn = useShowLoggedInScreensHeld() const setNativeNavRef = (ref: typeof C.Router2.navigationRef.current) => { setNavRef(ref) setNavigationReady(ref?.isReady() ?? false) @@ -740,11 +738,11 @@ function NativeRouter() { ref={setNativeNavRef} theme={isDarkMode ? darkTheme : lightTheme} > - + - + ) From 0b9343780af2f325e7d4b048cbbc31efacc79d2b Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 24 Sep 2026 09:51:23 -0400 Subject: [PATCH 15/28] docs(claude): keep PR descriptions in step with their branch --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CLAUDE.md b/CLAUDE.md index 4a56fa46ceab..eeb1a19acd4b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,7 @@ - After editing `protocol/avdl/` or `protocol/bin/enabled-calls.json`: from `protocol/`, run `node ./bin/generate-ts.ts && cp ./js/rpc*.tsx ../shared/constants/rpc` and commit the regenerated `shared/constants/rpc/rpc-gen.tsx`. - Never hand-edit generated code (rpc-gen, protocol output, mocks, codegen'd files of any kind). Edit the source it's generated from and rerun the generator. CI regenerates and fails on any diff. - When updating `electron`: run `shared/desktop/extract-electron-shasums.sh `. +- Keep an open PR's description in step with its branch. Whenever new commits change what the PR does or how (a new fix, a changed approach, a removed piece, new tests or evidence), rewrite the affected sections with `gh pr edit --body-file`, and the title if the scope moved. It should read as a description of the current diff, not a changelog. Skip it for commits that don't change the story (lint, renames, test placeholders). - Never patch `react-native` itself (patch-package or node_modules edits): we use prebuilt RN core and don't compile its source, so native-side patches never take effect. Work around RN core bugs in app code. ## Working Directory From f8f7146c4c671e07b4881f8535db40e23ba1d975 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 24 Sep 2026 10:39:54 -0400 Subject: [PATCH 16/28] fix(desktop): stop cancelling the account switch's own login While a switch ran, desktop reset the engine on the service's loggedOut and loggedIn. The service sends both while still handling the switch's login.login, so each reset cancelled that login. The client took the cancellation as a login error, ended the switch and showed the logged-out screens while the service went on to finish the login. Desktop now handles a switch as mobile does. Instrumented switches on both platforms showed only the switch's own login.login (or logout) in flight across the switch, so the reset protected nothing. The 500 ms hold on the logged-out screens only masked that flash. It also kept the logged-in screens up for every logout, rendering reset stores: the account switcher listed the current account after "Log in as another user". Removed. --- shared/constants/init/index.tsx | 17 +---- shared/router-v2/logged-in-screens.test.tsx | 69 --------------------- shared/router-v2/logged-in-screens.tsx | 25 +------- 3 files changed, 2 insertions(+), 109 deletions(-) delete mode 100644 shared/router-v2/logged-in-screens.test.tsx diff --git a/shared/constants/init/index.tsx b/shared/constants/init/index.tsx index 6897f61f080d..b078193bc891 100644 --- a/shared/constants/init/index.tsx +++ b/shared/constants/init/index.tsx @@ -14,7 +14,7 @@ import logger from '@/logger' import {getEngine} from '@/engine' import {afterKbfsDaemonRpcStatusChanged} from '@/fs/common/lifecycle' import {logState, setThreadInputCommandStatus} from '@/constants/router' -import {initSharedSubscriptions, _onEngineIncoming, onEngineConnected as onSharedEngineConnected} from './shared' +import {initSharedSubscriptions, _onEngineIncoming} from './shared' import {noConversationIDKey} from '../types/chat/common' import {dumpLogs, persistRoute} from '@/util/storeless-actions' @@ -362,21 +362,6 @@ export const onEngineIncoming = (action: EngineGen.Actions) => { .dispatch.setOutOfDate({critical: true, message: upgradeMsg, outOfDate: true, updating: false}) break } - case 'keybase.1.NotifySession.loggedOut': { - if (useConfigState.getState().userSwitching) { - logger.info('Resetting renderer engine for account switch logout') - getEngine().reset() - } - break - } - case 'keybase.1.NotifySession.loggedIn': { - if (useConfigState.getState().userSwitching) { - logger.info('Refreshing renderer session registration for account switch login') - getEngine().reset() - onSharedEngineConnected() - } - break - } default: } } diff --git a/shared/router-v2/logged-in-screens.test.tsx b/shared/router-v2/logged-in-screens.test.tsx deleted file mode 100644 index 212c450b2615..000000000000 --- a/shared/router-v2/logged-in-screens.test.tsx +++ /dev/null @@ -1,69 +0,0 @@ -/** @jest-environment jsdom */ -/// -import {act, cleanup, renderHook} from '@testing-library/react' -import {useConfigState} from '@/stores/config' -import {loggedOutScreensDelayMs, useShowLoggedInScreensHeld} from './logged-in-screens' - -const setLoggedIn = (loggedIn: boolean) => { - act(() => { - useConfigState.setState({loggedIn}) - }) -} - -beforeEach(() => { - jest.useFakeTimers() -}) - -afterEach(() => { - cleanup() - jest.useRealTimers() - useConfigState.setState({loggedIn: false}) -}) - -test('shows the logged-in screens as soon as the session is logged in', () => { - const {result} = renderHook(() => useShowLoggedInScreensHeld()) - expect(result.current).toBe(false) - - setLoggedIn(true) - expect(result.current).toBe(true) -}) - -test('holds the logged-in screens through a logged-out blip shorter than the delay', () => { - setLoggedIn(true) - const {result} = renderHook(() => useShowLoggedInScreensHeld()) - - setLoggedIn(false) - act(() => { - jest.advanceTimersByTime(loggedOutScreensDelayMs - 1) - }) - expect(result.current).toBe(true) - - setLoggedIn(true) - act(() => { - jest.advanceTimersByTime(loggedOutScreensDelayMs) - }) - expect(result.current).toBe(true) - - // and the next blip is held from the start too - setLoggedIn(false) - expect(result.current).toBe(true) -}) - -test('shows the logged-out screens once the logout has held for the delay, and again after a later blip', () => { - setLoggedIn(true) - const {result} = renderHook(() => useShowLoggedInScreensHeld()) - - setLoggedIn(false) - act(() => { - jest.advanceTimersByTime(loggedOutScreensDelayMs) - }) - expect(result.current).toBe(false) - - setLoggedIn(true) - setLoggedIn(false) - expect(result.current).toBe(true) - act(() => { - jest.advanceTimersByTime(loggedOutScreensDelayMs) - }) - expect(result.current).toBe(false) -}) diff --git a/shared/router-v2/logged-in-screens.tsx b/shared/router-v2/logged-in-screens.tsx index 23d3284e26c1..a09af5ed26a6 100644 --- a/shared/router-v2/logged-in-screens.tsx +++ b/shared/router-v2/logged-in-screens.tsx @@ -2,34 +2,11 @@ import * as React from 'react' import {useConfigState} from '@/stores/config' import {showLoggedInScreens} from './account-switch' -// How long the logged-in screens stay up after the session says logged out. An account switch on -// desktop passes through a logged-out session for tens of milliseconds (its engine reset cancels -// the login, which ends the switch before the new account's loggedIn arrives), and showing the -// logged-out screens for that long is a visible flash. Only the UI waits: the logout itself, and -// the store reset that clears the old account's data, happen immediately. -export const loggedOutScreensDelayMs = 500 - -// Whether the root navigator shows the logged-in screens: showLoggedInScreens, but a change to -// false only lands once it has held for loggedOutScreensDelayMs. -export const useShowLoggedInScreensHeld = () => { - const show = useConfigState(showLoggedInScreens) - const [held, setHeld] = React.useState(show) - if (show && !held) { - setHeld(true) - } - React.useEffect(() => { - if (show) return - const id = setTimeout(() => setHeld(false), loggedOutScreensDelayMs) - return () => clearTimeout(id) - }, [show]) - return show || held -} - // Computed once, above the navigator, so the logged-in and logged-out groups (and the headers that // style themselves by it) never disagree. const LoggedInScreensContext = React.createContext(false) export const useLoggedInScreens = () => React.useContext(LoggedInScreensContext) export const LoggedInScreensProvider = ({children}: {children: React.ReactNode}) => ( - {children} + {children} ) From afe9bc4fa58c308b499027f8095851d3f522871b Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 24 Sep 2026 10:46:06 -0400 Subject: [PATCH 17/28] refactor(router): read the logged-in screens from the store directly, drop stale switch-reset comments --- shared/router-v2/logged-in-screens.tsx | 12 +++--------- shared/router-v2/router.tsx | 20 ++++++++------------ shared/stores/tests/daemon.test.ts | 4 ++-- 3 files changed, 13 insertions(+), 23 deletions(-) diff --git a/shared/router-v2/logged-in-screens.tsx b/shared/router-v2/logged-in-screens.tsx index a09af5ed26a6..71be4a61b250 100644 --- a/shared/router-v2/logged-in-screens.tsx +++ b/shared/router-v2/logged-in-screens.tsx @@ -1,12 +1,6 @@ -import * as React from 'react' import {useConfigState} from '@/stores/config' import {showLoggedInScreens} from './account-switch' -// Computed once, above the navigator, so the logged-in and logged-out groups (and the headers that -// style themselves by it) never disagree. -const LoggedInScreensContext = React.createContext(false) -export const useLoggedInScreens = () => React.useContext(LoggedInScreensContext) - -export const LoggedInScreensProvider = ({children}: {children: React.ReactNode}) => ( - {children} -) +// Whether the root navigator shows the logged-in screens. The routers' groups and the desktop +// header all read it here so they can't disagree. +export const useLoggedInScreens = () => useConfigState(showLoggedInScreens) diff --git a/shared/router-v2/router.tsx b/shared/router-v2/router.tsx index f418b8ad8396..a54ce526c2c1 100644 --- a/shared/router-v2/router.tsx +++ b/shared/router-v2/router.tsx @@ -33,7 +33,7 @@ import {isLiquidGlassSupported as _isLiquidGlassSupported} from '@callstack/liqu import {Platform, StatusBar, View} from 'react-native' import AccountSwitchHeaderAvatar from './account-switch-header-avatar' import {clearPendingAccountSwitch, consumePendingAccountSwitchTab} from './account-switch' -import {LoggedInScreensProvider, useLoggedInScreens} from './logged-in-screens' +import {useLoggedInScreens} from './logged-in-screens' import {useCurrentUserState} from '@/stores/current-user' import {useNavigationIntentsState} from '@/stores/navigation-intents' const isLiquidGlassSupported = isMobile ? (_isLiquidGlassSupported as boolean) : false @@ -100,7 +100,7 @@ const setNavRef = (ref: typeof C.Router2.navigationRef.current) => { // Sticky: once the handshake finishes we never go back to the splash, even if it // restarts later (engine reconnect); the disconnected overlay covers that case. // Module-level so it survives the navigator remount on user switch (a ref would -// reset and flash the splash while the post-switch handshake is still running). +// reset and flash the splash). let handshakeEverDone = false const useHandshakeEverDone = () => { return useDaemonState(s => { @@ -304,11 +304,9 @@ function DesktopRouter() { ref={setDesktopNavRef} theme={isDarkMode ? darkTheme : lightTheme} > - - - - - + + + ) } @@ -738,11 +736,9 @@ function NativeRouter() { ref={setNativeNavRef} theme={isDarkMode ? darkTheme : lightTheme} > - - - - - + + + ) diff --git a/shared/stores/tests/daemon.test.ts b/shared/stores/tests/daemon.test.ts index bf5a00998a8c..9537719865a8 100644 --- a/shared/stores/tests/daemon.test.ts +++ b/shared/stores/tests/daemon.test.ts @@ -107,8 +107,8 @@ describe('daemon store', () => { }) test('startHandshake does not reuse a load orphaned by an engine reset', async () => { - // engine.reset() drops in-flight RPCs without settling their promises (user switch does - // this twice); a later handshake must start a fresh load instead of awaiting the dead one + // engine.reset() drops in-flight RPCs without settling their promises; a later handshake + // must start a fresh load instead of awaiting the dead one const spy = jest .spyOn(T.RPCGen, 'configGetBootstrapStatusRpcPromise') .mockImplementationOnce(async () => new Promise(() => {})) From 4161023ec7757854fd946b96721cd373f927d794 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 24 Sep 2026 12:35:19 -0400 Subject: [PATCH 18/28] fix(engine): refuse replies and prompts for calls started before a logout With desktop's switch-time engine reset gone, an in-flight call from the old account settles after the logout reset, and its reply lands in the next account's stores (mobile always behaved this way). Each session now records the account generation it started in, which the logout reset bumps. A reply for an older generation is rejected as cancelled without touching the waiting count the reset already cleared, and prompts on it are answered with an error. Calls that change the account on purpose, or return process-wide state, are exempt. --- shared/engine/account-generation.tsx | 27 +++++++++++ shared/engine/session.test.tsx | 69 ++++++++++++++++++++++++++++ shared/engine/session.tsx | 23 ++++++++++ 3 files changed, 119 insertions(+) create mode 100644 shared/engine/account-generation.tsx diff --git a/shared/engine/account-generation.tsx b/shared/engine/account-generation.tsx new file mode 100644 index 000000000000..a60ba3cf01db --- /dev/null +++ b/shared/engine/account-generation.tsx @@ -0,0 +1,27 @@ +import {registerExternalResetter} from '@/util/zustand' +import type {MethodKey} from './types' + +// Goes up with every logout reset: every logout, and the old account's side of an account switch. +// A call started before it belongs to an account that is gone, so its reply and any prompts the +// service sends on it are refused (see Session) instead of landing in the next account's stores. +let accountGeneration = 0 +export const getAccountGeneration = () => accountGeneration +registerExternalResetter('engine-account-generation', () => { + accountGeneration++ +}) + +// Calls that change the logged-in account on purpose, so their replies arrive after the reset they +// cause, and calls whose answer belongs to the process rather than an account. +const spansAccountChange: ReadonlySet = new Set([ + 'keybase.1.account.cancelReset', + 'keybase.1.account.enterResetPipeline', + 'keybase.1.config.getBootstrapStatus', + 'keybase.1.login.accountDelete', + 'keybase.1.login.deprovision', + 'keybase.1.login.getConfiguredAccounts', + 'keybase.1.login.login', + 'keybase.1.login.logout', + 'keybase.1.login.recoverPassphrase', + 'keybase.1.signup.signup', +]) +export const survivesAccountChange = (method: MethodKey) => spansAccountChange.has(method) diff --git a/shared/engine/session.test.tsx b/shared/engine/session.test.tsx index 1f37838c158c..5f133bfe3578 100644 --- a/shared/engine/session.test.tsx +++ b/shared/engine/session.test.tsx @@ -2,6 +2,7 @@ import Session from './session' import {RPCError} from '@/util/errors' import * as T from '@/constants/types' +import {resetAllStores} from '@/util/zustand' const mockDispatchWaitingAction = jest.fn() jest.mock('./require', () => ({ @@ -66,3 +67,71 @@ test('a late server response after cancel does not fire the callback twice', () invokeCallback(undefined, {}) expect(callback).toHaveBeenCalledTimes(1) }) + +describe('a call that outlives its account', () => { + const startCall = (method: string) => { + const invoke = jest.fn() + const callback = jest.fn() + const session = new Session({ + customResponseIncomingCallMap: {'keybase.1.secretUi.getPassphrase': jest.fn()} as never, + endHandler: jest.fn(), + invoke, + sessionID: 9, + waitingKey: 'waiting-key', + }) + session.start(method, undefined, callback) + mockDispatchWaitingAction.mockReset() // drop the +1 from start + const reply = invoke.mock.calls[0]![2] as (err: unknown, data: unknown) => void + return {callback, reply, session} + } + const logOut = () => { + resetAllStores() + } + + test('its reply is refused after a logout, without touching the waiting count', () => { + const {callback, reply} = startCall('keybase.1.user.getUserBlocks') + logOut() + + reply(undefined, [{username: 'testuser-mac'}]) + + expect(callback).toHaveBeenCalledTimes(1) + const [err, data] = callback.mock.calls[0]! as [RPCError, unknown] + expect(err).toBeInstanceOf(RPCError) + expect(err.code).toBe(T.RPCGen.StatusCode.sccanceled) + expect(data).toBeUndefined() + expect(mockDispatchWaitingAction).not.toHaveBeenCalled() + }) + + test('a prompt the service sends on it is answered with an error, not handed to its handler', () => { + const {session} = startCall('keybase.1.identify3.identify3') + logOut() + const error = jest.fn() + const handler = (session as unknown as {_customResponseIncomingCallMap: Record}) + ._customResponseIncomingCallMap['keybase.1.secretUi.getPassphrase']! + + expect(session.incomingCall('keybase.1.secretUi.getPassphrase', {}, {error, seqid: 3} as never)).toBe(true) + + expect(handler).not.toHaveBeenCalled() + expect(error).toHaveBeenCalledWith(expect.objectContaining({code: T.RPCGen.StatusCode.sccanceled})) + expect(mockDispatchWaitingAction).not.toHaveBeenCalled() + }) + + test('a call that changes the account on purpose still gets its reply', () => { + const {callback, reply} = startCall('keybase.1.login.login') + logOut() + + reply(undefined, undefined) + + expect(callback).toHaveBeenCalledWith(undefined, undefined) + expect(mockDispatchWaitingAction).toHaveBeenCalledWith('waiting-key', false, undefined) + }) + + test('a call started after the logout is answered normally', () => { + logOut() + const {callback, reply} = startCall('keybase.1.user.getUserBlocks') + + reply(undefined, []) + + expect(callback).toHaveBeenCalledWith(undefined, []) + }) +}) diff --git a/shared/engine/session.tsx b/shared/engine/session.tsx index 6ce67eb6c2a8..1c71b1e70c1a 100644 --- a/shared/engine/session.tsx +++ b/shared/engine/session.tsx @@ -7,6 +7,7 @@ import {printRPC} from '@/local-debug' import {rpcLog, type InvokeType} from './index.platform' import {RPCError} from '@/util/errors' import {getEngine} from './require' +import {getAccountGeneration, survivesAccountChange} from './account-generation' import type {SessionID, ResponseType, EndHandlerType, MethodKey, WaitingKey} from './types' // A session is a series of calls back and forth tied together with a single sessionID @@ -31,6 +32,8 @@ class Session { _startMethod: MethodKey | undefined // Start callback so we can cancel our own callback _startCallback: ((err?: RPCError, ...args: Array) => void) | undefined + // The account generation the session started in; undefined until start + _accountGeneration: number | undefined // Allow us to make calls _invoke: InvokeType @@ -62,6 +65,16 @@ class Session { return this._dangling } + // Started for an account that has since logged out. The logout reset already cleared its waiting + // count, so nothing it receives may touch waiting state or reach its handlers. + _belongsToPreviousAccount() { + return ( + this._accountGeneration !== undefined && + this._accountGeneration !== getAccountGeneration() && + !survivesAccountChange(this._startMethod ?? '') + ) + } + // Make a waiting handler for the request. We add additional data before calling the parent waitingHandler // and do internal bookkeeping if the request is done _makeWaitingHandler(method: MethodKey, seqid?: number) { @@ -110,6 +123,7 @@ class Session { start(method: MethodKey, param: object | undefined, callback: (() => void) | undefined) { this._startMethod = method this._startCallback = callback + this._accountGeneration = getAccountGeneration() // When this request is done the session is done const wrappedCallback = (err: RPCError | undefined, ...args: Array) => { @@ -135,6 +149,10 @@ class Session { const updateWaiting = this._makeWaitingHandler(method) updateWaiting(true) this._invoke(method, [wrappedParam], (err: unknown, data: unknown) => { + if (this._belongsToPreviousAccount()) { + wrappedCallback(new RPCError('The account changed during this call', StatusCode.sccanceled)) + return + } updateWaiting(false, err as RPCError | undefined) wrappedCallback(err as RPCError | undefined, data) }) @@ -168,6 +186,11 @@ class Session { return false } + if (this._belongsToPreviousAccount()) { + response?.error?.({code: StatusCode.sccanceled, desc: 'The account changed during this call'}) + return true + } + if (response?.seqid !== undefined) { this._seqIDsAwaitingResponse.add(response.seqid) } From 0ce63bce612e2f3afae47b2964469cd6f406aa8d Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 24 Sep 2026 12:35:20 -0400 Subject: [PATCH 19/28] fix: keep the previous account's screens from acting as the next account The previous account's screens outlive an account switch by a few renders, and calls they start then go out as the next account. Each now checks the account it belongs to: - contacts import does not upload an address book read for another account - a pending draft save does not save a draft typed by another account - mark-read does not mark a thread loaded for another account - a delayed route persist is dropped once the account changed, and stamps the account it was asked for --- .../input-area/input-state.test.tsx | 42 +++++++++++ .../conversation/input-area/normal/index.tsx | 6 ++ .../chat/conversation/thread-context.test.tsx | 42 +++++++++++ shared/chat/conversation/thread-context.tsx | 7 ++ shared/stores/settings-contacts.tsx | 15 ++++ .../tests/settings-contacts.mobile.test.ts | 75 +++++++++++++++++++ shared/util/storeless-actions.test.ts | 48 ++++++++++++ shared/util/storeless-actions.tsx | 12 ++- 8 files changed, 244 insertions(+), 3 deletions(-) create mode 100644 shared/stores/tests/settings-contacts.mobile.test.ts create mode 100644 shared/util/storeless-actions.test.ts diff --git a/shared/chat/conversation/input-area/input-state.test.tsx b/shared/chat/conversation/input-area/input-state.test.tsx index f61618eadeca..d2ae9410aeb1 100644 --- a/shared/chat/conversation/input-area/input-state.test.tsx +++ b/shared/chat/conversation/input-area/input-state.test.tsx @@ -962,3 +962,45 @@ test('a commandStatus written while the provider is frozen is applied on thaw', expect(inputState?.commandStatus).toEqual(commandStatusInfo) }) + +describe('a pending draft save', () => { + const typeThenWait = (switchAccount: boolean) => { + jest.useFakeTimers() + try { + const saveDraft = jest.spyOn(T.RPCChat, 'localUpdateUnsentTextRpcPromise').mockResolvedValue(undefined) + jest.spyOn(T.RPCChat, 'localUpdateTypingRpcPromise').mockResolvedValue(undefined) + renderComposer() + act(() => { + mockPlatformInputProps?.onChangeText('a') + }) + // inside the 200ms throttle, so this save waits for its trailing edge + act(() => { + mockPlatformInputProps?.onChangeText('ab') + }) + if (switchAccount) { + act(() => { + useCurrentUserState.getState().dispatch.setBootstrap({ + deviceID: 'device-id-2', + deviceName: 'test-device-2', + uid: 'uid-2', + username: 'testuser-mac', + }) + }) + } + act(() => { + jest.advanceTimersByTime(250) + }) + return saveDraft.mock.calls.map(c => c[0].text) + } finally { + jest.useRealTimers() + } + } + + test('is saved for the account that typed it', () => { + expect(typeThenWait(false)).toContain('ab') + }) + + test('is not saved for the next account when a switch lands first', () => { + expect(typeThenWait(true)).not.toContain('ab') + }) +}) diff --git a/shared/chat/conversation/input-area/normal/index.tsx b/shared/chat/conversation/input-area/normal/index.tsx index 71802d18ce9f..e20f8bb60f55 100644 --- a/shared/chat/conversation/input-area/normal/index.tsx +++ b/shared/chat/conversation/input-area/normal/index.tsx @@ -241,7 +241,13 @@ const ConnectedPlatformInput = function ConnectedPlatformInput() { // throttled draft-save path rather than from onChangeText, so the composer does not // re-render on every keystroke. The preview debounces another 500ms downstream anyway. const [previewText, setPreviewText] = React.useState('') + // The account this composer was mounted for. After an account switch the service saves drafts + // for the next account, so the unmount flush of a draft typed here must not save it there. + const [composerUid] = React.useState(() => useCurrentUserState.getState().uid) const updateDraftRaw = (text: string) => { + if (useCurrentUserState.getState().uid !== composerUid) { + return + } // Immediately update local meta.draft so switching back to this thread // before the async unbox completes won't re-inject the old stale draft. // Merges from the current meta (same inbox version), so force past gating. diff --git a/shared/chat/conversation/thread-context.test.tsx b/shared/chat/conversation/thread-context.test.tsx index abcf188d74e0..298c10643853 100644 --- a/shared/chat/conversation/thread-context.test.tsx +++ b/shared/chat/conversation/thread-context.test.tsx @@ -844,6 +844,48 @@ test('active change marks read after an eligible mounted thread load', async () }) }) +test('a thread still on screen after an account switch does not mark read for the next account', async () => { + useConfigState.setState({loggedIn: true}) + useShellState.getState().dispatch.setActive(false) + jest + .spyOn(Common, 'isUserActivelyLookingAtThisThread') + .mockImplementation(() => useShellState.getState().active) + const markAsRead = jest + .spyOn(T.RPCChat, 'localMarkAsReadLocalRpcPromise') + .mockResolvedValue({offline: false}) + jest.spyOn(T.RPCChat, 'localGetThreadNonblockRpcListener').mockImplementation(async p => { + p.incomingCallMap['chat.1.chatUi.chatThreadFull']?.({ + thread: JSON.stringify({ + messages: [makeValidTextUIMessage(T.Chat.numberToMessageID(603), 'loaded inactive')], + pagination: {last: true, next: '', num: 100, previous: ''}, + }), + }) + await Promise.resolve() + return {offline: false} + }) + const {result} = renderHook(() => useConversationThreadLoadMoreMessages(), {wrapper}) + act(() => { + result.current({reason: 'tab selected'}) + }) + await act(async () => { + await flushPromises() + }) + + act(() => { + useCurrentUserState.getState().dispatch.setBootstrap({ + deviceID: 'device-id-2', + deviceName: 'test-device-2', + uid: 'uid-2', + username: 'testuser-mac', + }) + useShellState.getState().dispatch.setActive(true) + }) + await act(async () => { + await flushPromises() + }) + expect(markAsRead).not.toHaveBeenCalled() +}) + test('active change does not mark read after a centered thread load', async () => { useConfigState.setState({loggedIn: true}) useShellState.getState().dispatch.setActive(false) diff --git a/shared/chat/conversation/thread-context.tsx b/shared/chat/conversation/thread-context.tsx index 0a7abbe52b08..6b766b867545 100644 --- a/shared/chat/conversation/thread-context.tsx +++ b/shared/chat/conversation/thread-context.tsx @@ -403,6 +403,9 @@ const ConversationThreadProviderInner = (p: ConversationThreadProviderProps) => const lookingAtThread = active && appFocused && routeFocused const previousLookingAtThreadRef = React.useRef(lookingAtThread) const activeMarkReadEnabledRef = React.useRef(false) + // The account this thread was loaded for. Its screen outlives an account switch by a few renders, + // and a mark-read sent then would mark the next account's read position. + const [threadUid] = React.useState(() => useCurrentUserState.getState().uid) const markReadBlockedRef = React.useRef(false) const getSnapshot = React.useEffectEvent(() => threadStore.getState()) @@ -422,6 +425,10 @@ const ConversationThreadProviderInner = (p: ConversationThreadProviderProps) => logger.info('mark read bail on not logged in') return } + if (useCurrentUserState.getState().uid !== threadUid) { + logger.info('mark read bail on thread loaded for another account') + return + } if (!T.Chat.isValidConversationIDKey(id)) { logger.info('mark read bail on no selected conversation') return diff --git a/shared/stores/settings-contacts.tsx b/shared/stores/settings-contacts.tsx index b1a871e2c09a..4b9a31091612 100644 --- a/shared/stores/settings-contacts.tsx +++ b/shared/stores/settings-contacts.tsx @@ -189,6 +189,11 @@ export const useSettingsContactsState = Z.createZustand('settings-contact }, manageContactsCache: () => { const f = async () => { + // The import setting read below is this account's. Reading the address book can take + // seconds, and the upload goes to whichever account is logged in when it runs, so an + // account switch in between must not upload these contacts to the next account. + const uidBefore = useCurrentUserState.getState().uid + const accountChanged = () => useCurrentUserState.getState().uid !== uidBefore if (get().importEnabled === false) { await T.RPCGen.contactsSaveContactListRpcPromise({contacts: []}) set(s => { @@ -238,11 +243,18 @@ export const useSettingsContactsState = Z.createZustand('settings-contact }) return } + if (accountChanged()) { + logger.info('account changed while reading contacts, not importing') + return + } logger.info(`Importing ${mapped.length} contacts.`) try { const {newlyResolved, resolved} = await T.RPCGen.contactsSaveContactListRpcPromise({ contacts: mapped, }) + if (accountChanged()) { + return + } logger.info(`Success`) set(s => { s.importedCount = mapped.length @@ -268,6 +280,9 @@ export const useSettingsContactsState = Z.createZustand('settings-contact } catch (_error) { const error = _error as {message: string} logger.error('Error saving contacts list: ', error.message) + if (accountChanged()) { + return + } set(s => { s.importedCount = undefined s.importError = error.message diff --git a/shared/stores/tests/settings-contacts.mobile.test.ts b/shared/stores/tests/settings-contacts.mobile.test.ts new file mode 100644 index 000000000000..aa9c3f4967c7 --- /dev/null +++ b/shared/stores/tests/settings-contacts.mobile.test.ts @@ -0,0 +1,75 @@ +/// +// The contacts store is a no-op on desktop, so load it as mobile. +import type * as ContactsStore from '../settings-contacts' +import type * as CurrentUser from '../current-user' +import type * as TT from '@/constants/types' + +// Jest maps every native-only package (expo-contacts, expo-localization, react-native-kb) to one +// stub, so this mock stands in for all three. +const mockGetAllDetails = jest.fn() +jest.mock('../../test/mocks/native-module', () => ({ + Contact: {getAllDetails: (...args: Array) => mockGetAllDetails(...args)}, + ContactField: {EMAILS: 'emails', FULL_NAME: 'fullName', PHONES: 'phones'}, + PermissionStatus: {GRANTED: 'granted'}, + addNotificationRequest: async () => Promise.resolve(), + getLocales: () => [{regionCode: 'US'}], + getPermissionsAsync: async () => Promise.resolve({status: 'granted'}), + requireNativeModule: () => ({}), + requireOptionalNativeModule: () => null, +})) + +const g = globalThis as {isMobile?: boolean} +let store: typeof ContactsStore +let currentUser: typeof CurrentUser +let T: typeof TT + +beforeEach(() => { + g.isMobile = true + jest.isolateModules(() => { + store = require('../settings-contacts') + currentUser = require('../current-user') + T = require('@/constants/types') + }) + currentUser.useCurrentUserState + .getState() + .dispatch.setBootstrap({deviceID: 'd', deviceName: 'dn', uid: 'uid-1', username: 'testuser'}) + store.useSettingsContactsState.setState({importEnabled: true, permissionStatus: 'granted'}) +}) + +afterEach(() => { + g.isMobile = false + jest.restoreAllMocks() +}) + +const flush = async () => { + for (let i = 0; i < 10; i++) await Promise.resolve() +} + +test('uploads the address book for the account that enabled import', async () => { + mockGetAllDetails.mockResolvedValue([{fullName: 'a', phoneNumbers: [{number: '+15555550100'}]}]) + const save = jest + .spyOn(T.RPCGen, 'contactsSaveContactListRpcPromise') + .mockResolvedValue({newlyResolved: [], resolved: []} as never) + + store.useSettingsContactsState.getState().dispatch.manageContactsCache() + await flush() + + expect(store.useSettingsContactsState.getState().importError).toBe('') + expect(save).toHaveBeenCalledTimes(1) +}) + +test('does not upload to the next account when a switch lands while the address book is read', async () => { + let finishReading: (c: unknown) => void = () => {} + mockGetAllDetails.mockImplementation(async () => new Promise(resolve => (finishReading = resolve))) + const save = jest.spyOn(T.RPCGen, 'contactsSaveContactListRpcPromise') + + store.useSettingsContactsState.getState().dispatch.manageContactsCache() + await flush() + currentUser.useCurrentUserState + .getState() + .dispatch.setBootstrap({deviceID: 'd2', deviceName: 'dn2', uid: 'uid-2', username: 'testuser-mac'}) + finishReading([{fullName: 'a', phoneNumbers: [{number: '+15555550100'}]}]) + await flush() + + expect(save).not.toHaveBeenCalled() +}) diff --git a/shared/util/storeless-actions.test.ts b/shared/util/storeless-actions.test.ts new file mode 100644 index 000000000000..de75327c1c37 --- /dev/null +++ b/shared/util/storeless-actions.test.ts @@ -0,0 +1,48 @@ +/// +import * as T from '@/constants/types' +import {useCurrentUserState} from '@/stores/current-user' +import {resetAllStores} from '@/util/zustand' +import {persistRoute} from './storeless-actions' + +// persistRoute skips a route it already saved, so every test shows a different conversation +let mockConversation = '' +jest.mock('@/constants/router', () => ({ + getTab: () => 'tabs.chatTab', + getVisiblePath: () => [{name: 'chatConversation', params: {conversationIDKey: mockConversation}}], +})) + +const g = globalThis as {isMobile?: boolean} +const setUser = (uid: string, username: string) => + useCurrentUserState.getState().dispatch.setBootstrap({deviceID: 'd', deviceName: 'dn', uid, username}) + +beforeEach(() => { + g.isMobile = true + jest.useFakeTimers() +}) + +afterEach(() => { + g.isMobile = false + jest.useRealTimers() + jest.restoreAllMocks() + resetAllStores() +}) + +const persistedAfterDelay = async (switchAccount: boolean) => { + mockConversation = `conv-${String(switchAccount)}` + const setValue = jest.spyOn(T.RPCGen, 'configGuiSetValueRpcPromise').mockResolvedValue(undefined) + setUser('uid-1', 'testuser') + persistRoute(false, false, () => true) + if (switchAccount) { + setUser('uid-2', 'testuser-mac') + } + await jest.advanceTimersByTimeAsync(1000) + return setValue.mock.calls.map(c => JSON.parse(c[0].value.s ?? '') as {uid: string}) +} + +test('persists the route on screen under the account it belongs to', async () => { + expect(await persistedAfterDelay(false)).toEqual([expect.objectContaining({uid: 'uid-1'})]) +}) + +test('drops a delayed persist when an account switch lands first', async () => { + expect(await persistedAfterDelay(true)).toEqual([]) +}) diff --git a/shared/util/storeless-actions.tsx b/shared/util/storeless-actions.tsx index 0b27e52d635e..22969856cf85 100644 --- a/shared/util/storeless-actions.tsx +++ b/shared/util/storeless-actions.tsx @@ -82,10 +82,17 @@ export const persistRoute = (clear: boolean, immediate: boolean, isStartupLoaded } catch {} } + // The route being persisted is the one on screen when this was asked for. Across an account switch + // the previous account's screens stay up briefly, so persisting them under the next account's uid + // would restore a conversation that is not that account's on the next launch. + const uidAtRequest = useCurrentUserState.getState().uid const doPersist = async () => { if (!isStartupLoaded()) { return } + if (useCurrentUserState.getState().uid !== uidAtRequest) { + return + } let param = {} let routeName = peopleTab const cur = getTab() @@ -101,11 +108,10 @@ export const persistRoute = (clear: boolean, immediate: boolean, isStartupLoaded } return false }) - // Stamp the persisted route with the current uid. ui.routeState2 is stored + // Stamp the persisted route with its account's uid. ui.routeState2 is stored // device-globally (not per-account), so on startup we must only restore a // conversation that belongs to the account we end up logged in as. - const {uid} = useCurrentUserState.getState() - const next = JSON.stringify({param, routeName, uid}) + const next = JSON.stringify({param, routeName, uid: uidAtRequest}) if (lastPersist === next) { return } From d89459b1c71387a679c42e60a50202e312861d9c Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 24 Sep 2026 12:37:38 -0400 Subject: [PATCH 20/28] fix(chat): save the draft typed just before leaving a conversation The composer flushed its throttled draft save from its own unmount cleanup, on the belief that cleanups run in reverse order. React runs them in declaration order, so the throttle hook's cancel ran first and the flush found nothing: a draft typed in the last 200ms before leaving was dropped. useThrottledCallback now takes flushOnUnmount, and the composer uses it. --- .../input-area/input-state.test.tsx | 40 +++++++++++++++++++ .../conversation/input-area/normal/index.tsx | 9 +---- shared/util/use-debounce.test.tsx | 28 +++++++++++++ shared/util/use-debounce.tsx | 17 ++++++-- 4 files changed, 84 insertions(+), 10 deletions(-) diff --git a/shared/chat/conversation/input-area/input-state.test.tsx b/shared/chat/conversation/input-area/input-state.test.tsx index d2ae9410aeb1..577575506065 100644 --- a/shared/chat/conversation/input-area/input-state.test.tsx +++ b/shared/chat/conversation/input-area/input-state.test.tsx @@ -1004,3 +1004,43 @@ describe('a pending draft save', () => { expect(typeThenWait(true)).not.toContain('ab') }) }) + +describe('a draft typed just before leaving the conversation', () => { + const typeThenUnmount = (switchAccount: boolean) => { + jest.useFakeTimers() + try { + const saveDraft = jest.spyOn(T.RPCChat, 'localUpdateUnsentTextRpcPromise').mockResolvedValue(undefined) + jest.spyOn(T.RPCChat, 'localUpdateTypingRpcPromise').mockResolvedValue(undefined) + const {unmount} = renderComposer() + act(() => { + mockPlatformInputProps?.onChangeText('a') + }) + // inside the 200ms throttle, so this save is still pending at unmount + act(() => { + mockPlatformInputProps?.onChangeText('ab') + }) + if (switchAccount) { + act(() => { + useCurrentUserState.getState().dispatch.setBootstrap({ + deviceID: 'device-id-2', + deviceName: 'test-device-2', + uid: 'uid-2', + username: 'testuser-mac', + }) + }) + } + unmount() + return saveDraft.mock.calls.map(c => c[0].text) + } finally { + jest.useRealTimers() + } + } + + test('is saved when the composer unmounts', () => { + expect(typeThenUnmount(false)).toContain('ab') + }) + + test('is not saved for the next account when the unmount comes from a switch', () => { + expect(typeThenUnmount(true)).not.toContain('ab') + }) +}) diff --git a/shared/chat/conversation/input-area/normal/index.tsx b/shared/chat/conversation/input-area/normal/index.tsx index e20f8bb60f55..e59eaa69eaf0 100644 --- a/shared/chat/conversation/input-area/normal/index.tsx +++ b/shared/chat/conversation/input-area/normal/index.tsx @@ -265,13 +265,8 @@ const ConnectedPlatformInput = function ConnectedPlatformInput() { } C.ignorePromise(f()) } - const updateDraft = C.useThrottledCallback(updateDraftRaw, 200, {trailing: true}) - // Flush any pending draft save before cancel fires on unmount (hooks cleanup runs in reverse order) - React.useLayoutEffect(() => { - return () => { - updateDraft.flush() - } - }, [updateDraft]) + // flushOnUnmount: leaving the conversation must still save what was typed in the last 200ms + const updateDraft = C.useThrottledCallback(updateDraftRaw, 200, {flushOnUnmount: true, trailing: true}) const textValueRef = React.useRef('') const onChangeText = (text: string) => { diff --git a/shared/util/use-debounce.test.tsx b/shared/util/use-debounce.test.tsx index 1b9d85c349d6..a7bd185fe96d 100644 --- a/shared/util/use-debounce.test.tsx +++ b/shared/util/use-debounce.test.tsx @@ -287,3 +287,31 @@ test('useThrottledCallback collapses repeated calls within the wait window to th expect(callback).toHaveBeenCalledTimes(2) expect(callback).toHaveBeenNthCalledWith(2, 'gamma') }) + +test('useThrottledCallback drops a pending trailing call on unmount by default', () => { + const callback = jest.fn((value: string) => value) + const {result, unmount} = renderHook(() => useThrottledCallback(callback, 100)) + act(() => { + result.current('alpha') + result.current('beta') + }) + + unmount() + advance(100) + + expect(callback.mock.calls).toEqual([['alpha']]) +}) + +test('useThrottledCallback runs a pending trailing call on unmount with flushOnUnmount', () => { + const callback = jest.fn((value: string) => value) + const {result, unmount} = renderHook(() => useThrottledCallback(callback, 100, {flushOnUnmount: true})) + act(() => { + result.current('alpha') + result.current('beta') + }) + + unmount() + advance(100) + + expect(callback.mock.calls).toEqual([['alpha'], ['beta']]) +}) diff --git a/shared/util/use-debounce.tsx b/shared/util/use-debounce.tsx index 669b31e872ae..4fcd2e4722c0 100644 --- a/shared/util/use-debounce.tsx +++ b/shared/util/use-debounce.tsx @@ -22,6 +22,12 @@ type DebounceOptions = { trailing?: boolean } +type ThrottleOptions = DebounceOptions & { + // Run a pending trailing call on unmount instead of dropping it. A component can't do this in its + // own cleanup: React runs cleanups in declaration order, so this hook's cancel would run first. + flushOnUnmount?: boolean +} + const normalizeWait = (wait?: number) => Math.max(0, wait ?? 0) export function useDebouncedCallback( @@ -149,7 +155,7 @@ export function useDebouncedCallback( export function useThrottledCallback( func: T, wait: number, - options?: DebounceOptions + options?: ThrottleOptions ): DebouncedState { const funcRef = React.useRef(func) React.useLayoutEffect(() => { @@ -165,6 +171,7 @@ export function useThrottledCallback( const waitMs = normalizeWait(wait) const leading = options?.leading ?? true const trailing = options?.trailing ?? true + const flushOnUnmount = options?.flushOnUnmount ?? false const throttled = React.useMemo(() => { const clearTimer = () => { @@ -250,9 +257,13 @@ export function useThrottledCallback( React.useLayoutEffect(() => { runtimeRef.current = {} return () => { - throttled.cancel() + if (flushOnUnmount) { + throttled.flush() + } else { + throttled.cancel() + } } - }, [throttled]) + }, [throttled, flushOnUnmount]) return throttled } From eb62e88e7c090d1f1b1987abf1ba780fe2ea92af Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 24 Sep 2026 12:46:36 -0400 Subject: [PATCH 21/28] fix(engine): keep waiting counts balanced across a logout, refuse only account calls - The logout reset cleared waiting counts for calls still in flight, so each one that ended afterwards (the logout itself, a listener call, a refused reply) sent its count negative, and the next account's spinners never showed. Counts now survive the reset and every call releases its own. - The account generation goes up in setLoggedIn before anything reacts to the logout, so calls the logout starts are the new generation's. - Process-wide calls are exempt too: UI and notification registration, gui config values, waitForClient, logSend, appendGUILogs, helloIAm. - Contacts: the address-book read error no longer lands in the next account's store; the post-upload check the engine guard made redundant is gone. --- shared/engine/account-generation.tsx | 24 ++++++++++++++++------- shared/engine/session.test.tsx | 14 +++++++++---- shared/engine/session.tsx | 4 ++-- shared/provision/waiting-overlay.test.tsx | 2 ++ shared/stores/config.tsx | 8 ++++++++ shared/stores/settings-contacts.tsx | 7 ++++--- shared/stores/tests/config.test.ts | 18 +++++++++++++++++ shared/stores/tests/waiting.test.ts | 14 +++++++++++++ shared/stores/waiting.tsx | 9 ++++++++- shared/util/storeless-actions.tsx | 6 +++--- 10 files changed, 86 insertions(+), 20 deletions(-) diff --git a/shared/engine/account-generation.tsx b/shared/engine/account-generation.tsx index a60ba3cf01db..3afef3c7b65b 100644 --- a/shared/engine/account-generation.tsx +++ b/shared/engine/account-generation.tsx @@ -1,21 +1,28 @@ -import {registerExternalResetter} from '@/util/zustand' import type {MethodKey} from './types' -// Goes up with every logout reset: every logout, and the old account's side of an account switch. +// Goes up when the session logs out: every logout, and the old account's side of an account switch. // A call started before it belongs to an account that is gone, so its reply and any prompts the // service sends on it are refused (see Session) instead of landing in the next account's stores. let accountGeneration = 0 export const getAccountGeneration = () => accountGeneration -registerExternalResetter('engine-account-generation', () => { +// Called before anything reacts to the logout, so calls the logout itself starts are the new +// generation's. +export const startNewAccountGeneration = () => { accountGeneration++ -}) +} -// Calls that change the logged-in account on purpose, so their replies arrive after the reset they -// cause, and calls whose answer belongs to the process rather than an account. +// Calls that change the logged-in account on purpose, so their replies arrive after the logout +// they cause, and calls whose answer belongs to the process rather than an account. const spansAccountChange: ReadonlySet = new Set([ 'keybase.1.account.cancelReset', 'keybase.1.account.enterResetPipeline', + 'keybase.1.config.appendGUILogs', 'keybase.1.config.getBootstrapStatus', + 'keybase.1.config.guiGetValue', + 'keybase.1.config.guiSetValue', + 'keybase.1.config.helloIAm', + 'keybase.1.config.logSend', + 'keybase.1.config.waitForClient', 'keybase.1.login.accountDelete', 'keybase.1.login.deprovision', 'keybase.1.login.getConfiguredAccounts', @@ -24,4 +31,7 @@ const spansAccountChange: ReadonlySet = new Set([ 'keybase.1.login.recoverPassphrase', 'keybase.1.signup.signup', ]) -export const survivesAccountChange = (method: MethodKey) => spansAccountChange.has(method) +// Registering UIs and notification channels is per connection, not per account. +const processWidePrefixes = ['keybase.1.delegateUiCtl.', 'keybase.1.notifyCtl.'] +export const survivesAccountChange = (method: MethodKey) => + spansAccountChange.has(method) || processWidePrefixes.some(p => method.startsWith(p)) diff --git a/shared/engine/session.test.tsx b/shared/engine/session.test.tsx index 5f133bfe3578..8b74f063c358 100644 --- a/shared/engine/session.test.tsx +++ b/shared/engine/session.test.tsx @@ -2,7 +2,7 @@ import Session from './session' import {RPCError} from '@/util/errors' import * as T from '@/constants/types' -import {resetAllStores} from '@/util/zustand' +import {startNewAccountGeneration, survivesAccountChange} from './account-generation' const mockDispatchWaitingAction = jest.fn() jest.mock('./require', () => ({ @@ -85,10 +85,10 @@ describe('a call that outlives its account', () => { return {callback, reply, session} } const logOut = () => { - resetAllStores() + startNewAccountGeneration() } - test('its reply is refused after a logout, without touching the waiting count', () => { + test('its reply is refused after a logout, and still releases its waiting count', () => { const {callback, reply} = startCall('keybase.1.user.getUserBlocks') logOut() @@ -99,7 +99,7 @@ describe('a call that outlives its account', () => { expect(err).toBeInstanceOf(RPCError) expect(err.code).toBe(T.RPCGen.StatusCode.sccanceled) expect(data).toBeUndefined() - expect(mockDispatchWaitingAction).not.toHaveBeenCalled() + expect(mockDispatchWaitingAction).toHaveBeenCalledWith('waiting-key', false, undefined) }) test('a prompt the service sends on it is answered with an error, not handed to its handler', () => { @@ -126,6 +126,12 @@ describe('a call that outlives its account', () => { expect(mockDispatchWaitingAction).toHaveBeenCalledWith('waiting-key', false, undefined) }) + test('registering with the service outlives an account', () => { + expect(survivesAccountChange('keybase.1.delegateUiCtl.registerChatUI')).toBe(true) + expect(survivesAccountChange('keybase.1.notifyCtl.setNotifications')).toBe(true) + expect(survivesAccountChange('keybase.1.user.getUserBlocks')).toBe(false) + }) + test('a call started after the logout is answered normally', () => { logOut() const {callback, reply} = startCall('keybase.1.user.getUserBlocks') diff --git a/shared/engine/session.tsx b/shared/engine/session.tsx index 1c71b1e70c1a..4b3a0a752f5c 100644 --- a/shared/engine/session.tsx +++ b/shared/engine/session.tsx @@ -65,8 +65,7 @@ class Session { return this._dangling } - // Started for an account that has since logged out. The logout reset already cleared its waiting - // count, so nothing it receives may touch waiting state or reach its handlers. + // Started for an account that has since logged out, so nothing it receives may reach its handlers. _belongsToPreviousAccount() { return ( this._accountGeneration !== undefined && @@ -150,6 +149,7 @@ class Session { updateWaiting(true) this._invoke(method, [wrappedParam], (err: unknown, data: unknown) => { if (this._belongsToPreviousAccount()) { + updateWaiting(false) wrappedCallback(new RPCError('The account changed during this call', StatusCode.sccanceled)) return } diff --git a/shared/provision/waiting-overlay.test.tsx b/shared/provision/waiting-overlay.test.tsx index 798816d3d497..e03f96851427 100644 --- a/shared/provision/waiting-overlay.test.tsx +++ b/shared/provision/waiting-overlay.test.tsx @@ -64,6 +64,8 @@ describe('ProvisionWaitingOverlay', () => { mockAddListener.mockReset() mockPauseProvision.mockReset() mockNavigateUp.mockReset() + // a logout keeps in-flight waiting counts, and some tests end mid-wait + useWaitingState.getState().dispatch.clear(waitingKeyProvision) resetAllStores() }) diff --git a/shared/stores/config.tsx b/shared/stores/config.tsx index 48e6d1e82f2a..08a495fb509a 100644 --- a/shared/stores/config.tsx +++ b/shared/stores/config.tsx @@ -17,6 +17,7 @@ import { niceError, } from "@/util/errors"; import { type CommonResponseHandler } from "@/engine/types"; +import { startNewAccountGeneration } from "@/engine/account-generation"; import { invalidPasswordErrorString } from "@/constants/config"; import { navigateAppendOnceRootHas } from "@/constants/router"; import { onEngineConnected as onEngineConnectedInPlatform } from "@/util/storeless-actions"; @@ -591,6 +592,9 @@ export const useConfigState = Z.createZustand("config", (set, get) => { }, setLoggedIn: (loggedIn) => { const changed = get().loggedIn !== loggedIn; + if (changed && !loggedIn) { + startNewAccountGeneration(); + } set((s) => { s.loggedIn = loggedIn; }); @@ -632,6 +636,10 @@ export const useConfigState = Z.createZustand("config", (set, get) => { // Read before the reset below, which clears loggedIn const fromLoggedIn = sw && get().loggedIn; if (sw && !get().userSwitching) { + // The reset logs the old account out of our stores without going through setLoggedIn + if (fromLoggedIn) { + startNewAccountGeneration(); + } Z.resetAllStores(); if (hasEngine()) { getEngine().cancelOutstandingSessions(); diff --git a/shared/stores/settings-contacts.tsx b/shared/stores/settings-contacts.tsx index 4b9a31091612..742facd563b7 100644 --- a/shared/stores/settings-contacts.tsx +++ b/shared/stores/settings-contacts.tsx @@ -237,6 +237,9 @@ export const useSettingsContactsState = Z.createZustand('settings-contact } catch (_error) { const error = _error as {message: string} logger.error(`error loading contacts: ${error.message}`) + if (accountChanged()) { + return + } set(s => { s.importedCount = undefined s.importError = error.message @@ -252,9 +255,6 @@ export const useSettingsContactsState = Z.createZustand('settings-contact const {newlyResolved, resolved} = await T.RPCGen.contactsSaveContactListRpcPromise({ contacts: mapped, }) - if (accountChanged()) { - return - } logger.info(`Success`) set(s => { s.importedCount = mapped.length @@ -280,6 +280,7 @@ export const useSettingsContactsState = Z.createZustand('settings-contact } catch (_error) { const error = _error as {message: string} logger.error('Error saving contacts list: ', error.message) + // includes the engine refusing the reply because a switch landed during the upload if (accountChanged()) { return } diff --git a/shared/stores/tests/config.test.ts b/shared/stores/tests/config.test.ts index 8ddba8efe83e..ab6ae54c5d92 100644 --- a/shared/stores/tests/config.test.ts +++ b/shared/stores/tests/config.test.ts @@ -8,6 +8,7 @@ import * as T from '../../constants/types' import * as Tabs from '../../constants/tabs' import {navigateAppendOnceRootHas} from '../../constants/router' import {RPCError} from '../../util/errors' +import {getAccountGeneration} from '../../engine/account-generation' import {useDaemonState} from '../daemon' import {noConversationIDKey} from '../../constants/types/chat/common' import {useConfigState} from '../config' @@ -363,3 +364,20 @@ test('switchToAccount starts a switch to its target and logs in, and refuses whi loginSpy.mockRestore() dispatch.setUserSwitching(false) }) + +test('a logout starts a new account generation before anything reacts to it', () => { + const {dispatch} = useConfigState.getState() + dispatch.setLoggedIn(true) + const before = getAccountGeneration() + let seenByReaction: number | undefined + const unsub = useConfigState.subscribe((s, old) => { + if (old.loggedIn && !s.loggedIn) { + seenByReaction = getAccountGeneration() + } + }) + + dispatch.setLoggedIn(false) + unsub() + + expect(seenByReaction).toBe(before + 1) +}) diff --git a/shared/stores/tests/waiting.test.ts b/shared/stores/tests/waiting.test.ts index 3a16967aeb3f..18e27e7a9d4c 100644 --- a/shared/stores/tests/waiting.test.ts +++ b/shared/stores/tests/waiting.test.ts @@ -36,3 +36,17 @@ test('batch applies a mixed waiting update set', () => { expect((useWaitingState.getState().counts.get('b') ?? 0) > 0).toBe(true) expect((useWaitingState.getState().counts.get('c') ?? 0) > 0).toBe(true) }) + +test('a logout keeps in-flight counts so the calls that end afterwards bring them back to zero', () => { + const {dispatch} = useWaitingState.getState() + const error = new RPCError('boom', 7) + dispatch.increment('load') + dispatch.decrement('other', error) + + resetAllStores() + + expect(useWaitingState.getState().errors.get('other')).toBeUndefined() + expect(useWaitingState.getState().counts.get('load')).toBe(1) + dispatch.decrement('load') + expect(useWaitingState.getState().counts.get('load')).toBeUndefined() +}) diff --git a/shared/stores/waiting.tsx b/shared/stores/waiting.tsx index e1bfe8efdf89..fb0aeeb71e25 100644 --- a/shared/stores/waiting.tsx +++ b/shared/stores/waiting.tsx @@ -73,7 +73,14 @@ export const useWaitingState = Z.createZustand('waiting', (set, get) => { increment: keys => { changeHelper(keys, 1) }, - resetState: Z.defaultReset, + // Counts track calls still in flight, and every one of those decrements its count when it + // settles, so a logout keeps them: clearing them would send the count negative when those calls + // end. Errors belong to the account's screens and go. + resetState: () => { + set(s => { + s.errors.clear() + }) + }, } return { diff --git a/shared/util/storeless-actions.tsx b/shared/util/storeless-actions.tsx index 22969856cf85..aa4056bd480a 100644 --- a/shared/util/storeless-actions.tsx +++ b/shared/util/storeless-actions.tsx @@ -82,9 +82,9 @@ export const persistRoute = (clear: boolean, immediate: boolean, isStartupLoaded } catch {} } - // The route being persisted is the one on screen when this was asked for. Across an account switch - // the previous account's screens stay up briefly, so persisting them under the next account's uid - // would restore a conversation that is not that account's on the next launch. + // The account this persist was asked for. Across an account switch the previous account's screens + // stay up briefly, so a delayed persist that runs after the switch would save their route under + // the next account's uid and restore a conversation that is not that account's on the next launch. const uidAtRequest = useCurrentUserState.getState().uid const doPersist = async () => { if (!isStartupLoaded()) { From 501d2ba7ba8f4b88ccdaeef211e6322c2f23d92f Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 24 Sep 2026 16:32:56 -0400 Subject: [PATCH 22/28] test(chat): name the switch target in master's unbox-abandon test setUserSwitching(true) now takes the account the switch is logging into. --- shared/chat/inbox/metadata.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/chat/inbox/metadata.test.tsx b/shared/chat/inbox/metadata.test.tsx index 69af67da1ea4..9bca7c03915a 100644 --- a/shared/chat/inbox/metadata.test.tsx +++ b/shared/chat/inbox/metadata.test.tsx @@ -330,7 +330,7 @@ test('setUserSwitching abandons further unbox until switch completes', async () await flushPromises() expect(T.RPCChat.localRequestInboxUnboxRpcPromise).toHaveBeenCalledTimes(1) - useConfigState.getState().dispatch.setUserSwitching(true) + useConfigState.getState().dispatch.setUserSwitching(true, 'testuser') resolvers[0]?.() await flushPromises() From a67fbf9b55fa5c6756058a00dad9ea68c88716fc Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 25 Sep 2026 11:39:00 -0400 Subject: [PATCH 23/28] fix(config): mark the switch before its store reset so the reset isn't read as a logout setUserSwitching(true) reset every store before setting userSwitching, so for a moment loggedIn was false with no switch running. The tapped-push switcher read that as a logout and dropped the tap that started the switch. --- shared/router-v2/account-link-switch.test.ts | 9 +++++++++ shared/stores/config.tsx | 15 +++++++++------ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/shared/router-v2/account-link-switch.test.ts b/shared/router-v2/account-link-switch.test.ts index 95df1a0b3e43..6c96324acb06 100644 --- a/shared/router-v2/account-link-switch.test.ts +++ b/shared/router-v2/account-link-switch.test.ts @@ -81,6 +81,15 @@ test('a tap for a stored account switches to it once', () => { expect(login).toHaveBeenCalledTimes(1) }) +// Starting the switch resets the stores, loggedIn with them; that is not a logout to drop the tap for. +test('the store reset a switch starts with keeps the tap it is for', () => { + tapFor(otherAccount.uid) + + expect(useConfigState.getState().loggedIn).toBe(false) + expect(mockAckPushTap).not.toHaveBeenCalled() + expect(useNavigationIntentsState.getState().intent?.targetUid).toBe(otherAccount.uid) +}) + test('a tap for an account not listed yet waits for the account list', () => { setAccounts([currentAccount]) tapFor(otherAccount.uid) diff --git a/shared/stores/config.tsx b/shared/stores/config.tsx index 08a495fb509a..6993140bd24a 100644 --- a/shared/stores/config.tsx +++ b/shared/stores/config.tsx @@ -635,7 +635,15 @@ export const useConfigState = Z.createZustand("config", (set, get) => { const [sw, to] = args; // Read before the reset below, which clears loggedIn const fromLoggedIn = sw && get().loggedIn; - if (sw && !get().userSwitching) { + const starting = sw && !get().userSwitching; + // Set before the reset, which keeps these: a subscriber that sees loggedIn go false must + // already see the switch, or it reads the reset as a logout. + set((s) => { + s.userSwitching = sw; + s.userSwitchingFromLoggedIn = fromLoggedIn; + s.userSwitchingTo = sw ? to : ""; + }); + if (starting) { // The reset logs the old account out of our stores without going through setLoggedIn if (fromLoggedIn) { startNewAccountGeneration(); @@ -645,11 +653,6 @@ export const useConfigState = Z.createZustand("config", (set, get) => { getEngine().cancelOutstandingSessions(); } } - set((s) => { - s.userSwitching = sw; - s.userSwitchingFromLoggedIn = fromLoggedIn; - s.userSwitchingTo = sw ? to : ""; - }); }, switchToAccount: (username) => { if (get().userSwitching) return false; From e9bb2b50a7d7ef407c80e65131740cf9ca259818 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 25 Sep 2026 11:44:11 -0400 Subject: [PATCH 24/28] fix(config): end an account switch only when its navigator lands The bootstrap handler also ended a switch once the target's status came back, before the replacement navigator existed, so everything gated on userSwitching reopened under the old navigator. endUserSwitchLandedOn is now the one end. The inbox RPCs refuse to run while a switch is under way, so the inbox hook now waits for the switch to end too, and its first load fires again then. --- shared/chat/inbox/use-inbox-state.test.ts | 19 ++++++++++++++++++- shared/chat/inbox/use-inbox-state.tsx | 20 +++++++++++--------- shared/constants/init/shared.test.ts | 12 ++++++++++++ shared/constants/init/shared.tsx | 4 ---- 4 files changed, 41 insertions(+), 14 deletions(-) diff --git a/shared/chat/inbox/use-inbox-state.test.ts b/shared/chat/inbox/use-inbox-state.test.ts index 75fb6bf366fa..da6315adb600 100644 --- a/shared/chat/inbox/use-inbox-state.test.ts +++ b/shared/chat/inbox/use-inbox-state.test.ts @@ -12,6 +12,7 @@ type MockInboxLayoutState = { } let mockInboxLayoutState: MockInboxLayoutState +let mockConfigState = {loggedIn: true, userSwitching: false} jest.mock('@/constants', () => { const React = require('react') @@ -60,7 +61,7 @@ jest.mock('./metadata', () => ({ })) jest.mock('@/stores/config', () => ({ - useConfigState: (selector: (state: {loggedIn: boolean}) => T) => selector({loggedIn: true}), + useConfigState: (selector: (state: typeof mockConfigState) => T) => selector(mockConfigState), })) jest.mock('@/stores/current-user', () => ({ @@ -85,6 +86,7 @@ let mockSetInboxRetriedOnCurrentEmpty: jest.Mock beforeEach(() => { mockLoadInboxNumSmallRows = jest.fn() mockInboxRefresh = jest.fn() + mockConfigState = {loggedIn: true, userSwitching: false} mockSetInboxRetriedOnCurrentEmpty = jest.fn() mockInboxLayoutState = { dispatch: { @@ -140,3 +142,18 @@ test('useInboxState updates inbox row count without persisting when persist is f expect(result.current.inboxNumSmallRows).toBe(7) expect(T.RPCGen.configGuiSetValueRpcPromise).not.toHaveBeenCalled() }) + +// The inbox RPCs refuse to run while a switch is under way, so the first load has to wait for it. +test('useInboxState loads the inbox once an account switch ends', () => { + mockInboxRefresh.mockReturnValue(Promise.resolve()) + mockInboxLayoutState.hasLoaded = false + mockConfigState = {loggedIn: true, userSwitching: true} + const {rerender} = renderHook(() => useInboxState()) + // what fired on mount ran into the switch and was refused + mockInboxRefresh.mockClear() + + mockConfigState = {loggedIn: true, userSwitching: false} + rerender() + + expect(mockInboxRefresh).toHaveBeenCalledWith('componentNeverLoaded') +}) diff --git a/shared/chat/inbox/use-inbox-state.tsx b/shared/chat/inbox/use-inbox-state.tsx index 83d55f37e629..a4065f3e7abf 100644 --- a/shared/chat/inbox/use-inbox-state.tsx +++ b/shared/chat/inbox/use-inbox-state.tsx @@ -62,7 +62,9 @@ export function useInboxState( refreshInbox?: T.Chat.ChatRootInboxRefresh ) { const isFocused = useIsFocused() - const loggedIn = useConfigState(s => s.loggedIn) + // Matches isChatSessionReady, which gates the inbox RPCs: loads skipped while a switch runs have + // to fire again once it ends. + const sessionReady = useConfigState(s => s.loggedIn && !s.userSwitching) const username = useCurrentUserState(s => s.username) const loadInboxNumSmallRows = C.useRPC(T.RPCGen.configGuiGetValueRpcPromise) @@ -130,14 +132,14 @@ export function useInboxState( }) React.useEffect(() => { - const ready = loggedIn && !!username && (!isMobile || isFocused) + const ready = sessionReady && !!username && (!isMobile || isFocused) if (!ready || !refreshInbox || handledRefreshNonceRef.current === refreshInbox.nonce) { return } handledRefreshNonceRef.current = refreshInbox.nonce C.ignorePromise(inboxRefresh(refreshInbox.reason)) C.Router2.setChatRootParams({refreshInbox: undefined}) - }, [inboxRefresh, isFocused, loggedIn, refreshInbox, username]) + }, [inboxRefresh, isFocused, sessionReady, refreshInbox, username]) C.Router2.useSafeFocusEffect( React.useCallback(() => { @@ -148,15 +150,15 @@ export function useInboxState( ) React.useEffect(() => { - const ready = loggedIn && !!username + const ready = sessionReady && !!username const shouldRetry = !inboxHasLoaded && ready && (!isMobile || isFocused) if (shouldRetry) { C.ignorePromise(inboxRefresh('componentNeverLoaded')) } - }, [inboxHasLoaded, inboxRefresh, isFocused, loggedIn, username]) + }, [inboxHasLoaded, inboxRefresh, isFocused, sessionReady, username]) React.useEffect(() => { - const ready = loggedIn && !!username + const ready = sessionReady && !!username if (!ready) { return } @@ -200,10 +202,10 @@ export function useInboxState( inboxNumSmallRowsLoadVersionRef.current++ } } - }, [inboxNumSmallRowsLoaded, loadInboxNumSmallRows, loggedIn, username]) + }, [inboxNumSmallRowsLoaded, loadInboxNumSmallRows, sessionReady, username]) React.useEffect(() => { - const ready = loggedIn && !!username && (!isMobile || isFocused) + const ready = sessionReady && !!username && (!isMobile || isFocused) if (!ready || isSearching || !inboxHasLoaded || inboxRows.length > 0 || inboxRetriedOnCurrentEmpty) { return } @@ -216,7 +218,7 @@ export function useInboxState( inboxRows.length, isFocused, isSearching, - loggedIn, + sessionReady, setRetriedOnCurrentEmpty, username, ]) diff --git a/shared/constants/init/shared.test.ts b/shared/constants/init/shared.test.ts index 61b78f52c071..6ccec841f38b 100644 --- a/shared/constants/init/shared.test.ts +++ b/shared/constants/init/shared.test.ts @@ -272,6 +272,18 @@ describe('the session comes from the daemon; notifications only say to read it', expect(useConfigState.getState().userSwitching).toBe(true) }) + // The navigator for the new account ends the switch (endUserSwitchLandedOn), not its bootstrap. + test("the switch's target logging in leaves the switch for its navigator to end", async () => { + await readReplying(userA) + useConfigState.getState().dispatch.setUserSwitching(true, 'testuser2') + + await readReplying(userB) + + expect(useConfigState.getState().loggedIn).toBe(true) + expect(useCurrentUserState.getState().username).toBe('testuser2') + expect(useConfigState.getState().userSwitching).toBe(true) + }) + test('a switch whose login fails ends logged out, no longer switching', async () => { await readReplying(userA) useConfigState.getState().dispatch.setUserSwitching(true, 'testuser') diff --git a/shared/constants/init/shared.tsx b/shared/constants/init/shared.tsx index bb82d9078529..f4ba4384b7e1 100644 --- a/shared/constants/init/shared.tsx +++ b/shared/constants/init/shared.tsx @@ -301,10 +301,6 @@ const onBootstrapStatusChanged = ( } configDispatch.setLoggedIn(loggedIn); - if (loggedIn && username && username === intendedUsername) { - configDispatch.setUserSwitching(false); - } - if (bootstrap.httpSrvInfo) { configDispatch.setHTTPSrvInfo( bootstrap.httpSrvInfo.address, From cdb8e81c0711903372081a7f593ccbadc6768915 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 25 Sep 2026 15:56:50 -0400 Subject: [PATCH 25/28] docs(claude): require lint:all and test:unit to pass before review or push --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index eeb1a19acd4b..a9a8e7a3cab1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,4 +30,4 @@ Repo root is `client/`. TS source lives in `shared/`. Always use absolute paths ## Validation After TS changes (from `shared/`): `yarn lint:all` (= `yarn lint` && `yarn lint:bailouts` && `yarn tsc`). Plain `yarn lint` is eslint only and does NOT catch react-compiler bailouts — no compiler rule is wired into `eslint.config.mjs`, so bailouts only surface via `lint:bailouts`. `lint:bailouts` also flags components the compiler cannot name (an `isMobile ? arrow : arrow` ternary is never compiled at all, so nothing in it is memoized — name both branches instead), and memo scopes keyed on the whole props object (a `props.x` read inside a callback, or a destructure below one, makes the compiler key on `props` itself, so the cache never hits — read every prop through one destructure at the top, above every callback). Repo baseline is 0 bailouts and 0 whole-props deps; keep it there. When debugging visually, skip until fix is confirmed. Never delete the ESLint cache. -Before reporting any TS change complete: run `yarn lint:all` and get it clean. Do NOT run `/code-review` while iterating, building, testing, or debugging — only once the change is about to be pushed (commit for a PR, push, or open a PR). At that point, if the diff has real logic in it, run `/code-review high` against the diff and fix what it finds; if a finding is wrong, say why instead of applying it. Skip the review for trivial diffs (a config/JSON line, a codegen resync, a typo) and say you skipped it. +Before reporting any TS change complete: run `yarn lint:all` and get it clean. Do NOT run `/code-review` while iterating, building, testing, or debugging — only once the change is about to be pushed (commit for a PR, push, or open a PR). At that point, first get both `yarn lint:all` and `yarn test:unit` passing — never review, push, or open a PR with either failing. Then, if the diff has real logic in it, run `/code-review high` against the diff and fix what it finds; if a finding is wrong, say why instead of applying it. Skip the review for trivial diffs (a config/JSON line, a codegen resync, a typo) and say you skipped it. From 10fbe2d9ac2f8c7f0b40c1b9ae0910194636a842 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 25 Sep 2026 15:56:50 -0400 Subject: [PATCH 26/28] fix(init): read the held user before overwriting it with the new bootstrap #29647 set the current user at the top of onBootstrapStatusChanged, so the 'session is another user' check compared the new uid against itself and never logged the previous account out. The switch-start store reset also means loggedIn is already false mid-switch; the switch tests now expect that. --- shared/constants/init/shared.test.ts | 8 +++++--- shared/constants/init/shared.tsx | 3 --- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/shared/constants/init/shared.test.ts b/shared/constants/init/shared.test.ts index 6ccec841f38b..29f34da0d3a4 100644 --- a/shared/constants/init/shared.test.ts +++ b/shared/constants/init/shared.test.ts @@ -257,16 +257,18 @@ describe('the session comes from the daemon; notifications only say to read it', test('during an account switch a logged-out reply is ignored, and the new user still replaces the old', async () => { await readReplying(userA) markAccountState() + // Starting the switch resets every store, which logs the old account out of them useConfigState.getState().dispatch.setUserSwitching(true, 'testuser') + expect(useConfigState.getState().loggedIn).toBe(false) const {changes, unsub} = loginChanges() await readReplying(loggedOut) - expect(useConfigState.getState().loggedIn).toBe(true) + expect(useConfigState.getState().userSwitching).toBe(true) await readReplying(userB) unsub() - expect(changes).toEqual([false, true]) + expect(changes).toEqual([true]) expect(accountStateCleared()).toBe(true) expect(useCurrentUserState.getState().username).toBe('testuser2') expect(useConfigState.getState().userSwitching).toBe(true) @@ -303,7 +305,7 @@ describe('the session comes from the daemon; notifications only say to read it', await readReplying(userA) useConfigState.getState().dispatch.setUserSwitching(true, 'testuser') await readReplying(loggedOut) - expect(useConfigState.getState().loggedIn).toBe(true) + expect(useConfigState.getState().userSwitching).toBe(true) jest.spyOn(T.RPCGen, 'loginLoginRpcListener').mockRejectedValue(error) useConfigState.getState().dispatch.login('testuser2', 'password') diff --git a/shared/constants/init/shared.tsx b/shared/constants/init/shared.tsx index f4ba4384b7e1..380061007325 100644 --- a/shared/constants/init/shared.tsx +++ b/shared/constants/init/shared.tsx @@ -256,9 +256,6 @@ const onBootstrapStatusChanged = ( } const { deviceID, deviceName, loggedIn, uid, username } = bootstrap; - useCurrentUserState - .getState() - .dispatch.setBootstrap({ deviceID, deviceName, uid, username }); const { dispatch: configDispatch, From 1a1050cef1639ec4ebb43952def3e8229a0dd521 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 25 Sep 2026 15:56:50 -0400 Subject: [PATCH 27/28] test: give chat and router tests the session the switch gates now need #29647 gated thread and inbox RPCs on a logged-in session with a current user, and resets every store when a switch starts. Tests now set loggedIn/the user, and replay the switched-to account logging back in before the switch ends. --- shared/chat/conversation/thread-context.test.tsx | 1 + .../conversation/thread-load-status-context.test.tsx | 2 ++ shared/chat/inbox/engine.test.tsx | 7 +++++++ shared/router-v2/intent-consumption.test.ts | 6 ++++++ shared/router-v2/linking.test.ts | 10 ++++++++++ 5 files changed, 26 insertions(+) diff --git a/shared/chat/conversation/thread-context.test.tsx b/shared/chat/conversation/thread-context.test.tsx index 298c10643853..27f079411140 100644 --- a/shared/chat/conversation/thread-context.test.tsx +++ b/shared/chat/conversation/thread-context.test.tsx @@ -246,6 +246,7 @@ const separatePlainThreadWrapper = ({children}: {children: React.ReactNode}) => ) beforeEach(() => { + useConfigState.setState({loggedIn: true}) useCurrentUserState.getState().dispatch.setBootstrap({ deviceID: 'device-id', deviceName: 'test-device', diff --git a/shared/chat/conversation/thread-load-status-context.test.tsx b/shared/chat/conversation/thread-load-status-context.test.tsx index 797ac75ffebc..a0c74f73dd4e 100644 --- a/shared/chat/conversation/thread-load-status-context.test.tsx +++ b/shared/chat/conversation/thread-load-status-context.test.tsx @@ -5,6 +5,7 @@ import type * as React from 'react' import * as T from '@/constants/types' import {notifyEngineActionListeners} from '@/engine/action-listener' import {resetAllStores} from '@/util/zustand' +import {useConfigState} from '@/stores/config' import {useCurrentUserState} from '@/stores/current-user' import { ConversationThreadLoadStatusProvider, @@ -25,6 +26,7 @@ const flushPromises = async () => { beforeEach(() => { jest.spyOn(T.RPCChat, 'localRequestInboxUnboxRpcPromise').mockResolvedValue(undefined) + useConfigState.setState({loggedIn: true}) useCurrentUserState.getState().dispatch.setBootstrap({ deviceID: 'device-id', deviceName: 'test-device', diff --git a/shared/chat/inbox/engine.test.tsx b/shared/chat/inbox/engine.test.tsx index 60ed618248fc..28879d27fc71 100644 --- a/shared/chat/inbox/engine.test.tsx +++ b/shared/chat/inbox/engine.test.tsx @@ -4,6 +4,7 @@ import {resetAllStores} from '@/util/zustand' import {handleConvoEngineIncoming} from './engine' import {getInboxConversationMeta, getInboxConversationParticipants} from './metadata' import {useConfigState} from '@/stores/config' +import {useCurrentUserState} from '@/stores/current-user' import {updateInboxTyping} from '@/chat/inbox/typing-state' jest.mock('@/chat/inbox/badge-state', () => ({ @@ -260,6 +261,12 @@ test('global message activity routing preserves returned global data', () => { test('read message activity without attached inbox item refreshes service-owned metadata', () => { useConfigState.setState({loggedIn: true}) + useCurrentUserState.getState().dispatch.setBootstrap({ + deviceID: 'device-id', + deviceName: 'test-device', + uid: 'uid', + username: 'alice', + }) const unbox = jest.spyOn(T.RPCChat, 'localRequestInboxUnboxRpcPromise').mockResolvedValue(undefined) expect( diff --git a/shared/router-v2/intent-consumption.test.ts b/shared/router-v2/intent-consumption.test.ts index 4f0cde98e3e1..858f3bf7c162 100644 --- a/shared/router-v2/intent-consumption.test.ts +++ b/shared/router-v2/intent-consumption.test.ts @@ -135,6 +135,12 @@ test('an intent that is still within its lifetime is consumed after the block cl emitDeepLink('keybase://convid/fresh-conversation') + // Starting the switch reset every store; the switched-to account logs back in and readies its router + setCurrentUser('current-uid') + useConfigState.getState().dispatch.setLoggedIn(true) + useNavigationIntentsState.getState().dispatch.setNavigationReady(true, 'current-uid') + expect(listener).not.toHaveBeenCalled() + now.mockReturnValue(1_000 + 5 * 60_000 - 1) useConfigState.getState().dispatch.setUserSwitching(false) diff --git a/shared/router-v2/linking.test.ts b/shared/router-v2/linking.test.ts index d587c1ec0a55..11529065121d 100644 --- a/shared/router-v2/linking.test.ts +++ b/shared/router-v2/linking.test.ts @@ -90,6 +90,14 @@ test('waits until the intended account is active', () => { unsubscribe() }) +// Starting a switch resets every store, logging the old account out of them; the switched-to +// account's bootstrap then logs it back in and its router readies before the switch ends. +const landSwitchOn = (uid: string) => { + setCurrentUser(uid) + useConfigState.getState().dispatch.setLoggedIn(true) + useNavigationIntentsState.getState().dispatch.setNavigationReady(true, uid) +} + test('waits for an account switch to finish', () => { useNavigationIntentsState.getState().dispatch.setNavigationReady(true, 'current-uid') useConfigState.getState().dispatch.setUserSwitching(true, 'testuser') @@ -97,6 +105,7 @@ test('waits for an account switch to finish', () => { const unsubscribe = subscribeNavigationIntents(listener, jest.fn()) enqueuePushTapRoute({id: tapID(), targetUid: 'current-uid', url: 'keybase://convid/account-switch-conversation'}) + landSwitchOn('current-uid') expect(listener).not.toHaveBeenCalled() useConfigState.getState().dispatch.setUserSwitching(false) @@ -114,6 +123,7 @@ test('waits for the replacement router after the current account changes', () => enqueuePushTapRoute({id: tapID(), targetUid: 'target-uid', url: 'keybase://convid/replacement-router-conversation'}) setCurrentUser('target-uid') + useConfigState.getState().dispatch.setLoggedIn(true) // The bootstrap UID can change before React commits the keyed router remount. // Even if switching is cleared early, the old account's ready router must not From 2c5bf850de575b4abc24f647dc7d37adda52f850 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 25 Sep 2026 16:06:57 -0400 Subject: [PATCH 28/28] fix(init): apply only the switch target's session while a switch runs A read of the old account that was in flight when a switch began replied after the switch-start reset and logged that account back in mid-switch. Mid-switch, any status other than the target's is now ignored; onUserSwitchingChanged applies the latest one when the switch ends. Drops #29647's guarded setDefaultUsername, which compared against the old account and was overridden below anyway. --- shared/constants/init/shared.test.ts | 30 +++++++++++++++++++++++----- shared/constants/init/shared.tsx | 15 +++++++------- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/shared/constants/init/shared.test.ts b/shared/constants/init/shared.test.ts index 29f34da0d3a4..0b5f1d7a6c62 100644 --- a/shared/constants/init/shared.test.ts +++ b/shared/constants/init/shared.test.ts @@ -254,22 +254,42 @@ describe('the session comes from the daemon; notifications only say to read it', expect(useCurrentUserState.getState().uid).toBe('u1') }) - test('during an account switch a logged-out reply is ignored, and the new user still replaces the old', async () => { + test('starting a switch logs the old account out of our stores', async () => { await readReplying(userA) markAccountState() - // Starting the switch resets every store, which logs the old account out of them - useConfigState.getState().dispatch.setUserSwitching(true, 'testuser') + useConfigState.getState().dispatch.setUserSwitching(true, 'testuser2') + expect(useConfigState.getState().loggedIn).toBe(false) - const {changes, unsub} = loginChanges() + expect(accountStateCleared()).toBe(true) + }) + + test("once the switch's target is logged in, a logged-out reply mid-switch is ignored", async () => { + await readReplying(userA) + useConfigState.getState().dispatch.setUserSwitching(true, 'testuser2') + await readReplying(userB) + expect(useConfigState.getState().loggedIn).toBe(true) await readReplying(loggedOut) + + expect(useConfigState.getState().loggedIn).toBe(true) + expect(useCurrentUserState.getState().username).toBe('testuser2') expect(useConfigState.getState().userSwitching).toBe(true) + }) + + // A read of the old account in flight when the switch began replies after the reset. + test("mid-switch, the old account's reply is ignored, and the target's still applies", async () => { + await readReplying(userA) + useConfigState.getState().dispatch.setUserSwitching(true, 'testuser2') + const {changes, unsub} = loginChanges() + + await readReplying(userA) + expect(useConfigState.getState().loggedIn).toBe(false) + expect(useCurrentUserState.getState().uid).toBe('') await readReplying(userB) unsub() expect(changes).toEqual([true]) - expect(accountStateCleared()).toBe(true) expect(useCurrentUserState.getState().username).toBe('testuser2') expect(useConfigState.getState().userSwitching).toBe(true) }) diff --git a/shared/constants/init/shared.tsx b/shared/constants/init/shared.tsx index 380061007325..31ded8d2e135 100644 --- a/shared/constants/init/shared.tsx +++ b/shared/constants/init/shared.tsx @@ -259,22 +259,23 @@ const onBootstrapStatusChanged = ( const { dispatch: configDispatch, - defaultUsername: intendedUsername, userSwitching, + userSwitchingTo, } = useConfigState.getState(); - if (username && (!userSwitching || username === intendedUsername)) { - configDispatch.setDefaultUsername(username); - } - if (!loggedIn && userSwitching) { + // Mid-switch, only the target's session applies: a logged-out status, or one for the account + // being left (a read in flight when the switch began). onUserSwitchingChanged applies the + // latest status once the switch ends. + if (userSwitching && (!loggedIn || username !== userSwitchingTo)) { logger.info( - "[Bootstrap] ignoring loggedIn=false result during account switch", + "[Bootstrap] ignoring a status other than the switch target's during account switch", ); return; } // Logged in as someone else than the user we hold is a logout and then a login, however the // notifications in between reached us. Logging out clears the previous account's stores, the - // daemon's status among them, so put this status back and let that change apply it. + // daemon's status among them, so put this status back and let that change apply it. Read the + // held uid before anything below writes the current user. const currentUid = useCurrentUserState.getState().uid; if ( loggedIn &&