Skip to content
Open
85 changes: 85 additions & 0 deletions shared/constants/navigate-append-once-root-has.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/// <reference types="jest" />
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<unknown>) => {
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<string, unknown>
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()
})
27 changes: 27 additions & 0 deletions shared/constants/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Expand Down
27 changes: 27 additions & 0 deletions shared/patches/react-native-screens+4.28.0.patch
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion shared/router-v2/account-switch-header-avatar.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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, '')
}

Expand Down
31 changes: 31 additions & 0 deletions shared/router-v2/account-switch.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import {
clearPendingAccountSwitch,
consumePendingAccountSwitchTab,
getMostRecentlyUsedAccount,
peekPendingAccountSwitchTab,
rememberAccountSwitchTab,
showLoggedInScreens,
} from './account-switch'

const account = (username: string, hasStoredSecret = true) => ({
Expand Down Expand Up @@ -44,6 +46,14 @@ describe('pending account-switch tab', () => {
expect(consumePendingAccountSwitchTab('bob')).toBeUndefined()
})

test('peeks the remembered tab for the target account without consuming it', () => {
rememberAccountSwitchTab('alice', 'bob', Tabs.fsTab)

expect(peekPendingAccountSwitchTab('alice')).toBeUndefined()
expect(peekPendingAccountSwitchTab('bob')).toBe(Tabs.fsTab)
expect(consumePendingAccountSwitchTab('bob')).toBe(Tabs.fsTab)
})

test('does not consume the tab before the account changes', () => {
rememberAccountSwitchTab('alice', 'bob', Tabs.fsTab)

Expand Down Expand Up @@ -73,3 +83,24 @@ describe('pending account-switch tab', () => {
expect(consumePendingAccountSwitchTab('bob')).toBeUndefined()
})
})

describe('showLoggedInScreens', () => {
const state = (loggedIn: boolean, userSwitching = false, userSwitchingFromLoggedIn = false) => ({
loggedIn,
userSwitching,
userSwitchingFromLoggedIn,
})

test('follows loggedIn when no switch is running', () => {
expect(showLoggedInScreens(state(true))).toBe(true)
expect(showLoggedInScreens(state(false))).toBe(false)
})

test('holds the logged-in screens through the loggedIn flap of a switch that started logged in', () => {
expect(showLoggedInScreens(state(false, true, true))).toBe(true)
})

test('keeps the logged-out screens for a switch that started logged out', () => {
expect(showLoggedInScreens(state(false, true, false))).toBe(false)
})
})
15 changes: 15 additions & 0 deletions shared/router-v2/account-switch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,28 @@ 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
pendingAccountSwitch = undefined
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
Expand Down
69 changes: 69 additions & 0 deletions shared/router-v2/account-switcher/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/** @jest-environment jsdom */
/// <reference types="jest" />
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(<AccountSwitcher />)

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(<AccountSwitcher />)

fireEvent.click(screen.getByRole('button', {name: 'testuser-mac'}))

expect(login).not.toHaveBeenCalled()
})
9 changes: 7 additions & 2 deletions shared/router-v2/account-switcher/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,24 +19,28 @@ const AccountSwitcher = (p: {onSelected?: () => void}) => {
logoutAndTryToLogInAs: onSelectAccountLoggedOut,
logoutToLoggedOutFlow: onLoginAsAnotherUser,
setUserSwitching,
userSwitching,
} = useConfigState(
C.useShallow(s => ({
accountRows: s.configuredAccounts,
login: s.dispatch.login,
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) {
rememberAccountSwitchTab(you, username, C.Router2.getTab())
}
setUserSwitching(true)
setUserSwitching(true, username)
login(username, '')
}

Expand Down Expand Up @@ -124,6 +128,7 @@ const MobileHeader = (props: Props) => {
mode="Primary"
fullWidth={true}
waitingKey={C.waitingKeyConfigLoginAsOther}
disabled={props.waiting}
/>
</Kb.Box2>
</>
Expand Down
18 changes: 18 additions & 0 deletions shared/router-v2/linking-initial-url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {useConfigState} from '@/stores/config'
import {useCurrentUserState} from '@/stores/current-user'
import {useNavigationIntentsState} from '@/stores/navigation-intents'
import {usePushState} from '@/stores/push'
import {peekPendingAccountSwitchTab, rememberAccountSwitchTab} from './account-switch'
import {createLinkingConfig} from './linking'

const setCurrentUser = (uid: string) => {
Expand Down Expand Up @@ -53,9 +54,26 @@ beforeEach(() => {

afterEach(() => {
handleAppLink.mockReset()
rememberAccountSwitchTab('', '', undefined)
resetAllStores()
})

test('an account switch starts on the switcher tab without consuming it before onReady', async () => {
rememberAccountSwitchTab('testuser', 'testuser-mac', Tabs.teamsTab)
setCurrentUser('testuser-mac')
setStartup({conversation: 'conv-1', conversationUid: 'testuser-mac', tab: Tabs.chatTab})

await expect(getInitialURL()).resolves.toBe(`keybase://${Tabs.teamsTab}`)
expect(peekPendingAccountSwitchTab('testuser-mac')).toBe(Tabs.teamsTab)
})

test('a switcher tab remembered for another account does not preempt the saved route', async () => {
rememberAccountSwitchTab('current-uid', 'testuser-mac', Tabs.teamsTab)
setStartup({tab: Tabs.chatTab})

await expect(getInitialURL()).resolves.toBe(`keybase://${Tabs.chatTab}`)
})

test('a logged out app has no initial url', async () => {
useConfigState.getState().dispatch.setLoggedIn(false)
setStartup({tab: Tabs.chatTab})
Expand Down
Loading