Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.27.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
2 changes: 1 addition & 1 deletion shared/router-v2/account-switcher/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ const AccountSwitcher = (p: {onSelected?: () => void}) => {
if (isMobile) {
rememberAccountSwitchTab(you, username, C.Router2.getTab())
}
setUserSwitching(true)
setUserSwitching(true, username)
login(username, '')
}

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
8 changes: 8 additions & 0 deletions shared/router-v2/linking.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {usePushState} from '@/stores/push'
import type {LinkingOptions} from '@react-navigation/native'
import type {RootParamList} from './route-params'
import {Linking} from 'react-native'
import {peekPendingAccountSwitchTab} from './account-switch'
import {emitDeepLink, normalizeUrl, setInitialURLOnce} from './deep-link-emitter'
// Re-exported so existing importers ('@/router-v2/linking') keep working; the
// definitions live in the dependency-free './deep-link-emitter' leaf.
Expand Down Expand Up @@ -255,6 +256,13 @@ export const createLinkingConfig = (
const {loggedIn, startup, androidShare} = useConfigState.getState()
if (!loggedIn) return null

// An account switch remounts the navigator. Start it on the switcher's tab: switching there
// after mount slides the iOS 26 glass tab pill over from the first tab.
const accountSwitchTab = peekPendingAccountSwitchTab(useCurrentUserState.getState().username)
if (accountSwitchTab) {
return setInitialURLOnce(`keybase://${accountSwitchTab}`)
Comment on lines +261 to +263
}

const {tab: startupTab, followUser: startupFollowUser} = startup
let startupConversation = startup.conversation
if (!isValidConversationIDKey(startupConversation)) {
Expand Down
6 changes: 3 additions & 3 deletions shared/router-v2/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -604,8 +604,8 @@ if (isMobile) {
}
}

const useIsLoggedInNative = () => useConfigState(s => s.loggedIn)
const useIsLoggedOutNative = () => !useConfigState(s => s.loggedIn)
const useIsLoggedInNative = () => useConfigState(showLoggedInScreens)
const useIsLoggedOutNative = () => !useConfigState(showLoggedInScreens)

const nativeModalScreensConfig = routeMapToStaticScreens(modalRoutes, makeLayout, true, false, false)
const nativePhoneRootScreensConfig = routeMapToStaticScreens(
Expand Down
2 changes: 1 addition & 1 deletion shared/router-v2/tab-bar.desktop.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading